Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions js_interop/lib/js_interop.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,6 @@
// BSD-style license that can be found in the LICENSE file.

export 'src/dart/date_time.dart';
export 'src/dart/map.dart';
export 'src/date.dart';
export 'src/record.dart';
13 changes: 13 additions & 0 deletions js_interop/lib/src/dart/map.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.p

import 'dart:js_interop';

import '../record.dart';

/// Conversion from [Map] to [JSRecord].
extension MapToJSRecord<V extends JSAny?> on Map<String, V> {
/// Converts [this] to a [JSRecord] by cloning it.
JSRecord<V> get toJSRecord => JSRecord.ofMap<V>(this);
}
192 changes: 192 additions & 0 deletions js_interop/lib/src/record.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

import 'dart:js_interop';
import 'dart:js_interop_unsafe';

import 'unsafe/object.dart';

/// A JavaScript "record type", or in other words an object that's used as a
/// lightweight map.
///
/// This provides a map-like API and utilities for interacting with records, as
/// well as a [toDart] method for converting it into a true map. It considers
/// the object's keys to be its enumerable, own, string properties (following
/// `Object.keys()`).
///
/// In most cases, JS records only accept string keys, and this type is
/// optimized to make this case easy to work with by automatically wrapping and
/// unwrapping [JSString]s. However, there are cases where [JSSymbol]s are used
/// as keys, in which case [JSSymbolicRecord] may be used instead.
///
/// Because this is a JavaScript object it follows JavaScript ordering
/// semantics. Specifically: all number-like keys come first in numeric order,
/// then all string keys in insertion order.
///
/// **Note:** Like Dart collections, it's not guaranteed to be safe to modify
/// this while iterating over it. Unlike Dart collections, it doesn't have any
/// fail-safes to throw errors if this happens. So be extra careful!
extension type JSRecord<V extends JSAny?>._(JSObject _) implements JSObject {
/// Returns an iterable over tuples of the `key`/`value` pairs in this record.
Iterable<(String, V)> get pairs =>
JSObjectUnsafeExtension(this).entries.cast<(String, V)>();

/// See [Map.entries].
Iterable<MapEntry<String, V>> get entries sync* {
for (var (key, value) in pairs) {
yield MapEntry(key, value);
}
}

/// See [Map.isEmpty].
bool get isEmpty => length == 0;

/// See [Map.isNotEmpty].
bool get isNotEmpty => length != 0;

/// See [Map.keys].
Iterable<String> get keys sync* {
for (var key in JSObjectUnsafeExtension(this).keys) {
yield key.toDart;
}
}

/// See [Map.length].
int get length => JSObjectUnsafeExtension(this).keys.length;

/// See [Map.values].
Iterable<V> get values => JSObjectUnsafeExtension(this).values.cast<V>();

/// Creates a new Dart map with the same contents as this record.
Map<String, V> get toDart => {for (var (key, value) in pairs) key: value};

/// Creates a new, empty record.
factory JSRecord() => JSRecord._(JSObject());

/// Creates a [JSRecord] with the same keys and values as [other].
static JSRecord<V> ofRecord<V extends JSAny?>(JSRecord<V> other) =>
JSRecord<V>()..addAllRecord(other);

/// Like [Map.of], but creates a record.
static JSRecord<V> ofMap<V extends JSAny?>(Map<String, V> other) =>
JSRecord.fromEntries<V>(other.entries);

/// Like [Map.fromEntries], but creates a record.
static JSRecord<V> fromEntries<V extends JSAny?>(
Iterable<MapEntry<String, V>> entries,
) => JSRecord<V>()..addEntries(entries);

/// Creates a new record and adds all the [pairs].
///
/// If multiple pairs have the same key, later occurrences overwrite the value
/// of the earlier ones.
static JSRecord<V> fromPairs<V extends JSAny?>(Iterable<(String, V)> pairs) =>
JSRecord<V>()..addPairs(pairs);

/// See [Map.addAll].
void addAll(Map<String, V> other) => addEntries(other.entries);

/// Adds all enumerable, own, string key/value pairs of [other] to this
/// record.
///
/// If a key of [other] is already in this record, its value is overwritten.
///
/// The operation is equivalent to doing `this[key] = value` for each key and
/// associated value in [other]. It iterates over [other], which must therefore
/// not change during the iteration.
void addAllRecord(JSRecord<V> other) => addPairs(other.pairs);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tiny nit: maybe addAllFromRecord instead?


/// See [Map.addEntries].
void addEntries(Iterable<MapEntry<String, V>> entries) {
for (var MapEntry(key: key, value: value) in entries) {
this[key] = value;
}
}

/// Adds all key/value pairs of [newPairs] to this record.
///
/// If a key of [newPairs] is already in this record, the corresponding value
/// is overwritten.
///
/// The operation is equivalent to doing `this[entry.key] = entry.value` for
/// each pair of the iterable.
void addPairs(Iterable<(String, V)> newPairs) {
for (var (key, value) in newPairs) {
this[key] = value;
}
}

/// See [Map.clear].
void clear() {
for (var key in JSObjectUnsafeExtension(this).keys) {
delete(key);
}
}

/// See [Map.containsKey].
bool containsKey(Object? key) =>
key is String && propertyIsEnumerable(key.toJS);

/// See [Map.containsValue].
bool containsValue(Object? value) => values.any((actual) => actual == value);

/// See [Map.forEach].
void forEach(void action(String key, V value)) {
for (var (key, value) in pairs) {
action(key, value);
}
}

/// See [Map.map].
Map<K2, V2> map<K2, V2>(MapEntry<K2, V2> convert(String key, V value)) =>
Map.fromEntries(pairs.map((pair) => convert(pair.$1, pair.$2)));

/// See [Map.putIfAbsent].
V putIfAbsent(String key, V ifAbsent()) {
if (containsKey(key)) return this[key]!;
var result = ifAbsent();
this[key] = result;
return result;
}

/// See [Map.remove].
V? remove(Object? key) {
if (!containsKey(key)) return null;
var value = this[key];
delete((key as String).toJS);
return value;
}

/// See [Map.removeWhere].
void removeWhere(bool test(String key, V value)) {
for (var (key, value) in pairs) {
if (test(key, value)) delete(key.toJS);
}
}

/// See [Map.update].
V update(String key, V update(V value), {V ifAbsent()?}) {
if (containsKey(key)) {
return this[key] = update(this[key]!);
} else if (ifAbsent == null) {
throw new ArgumentError("ifAbsent must be passed if the key is absent.");
} else {
return this[key] = ifAbsent();
}
}

/// See [Map.updateAll].
void updateAll(V update(String key, V value)) {
for (var (key, value) in pairs) {
this[key] = update(key, value);
}
}

/// See [Map.operator[]].
V? operator [](Object? key) =>
key is String ? getProperty(key.toJS) as V? : null;

/// See [Map.operator[]=].
void operator []=(String key, V value) => setProperty(key.toJS, value);
}
134 changes: 134 additions & 0 deletions js_interop/lib/src/unsafe/object.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

