[dart:js_interop] Add toJSProxyOrRef
Adds a conversion function on List<JSAny?> so that users can
modify the original list and have those changes carry forward
to the array. Creates a proxy object for dart2wasm when the
list is not a JSArrayImpl. This proxy uses handler methods and
the fact that Array methods are generic to provide an Array
interface. Also fixes a small issue in the exporting so that
toJS'd function types have a valid return type.
CoreLibraryReviewExempt: Backend-specific library.
Change-Id: I00f453aa82dd19f2913820579eb2675b799288d3
Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/323446
Commit-Queue: Srujan Gaddam <srujzs@google.com>
Reviewed-by: Sigmund Cherem <sigmund@google.com>
diff --git a/pkg/_js_interop_checks/lib/src/transformations/export_creator.dart b/pkg/_js_interop_checks/lib/src/transformations/export_creator.dart
index 6ebba4e..05adf8e 100644
--- a/pkg/_js_interop_checks/lib/src/transformations/export_creator.dart
+++ b/pkg/_js_interop_checks/lib/src/transformations/export_creator.dart
@@ -304,20 +304,19 @@
var getter = getSet.getter;
var setter = getSet.setter;
if (getter != null) {
+ final resultType = _staticInteropMockValidator.typeParameterResolver
+ .resolve(getter.getterType);
block.add(setProperty(
VariableGet(getSetMap),
'get',
StaticInvocation(
_functionToJS,
Arguments([
- FunctionExpression(FunctionNode(ReturnStatement(InstanceGet(
- InstanceAccessKind.Instance,
- VariableGet(dartInstance),
- getter.name,
- interfaceTarget: getter,
- resultType: _staticInteropMockValidator
- .typeParameterResolver
- .resolve(getter.getterType)))))
+ FunctionExpression(FunctionNode(
+ ReturnStatement(InstanceGet(InstanceAccessKind.Instance,
+ VariableGet(dartInstance), getter.name,
+ interfaceTarget: getter, resultType: resultType)),
+ returnType: resultType))
]))));
}
if (setter != null) {
@@ -340,7 +339,8 @@
setter.name,
VariableGet(setterParameter),
interfaceTarget: setter)),
- positionalParameters: [setterParameter]))
+ positionalParameters: [setterParameter],
+ returnType: const VoidType()))
]))));
}
// Call `Object.defineProperty` to define the export name with the
diff --git a/sdk/lib/_internal/js_shared/lib/js_interop_patch.dart b/sdk/lib/_internal/js_shared/lib/js_interop_patch.dart
index a2afcad..b5f6883 100644
--- a/sdk/lib/_internal/js_shared/lib/js_interop_patch.dart
+++ b/sdk/lib/_internal/js_shared/lib/js_interop_patch.dart
@@ -298,6 +298,18 @@
@patch
@pragma('dart2js:prefer-inline')
JSArray get toJS => this as JSArray;
+
+ // TODO(srujzs): Should we do a check to make sure this List is a JSArray
+ // under the hood and then potentially proxy? This applies for user lists. For
+ // now, don't do the check to avoid the cost of the check in the general case,
+ // and user lists will likely crash. Note that on dart2js, we do an
+ // `Array.isArray` check instead of `instanceof Array` when we cast to a
+ // `List`, which is what `JSArray` is. This won't work for proxy objects as
+ // they're not actually Arrays, so the cast will fail unless we change that
+ // check.
+ @patch
+ @pragma('dart2js:prefer-inline')
+ JSArray get toJSProxyOrRef => this as JSArray;
}
/// [JSNumber] -> [double] or [int].
diff --git a/sdk/lib/_internal/wasm/lib/js_interop_patch.dart b/sdk/lib/_internal/wasm/lib/js_interop_patch.dart
index 044ee37..be9cf75 100644
--- a/sdk/lib/_internal/wasm/lib/js_interop_patch.dart
+++ b/sdk/lib/_internal/wasm/lib/js_interop_patch.dart
@@ -9,6 +9,7 @@
import 'dart:_wasm';
import 'dart:async' show Completer;
import 'dart:js_interop';
+import 'dart:js_interop_unsafe' as unsafe;
import 'dart:js_util' as js_util;
import 'dart:typed_data';
@@ -330,13 +331,18 @@
@patch
extension ListToJSArray on List<JSAny?> {
- @patch
- JSArray get toJS {
+ JSArray? get _underlyingArray {
final t = this;
return t is js_types.JSArrayImpl
? JSValue.boxT<JSArray>(t.toExternRef)
- : toJSArray(this);
+ : null;
}
+
+ @patch
+ JSArray get toJS => _underlyingArray ?? toJSArray(this);
+
+ @patch
+ JSArray get toJSProxyOrRef => _underlyingArray ?? _createJSProxyOfList(this);
}
/// [JSNumber] -> [double] or [int].
@@ -392,3 +398,153 @@
t is js_types.JSStringImpl ? t.toExternRef : jsStringFromDartString(t));
}
}
+
+@JS('Array')
+@staticInterop
+class _Array {
+ external static JSObject get prototype;
+}
+
+@JS('Symbol')
+@staticInterop
+class _Symbol {
+ external static JSSymbol get isConcatSpreadable;
+}
+
+// Used only so we can use `createStaticInteropMock`'s prototype-setting.
+@JS()
+@staticInterop
+class __ListBackedJSArray {}
+
+/// Implementation of indexing, `length`, and core handler methods.
+///
+/// JavaScript's `Array` methods are similar to Dart's `ListMixin`, because they
+/// only rely on the implementation of `length` and indexing methods (and
+/// support for any JS operators like `in` or `delete`).
+/// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array#generic_array_methods
+class _ListBackedJSArray {
+ final List<JSAny?> _list;
+ // The proxy that wraps this list.
+ late final JSArray proxy;
+
+ _ListBackedJSArray(this._list);
+
+ @JSExport()
+ int get length => _list.length;
+
+ // TODO(srujzs): Resizing the list populates the list with `null`. Should we
+ // instead populate it with `undefined` as JS does?
+ @JSExport()
+ void set length(int val) => _list.length = val;
+
+ // []
+ @JSExport()
+ JSAny? _getIndex(int index) => _list[index];
+
+ // []=
+ @JSExport()
+ void _setIndex(int index, JSAny? value) {
+ // Need to resize the array if out of bounds.
+ if (index >= length) length = index + 1;
+ _list[index] = value;
+ }
+
+ // in
+ @JSExport()
+ bool _hasIndex(int index) => index >= 0 && index < length;
+
+ // delete
+ @JSExport()
+ bool _deleteIndex(int index) {
+ if (_hasIndex(index)) {
+ _list.removeAt(index);
+ return true;
+ }
+ return false;
+ }
+}
+
+JSArray _createJSProxyOfList(List<JSAny?> list) {
+ final wrapper = _ListBackedJSArray(list);
+ final jsExportWrapper =
+ js_util.createStaticInteropMock<__ListBackedJSArray, _ListBackedJSArray>(
+ wrapper, _Array.prototype) as JSObject;
+
+ // Needed for `concat` to spread the contents of the current array instead of
+ // prepending.
+ // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol/isConcatSpreadable
+ unsafe.JSObjectUtilExtension(jsExportWrapper)[_Symbol.isConcatSpreadable] =
+ true.toJS;
+
+ final getIndex =
+ (unsafe.JSObjectUtilExtension(jsExportWrapper)['_getIndex'.toJS]
+ as JSFunction)
+ .toExternRef;
+ final setIndex =
+ (unsafe.JSObjectUtilExtension(jsExportWrapper)['_setIndex'.toJS]
+ as JSFunction)
+ .toExternRef;
+ final hasIndex =
+ (unsafe.JSObjectUtilExtension(jsExportWrapper)['_hasIndex'.toJS]
+ as JSFunction)
+ .toExternRef;
+ final deleteIndex =
+ (unsafe.JSObjectUtilExtension(jsExportWrapper)['_deleteIndex'.toJS]
+ as JSFunction)
+ .toExternRef;
+
+ final proxy = _box<JSArray>(js_helper.JS<WasmExternRef?>('''
+ (wrapper, getIndex, setIndex, hasIndex, deleteIndex) => new Proxy(wrapper, {
+ 'get': function (target, prop, receiver) {
+ if (typeof prop == 'string') {
+ const numProp = Number(prop);
+ if (Number.isInteger(numProp)) {
+ const args = new Array();
+ args.push(numProp);
+ return Reflect.apply(getIndex, wrapper, args);
+ }
+ }
+ return Reflect.get(target, prop, receiver);
+ },
+ 'set': function (target, prop, value, receiver) {
+ if (typeof prop == 'string') {
+ const numProp = Number(prop);
+ if (Number.isInteger(numProp)) {
+ const args = new Array();
+ args.push(numProp, value);
+ Reflect.apply(setIndex, wrapper, args);
+ return true;
+ }
+ }
+ // Note that handler set is required to return a bool (whether it
+ // succeeded or not), so `[]=` won't return the value set.
+ return Reflect.set(target, prop, value, receiver);
+ },
+ 'has': function (target, prop) {
+ if (typeof prop == 'string') {
+ const numProp = Number(prop);
+ if (Number.isInteger(numProp)) {
+ const args = new Array();
+ args.push(numProp);
+ // Array-like objects are assumed to have indices as properties.
+ return Reflect.apply(hasIndex, wrapper, args);
+ }
+ }
+ return Reflect.has(target, prop);
+ },
+ 'deleteProperty': function (target, prop) {
+ if (typeof prop == 'string') {
+ const numProp = Number(prop);
+ if (Number.isInteger(numProp)) {
+ const args = new Array();
+ args.push(numProp);
+ return Reflect.apply(deleteIndex, wrapper, args);
+ }
+ }
+ return Reflect.deleteProperty(target, prop);
+ }
+ })''', jsExportWrapper.toExternRef, getIndex, setIndex, hasIndex,
+ deleteIndex));
+ wrapper.proxy = proxy;
+ return proxy;
+}
diff --git a/sdk/lib/js_interop/js_interop.dart b/sdk/lib/js_interop/js_interop.dart
index 9413d88..1d959ab 100644
--- a/sdk/lib/js_interop/js_interop.dart
+++ b/sdk/lib/js_interop/js_interop.dart
@@ -316,11 +316,27 @@
/// [JSArray] <-> [List]
extension JSArrayToList on JSArray {
+ /// Returns a list wrapper of the JS array.
external List<JSAny?> get toDart;
}
extension ListToJSArray on List<JSAny?> {
+ /// Compiler-specific conversion from list to JS array.
+ ///
+ /// This is either a pass-by-reference, unwrap, or copy depending on the
+ /// implementation of the given list, and users shouldn't rely on
+ /// modifications to the list to affect the array or vice versa.
external JSArray get toJS;
+
+ /// Either passes by reference, unwraps, or creates a heavyweight proxy that
+ /// wraps the list.
+ ///
+ /// Only use this member if you want modifications to the list to also affect
+ /// the JS array and vice versa. In practice, dart2js and DDC will pass lists
+ /// by reference and dart2wasm will add a proxy or unwrap for most lists.
+ ///
+ /// **WARNING**: Do not rely on this to be performant.
+ external JSArray get toJSProxyOrRef;
}
/// [JSNumber] -> [double] or [int].
diff --git a/tests/lib/js/static_interop_test/js_array_impl_test.dart b/tests/lib/js/static_interop_test/js_array_impl_test.dart
index 0d2ac27..73f2ce3 100644
--- a/tests/lib/js/static_interop_test/js_array_impl_test.dart
+++ b/tests/lib/js/static_interop_test/js_array_impl_test.dart
@@ -35,7 +35,15 @@
(mode == TestMode.jsArgument ||
mode == TestMode.jsReceiverAndArguments));
-List<JSAny?> jsList(List<JSAny?> l) => l.toJS.toDart;
+late bool testProxiedArray;
+
+// We test two types of round-trips from Dart to JS to Dart:
+// - A copy that `toJS` creates that then gets wrapped by JSArrayImpl
+// - A proxy that `toJSProxyOrRef` creates that then gets wrapped by JSArrayImpl
+List<JSAny?> jsList(List<JSAny?> l) {
+ final arr = testProxiedArray ? l.toJS : l.toJSProxyOrRef;
+ return arr.toDart;
+}
String jsString(String s) => s.toJS.toDart;
@@ -427,7 +435,7 @@
Expect.isTrue(list.contains(4.toJS));
}
-void main() {
+void runAllTests() {
for (final mode in [
TestMode.jsReceiver,
TestMode.jsArgument,
@@ -438,6 +446,13 @@
nonModedTests();
}
+void main() {
+ testProxiedArray = false;
+ runAllTests();
+ testProxiedArray = true;
+ runAllTests();
+}
+
// Will swap the first and last items in a list, while leaving the the middle
// elements in place because they will be swapped and then swapped back as
// shuffle iterates through the list.
diff --git a/tests/lib/js/static_interop_test/js_array_proxy_or_ref_test.dart b/tests/lib/js/static_interop_test/js_array_proxy_or_ref_test.dart
new file mode 100644
index 0000000..31ac275
--- /dev/null
+++ b/tests/lib/js/static_interop_test/js_array_proxy_or_ref_test.dart
@@ -0,0 +1,161 @@
+// Copyright (c) 2023, 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.
+
+// SharedOptions=--enable-experiment=inline-class
+
+// Test the JS array proxy or reference that's created for lists for interop.
+
+import 'dart:js_interop';
+import 'package:expect/minitest.dart';
+
+@JS()
+external void eval(String code);
+
+@JS()
+external int iterateAndValidateArray(JSArray array);
+
+@JS()
+external Counter get counter;
+
+extension type Counter(JSObject _) implements JSObject {
+ external int get numElements;
+ external JSFunction get forEachBound;
+}
+
+extension on JSArray {
+ external int get length;
+ external set length(int val);
+ external int operator [](int i);
+ external void operator []=(int i, int value);
+ external int at(int i);
+ external JSArray concat([JSAny? arg1, JSAny? arg2, JSAny? arg3, JSAny? arg4]);
+ external void forEach(JSFunction callback, [JSObject thisObj]);
+ external int indexOf(JSAny? element, [int? fromIndex]);
+ external bool includes(JSAny? element, [int? fromIndex]);
+ external String join([String? delimiter]);
+ external int pop();
+ external int push([JSAny? arg1, JSAny? arg2, JSAny? arg3]);
+ external JSArray slice([int? start, int? end]);
+ external JSArray splice(
+ [int? start,
+ int? deleteCount,
+ JSAny? arg1,
+ JSAny? arg2,
+ JSAny? arg3,
+ JSAny? arg4]);
+}
+
+void main() {
+ final numList = <int?>[4, 3, 2, 1, 0].map((e) => e?.toJS).toList();
+ final numProxy = numList.toJSProxyOrRef;
+ final roundTripProxy = numProxy.toDart.toJSProxyOrRef;
+ expect(numProxy, roundTripProxy);
+
+ // `length`/`[]`/`at`/`[]=`
+ numProxy.length = 6;
+ expect(numList.length, 6);
+ expect(numProxy.length, numList.length);
+ numList.length = 5;
+ expect(numProxy.length, numList.length);
+ for (var i = 0; i < numProxy.length; i++) {
+ expect(numProxy[i], numProxy.length - i - 1);
+ expect(numProxy.at(i), numProxy.length - i - 1);
+
+ numProxy[i] = i;
+
+ expect(numProxy[i], i);
+ expect(numProxy.at(i), i);
+ // Test negative indexing.
+ expect(numProxy.at(i - numProxy.length), i);
+ expect(numList[i]!.toDartInt, i);
+ }
+
+ // `includes`/`indexOf`
+ expect(numProxy.includes(0.toJS), true);
+ expect(numProxy.includes(0.toJS, 1), false);
+ expect(numProxy.includes(5.toJS), false);
+ expect(numProxy.indexOf(0.toJS), 0);
+ expect(numProxy.indexOf(0.toJS, 1), -1);
+ expect(numProxy.indexOf(5.toJS), -1);
+
+ // `pop`/`push`
+ expect(numProxy.push(5.toJS), numList.length);
+ expect(numProxy.length, numList.length);
+ expect(numList.length, 6);
+ expect(numList[5]!.toDartInt, 5);
+
+ expect(numProxy.pop(), 5);
+ expect(numProxy.length, numList.length);
+ expect(numList.length, 5);
+
+ expect(numProxy.push(5.toJS, 6.toJS, 7.toJS), 8);
+ expect(numProxy.push(8.toJS, null, null), 11);
+ expect(numList.length, 11);
+ numList.length = 9; // Remove the `null`s we just added.
+
+ // iteration/for loop
+ eval('''
+ globalThis.iterateAndValidateArray = function(array) {
+ var counter = 0;
+ for (const i of array) {
+ if (counter != i) break;
+ counter++;
+ }
+ return counter;
+ };
+ ''');
+ expect(iterateAndValidateArray(numProxy), numList.length);
+
+ // `forEach`
+ var numElements = 0;
+ numProxy.forEach((int element, int index, JSArray array) {
+ expect(numElements, element);
+ expect(numElements, index);
+ expect(numProxy, array);
+ numElements++;
+ }.toJS);
+ expect(numElements, numList.length);
+
+ // `forEach` with a bound this.
+ eval('''
+ const counter = {
+ numElements: 0,
+ forEachBound: function (element, index, array) {
+ this.numElements++;
+ },
+ };
+ globalThis.counter = counter;
+ ''');
+ numProxy.forEach(counter.forEachBound, counter);
+ expect(counter.numElements, numList.length);
+
+ // `slice`, `splice`, and `concat`
+ void testEquals(JSArray arr, List<int> list) {
+ final len = list.length;
+ for (var i = 0; i < len; i++) {
+ expect(arr[i], list[i]);
+ }
+ }
+
+ final sliced = numProxy.slice(4, 9);
+ testEquals(sliced, [4, 5, 6, 7, 8]);
+ expect(numList.length, 9);
+ final deleted = numProxy.splice(4, 5);
+ testEquals(deleted, [4, 5, 6, 7, 8]);
+ expect(numList.length, 4);
+
+ final deleted2 = numProxy.splice(0, 4, 3.toJS, 2.toJS, 1.toJS, 0.toJS);
+ testEquals(deleted2, [0, 1, 2, 3]);
+ testEquals(numProxy, [3, 2, 1, 0]);
+ testEquals(numProxy.concat(deleted2), [3, 2, 1, 0, 0, 1, 2, 3]);
+
+ // `join`
+ expect(numProxy.join('-'), '3-2-1-0');
+ expect(numProxy.join(), '3,2,1,0');
+
+ // TODO(srujzs): Test the remaining JS Array methods. While we cover the
+ // common ones and the ones needed for JSArrayImpl, we might be missing some
+ // subtleties (like `isConcatSpreadble` for example) or missing handler
+ // methods that are needed other Array methods.
+}
diff --git a/tests/lib/lib.status b/tests/lib/lib.status
index f660402..b65d85a 100644
--- a/tests/lib/lib.status
+++ b/tests/lib/lib.status
@@ -81,6 +81,7 @@
js/static_interop_test/inline_class/external_member_test: SkipByDesign # Issue 42085. CSP policy disallows injected JS code
js/static_interop_test/inline_class/external_static_member_test: SkipByDesign # Issue 42085. CSP policy disallows injected JS code
js/static_interop_test/inline_class/external_static_member_with_namespaces_test: SkipByDesign # Issue 42085. CSP policy disallows injected JS code
+js/static_interop_test/js_array_proxy_or_ref_test: SkipByDesign # Issue 42085. CSP policy disallows injected JS code
js/static_interop_test/js_array_test: SkipByDesign # Issue 42085. CSP policy disallows injected JS code
js/static_interop_test/js_function_conversions_test: SkipByDesign # Issue 42085. CSP policy disallows injected JS code
js/static_interop_test/jsobject_type_test: SkipByDesign # Issue 42085. CSP policy disallows injected JS code