From ae3dbcdfb14dcd69c6b6e303be647057f1ebcafb Mon Sep 17 00:00:00 2001 From: Srujan Gaddam Date: Thu, 1 Aug 2024 01:02:47 +0000 Subject: [PATCH] [dart:js_interop] Add toJSCaptureThis Closes https://github.com/dart-lang/sdk/issues/54381 - Adds an API to capture the this value so that users can use it in the callback. - Adds specialized stubs to dart2js similar to what was done for toJS. - Adds generic stub to DDC as these stubs don't get tree-shaken away and toJSCaptureThis is less likely to be used. - Modifies dart2wasm lowerings to add this to the JS function wrapper if calling toJSCaptureThis. Change-Id: Ic0a7fd768de1dd6b491998e029ff5eb406ee7992 Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/377160 Reviewed-by: Stephen Adams Commit-Queue: Srujan Gaddam Reviewed-by: Leaf Petersen Reviewed-by: Martin Kustermann --- CHANGELOG.md | 2 + .../transformations/js_util_optimizer.dart | 83 ++++++--- .../lib/src/kernel/dart2js_target.dart | 5 +- .../lib/js/callback_specializer.dart | 41 +++-- pkg/dart2wasm/lib/js/interop_transformer.dart | 2 + pkg/dart2wasm/lib/js/util.dart | 4 + pkg/dev_compiler/lib/src/kernel/target.dart | 5 +- .../patch/js_allow_interop_patch.dart | 24 +++ .../lib/js_allow_interop_patch.dart | 124 ++++++++++++- .../js_shared/lib/js_interop_patch.dart | 6 + .../_internal/wasm/lib/js_interop_patch.dart | 6 + sdk/lib/js_interop/js_interop.dart | 18 +- .../js_function_arity_test.dart | 171 +++++++++++++++--- .../js_function_conversions_test.dart | 19 +- .../js/static_interop_test/js_types_test.dart | 15 +- 15 files changed, 456 insertions(+), 69 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5da0808782e..f49e7d0e7b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ - Added constructors for `JSArrayBuffer`, `JSDataView`, and concrete typed array types e.g. `JSInt8Array`. - Added `length` and `[]`/`[]=` operators to `JSArray`. +- Added `toJSCaptureThis` so `this` is passed in from JavaScript to the + callback as the first parameter. ## 3.5.0 diff --git a/pkg/_js_interop_checks/lib/src/transformations/js_util_optimizer.dart b/pkg/_js_interop_checks/lib/src/transformations/js_util_optimizer.dart index 4e172d764b0..7795f9a32ac 100644 --- a/pkg/_js_interop_checks/lib/src/transformations/js_util_optimizer.dart +++ b/pkg/_js_interop_checks/lib/src/transformations/js_util_optimizer.dart @@ -33,26 +33,37 @@ typedef _InvocationBuilder = Expression Function( /// Replaces js_util methods with inline calls to foreign_helper JS which /// emits the code as a JavaScript code fragment. class JsUtilOptimizer extends Transformer { + final Procedure _allowInteropTarget; + final Iterable _allowedInteropJsUtilTargets; final Procedure _callMethodTarget; final Procedure _callMethodTrustTypeTarget; final List _callMethodUncheckedTargets; final List _callMethodUncheckedTrustTypeTargets; final Procedure _callConstructorTarget; final List _callConstructorUncheckedTargets; - final CloneVisitorNotMembers _cloner = CloneVisitorWithMembers(); - final Map _externalInvocationBuilders = {}; final Procedure _functionToJSTarget; + final Procedure _functionToJSCaptureThisTarget; final List _functionToJSTargets; + final List? _functionToJSCaptureThisTargets; final Procedure _functionToJSNTarget; + final Procedure _functionToJSCaptureThisNTarget; final Procedure _getPropertyTarget; final Procedure _getPropertyTrustTypeTarget; final Procedure _globalContextTarget; final Procedure _jsExportedDartFunctionToDartTarget; final Procedure _jsFunctionToDart; + final Procedure _jsTarget; + final Procedure _listEmptyFactory; final InterfaceType _objectType; final Procedure _setPropertyTarget; final Procedure _setPropertyUncheckedTarget; + final CoreTypes _coreTypes; + final CloneVisitorNotMembers _cloner = CloneVisitorWithMembers(); + final ExtensionIndex _extensionIndex; + final Map _externalInvocationBuilders = {}; + final StatefulStaticTypeContext _staticTypeContext; + /// Dynamic members in js_util that interop allowed. static const List _allowedInteropJsUtilMembers = [ 'callConstructor', @@ -63,18 +74,9 @@ class JsUtilOptimizer extends Transformer { 'setProperty' ]; - final Procedure _allowInteropTarget; - final Iterable _allowedInteropJsUtilTargets; - final Procedure _jsTarget; - final Procedure _listEmptyFactory; - - final CoreTypes _coreTypes; - final StatefulStaticTypeContext _staticTypeContext; - - final ExtensionIndex _extensionIndex; - JsUtilOptimizer( - this._coreTypes, ClassHierarchy hierarchy, this._extensionIndex) + this._coreTypes, ClassHierarchy hierarchy, this._extensionIndex, + {required bool isDart2JS}) : _callMethodTarget = _coreTypes.index.getTopLevelProcedure('dart:js_util', 'callMethod'), _callMethodTrustTypeTarget = _coreTypes.index @@ -95,12 +97,23 @@ class JsUtilOptimizer extends Transformer { 'dart:js_util', '_callConstructorUnchecked$i')), _functionToJSTarget = _coreTypes.index.getTopLevelProcedure( 'dart:js_interop', 'FunctionToJSExportedDartFunction|get#toJS'), + _functionToJSCaptureThisTarget = _coreTypes.index.getTopLevelProcedure( + 'dart:js_interop', + 'FunctionToJSExportedDartFunction|get#toJSCaptureThis'), _functionToJSTargets = List.generate( 6, (i) => _coreTypes.index .getTopLevelProcedure('dart:js_util', '_functionToJS$i')), + _functionToJSCaptureThisTargets = isDart2JS + ? List.generate( + 5, + (i) => _coreTypes.index.getTopLevelProcedure( + 'dart:js_util', '_functionToJSCaptureThis$i')) + : null, _functionToJSNTarget = _coreTypes.index .getTopLevelProcedure('dart:js_util', '_functionToJSN'), + _functionToJSCaptureThisNTarget = _coreTypes.index + .getTopLevelProcedure('dart:js_util', '_functionToJSCaptureThisN'), _getPropertyTarget = _coreTypes.index .getTopLevelProcedure('dart:js_util', 'getProperty'), _getPropertyTrustTypeTarget = _coreTypes.index @@ -500,6 +513,8 @@ class JsUtilOptimizer extends Transformer { // https://github.com/dart-lang/sdk/issues/53367 is resolved. } else if (target == _functionToJSTarget) { invocation = _lowerFunctionToJS(node); + } else if (target == _functionToJSCaptureThisTarget) { + invocation = _lowerFunctionToJS(node, captureThis: true); } else if (target == _jsExportedDartFunctionToDartTarget) { invocation = _lowerJSExportedDartFunctionToDart(node); } else if (target.isExternal && !JsInteropChecks.isPatchedMember(target)) { @@ -684,27 +699,47 @@ class JsUtilOptimizer extends Transformer { ..parent = nodeParent; } - /// For the given `dart:js_interop` `Function.toJS` invocation [node], returns - /// an invocation of `_functionToJSX` with the given `Function` argument, - /// where X is the number of the positional arguments. + /// For the given `dart:js_interop` `Function.toJS` or + /// `Function.toJSCaptureThis` invocation [node], returns an invocation of the + /// corresponding private stub in `js_util` with the invocation's [Function] + /// argument and the number of positional parameters of that [Function]. /// - /// If the number of the positional arguments is larger than 5, returns an - /// invocation of `_functionToJSN` instead. - StaticInvocation _lowerFunctionToJS(StaticInvocation node) { + /// If [captureThis] is false, the node target is assumed to be + /// `Function.toJS` and otherwise `Function.toJSCaptureThis`. + /// + /// There are specialized stubs up to a certain positional parameter length, + /// and after that, either an invocation of `_functionToJSN` or + /// `_functionToJSCaptureThisN` is returned. + StaticInvocation _lowerFunctionToJS(StaticInvocation node, + {bool captureThis = false}) { // JS interop checks assert that the static type is available, and that // there are no named arguments or type arguments. final function = node.arguments.positional.single; final functionType = function.getStaticType(_staticTypeContext) as FunctionType; - final argumentsLength = functionType.positionalParameters.length; + final parametersLength = functionType.positionalParameters.length; + List? specializedStubs; + int stubIndex; + Procedure genericStub; + if (captureThis) { + specializedStubs = _functionToJSCaptureThisTargets; + stubIndex = parametersLength - 1; // Account for `this`. + genericStub = _functionToJSCaptureThisNTarget; + } else { + specializedStubs = _functionToJSTargets; + stubIndex = parametersLength; + genericStub = _functionToJSNTarget; + } Procedure target; Arguments arguments; - if (argumentsLength < _functionToJSTargets.length) { - target = _functionToJSTargets[argumentsLength]; + if (specializedStubs != null && + stubIndex >= 0 && + stubIndex < specializedStubs.length) { + target = specializedStubs[stubIndex]; arguments = Arguments([function]); } else { - target = _functionToJSNTarget; - arguments = Arguments([function, IntLiteral(argumentsLength)]); + target = genericStub; + arguments = Arguments([function, IntLiteral(parametersLength)]); } return StaticInvocation( target, arguments..fileOffset = node.arguments.fileOffset) diff --git a/pkg/compiler/lib/src/kernel/dart2js_target.dart b/pkg/compiler/lib/src/kernel/dart2js_target.dart index 444b9b816ab..a556c26dbb5 100644 --- a/pkg/compiler/lib/src/kernel/dart2js_target.dart +++ b/pkg/compiler/lib/src/kernel/dart2js_target.dart @@ -156,8 +156,9 @@ class Dart2jsTarget extends Target { jsInteropReporter, jsInteropChecks.exportChecker, jsInteropChecks.extensionIndex); - var jsUtilOptimizer = - JsUtilOptimizer(coreTypes, hierarchy, jsInteropChecks.extensionIndex); + var jsUtilOptimizer = JsUtilOptimizer( + coreTypes, hierarchy, jsInteropChecks.extensionIndex, + isDart2JS: true); for (var library in libraries) { // Shared transformer has static checks, so we still visit even if there // are errors. diff --git a/pkg/dart2wasm/lib/js/callback_specializer.dart b/pkg/dart2wasm/lib/js/callback_specializer.dart index 07404a1ebaa..62cbed10434 100644 --- a/pkg/dart2wasm/lib/js/callback_specializer.dart +++ b/pkg/dart2wasm/lib/js/callback_specializer.dart @@ -177,11 +177,12 @@ class CallbackSpecializer { /// Create a [Procedure] that will wrap a Dart callback in a JS wrapper. /// /// [node] is the conversion function that is called by the user (either - /// `allowInterop` or `Function.toJS`). [type] is the static type of the - /// callback. [boxExternRef] determines if the trampoline should box the - /// arguments and return value or convert every value. [needsCastClosure] - /// determines if a cast closure is needed in order to validate the types of - /// some arguments. + /// `allowInterop`, `Function.toJS`, or `Function.toJSCaptureThis`). [type] is + /// the static type of the callback. [boxExternRef] determines if the + /// trampoline should box the arguments and return value or convert every + /// value. [needsCastClosure] determines if a cast closure is needed in order + /// to validate the types of some arguments. [captureThis] determines if + /// `this` needs to be passed into the trampoline from the JS wrapper. /// /// The procedure will call a JS method that will create a wrapper, cache the /// callback, and call the trampoline function with the callback, the JS @@ -190,20 +191,30 @@ class CallbackSpecializer { /// /// Returns the created [Procedure]. Procedure _getJSWrapperFunction(Procedure node, FunctionType type, - {required bool boxExternRef, required bool needsCastClosure}) { + {required bool boxExternRef, + required bool needsCastClosure, + required bool captureThis}) { final functionTrampolineName = _createFunctionTrampoline(node, type, boxExternRef: boxExternRef); List jsParameters = []; - for (int i = 0; i < type.positionalParameters.length; i++) { + var jsParametersLength = type.positionalParameters.length; + if (captureThis) jsParametersLength--; + for (int i = 0; i < jsParametersLength; i++) { jsParameters.add('x$i'); } String jsWrapperParams = jsParameters.join(','); - String dartArguments = 'f,arguments.length'; + // We could avoid incrementing the arguments length in the case of + // `captureThis` and have the function trampoline account for the extra + // argument, but there's no benefit in doing that. + String argumentsLength = + captureThis ? 'arguments.length + 1' : 'arguments.length'; + String dartArguments = 'f,$argumentsLength'; String jsMethodParams = 'f'; if (needsCastClosure) { dartArguments = '$dartArguments,castClosure'; jsMethodParams = '($jsMethodParams,castClosure)'; } + if (captureThis) dartArguments = '$dartArguments,this'; if (jsParameters.isNotEmpty) { dartArguments = '$dartArguments,$jsWrapperParams'; } @@ -263,7 +274,7 @@ class CallbackSpecializer { final type = argument.getStaticType(_staticTypeContext) as FunctionType; final jsWrapperFunction = _getJSWrapperFunction( staticInvocation.target, type, - boxExternRef: false, needsCastClosure: false); + boxExternRef: false, needsCastClosure: false, captureThis: false); final v = VariableDeclaration('#var', initializer: argument, type: type, isSynthesized: true); return Let( @@ -332,7 +343,7 @@ class CallbackSpecializer { returnType: VoidType())); } - /// Given an invocation of `.toJS`, returns an [Expression] + /// Given an invocation of `Function.toJS`, returns an [Expression] /// representing: /// /// JSValue(jsWrapperFunction()) @@ -340,13 +351,19 @@ class CallbackSpecializer { /// or if a cast closure is needed: /// /// JSValue(jsWrapperFunction(, )) - Expression functionToJS(StaticInvocation staticInvocation) { + /// + /// If [captureThis] is true, this is assumed to be an invocation of + /// `Function.toJSCaptureThis`. + Expression functionToJS(StaticInvocation staticInvocation, + {bool captureThis = false}) { final argument = staticInvocation.arguments.positional.single; final type = argument.getStaticType(_staticTypeContext) as FunctionType; final castClosure = _createCastClosure(type); final jsWrapperFunction = _getJSWrapperFunction( staticInvocation.target, type, - boxExternRef: true, needsCastClosure: castClosure != null); + boxExternRef: true, + needsCastClosure: castClosure != null, + captureThis: captureThis); return _createJSValue(StaticInvocation( jsWrapperFunction, Arguments([ diff --git a/pkg/dart2wasm/lib/js/interop_transformer.dart b/pkg/dart2wasm/lib/js/interop_transformer.dart index ff2b1d40da2..25ab9c875f3 100644 --- a/pkg/dart2wasm/lib/js/interop_transformer.dart +++ b/pkg/dart2wasm/lib/js/interop_transformer.dart @@ -79,6 +79,8 @@ class InteropTransformer extends Transformer { return _callbackSpecializer.allowInterop(node); } else if (target == _util.functionToJSTarget) { return _callbackSpecializer.functionToJS(node); + } else if (target == _util.functionToJSCaptureThisTarget) { + return _callbackSpecializer.functionToJS(node, captureThis: true); } else if (target == _util.inlineJSTarget) { return _inlineExpander.expand(node); } else { diff --git a/pkg/dart2wasm/lib/js/util.dart b/pkg/dart2wasm/lib/js/util.dart index a6a15fd3662..0f9f6ae33c6 100644 --- a/pkg/dart2wasm/lib/js/util.dart +++ b/pkg/dart2wasm/lib/js/util.dart @@ -16,6 +16,7 @@ class CoreTypesUtil { final Procedure allowInteropTarget; final Procedure dartifyRawTarget; final Procedure functionToJSTarget; + final Procedure functionToJSCaptureThisTarget; final Procedure greaterThanOrEqualToTarget; final Procedure inlineJSTarget; final Procedure isDartFunctionWrappedTarget; @@ -35,6 +36,9 @@ class CoreTypesUtil { .getTopLevelProcedure('dart:_js_helper', 'dartifyRaw'), functionToJSTarget = coreTypes.index.getTopLevelProcedure( 'dart:js_interop', 'FunctionToJSExportedDartFunction|get#toJS'), + functionToJSCaptureThisTarget = coreTypes.index.getTopLevelProcedure( + 'dart:js_interop', + 'FunctionToJSExportedDartFunction|get#toJSCaptureThis'), greaterThanOrEqualToTarget = coreTypes.index.getProcedure('dart:core', 'num', '>='), inlineJSTarget = diff --git a/pkg/dev_compiler/lib/src/kernel/target.dart b/pkg/dev_compiler/lib/src/kernel/target.dart index 471eee1f2d9..f11231af694 100644 --- a/pkg/dev_compiler/lib/src/kernel/target.dart +++ b/pkg/dev_compiler/lib/src/kernel/target.dart @@ -209,8 +209,9 @@ class DevCompilerTarget extends Target { jsInteropReporter, jsInteropChecks.exportChecker, jsInteropChecks.extensionIndex); - final jsUtilOptimizer = - JsUtilOptimizer(coreTypes, hierarchy, jsInteropChecks.extensionIndex); + final jsUtilOptimizer = JsUtilOptimizer( + coreTypes, hierarchy, jsInteropChecks.extensionIndex, + isDart2JS: false); for (var node in nodes) { _CovarianceTransformer(node).transform(); // Shared interop transformer has static checks, so we still visit. diff --git a/sdk/lib/_internal/js_dev_runtime/patch/js_allow_interop_patch.dart b/sdk/lib/_internal/js_dev_runtime/patch/js_allow_interop_patch.dart index 87e396448b4..ad60d7d4188 100644 --- a/sdk/lib/_internal/js_dev_runtime/patch/js_allow_interop_patch.dart +++ b/sdk/lib/_internal/js_dev_runtime/patch/js_allow_interop_patch.dart @@ -178,6 +178,30 @@ JavaScriptFunction _functionToJSN(Function f, int maxLength) { return ret; } +// TODO(srujzs): We could add specific arity stubs for this like we do with +// `Function.toJS`, but unlike dart2js, unused ones aren't tree-shaken away. +// Considering this isn't a very commonly used API, that seems wasteful. +JavaScriptFunction _functionToJSCaptureThisN(Function f, int maxLength) { + if (!dart.isDartFunction(f)) { + throw ArgumentError('Attempting to rewrap a JS function.'); + } + final ret = JS( + '!', + ''' + function (...arguments) { + let args = [this]; + args.push.apply(args, arguments); + return #(#, Array.prototype.slice.call(args, 0, + Math.min(args.length, #))); + } + ''', + dart.dcall, + f, + maxLength); + JS('', '#[#] = #', ret, _functionToJSProperty, f); + return ret; +} + _callDartFunctionFast0(callback) => JS('', '#()', callback); _callDartFunctionFast1(callback, arg1, int length) { diff --git a/sdk/lib/_internal/js_runtime/lib/js_allow_interop_patch.dart b/sdk/lib/_internal/js_runtime/lib/js_allow_interop_patch.dart index 0fcf4ab6224..c5d77eb99d9 100644 --- a/sdk/lib/_internal/js_runtime/lib/js_allow_interop_patch.dart +++ b/sdk/lib/_internal/js_runtime/lib/js_allow_interop_patch.dart @@ -103,6 +103,25 @@ JavaScriptFunction _functionToJS0(Function f) { return result; } +JavaScriptFunction _functionToJSCaptureThis0(Function f) { + if (isJSFunction(f)) { + throw ArgumentError('Attempting to rewrap a JS function.'); + } + final result = JS( + 'JavaScriptFunction', + ''' + function(_call, f) { + return function() { + return _call(f, this, 1); + } + }(#, #) + ''', + DART_CLOSURE_TO_JS(_callDartFunctionFast1), + f); + JS('', '#.# = #', result, DART_CLOSURE_PROPERTY_NAME, f); + return result; +} + JavaScriptFunction _functionToJS1(Function f) { if (isJSFunction(f)) { throw ArgumentError('Attempting to rewrap a JS function.'); @@ -122,6 +141,25 @@ JavaScriptFunction _functionToJS1(Function f) { return result; } +JavaScriptFunction _functionToJSCaptureThis1(Function f) { + if (isJSFunction(f)) { + throw ArgumentError('Attempting to rewrap a JS function.'); + } + final result = JS( + 'JavaScriptFunction', + ''' + function(_call, f) { + return function(arg1) { + return _call(f, this, arg1, arguments.length + 1); + } + }(#, #) + ''', + DART_CLOSURE_TO_JS(_callDartFunctionFast2), + f); + JS('', '#.# = #', result, DART_CLOSURE_PROPERTY_NAME, f); + return result; +} + JavaScriptFunction _functionToJS2(Function f) { if (isJSFunction(f)) { throw ArgumentError('Attempting to rewrap a JS function.'); @@ -141,6 +179,25 @@ JavaScriptFunction _functionToJS2(Function f) { return result; } +JavaScriptFunction _functionToJSCaptureThis2(Function f) { + if (isJSFunction(f)) { + throw ArgumentError('Attempting to rewrap a JS function.'); + } + final result = JS( + 'JavaScriptFunction', + ''' + function(_call, f) { + return function(arg1, arg2) { + return _call(f, this, arg1, arg2, arguments.length + 1); + } + }(#, #) + ''', + DART_CLOSURE_TO_JS(_callDartFunctionFast3), + f); + JS('', '#.# = #', result, DART_CLOSURE_PROPERTY_NAME, f); + return result; +} + JavaScriptFunction _functionToJS3(Function f) { if (isJSFunction(f)) { throw ArgumentError('Attempting to rewrap a JS function.'); @@ -160,6 +217,25 @@ JavaScriptFunction _functionToJS3(Function f) { return result; } +JavaScriptFunction _functionToJSCaptureThis3(Function f) { + if (isJSFunction(f)) { + throw ArgumentError('Attempting to rewrap a JS function.'); + } + final result = JS( + 'JavaScriptFunction', + ''' + function(_call, f) { + return function(arg1, arg2, arg3) { + return _call(f, this, arg1, arg2, arg3, arguments.length + 1); + } + }(#, #) + ''', + DART_CLOSURE_TO_JS(_callDartFunctionFast4), + f); + JS('', '#.# = #', result, DART_CLOSURE_PROPERTY_NAME, f); + return result; +} + JavaScriptFunction _functionToJS4(Function f) { if (isJSFunction(f)) { throw ArgumentError('Attempting to rewrap a JS function.'); @@ -179,6 +255,25 @@ JavaScriptFunction _functionToJS4(Function f) { return result; } +JavaScriptFunction _functionToJSCaptureThis4(Function f) { + if (isJSFunction(f)) { + throw ArgumentError('Attempting to rewrap a JS function.'); + } + final result = JS( + 'JavaScriptFunction', + ''' + function(_call, f) { + return function(arg1, arg2, arg3, arg4) { + return _call(f, this, arg1, arg2, arg3, arg4, arguments.length + 1); + } + }(#, #) + ''', + DART_CLOSURE_TO_JS(_callDartFunctionFast5), + f); + JS('', '#.# = #', result, DART_CLOSURE_PROPERTY_NAME, f); + return result; +} + JavaScriptFunction _functionToJS5(Function f) { if (isJSFunction(f)) { throw ArgumentError('Attempting to rewrap a JS function.'); @@ -206,7 +301,7 @@ JavaScriptFunction _functionToJSN(Function f, int maxLength) { 'JavaScriptFunction', ''' function(_call, f, maxLength) { - return function () { + return function() { return _call(f, Array.prototype.slice.call(arguments, 0, Math.min(arguments.length, maxLength))); } @@ -219,6 +314,33 @@ JavaScriptFunction _functionToJSN(Function f, int maxLength) { return result; } +// TODO(srujzs): It would be nice if we can generate these and the +// `Function.toJS` stubs dynamically as needed in the interop transformer. It +// would reduce the potential of accidentally introducing bugs in specific +// stubs. +JavaScriptFunction _functionToJSCaptureThisN(Function f, int maxLength) { + if (isJSFunction(f)) { + throw ArgumentError('Attempting to rewrap a JS function.'); + } + final result = JS( + 'JavaScriptFunction', + ''' + function(_call, f, maxLength) { + return function() { + var args = [this]; + args.push.apply(args, arguments); + return _call(f, Array.prototype.slice.call(args, 0, + Math.min(args.length, maxLength))); + } + }(#, #, #) + ''', + DART_CLOSURE_TO_JS(_callDartFunctionFastN), + f, + maxLength); + JS('', '#.# = #', result, DART_CLOSURE_PROPERTY_NAME, f); + return result; +} + _callDartFunctionFast0(Function callback) => callback(); _callDartFunctionFast1(Function callback, arg1, int length) { 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 0a66d9cf1f7..46a92acb217 100644 --- a/sdk/lib/_internal/js_shared/lib/js_interop_patch.dart +++ b/sdk/lib/_internal/js_shared/lib/js_interop_patch.dart @@ -77,6 +77,12 @@ extension FunctionToJSExportedDartFunction on Function { JSExportedDartFunction get toJS => throw UnimplementedError( "'toJS' should never directly be called. Calls to 'toJS' should have " 'been transformed by the interop transformer.'); + + @patch + JSExportedDartFunction get toJSCaptureThis => throw UnimplementedError( + "'toJSCaptureThis' should never directly be called. Calls to " + "'toJSCaptureThis' should have been transformed by the interop " + 'transformer.'); } // Embedded global property for wrapped Dart objects passed via JS interop. diff --git a/sdk/lib/_internal/wasm/lib/js_interop_patch.dart b/sdk/lib/_internal/wasm/lib/js_interop_patch.dart index a5f04e259ed..a8bb27addbe 100644 --- a/sdk/lib/_internal/wasm/lib/js_interop_patch.dart +++ b/sdk/lib/_internal/wasm/lib/js_interop_patch.dart @@ -92,6 +92,12 @@ extension FunctionToJSExportedDartFunction on Function { JSExportedDartFunction get toJS => throw UnimplementedError( "This should never be called. Calls to 'toJS' should have been " 'transformed by the interop transformer.'); + + @patch + JSExportedDartFunction get toJSCaptureThis => throw UnimplementedError( + "'toJSCaptureThis' should never directly be called. Calls to " + "'toJSCaptureThis' should have been transformed by the interop " + 'transformer.'); } // Embedded global property for wrapped Dart objects passed via JS interop. diff --git a/sdk/lib/js_interop/js_interop.dart b/sdk/lib/js_interop/js_interop.dart index da847ae30c0..8fe6ccc8361 100644 --- a/sdk/lib/js_interop/js_interop.dart +++ b/sdk/lib/js_interop/js_interop.dart @@ -123,8 +123,9 @@ extension type JSFunction._(JSFunctionRepType _jsFunction) /// A JavaScript callable function created from a Dart function. /// -/// See [FunctionToJSExportedDartFunction.toJS] for more details on how to -/// convert a Dart function. +/// See [FunctionToJSExportedDartFunction.toJS] or +/// [FunctionToJSExportedDartFunction.toJSCaptureThis] for more details on how +/// to convert a Dart function. @JS('Function') extension type JSExportedDartFunction._( JSExportedDartFunctionRepType _jsExportedDartFunction) @@ -591,7 +592,8 @@ extension JSExportedDartFunctionToFunction on JSExportedDartFunction { /// The Dart [Function] that this [JSExportedDartFunction] wrapped. /// /// Must be a function that was wrapped with - /// [FunctionToJSExportedDartFunction.toJS]. + /// [FunctionToJSExportedDartFunction.toJS] or + /// [FunctionToJSExportedDartFunction.toJSCaptureThis]. external Function get toDart; } @@ -614,6 +616,16 @@ extension FunctionToJSExportedDartFunction on Function { /// Calling this on the same [Function] again will always result in a new /// JavaScript function. external JSExportedDartFunction get toJS; + + /// A callable JavaScript function that wraps this [Function] and captures the + /// `this` value when called. + /// + /// Identical to [toJS], except the resulting [JSExportedDartFunction] will + /// pass `this` from JavaScript as the first argument to the converted + /// [Function]. Any [Function] that is converted with this member should take + /// in an extra parameter at the beginning of the parameter list to handle + /// this. + external JSExportedDartFunction get toJSCaptureThis; } /// Conversions from [JSBoxedDartObject] to [Object]. diff --git a/tests/lib/js/static_interop_test/js_function_arity_test.dart b/tests/lib/js/static_interop_test/js_function_arity_test.dart index 653438a925a..caf62436805 100644 --- a/tests/lib/js/static_interop_test/js_function_arity_test.dart +++ b/tests/lib/js/static_interop_test/js_function_arity_test.dart @@ -7,9 +7,11 @@ import 'dart:js_interop'; import 'package:expect/expect.dart'; +import 'package:expect/variations.dart'; const isDDC = const bool.fromEnvironment('dart.library._ddc_only'); const isDart2JS = const bool.fromEnvironment('dart.tool.dart2js'); +const soundNullSafety = !unsoundNullSafety; @JS('call') external String _call(JSFunction f, JSArray args); @@ -22,10 +24,12 @@ external void eval(String code); // Zero. String zeroArgs() => '0'; +String zeroArgsThis([JSObject? this_]) => '0'; // One. String oneRequired(String arg1) => arg1; String oneOptional([String arg1 = 'default']) => '$arg1'; +String oneOptionalThis(JSObject? this_, [String arg1 = 'default']) => '$arg1'; // Two. String twoRequired(String arg1, String? arg2) => '$arg1$arg2'; @@ -33,6 +37,9 @@ String oneRequiredOneOptional(String arg1, [String? arg2 = 'default']) => '$arg1$arg2'; String twoOptional([String arg1 = 'default', String? arg2 = 'default']) => '$arg1$arg2'; +String oneRequiredOneOptionalThis(JSObject? this_, String arg1, + [String? arg2 = 'default']) => + '$arg1$arg2'; // Three. String threeRequired(String arg1, String? arg2, String arg3) => @@ -48,6 +55,12 @@ String threeOptional( String? arg2 = 'default', String arg3 = 'default']) => '$arg1$arg2$arg3'; +String threeOptionalThis( + [JSObject? this_, + String arg1 = 'default', + String? arg2 = 'default', + String arg3 = 'default']) => + '$arg1$arg2$arg3'; // Four. String fourRequired(String arg1, String? arg2, String arg3, String arg4) => @@ -69,6 +82,11 @@ String fourOptional( String arg3 = 'default', String arg4 = 'default']) => '$arg1$arg2$arg3$arg4'; +String oneRequiredThreeOptionalThis(JSObject? this_, String arg1, + [String? arg2 = 'default', + String arg3 = 'default', + String arg4 = 'default']) => + '$arg1$arg2$arg3$arg4'; // Five. String fiveRequired( @@ -99,6 +117,10 @@ String fiveOptional( String arg4 = 'default', String arg5 = 'default']) => '$arg1$arg2$arg3$arg4$arg5'; +String threeRequiredTwoOptionalThis( + JSObject? this_, String arg1, String? arg2, String arg3, + [String arg4 = 'default', String arg5 = 'default']) => + '$arg1$arg2$arg3$arg4$arg5'; // Six. String sixRequired(String arg1, String? arg2, String arg3, String arg4, @@ -138,29 +160,45 @@ String sixOptional( String arg5 = 'default', String arg6 = 'default']) => '$arg1$arg2$arg3$arg4$arg5$arg6'; +String sixOptionalThis( + [JSObject? this_, + String arg1 = 'default', + String? arg2 = 'default', + String arg3 = 'default', + String arg4 = 'default', + String arg5 = 'default', + String arg6 = 'default']) => + '$arg1$arg2$arg3$arg4$arg5$arg6'; void testZero() { // Arity tests. Expect.equals(call(zeroArgs.toJS, []), '0'); Expect.equals(call(zeroArgs.toJS, ['extra']), '0'); Expect.equals(call(zeroArgs.toJS, [1.0]), '0'); + Expect.equals(call(zeroArgsThis.toJSCaptureThis, []), '0'); + Expect.equals(call(zeroArgs.toJSCaptureThis, []), '0'); // Conversion round-trip test. final tearOff = zeroArgs; - Expect.equals(tearOff, tearOff.toJS.toDart); + Expect.identical(tearOff, tearOff.toJS.toDart); + final tearOffThis = zeroArgsThis; + Expect.identical(tearOffThis, tearOffThis.toJSCaptureThis.toDart); // Avoid rewrapping test. if (isDDC || isDart2JS) { - Expect.throws(() => (zeroArgs.toJS as String Function()).toJS); + Expect.throwsArgumentError(() => (zeroArgs.toJS as String Function()).toJS); + Expect.throwsArgumentError(() => + (zeroArgsThis.toJSCaptureThis as String Function()).toJSCaptureThis); } } void testOne() { // Type tests. Expect.throws(() => call(oneRequired.toJS, [0])); - Expect.throwsWhen(hasSoundNullSafety, () => call(oneOptional.toJS, [null])); + Expect.throwsWhen(soundNullSafety, () => call(oneOptional.toJS, [null])); Expect.throwsWhen( - hasSoundNullSafety, () => call(oneOptional.toJS, ['undefined'])); + soundNullSafety, () => call(oneOptional.toJS, ['undefined'])); + Expect.throws(() => call(oneOptionalThis.toJSCaptureThis, [true])); // Arity tests. Expect.throws(() => call(oneRequired.toJS, [])); @@ -169,20 +207,32 @@ void testOne() { Expect.equals(call(oneOptional.toJS, []), 'default'); Expect.equals(call(oneOptional.toJS, ['a']), 'a'); Expect.equals(call(oneOptional.toJS, ['a', 'extra']), 'a'); + Expect.equals(call(oneOptionalThis.toJSCaptureThis, ['a']), 'a'); + Expect.throws(() => call(oneRequired.toJSCaptureThis, [])); // Function subtyping tests. Expect.equals(call((oneOptional as String Function()).toJS, []), 'default'); // Throws away the additional args due to the static typing. Expect.equals( call((oneOptional as String Function()).toJS, ['a']), 'default'); + Expect.equals( + call((oneOptionalThis as String Function(JSObject?)).toJSCaptureThis, + ['a']), + 'default'); // Conversion round-trip test. final tearOff = oneRequired; - Expect.equals(tearOff, tearOff.toJS.toDart); + Expect.identical(tearOff, tearOff.toJS.toDart); + final tearOffThis = oneOptionalThis; + Expect.identical(tearOffThis, tearOffThis.toJSCaptureThis.toDart); // Avoid rewrapping test. if (isDDC || isDart2JS) { - Expect.throws(() => (oneOptional.toJS as String Function()).toJS); + Expect.throwsArgumentError( + () => (oneOptional.toJS as String Function()).toJS); + Expect.throwsArgumentError(() => + (oneOptionalThis.toJSCaptureThis as String Function(JSObject?)) + .toJSCaptureThis); } } @@ -190,11 +240,12 @@ void testTwo() { // Type tests. Expect.throws(() => call(twoOptional.toJS, [false, 'b'])); Expect.throws(() => call(twoOptional.toJS, ['a', 1.0])); - Expect.throwsWhen(hasSoundNullSafety, + Expect.throwsWhen(soundNullSafety, () => call(oneRequiredOneOptional.toJS, ['undefined', 'b'])); Expect.throws(() => call(oneRequiredOneOptional.toJS, ['a', true])); Expect.throws(() => call(twoRequired.toJS, [0, 'b'])); Expect.throws(() => call(twoRequired.toJS, ['a', 0])); + Expect.throws(() => call(oneRequiredOneOptional.toJSCaptureThis, [0])); // Arity tests. Expect.throws(() => call(twoRequired.toJS, [])); @@ -209,6 +260,11 @@ void testTwo() { Expect.equals(call(twoOptional.toJS, ['a']), 'adefault'); Expect.equals(call(twoOptional.toJS, ['a', 'b']), 'ab'); Expect.equals(call(twoOptional.toJS, ['a', 'b', 'extra']), 'ab'); + Expect.equals( + call(oneRequiredOneOptionalThis.toJSCaptureThis, ['a', 'b', 'extra']), + 'ab'); + Expect.equals( + call(oneRequiredOneOptionalThis.toJSCaptureThis, ['a']), 'adefault'); // Function subtyping tests. // TODO(55881): dart2wasm's type conversions are based on the static type, @@ -242,6 +298,12 @@ void testTwo() { Expect.equals( call((twoOptional as String Function([String])).toJS, ['a', false]), 'adefault'); + Expect.equals( + call( + (oneRequiredOneOptionalThis as String Function(JSObject?, String)) + .toJSCaptureThis, + ['a', 'b']), + 'adefault'); // `undefined` tests. Expect.equals(call(twoRequired.toJS, ['a', 'undefined']), 'anull'); @@ -255,12 +317,17 @@ void testTwo() { // Conversion round-trip test. final tearOff = twoRequired; - Expect.equals(tearOff, tearOff.toJS.toDart); + Expect.identical(tearOff, tearOff.toJS.toDart); + final tearOffThis = oneRequiredOneOptionalThis; + Expect.identical(tearOffThis, tearOffThis.toJSCaptureThis.toDart); // Avoid rewrapping test. if (isDDC || isDart2JS) { - Expect.throws( - () => (oneRequiredOneOptional.toJS as String Function()).toJS); + Expect.throwsArgumentError( + () => (oneRequiredOneOptional.toJS as String Function(String)).toJS); + Expect.throwsArgumentError(() => (oneRequiredOneOptionalThis.toJSCaptureThis + as String Function(JSObject?, String)) + .toJSCaptureThis); } } @@ -270,10 +337,13 @@ void testThree() { // Type tests. Expect.throws(() => call(threeRequired.toJS, [0, 'b', 'c'])); Expect.throws(() => call(oneRequiredTwoOptional.toJS, ['a', false])); + Expect.throws(() => call(threeOptionalThis.toJSCaptureThis, [true])); // Arity tests. Expect.equals(call(twoRequiredOneOptional.toJS, ['a', 'b']), 'abdefault'); Expect.throws(() => call(oneRequiredTwoOptional.toJS, [])); + Expect.equals( + call(threeOptionalThis.toJSCaptureThis, ['a', 'b']), 'abdefault'); // Function subtyping tests. var closure = () => call( @@ -287,6 +357,12 @@ void testThree() { Expect.equals( call((threeOptional as String Function([String])).toJS, ['a', 0, true]), 'adefaultdefault'); + Expect.equals( + call( + (threeOptionalThis as String Function([JSObject?, String, String?])) + .toJSCaptureThis, + ['a', 'b', false]), + 'abdefault'); // `undefined` tests. Expect.equals(call(threeOptional.toJS, ['a', 'undefined']), @@ -294,12 +370,17 @@ void testThree() { // Conversion round-trip test. final tearOff = threeRequired; - Expect.equals(tearOff, tearOff.toJS.toDart); + Expect.identical(tearOff, tearOff.toJS.toDart); + final tearOffThis = threeOptionalThis; + Expect.identical(tearOffThis, tearOffThis.toJSCaptureThis.toDart); // Avoid rewrapping test. if (isDDC || isDart2JS) { - Expect.throws( - () => (twoRequiredOneOptional.toJS as String Function()).toJS); + Expect.throwsArgumentError(() => + (twoRequiredOneOptional.toJS as String Function(String, String?)).toJS); + Expect.throwsArgumentError(() => + (threeOptionalThis.toJSCaptureThis as String Function(JSObject?)) + .toJSCaptureThis); } } @@ -308,10 +389,13 @@ void testFour() { Expect.throws( () => call(threeRequiredOneOptional.toJS, ['a', 'b', 'c', true])); Expect.throws(() => call(oneRequiredThreeOptional.toJS, [false])); + Expect.throws(() => call(oneRequiredThreeOptional.toJSCaptureThis, ['a'])); // Arity tests. Expect.equals(call(fourRequired.toJS, ['a', 'b', 'c', 'd', false]), 'abcd'); Expect.equals(call(fourOptional.toJS, ['a']), 'adefaultdefaultdefault'); + Expect.equals(call(oneRequiredThreeOptionalThis.toJSCaptureThis, ['a', 'b']), + 'abdefaultdefault'); // Function subtyping tests. final closure = () => call( @@ -329,6 +413,12 @@ void testFour() { .toJS, ['a', null]), 'anulldefaultdefault'); + Expect.equals( + call( + (oneRequiredThreeOptionalThis as String Function(JSObject?, String)) + .toJSCaptureThis, + ['a', 'b']), + 'adefaultdefaultdefault'); // `undefined` tests. Expect.equals(call(oneRequiredThreeOptional.toJS, ['a', 'undefined', 'c']), @@ -336,12 +426,17 @@ void testFour() { // Conversion round-trip test. final tearOff = fourRequired; - Expect.equals(tearOff, tearOff.toJS.toDart); + Expect.identical(tearOff, tearOff.toJS.toDart); + final tearOffThis = oneRequiredThreeOptionalThis; + Expect.identical(tearOffThis, tearOffThis.toJSCaptureThis.toDart); // Avoid rewrapping test. if (isDDC || isDart2JS) { - Expect.throws( - () => (oneRequiredThreeOptional.toJS as String Function()).toJS); + Expect.throwsArgumentError( + () => (oneRequiredThreeOptional.toJS as String Function(String)).toJS); + Expect.throwsArgumentError(() => (oneRequiredThreeOptionalThis + .toJSCaptureThis as String Function(JSObject?, String)) + .toJSCaptureThis); } } @@ -349,11 +444,16 @@ void testFive() { // Type tests. Expect.throws(() => call(twoRequiredThreeOptional.toJS, ['a', 0])); Expect.throws(() => call(fiveOptional.toJS, [false])); + Expect.throws(() => + call(threeRequiredTwoOptionalThis.toJSCaptureThis, ['a', 'b', 1.0])); // Arity tests. Expect.equals(call(fiveRequired.toJS, ['a', 'b', 'c', 'd', 'e', 0]), 'abcde'); Expect.equals(call(fourRequiredOneOptional.toJS, ['a', null, 'c', 'd']), 'anullcddefault'); + Expect.equals( + call(threeRequiredTwoOptionalThis.toJSCaptureThis, ['a', 'b', 'c', 'd']), + 'abcddefault'); // Function subtyping tests. final closure = () => call( @@ -373,6 +473,13 @@ void testFive() { .toJS, ['a', null, 'c']), 'anullcdefaultdefault'); + Expect.equals( + call( + (threeRequiredTwoOptionalThis as String Function( + JSObject?, String, String?, String)) + .toJSCaptureThis, + ['a', 'b', 'c', 'd']), + 'abcdefaultdefault'); // `undefined` tests. Expect.equals( @@ -381,12 +488,19 @@ void testFive() { // Conversion round-trip test. final tearOff = fiveRequired; - Expect.equals(tearOff, tearOff.toJS.toDart); + Expect.identical(tearOff, tearOff.toJS.toDart); + final tearOffThis = threeRequiredTwoOptionalThis; + Expect.identical(tearOffThis, tearOffThis.toJSCaptureThis.toDart); // Avoid rewrapping test. if (isDDC || isDart2JS) { - Expect.throws( - () => (twoRequiredThreeOptional.toJS as String Function()).toJS); + Expect.throwsArgumentError(() => + (twoRequiredThreeOptional.toJS as String Function(String, String?)) + .toJS); + Expect.throwsArgumentError(() => + (threeRequiredTwoOptionalThis.toJSCaptureThis as String Function( + JSObject?, String, String?, String)) + .toJSCaptureThis); } } @@ -395,6 +509,8 @@ void testSix() { // Type tests. Expect.throws(() => call(sixRequired.toJS, ['a', 'b', 0.0, 'd', 'e', 'f'])); Expect.throws(() => call(threeRequiredThreeOptional.toJS, ['undefined'])); + Expect.throwsWhen( + soundNullSafety, () => call(sixOptionalThis.toJSCaptureThis, [null])); // Arity tests. // Verify that we appropriately truncate arguments even though we don't have @@ -403,6 +519,9 @@ void testSix() { call(fourRequiredTwoOptional.toJS, ['a', 'b', 'c', 'd', 'e', 'f', 0]), 'abcdef'); Expect.throws(() => call(twoRequiredFourOptional.toJS, [])); + Expect.equals( + call(sixOptionalThis.toJSCaptureThis, ['a', 'b', 'c', 'd', 'e', 'f', 0]), + 'abcdef'); // Function subtyping tests. var closure = () => call( @@ -419,6 +538,10 @@ void testSix() { call((oneRequiredFiveOptional as String Function(String, [String?])).toJS, ['a', 'b', 0, 0.0, false]), 'abdefaultdefaultdefaultdefault'); + Expect.equals( + call((sixOptionalThis as String Function()).toJSCaptureThis, + [true, 0, 0.0]), + 'defaultdefaultdefaultdefaultdefaultdefault'); // `undefined` tests. Expect.equals(call(sixOptional.toJS, ['a', 'undefined', 'c', 'd', 'e']), @@ -426,11 +549,17 @@ void testSix() { // Conversion round-trip test. final tearOff = sixRequired; - Expect.equals(tearOff, tearOff.toJS.toDart); + Expect.identical(tearOff, tearOff.toJS.toDart); + final tearOffThis = sixOptionalThis; + Expect.identical(tearOffThis, tearOffThis.toJSCaptureThis.toDart); // Avoid rewrapping test. if (isDDC || isDart2JS) { - Expect.throws(() => (sixOptional.toJS as String Function()).toJS); + Expect.throwsArgumentError( + () => (sixOptional.toJS as String Function()).toJS); + Expect.throwsArgumentError(() => + (sixOptionalThis.toJSCaptureThis as String Function(JSObject?)) + .toJSCaptureThis); } } diff --git a/tests/lib/js/static_interop_test/js_function_conversions_test.dart b/tests/lib/js/static_interop_test/js_function_conversions_test.dart index c001c4da7e4..97197355af9 100644 --- a/tests/lib/js/static_interop_test/js_function_conversions_test.dart +++ b/tests/lib/js/static_interop_test/js_function_conversions_test.dart @@ -215,8 +215,12 @@ void main() { expect(anyF(zero), zero); void setBoundNonNullAnyMultipleParametersFunction() { - jsFunction = ((T t, U u, [V? v]) => t).toJS; + U extends JSAny, V extends JSAny>({bool captureThis = false}) { + if (captureThis) { + jsFunction = ((T this_, U u, [V? v]) => this_).toJSCaptureThis; + } else { + jsFunction = ((T t, U u, [V? v]) => t).toJS; + } } setBoundNonNullAnyMultipleParametersFunction(); @@ -225,6 +229,17 @@ void main() { anyF(zero, zero, null); anyF(zero, zero, zero); + setBoundNonNullAnyMultipleParametersFunction(captureThis: true); + Expect.throwsWhen(soundNullSafety, () => anyF(null, zero)); + anyF(zero, null); + anyF(zero, zero); + // TODO(srujzs): It'd be nice if we can test that passing a null value for + // `this` throws. However, unless we're in strict mode, that isn't possible + // on all backends. While we can define local functions in strict mode, the + // wrapping of the function when converting a Dart function to JS may not be + // in a strict mode context, and therefore `this` will still be non-nullable. + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/call#thisarg + void setBoundExternalDartReference?, U>() { jsFunction = ((T t) => t?.toDartObject.toExternalReference).toJS; } diff --git a/tests/lib/js/static_interop_test/js_types_test.dart b/tests/lib/js/static_interop_test/js_types_test.dart index 9e2eeb5b382..f596895e102 100644 --- a/tests/lib/js/static_interop_test/js_types_test.dart +++ b/tests/lib/js/static_interop_test/js_types_test.dart @@ -184,12 +184,23 @@ void syncTests() { 'foo'.toJS, 'bar'.toJS) .toDart, 'foobar'); - Expect.equals(edf.toDart, dartFunction); - Expect.isTrue(identical(edf.toDart, dartFunction)); + Expect.identical(edf.toDart, dartFunction); // Two wrappers should not be the same. Expect.notEquals(edf, dartFunction.toJS); // Converting a non-function should throw. Expect.throws(() => ('foo'.toJS as JSExportedDartFunction).toDart); + // `this` should be captured correctly in `toJSCaptureThis`. + final this_ = JSObject(); + final dartFunctionThis = (JSObject this__, JSString a, JSString b) { + Expect.equals(this_, this__); + return (a.toDart + b.toDart).toJS; + }; + edf = dartFunctionThis.toJSCaptureThis; + Expect.equals( + (edf.callAsFunction(this_, 'foo'.toJS, 'bar'.toJS) as JSString).toDart, + 'foobar'); + Expect.identical(edf.toDart, dartFunctionThis); + Expect.notEquals(edf, dartFunctionThis.toJSCaptureThis); // [JSBoxedDartObject] <-> [Object] edo = DartObject().toJSBox;