import 'dart:js_interop';

@JS('Object.assign')
external void _assign(
JSObject target, [
JSAny? source1,
JSAny? source2,
JSAny? source3,
JSAny? source4,
]);

@JS('Object.entries')
external JSArray<JSArray<JSAny?>> _entries(JSObject object);

@JS('Object.freeze')
external void _freeze(JSObject object);

@JS('Reflect.get')
external JSAny? _get(JSObject object, JSAny name, JSAny? thisArg);

@JS('Object.getOwnPropertyNames')
external JSArray<JSString> _getOwnPropertyNames(JSObject object);

@JS('Object.getOwnPropertySymbols')
external JSArray<JSSymbol> _getOwnPropertySymbols(JSObject object);

@JS('Object.hasOwn')
external bool _hasOwn(JSObject object, JSAny property);

@JS('Object.keys')
external JSArray<JSString> _keys(JSObject object);

@JS('Reflect.ownKeys')
external JSArray<JSAny> _ownKeys(JSObject object);

@JS('Reflect.set')
external bool _set(JSObject object, JSAny name, JSAny? value, JSAny? thisArg);

@JS('Object.values')
external JSArray<JSAny?> _values(JSObject object);

/// Additional instance methods for the `dart:js_interop` [interop.JSObject]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: remove interop.

