@felipe-software/react-native-gpui 0.0.0-placeholder → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.reanimated-prebuilt/react-native-reanimated.mjs +29373 -0
- package/LICENSE +21 -0
- package/README.md +658 -1
- package/cli/bin.ts +379 -0
- package/cli/bundle.ts +32 -0
- package/cli/commands/do.ts +213 -0
- package/cli/commands/flow.ts +283 -0
- package/cli/commands/get.ts +584 -0
- package/cli/commands/shot.ts +312 -0
- package/cli/commands/trace.ts +293 -0
- package/cli/host.ts +714 -0
- package/cli/hotReload.ts +210 -0
- package/cli/run.ts +66 -0
- package/cli/selectors.ts +205 -0
- package/dist/Animated.d.ts +120 -0
- package/dist/ChatList.d.ts +28 -0
- package/dist/Dimensions.d.ts +31 -0
- package/dist/StyleSheet.d.ts +18 -0
- package/dist/Window.d.ts +33 -0
- package/dist/apis.d.ts +165 -0
- package/dist/app-state.d.ts +27 -0
- package/dist/chat-list-types.d.ts +136 -0
- package/dist/colors.d.ts +32 -0
- package/dist/commands.d.ts +230 -0
- package/dist/components.d.ts +419 -0
- package/dist/hotModules.d.ts +1 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.development.js +14876 -0
- package/dist/index.js +12 -0
- package/dist/keyboard.d.ts +140 -0
- package/dist/native-menu.d.ts +7 -0
- package/dist/platform-driver.d.ts +32 -0
- package/dist/platform-services.d.ts +35 -0
- package/dist/portal.d.ts +43 -0
- package/dist/raf.d.ts +4 -0
- package/dist/reanimated/colors-processor-shim.d.ts +10 -0
- package/dist/reanimated/reanimated-host.d.ts +10 -0
- package/dist/reanimated/seam.d.ts +34 -0
- package/dist/reanimated/ui-entry.d.ts +2 -0
- package/dist/reanimated/worklet-channel.d.ts +10 -0
- package/dist/reanimated/worklet-runtime.d.ts +466 -0
- package/dist/reanimated/worklets.d.ts +199 -0
- package/dist/reconciler.d.ts +102 -0
- package/dist/refresh.d.ts +1 -0
- package/dist/render.d.ts +37 -0
- package/dist/runtime.d.ts +235 -0
- package/dist/surfaces.d.ts +51 -0
- package/dist/types.d.ts +276 -0
- package/dist/ui-runtime.js +42276 -0
- package/dist/window-context.d.ts +7 -0
- package/dist/window-host.d.ts +41 -0
- package/dist/wire-delta.d.ts +20 -0
- package/package.json +177 -1
- package/scripts/bundle-app.mjs +88 -0
- package/scripts/conformance-utils.mjs +293 -0
- package/scripts/hot-update-alias-plugin.mjs +88 -0
- package/scripts/native-package-exports-plugin.mjs +235 -0
- package/scripts/pixel.mjs +152 -0
- package/scripts/png.mjs +198 -0
- package/scripts/react-refresh-swc.mjs +35 -0
- package/scripts/reanimated-bun-plugin.mjs +188 -0
- package/scripts/service-binary.d.mts +27 -0
- package/scripts/service-binary.mjs +100 -0
- package/src/reanimated/colors-processor-shim.ts +119 -0
- package/src/reanimated/reanimated-host.ts +69 -0
- package/src/reanimated/rngpui-reanimated-prebuilt.d.ts +7 -0
- package/src/reanimated/seam.ts +566 -0
- package/src/reanimated/ui-entry.ts +80 -0
- package/src/reanimated/worklet-channel.ts +64 -0
- package/src/reanimated/worklet-runtime.ts +2569 -0
- package/src/reanimated/worklets.ts +1334 -0
- package/src/refresh.ts +33 -0
|
@@ -0,0 +1,2569 @@
|
|
|
1
|
+
// cross-runtime worklet runtime + shared-slot-backed shared values.
|
|
2
|
+
//
|
|
3
|
+
// faithful port of sootsim's worklet-runtime.ts
|
|
4
|
+
// (~/soot/packages/sootsim-engine/src/render-worker/worklet-runtime.ts) adapted
|
|
5
|
+
// for rngpui's two-JavaScriptCore-runtime architecture. it runs on BOTH the React
|
|
6
|
+
// runtime (role 'react', even slot ids — sootsim's "tenant") and the UI runtime
|
|
7
|
+
// (role 'ui', odd slot ids — sootsim's "shell"). it exposes a small surface that
|
|
8
|
+
// the compat stubs (react-native-worklets/worklets.ts, the reanimated seam) call
|
|
9
|
+
// into:
|
|
10
|
+
//
|
|
11
|
+
// - allocSharedValueSlot(initial) -> id
|
|
12
|
+
// - readSlot(id) / writeSlot(id, value)
|
|
13
|
+
// - subscribeSlot(id, fn) (local-listener fan-out)
|
|
14
|
+
// - dispatchWorklet(target, fn, args, opts) -> Promise<value>
|
|
15
|
+
//
|
|
16
|
+
// the babel transform emits worklets with `__initData.code`, `__closure`,
|
|
17
|
+
// `__workletHash`. we serialize the closure (replacing SharedValue refs with
|
|
18
|
+
// {kind:'sv', id} markers), ship code+closureSpec+args over the channel, and
|
|
19
|
+
// re-eval on the other side.
|
|
20
|
+
//
|
|
21
|
+
// the channel (send/hasPeer) is injected at construction; inbound messages arrive
|
|
22
|
+
// via the channel adapter calling `runtime.onMessage(msg)` (see worklet-channel.ts).
|
|
23
|
+
//
|
|
24
|
+
// shared slots: instead of a SharedArrayBuffer parameter we read the plain
|
|
25
|
+
// ArrayBuffer installed by the rust host at `globalThis.__rngpui_svSlots` — both
|
|
26
|
+
// runtimes see the SAME backing memory, so a Float64Array view works identically
|
|
27
|
+
// to sootsim's SAB. layout: a small header followed by `capacity` Float64 slots.
|
|
28
|
+
// Boolean shared values store as 0 / 1. object/array shared values live in
|
|
29
|
+
// per-runtime object-slot maps and propagate over the channel as structured
|
|
30
|
+
// snapshots.
|
|
31
|
+
//
|
|
32
|
+
// what was stripped vs. sootsim (no rngpui counterpart): the animatedBridge
|
|
33
|
+
// listener relay (registerAnimatedView / unregisterAnimatedView /
|
|
34
|
+
// applyAnimatedProps message kinds and onAnimatedBridge / postAnimatedBridge),
|
|
35
|
+
// traceReanimated debug hooks, and surface/mirror notions. everything else is a
|
|
36
|
+
// faithful port — the lessons embedded in the comments are the design contract.
|
|
37
|
+
|
|
38
|
+
const SAB_MAGIC = 0x504e9a01
|
|
39
|
+
const SAB_HEADER_FLOATS = 4 // magic, capacity, _pad, _pad
|
|
40
|
+
const DEFAULT_CAPACITY = 262144
|
|
41
|
+
// object SVs live in a per-runtime JS Map (objectSlots), not the shared Float64
|
|
42
|
+
// array, so they need only a unique id — never a Float64 cell — and therefore
|
|
43
|
+
// must NOT be bounded by the slot `capacity` (which exists solely to bound
|
|
44
|
+
// `floats[]`). they get a DISJOINT high id range with their own counter +
|
|
45
|
+
// free-list. two reasons it must be disjoint, not shared:
|
|
46
|
+
// 1. a numeric id alone then identifies its kind (id >= OBJECT_ID_BASE →
|
|
47
|
+
// object slot), so a primitive free and an object free can never alias
|
|
48
|
+
// and clobber the same cell via releaseSlotState().
|
|
49
|
+
// 2. high-churn screens (hundreds of reanimated rows + tamagui configRef
|
|
50
|
+
// object-SVs mounting/unmounting) used to drain the 262144 primitive id
|
|
51
|
+
// space — GC-driven freeSlot lags the burst, so the monotonic counter
|
|
52
|
+
// outran reclaim and `allocSlotId` threw. object slots have no slot ceiling,
|
|
53
|
+
// matching upstream reanimated where object shareables are plain GC'd JS
|
|
54
|
+
// objects.
|
|
55
|
+
// 2^32 base keeps ids exact integers and far above any primitive id; the
|
|
56
|
+
// capacity is a final safety valve only.
|
|
57
|
+
const OBJECT_ID_BASE = 0x1_0000_0000 // 2^32
|
|
58
|
+
const OBJECT_ID_CAPACITY = 0x1_0000_0000 // 2^32 ids (~4.3B) — effectively unbounded
|
|
59
|
+
type WorkletBuiltinName = string
|
|
60
|
+
|
|
61
|
+
// kinds of values that can appear in a closure spec
|
|
62
|
+
export type ValueSpec =
|
|
63
|
+
| { kind: 'sv'; id: number }
|
|
64
|
+
// object SV — same id-space as primitive 'sv', but the live value lives in a
|
|
65
|
+
// per-runtime Map<id, unknown> rather than the shared Float64 slots.
|
|
66
|
+
| { kind: 'svObject'; id: number }
|
|
67
|
+
// a reanimated mutable that reached the cross-runtime chokepoint without an
|
|
68
|
+
// `_id` AND could not be slot-backed at ship time (slot pool exhausted).
|
|
69
|
+
// instead of deep-cloning it into a plain object — whose peer-side `.get()`
|
|
70
|
+
// would be missing and crash tamagui's animated worklet with `configRef.get is
|
|
71
|
+
// not a function` — we ship a detached snapshot. the peer rebuilds a read-only
|
|
72
|
+
// object-SV-shaped proxy whose `.get()`/`.value` return this snapshot.
|
|
73
|
+
| { kind: 'svDetached'; value: unknown }
|
|
74
|
+
// a reanimated animation object (withSpring/withTiming/... result) stamped by
|
|
75
|
+
// reanimated-host.ts with its {type, args} provenance. live animation objects
|
|
76
|
+
// carry runtime-created onFrame/onStart closures that CANNOT cross (they'd
|
|
77
|
+
// degrade to async jsCallback proxies and the animation dies — the dialog-bg
|
|
78
|
+
// regression); instead the receiving runtime re-creates the animation from its
|
|
79
|
+
// own factory table (__rngpuiReanimatedAnimationFactories).
|
|
80
|
+
| { kind: 'animation'; type: string; args: ValueSpec[] }
|
|
81
|
+
| { kind: 'plain'; value: unknown }
|
|
82
|
+
// runtime-provided functions such as runOnJS / withTiming. upstream
|
|
83
|
+
// workletization captures these imports in `__closure`; on real iOS the UI
|
|
84
|
+
// runtime resolves them as builtins, so we rebind by name on the receiving
|
|
85
|
+
// runtime instead of trying to structured-clone a function.
|
|
86
|
+
| { kind: 'builtin'; name: WorkletBuiltinName }
|
|
87
|
+
| {
|
|
88
|
+
kind: 'fn'
|
|
89
|
+
code: string
|
|
90
|
+
closureSpec: ClosureSpec
|
|
91
|
+
hash: number
|
|
92
|
+
closureId: number
|
|
93
|
+
}
|
|
94
|
+
// upstream createSerializable({__init}) / makeShareableCloneRecursive handles:
|
|
95
|
+
// the receiving runtime evaluates the initializer worklet and uses its return
|
|
96
|
+
// value as the closure object. useAnimatedRef depends on this.
|
|
97
|
+
| { kind: 'initializer'; init: WorkletFunctionSpec }
|
|
98
|
+
// upstream useAnimatedRef must be callable on the UI runtime and return a
|
|
99
|
+
// shadow-node wrapper/tag synchronously. on rngpui the "tag" carried is the
|
|
100
|
+
// globalId (the reconciler Instance `.id` rust keys on).
|
|
101
|
+
| { kind: 'animatedRef'; viewTag: number }
|
|
102
|
+
| { kind: 'animatedRefSlot'; slotId: number }
|
|
103
|
+
// a callback that ran through `runOnJS` (or equivalent) on the React runtime.
|
|
104
|
+
// when re-bound on the UI side, calling it ships a `runJSCallback` message back
|
|
105
|
+
// so the React runtime invokes the original fn with the rebound args.
|
|
106
|
+
| { kind: 'jsCallback'; id: number }
|
|
107
|
+
| { kind: 'undef' }
|
|
108
|
+
// arrays / nested plain objects that may themselves contain sv / fn /
|
|
109
|
+
// jsCallback refs.
|
|
110
|
+
| { kind: 'array'; items: ValueSpec[] }
|
|
111
|
+
| { kind: 'object'; entries: Array<[string, ValueSpec]> }
|
|
112
|
+
// a `makeShareable`-stamped record (reanimated `remoteState`, scroll handler
|
|
113
|
+
// state, …). upstream's `serializableMappingCache` maps the SAME shareable to
|
|
114
|
+
// the SAME UI-runtime clone across every (re-)ship so the mapper's mutations
|
|
115
|
+
// (`state.last`/`animations`) persist across stopMapper/startMapper. `id` is
|
|
116
|
+
// stable for the lifetime of the source object; `snapshot` is the deep
|
|
117
|
+
// `kind:'object'` entries used ONLY for first materialization.
|
|
118
|
+
| { kind: 'shareableRef'; id: number; snapshot: Array<[string, ValueSpec]> }
|
|
119
|
+
|
|
120
|
+
export type ClosureSpec = Record<string, ValueSpec>
|
|
121
|
+
type WorkletFunctionSpec = Extract<ValueSpec, { kind: 'fn' }>
|
|
122
|
+
|
|
123
|
+
const WORKLET_BUILTIN_NAME = Symbol.for('rngpui.workletBuiltinName')
|
|
124
|
+
const LEGACY_WORKLET_BUILTIN_NAME = Symbol.for('rngpui.workletBuiltin')
|
|
125
|
+
const SERIALIZABLE_MAPPING_CACHE = Symbol.for('rngpui.serializableMappingCache')
|
|
126
|
+
const WORKLET_BUILTIN_REGISTRY = '__rngpui_worklet_builtins'
|
|
127
|
+
const REANIMATED_ANIMATION_FACTORIES = '__rngpuiReanimatedAnimationFactories'
|
|
128
|
+
const REANIMATED_VALUE_SETTER = '__rngpuiReanimatedValueSetter'
|
|
129
|
+
|
|
130
|
+
const KNOWN_WORKLET_BUILTINS = new Set([
|
|
131
|
+
'cancelAnimation',
|
|
132
|
+
'dispatchCommand',
|
|
133
|
+
'interpolate',
|
|
134
|
+
'interpolateColor',
|
|
135
|
+
'measure',
|
|
136
|
+
'runOnJS',
|
|
137
|
+
'runOnRuntime',
|
|
138
|
+
'runOnUI',
|
|
139
|
+
'scheduleOnRN',
|
|
140
|
+
'scheduleOnUI',
|
|
141
|
+
'scrollTo',
|
|
142
|
+
'setGestureState',
|
|
143
|
+
'setNativeProps',
|
|
144
|
+
'withClamp',
|
|
145
|
+
'withDecay',
|
|
146
|
+
'withDelay',
|
|
147
|
+
'withRepeat',
|
|
148
|
+
'withSequence',
|
|
149
|
+
'withSpring',
|
|
150
|
+
'withTiming',
|
|
151
|
+
])
|
|
152
|
+
|
|
153
|
+
function installWorkletGlobalAliases(): void {
|
|
154
|
+
const g = globalThis as typeof globalThis & { global?: typeof globalThis }
|
|
155
|
+
if (typeof g.global === 'undefined') {
|
|
156
|
+
Object.defineProperty(g, 'global', {
|
|
157
|
+
configurable: true,
|
|
158
|
+
enumerable: false,
|
|
159
|
+
writable: true,
|
|
160
|
+
value: globalThis,
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
type WorkletBuiltinRegistry = Map<string, unknown>
|
|
166
|
+
|
|
167
|
+
function getWorkletBuiltinRegistry(): WorkletBuiltinRegistry {
|
|
168
|
+
installWorkletGlobalAliases()
|
|
169
|
+
const g = globalThis as unknown as {
|
|
170
|
+
[WORKLET_BUILTIN_REGISTRY]?: WorkletBuiltinRegistry
|
|
171
|
+
}
|
|
172
|
+
if (!g[WORKLET_BUILTIN_REGISTRY]) {
|
|
173
|
+
g[WORKLET_BUILTIN_REGISTRY] = new Map()
|
|
174
|
+
}
|
|
175
|
+
return g[WORKLET_BUILTIN_REGISTRY]
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function markWorkletBuiltin<T extends (...args: never[]) => unknown>(
|
|
179
|
+
name: string,
|
|
180
|
+
fn: T,
|
|
181
|
+
): T {
|
|
182
|
+
try {
|
|
183
|
+
Object.defineProperty(fn, WORKLET_BUILTIN_NAME, {
|
|
184
|
+
value: name,
|
|
185
|
+
enumerable: false,
|
|
186
|
+
configurable: true,
|
|
187
|
+
writable: false,
|
|
188
|
+
})
|
|
189
|
+
} catch {}
|
|
190
|
+
getWorkletBuiltinRegistry().set(name, fn)
|
|
191
|
+
return fn
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Register a worklet builtin by name. The integrator populates this from the
|
|
196
|
+
* UI-side upstream reanimated module so that closures captured on the React side
|
|
197
|
+
* (which serialize builtins as {kind:'builtin', name}) resolve to the real UI
|
|
198
|
+
* implementation on deserialization. See bindBuiltin's special cases.
|
|
199
|
+
*
|
|
200
|
+
* The value is usually a function (withSpring, scrollTo, …) but may be a namespace
|
|
201
|
+
* object exposed to worklet bodies by name (e.g. `Easing`), so the registry holds
|
|
202
|
+
* `unknown`. Functions also get the hidden name brand (for outbound serialization);
|
|
203
|
+
* non-functions just land in the registry for by-name resolution.
|
|
204
|
+
*/
|
|
205
|
+
export function registerWorkletBuiltin(name: string, value: unknown): void {
|
|
206
|
+
if (typeof value === 'function') {
|
|
207
|
+
markWorkletBuiltin(name, value as (...args: never[]) => unknown)
|
|
208
|
+
return
|
|
209
|
+
}
|
|
210
|
+
getWorkletBuiltinRegistry().set(name, value)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function getSerializedBuiltinName(value: unknown): string | null {
|
|
214
|
+
if (typeof value !== 'function') return null
|
|
215
|
+
const branded = (value as { [WORKLET_BUILTIN_NAME]?: unknown })[WORKLET_BUILTIN_NAME]
|
|
216
|
+
if (typeof branded === 'string') return branded
|
|
217
|
+
const legacyBranded = (value as { [LEGACY_WORKLET_BUILTIN_NAME]?: unknown })[
|
|
218
|
+
LEGACY_WORKLET_BUILTIN_NAME
|
|
219
|
+
]
|
|
220
|
+
if (typeof legacyBranded === 'string') return legacyBranded
|
|
221
|
+
const name = value.name
|
|
222
|
+
return KNOWN_WORKLET_BUILTINS.has(name) ? name : null
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function getSerializableMapping(value: unknown): unknown {
|
|
226
|
+
if ((typeof value !== 'object' && typeof value !== 'function') || value === null) {
|
|
227
|
+
return undefined
|
|
228
|
+
}
|
|
229
|
+
const cache = (globalThis as Record<symbol, unknown>)[SERIALIZABLE_MAPPING_CACHE]
|
|
230
|
+
if (!cache || typeof (cache as { get?: unknown }).get !== 'function') {
|
|
231
|
+
return undefined
|
|
232
|
+
}
|
|
233
|
+
try {
|
|
234
|
+
return (cache as WeakMap<object, unknown>).get(value as object)
|
|
235
|
+
} catch {
|
|
236
|
+
return undefined
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export interface WorkletDispatchOptions {
|
|
241
|
+
// when false, dispatch is fire-and-forget (no reply expected).
|
|
242
|
+
awaitReply?: boolean
|
|
243
|
+
// native reanimated wraps UI-thread event handlers with
|
|
244
|
+
// `global.__frameTimestamp = eventTimestamp`, runs the handler, then
|
|
245
|
+
// `global.__flushAnimationFrame(eventTimestamp)`. gesture callbacks that start
|
|
246
|
+
// animations need the same event-clock envelope.
|
|
247
|
+
eventTimestamp?: number
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// messages exchanged across the channel. the channel implementation chooses how
|
|
251
|
+
// to wrap/unwrap these onto the real protocol (JSON over the rust host fns).
|
|
252
|
+
export type WorkletMessage =
|
|
253
|
+
| {
|
|
254
|
+
type: 'runWorklet'
|
|
255
|
+
runId: number
|
|
256
|
+
code: string
|
|
257
|
+
hash: number
|
|
258
|
+
closureId: number
|
|
259
|
+
closureSpec: ClosureSpec
|
|
260
|
+
argsSpec: ValueSpec[]
|
|
261
|
+
// when true, reply is expected with the return value
|
|
262
|
+
awaitReply: boolean
|
|
263
|
+
eventTimestamp?: number
|
|
264
|
+
}
|
|
265
|
+
| {
|
|
266
|
+
type: 'workletReply'
|
|
267
|
+
runId: number
|
|
268
|
+
ok: boolean
|
|
269
|
+
returnValueSpec?: ValueSpec
|
|
270
|
+
error?: string
|
|
271
|
+
}
|
|
272
|
+
| {
|
|
273
|
+
type: 'workletDone'
|
|
274
|
+
runId: number
|
|
275
|
+
ok: boolean
|
|
276
|
+
error?: string
|
|
277
|
+
}
|
|
278
|
+
| {
|
|
279
|
+
type: 'svUpdate'
|
|
280
|
+
svId: number
|
|
281
|
+
value: number
|
|
282
|
+
}
|
|
283
|
+
| {
|
|
284
|
+
// coalesced version of svUpdate — multiple slot writes within a single JS
|
|
285
|
+
// turn flush as one message. dramatic crossing reduction on hot paths like
|
|
286
|
+
// animated tickers. also carries object SV updates as an optional second
|
|
287
|
+
// list so a mixed-write turn ships a single combined message.
|
|
288
|
+
type: 'svUpdateBatch'
|
|
289
|
+
updates: Array<{ svId: number; value: number }>
|
|
290
|
+
objectUpdates?: Array<{ svId: number; value: unknown }>
|
|
291
|
+
}
|
|
292
|
+
| {
|
|
293
|
+
type: 'svAlloc'
|
|
294
|
+
svId: number
|
|
295
|
+
// initial value (pre-written to slot). fire-and-forget today.
|
|
296
|
+
initial: number
|
|
297
|
+
// the slot holds a boolean shared value (stored Float64 0/1). lets the
|
|
298
|
+
// peer's proxy reads restore the boolean type, matching real reanimated
|
|
299
|
+
// where `useSharedValue(false).value === false`, not `0`.
|
|
300
|
+
bool?: boolean
|
|
301
|
+
}
|
|
302
|
+
| {
|
|
303
|
+
// object SV allocation — peer seeds its objectSlots Map with the initial
|
|
304
|
+
// value so subsequent reads (before any update arrives) return the right
|
|
305
|
+
// shape. sent fire-and-forget at alloc time.
|
|
306
|
+
type: 'svObjectAlloc'
|
|
307
|
+
svId: number
|
|
308
|
+
initial: unknown
|
|
309
|
+
}
|
|
310
|
+
| {
|
|
311
|
+
// a SharedValue's JS wrapper was garbage-collected on the owning runtime
|
|
312
|
+
// (FinalizationRegistry). the owner returns the slot id to its free-list
|
|
313
|
+
// and tells the peer to drop its mirror state for that id so a future
|
|
314
|
+
// re-alloc of the same id starts clean. the peer must NOT add the id to its
|
|
315
|
+
// own free-list — only the owning (even=react / odd=ui) runtime allocates
|
|
316
|
+
// that id.
|
|
317
|
+
type: 'svFree'
|
|
318
|
+
svId: number
|
|
319
|
+
}
|
|
320
|
+
| {
|
|
321
|
+
// UI-side worklet calling a React-side runOnJS-wrapped fn. the React
|
|
322
|
+
// runtime looks up callbackId in its js-callback registry and invokes it
|
|
323
|
+
// with the rebound args.
|
|
324
|
+
type: 'runJSCallback'
|
|
325
|
+
callbackId: number
|
|
326
|
+
argsSpec: ValueSpec[]
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// a Channel is the runtime-specific bridge to the other side. messages are
|
|
330
|
+
// strings through the rust host fns (FIFO per direction).
|
|
331
|
+
export interface WorkletChannel {
|
|
332
|
+
send(msg: WorkletMessage): void
|
|
333
|
+
// return whether this side has a peer at all. true once the host bridge fn is
|
|
334
|
+
// present in this runtime.
|
|
335
|
+
hasPeer(): boolean
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
interface PendingReply {
|
|
339
|
+
resolve(spec: ValueSpec | undefined): void
|
|
340
|
+
reject(err: Error): void
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
interface SerializedWorkletCacheEntry {
|
|
344
|
+
code: string
|
|
345
|
+
hash: number
|
|
346
|
+
closure: Record<string, unknown>
|
|
347
|
+
spec: WorkletFunctionSpec
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function getMaterializedClosureCacheKey(hash: number, closureId: number): string {
|
|
351
|
+
return `${hash}:${closureId}`
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function readOwnNumber(value: object, key: string): number | null {
|
|
355
|
+
const desc = Object.getOwnPropertyDescriptor(value, key)
|
|
356
|
+
if (!desc || !('value' in desc)) return null
|
|
357
|
+
return typeof desc.value === 'number' && Number.isFinite(desc.value) ? desc.value : null
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function coerceSlotValue(value: unknown): number {
|
|
361
|
+
if (typeof value === 'number') return value
|
|
362
|
+
if (typeof value === 'boolean') return value ? 1 : 0
|
|
363
|
+
if (value && typeof value === 'object') {
|
|
364
|
+
const current = readOwnNumber(value, 'current')
|
|
365
|
+
if (current !== null) return current
|
|
366
|
+
const toValue = readOwnNumber(value, 'toValue')
|
|
367
|
+
if (toValue !== null) return toValue
|
|
368
|
+
}
|
|
369
|
+
return Number.NaN
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// cached slot-exhaustion errors. thrown (and caught by createShareable) on every
|
|
373
|
+
// failed alloc during a storm; sharing one instance per kind avoids building a
|
|
374
|
+
// fresh Error + stack hundreds of thousands of times, which on its own can OOM
|
|
375
|
+
// the runtime.
|
|
376
|
+
const SAB_SLOT_EXHAUSTION_ERROR = new Error('worklet-runtime: shared slot exhaustion')
|
|
377
|
+
const OBJECT_SLOT_EXHAUSTION_ERROR = new Error(
|
|
378
|
+
'worklet-runtime: id space exhaustion (object slot)',
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
export type WorkletRuntimeRole = 'react' | 'ui'
|
|
382
|
+
|
|
383
|
+
// the singleton runtime per JavaScriptCore runtime. compat stubs call getWorkletRuntime().
|
|
384
|
+
export class WorkletRuntime {
|
|
385
|
+
private channel: WorkletChannel
|
|
386
|
+
private buffer: ArrayBuffer
|
|
387
|
+
private floats: Float64Array
|
|
388
|
+
private capacity: number
|
|
389
|
+
// local slot listener registry. fired both by local writes (after the slot
|
|
390
|
+
// write lands) and by inbound svUpdate messages from the peer.
|
|
391
|
+
private listeners = new Map<number, Set<(value: number) => void>>()
|
|
392
|
+
private lastSlotListenerValue = new Map<number, number>()
|
|
393
|
+
// slot ids returned by freeSlot(), available for reuse. real reanimated
|
|
394
|
+
// releases the native Shareable when a JS Mutable is garbage-collected;
|
|
395
|
+
// freeSlot() is the faithful analog (driven by a FinalizationRegistry in the
|
|
396
|
+
// worklets stub). without reuse the monotonic nextId climbs until
|
|
397
|
+
// `id >= capacity` and a long-running app crashes with "slot exhaustion".
|
|
398
|
+
// freedSet gives O(1) double-free protection (a slot must not land on the list
|
|
399
|
+
// twice or two SharedValues would alias one cell).
|
|
400
|
+
private freeSlots: number[] = []
|
|
401
|
+
private freedSet = new Set<number>()
|
|
402
|
+
// object-slot free-list, disjoint from the primitive one above.
|
|
403
|
+
private freeObjectSlots: number[] = []
|
|
404
|
+
private freedObjectSet = new Set<number>()
|
|
405
|
+
// SharedValue proxies are cached by slot id so every worklet closure that
|
|
406
|
+
// deserializes a ref to the same shared value gets back the SAME proxy object.
|
|
407
|
+
// upstream reanimated has exactly one `Mutable` per shared value, and its
|
|
408
|
+
// `valueSetter` cancels a running animation by reading the per-object
|
|
409
|
+
// `mutable._animation`. minting a fresh proxy per deserialization fragments
|
|
410
|
+
// `_animation`: a direct `.value =` write from one worklet (a gesture's
|
|
411
|
+
// onUpdate) can no longer cancel a `withSpring` started from another (the
|
|
412
|
+
// previous gesture's onEnd), so the orphaned spring keeps writing the slot and
|
|
413
|
+
// fights the next gesture. one proxy per slot restores upstream's
|
|
414
|
+
// single-object-per-shared-value identity.
|
|
415
|
+
private svProxyCache = new Map<number, SharedValueProxy>()
|
|
416
|
+
private svObjectProxyCache = new Map<number, ObjectSharedValueProxy>()
|
|
417
|
+
// primitive slots that hold a boolean shared value. the slot array only stores
|
|
418
|
+
// Float64, so booleans live as 0/1 — this set lets reads (proxy + getSync)
|
|
419
|
+
// restore the boolean type so `useSharedValue(false).value === false` like real
|
|
420
|
+
// reanimated, instead of leaking a `0` that breaks `=== false` checks.
|
|
421
|
+
private boolSlots = new Set<number>()
|
|
422
|
+
// monotonic id allocator. react gets even ids, ui gets odd, so there's no
|
|
423
|
+
// collision and the two runtimes allocate without coordination.
|
|
424
|
+
private nextId: number
|
|
425
|
+
// object-slot allocator, disjoint high range (see OBJECT_ID_BASE). same
|
|
426
|
+
// even=react / odd=ui stride.
|
|
427
|
+
private nextObjectId: number
|
|
428
|
+
private idStride: number
|
|
429
|
+
private role: WorkletRuntimeRole
|
|
430
|
+
private statPrimitiveAllocs = 0
|
|
431
|
+
private statObjectAllocs = 0
|
|
432
|
+
private statReusedAllocs = 0
|
|
433
|
+
private statFrees = 0
|
|
434
|
+
// slot-exhaustion logging throttle. a re-render storm can hit the exhausted
|
|
435
|
+
// allocator hundreds of thousands of times in a burst; building an Error+stack
|
|
436
|
+
// and console.error-ing each time is itself enough to OOM/crash the runtime.
|
|
437
|
+
private exhaustionLoggedAt = 0
|
|
438
|
+
private exhaustionSuppressed = 0
|
|
439
|
+
private runIdCounter = 1
|
|
440
|
+
private pending = new Map<number, PendingReply>()
|
|
441
|
+
// worklet fn cache by hash so we don't re-eval identical bodies.
|
|
442
|
+
private workletCache = new Map<number, (...args: unknown[]) => unknown>()
|
|
443
|
+
// receiver-side closure materialization cache. sender-side closureId values
|
|
444
|
+
// name a specific worklet function + closure-object pairing; the receiving
|
|
445
|
+
// runtime should hydrate that closure clone once and reuse it, matching
|
|
446
|
+
// reanimated's UI-runtime clone lifetime instead of rebuilding every frame.
|
|
447
|
+
private materializedClosureCache = new Map<string, Record<string, unknown>>()
|
|
448
|
+
private remoteWorkletCache = new Map<string, WorkletFn>()
|
|
449
|
+
// persistent objects for `makeShareable` records, keyed by the source's stable
|
|
450
|
+
// shareable id. mirrors upstream `serializableMappingCache` ("already converted
|
|
451
|
+
// → return the same clone"): a re-shipped shareable (every stopMapper/
|
|
452
|
+
// startMapper re-trigger ships the closure again) MUST resolve to the SAME
|
|
453
|
+
// object so the mapper's `state.last`/`animations` mutations persist across
|
|
454
|
+
// registrations. without this, the 2nd withSpring on a prop re-seeds from a
|
|
455
|
+
// stale snapshot and snaps.
|
|
456
|
+
private shareableCache = new Map<number, object>()
|
|
457
|
+
// outbound cache by source function identity. babel-emitted worklet closures
|
|
458
|
+
// are snapshots, so the same fn with the same closure object can reuse the
|
|
459
|
+
// already-walked closure spec instead of reserializing large mapper helpers.
|
|
460
|
+
private serializedWorklets = new WeakMap<object, SerializedWorkletCacheEntry>()
|
|
461
|
+
private nextSerializedClosureId = 1
|
|
462
|
+
// js-callback registry: ids handed to runOnJS-tagged wrappers map to the
|
|
463
|
+
// original React-local function. UI-side calls post a runJSCallback back; the
|
|
464
|
+
// React runtime resolves through this map.
|
|
465
|
+
private jsCallbacks = new Map<number, (...args: unknown[]) => unknown>()
|
|
466
|
+
// memoize wrappers per source fn so re-registering the same callback does not
|
|
467
|
+
// exhaust id space.
|
|
468
|
+
private jsCallbackByFn = new WeakMap<
|
|
469
|
+
object,
|
|
470
|
+
{ id: number; wrapper: (...args: unknown[]) => unknown }
|
|
471
|
+
>()
|
|
472
|
+
private nextJsCallbackId = 1
|
|
473
|
+
private animatedRefSlots = new WeakMap<
|
|
474
|
+
object,
|
|
475
|
+
{ slotId: number; unsubscribe?: () => void }
|
|
476
|
+
>()
|
|
477
|
+
private statRunJSCallbackSent = 0
|
|
478
|
+
private statRunJSCallbackReceived = 0
|
|
479
|
+
private statRunJSCallbackMissing = 0
|
|
480
|
+
private pendingRunWorklets = new Set<number>()
|
|
481
|
+
private statRunWorkletSent = 0
|
|
482
|
+
private statRunWorkletDone = 0
|
|
483
|
+
private statRunWorkletFailed = 0
|
|
484
|
+
private statClosureCacheHits = 0
|
|
485
|
+
private statClosureCacheMisses = 0
|
|
486
|
+
// svUpdate coalescing — local writes within one JS turn batch into a single
|
|
487
|
+
// crossing at microtask boundary. last-write-wins per slot. hot paths (animated
|
|
488
|
+
// digit tickers, scroll-driven SVs) write the same slot many times per task;
|
|
489
|
+
// coalescing collapses that traffic to one message per JS turn.
|
|
490
|
+
private pendingPeerUpdates = new Map<number, number>()
|
|
491
|
+
private peerFlushScheduled = false
|
|
492
|
+
private statWriteRequests = 0
|
|
493
|
+
private statBatchesSent = 0
|
|
494
|
+
// object SV slots — non-primitive shareable values live here, keyed by the same
|
|
495
|
+
// id-space as primitive slots. each runtime keeps its own copy (objects are
|
|
496
|
+
// structured-cloned across the channel; identity is not preserved across
|
|
497
|
+
// runtimes). reads return the local copy; writes update local + queue peer
|
|
498
|
+
// notification.
|
|
499
|
+
private objectSlots = new Map<number, unknown>()
|
|
500
|
+
private objectListeners = new Map<number, Set<(value: unknown) => void>>()
|
|
501
|
+
private pendingObjectUpdates = new Map<number, unknown>()
|
|
502
|
+
|
|
503
|
+
constructor(opts: { channel: WorkletChannel; buffer: ArrayBuffer; role: WorkletRuntimeRole }) {
|
|
504
|
+
installWorkletGlobalAliases()
|
|
505
|
+
this.channel = opts.channel
|
|
506
|
+
this.buffer = opts.buffer
|
|
507
|
+
this.role = opts.role
|
|
508
|
+
this.floats = new Float64Array(this.buffer)
|
|
509
|
+
this.capacity = this.floats[1] || DEFAULT_CAPACITY
|
|
510
|
+
|
|
511
|
+
const magic = this.floats[0]
|
|
512
|
+
if (magic !== SAB_MAGIC) {
|
|
513
|
+
throw new Error('worklet-runtime: shared-slot magic mismatch')
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// ids start past the header. react uses even ids; ui uses odd; that way both
|
|
517
|
+
// sides allocate without coordination and never collide.
|
|
518
|
+
this.idStride = 2
|
|
519
|
+
const firstEven =
|
|
520
|
+
SAB_HEADER_FLOATS % 2 === 0 ? SAB_HEADER_FLOATS : SAB_HEADER_FLOATS + 1
|
|
521
|
+
const firstOdd = firstEven + 1
|
|
522
|
+
this.nextId = opts.role === 'ui' ? firstOdd : firstEven
|
|
523
|
+
// object ids start at OBJECT_ID_BASE (even); ui takes the odd lane.
|
|
524
|
+
this.nextObjectId = opts.role === 'ui' ? OBJECT_ID_BASE + 1 : OBJECT_ID_BASE
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Inbound channel entry point. The channel adapter calls this with each
|
|
529
|
+
* decoded message. Public so the adapter (worklet-channel.ts) can wire it.
|
|
530
|
+
*/
|
|
531
|
+
onMessage(msg: WorkletMessage): void {
|
|
532
|
+
switch (msg.type) {
|
|
533
|
+
case 'svUpdate': {
|
|
534
|
+
// peer wrote — the shared cell is the source of truth. the queued payload
|
|
535
|
+
// is only a listener wakeup and can lag newer writes when the runtime is
|
|
536
|
+
// busy; never rewind the shared cell to the queued payload.
|
|
537
|
+
this.fireLocalIfChanged(msg.svId, this.floats[msg.svId])
|
|
538
|
+
return
|
|
539
|
+
}
|
|
540
|
+
case 'svUpdateBatch': {
|
|
541
|
+
// peer flushed a coalesced batch of writes. iterate and apply each — same
|
|
542
|
+
// fan-out semantics as svUpdate, just amortized. objectUpdates rides along
|
|
543
|
+
// when the peer's turn touched both primitive and object SVs.
|
|
544
|
+
if (Array.isArray(msg.updates)) {
|
|
545
|
+
for (const u of msg.updates) {
|
|
546
|
+
if (!u || typeof u.svId !== 'number' || typeof u.value !== 'number') {
|
|
547
|
+
continue
|
|
548
|
+
}
|
|
549
|
+
// use the current shared value, not the queued payload. when gesture
|
|
550
|
+
// frames are waiting in the queue, replaying payload values makes
|
|
551
|
+
// UI-rendered animations visibly run in slow motion and alternate
|
|
552
|
+
// stale/current transforms.
|
|
553
|
+
this.fireLocalIfChanged(u.svId, this.floats[u.svId])
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
if (Array.isArray(msg.objectUpdates)) {
|
|
557
|
+
for (const u of msg.objectUpdates) {
|
|
558
|
+
if (!u || typeof u.svId !== 'number') continue
|
|
559
|
+
this.objectSlots.set(u.svId, u.value)
|
|
560
|
+
this.fireObjectLocal(u.svId, u.value)
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
return
|
|
564
|
+
}
|
|
565
|
+
case 'svAlloc': {
|
|
566
|
+
// peer allocated; just keep our nextId past it to avoid future collisions.
|
|
567
|
+
// both runtimes share the same buffer, so the allocator already seeded the
|
|
568
|
+
// cell synchronously. do not overwrite here: a live write can race ahead of
|
|
569
|
+
// this low-priority alloc message.
|
|
570
|
+
if (this.floats[msg.svId] === 0 && msg.initial !== 0) {
|
|
571
|
+
this.floats[msg.svId] = msg.initial
|
|
572
|
+
}
|
|
573
|
+
// mirror the boolean-slot tag so this side's proxy reads also restore the
|
|
574
|
+
// boolean type for a peer-allocated boolean shared value.
|
|
575
|
+
if (msg.bool) this.boolSlots.add(msg.svId)
|
|
576
|
+
// ensure local nextId leapfrogs as needed.
|
|
577
|
+
if (msg.svId >= this.nextId) {
|
|
578
|
+
while (this.nextId <= msg.svId) this.nextId += this.idStride
|
|
579
|
+
}
|
|
580
|
+
return
|
|
581
|
+
}
|
|
582
|
+
case 'svObjectAlloc': {
|
|
583
|
+
this.objectSlots.set(msg.svId, msg.initial)
|
|
584
|
+
// object ids live in their own counter's range; leapfrog it (not nextId)
|
|
585
|
+
// so this side never re-hands-out the peer's object id.
|
|
586
|
+
if (msg.svId >= this.nextObjectId) {
|
|
587
|
+
while (this.nextObjectId <= msg.svId) this.nextObjectId += this.idStride
|
|
588
|
+
}
|
|
589
|
+
return
|
|
590
|
+
}
|
|
591
|
+
case 'svFree': {
|
|
592
|
+
// peer's SharedValue wrapper was GC'd. drop our mirror state for that id so
|
|
593
|
+
// a future re-alloc at the same id starts clean. we do not own this id's
|
|
594
|
+
// allocation parity, so it does NOT go on our free-list and nextId is left
|
|
595
|
+
// untouched (a later svAlloc / svObjectAlloc for the same id re-seeds it).
|
|
596
|
+
this.releaseSlotState(msg.svId)
|
|
597
|
+
return
|
|
598
|
+
}
|
|
599
|
+
case 'runWorklet': {
|
|
600
|
+
this.handleRunWorklet(msg)
|
|
601
|
+
return
|
|
602
|
+
}
|
|
603
|
+
case 'workletDone': {
|
|
604
|
+
if (this.pendingRunWorklets.delete(msg.runId)) {
|
|
605
|
+
this.statRunWorkletDone++
|
|
606
|
+
if (!msg.ok) this.statRunWorkletFailed++
|
|
607
|
+
}
|
|
608
|
+
return
|
|
609
|
+
}
|
|
610
|
+
case 'workletReply': {
|
|
611
|
+
const pending = this.pending.get(msg.runId)
|
|
612
|
+
if (!pending) return
|
|
613
|
+
this.pending.delete(msg.runId)
|
|
614
|
+
if (msg.ok) pending.resolve(msg.returnValueSpec)
|
|
615
|
+
else pending.reject(new Error(msg.error ?? 'worklet error'))
|
|
616
|
+
return
|
|
617
|
+
}
|
|
618
|
+
case 'runJSCallback': {
|
|
619
|
+
this.statRunJSCallbackReceived++
|
|
620
|
+
const fn = this.jsCallbacks.get(msg.callbackId)
|
|
621
|
+
if (!fn) {
|
|
622
|
+
this.statRunJSCallbackMissing++
|
|
623
|
+
console.warn('[worklet-runtime] runJSCallback: no fn for id', msg.callbackId)
|
|
624
|
+
return
|
|
625
|
+
}
|
|
626
|
+
try {
|
|
627
|
+
const args = msg.argsSpec.map((spec) => this.deserializeValue(spec))
|
|
628
|
+
fn.apply(null, args)
|
|
629
|
+
} catch (err) {
|
|
630
|
+
console.error('[worklet-runtime] runJSCallback threw:', err)
|
|
631
|
+
}
|
|
632
|
+
return
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Allocate a slot, write the initial value, and announce to the peer.
|
|
639
|
+
* Returns the slot id. Slots 0..3 are reserved (header).
|
|
640
|
+
*/
|
|
641
|
+
allocSharedValueSlot(initial: number, isBool = false): number {
|
|
642
|
+
const id = this.allocSlotId(false)
|
|
643
|
+
this.statPrimitiveAllocs++
|
|
644
|
+
this.floats[id] = initial
|
|
645
|
+
if (isBool) this.boolSlots.add(id)
|
|
646
|
+
if (this.channel.hasPeer()) {
|
|
647
|
+
this.channel.send({ type: 'svAlloc', svId: id, initial, bool: isBool || undefined })
|
|
648
|
+
}
|
|
649
|
+
return id
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Reuse a reclaimed slot id if one is available, else advance the matching
|
|
654
|
+
* counter. Primitive and object ids live in DISJOINT ranges with their own
|
|
655
|
+
* counters + free-lists (see OBJECT_ID_BASE): primitive ids index the shared
|
|
656
|
+
* Float64 array so they are capacity-bounded; object ids are JS-Map-backed so
|
|
657
|
+
* their range is effectively unbounded. A freed id is fully reset by
|
|
658
|
+
* releaseSlotState() before it returns to its pool.
|
|
659
|
+
*/
|
|
660
|
+
private allocSlotId(objectSlot: boolean): number {
|
|
661
|
+
if (objectSlot) {
|
|
662
|
+
const reused = this.freeObjectSlots.pop()
|
|
663
|
+
if (reused !== undefined) {
|
|
664
|
+
this.freedObjectSet.delete(reused)
|
|
665
|
+
this.statReusedAllocs++
|
|
666
|
+
return reused
|
|
667
|
+
}
|
|
668
|
+
if (this.nextObjectId >= OBJECT_ID_BASE + OBJECT_ID_CAPACITY) {
|
|
669
|
+
this.logExhaustion()
|
|
670
|
+
throw OBJECT_SLOT_EXHAUSTION_ERROR
|
|
671
|
+
}
|
|
672
|
+
const id = this.nextObjectId
|
|
673
|
+
this.nextObjectId += this.idStride
|
|
674
|
+
return id
|
|
675
|
+
}
|
|
676
|
+
const reused = this.freeSlots.pop()
|
|
677
|
+
if (reused !== undefined) {
|
|
678
|
+
this.freedSet.delete(reused)
|
|
679
|
+
this.statReusedAllocs++
|
|
680
|
+
return reused
|
|
681
|
+
}
|
|
682
|
+
if (this.nextId >= this.capacity) {
|
|
683
|
+
// exhausted. do NOT advance nextId further (bounds the counter under a
|
|
684
|
+
// storm). the caller catches and falls back to a local-only shareable so
|
|
685
|
+
// exhaustion degrades gracefully instead of taking the runtime down.
|
|
686
|
+
this.logExhaustion()
|
|
687
|
+
throw SAB_SLOT_EXHAUSTION_ERROR
|
|
688
|
+
}
|
|
689
|
+
const id = this.nextId
|
|
690
|
+
this.nextId += this.idStride
|
|
691
|
+
return id
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Throttled exhaustion logging. A re-render storm can hit the exhausted
|
|
696
|
+
* allocator hundreds of thousands of times in a burst; building an Error+stack
|
|
697
|
+
* and console.error-ing each time is itself enough to OOM/crash the runtime.
|
|
698
|
+
* Throttle to one log per window with a suppressed-count.
|
|
699
|
+
*/
|
|
700
|
+
private logExhaustion(): void {
|
|
701
|
+
this.exhaustionSuppressed++
|
|
702
|
+
const now = Date.now()
|
|
703
|
+
if (now - this.exhaustionLoggedAt > 2000) {
|
|
704
|
+
console.error(
|
|
705
|
+
`[worklet-runtime] slot exhaustion (+${this.exhaustionSuppressed} suppressed since last log)`,
|
|
706
|
+
this.getSlotStats(),
|
|
707
|
+
)
|
|
708
|
+
this.exhaustionLoggedAt = now
|
|
709
|
+
this.exhaustionSuppressed = 0
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/** true if `id` belongs to the disjoint object-slot id range. */
|
|
714
|
+
private isObjectId(id: number): boolean {
|
|
715
|
+
return id >= OBJECT_ID_BASE
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Clear every per-id structure for `id` and return the cell to zero. Shared by
|
|
720
|
+
* freeSlot() (owner side) and the inbound 'svFree' handler (peer side); both
|
|
721
|
+
* need the mirror dropped, only the owner recycles.
|
|
722
|
+
*/
|
|
723
|
+
private releaseSlotState(id: number): void {
|
|
724
|
+
this.listeners.delete(id)
|
|
725
|
+
this.objectListeners.delete(id)
|
|
726
|
+
this.objectSlots.delete(id)
|
|
727
|
+
this.svProxyCache.delete(id)
|
|
728
|
+
this.svObjectProxyCache.delete(id)
|
|
729
|
+
this.lastSlotListenerValue.delete(id)
|
|
730
|
+
this.pendingPeerUpdates.delete(id)
|
|
731
|
+
this.pendingObjectUpdates.delete(id)
|
|
732
|
+
this.boolSlots.delete(id)
|
|
733
|
+
// only primitive ids index the shared array; object ids are out of range.
|
|
734
|
+
if (!this.isObjectId(id)) this.floats[id] = 0
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* Reclaim a SharedValue slot whose JS wrapper was garbage-collected. Mirrors
|
|
739
|
+
* reanimated's native Shareable lifetime: once the JS Mutable is collected
|
|
740
|
+
* nothing can reference the value (live mappers / useAnimatedStyle deps keep the
|
|
741
|
+
* wrapper alive), so the slot is safe to recycle. Idempotent. Owner-only — a
|
|
742
|
+
* runtime only frees ids it allocates (even=react / odd=ui); the parity guard
|
|
743
|
+
* keeps a stray call from poisoning the peer's id space.
|
|
744
|
+
*/
|
|
745
|
+
freeSlot(id: number): void {
|
|
746
|
+
if (this.isObjectId(id)) {
|
|
747
|
+
if (id >= OBJECT_ID_BASE + OBJECT_ID_CAPACITY) return
|
|
748
|
+
// parity guard (even=react / odd=ui) — same role parity as nextId.
|
|
749
|
+
if (id % this.idStride !== this.nextId % this.idStride) return
|
|
750
|
+
if (this.freedObjectSet.has(id)) return
|
|
751
|
+
this.freedObjectSet.add(id)
|
|
752
|
+
this.statFrees++
|
|
753
|
+
this.releaseSlotState(id)
|
|
754
|
+
this.freeObjectSlots.push(id)
|
|
755
|
+
if (this.channel.hasPeer()) {
|
|
756
|
+
this.channel.send({ type: 'svFree', svId: id })
|
|
757
|
+
}
|
|
758
|
+
return
|
|
759
|
+
}
|
|
760
|
+
if (id < SAB_HEADER_FLOATS || id >= this.capacity) return
|
|
761
|
+
if (id % this.idStride !== this.nextId % this.idStride) return
|
|
762
|
+
if (this.freedSet.has(id)) return
|
|
763
|
+
this.freedSet.add(id)
|
|
764
|
+
this.statFrees++
|
|
765
|
+
this.releaseSlotState(id)
|
|
766
|
+
this.freeSlots.push(id)
|
|
767
|
+
if (this.channel.hasPeer()) {
|
|
768
|
+
this.channel.send({ type: 'svFree', svId: id })
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
readSlot(id: number): number {
|
|
773
|
+
return this.floats[id]
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* Write a value to a slot, fire local listeners, and notify the peer.
|
|
778
|
+
* skipNotify=true is used when a write came in via inbound svUpdate to avoid
|
|
779
|
+
* bouncing it back. peer notifications coalesce per microtask — see
|
|
780
|
+
* `enqueuePeerUpdate`.
|
|
781
|
+
*/
|
|
782
|
+
writeSlot(id: number, value: unknown, skipNotify = false): boolean {
|
|
783
|
+
const slotValue = coerceSlotValue(value)
|
|
784
|
+
if (this.floats[id] === slotValue) return false
|
|
785
|
+
this.floats[id] = slotValue
|
|
786
|
+
this.fireLocal(id, slotValue)
|
|
787
|
+
if (!skipNotify) this.enqueuePeerUpdate(id, slotValue)
|
|
788
|
+
return true
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* Used by createShareable's local-write echo: updates the cell and notifies the
|
|
793
|
+
* peer, but skips fireLocal. Required because the local SharedValue's listeners
|
|
794
|
+
* already fired the upstream fan-out — we don't want subscribeSlot listeners to
|
|
795
|
+
* fire again and recurse back into upstream's `_value` setter.
|
|
796
|
+
*/
|
|
797
|
+
syncToPeer(id: number, value: unknown): boolean {
|
|
798
|
+
const slotValue = coerceSlotValue(value)
|
|
799
|
+
if (this.floats[id] === slotValue) return false
|
|
800
|
+
this.floats[id] = slotValue
|
|
801
|
+
this.enqueuePeerUpdate(id, slotValue)
|
|
802
|
+
return true
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* Queue an svUpdate for the peer. Within a single JS turn, repeat writes to the
|
|
807
|
+
* same slot collapse to last-write-wins. Flush happens at microtask boundary as
|
|
808
|
+
* a single `svUpdateBatch` message.
|
|
809
|
+
*/
|
|
810
|
+
private enqueuePeerUpdate(id: number, value: number): void {
|
|
811
|
+
if (!this.channel.hasPeer()) return
|
|
812
|
+
this.statWriteRequests++
|
|
813
|
+
this.pendingPeerUpdates.set(id, value)
|
|
814
|
+
this.scheduleUnifiedFlush()
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* Single microtask schedules both primitive and object pending updates so a
|
|
819
|
+
* mixed-write turn ships at most one combined batch message per microtask (not
|
|
820
|
+
* two — one per kind). Both sides decode `svUpdateBatch` with optional
|
|
821
|
+
* `objectUpdates` for backward compat.
|
|
822
|
+
*/
|
|
823
|
+
private scheduleUnifiedFlush(): void {
|
|
824
|
+
if (this.peerFlushScheduled) return
|
|
825
|
+
this.peerFlushScheduled = true
|
|
826
|
+
queueMicrotask(() => this.flushPendingPeerUpdates())
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
private flushPendingPeerUpdates(): void {
|
|
830
|
+
this.peerFlushScheduled = false
|
|
831
|
+
const hasPrimitives = this.pendingPeerUpdates.size > 0
|
|
832
|
+
const hasObjects = this.pendingObjectUpdates.size > 0
|
|
833
|
+
if (!hasPrimitives && !hasObjects) return
|
|
834
|
+
if (!this.channel.hasPeer()) {
|
|
835
|
+
this.pendingPeerUpdates.clear()
|
|
836
|
+
this.pendingObjectUpdates.clear()
|
|
837
|
+
return
|
|
838
|
+
}
|
|
839
|
+
const updates: Array<{ svId: number; value: number }> = []
|
|
840
|
+
for (const [svId, value] of this.pendingPeerUpdates) {
|
|
841
|
+
updates.push({ svId, value })
|
|
842
|
+
}
|
|
843
|
+
this.pendingPeerUpdates.clear()
|
|
844
|
+
const objectUpdates: Array<{ svId: number; value: unknown }> = []
|
|
845
|
+
for (const [svId, value] of this.pendingObjectUpdates) {
|
|
846
|
+
objectUpdates.push({ svId, value: sanitizeForPeer(value) })
|
|
847
|
+
}
|
|
848
|
+
this.pendingObjectUpdates.clear()
|
|
849
|
+
this.statBatchesSent++
|
|
850
|
+
this.channel.send({
|
|
851
|
+
type: 'svUpdateBatch',
|
|
852
|
+
updates,
|
|
853
|
+
objectUpdates: objectUpdates.length > 0 ? objectUpdates : undefined,
|
|
854
|
+
})
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* Force-flush any pending svUpdate batch to the peer. Used by tests and callers
|
|
859
|
+
* that need a synchronous handoff. Normal callers should rely on the microtask
|
|
860
|
+
* flush.
|
|
861
|
+
*/
|
|
862
|
+
flushPeerUpdates(): void {
|
|
863
|
+
this.flushPendingPeerUpdates()
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// ---------------------------------------------------------------------
|
|
867
|
+
// object SVs — separate high id range from primitive SVs, with values stored in
|
|
868
|
+
// a per-runtime Map<id, unknown> rather than the shared Float64 array.
|
|
869
|
+
// propagation goes through structured snapshots in the channel with the same
|
|
870
|
+
// microtask-coalesced batching as primitives.
|
|
871
|
+
// ---------------------------------------------------------------------
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* Allocate an object SV slot, seed the local Map, and announce to the peer.
|
|
875
|
+
* Returns the slot id. Object SVs use the same react-even / ui-odd lane
|
|
876
|
+
* convention as primitive SVs, but in a disjoint high range so object churn
|
|
877
|
+
* never consumes primitive cells.
|
|
878
|
+
*/
|
|
879
|
+
allocObjectSharedValueSlot(initial: unknown): number {
|
|
880
|
+
const id = this.allocSlotId(true)
|
|
881
|
+
this.statObjectAllocs++
|
|
882
|
+
this.objectSlots.set(id, initial)
|
|
883
|
+
if (this.channel.hasPeer()) {
|
|
884
|
+
this.channel.send({
|
|
885
|
+
type: 'svObjectAlloc',
|
|
886
|
+
svId: id,
|
|
887
|
+
initial: sanitizeForPeer(initial),
|
|
888
|
+
})
|
|
889
|
+
}
|
|
890
|
+
return id
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
readObjectSlot(id: number): unknown {
|
|
894
|
+
return this.objectSlots.get(id)
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* Write an object value to a slot, fire local listeners, and queue a peer
|
|
899
|
+
* notification. skipNotify avoids bouncing inbound updates back. Identity
|
|
900
|
+
* comparison would be too expensive (deep equality); we always notify on write
|
|
901
|
+
* — callers that want dedup should compare before calling.
|
|
902
|
+
*/
|
|
903
|
+
writeObjectSlot(id: number, value: unknown, skipNotify = false): boolean {
|
|
904
|
+
this.objectSlots.set(id, value)
|
|
905
|
+
this.fireObjectLocal(id, value)
|
|
906
|
+
if (!skipNotify) this.enqueueObjectUpdate(id, value)
|
|
907
|
+
return true
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* Object-slot equivalent of syncToPeer: updates the local Map and notifies the
|
|
912
|
+
* peer, but skips fireObjectLocal. Used by createShareable to avoid recursing
|
|
913
|
+
* into upstream listener fan-out.
|
|
914
|
+
*/
|
|
915
|
+
syncObjectToPeer(id: number, value: unknown): boolean {
|
|
916
|
+
this.objectSlots.set(id, value)
|
|
917
|
+
this.enqueueObjectUpdate(id, value)
|
|
918
|
+
return true
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
subscribeObjectSlot(id: number, fn: (value: unknown) => void): () => void {
|
|
922
|
+
let set = this.objectListeners.get(id)
|
|
923
|
+
if (!set) {
|
|
924
|
+
set = new Set()
|
|
925
|
+
this.objectListeners.set(id, set)
|
|
926
|
+
}
|
|
927
|
+
set.add(fn)
|
|
928
|
+
return () => {
|
|
929
|
+
set?.delete(fn)
|
|
930
|
+
if (set?.size === 0) this.objectListeners.delete(id)
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
private fireObjectLocal(id: number, value: unknown) {
|
|
935
|
+
const set = this.objectListeners.get(id)
|
|
936
|
+
if (!set) return
|
|
937
|
+
for (const fn of set) {
|
|
938
|
+
try {
|
|
939
|
+
fn(value)
|
|
940
|
+
} catch (err) {
|
|
941
|
+
console.error('[worklet-runtime] object listener error', err)
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
private enqueueObjectUpdate(id: number, value: unknown): void {
|
|
947
|
+
if (!this.channel.hasPeer()) return
|
|
948
|
+
this.statWriteRequests++
|
|
949
|
+
this.pendingObjectUpdates.set(id, value)
|
|
950
|
+
this.scheduleUnifiedFlush()
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
/**
|
|
954
|
+
* Snapshot of the per-runtime svUpdate coalescing counters. Read by tests for
|
|
955
|
+
* empirical perf validation. ratio (writeRequests / batchesSent) is the average
|
|
956
|
+
* coalescing factor — higher is better.
|
|
957
|
+
*/
|
|
958
|
+
getPeerStats(reset = false): {
|
|
959
|
+
writeRequests: number
|
|
960
|
+
batchesSent: number
|
|
961
|
+
pendingRunWorklets: number
|
|
962
|
+
runWorkletsSent: number
|
|
963
|
+
runWorkletsDone: number
|
|
964
|
+
runWorkletsFailed: number
|
|
965
|
+
closureCacheHits: number
|
|
966
|
+
closureCacheMisses: number
|
|
967
|
+
} {
|
|
968
|
+
const out = {
|
|
969
|
+
writeRequests: this.statWriteRequests,
|
|
970
|
+
batchesSent: this.statBatchesSent,
|
|
971
|
+
pendingRunWorklets: this.pendingRunWorklets.size,
|
|
972
|
+
runWorkletsSent: this.statRunWorkletSent,
|
|
973
|
+
runWorkletsDone: this.statRunWorkletDone,
|
|
974
|
+
runWorkletsFailed: this.statRunWorkletFailed,
|
|
975
|
+
closureCacheHits: this.statClosureCacheHits,
|
|
976
|
+
closureCacheMisses: this.statClosureCacheMisses,
|
|
977
|
+
}
|
|
978
|
+
if (reset) {
|
|
979
|
+
this.statWriteRequests = 0
|
|
980
|
+
this.statBatchesSent = 0
|
|
981
|
+
this.statRunWorkletSent = 0
|
|
982
|
+
this.statRunWorkletDone = 0
|
|
983
|
+
this.statRunWorkletFailed = 0
|
|
984
|
+
this.statClosureCacheHits = 0
|
|
985
|
+
this.statClosureCacheMisses = 0
|
|
986
|
+
}
|
|
987
|
+
return out
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
getSlotStats(): {
|
|
991
|
+
role: WorkletRuntimeRole
|
|
992
|
+
capacity: number
|
|
993
|
+
nextId: number
|
|
994
|
+
freeSlots: number
|
|
995
|
+
freedSlots: number
|
|
996
|
+
nextObjectId: number
|
|
997
|
+
freeObjectSlots: number
|
|
998
|
+
freedObjectSlots: number
|
|
999
|
+
primitiveAllocs: number
|
|
1000
|
+
objectAllocs: number
|
|
1001
|
+
reusedAllocs: number
|
|
1002
|
+
frees: number
|
|
1003
|
+
listenerSlots: number
|
|
1004
|
+
objectListenerSlots: number
|
|
1005
|
+
objectSlots: number
|
|
1006
|
+
pendingPrimitiveUpdates: number
|
|
1007
|
+
pendingObjectUpdates: number
|
|
1008
|
+
runJSCallbackSent: number
|
|
1009
|
+
runJSCallbackReceived: number
|
|
1010
|
+
runJSCallbackMissing: number
|
|
1011
|
+
} {
|
|
1012
|
+
return {
|
|
1013
|
+
role: this.role,
|
|
1014
|
+
capacity: this.capacity,
|
|
1015
|
+
nextId: this.nextId,
|
|
1016
|
+
freeSlots: this.freeSlots.length,
|
|
1017
|
+
freedSlots: this.freedSet.size,
|
|
1018
|
+
nextObjectId: this.nextObjectId,
|
|
1019
|
+
freeObjectSlots: this.freeObjectSlots.length,
|
|
1020
|
+
freedObjectSlots: this.freedObjectSet.size,
|
|
1021
|
+
primitiveAllocs: this.statPrimitiveAllocs,
|
|
1022
|
+
objectAllocs: this.statObjectAllocs,
|
|
1023
|
+
reusedAllocs: this.statReusedAllocs,
|
|
1024
|
+
frees: this.statFrees,
|
|
1025
|
+
listenerSlots: this.listeners.size,
|
|
1026
|
+
objectListenerSlots: this.objectListeners.size,
|
|
1027
|
+
objectSlots: this.objectSlots.size,
|
|
1028
|
+
pendingPrimitiveUpdates: this.pendingPeerUpdates.size,
|
|
1029
|
+
pendingObjectUpdates: this.pendingObjectUpdates.size,
|
|
1030
|
+
runJSCallbackSent: this.statRunJSCallbackSent,
|
|
1031
|
+
runJSCallbackReceived: this.statRunJSCallbackReceived,
|
|
1032
|
+
runJSCallbackMissing: this.statRunJSCallbackMissing,
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
subscribeSlot(id: number, fn: (value: number) => void): () => void {
|
|
1037
|
+
let set = this.listeners.get(id)
|
|
1038
|
+
if (!set) {
|
|
1039
|
+
set = new Set()
|
|
1040
|
+
this.listeners.set(id, set)
|
|
1041
|
+
}
|
|
1042
|
+
set.add(fn)
|
|
1043
|
+
return () => {
|
|
1044
|
+
set?.delete(fn)
|
|
1045
|
+
if (set?.size === 0) this.listeners.delete(id)
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
private fireLocal(id: number, value: number) {
|
|
1050
|
+
this.lastSlotListenerValue.set(id, value)
|
|
1051
|
+
const set = this.listeners.get(id)
|
|
1052
|
+
if (!set) return
|
|
1053
|
+
for (const fn of set) {
|
|
1054
|
+
try {
|
|
1055
|
+
fn(value)
|
|
1056
|
+
} catch (err) {
|
|
1057
|
+
console.error('[worklet-runtime] listener error', err)
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
private fireLocalIfChanged(id: number, value: number) {
|
|
1063
|
+
if (Object.is(this.lastSlotListenerValue.get(id), value)) return
|
|
1064
|
+
this.fireLocal(id, value)
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
/**
|
|
1068
|
+
* Ship a worklet to the peer for execution. The fn must have been processed by
|
|
1069
|
+
* react-native-worklets/plugin so it carries __initData, __closure,
|
|
1070
|
+
* __workletHash. Returns a Promise that resolves with the worklet's return value
|
|
1071
|
+
* (when awaitReply is true) or undefined for fire-and-forget.
|
|
1072
|
+
*/
|
|
1073
|
+
dispatchWorklet(
|
|
1074
|
+
fn: WorkletFn,
|
|
1075
|
+
args: unknown[],
|
|
1076
|
+
opts: WorkletDispatchOptions = {},
|
|
1077
|
+
): Promise<unknown> {
|
|
1078
|
+
if (!this.channel.hasPeer()) {
|
|
1079
|
+
// no peer wired yet — degrade to local execution. matches the identity
|
|
1080
|
+
// passthrough behavior on cold-boot before the host bridge is present.
|
|
1081
|
+
return Promise.resolve(
|
|
1082
|
+
runWithEventFrameTimestamp(opts.eventTimestamp, () => fn.apply(fn, args)),
|
|
1083
|
+
)
|
|
1084
|
+
}
|
|
1085
|
+
const spec = this.serializeWorkletFunctionForDispatch(fn)
|
|
1086
|
+
const { code, hash, closureId, closureSpec } = spec
|
|
1087
|
+
const argsSpec = args.map((a) => this.serializeValueForDispatch(a))
|
|
1088
|
+
const awaitReply = !!opts.awaitReply
|
|
1089
|
+
const runId = this.runIdCounter++
|
|
1090
|
+
|
|
1091
|
+
const promise = awaitReply
|
|
1092
|
+
? new Promise<unknown>((resolve, reject) => {
|
|
1093
|
+
this.pending.set(runId, {
|
|
1094
|
+
resolve: (spec) =>
|
|
1095
|
+
resolve(spec === undefined ? undefined : this.deserializeValue(spec)),
|
|
1096
|
+
reject,
|
|
1097
|
+
})
|
|
1098
|
+
})
|
|
1099
|
+
: Promise.resolve(undefined)
|
|
1100
|
+
if (!awaitReply) {
|
|
1101
|
+
this.pendingRunWorklets.add(runId)
|
|
1102
|
+
}
|
|
1103
|
+
this.statRunWorkletSent++
|
|
1104
|
+
|
|
1105
|
+
const message: Extract<WorkletMessage, { type: 'runWorklet' }> = {
|
|
1106
|
+
type: 'runWorklet',
|
|
1107
|
+
runId,
|
|
1108
|
+
code,
|
|
1109
|
+
hash,
|
|
1110
|
+
closureId,
|
|
1111
|
+
closureSpec,
|
|
1112
|
+
argsSpec,
|
|
1113
|
+
awaitReply,
|
|
1114
|
+
}
|
|
1115
|
+
if (typeof opts.eventTimestamp === 'number') {
|
|
1116
|
+
message.eventTimestamp = opts.eventTimestamp
|
|
1117
|
+
}
|
|
1118
|
+
this.channel.send(message)
|
|
1119
|
+
|
|
1120
|
+
return promise
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
/** returns true if a SharedValue with this id has a slot in the shared array */
|
|
1124
|
+
hasSlot(id: number): boolean {
|
|
1125
|
+
return id >= SAB_HEADER_FLOATS && id < this.capacity
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
/**
|
|
1129
|
+
* Register a React-local callback so it can be invoked by UI-side worklets via
|
|
1130
|
+
* runOnJS. Returns a wrapper function that:
|
|
1131
|
+
* - when called locally on react, just runs `fn` (so existing call sites that
|
|
1132
|
+
* don't ship to ui still work)
|
|
1133
|
+
* - when serialized via serializeValue, becomes a kind:'jsCallback' spec
|
|
1134
|
+
* carrying the registration id
|
|
1135
|
+
*
|
|
1136
|
+
* Memoizes per source fn so the same `runOnJS(fn)` call returns a stable wrapper
|
|
1137
|
+
* across renders, keeping the id space small.
|
|
1138
|
+
*/
|
|
1139
|
+
registerJSCallback<T extends (...args: unknown[]) => unknown>(fn: T): T {
|
|
1140
|
+
const existing = this.jsCallbackByFn.get(fn as unknown as object)
|
|
1141
|
+
if (existing) return existing.wrapper as unknown as T
|
|
1142
|
+
const id = this.nextJsCallbackId++
|
|
1143
|
+
this.jsCallbacks.set(id, fn as (...args: unknown[]) => unknown)
|
|
1144
|
+
const wrapper = ((...args: unknown[]) => fn.apply(null, args)) as T & {
|
|
1145
|
+
__rngpui_jsCallback?: number
|
|
1146
|
+
}
|
|
1147
|
+
wrapper.__rngpui_jsCallback = id
|
|
1148
|
+
this.jsCallbackByFn.set(fn as unknown as object, {
|
|
1149
|
+
id,
|
|
1150
|
+
wrapper: wrapper as unknown as (...args: unknown[]) => unknown,
|
|
1151
|
+
})
|
|
1152
|
+
return wrapper as unknown as T
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
private serializeValueForDispatch(value: unknown, path = '$'): ValueSpec {
|
|
1156
|
+
return serializeValueInternal(
|
|
1157
|
+
value,
|
|
1158
|
+
{
|
|
1159
|
+
registerPlainFunction: (fn) => this.serializeRegisteredJSCallback(fn),
|
|
1160
|
+
serializeWorkletFunction: (fn) => this.serializeWorkletFunctionForDispatch(fn),
|
|
1161
|
+
serializeAnimatedRef: (ref) => this.serializeAnimatedRefForDispatch(ref),
|
|
1162
|
+
},
|
|
1163
|
+
undefined,
|
|
1164
|
+
0,
|
|
1165
|
+
path,
|
|
1166
|
+
)
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
private serializeClosureForDispatch(
|
|
1170
|
+
closure: Record<string, unknown>,
|
|
1171
|
+
owner?: string,
|
|
1172
|
+
): ClosureSpec {
|
|
1173
|
+
const out: ClosureSpec = {}
|
|
1174
|
+
for (const key of Object.keys(closure)) {
|
|
1175
|
+
try {
|
|
1176
|
+
out[key] = this.serializeValueForDispatch(closure[key], `$.${key}`)
|
|
1177
|
+
} catch (err) {
|
|
1178
|
+
const suffix = owner ? ` while serializing ${owner} closure key "${key}"` : ''
|
|
1179
|
+
throw new Error(`${(err as Error).message}${suffix}`, { cause: err })
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
return out
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
private serializeWorkletFunctionForDispatch(fn: WorkletFn): WorkletFunctionSpec {
|
|
1186
|
+
const code = fn.__initData?.code
|
|
1187
|
+
const hash = fn.__workletHash
|
|
1188
|
+
if (!code || typeof hash !== 'number') {
|
|
1189
|
+
throw new Error(
|
|
1190
|
+
'worklet-runtime: function is not a worklet (missing __initData/__workletHash). ' +
|
|
1191
|
+
'did the babel reanimated/worklets plugin run on this file?',
|
|
1192
|
+
)
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
const closure = fn.__closure ?? {}
|
|
1196
|
+
const key = fn as unknown as object
|
|
1197
|
+
const cached = this.serializedWorklets.get(key)
|
|
1198
|
+
if (
|
|
1199
|
+
cached &&
|
|
1200
|
+
cached.code === code &&
|
|
1201
|
+
cached.hash === hash &&
|
|
1202
|
+
cached.closure === closure
|
|
1203
|
+
) {
|
|
1204
|
+
return cached.spec
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
const spec: WorkletFunctionSpec = {
|
|
1208
|
+
kind: 'fn',
|
|
1209
|
+
code,
|
|
1210
|
+
hash,
|
|
1211
|
+
closureId: this.nextSerializedClosureId++,
|
|
1212
|
+
closureSpec: this.serializeClosureForDispatch(closure, describeWorklet(fn)),
|
|
1213
|
+
}
|
|
1214
|
+
this.serializedWorklets.set(key, { code, hash, closure, spec })
|
|
1215
|
+
return spec
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
private serializeRegisteredJSCallback(
|
|
1219
|
+
fn: (...args: unknown[]) => unknown,
|
|
1220
|
+
): Extract<ValueSpec, { kind: 'jsCallback' }> {
|
|
1221
|
+
const wrapped = this.registerJSCallback(fn) as ((...args: unknown[]) => unknown) & {
|
|
1222
|
+
__rngpui_jsCallback?: number
|
|
1223
|
+
}
|
|
1224
|
+
const id = wrapped.__rngpui_jsCallback
|
|
1225
|
+
if (typeof id !== 'number') {
|
|
1226
|
+
throw new Error('worklet-runtime: failed to register js callback')
|
|
1227
|
+
}
|
|
1228
|
+
return { kind: 'jsCallback', id }
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
/**
|
|
1232
|
+
* Send a `runJSCallback` to the peer. UI-side bindRemoteJSCallback uses this
|
|
1233
|
+
* when a deserialized callback wrapper is invoked.
|
|
1234
|
+
*/
|
|
1235
|
+
postJSCallback(id: number, args: unknown[]): void {
|
|
1236
|
+
if (!this.channel.hasPeer()) {
|
|
1237
|
+
console.warn('[worklet-runtime] postJSCallback with no peer')
|
|
1238
|
+
return
|
|
1239
|
+
}
|
|
1240
|
+
this.statRunJSCallbackSent++
|
|
1241
|
+
this.channel.send({
|
|
1242
|
+
type: 'runJSCallback',
|
|
1243
|
+
callbackId: id,
|
|
1244
|
+
argsSpec: args.map((a) => serializeValue(a)),
|
|
1245
|
+
})
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
private handleRunWorklet(msg: Extract<WorkletMessage, { type: 'runWorklet' }>) {
|
|
1249
|
+
let fn = this.workletCache.get(msg.hash)
|
|
1250
|
+
if (!fn) {
|
|
1251
|
+
try {
|
|
1252
|
+
fn = evalWorkletCode(msg.code)
|
|
1253
|
+
this.workletCache.set(msg.hash, fn)
|
|
1254
|
+
} catch (err) {
|
|
1255
|
+
if (msg.awaitReply) {
|
|
1256
|
+
this.channel.send({
|
|
1257
|
+
type: 'workletReply',
|
|
1258
|
+
runId: msg.runId,
|
|
1259
|
+
ok: false,
|
|
1260
|
+
error: `eval failed: ${(err as Error).message}`,
|
|
1261
|
+
})
|
|
1262
|
+
} else {
|
|
1263
|
+
console.error('[worklet-runtime] eval failed', err)
|
|
1264
|
+
this.channel.send({
|
|
1265
|
+
type: 'workletDone',
|
|
1266
|
+
runId: msg.runId,
|
|
1267
|
+
ok: false,
|
|
1268
|
+
error: `eval failed: ${(err as Error).message}`,
|
|
1269
|
+
})
|
|
1270
|
+
}
|
|
1271
|
+
return
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
let result: unknown
|
|
1275
|
+
try {
|
|
1276
|
+
const closure = this.getMaterializedClosure(msg.hash, msg.closureId, msg.closureSpec)
|
|
1277
|
+
const args = msg.argsSpec.map((spec) => this.deserializeValue(spec))
|
|
1278
|
+
// attach closure as expected by the babel transform: the fn body reads
|
|
1279
|
+
// `this.__closure.foo` for any captured variable, so attach the closure and
|
|
1280
|
+
// call with fn as `this`. the evaled fn is SHARED per workletHash, so this
|
|
1281
|
+
// attach must happen on EVERY dispatch — two different closures of the same
|
|
1282
|
+
// worklet would otherwise read each other's state.
|
|
1283
|
+
;(fn as WorkletFn).__closure = closure as Record<string, unknown>
|
|
1284
|
+
// recursive worklets carry `const NAME = this._recur` at the top of their
|
|
1285
|
+
// body (babel-plugin-worklets emits it whenever the function
|
|
1286
|
+
// self-references). bind `_recur` to fn-with-fn-as-this so that when the body
|
|
1287
|
+
// passes `_recur` as a callback to Array.some / map / etc., the inner
|
|
1288
|
+
// invocation still has `this === fn` and the closure access keeps working.
|
|
1289
|
+
;(fn as WorkletFn & { _recur?: unknown })._recur = fn.bind(fn)
|
|
1290
|
+
result = runWithEventFrameTimestamp(msg.eventTimestamp, () =>
|
|
1291
|
+
runWithWorkletFlag(() => fn.apply(fn, args)),
|
|
1292
|
+
)
|
|
1293
|
+
} catch (err) {
|
|
1294
|
+
if (msg.awaitReply) {
|
|
1295
|
+
this.channel.send({
|
|
1296
|
+
type: 'workletReply',
|
|
1297
|
+
runId: msg.runId,
|
|
1298
|
+
ok: false,
|
|
1299
|
+
error: `worklet threw: ${(err as Error).message}`,
|
|
1300
|
+
})
|
|
1301
|
+
} else {
|
|
1302
|
+
console.error('[worklet-runtime] worklet threw', err)
|
|
1303
|
+
this.channel.send({
|
|
1304
|
+
type: 'workletDone',
|
|
1305
|
+
runId: msg.runId,
|
|
1306
|
+
ok: false,
|
|
1307
|
+
error: `worklet threw: ${(err as Error).message}`,
|
|
1308
|
+
})
|
|
1309
|
+
}
|
|
1310
|
+
return
|
|
1311
|
+
}
|
|
1312
|
+
if (msg.awaitReply) {
|
|
1313
|
+
this.channel.send({
|
|
1314
|
+
type: 'workletReply',
|
|
1315
|
+
runId: msg.runId,
|
|
1316
|
+
ok: true,
|
|
1317
|
+
returnValueSpec: serializeValue(result),
|
|
1318
|
+
})
|
|
1319
|
+
} else {
|
|
1320
|
+
this.channel.send({ type: 'workletDone', runId: msg.runId, ok: true })
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
/**
|
|
1325
|
+
* Re-hydrate a closure spec into an object the worklet body can read via
|
|
1326
|
+
* `this.__closure`. SV refs become live SharedValue proxies bound to this
|
|
1327
|
+
* runtime's slot view; nested fn specs become callable worklets that themselves
|
|
1328
|
+
* dispatch back to the peer.
|
|
1329
|
+
*/
|
|
1330
|
+
deserializeClosure(spec: ClosureSpec): Record<string, unknown> {
|
|
1331
|
+
const out: Record<string, unknown> = {}
|
|
1332
|
+
for (const key of Object.keys(spec)) {
|
|
1333
|
+
out[key] = this.deserializeValue(spec[key])
|
|
1334
|
+
}
|
|
1335
|
+
return out
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
private getMaterializedClosure(
|
|
1339
|
+
hash: number,
|
|
1340
|
+
closureId: number,
|
|
1341
|
+
spec: ClosureSpec,
|
|
1342
|
+
): Record<string, unknown> {
|
|
1343
|
+
if (closureId > 0) {
|
|
1344
|
+
const cacheKey = getMaterializedClosureCacheKey(hash, closureId)
|
|
1345
|
+
const cached = this.materializedClosureCache.get(cacheKey)
|
|
1346
|
+
if (cached) {
|
|
1347
|
+
this.statClosureCacheHits++
|
|
1348
|
+
return cached
|
|
1349
|
+
}
|
|
1350
|
+
this.statClosureCacheMisses++
|
|
1351
|
+
const closure = this.deserializeClosure(spec)
|
|
1352
|
+
this.materializedClosureCache.set(cacheKey, closure)
|
|
1353
|
+
return closure
|
|
1354
|
+
}
|
|
1355
|
+
this.statClosureCacheMisses++
|
|
1356
|
+
return this.deserializeClosure(spec)
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
deserializeValue(spec: ValueSpec): unknown {
|
|
1360
|
+
switch (spec.kind) {
|
|
1361
|
+
case 'sv':
|
|
1362
|
+
return this.bindSharedValueProxy(spec.id)
|
|
1363
|
+
case 'svObject':
|
|
1364
|
+
return this.bindObjectSharedValueProxy(spec.id)
|
|
1365
|
+
case 'svDetached':
|
|
1366
|
+
return bindDetachedObjectSharedValue(spec.value)
|
|
1367
|
+
case 'animation': {
|
|
1368
|
+
const factories = (
|
|
1369
|
+
globalThis as {
|
|
1370
|
+
__rngpuiReanimatedAnimationFactories?: Record<string, (...args: unknown[]) => unknown>
|
|
1371
|
+
}
|
|
1372
|
+
).__rngpuiReanimatedAnimationFactories
|
|
1373
|
+
const factory = factories?.[spec.type]
|
|
1374
|
+
if (typeof factory !== 'function') {
|
|
1375
|
+
throw new Error(
|
|
1376
|
+
`[worklet-runtime] no animation factory '${spec.type}' on this runtime (ui-entry registers them)`,
|
|
1377
|
+
)
|
|
1378
|
+
}
|
|
1379
|
+
return factory(...spec.args.map((arg) => this.deserializeValue(arg)))
|
|
1380
|
+
}
|
|
1381
|
+
case 'plain':
|
|
1382
|
+
return spec.value
|
|
1383
|
+
case 'builtin':
|
|
1384
|
+
return this.bindBuiltin(spec.name)
|
|
1385
|
+
case 'fn':
|
|
1386
|
+
return this.bindRemoteWorklet(spec)
|
|
1387
|
+
case 'initializer': {
|
|
1388
|
+
const init = this.bindRemoteWorklet(spec.init)
|
|
1389
|
+
return init()
|
|
1390
|
+
}
|
|
1391
|
+
case 'animatedRef':
|
|
1392
|
+
return makeRemoteAnimatedRef(spec.viewTag)
|
|
1393
|
+
case 'animatedRefSlot':
|
|
1394
|
+
return this.bindRemoteAnimatedRefSlot(spec.slotId)
|
|
1395
|
+
case 'jsCallback':
|
|
1396
|
+
return this.bindRemoteJSCallback(spec.id)
|
|
1397
|
+
case 'undef':
|
|
1398
|
+
return undefined
|
|
1399
|
+
case 'array':
|
|
1400
|
+
return spec.items.map((item) => this.deserializeValue(item))
|
|
1401
|
+
case 'object': {
|
|
1402
|
+
const out: Record<string, unknown> = {}
|
|
1403
|
+
for (const [key, value] of spec.entries) {
|
|
1404
|
+
out[key] = this.deserializeValue(value)
|
|
1405
|
+
}
|
|
1406
|
+
return out
|
|
1407
|
+
}
|
|
1408
|
+
case 'shareableRef': {
|
|
1409
|
+
// upstream serializableMappingCache contract: the SAME shareable resolves
|
|
1410
|
+
// to the SAME peer-side object on every (re-)ship, so the mapper's
|
|
1411
|
+
// accumulated state survives stopMapper/startMapper.
|
|
1412
|
+
const existing = this.shareableCache.get(spec.id)
|
|
1413
|
+
if (existing) return existing
|
|
1414
|
+
const out: Record<string, unknown> = {}
|
|
1415
|
+
for (const [key, value] of spec.snapshot) {
|
|
1416
|
+
out[key] = this.deserializeValue(value)
|
|
1417
|
+
}
|
|
1418
|
+
this.shareableCache.set(spec.id, out)
|
|
1419
|
+
return out
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
/**
|
|
1425
|
+
* Bind a peer-side js-callback id to a local function that ships args back
|
|
1426
|
+
* across the channel. Used on the UI runtime for runOnJS-tagged callbacks
|
|
1427
|
+
* captured in the closure.
|
|
1428
|
+
*/
|
|
1429
|
+
private bindRemoteJSCallback(id: number): (...args: unknown[]) => void {
|
|
1430
|
+
return (...args: unknown[]) => {
|
|
1431
|
+
this.postJSCallback(id, args)
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
private bindBuiltin(name: string): unknown {
|
|
1436
|
+
const registered = getWorkletBuiltinRegistry().get(name)
|
|
1437
|
+
if (registered) return registered
|
|
1438
|
+
|
|
1439
|
+
if (name === 'runOnJS') {
|
|
1440
|
+
return <T extends (...args: unknown[]) => unknown>(fn: T): T => fn
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
if (name === 'runOnUI' || name === 'scheduleOnUI') {
|
|
1444
|
+
return <T extends (...args: unknown[]) => unknown>(fn: T, ...bound: unknown[]) =>
|
|
1445
|
+
((...args: unknown[]) => {
|
|
1446
|
+
if (typeof fn !== 'function') return undefined
|
|
1447
|
+
return fn.apply(fn, [...bound, ...args])
|
|
1448
|
+
}) as T
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
if (name === 'scheduleOnRN') {
|
|
1452
|
+
return <T extends (...args: unknown[]) => unknown>(fn: T, ...args: unknown[]) => {
|
|
1453
|
+
queueMicrotask(() => {
|
|
1454
|
+
if (typeof fn === 'function') fn(...args)
|
|
1455
|
+
})
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
if (
|
|
1460
|
+
name === 'withTiming' ||
|
|
1461
|
+
name === 'withSpring' ||
|
|
1462
|
+
name === 'withDecay' ||
|
|
1463
|
+
name === 'withDelay' ||
|
|
1464
|
+
name === 'withRepeat' ||
|
|
1465
|
+
name === 'withSequence' ||
|
|
1466
|
+
name === 'withClamp'
|
|
1467
|
+
) {
|
|
1468
|
+
return getReanimatedAnimationFactory(name)
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
return undefined
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
/**
|
|
1475
|
+
* A SharedValue proxy on the receiving side: reads/writes route through the slot,
|
|
1476
|
+
* and sets fan out to listeners. the proxy intentionally matches the same minimal
|
|
1477
|
+
* shape the worklet body expects (.value getter/setter, .get(), .set()) so
|
|
1478
|
+
* worklets authored against the real SharedValue API run unmodified here.
|
|
1479
|
+
*/
|
|
1480
|
+
private bindSharedValueProxy(id: number): SharedValueProxy {
|
|
1481
|
+
const cached = this.svProxyCache.get(id)
|
|
1482
|
+
if (cached) return cached
|
|
1483
|
+
// a boolean shared value is stored Float64 0/1; restore the boolean type on
|
|
1484
|
+
// read so a worklet sees `false`/`true` (real reanimated semantics), not
|
|
1485
|
+
// `0`/`1`. captured once — proxies are cached per slot id.
|
|
1486
|
+
const isBool = this.boolSlots.has(id)
|
|
1487
|
+
const readSlot = (): number | boolean => {
|
|
1488
|
+
const raw = this.readSlot(id)
|
|
1489
|
+
return isBool ? raw !== 0 : raw
|
|
1490
|
+
}
|
|
1491
|
+
const writeSlot = (v: number | boolean) => {
|
|
1492
|
+
// this proxy represents a shared value captured by a worklet running on the
|
|
1493
|
+
// peer runtime. primitive slots live in the shared array, so writing here
|
|
1494
|
+
// already makes the new value visible to the owning runtime. real reanimated
|
|
1495
|
+
// does not bounce every UI-thread animation tick back into the JS React
|
|
1496
|
+
// tree; doing so makes UI-side timing loops produce React commits on every
|
|
1497
|
+
// frame. keep the local UI-side mapper fan-out, but skip the redundant peer
|
|
1498
|
+
// notification.
|
|
1499
|
+
this.writeSlot(id, v, true)
|
|
1500
|
+
}
|
|
1501
|
+
const subscribe = (fn: (value: number) => void) => this.subscribeSlot(id, fn)
|
|
1502
|
+
// upstream's mapper system addListener(mapperId, fn) and
|
|
1503
|
+
// removeListener(mapperId) when the mapper stops. upstream replaces an existing
|
|
1504
|
+
// listener for the same id on re-add. mirror that here so mappers don't leak
|
|
1505
|
+
// subscriptions across remounts.
|
|
1506
|
+
const unsubscribers = new Map<number, () => void>()
|
|
1507
|
+
const proxy: SharedValueProxy = {
|
|
1508
|
+
_id: id,
|
|
1509
|
+
// upstream's `isSharedValue` (and downstream `extractInputs` in mappers.ts)
|
|
1510
|
+
// gates on this exact prop. without it, the proxy looks like a plain object
|
|
1511
|
+
// and the mapper-input traversal walks into `Object.getPrototypeOf` of itself
|
|
1512
|
+
// and crashes on the next recursion layer.
|
|
1513
|
+
_isReanimatedSharedValue: true,
|
|
1514
|
+
get value() {
|
|
1515
|
+
return readSlot()
|
|
1516
|
+
},
|
|
1517
|
+
set value(v: number | boolean) {
|
|
1518
|
+
setSharedValueThroughReanimated(proxy, v, writeSlot)
|
|
1519
|
+
},
|
|
1520
|
+
get(): number | boolean {
|
|
1521
|
+
return readSlot()
|
|
1522
|
+
},
|
|
1523
|
+
set(v: number | boolean | (() => number | boolean)): void {
|
|
1524
|
+
const resolved = typeof v === 'function' ? (v as () => number | boolean)() : v
|
|
1525
|
+
setSharedValueThroughReanimated(proxy, resolved, writeSlot)
|
|
1526
|
+
},
|
|
1527
|
+
modify(
|
|
1528
|
+
modifier: (value: number | boolean) => number | boolean,
|
|
1529
|
+
forceUpdate = true,
|
|
1530
|
+
): void {
|
|
1531
|
+
const next = modifier !== undefined ? modifier(readSlot()) : readSlot()
|
|
1532
|
+
setSharedValueThroughReanimated(proxy, next, writeSlot, forceUpdate)
|
|
1533
|
+
},
|
|
1534
|
+
addListener(listenerId: number, fn: (value: number) => void): void {
|
|
1535
|
+
const prev = unsubscribers.get(listenerId)
|
|
1536
|
+
if (prev) prev()
|
|
1537
|
+
unsubscribers.set(listenerId, subscribe(fn))
|
|
1538
|
+
},
|
|
1539
|
+
removeListener(listenerId: number): void {
|
|
1540
|
+
const off = unsubscribers.get(listenerId)
|
|
1541
|
+
if (off) {
|
|
1542
|
+
off()
|
|
1543
|
+
unsubscribers.delete(listenerId)
|
|
1544
|
+
}
|
|
1545
|
+
},
|
|
1546
|
+
get _value() {
|
|
1547
|
+
return readSlot()
|
|
1548
|
+
},
|
|
1549
|
+
set _value(v: number | boolean) {
|
|
1550
|
+
writeSlot(v)
|
|
1551
|
+
},
|
|
1552
|
+
_animation: null,
|
|
1553
|
+
setDirty() {},
|
|
1554
|
+
} as SharedValueProxy
|
|
1555
|
+
this.svProxyCache.set(id, proxy)
|
|
1556
|
+
return proxy
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
/**
|
|
1560
|
+
* Object-SV equivalent of bindSharedValueProxy. Reads return the local Map entry;
|
|
1561
|
+
* writes update local + queue a peer notification. Identity isn't preserved across
|
|
1562
|
+
* runtimes (the value is structured-cloned across the channel), but each side's Map
|
|
1563
|
+
* holds the latest snapshot so .value always returns the most recent post-flush
|
|
1564
|
+
* state.
|
|
1565
|
+
*/
|
|
1566
|
+
private bindObjectSharedValueProxy(id: number): ObjectSharedValueProxy {
|
|
1567
|
+
const cached = this.svObjectProxyCache.get(id)
|
|
1568
|
+
if (cached) return cached
|
|
1569
|
+
const readSlot = () => this.readObjectSlot(id)
|
|
1570
|
+
const writeSlot = (v: unknown) => {
|
|
1571
|
+
this.writeObjectSlot(id, v)
|
|
1572
|
+
}
|
|
1573
|
+
const subscribe = (fn: (value: unknown) => void) => this.subscribeObjectSlot(id, fn)
|
|
1574
|
+
const unsubscribers = new Map<number, () => void>()
|
|
1575
|
+
const proxy: ObjectSharedValueProxy = {
|
|
1576
|
+
_id: id,
|
|
1577
|
+
_isReanimatedSharedValue: true,
|
|
1578
|
+
get value() {
|
|
1579
|
+
return readSlot()
|
|
1580
|
+
},
|
|
1581
|
+
set value(v: unknown) {
|
|
1582
|
+
if (Object.is(readSlot(), v)) return
|
|
1583
|
+
writeSlot(v)
|
|
1584
|
+
},
|
|
1585
|
+
get(): unknown {
|
|
1586
|
+
return readSlot()
|
|
1587
|
+
},
|
|
1588
|
+
set(v: unknown): void {
|
|
1589
|
+
const current = readSlot()
|
|
1590
|
+
const resolved =
|
|
1591
|
+
typeof v === 'function' ? (v as (c: unknown) => unknown)(current) : v
|
|
1592
|
+
if (Object.is(current, resolved)) return
|
|
1593
|
+
writeSlot(resolved)
|
|
1594
|
+
},
|
|
1595
|
+
modify(modifier: (value: unknown) => unknown, forceUpdate = true): void {
|
|
1596
|
+
const current = readSlot()
|
|
1597
|
+
const next = modifier !== undefined ? modifier(current) : current
|
|
1598
|
+
if (!forceUpdate && Object.is(current, next)) return
|
|
1599
|
+
writeSlot(next)
|
|
1600
|
+
},
|
|
1601
|
+
addListener(listenerId: number, fn: (value: unknown) => void): void {
|
|
1602
|
+
const prev = unsubscribers.get(listenerId)
|
|
1603
|
+
if (prev) prev()
|
|
1604
|
+
unsubscribers.set(listenerId, subscribe(fn))
|
|
1605
|
+
},
|
|
1606
|
+
removeListener(listenerId: number): void {
|
|
1607
|
+
const off = unsubscribers.get(listenerId)
|
|
1608
|
+
if (off) {
|
|
1609
|
+
off()
|
|
1610
|
+
unsubscribers.delete(listenerId)
|
|
1611
|
+
}
|
|
1612
|
+
},
|
|
1613
|
+
}
|
|
1614
|
+
markObjectSV(proxy)
|
|
1615
|
+
this.svObjectProxyCache.set(id, proxy)
|
|
1616
|
+
return proxy
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
private bindRemoteAnimatedRefSlot(slotId: number): AnimatedRefLike {
|
|
1620
|
+
const readTag = () => {
|
|
1621
|
+
const tag = this.readSlot(slotId)
|
|
1622
|
+
return isValidViewTag(tag) ? tag : -1
|
|
1623
|
+
}
|
|
1624
|
+
const wrapper = () => {
|
|
1625
|
+
const tag = readTag()
|
|
1626
|
+
return isValidViewTag(tag) ? { __viewTag: tag } : -1
|
|
1627
|
+
}
|
|
1628
|
+
const ref = wrapper as AnimatedRefLike
|
|
1629
|
+
Object.defineProperty(ref, 'current', {
|
|
1630
|
+
get: () => wrapper(),
|
|
1631
|
+
configurable: true,
|
|
1632
|
+
})
|
|
1633
|
+
ref.getTag = readTag
|
|
1634
|
+
ref.observe = (observer) => {
|
|
1635
|
+
let cleanup = observer(isValidViewTag(readTag()) ? readTag() : null)
|
|
1636
|
+
const unsubscribe = this.subscribeSlot(slotId, (value) => {
|
|
1637
|
+
if (typeof cleanup === 'function') cleanup()
|
|
1638
|
+
cleanup = observer(isValidViewTag(value) ? value : null)
|
|
1639
|
+
})
|
|
1640
|
+
return () => {
|
|
1641
|
+
unsubscribe()
|
|
1642
|
+
if (typeof cleanup === 'function') cleanup()
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
return ref
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
private serializeAnimatedRefForDispatch(value: unknown): ValueSpec | null {
|
|
1649
|
+
if (!isAnimatedRefLike(value)) return null
|
|
1650
|
+
if (typeof value.observe !== 'function') {
|
|
1651
|
+
const viewTag = getAnimatedRefViewTag(value)
|
|
1652
|
+
return viewTag === null ? null : { kind: 'animatedRef', viewTag }
|
|
1653
|
+
}
|
|
1654
|
+
const key = value as unknown as object
|
|
1655
|
+
const existing = this.animatedRefSlots.get(key)
|
|
1656
|
+
if (existing) return { kind: 'animatedRefSlot', slotId: existing.slotId }
|
|
1657
|
+
|
|
1658
|
+
const initialTag = getAnimatedRefViewTag(value) ?? -1
|
|
1659
|
+
const slotId = this.allocSharedValueSlot(initialTag)
|
|
1660
|
+
const entry: { slotId: number; unsubscribe?: () => void } = { slotId }
|
|
1661
|
+
this.animatedRefSlots.set(key, entry)
|
|
1662
|
+
try {
|
|
1663
|
+
const unsubscribe = value.observe((next) => {
|
|
1664
|
+
const tag = getSerializableViewTag(next) ?? (isValidViewTag(next) ? next : -1)
|
|
1665
|
+
this.writeSlot(slotId, tag)
|
|
1666
|
+
return undefined
|
|
1667
|
+
})
|
|
1668
|
+
if (typeof unsubscribe === 'function') {
|
|
1669
|
+
entry.unsubscribe = () => {
|
|
1670
|
+
unsubscribe()
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
} catch {
|
|
1674
|
+
this.writeSlot(slotId, initialTag)
|
|
1675
|
+
}
|
|
1676
|
+
return { kind: 'animatedRefSlot', slotId }
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
/**
|
|
1680
|
+
* Bind a remote worklet that lived in the original closure as a callable.
|
|
1681
|
+
* Calling it dispatches back through this runtime to the peer where the worklet's
|
|
1682
|
+
* home runtime can execute it. This is how runOnJS-from-ui round-trips: the
|
|
1683
|
+
* closure had a runOnJS-wrapped worklet, we call it, we ship it back across the
|
|
1684
|
+
* channel.
|
|
1685
|
+
*/
|
|
1686
|
+
private bindRemoteWorklet(spec: Extract<ValueSpec, { kind: 'fn' }>): WorkletFn {
|
|
1687
|
+
const cacheKey =
|
|
1688
|
+
spec.closureId > 0
|
|
1689
|
+
? getMaterializedClosureCacheKey(spec.hash, spec.closureId)
|
|
1690
|
+
: null
|
|
1691
|
+
if (cacheKey) {
|
|
1692
|
+
const cached = this.remoteWorkletCache.get(cacheKey)
|
|
1693
|
+
if (cached) return cached
|
|
1694
|
+
}
|
|
1695
|
+
// local materialization so we can call it locally if needed (e.g. when the
|
|
1696
|
+
// channel is one-way for this hop). cache by hash to share fns.
|
|
1697
|
+
let local = this.workletCache.get(spec.hash)
|
|
1698
|
+
if (!local) {
|
|
1699
|
+
local = evalWorkletCode(spec.code)
|
|
1700
|
+
this.workletCache.set(spec.hash, local)
|
|
1701
|
+
}
|
|
1702
|
+
// deserialize the closure ONCE and reuse it for every call. upstream
|
|
1703
|
+
// reanimated ships a worklet's closure to the UI runtime a single time; the
|
|
1704
|
+
// worklet then runs repeatedly against that same closure. two properties
|
|
1705
|
+
// depend on this:
|
|
1706
|
+
// - SharedValue proxies stay live regardless — they read the slot /
|
|
1707
|
+
// object-slot on every `.value` access, so a stable proxy is always
|
|
1708
|
+
// current.
|
|
1709
|
+
// - stateful captured objects persist their mutations across runs. a
|
|
1710
|
+
// reanimated mapper's `remoteState` (a `makeShareable` record holding
|
|
1711
|
+
// `{last, animations, …}`) is mutated every frame; the mapper relies on
|
|
1712
|
+
// reading back its own previous-frame state.
|
|
1713
|
+
// re-deserializing per call rebuilt `remoteState` fresh every frame —
|
|
1714
|
+
// `animations`/`last` reset to their initial values — which destroyed
|
|
1715
|
+
// animation continuity (springs glitched, never settled) and rebuilt the
|
|
1716
|
+
// entire closure graph on every animation/scroll frame. bind `_recur` once for
|
|
1717
|
+
// the same reason (see handleRunWorklet — recursive worklets read `this._recur`).
|
|
1718
|
+
let closure: Record<string, unknown> | null = null
|
|
1719
|
+
const recur = local.bind(local)
|
|
1720
|
+
const wrapped = ((...args: unknown[]) => {
|
|
1721
|
+
if (!closure) {
|
|
1722
|
+
closure = this.deserializeClosure(spec.closureSpec) as Record<string, unknown>
|
|
1723
|
+
}
|
|
1724
|
+
// the evaled fn is SHARED per workletHash — re-attach this wrapper's closure
|
|
1725
|
+
// on every call (another closure of the same worklet may have run since).
|
|
1726
|
+
;(local as WorkletFn).__closure = closure
|
|
1727
|
+
;(local as WorkletFn & { _recur?: unknown })._recur = recur
|
|
1728
|
+
return runWithWorkletFlag(() => local!.apply(local, args))
|
|
1729
|
+
}) as WorkletFn
|
|
1730
|
+
wrapped.__initData = { code: spec.code }
|
|
1731
|
+
wrapped.__workletHash = spec.hash
|
|
1732
|
+
wrapped.__closure = {}
|
|
1733
|
+
if (cacheKey) this.remoteWorkletCache.set(cacheKey, wrapped)
|
|
1734
|
+
return wrapped
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
function runWithWorkletFlag<T>(fn: () => T): T {
|
|
1739
|
+
const g = globalThis as { _WORKLET?: boolean }
|
|
1740
|
+
const previous = g._WORKLET
|
|
1741
|
+
g._WORKLET = true
|
|
1742
|
+
try {
|
|
1743
|
+
return fn()
|
|
1744
|
+
} finally {
|
|
1745
|
+
g._WORKLET = previous
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
function runWithEventFrameTimestamp<T>(timestamp: number | undefined, fn: () => T): T {
|
|
1750
|
+
if (typeof timestamp !== 'number' || !Number.isFinite(timestamp)) {
|
|
1751
|
+
return fn()
|
|
1752
|
+
}
|
|
1753
|
+
const g = globalThis as {
|
|
1754
|
+
__frameTimestamp?: number
|
|
1755
|
+
__flushAnimationFrame?: (timestamp: number) => void
|
|
1756
|
+
}
|
|
1757
|
+
const previous = g.__frameTimestamp
|
|
1758
|
+
g.__frameTimestamp = timestamp
|
|
1759
|
+
try {
|
|
1760
|
+
const result = fn()
|
|
1761
|
+
g.__flushAnimationFrame?.(timestamp)
|
|
1762
|
+
return result
|
|
1763
|
+
} finally {
|
|
1764
|
+
g.__frameTimestamp = previous
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
export interface SharedValueProxy {
|
|
1769
|
+
_id: number
|
|
1770
|
+
_isReanimatedSharedValue: true
|
|
1771
|
+
// booleans read back as booleans (boolean slots are stored Float64 0/1 but reads
|
|
1772
|
+
// restore the type — see bindSharedValueProxy), so a worklet sees the same value
|
|
1773
|
+
// real reanimated would.
|
|
1774
|
+
value: number | boolean
|
|
1775
|
+
_value: number | boolean
|
|
1776
|
+
_animation: unknown
|
|
1777
|
+
get(): number | boolean
|
|
1778
|
+
set(v: number | boolean | (() => number | boolean)): void
|
|
1779
|
+
modify(
|
|
1780
|
+
modifier: (value: number | boolean) => number | boolean,
|
|
1781
|
+
forceUpdate?: boolean,
|
|
1782
|
+
): void
|
|
1783
|
+
addListener(id: number, fn: (value: number) => void): void
|
|
1784
|
+
removeListener(id: number): void
|
|
1785
|
+
setDirty(dirty: boolean): void
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
// matches upstream's SharedValue shape for non-primitive values. the hidden
|
|
1789
|
+
// OBJECT_SV_BRAND lets serializer distinguish object SVs from primitive SVs (which
|
|
1790
|
+
// can also have non-number `.value` mid-animation when an animation object is
|
|
1791
|
+
// briefly assigned).
|
|
1792
|
+
export interface ObjectSharedValueProxy {
|
|
1793
|
+
_id: number
|
|
1794
|
+
_isReanimatedSharedValue: true
|
|
1795
|
+
value: unknown
|
|
1796
|
+
get(): unknown
|
|
1797
|
+
set(v: unknown): void
|
|
1798
|
+
modify(modifier: (value: unknown) => unknown, forceUpdate?: boolean): void
|
|
1799
|
+
addListener(id: number, fn: (value: unknown) => void): void
|
|
1800
|
+
removeListener(id: number): void
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
export interface WorkletFn {
|
|
1804
|
+
__closure?: Record<string, unknown>
|
|
1805
|
+
__workletHash?: number
|
|
1806
|
+
__initData?: { code?: string; location?: string; sourceMap?: string }
|
|
1807
|
+
(...args: unknown[]): unknown
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
// ---------------------------------------------------------------------
|
|
1811
|
+
// serialization helpers (closure-side; react typically calls these, ui calls them
|
|
1812
|
+
// on reply paths)
|
|
1813
|
+
// ---------------------------------------------------------------------
|
|
1814
|
+
|
|
1815
|
+
// stable across the (single-realm) react context: the worklets stub `makeShareable`
|
|
1816
|
+
// stamps source records with this key; the serializer reads it to emit
|
|
1817
|
+
// `shareableRef`. Symbol.for keeps it identical between the compat stub and the
|
|
1818
|
+
// runtime without a cross-module import.
|
|
1819
|
+
export const SHAREABLE_ID_KEY = Symbol.for('rngpui.shareableId')
|
|
1820
|
+
|
|
1821
|
+
export function serializeValue(value: unknown): ValueSpec {
|
|
1822
|
+
return serializeValueInternal(value)
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
interface SerializeOptions {
|
|
1826
|
+
registerPlainFunction?: (
|
|
1827
|
+
fn: (...args: unknown[]) => unknown,
|
|
1828
|
+
) => Extract<ValueSpec, { kind: 'jsCallback' }>
|
|
1829
|
+
serializeWorkletFunction?: (fn: WorkletFn) => WorkletFunctionSpec
|
|
1830
|
+
serializeAnimatedRef?: (value: unknown) => ValueSpec | null
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
function serializeWorkletFunctionValue(
|
|
1834
|
+
value: WorkletFn,
|
|
1835
|
+
opts: SerializeOptions | undefined,
|
|
1836
|
+
seen: WeakSet<object>,
|
|
1837
|
+
depth: number,
|
|
1838
|
+
): WorkletFunctionSpec {
|
|
1839
|
+
if (opts?.serializeWorkletFunction) return opts.serializeWorkletFunction(value)
|
|
1840
|
+
return {
|
|
1841
|
+
kind: 'fn',
|
|
1842
|
+
code: value.__initData!.code!,
|
|
1843
|
+
hash: value.__workletHash!,
|
|
1844
|
+
closureId: 0,
|
|
1845
|
+
closureSpec: serializeClosureInternal(value.__closure ?? {}, opts, seen, depth + 1),
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
function serializeValueInternal(
|
|
1850
|
+
value: unknown,
|
|
1851
|
+
opts?: SerializeOptions,
|
|
1852
|
+
seen: WeakSet<object> = new WeakSet(),
|
|
1853
|
+
depth = 0,
|
|
1854
|
+
path = '$',
|
|
1855
|
+
): ValueSpec {
|
|
1856
|
+
if (value === undefined) return { kind: 'undef' }
|
|
1857
|
+
const animatedRefSpec = opts?.serializeAnimatedRef?.(value)
|
|
1858
|
+
if (animatedRefSpec) return animatedRefSpec
|
|
1859
|
+
const animatedRefTag = getAnimatedRefViewTag(value)
|
|
1860
|
+
if (animatedRefTag !== null) {
|
|
1861
|
+
return { kind: 'animatedRef', viewTag: animatedRefTag }
|
|
1862
|
+
}
|
|
1863
|
+
const mapped = getSerializableMapping(value)
|
|
1864
|
+
if (mapped !== undefined && mapped !== value) {
|
|
1865
|
+
return serializeValueInternal(mapped, opts, seen, depth, path)
|
|
1866
|
+
}
|
|
1867
|
+
// SharedValue detection: real reanimated has _id + .value getter; our slot-backed
|
|
1868
|
+
// and object-backed proxies both have the same minimal shape. discriminate via
|
|
1869
|
+
// the `_isObjectSV` brand we plant when createShareable allocates an object slot,
|
|
1870
|
+
// so primitive vs object routing is unambiguous even when a primitive's `.value`
|
|
1871
|
+
// is briefly an animation object during a `withTiming` assignment.
|
|
1872
|
+
if (isObjectSharedValueLike(value)) {
|
|
1873
|
+
return { kind: 'svObject', id: value._id }
|
|
1874
|
+
}
|
|
1875
|
+
if (isSharedValueLike(value)) {
|
|
1876
|
+
return { kind: 'sv', id: value._id }
|
|
1877
|
+
}
|
|
1878
|
+
// a reanimated mutable that arrived WITHOUT an `_id`. createShareable's object
|
|
1879
|
+
// branch fell back to the local-only `decorated` object — the slot/object-slot
|
|
1880
|
+
// pool was exhausted under churn, or, in degraded contexts, the runtime wasn't
|
|
1881
|
+
// wired. upstream's invariant is that a SharedValue is ALWAYS shippable to the UI
|
|
1882
|
+
// runtime; rngpui's EAGER wiring (in createShareable) can fail, so restore the
|
|
1883
|
+
// invariant here — the single cross-runtime chokepoint — by wiring it LAZILY at
|
|
1884
|
+
// ship time, exactly like upstream reanimated's makeShareableCloneRecursive. the
|
|
1885
|
+
// wirer mutates `value` in place: it gains `_id` + the object-SV brand, so every
|
|
1886
|
+
// subsequent ship takes the normal isObjectSharedValueLike path and react
|
|
1887
|
+
// `.value =` writes propagate to the peer slot. without this the mutable would
|
|
1888
|
+
// deep-clone into a plain object below and the peer worklet would throw
|
|
1889
|
+
// `configRef.get is not a function`.
|
|
1890
|
+
if (isUnwiredReanimatedMutable(value)) {
|
|
1891
|
+
const wiredId = getLazyObjectShareableWirer()?.(value) ?? null
|
|
1892
|
+
if (typeof wiredId === 'number') {
|
|
1893
|
+
return { kind: 'svObject', id: wiredId }
|
|
1894
|
+
}
|
|
1895
|
+
// wiring unavailable or the pool is still exhausted: ship a detached snapshot so
|
|
1896
|
+
// the peer worklet still gets a `.get()`-bearing object.
|
|
1897
|
+
return { kind: 'svDetached', value: structuredCloneSafe(value.value) }
|
|
1898
|
+
}
|
|
1899
|
+
if (isJSCallback(value)) {
|
|
1900
|
+
return { kind: 'jsCallback', id: value.__rngpui_jsCallback }
|
|
1901
|
+
}
|
|
1902
|
+
const builtin = getWorkletBuiltinName(value)
|
|
1903
|
+
if (builtin) {
|
|
1904
|
+
return { kind: 'builtin', name: builtin }
|
|
1905
|
+
}
|
|
1906
|
+
if (isWorkletFn(value)) {
|
|
1907
|
+
return serializeWorkletFunctionValue(value, opts, seen, depth)
|
|
1908
|
+
}
|
|
1909
|
+
if (typeof value === 'function' && opts?.registerPlainFunction) {
|
|
1910
|
+
return opts.registerPlainFunction(value as (...args: unknown[]) => unknown)
|
|
1911
|
+
}
|
|
1912
|
+
// arrays — recurse so nested SharedValues / worklet refs survive. upstream
|
|
1913
|
+
// reanimated ships SharedValue arrays as `inputs` to startMapper(); a shallow
|
|
1914
|
+
// `structuredClone` would strip the `_id` wiring and the UI-side mapper would
|
|
1915
|
+
// crash extracting inputs.
|
|
1916
|
+
if (Array.isArray(value)) {
|
|
1917
|
+
if (seen.has(value)) {
|
|
1918
|
+
throw new Error(`worklet-runtime: cyclic array in closure at ${path}`)
|
|
1919
|
+
}
|
|
1920
|
+
seen.add(value)
|
|
1921
|
+
try {
|
|
1922
|
+
return {
|
|
1923
|
+
kind: 'array',
|
|
1924
|
+
items: value.map((v, index) =>
|
|
1925
|
+
serializeValueInternal(v, opts, seen, depth + 1, `${path}[${index}]`),
|
|
1926
|
+
),
|
|
1927
|
+
}
|
|
1928
|
+
} finally {
|
|
1929
|
+
seen.delete(value)
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
const viewTag = getSerializableViewTag(value)
|
|
1933
|
+
if (viewTag !== null) {
|
|
1934
|
+
return { kind: 'plain', value: { __viewTag: viewTag } }
|
|
1935
|
+
}
|
|
1936
|
+
const animationDescriptor = readAnimationDescriptorSpec(value)
|
|
1937
|
+
if (animationDescriptor) {
|
|
1938
|
+
return {
|
|
1939
|
+
kind: 'animation',
|
|
1940
|
+
type: animationDescriptor.type,
|
|
1941
|
+
args: animationDescriptor.args.map((arg, i) =>
|
|
1942
|
+
serializeValueInternal(arg, opts, seen, depth + 1, `${path}.<anim:${animationDescriptor.type}:${i}>`),
|
|
1943
|
+
),
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
if (isSerializableInitializer(value)) {
|
|
1947
|
+
return {
|
|
1948
|
+
kind: 'initializer',
|
|
1949
|
+
init: serializeWorkletFunctionValue(value.__init, opts, seen, depth),
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
// plain objects — recurse the same way for {...keyboardConfig} style closures
|
|
1953
|
+
// that mix primitives with SharedValues. only "plain" objects (Object.prototype)
|
|
1954
|
+
// get recursed; class instances and exotic objects fall through to
|
|
1955
|
+
// structuredClone where their identity may be lost (acceptable: those callers
|
|
1956
|
+
// already sit outside upstream's worklet contract).
|
|
1957
|
+
if (
|
|
1958
|
+
value !== null &&
|
|
1959
|
+
typeof value === 'object' &&
|
|
1960
|
+
Object.getPrototypeOf(value) === Object.prototype
|
|
1961
|
+
) {
|
|
1962
|
+
if (seen.has(value)) {
|
|
1963
|
+
throw new Error(`worklet-runtime: cyclic object in closure at ${path}`)
|
|
1964
|
+
}
|
|
1965
|
+
seen.add(value)
|
|
1966
|
+
const entries: Array<[string, ValueSpec]> = []
|
|
1967
|
+
try {
|
|
1968
|
+
for (const key of Object.keys(value as Record<string, unknown>)) {
|
|
1969
|
+
if (key === 'shadowNodeWrapper') {
|
|
1970
|
+
// on rngpui, getShadowNodeWrapperFromRef returns the BARE NUMERIC id
|
|
1971
|
+
// (the reconciler's __internalInstanceHandle.stateNode.node IS the
|
|
1972
|
+
// globalId — see reconciler.ts); object shapes come from other paths.
|
|
1973
|
+
const raw = (value as Record<string, unknown>)[key]
|
|
1974
|
+
const tag = isValidViewTag(raw) ? raw : getSerializableViewTag(raw)
|
|
1975
|
+
entries.push([
|
|
1976
|
+
key,
|
|
1977
|
+
tag === null
|
|
1978
|
+
? { kind: 'plain', value: null }
|
|
1979
|
+
: { kind: 'plain', value: { __viewTag: tag } },
|
|
1980
|
+
])
|
|
1981
|
+
continue
|
|
1982
|
+
}
|
|
1983
|
+
entries.push([
|
|
1984
|
+
key,
|
|
1985
|
+
serializeValueInternal(
|
|
1986
|
+
(value as Record<string, unknown>)[key],
|
|
1987
|
+
opts,
|
|
1988
|
+
seen,
|
|
1989
|
+
depth + 1,
|
|
1990
|
+
`${path}.${key}`,
|
|
1991
|
+
),
|
|
1992
|
+
])
|
|
1993
|
+
}
|
|
1994
|
+
// `makeShareable`-stamped record: emit a stable ref so re-ships resolve to the
|
|
1995
|
+
// SAME persistent peer-side object (upstream serializableMappingCache
|
|
1996
|
+
// contract). first materialization still uses the deep entries above; later
|
|
1997
|
+
// ships ignore them. SHAREABLE_ID_KEY is non-enumerable so it never appears in
|
|
1998
|
+
// `entries`.
|
|
1999
|
+
const shareableId = (value as Record<symbol, unknown>)[SHAREABLE_ID_KEY]
|
|
2000
|
+
if (typeof shareableId === 'number') {
|
|
2001
|
+
return { kind: 'shareableRef', id: shareableId, snapshot: entries }
|
|
2002
|
+
}
|
|
2003
|
+
return { kind: 'object', entries }
|
|
2004
|
+
} finally {
|
|
2005
|
+
seen.delete(value)
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
// primitives / typed arrays / Maps / etc. — structured-clone-safe.
|
|
2009
|
+
return { kind: 'plain', value: structuredCloneSafe(value) }
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
export function serializeClosure(closure: Record<string, unknown>): ClosureSpec {
|
|
2013
|
+
return serializeClosureInternal(closure)
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
function serializeClosureInternal(
|
|
2017
|
+
closure: Record<string, unknown>,
|
|
2018
|
+
opts?: SerializeOptions,
|
|
2019
|
+
seen: WeakSet<object> = new WeakSet(),
|
|
2020
|
+
depth = 0,
|
|
2021
|
+
path = '$',
|
|
2022
|
+
): ClosureSpec {
|
|
2023
|
+
const out: ClosureSpec = {}
|
|
2024
|
+
for (const key of Object.keys(closure)) {
|
|
2025
|
+
out[key] = serializeValueInternal(closure[key], opts, seen, depth + 1, `${path}.${key}`)
|
|
2026
|
+
}
|
|
2027
|
+
return out
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
function isSharedValueLike(v: unknown): v is { _id: number; value: number } {
|
|
2031
|
+
if (!v || typeof v !== 'object') return false
|
|
2032
|
+
const obj = v as { _id?: unknown; value?: unknown }
|
|
2033
|
+
return (
|
|
2034
|
+
typeof obj._id === 'number' && obj._id >= SAB_HEADER_FLOATS && obj._id < OBJECT_ID_BASE
|
|
2035
|
+
)
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
const RNGPUI_ANIMATION_DESCRIPTOR = Symbol.for('rngpui.reanimatedAnimationDescriptor')
|
|
2039
|
+
|
|
2040
|
+
/** read the {type, args} stamp reanimated-host.ts places on animation objects. */
|
|
2041
|
+
function readAnimationDescriptorSpec(value: unknown): { type: string; args: unknown[] } | null {
|
|
2042
|
+
if (!value || (typeof value !== 'object' && typeof value !== 'function')) return null
|
|
2043
|
+
const descriptor = (value as { [RNGPUI_ANIMATION_DESCRIPTOR]?: unknown })[RNGPUI_ANIMATION_DESCRIPTOR]
|
|
2044
|
+
if (!descriptor || typeof descriptor !== 'object') return null
|
|
2045
|
+
const type = (descriptor as { type?: unknown }).type
|
|
2046
|
+
const args = (descriptor as { args?: unknown }).args
|
|
2047
|
+
if (typeof type !== 'string' || !Array.isArray(args)) return null
|
|
2048
|
+
return { type, args }
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
function getSerializableViewTag(
|
|
2052
|
+
value: unknown,
|
|
2053
|
+
seen: WeakSet<object> = new WeakSet(),
|
|
2054
|
+
): number | null {
|
|
2055
|
+
if (!value || typeof value !== 'object') return null
|
|
2056
|
+
if (seen.has(value)) return null
|
|
2057
|
+
seen.add(value)
|
|
2058
|
+
const direct = (value as { __viewTag?: unknown }).__viewTag
|
|
2059
|
+
if (isValidViewTag(direct)) return direct
|
|
2060
|
+
const nativeTag = (value as { __nativeTag?: unknown }).__nativeTag
|
|
2061
|
+
if (isValidViewTag(nativeTag)) return nativeTag
|
|
2062
|
+
const legacyNativeTag = (value as { _nativeTag?: unknown })._nativeTag
|
|
2063
|
+
if (isValidViewTag(legacyNativeTag)) return legacyNativeTag
|
|
2064
|
+
const nested = (value as { node?: { __viewTag?: unknown } }).node?.__viewTag
|
|
2065
|
+
if (isValidViewTag(nested)) return nested
|
|
2066
|
+
const stateNode = (
|
|
2067
|
+
value as {
|
|
2068
|
+
stateNode?: { node?: unknown }
|
|
2069
|
+
}
|
|
2070
|
+
).stateNode?.node
|
|
2071
|
+
const stateNodeTag = getSerializableViewTag(stateNode, seen)
|
|
2072
|
+
if (stateNodeTag !== null) return stateNodeTag
|
|
2073
|
+
const internalNode = (
|
|
2074
|
+
value as {
|
|
2075
|
+
__internalInstanceHandle?: { stateNode?: { node?: unknown } }
|
|
2076
|
+
}
|
|
2077
|
+
).__internalInstanceHandle?.stateNode?.node
|
|
2078
|
+
const internalNodeTag = getSerializableViewTag(internalNode, seen)
|
|
2079
|
+
if (internalNodeTag !== null) return internalNodeTag
|
|
2080
|
+
// rngpui: a reconciler Instance's `.id` IS the globalId rust keys on, so an
|
|
2081
|
+
// animated component ref resolves to its globalId here.
|
|
2082
|
+
const id = (value as { id?: unknown }).id
|
|
2083
|
+
if (isValidViewTag(id)) return id
|
|
2084
|
+
return null
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
interface AnimatedRefLike {
|
|
2088
|
+
(...args: unknown[]): unknown
|
|
2089
|
+
current?: unknown
|
|
2090
|
+
getTag?: () => unknown
|
|
2091
|
+
observe?: (observer: (tagOrWrapper: unknown) => unknown) => unknown
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
function isAnimatedRefLike(value: unknown): value is AnimatedRefLike {
|
|
2095
|
+
if (typeof value !== 'function') return false
|
|
2096
|
+
const ref = value as {
|
|
2097
|
+
current?: unknown
|
|
2098
|
+
getTag?: () => unknown
|
|
2099
|
+
observe?: unknown
|
|
2100
|
+
}
|
|
2101
|
+
return typeof ref.getTag === 'function' || typeof ref.observe === 'function'
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
function getAnimatedRefViewTag(value: unknown): number | null {
|
|
2105
|
+
if (!isAnimatedRefLike(value)) return null
|
|
2106
|
+
const ref = value
|
|
2107
|
+
const fromCurrent = getSerializableViewTag(ref.current)
|
|
2108
|
+
if (fromCurrent !== null) return fromCurrent
|
|
2109
|
+
try {
|
|
2110
|
+
const tag = ref.getTag?.()
|
|
2111
|
+
if (isValidViewTag(tag)) return tag
|
|
2112
|
+
const fromTagObject = getSerializableViewTag(tag)
|
|
2113
|
+
if (fromTagObject !== null) return fromTagObject
|
|
2114
|
+
} catch {}
|
|
2115
|
+
try {
|
|
2116
|
+
const tag = ref()
|
|
2117
|
+
if (isValidViewTag(tag)) return tag
|
|
2118
|
+
const fromTagObject = getSerializableViewTag(tag)
|
|
2119
|
+
if (fromTagObject !== null) return fromTagObject
|
|
2120
|
+
} catch {}
|
|
2121
|
+
return null
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
function isValidViewTag(value: unknown): value is number {
|
|
2125
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0
|
|
2126
|
+
}
|
|
2127
|
+
|
|
2128
|
+
function makeRemoteAnimatedRef(viewTag: number): (() => { __viewTag: number }) & {
|
|
2129
|
+
current: { __viewTag: number }
|
|
2130
|
+
getTag: () => number
|
|
2131
|
+
} {
|
|
2132
|
+
const wrapper = { __viewTag: viewTag }
|
|
2133
|
+
const ref = (() => wrapper) as (() => { __viewTag: number }) & {
|
|
2134
|
+
current: { __viewTag: number }
|
|
2135
|
+
getTag: () => number
|
|
2136
|
+
}
|
|
2137
|
+
ref.current = wrapper
|
|
2138
|
+
ref.getTag = () => viewTag
|
|
2139
|
+
return ref
|
|
2140
|
+
}
|
|
2141
|
+
|
|
2142
|
+
function getReanimatedAnimationFactory(name: string): (...args: unknown[]) => unknown {
|
|
2143
|
+
const factories = (globalThis as Record<string, unknown>)[REANIMATED_ANIMATION_FACTORIES]
|
|
2144
|
+
const factory =
|
|
2145
|
+
factories && typeof factories === 'object'
|
|
2146
|
+
? (factories as Record<string, unknown>)[name]
|
|
2147
|
+
: undefined
|
|
2148
|
+
if (typeof factory !== 'function') {
|
|
2149
|
+
throw new Error(
|
|
2150
|
+
`[worklet-runtime] missing reanimated animation factory "${name}" on this runtime`,
|
|
2151
|
+
)
|
|
2152
|
+
}
|
|
2153
|
+
return factory as (...args: unknown[]) => unknown
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
function getReanimatedValueSetter():
|
|
2157
|
+
| ((mutable: SharedValueProxy, value: unknown, forceUpdate?: boolean) => void)
|
|
2158
|
+
| null {
|
|
2159
|
+
const setter = (globalThis as Record<string, unknown>)[REANIMATED_VALUE_SETTER]
|
|
2160
|
+
return typeof setter === 'function'
|
|
2161
|
+
? (setter as (mutable: SharedValueProxy, value: unknown, forceUpdate?: boolean) => void)
|
|
2162
|
+
: null
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
function isReanimatedAnimationValue(value: unknown): boolean {
|
|
2166
|
+
return (
|
|
2167
|
+
typeof value === 'function' ||
|
|
2168
|
+
(!!value &&
|
|
2169
|
+
typeof value === 'object' &&
|
|
2170
|
+
typeof (value as { onFrame?: unknown }).onFrame === 'function')
|
|
2171
|
+
)
|
|
2172
|
+
}
|
|
2173
|
+
|
|
2174
|
+
function setSharedValueThroughReanimated(
|
|
2175
|
+
mutable: SharedValueProxy,
|
|
2176
|
+
value: unknown,
|
|
2177
|
+
writeSlot: (value: number) => void,
|
|
2178
|
+
forceUpdate = false,
|
|
2179
|
+
): void {
|
|
2180
|
+
const setter = getReanimatedValueSetter()
|
|
2181
|
+
if (setter) {
|
|
2182
|
+
setter(mutable, value, forceUpdate)
|
|
2183
|
+
return
|
|
2184
|
+
}
|
|
2185
|
+
if (isReanimatedAnimationValue(value)) {
|
|
2186
|
+
throw new Error('[worklet-runtime] reanimated valueSetter is not installed')
|
|
2187
|
+
}
|
|
2188
|
+
writeSlot(value as number)
|
|
2189
|
+
}
|
|
2190
|
+
|
|
2191
|
+
// brand planted by `createShareable` (worklets stub) when an object SV is
|
|
2192
|
+
// allocated. avoids relying on .value being non-number (the briefly-an-animation-
|
|
2193
|
+
// object case during withTiming would otherwise misclassify a primitive SV as
|
|
2194
|
+
// object).
|
|
2195
|
+
const OBJECT_SV_BRAND = Symbol.for('rngpui.objectSV')
|
|
2196
|
+
|
|
2197
|
+
export function markObjectSV(target: object): void {
|
|
2198
|
+
;(target as { [k: symbol]: boolean })[OBJECT_SV_BRAND] = true
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
function isObjectSharedValueLike(v: unknown): v is { _id: number; value: unknown } {
|
|
2202
|
+
if (!v || typeof v !== 'object') return false
|
|
2203
|
+
const obj = v as { _id?: unknown; [k: symbol]: unknown }
|
|
2204
|
+
return typeof obj._id === 'number' && obj[OBJECT_SV_BRAND] === true
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
// a reanimated mutable (createShareable's host-decorated SharedValue) that reached
|
|
2208
|
+
// the serializer WITHOUT an `_id` — i.e. createShareable's object branch fell back
|
|
2209
|
+
// to the local-only `decorated` object. recognized by the stable
|
|
2210
|
+
// `_isReanimatedSharedValue` brand reanimated's host decorator plants (and
|
|
2211
|
+
// createShareable preserves). see the lazy-wire branch in serializeValueInternal.
|
|
2212
|
+
function isUnwiredReanimatedMutable(v: unknown): v is { value: unknown } {
|
|
2213
|
+
if (!v || typeof v !== 'object') return false
|
|
2214
|
+
const o = v as { _id?: unknown; _isReanimatedSharedValue?: unknown }
|
|
2215
|
+
return o._isReanimatedSharedValue === true && typeof o._id !== 'number'
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
// the worklets stub installs this so the runtime serializer can lazily wire a
|
|
2219
|
+
// reanimated mutable into a real object slot at ship time (reusing the same
|
|
2220
|
+
// wireObjectSharedValue logic createShareable uses, so the wiring lives in ONE
|
|
2221
|
+
// place). returns the allocated slot id, or null when the runtime is unavailable /
|
|
2222
|
+
// the pool is still exhausted.
|
|
2223
|
+
const LAZY_OBJECT_SHAREABLE_WIRER = '__rngpui_wire_object_shareable'
|
|
2224
|
+
type LazyObjectShareableWirer = (value: { value: unknown }) => number | null
|
|
2225
|
+
function getLazyObjectShareableWirer(): LazyObjectShareableWirer | null {
|
|
2226
|
+
const fn = (globalThis as Record<string, unknown>)[LAZY_OBJECT_SHAREABLE_WIRER]
|
|
2227
|
+
return typeof fn === 'function' ? (fn as LazyObjectShareableWirer) : null
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
// rebuild a read-only object-SV proxy for a `svDetached` spec — a mutable that
|
|
2231
|
+
// could not be slot-backed at ship time. `.get()`/`.value` return the shipped
|
|
2232
|
+
// snapshot so the peer worklet runs instead of throwing; writes/listeners are
|
|
2233
|
+
// no-ops (there is no slot to propagate through). NOT cached — each detached ship
|
|
2234
|
+
// is its own one-shot snapshot.
|
|
2235
|
+
//
|
|
2236
|
+
// the proxy deliberately carries NO numeric `_id`: it keeps the
|
|
2237
|
+
// `_isReanimatedSharedValue` brand so that if this detached proxy is itself
|
|
2238
|
+
// captured in another worklet closure and re-shipped, `isUnwiredReanimatedMutable`
|
|
2239
|
+
// re-catches it (its `_id` is not a number) and re-attempts real wiring (the slot
|
|
2240
|
+
// pool may have recovered) or re-ships as another svDetached snapshot — so
|
|
2241
|
+
// `.get()` survives every hop. a numeric `_id` here would fall through every SV
|
|
2242
|
+
// guard on re-ship and deep-clone `.get()` away.
|
|
2243
|
+
function bindDetachedObjectSharedValue(
|
|
2244
|
+
snapshot: unknown,
|
|
2245
|
+
): Omit<ObjectSharedValueProxy, '_id'> {
|
|
2246
|
+
return {
|
|
2247
|
+
_isReanimatedSharedValue: true,
|
|
2248
|
+
get value() {
|
|
2249
|
+
return snapshot
|
|
2250
|
+
},
|
|
2251
|
+
set value(_v: unknown) {},
|
|
2252
|
+
get(): unknown {
|
|
2253
|
+
return snapshot
|
|
2254
|
+
},
|
|
2255
|
+
set(_v: unknown): void {},
|
|
2256
|
+
modify(_modifier: (value: unknown) => unknown, _forceUpdate = true): void {},
|
|
2257
|
+
addListener(_listenerId: number, _fn: (value: unknown) => void): void {},
|
|
2258
|
+
removeListener(_listenerId: number): void {},
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2261
|
+
|
|
2262
|
+
function isWorkletFn(v: unknown): v is WorkletFn {
|
|
2263
|
+
if (typeof v !== 'function') return false
|
|
2264
|
+
const f = v as WorkletFn
|
|
2265
|
+
return typeof f.__workletHash === 'number' && typeof f.__initData?.code === 'string'
|
|
2266
|
+
}
|
|
2267
|
+
|
|
2268
|
+
function describeWorklet(fn: WorkletFn): string {
|
|
2269
|
+
const location = fn.__initData?.location
|
|
2270
|
+
const hash = typeof fn.__workletHash === 'number' ? fn.__workletHash : 'unknown'
|
|
2271
|
+
return location ? `worklet ${hash} at ${location}` : `worklet ${hash}`
|
|
2272
|
+
}
|
|
2273
|
+
|
|
2274
|
+
function isSerializableInitializer(v: unknown): v is { __init: WorkletFn } {
|
|
2275
|
+
if (!v || typeof v !== 'object') return false
|
|
2276
|
+
if (Object.getPrototypeOf(v) !== Object.prototype) return false
|
|
2277
|
+
return isWorkletFn((v as { __init?: unknown }).__init)
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
function getWorkletBuiltinName(v: unknown): WorkletBuiltinName | null {
|
|
2281
|
+
return getSerializedBuiltinName(v)
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
function isJSCallback(
|
|
2285
|
+
v: unknown,
|
|
2286
|
+
): v is ((...args: unknown[]) => unknown) & { __rngpui_jsCallback: number } {
|
|
2287
|
+
if (typeof v !== 'function') return false
|
|
2288
|
+
const f = v as { __rngpui_jsCallback?: unknown }
|
|
2289
|
+
return typeof f.__rngpui_jsCallback === 'number'
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
function structuredCloneSafe(value: unknown): unknown {
|
|
2293
|
+
// primitives pass through untouched — the overwhelmingly common case (closure
|
|
2294
|
+
// numbers / strings / booleans).
|
|
2295
|
+
if (value === null || typeof value !== 'object') {
|
|
2296
|
+
return typeof value === 'function' || typeof value === 'symbol' ? undefined : value
|
|
2297
|
+
}
|
|
2298
|
+
// NOT structuredClone: JavaScriptCore doesn't have it (it silently threw here and every
|
|
2299
|
+
// plain closure value crossed as undefined — the dead-animation bug), and using
|
|
2300
|
+
// it only-when-available would make bun tests semantically diverge from the
|
|
2301
|
+
// real runtime. The channel is JSON-string transport, so a JSON round-trip IS
|
|
2302
|
+
// the faithful clone for anything that can cross (drops functions/symbols,
|
|
2303
|
+
// plain-objectifies Maps/typed arrays — exactly what the wire would do).
|
|
2304
|
+
// cycles are pre-guarded by the caller's `seen` set; a throw (e.g. BigInt)
|
|
2305
|
+
// degrades to undefined — a clear gap rather than a shared live reference.
|
|
2306
|
+
try {
|
|
2307
|
+
return JSON.parse(JSON.stringify(value))
|
|
2308
|
+
} catch {
|
|
2309
|
+
return undefined
|
|
2310
|
+
}
|
|
2311
|
+
}
|
|
2312
|
+
|
|
2313
|
+
// sanitize before send: any function or symbol nested in an object-SV payload
|
|
2314
|
+
// would otherwise throw during JSON/structured encoding and tear down the whole
|
|
2315
|
+
// batch. also shrink descriptor shadowNodeWrappers to `{__viewTag}` — those
|
|
2316
|
+
// wrappers are live engine nodes full of methods / parent refs, while the peer
|
|
2317
|
+
// only needs the tag to route _updateProps.
|
|
2318
|
+
//
|
|
2319
|
+
// object SVs are a protocol boundary, not a general-purpose object tunnel.
|
|
2320
|
+
// reanimated uses them for shallow layout records and viewDescriptors; shipping
|
|
2321
|
+
// arbitrary app/runtime graphs is both unnecessary and unsafe because the encoder
|
|
2322
|
+
// can recurse deeply enough to overflow.
|
|
2323
|
+
const MAX_PEER_OBJECT_DEPTH = 24
|
|
2324
|
+
const MAX_PEER_ARRAY_ITEMS = 4096
|
|
2325
|
+
const MAX_PEER_OBJECT_KEYS = 256
|
|
2326
|
+
|
|
2327
|
+
interface SanitizeState {
|
|
2328
|
+
seen: WeakMap<object, unknown>
|
|
2329
|
+
active: WeakSet<object>
|
|
2330
|
+
depth: number
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2333
|
+
function isPeerPrimitive(value: unknown): boolean {
|
|
2334
|
+
const t = typeof value
|
|
2335
|
+
return (
|
|
2336
|
+
value === null || t === 'string' || t === 'number' || t === 'boolean' || t === 'bigint'
|
|
2337
|
+
)
|
|
2338
|
+
}
|
|
2339
|
+
|
|
2340
|
+
function childSanitizeState(state: SanitizeState): SanitizeState {
|
|
2341
|
+
return { seen: state.seen, active: state.active, depth: state.depth + 1 }
|
|
2342
|
+
}
|
|
2343
|
+
|
|
2344
|
+
function sanitizeForPeer(
|
|
2345
|
+
value: unknown,
|
|
2346
|
+
state: SanitizeState = {
|
|
2347
|
+
seen: new WeakMap(),
|
|
2348
|
+
active: new WeakSet(),
|
|
2349
|
+
depth: 0,
|
|
2350
|
+
},
|
|
2351
|
+
): unknown {
|
|
2352
|
+
if (value === null) return null
|
|
2353
|
+
const t = typeof value
|
|
2354
|
+
if (t === 'function' || t === 'symbol') return undefined
|
|
2355
|
+
if (t !== 'object') return value
|
|
2356
|
+
if (state.depth >= MAX_PEER_OBJECT_DEPTH) return undefined
|
|
2357
|
+
|
|
2358
|
+
if (
|
|
2359
|
+
ArrayBuffer.isView(value as object) ||
|
|
2360
|
+
value instanceof ArrayBuffer ||
|
|
2361
|
+
value instanceof Date ||
|
|
2362
|
+
value instanceof RegExp ||
|
|
2363
|
+
(typeof Blob !== 'undefined' && value instanceof Blob) ||
|
|
2364
|
+
(typeof File !== 'undefined' && value instanceof File) ||
|
|
2365
|
+
(typeof ImageData !== 'undefined' && value instanceof ImageData)
|
|
2366
|
+
) {
|
|
2367
|
+
return value
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2370
|
+
const objRef = value as object
|
|
2371
|
+
if (state.active.has(objRef)) return undefined
|
|
2372
|
+
if (state.seen.has(objRef)) return state.seen.get(objRef)
|
|
2373
|
+
|
|
2374
|
+
if (Array.isArray(value)) {
|
|
2375
|
+
const out: unknown[] = []
|
|
2376
|
+
state.seen.set(objRef, out)
|
|
2377
|
+
state.active.add(objRef)
|
|
2378
|
+
const childState = childSanitizeState(state)
|
|
2379
|
+
const length = Math.min(value.length, MAX_PEER_ARRAY_ITEMS)
|
|
2380
|
+
for (let i = 0; i < length; i++) {
|
|
2381
|
+
const desc = Object.getOwnPropertyDescriptor(value, String(i))
|
|
2382
|
+
out.push(desc && 'value' in desc ? sanitizeForPeer(desc.value, childState) : undefined)
|
|
2383
|
+
}
|
|
2384
|
+
state.active.delete(objRef)
|
|
2385
|
+
return out
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2388
|
+
if (value instanceof Map) {
|
|
2389
|
+
const out = new Map<unknown, unknown>()
|
|
2390
|
+
state.seen.set(objRef, out)
|
|
2391
|
+
state.active.add(objRef)
|
|
2392
|
+
const childState = childSanitizeState(state)
|
|
2393
|
+
let count = 0
|
|
2394
|
+
for (const [k, v] of value) {
|
|
2395
|
+
if (count++ >= MAX_PEER_OBJECT_KEYS) break
|
|
2396
|
+
const tk = typeof k
|
|
2397
|
+
if (tk === 'function' || tk === 'symbol') continue
|
|
2398
|
+
out.set(sanitizeForPeer(k, childState), sanitizeForPeer(v, childState))
|
|
2399
|
+
}
|
|
2400
|
+
state.active.delete(objRef)
|
|
2401
|
+
return out
|
|
2402
|
+
}
|
|
2403
|
+
|
|
2404
|
+
if (value instanceof Set) {
|
|
2405
|
+
const out = new Set<unknown>()
|
|
2406
|
+
state.seen.set(objRef, out)
|
|
2407
|
+
state.active.add(objRef)
|
|
2408
|
+
const childState = childSanitizeState(state)
|
|
2409
|
+
let count = 0
|
|
2410
|
+
for (const v of value) {
|
|
2411
|
+
if (count++ >= MAX_PEER_OBJECT_KEYS) break
|
|
2412
|
+
const tv = typeof v
|
|
2413
|
+
if (tv === 'function' || tv === 'symbol') continue
|
|
2414
|
+
out.add(sanitizeForPeer(v, childState))
|
|
2415
|
+
}
|
|
2416
|
+
state.active.delete(objRef)
|
|
2417
|
+
return out
|
|
2418
|
+
}
|
|
2419
|
+
|
|
2420
|
+
const out: Record<string, unknown> = {}
|
|
2421
|
+
state.seen.set(objRef, out)
|
|
2422
|
+
state.active.add(objRef)
|
|
2423
|
+
const obj = value as Record<string, unknown>
|
|
2424
|
+
const wrapperDesc = Object.getOwnPropertyDescriptor(obj, 'shadowNodeWrapper')
|
|
2425
|
+
const wrapper =
|
|
2426
|
+
wrapperDesc && 'value' in wrapperDesc
|
|
2427
|
+
? (wrapperDesc.value as { __viewTag?: number } | null | undefined)
|
|
2428
|
+
: null
|
|
2429
|
+
const wrapperTag =
|
|
2430
|
+
wrapper && typeof wrapper === 'object' && typeof wrapper.__viewTag === 'number'
|
|
2431
|
+
? wrapper.__viewTag
|
|
2432
|
+
: null
|
|
2433
|
+
|
|
2434
|
+
if (wrapperDesc) {
|
|
2435
|
+
for (const key of Object.keys(value as Record<string, unknown>)) {
|
|
2436
|
+
if (key === 'shadowNodeWrapper') {
|
|
2437
|
+
out.shadowNodeWrapper = wrapperTag !== null ? { __viewTag: wrapperTag } : null
|
|
2438
|
+
continue
|
|
2439
|
+
}
|
|
2440
|
+
const desc = Object.getOwnPropertyDescriptor(obj, key)
|
|
2441
|
+
if (!desc || !('value' in desc)) continue
|
|
2442
|
+
const v = desc.value
|
|
2443
|
+
if (isPeerPrimitive(v)) out[key] = v
|
|
2444
|
+
}
|
|
2445
|
+
state.active.delete(objRef)
|
|
2446
|
+
return out
|
|
2447
|
+
}
|
|
2448
|
+
|
|
2449
|
+
const childState = childSanitizeState(state)
|
|
2450
|
+
const keys = Object.keys(value as Record<string, unknown>).slice(0, MAX_PEER_OBJECT_KEYS)
|
|
2451
|
+
for (const key of keys) {
|
|
2452
|
+
if (key === 'shadowNodeWrapper') {
|
|
2453
|
+
out.shadowNodeWrapper = wrapperTag !== null ? { __viewTag: wrapperTag } : null
|
|
2454
|
+
continue
|
|
2455
|
+
}
|
|
2456
|
+
const desc = Object.getOwnPropertyDescriptor(obj, key)
|
|
2457
|
+
if (!desc || !('value' in desc)) continue
|
|
2458
|
+
const v = desc.value
|
|
2459
|
+
const tv = typeof v
|
|
2460
|
+
if (tv === 'function' || tv === 'symbol') continue
|
|
2461
|
+
out[key] = sanitizeForPeer(v, childState)
|
|
2462
|
+
}
|
|
2463
|
+
state.active.delete(objRef)
|
|
2464
|
+
return out
|
|
2465
|
+
}
|
|
2466
|
+
|
|
2467
|
+
// eval a worklet body. the babel transform emits the body as the entire function
|
|
2468
|
+
// source, e.g. `function anonymous() { 'worklet'; ... }`. we build a fresh function
|
|
2469
|
+
// via `(0, eval)(...)` rather than `new Function` because the source is already a
|
|
2470
|
+
// complete function expression.
|
|
2471
|
+
export function evalWorkletCode(code: string): (...args: unknown[]) => unknown {
|
|
2472
|
+
installWorkletGlobalAliases()
|
|
2473
|
+
if (typeof globalThis !== 'undefined') {
|
|
2474
|
+
const allowed = (globalThis as { __rngpui_worklet_eval_allowed?: boolean })
|
|
2475
|
+
.__rngpui_worklet_eval_allowed
|
|
2476
|
+
if (allowed === false) {
|
|
2477
|
+
throw new Error('worklet eval is disabled in this runtime')
|
|
2478
|
+
}
|
|
2479
|
+
}
|
|
2480
|
+
// wrap as `(<code>)` so an emitted `function name(...)` expression becomes a value
|
|
2481
|
+
// rather than a declaration.
|
|
2482
|
+
// biome-ignore lint/security/noGlobalEval: required for worklet eval
|
|
2483
|
+
const fn = (0, eval)(`(${code})`) as (...args: unknown[]) => unknown
|
|
2484
|
+
if (typeof fn !== 'function') {
|
|
2485
|
+
throw new Error('worklet eval did not produce a function')
|
|
2486
|
+
}
|
|
2487
|
+
return fn
|
|
2488
|
+
}
|
|
2489
|
+
|
|
2490
|
+
|
|
2491
|
+
// ---------------------------------------------------------------------
|
|
2492
|
+
// shared-slot buffer (host-side helper). the rust host creates the buffer once and
|
|
2493
|
+
// installs it in both runtimes as `globalThis.__rngpui_svSlots`. this helper is for
|
|
2494
|
+
// tests / fallback contexts that need to build a compatible buffer themselves.
|
|
2495
|
+
// ---------------------------------------------------------------------
|
|
2496
|
+
|
|
2497
|
+
export function createSharedValueBuffer(capacity: number = DEFAULT_CAPACITY): ArrayBuffer {
|
|
2498
|
+
const bytes = capacity * 8
|
|
2499
|
+
const buffer = new ArrayBuffer(bytes)
|
|
2500
|
+
const f = new Float64Array(buffer)
|
|
2501
|
+
f[0] = SAB_MAGIC
|
|
2502
|
+
f[1] = capacity
|
|
2503
|
+
// f[2], f[3] reserved.
|
|
2504
|
+
return buffer
|
|
2505
|
+
}
|
|
2506
|
+
|
|
2507
|
+
// ---------------------------------------------------------------------
|
|
2508
|
+
// per-runtime singleton
|
|
2509
|
+
// ---------------------------------------------------------------------
|
|
2510
|
+
|
|
2511
|
+
// the singleton lives on globalThis so the compat stubs (which have no import
|
|
2512
|
+
// dependency on the engine) can resolve it cross-module.
|
|
2513
|
+
const GLOBAL_KEY = '__rngpui_worklet_runtime'
|
|
2514
|
+
// the shared-slot ArrayBuffer the rust host installs in both runtimes.
|
|
2515
|
+
const SHARED_SLOTS_KEY = '__rngpui_svSlots'
|
|
2516
|
+
|
|
2517
|
+
interface WorkletRuntimeGlobals {
|
|
2518
|
+
[GLOBAL_KEY]?: WorkletRuntime
|
|
2519
|
+
}
|
|
2520
|
+
|
|
2521
|
+
export function setActiveWorkletRuntime(rt: WorkletRuntime): void {
|
|
2522
|
+
;(globalThis as WorkletRuntimeGlobals)[GLOBAL_KEY] = rt
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2525
|
+
export function getWorkletRuntime(): WorkletRuntime | null {
|
|
2526
|
+
return (globalThis as WorkletRuntimeGlobals)[GLOBAL_KEY] ?? null
|
|
2527
|
+
}
|
|
2528
|
+
|
|
2529
|
+
export function isWorkletRuntimeReady(): boolean {
|
|
2530
|
+
return getWorkletRuntime() !== null
|
|
2531
|
+
}
|
|
2532
|
+
|
|
2533
|
+
function getSharedSlotsBuffer(): ArrayBuffer | null {
|
|
2534
|
+
const buf = (globalThis as Record<string, unknown>)[SHARED_SLOTS_KEY]
|
|
2535
|
+
return buf instanceof ArrayBuffer ? buf : null
|
|
2536
|
+
}
|
|
2537
|
+
|
|
2538
|
+
/**
|
|
2539
|
+
* Install (or return the existing) per-runtime WorkletRuntime singleton. Reads the
|
|
2540
|
+
* shared-slot ArrayBuffer from `globalThis.__rngpui_svSlots` (installed by the rust
|
|
2541
|
+
* host). The channel adapter (worklet-channel.ts) wires itself in via installChannel
|
|
2542
|
+
* — this installs the runtime against a supplied channel. If no channel is supplied
|
|
2543
|
+
* the caller is expected to follow up with installChannel(role, runtime).
|
|
2544
|
+
*
|
|
2545
|
+
* Returns null when the shared-slot buffer is absent (bun unit tests without the
|
|
2546
|
+
* host bridge): callers then keep their local-only path.
|
|
2547
|
+
*/
|
|
2548
|
+
export function installWorkletRuntime(opts: {
|
|
2549
|
+
role: WorkletRuntimeRole
|
|
2550
|
+
channel: WorkletChannel
|
|
2551
|
+
buffer?: ArrayBuffer
|
|
2552
|
+
}): WorkletRuntime | null {
|
|
2553
|
+
const existing = getWorkletRuntime()
|
|
2554
|
+
if (existing) return existing
|
|
2555
|
+
const buffer = opts.buffer ?? getSharedSlotsBuffer()
|
|
2556
|
+
if (!buffer) return null
|
|
2557
|
+
const rt = new WorkletRuntime({ channel: opts.channel, buffer, role: opts.role })
|
|
2558
|
+
setActiveWorkletRuntime(rt)
|
|
2559
|
+
return rt
|
|
2560
|
+
}
|
|
2561
|
+
|
|
2562
|
+
// constants exported for tests
|
|
2563
|
+
export const __WORKLET_RUNTIME_INTERNALS = {
|
|
2564
|
+
SAB_HEADER_FLOATS,
|
|
2565
|
+
SAB_MAGIC,
|
|
2566
|
+
DEFAULT_CAPACITY,
|
|
2567
|
+
OBJECT_ID_BASE,
|
|
2568
|
+
SHARED_SLOTS_KEY,
|
|
2569
|
+
}
|