Skip to content

Commit 45bbfef

Browse files
authored
feat(Worklets): custom serialization support (#8600)
## Summary Adding a feature to register Custom Serializables - a way to transfer objects between runtimes that can't be trivially serialized or deserialized - like primitives, or objects without circular references, containing only primitives. The user can register that certain objects need "packing" before serializing and "unpacking" after deserialization. For the time being the API is going to be JS only. For an object to be considered a potential Custom Serializable it __must have a custom prototype, different than just `Object`__. ### API The user registers a Custom Serializable with the `registerCustomSerializable` function: ```ts function registerCustomSerializable< TValue extends object, TSerialized extends object, >(registrationData: RegistrationData<TValue, TSerialized>); type RegistrationData< TValue extends object, TSerialized extends object, > = { name: string; determine: (value: object) => value is TValue; pack: (value: TValue) => TSerialized; unpack: (value: TSerialized) => TValue; }; ``` where: - `name` is the user-specified name for the Custom Serializable. It's used to prevent registering the same Custom Serializable twice, i.e. during hot-reload. - `determine` is a __worklet__ that receives an `object` type and returns a boolean that tells if the object is an instance of `TValue`, that is a known type for packing and unpacking. - `pack` is a __worklet__ that receives a `TValue` that passed the `determine` checks and returns an object that can be trivially serialized, `TPacked`. - `unpack` is a __worklet__ that receives a `TPacked` objects and unpacks it to the `TValue` object for future use. All these functions are called by Worklets, so the packing and unpacking process is hidden from them. ### Example use (with Nitro Hybrid Objects) ```ts import { createMMKV, type MMKV } from 'react-native-mmkv'; import { type HybridObject, NitroModules } from 'react-native-nitro-modules'; import type { BoxedHybridObject } from 'react-native-nitro-modules'; import { registerCustomSerializable, scheduleOnUI } from 'react-native-worklets'; const storage: createMMKV(); // MMKV instance can't be trivially serialized, it has a custom prototype. storage.set('key', 42); const determine = (value: object) => { 'worklet'; return NitroModules.isHybridObject(value); // `NitroModules.isHybridObject` is the source of truth. }; const pack = (value: HybridObject<never>) => { 'worklet'; return NitroModules.box(value); // `.box()` method creates a HostObject, which is trivially serializable. }; const unpack = (value: BoxedHybridObject<HybridObject<never>>) => { 'worklet'; return value.unbox(); // `.unbox()` method extracts the HostObject to an object with the customn prototype. }; registerCustomSerializable({ name: 'nitro::HybridObject', determine, pack, unpack, }); scheduleOnUI(() => { 'worklet'; const value = storage.getNumber('key'); // `storage` was implicitly packed on the RN Runtime and unpacked on the UI Runtime. console.log(value); // 42 }); ``` ### Implementation details React Native runtime is the source of truth here. When a new type of a Serializable is registered, the registration data is __forwarded synchronously to all active runtimes__ and registered there. This way all runtimes are consistent and all of them can accept/send the new type of an object. The registrations are supposed to be rare so I don't think we should consider it a bottleneck. Registrations are also saved in new `MemoryManager` class. When a new Worklet Runtime is created `MemoryManager` loads all Custom Serializable info on it. ## Test plan Added runtime test suite. --- Docs will land after we decide if the current form of the API is sufficient.
1 parent 7bd206b commit 45bbfef

30 files changed

+953
-7
lines changed

apps/common-app/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@
3535
"react": "19.1.1",
3636
"react-dom": "19.1.1",
3737
"react-native-gesture-handler": "2.28.0",
38+
"react-native-mmkv": "4.0.0",
39+
"react-native-nitro-modules": "0.31.9",
3840
"react-native-pager-view": "7.0.0",
3941
"react-native-reanimated": "workspace:*",
4042
"react-native-safe-area-context": "5.6.1",

apps/common-app/src/apps/reanimated/examples/RuntimeTests/RuntimeTestsExample.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ export default function RuntimeTestsExample() {
4848
require('./tests/memory/createSerializableOnUI.test');
4949
require('./tests/memory/isSerializableRef.test');
5050
require('./tests/memory/synchronizable.test');
51+
require('./tests/memory/customSerializable.test');
52+
require('./tests/memory/hybridObjectSupport.test');
5153
},
5254
},
5355
{
Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
2+
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
3+
/* eslint-disable @typescript-eslint/no-explicit-any */
4+
5+
import { describe, expect, notify, test, waitForNotification } from '../../ReJest/RuntimeTestsApi';
6+
import {
7+
createSerializable,
8+
createSynchronizable,
9+
createWorkletRuntime,
10+
registerCustomSerializable,
11+
runOnUISync,
12+
scheduleOnRN,
13+
scheduleOnRuntime,
14+
scheduleOnUI,
15+
} from 'react-native-worklets';
16+
17+
type IGlobalConstructorCarrier = {
18+
__isCustomObject: true;
19+
constructor: any;
20+
};
21+
22+
function GlobalConstructorCarrierFactory(constructor: any) {
23+
'worklet';
24+
// Workaround because `new` keyword is reserved for Worklet Classes...
25+
const GlobalConstructorCarrier = function GlobalConstructorCarrier(
26+
this: IGlobalConstructorCarrier,
27+
constructor: any,
28+
) {
29+
this.__isCustomObject = true;
30+
this.constructor = constructor;
31+
} as unknown as {
32+
new (constructor: any): IGlobalConstructorCarrier;
33+
};
34+
35+
return new GlobalConstructorCarrier(constructor);
36+
}
37+
38+
const determine = (value: object): value is IGlobalConstructorCarrier => {
39+
'worklet';
40+
return (value as Record<string, unknown>).__isCustomObject === true;
41+
};
42+
43+
const pack = (value: IGlobalConstructorCarrier) => {
44+
'worklet';
45+
const constructorName = value.constructor.name;
46+
return { constructorName };
47+
};
48+
49+
const unpack = (value: { constructorName: string }) => {
50+
'worklet';
51+
return GlobalConstructorCarrierFactory((globalThis as any)[value.constructorName]);
52+
};
53+
54+
describe('Test CustomSerializables', () => {
55+
test('registers without failure', () => {
56+
// Arrange
57+
let error = false;
58+
59+
// Act
60+
try {
61+
registerCustomSerializable({
62+
name: 'registration test',
63+
determine,
64+
pack,
65+
unpack,
66+
});
67+
} catch {
68+
error = true;
69+
}
70+
71+
// Assert
72+
expect(error).toBe(false);
73+
});
74+
75+
test('serializes custom object on RN Runtime', () => {
76+
// Arrange
77+
const testObject = GlobalConstructorCarrierFactory(Array);
78+
let serialized: object | null = null;
79+
80+
// Act
81+
serialized = createSerializable(testObject);
82+
83+
// Assert
84+
expect(serialized).not.toBe(null);
85+
});
86+
87+
test('serializes custom object on RN Runtime and deserializes on UI', () => {
88+
// Arrange
89+
const testObject = GlobalConstructorCarrierFactory(Array);
90+
91+
// Act
92+
const pass = runOnUISync(() => {
93+
'worklet';
94+
return testObject.constructor === Array;
95+
});
96+
97+
expect(pass).toBe(true);
98+
});
99+
100+
test('serializes custom object on the UI Runtime and deserializes on RN', () => {
101+
// Arrange
102+
103+
const testObject = runOnUISync(() => {
104+
'worklet';
105+
return GlobalConstructorCarrierFactory(Array);
106+
});
107+
108+
// Act
109+
const pass = testObject.constructor === Array;
110+
111+
// Assert
112+
expect(pass).toBe(true);
113+
});
114+
115+
test('passes object back and forth between RN and UI runtimes', () => {
116+
// Arrange
117+
118+
const testObject = GlobalConstructorCarrierFactory(Array);
119+
120+
// Act
121+
const testObject2 = runOnUISync(() => {
122+
'worklet';
123+
return testObject;
124+
});
125+
126+
const pass = testObject2.constructor === Array;
127+
128+
// Assert
129+
expect(pass).toBe(true);
130+
});
131+
132+
test('serializes custom object on RN Runtime and deserializes on custom Worklet Runtime', async () => {
133+
// Arrange
134+
const testObject = GlobalConstructorCarrierFactory(Array);
135+
const runtime = createWorkletRuntime();
136+
const pass = createSynchronizable(false);
137+
const notificationName = 'done';
138+
139+
// Act
140+
scheduleOnRuntime(runtime, () => {
141+
'worklet';
142+
pass.setBlocking(testObject.constructor === Array);
143+
notify(notificationName);
144+
});
145+
146+
await waitForNotification(notificationName);
147+
148+
// Assert
149+
expect(pass.getBlocking()).toBe(true);
150+
});
151+
152+
test('serializes custom object on custom Worklet Runtime and deserializes on RN', async () => {
153+
// Arrange
154+
const runtime = createWorkletRuntime();
155+
let testObject: IGlobalConstructorCarrier | null = null;
156+
const notificationName = 'done';
157+
158+
const setReturnValue = (value: any) => (testObject = value);
159+
160+
// Act
161+
scheduleOnRuntime(runtime, () => {
162+
'worklet';
163+
const testObject = GlobalConstructorCarrierFactory(Array);
164+
scheduleOnRN(setReturnValue, testObject);
165+
notify(notificationName);
166+
});
167+
168+
await waitForNotification(notificationName);
169+
170+
const pass = testObject!.constructor === Array;
171+
172+
// Assert
173+
expect(pass).toBe(true);
174+
});
175+
176+
test('propagates new registrations to all runtimes', async () => {
177+
// Arrange
178+
const preRuntime = createWorkletRuntime();
179+
180+
type IGlobalConstructorCarrier2 = {
181+
__isCustomObject2: true;
182+
constructor: any;
183+
};
184+
185+
function GlobalConstructorCarrierFactory2(constructor: any) {
186+
'worklet';
187+
// Workaround because `new` keyword is reserved for Worklet Classes...
188+
const GlobalConstructorCarrier2 = function GlobalConstructorCarrier2(
189+
this: IGlobalConstructorCarrier2,
190+
constructor: any,
191+
) {
192+
this.__isCustomObject2 = true;
193+
this.constructor = constructor;
194+
} as unknown as {
195+
new (constructor: any): IGlobalConstructorCarrier2;
196+
};
197+
198+
return new GlobalConstructorCarrier2(constructor);
199+
}
200+
201+
const determine2 = (value: object): value is IGlobalConstructorCarrier2 => {
202+
'worklet';
203+
return (value as Record<string, unknown>).__isCustomObject2 === true;
204+
};
205+
206+
registerCustomSerializable({
207+
name: 'propagation test',
208+
determine: determine2 as unknown as typeof determine,
209+
pack,
210+
unpack,
211+
});
212+
213+
const postRuntime = createWorkletRuntime();
214+
215+
const uiNotificationName = 'ui_done';
216+
const preRuntimeNotificationName = 'pre_done';
217+
const postRuntimeNotificationName = 'post_done';
218+
219+
let uiPass = false;
220+
const setUiPass = (value: boolean) => {
221+
uiPass = value;
222+
};
223+
let preRuntimePass = false;
224+
const setPreRuntimePass = (value: boolean) => {
225+
preRuntimePass = value;
226+
};
227+
let postRuntimePass = false;
228+
const setPostRuntimePass = (value: boolean) => {
229+
postRuntimePass = value;
230+
};
231+
232+
// Act
233+
scheduleOnUI(() => {
234+
'worklet';
235+
const testObject = GlobalConstructorCarrierFactory2(Array);
236+
const pass = testObject.constructor === Array;
237+
scheduleOnRN(setUiPass, pass);
238+
notify(uiNotificationName);
239+
});
240+
241+
scheduleOnRuntime(preRuntime, () => {
242+
'worklet';
243+
const testObject = GlobalConstructorCarrierFactory2(Array);
244+
const pass = testObject.constructor === Array;
245+
scheduleOnRN(setPreRuntimePass, pass);
246+
notify(preRuntimeNotificationName);
247+
});
248+
249+
scheduleOnRuntime(postRuntime, () => {
250+
'worklet';
251+
const testObject = GlobalConstructorCarrierFactory2(Array);
252+
const pass = testObject.constructor === Array;
253+
scheduleOnRN(setPostRuntimePass, pass);
254+
notify(postRuntimeNotificationName);
255+
});
256+
257+
await waitForNotification(uiNotificationName);
258+
await waitForNotification(preRuntimeNotificationName);
259+
await waitForNotification(postRuntimeNotificationName);
260+
261+
// Assert
262+
expect(uiPass).toBe(true);
263+
expect(preRuntimePass).toBe(true);
264+
expect(postRuntimePass).toBe(true);
265+
});
266+
});

0 commit comments

Comments
 (0)