/// type meant to be used when the names of properties or methods are not known
/// statically.
extension JSObjectUnsafeExtension on JSObject {
/// See [`Object.entries()`].
///
/// [`Object.entries()`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/entries
List<(String, JSAny?)> get entries => [
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thoughts on making this a generic method instead so that the second tuple member can be a generic (and therefore avoid the need for a cast list)?

for (var entry in _entries(this).toDart)
((entry[0] as JSString).toDart, entry[1]),
];

/// See [`Reflect.ownKeys()`].
///
/// The return value contains only [JSString]s and [JSSymbol]s.
///
/// [`Reflect.ownKeys()`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/ownKeys
List<JSAny> get ownKeys => _ownKeys(this).toDart;

/// See [`Object.getOwnPropertyNames()`].
///
/// [`Object.getOwnPropertyNames()`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/getOwnPropertyNames
List<JSString> get ownPropertyNames => _getOwnPropertyNames(this).toDart;

/// See [`Object.getOwnPropertySymbols()`].
///
/// [`Object.getOwnPropertySymbols()`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/getOwnPropertySymbols
List<JSSymbol> get ownPropertySymbols => _getOwnPropertySymbols(this).toDart;

/// See [`Object.keys()`].
///
/// [`Object.keys()`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/keys
List<JSString> get keys => _keys(this).toDart;

/// See [`Object.values()`].
///
/// [`Object.values()`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/values
List<JSAny?> get values => _values(this).toDart;

/// See [`Object.assign()`].
///
/// [`Object.assign()`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/assign
void assign([
JSObject? source1,
JSObject? source2,
JSObject? source3,
JSObject? source4,
]) => _assign(this, source1, source2, source3, source4);

/// See [`Object.freeze()`].
///
/// [`Object.freeze()`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/freeze
void freeze() => _freeze(this);

/// See [`Reflect.get()`].
///
/// The [name] must be a [JSString] or a [JSSymbol].
///
/// [`Reflect.get()`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Reflect/get
R getPropertyWithThis<R extends JSAny?>(JSAny name, JSAny? thisArg) =>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like this and setPropertyWithThis are sufficiently different in name that we may want a Reflect type instead, especially if we're planning to add more Reflect members. Thoughts?

_get(this, name, thisArg) as R;

/// See [`Object.hasOwn()`].
///
/// The [name] must be a [JSString] or a [JSSymbol].
///
/// [`Object.hasOwn()`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/hasOwn
bool hasOwnProperty(JSAny name) => _hasOwn(this, name);

/// See [`Reflect.set()`].
///
/// The [name] must be a [JSString] or a [JSSymbol].
///
/// [`Reflect.set()`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Reflect/set
bool setPropertyWithThis(JSAny name, JSAny? thisArg, JSAny? value) =>
_set(this, name, value, thisArg);

/// See [`Object.isPrototypeOf()`].
///
/// [`Object.isPrototypeOf()`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/isPrototypeOf
external bool isPrototypeOf(JSObject other);

/// See [`Object.propertyIsEnumerable()`].
///
/// The [name] must be a [JSString] or a [JSSymbol].
///
/// [`Object.propertyIsEnumerable()`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/propertyIsEnumerable
external bool propertyIsEnumerable(JSAny name);
}
12 changes: 12 additions & 0 deletions js_interop/lib/unsafe.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

/// Like `dart:js_interop_unsafe`, this library contains utilities that treat JS
/// objects as arbitrary sets of properties as well as those that expose JS's
/// runtime reflection capabilities. It should be used with care as it can
/// invalidate assumptions made by the statically type-annotated JS APIs used
/// elsewhere.
library;

export 'src/unsafe/object.dart';
Loading