diff --git a/js_interop/lib/js_interop.dart b/js_interop/lib/js_interop.dart index 2426b8e9..55d8231f 100644 --- a/js_interop/lib/js_interop.dart +++ b/js_interop/lib/js_interop.dart @@ -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'; diff --git a/js_interop/lib/src/dart/map.dart b/js_interop/lib/src/dart/map.dart new file mode 100644 index 00000000..8ec6d479 --- /dev/null +++ b/js_interop/lib/src/dart/map.dart @@ -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 on Map { + /// Converts [this] to a [JSRecord] by cloning it. + JSRecord get toJSRecord => JSRecord.ofMap(this); +} diff --git a/js_interop/lib/src/record.dart b/js_interop/lib/src/record.dart new file mode 100644 index 00000000..a71db7c4 --- /dev/null +++ b/js_interop/lib/src/record.dart @@ -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._(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> 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 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 get values => JSObjectUnsafeExtension(this).values.cast(); + + /// Creates a new Dart map with the same contents as this record. + Map 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 ofRecord(JSRecord other) => + JSRecord()..addAllRecord(other); + + /// Like [Map.of], but creates a record. + static JSRecord ofMap(Map other) => + JSRecord.fromEntries(other.entries); + + /// Like [Map.fromEntries], but creates a record. + static JSRecord fromEntries( + Iterable> entries, + ) => JSRecord()..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 fromPairs(Iterable<(String, V)> pairs) => + JSRecord()..addPairs(pairs); + + /// See [Map.addAll]. + void addAll(Map 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 other) => addPairs(other.pairs); + + /// See [Map.addEntries]. + void addEntries(Iterable> 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 map(MapEntry 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); +} diff --git a/js_interop/lib/src/unsafe/object.dart b/js_interop/lib/src/unsafe/object.dart new file mode 100644 index 00000000..efe4bd40 --- /dev/null +++ b/js_interop/lib/src/unsafe/object.dart @@ -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> _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 _getOwnPropertyNames(JSObject object); + +@JS('Object.getOwnPropertySymbols') +external JSArray _getOwnPropertySymbols(JSObject object); + +@JS('Object.hasOwn') +external bool _hasOwn(JSObject object, JSAny property); + +@JS('Object.keys') +external JSArray _keys(JSObject object); + +@JS('Reflect.ownKeys') +external JSArray _ownKeys(JSObject object); + +@JS('Reflect.set') +external bool _set(JSObject object, JSAny name, JSAny? value, JSAny? thisArg); + +@JS('Object.values') +external JSArray _values(JSObject object); + +/// Additional instance methods for the `dart:js_interop` [interop.JSObject] +/// 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 => [ + 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 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 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 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 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 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(JSAny name, JSAny? thisArg) => + _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); +} diff --git a/js_interop/lib/unsafe.dart b/js_interop/lib/unsafe.dart new file mode 100644 index 00000000..4a637762 --- /dev/null +++ b/js_interop/lib/unsafe.dart @@ -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'; diff --git a/js_interop/test/record_test.dart b/js_interop/test/record_test.dart new file mode 100644 index 00000000..8feafc8a --- /dev/null +++ b/js_interop/test/record_test.dart @@ -0,0 +1,257 @@ +// 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 'package:test/test.dart'; + +import 'package:js_interop/js_interop.dart'; + +void main() { + late JSRecord record; + setUp(() => record = {"foo": 1.toJS, "bar": 2.toJS}.toJSRecord); + + test( + "pairs", + () => expect(record.pairs, equals([("foo", 1.toJS), ("bar", 2.toJS)])), + ); + + test("entries", () { + var entries = record.entries.toList(); + expect(entries.length, equals(2)); + expect(entries[0].key, equals("foo")); + expect(entries[0].value, equals(1.toJS)); + expect(entries[1].key, equals("bar")); + expect(entries[1].value, equals(2.toJS)); + }); + + test("isEmpty", () { + expect(record.isEmpty, isFalse); + expect(JSRecord().isEmpty, isTrue); + }); + + test("isNotEmpty", () { + expect(record.isNotEmpty, isTrue); + expect(JSRecord().isNotEmpty, isFalse); + }); + + test("keys", () => expect(record.keys, equals(["foo", "bar"]))); + + test("length", () => expect(record.length, equals(2))); + + test("values", () => expect(record.values, equals([1.toJS, 2.toJS]))); + + test( + "toDart", + () => expect(record.toDart, equals({"foo": 1.toJS, "bar": 2.toJS})), + ); + + test("JSRecord()", () => expect(JSRecord().toDart, equals({}))); + + test( + "JSRecord.ofRecord()", + () => expect( + JSRecord.ofRecord(record).toDart, + equals({"foo": 1.toJS, "bar": 2.toJS}), + ), + ); + + test( + "JSRecord.ofMap()", + () => expect( + JSRecord.ofMap({"foo": 1.toJS, "bar": 2.toJS}).toDart, + equals({"foo": 1.toJS, "bar": 2.toJS}), + ), + ); + + test( + "JSRecord.fromEntries()", + () => expect( + JSRecord.fromEntries([ + MapEntry("foo", 1.toJS), + MapEntry("bar", 2.toJS), + ]).toDart, + equals({"foo": 1.toJS, "bar": 2.toJS}), + ), + ); + + test( + "JSRecord.fromPairs()", + () => expect( + JSRecord.fromPairs([("foo", 1.toJS), ("bar", 2.toJS)]).toDart, + equals({"foo": 1.toJS, "bar": 2.toJS}), + ), + ); + + test("addAll()", () { + record.addAll({"bar": 3.toJS, "baz": 4.toJS}); + expect( + record.toDart, + equals({"foo": 1.toJS, "bar": 3.toJS, "baz": 4.toJS}), + ); + }); + + test("addAllRecord()", () { + record.addAllRecord({"bar": 3.toJS, "baz": 4.toJS}.toJSRecord); + expect( + record.toDart, + equals({"foo": 1.toJS, "bar": 3.toJS, "baz": 4.toJS}), + ); + }); + + test("addEntries()", () { + record.addEntries([MapEntry("bar", 3.toJS), MapEntry("baz", 4.toJS)]); + expect( + record.toDart, + equals({"foo": 1.toJS, "bar": 3.toJS, "baz": 4.toJS}), + ); + }); + + test("addPairs()", () { + record.addPairs([("bar", 3.toJS), ("baz", 4.toJS)]); + expect( + record.toDart, + equals({"foo": 1.toJS, "bar": 3.toJS, "baz": 4.toJS}), + ); + }); + + test("clear()", () { + record.clear(); + expect(record.toDart, equals({})); + }); + + test("containsKey()", () { + expect(record.containsKey("foo"), isTrue); + expect(record.containsKey("baz"), isFalse); + }); + + test("forEach()", () { + var args = <(String, JSNumber)>[]; + record.forEach( + expectAsync2((key, value) { + args.add((key, value)); + }, count: 2), + ); + expect(args, equals([("foo", 1.toJS), ("bar", 2.toJS)])); + }); + + test( + "map()", + () => expect( + record.map((key, value) => MapEntry("_$key", (value.toDartInt + 1).toJS)), + equals({"_foo": 2.toJS, "_bar": 3.toJS}), + ), + ); + + group("putIfAbsent()", () { + test("present key", () { + expect( + record.putIfAbsent("foo", expectAsync0(() => 0.toJS, count: 0)), + equals(1.toJS), + ); + expect(record.toDart, equals({"foo": 1.toJS, "bar": 2.toJS})); + }); + + test("absent key", () { + expect( + record.putIfAbsent("baz", expectAsync0(() => 0.toJS)), + equals(0.toJS), + ); + expect( + record.toDart, + equals({"foo": 1.toJS, "bar": 2.toJS, "baz": 0.toJS}), + ); + }); + }); + + group("remove()", () { + test("present key", () { + expect(record.remove("foo"), equals(1.toJS)); + expect(record.toDart, equals({"bar": 2.toJS})); + }); + + test("absent key", () { + expect(record.remove("baz"), isNull); + expect(record.toDart, equals({"foo": 1.toJS, "bar": 2.toJS})); + }); + }); + + test("removeWhere()", () { + record.removeWhere((key, _) => key.startsWith("f")); + expect(record.toDart, equals({"bar": 2.toJS})); + }); + + group("update()", () { + group("no ifAbsent()", () { + test("present key", () { + expect( + record.update( + "foo", + expectAsync1((value) => (value.toDartInt + 1).toJS), + ), + equals(2.toJS), + ); + expect(record.toDart, equals({"foo": 2.toJS, "bar": 2.toJS})); + }); + + test( + "absent key", + () => expect( + () => record.update("baz", expectAsync1((_) => 0.toJS, count: 0)), + throwsArgumentError, + ), + ); + }); + + group("with ifAbsent()", () { + test("present key", () { + expect( + record.update( + "foo", + expectAsync1((value) => (value.toDartInt + 1).toJS), + ifAbsent: expectAsync0(() => 0.toJS, count: 0), + ), + equals(2.toJS), + ); + expect(record.toDart, equals({"foo": 2.toJS, "bar": 2.toJS})); + }); + + test("absent key", () { + expect( + record.update( + "baz", + expectAsync1((_) => 0.toJS, count: 0), + ifAbsent: expectAsync0(() => 0.toJS), + ), + equals(0.toJS), + ); + expect( + record.toDart, + equals({"foo": 1.toJS, "bar": 2.toJS, "baz": 0.toJS}), + ); + }); + }); + }); + + test("updateAll()", () { + record.updateAll( + expectAsync2((_, value) => (value.toDartInt + 1).toJS, count: 2), + ); + expect(record.toDart, equals({"foo": 2.toJS, "bar": 3.toJS})); + }); + + test("[]", () { + expect(record["foo"], equals(1.toJS)); + expect(record["baz"], isNull); + }); + + test("[]=", () { + record["foo"] = 3.toJS; + record["baz"] = 4.toJS; + expect( + record.toDart, + equals({"foo": 3.toJS, "bar": 2.toJS, "baz": 4.toJS}), + ); + }); +} diff --git a/js_interop/test/unsafe/object_test.dart b/js_interop/test/unsafe/object_test.dart new file mode 100644 index 00000000..72275e5e --- /dev/null +++ b/js_interop/test/unsafe/object_test.dart @@ -0,0 +1,68 @@ +// 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 'dart:js_interop_unsafe'; + +import 'package:test/test.dart'; + +import 'package:js_interop/unsafe.dart'; + +void main() { + late JSObject object; + setUp(() => object = {"foo": 1, "bar": 2}.jsify() as JSObject); + + test( + "entries", + () => expect(object.entries, equals([("foo", 1.toJS), ("bar", 2.toJS)])), + ); + + test( + "ownKeys", + () => expect(object.ownKeys, equals(["foo".toJS, "bar".toJS])), + ); + + test( + "ownPropertyNames", + () => expect(object.ownPropertyNames, equals(["foo".toJS, "bar".toJS])), + ); + + test("keys", () => expect(object.keys, equals(["foo".toJS, "bar".toJS]))); + + test("values", () => expect(object.values, equals([1.toJS, 2.toJS]))); + + test("assign", () { + object.assign({"bar": 3, "baz": 4}.jsify() as JSObject); + expect(object.dartify(), equals({"foo": 1, "bar": 3, "baz": 4})); + }); + + test("freeze", () { + object.freeze(); + + try { + object.setProperty("foo".toJS, 3.toJS); + } catch (e) { + // This throws a JavaScriptError on WASM but does not throw in JS. Either + // way, it shouldn't modify the object. + } + + expect(object.dartify(), equals({"foo": 1, "bar": 2})); + }); + + test( + "getPropertyWithThis", + () => + expect(object.getPropertyWithThis("foo".toJS, object), equals(1.toJS)), + ); + + test( + "isPrototypeOf", + () => expect(object.isPrototypeOf(JSObject()), isFalse), + ); + + test( + "propertyIsEnumerable", + () => expect(object.propertyIsEnumerable("foo".toJS), isTrue), + ); +}