From 4dcf605ed7c55eba6b783ec895fb5771cec48949 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20=C5=BBelawski?= <40713406+tjzel@users.noreply.github.com> Date: Wed, 17 Jul 2024 13:28:35 +0200 Subject: [PATCH] Workletizable Context Objects (#6229) This pull request introduces the concept of _Context Objects_ on the Worklet Runtime. ## What? Context Object is a shareable object that doesn't lose its `this` binding when it goes to the Worklet Runtime. To define an object as a Context Object, the user has to define property `__workletObject` on it. (Should it be `__contextObject`?) ## Why? Currently it's not possible to refer to an object's methods from its other methods. ```ts const obj = { foo() { console.log("foo"); } bar() { this.foo(); } } runOnUI(() => { obj.bar(); // Crash, the binding is lost on serialization. })(); ``` ## How? Context Objects are handled as another type of shareable entity on the React Runtime. 1. The plugin adds a special property `__workletObjectFactory` to Context Objects. It's a function that creates an identical object. 2. We serialize the factory instead of the object itself. 3. We recreate the object on the Worklet Runtime via ShareableHandle mechanism. ## Test plan - [x] Add unit tests - [x] Add a runtime test suite --- .../tests/plugin/contextObjects.test.tsx | 178 ++++++++++++++++++ .../__snapshots__/plugin.test.ts.snap | 122 ++++++++++++ .../__tests__/plugin.test.ts | 66 +++++++ .../plugin/build/plugin.js | 48 +++++ .../plugin/src/contextObject.ts | 72 +++++++ .../plugin/src/globals.ts | 2 + .../plugin/src/plugin.ts | 15 +- .../react-native-reanimated/src/shareables.ts | 16 +- 8 files changed, 515 insertions(+), 4 deletions(-) create mode 100644 apps/common-app/src/examples/RuntimeTests/tests/plugin/contextObjects.test.tsx create mode 100644 packages/react-native-reanimated/plugin/src/contextObject.ts diff --git a/apps/common-app/src/examples/RuntimeTests/tests/plugin/contextObjects.test.tsx b/apps/common-app/src/examples/RuntimeTests/tests/plugin/contextObjects.test.tsx new file mode 100644 index 000000000000..be42d72bcbf7 --- /dev/null +++ b/apps/common-app/src/examples/RuntimeTests/tests/plugin/contextObjects.test.tsx @@ -0,0 +1,178 @@ +import React, { useEffect } from 'react'; +import { View } from 'react-native'; +import { useSharedValue, runOnUI } from 'react-native-reanimated'; +import { + render, + wait, + describe, + getRegisteredValue, + registerValue, + test, + expect, +} from '../../ReanimatedRuntimeTestsRunner/RuntimeTestsApi'; + +const SHARED_VALUE_REF = 'SHARED_VALUE_REF'; + +describe('Test context objects', () => { + test('methods are workletized', async () => { + const ExampleComponent = () => { + const output = useSharedValue(null); + registerValue(SHARED_VALUE_REF, output); + const contextObject = { + foo() { + return 1; + }, + __workletObject: true, + }; + + useEffect(() => { + runOnUI(() => { + output.value = contextObject.foo(); + })(); + }); + + return ; + }; + await render(); + await wait(100); + const sharedValue = await getRegisteredValue(SHARED_VALUE_REF); + expect(sharedValue.onUI).toBe(1); + }); + + test('properties are workletized', async () => { + const ExampleComponent = () => { + const output = useSharedValue(null); + registerValue(SHARED_VALUE_REF, output); + const contextObject = { + foo: () => 1, + __workletObject: true, + }; + + useEffect(() => { + runOnUI(() => { + output.value = contextObject.foo(); + })(); + }); + + return ; + }; + await render(); + await wait(100); + const sharedValue = await getRegisteredValue(SHARED_VALUE_REF); + expect(sharedValue.onUI).toBe(1); + }); + + test('methods preserve implicit context', async () => { + const ExampleComponent = () => { + const output = useSharedValue(null); + registerValue(SHARED_VALUE_REF, output); + const contextObject = { + foo() { + return 1; + }, + bar() { + return this.foo() + 1; + }, + __workletObject: true, + }; + + useEffect(() => { + runOnUI(() => { + output.value = contextObject.bar(); + })(); + }); + + return ; + }; + await render(); + await wait(100); + const sharedValue = await getRegisteredValue(SHARED_VALUE_REF); + expect(sharedValue.onUI).toBe(2); + }); + + test('methods preserve explicit context', async () => { + const ExampleComponent = () => { + const output = useSharedValue(null); + registerValue(SHARED_VALUE_REF, output); + const contextObject = { + foo() { + return 1; + }, + bar() { + return this.foo.call(contextObject) + 1; + }, + __workletObject: true, + }; + + useEffect(() => { + runOnUI(() => { + output.value = contextObject.bar(); + })(); + }); + + return ; + }; + await render(); + await wait(100); + const sharedValue = await getRegisteredValue(SHARED_VALUE_REF); + expect(sharedValue.onUI).toBe(2); + }); + + test('methods change the state of the object', async () => { + const ExampleComponent = () => { + const output = useSharedValue(null); + registerValue(SHARED_VALUE_REF, output); + const contextObject = { + foo: 1, + bar() { + this.foo += 1; + }, + __workletObject: true, + }; + + useEffect(() => { + runOnUI(() => { + contextObject.bar(); + output.value = contextObject.foo; + })(); + }); + + return ; + }; + await render(); + await wait(100); + const sharedValue = await getRegisteredValue(SHARED_VALUE_REF); + expect(sharedValue.onUI).toBe(2); + }); + + test("the object doesn't persist in memory", async () => { + const ExampleComponent = () => { + const output = useSharedValue(null); + registerValue(SHARED_VALUE_REF, output); + const contextObject = { + foo: 1, + bar() { + this.foo += 1; + }, + __workletObject: true, + }; + + useEffect(() => { + runOnUI(() => { + contextObject.bar(); + output.value = contextObject.foo; + })(); + }); + + return ; + }; + await render(); + await wait(100); + const sharedValue = await getRegisteredValue(SHARED_VALUE_REF); + expect(sharedValue.onUI).toBe(2); + await render(); + await wait(100); + const sharedValue2 = await getRegisteredValue(SHARED_VALUE_REF); + expect(sharedValue2.onUI).toBe(2); + }); +}); diff --git a/packages/react-native-reanimated/__tests__/__snapshots__/plugin.test.ts.snap b/packages/react-native-reanimated/__tests__/__snapshots__/plugin.test.ts.snap index 70193b42bd78..ddc16a51ecf0 100644 --- a/packages/react-native-reanimated/__tests__/__snapshots__/plugin.test.ts.snap +++ b/packages/react-native-reanimated/__tests__/__snapshots__/plugin.test.ts.snap @@ -708,6 +708,128 @@ var f = function () { }();" `; +exports[`babel plugin for context objects creates factories 1`] = ` +"var _worklet_14630842371699_init_data = { + code: "function __workletObjectFactory_null1(){return{bar:function(){return'bar';}};}", + location: "/dev/null", + sourceMap: "\\"mock source map\\"", + version: "x.y.z" +}; +var foo = { + bar: function bar() { + return 'bar'; + }, + __workletObjectFactory: function () { + var _e = [new global.Error(), 1, -27]; + var __workletObjectFactory_null1 = function __workletObjectFactory_null1() { + return { + bar: function bar() { + return 'bar'; + } + }; + }; + __workletObjectFactory_null1.__closure = {}; + __workletObjectFactory_null1.__workletHash = 14630842371699; + __workletObjectFactory_null1.__initData = _worklet_14630842371699_init_data; + __workletObjectFactory_null1.__stackDetails = _e; + return __workletObjectFactory_null1; + }() +};" +`; + +exports[`babel plugin for context objects preserves bindings 1`] = ` +"var _worklet_13432710970622_init_data = { + code: "function __workletObjectFactory_null1(){return{bar:function(){return'bar';},foobar:function(){return this.bar();}};}", + location: "/dev/null", + sourceMap: "\\"mock source map\\"", + version: "x.y.z" +}; +var foo = { + bar: function bar() { + return 'bar'; + }, + foobar: function foobar() { + return this.bar(); + }, + __workletObjectFactory: function () { + var _e = [new global.Error(), 1, -27]; + var __workletObjectFactory_null1 = function __workletObjectFactory_null1() { + return { + bar: function bar() { + return 'bar'; + }, + foobar: function foobar() { + return this.bar(); + } + }; + }; + __workletObjectFactory_null1.__closure = {}; + __workletObjectFactory_null1.__workletHash = 13432710970622; + __workletObjectFactory_null1.__initData = _worklet_13432710970622_init_data; + __workletObjectFactory_null1.__stackDetails = _e; + return __workletObjectFactory_null1; + }() +};" +`; + +exports[`babel plugin for context objects removes marker 1`] = ` +"var _worklet_14630842371699_init_data = { + code: "function __workletObjectFactory_null1(){return{bar:function(){return'bar';}};}", + location: "/dev/null", + sourceMap: "\\"mock source map\\"", + version: "x.y.z" +}; +var foo = { + bar: function bar() { + return 'bar'; + }, + __workletObjectFactory: function () { + var _e = [new global.Error(), 1, -27]; + var __workletObjectFactory_null1 = function __workletObjectFactory_null1() { + return { + bar: function bar() { + return 'bar'; + } + }; + }; + __workletObjectFactory_null1.__closure = {}; + __workletObjectFactory_null1.__workletHash = 14630842371699; + __workletObjectFactory_null1.__initData = _worklet_14630842371699_init_data; + __workletObjectFactory_null1.__stackDetails = _e; + return __workletObjectFactory_null1; + }() +};" +`; + +exports[`babel plugin for context objects workletizes regardless of marker value 1`] = ` +"var _worklet_14630842371699_init_data = { + code: "function __workletObjectFactory_null1(){return{bar:function(){return'bar';}};}", + location: "/dev/null", + sourceMap: "\\"mock source map\\"", + version: "x.y.z" +}; +var foo = { + bar: function bar() { + return 'bar'; + }, + __workletObjectFactory: function () { + var _e = [new global.Error(), 1, -27]; + var __workletObjectFactory_null1 = function __workletObjectFactory_null1() { + return { + bar: function bar() { + return 'bar'; + } + }; + }; + __workletObjectFactory_null1.__closure = {}; + __workletObjectFactory_null1.__workletHash = 14630842371699; + __workletObjectFactory_null1.__initData = _worklet_14630842371699_init_data; + __workletObjectFactory_null1.__stackDetails = _e; + return __workletObjectFactory_null1; + }() +};" +`; + exports[`babel plugin for debugging does inject location for worklets in dev builds 1`] = ` "var _worklet_8623346549410_init_data = { code: "function null1(){const x=1;}", diff --git a/packages/react-native-reanimated/__tests__/plugin.test.ts b/packages/react-native-reanimated/__tests__/plugin.test.ts index 756ba2c072a5..e310aa02c358 100644 --- a/packages/react-native-reanimated/__tests__/plugin.test.ts +++ b/packages/react-native-reanimated/__tests__/plugin.test.ts @@ -2282,4 +2282,70 @@ describe('babel plugin', () => { expect(code).toMatchSnapshot(); }); }); + + describe('for context objects', () => { + it('removes marker', () => { + const input = html``; + + const { code } = runPlugin(input); + expect(code).not.toMatch(/__workletObject:\s*/g); + expect(code).toMatchSnapshot(); + }); + + it('creates factories', () => { + const input = html``; + + const { code } = runPlugin(input); + expect(code).toContain('__workletObjectFactory'); + expect(code).toHaveWorkletData(); + expect(code).toMatchSnapshot(); + }); + + it('workletizes regardless of marker value', () => { + const input = html``; + + const { code } = runPlugin(input); + expect(code).toHaveWorkletData(); + expect(code).toMatchSnapshot(); + }); + + it('preserves bindings', () => { + const input = html``; + + const { code } = runPlugin(input); + expect(code).toIncludeInWorkletString('this.bar()'); + expect(code).toMatchSnapshot(); + }); + }); }); diff --git a/packages/react-native-reanimated/plugin/build/plugin.js b/packages/react-native-reanimated/plugin/build/plugin.js index fdb7513fc6ad..525e9d7e93cc 100644 --- a/packages/react-native-reanimated/plugin/build/plugin.js +++ b/packages/react-native-reanimated/plugin/build/plugin.js @@ -131,6 +131,8 @@ var require_globals = __commonJS({ "null", "this", "global", + "window", + "globalThis", "console", "performance", "queueMicrotask", @@ -1217,6 +1219,44 @@ var require_file = __commonJS({ } }); +// lib/contextObject.js +var require_contextObject = __commonJS({ + "lib/contextObject.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + exports2.processIfWorkletContextObject = void 0; + var types_12 = require("@babel/types"); + var contextObjectMarker = "__workletObject"; + function processIfWorkletContextObject(path, state) { + let isWorkletContextObject = false; + path.traverse({ + ObjectProperty(subPath) { + if ((0, types_12.isIdentifier)(subPath.node.key) && subPath.node.key.name === contextObjectMarker) { + isWorkletContextObject = true; + subPath.stop(); + } + } + }); + if (isWorkletContextObject) { + processWorkletContextObject(path, state); + } + return isWorkletContextObject; + } + exports2.processIfWorkletContextObject = processIfWorkletContextObject; + function processWorkletContextObject(path, _state) { + path.traverse({ + ObjectProperty(subPath) { + if ((0, types_12.isIdentifier)(subPath.node.key) && subPath.node.key.name === contextObjectMarker) { + subPath.remove(); + } + } + }); + const workletObjectFactory = (0, types_12.functionExpression)(null, [], (0, types_12.blockStatement)([(0, types_12.returnStatement)((0, types_12.cloneNode)(path.node))], [(0, types_12.directive)((0, types_12.directiveLiteral)("worklet"))])); + path.node.properties.push((0, types_12.objectProperty)((0, types_12.identifier)("__workletObjectFactory"), workletObjectFactory)); + } + } +}); + // lib/plugin.js Object.defineProperty(exports, "__esModule", { value: true }); var autoworkletization_1 = require_autoworkletization(); @@ -1227,6 +1267,7 @@ var utils_1 = require_utils(); var globals_1 = require_globals(); var webOptimization_1 = require_webOptimization(); var file_1 = require_file(); +var contextObject_1 = require_contextObject(); module.exports = function() { function runWithTaggedExceptions(fun) { try { @@ -1261,6 +1302,13 @@ module.exports = function() { }); } }, + ObjectExpression: { + enter(path, state) { + runWithTaggedExceptions(() => { + (0, contextObject_1.processIfWorkletContextObject)(path, state); + }); + } + }, Program: { enter(path, state) { runWithTaggedExceptions(() => { diff --git a/packages/react-native-reanimated/plugin/src/contextObject.ts b/packages/react-native-reanimated/plugin/src/contextObject.ts new file mode 100644 index 000000000000..2ec29a5dcb83 --- /dev/null +++ b/packages/react-native-reanimated/plugin/src/contextObject.ts @@ -0,0 +1,72 @@ +import type { NodePath } from '@babel/core'; +import { + blockStatement, + cloneNode, + directive, + directiveLiteral, + functionExpression, + identifier, + isIdentifier, + objectProperty, + returnStatement, +} from '@babel/types'; +import type { ObjectExpression } from '@babel/types'; +import type { ReanimatedPluginPass } from './types'; + +const contextObjectMarker = '__workletObject'; + +export function processIfWorkletContextObject( + path: NodePath, + state: ReanimatedPluginPass +): boolean { + let isWorkletContextObject = false; + + path.traverse({ + ObjectProperty(subPath) { + if ( + isIdentifier(subPath.node.key) && + subPath.node.key.name === contextObjectMarker + ) { + isWorkletContextObject = true; + subPath.stop(); + } + }, + }); + + if (isWorkletContextObject) { + processWorkletContextObject(path, state); + } + + return isWorkletContextObject; +} + +function processWorkletContextObject( + path: NodePath, + _state: ReanimatedPluginPass +): void { + path.traverse({ + ObjectProperty(subPath) { + if ( + isIdentifier(subPath.node.key) && + subPath.node.key.name === contextObjectMarker + ) { + // We need to remove the marker so that we won't process it again. + subPath.remove(); + } + }, + }); + + // A simple factory function that returns the context object. + const workletObjectFactory = functionExpression( + null, + [], + blockStatement( + [returnStatement(cloneNode(path.node))], + [directive(directiveLiteral('worklet'))] + ) + ); + + path.node.properties.push( + objectProperty(identifier('__workletObjectFactory'), workletObjectFactory) + ); +} diff --git a/packages/react-native-reanimated/plugin/src/globals.ts b/packages/react-native-reanimated/plugin/src/globals.ts index f097078ea592..dc790c8a812d 100644 --- a/packages/react-native-reanimated/plugin/src/globals.ts +++ b/packages/react-native-reanimated/plugin/src/globals.ts @@ -102,6 +102,8 @@ const notCapturedIdentifiers = [ 'null', 'this', 'global', + 'window', + 'globalThis', 'console', 'performance', 'queueMicrotask', diff --git a/packages/react-native-reanimated/plugin/src/plugin.ts b/packages/react-native-reanimated/plugin/src/plugin.ts index 426d08105f55..42d723cb11ed 100644 --- a/packages/react-native-reanimated/plugin/src/plugin.ts +++ b/packages/react-native-reanimated/plugin/src/plugin.ts @@ -1,5 +1,10 @@ import type { PluginItem, NodePath } from '@babel/core'; -import type { CallExpression, JSXAttribute, Program } from '@babel/types'; +import type { + CallExpression, + JSXAttribute, + ObjectExpression, + Program, +} from '@babel/types'; import { processIfAutoworkletizableCallback, processCalleesAutoworkletizableCallbacks, @@ -12,6 +17,7 @@ import { addCustomGlobals } from './utils'; import { initializeGlobals } from './globals'; import { substituteWebCallExpression } from './webOptimization'; import { processIfWorkletFile } from './file'; +import { processIfWorkletContextObject } from './contextObject'; module.exports = function (): PluginItem { function runWithTaggedExceptions(fun: () => void) { @@ -53,6 +59,13 @@ module.exports = function (): PluginItem { }); }, }, + ObjectExpression: { + enter(path: NodePath, state: ReanimatedPluginPass) { + runWithTaggedExceptions(() => { + processIfWorkletContextObject(path, state); + }); + }, + }, Program: { enter(path: NodePath, state: ReanimatedPluginPass) { runWithTaggedExceptions(() => { diff --git a/packages/react-native-reanimated/src/shareables.ts b/packages/react-native-reanimated/src/shareables.ts index 87b812e047d7..fc45985a6ab5 100644 --- a/packages/react-native-reanimated/src/shareables.ts +++ b/packages/react-native-reanimated/src/shareables.ts @@ -17,7 +17,7 @@ import { // for web/chrome debugger/jest environments this file provides a stub implementation // where no shareable references are used. Instead, the objects themselves are used // instead of shareable references, because of the fact that we don't have to deal with -// runnning the code on separate VMs. +// running the code on separate VMs. const SHOULD_BE_USE_WEB = shouldBeUseWeb(); const MAGIC_KEY = 'REANIMATED_MAGIC_KEY'; @@ -109,7 +109,7 @@ export function makeShareableCloneRecursive( } if (depth >= DETECT_CYCLIC_OBJECT_DEPTH_THRESHOLD) { // if we reach certain recursion depth we suspect that we are dealing with a cyclic object. - // this type of objects are not supported and cannot be trasferred as shareable, so we + // this type of objects are not supported and cannot be transferred as shareable, so we // implement a simple detection mechanism that remembers the value at a given depth and // tests whether we try reenter this method later on with the same value. If that happens // we throw an appropriate error. @@ -149,6 +149,16 @@ export function makeShareableCloneRecursive( // then recreate new host object wrapping the same instance on the UI thread. // there is no point of iterating over keys as we do for regular objects. toAdapt = value; + } else if (isPlainJSObject(value) && value.__workletObjectFactory) { + const workletObjectFactory = value.__workletObjectFactory; + const handle = makeShareableCloneRecursive({ + __init: () => { + 'worklet'; + return workletObjectFactory(); + }, + }); + shareableMappingCache.set(value, handle); + return handle as ShareableRef; } else if (isPlainJSObject(value) || isTypeFunction) { toAdapt = {}; if (isWorkletFunction(value)) { @@ -173,7 +183,7 @@ Offending code was: \`${getWorkletCode(value)}\``); } // to save on transferring static __initData field of worklet structure // we request shareable value to persist its UI counterpart. This means - // that the __initData field that contains long strings represeting the + // that the __initData field that contains long strings representing the // worklet code, source map, and location, will always be // serialized/deserialized once. toAdapt.__initData = makeShareableCloneRecursive(