@felipe-software/react-native-gpui 0.0.0-stage → 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.
Files changed (72) hide show
  1. package/.reanimated-prebuilt/react-native-reanimated.mjs +29373 -0
  2. package/LICENSE +21 -0
  3. package/README.md +657 -2
  4. package/cli/bin.ts +379 -0
  5. package/cli/bundle.ts +32 -0
  6. package/cli/commands/do.ts +213 -0
  7. package/cli/commands/flow.ts +283 -0
  8. package/cli/commands/get.ts +584 -0
  9. package/cli/commands/shot.ts +312 -0
  10. package/cli/commands/trace.ts +293 -0
  11. package/cli/host.ts +714 -0
  12. package/cli/hotReload.ts +210 -0
  13. package/cli/run.ts +66 -0
  14. package/cli/selectors.ts +205 -0
  15. package/dist/Animated.d.ts +120 -0
  16. package/dist/ChatList.d.ts +28 -0
  17. package/dist/Dimensions.d.ts +31 -0
  18. package/dist/StyleSheet.d.ts +18 -0
  19. package/dist/Window.d.ts +33 -0
  20. package/dist/apis.d.ts +165 -0
  21. package/dist/app-state.d.ts +27 -0
  22. package/dist/chat-list-types.d.ts +136 -0
  23. package/dist/colors.d.ts +32 -0
  24. package/dist/commands.d.ts +230 -0
  25. package/dist/components.d.ts +419 -0
  26. package/dist/hotModules.d.ts +1 -0
  27. package/dist/index.d.ts +31 -0
  28. package/dist/index.development.js +14876 -0
  29. package/dist/index.js +12 -0
  30. package/dist/keyboard.d.ts +140 -0
  31. package/dist/native-menu.d.ts +7 -0
  32. package/dist/platform-driver.d.ts +32 -0
  33. package/dist/platform-services.d.ts +35 -0
  34. package/dist/portal.d.ts +43 -0
  35. package/dist/raf.d.ts +4 -0
  36. package/dist/reanimated/colors-processor-shim.d.ts +10 -0
  37. package/dist/reanimated/reanimated-host.d.ts +10 -0
  38. package/dist/reanimated/seam.d.ts +34 -0
  39. package/dist/reanimated/ui-entry.d.ts +2 -0
  40. package/dist/reanimated/worklet-channel.d.ts +10 -0
  41. package/dist/reanimated/worklet-runtime.d.ts +466 -0
  42. package/dist/reanimated/worklets.d.ts +199 -0
  43. package/dist/reconciler.d.ts +102 -0
  44. package/dist/refresh.d.ts +1 -0
  45. package/dist/render.d.ts +37 -0
  46. package/dist/runtime.d.ts +235 -0
  47. package/dist/surfaces.d.ts +51 -0
  48. package/dist/types.d.ts +276 -0
  49. package/dist/ui-runtime.js +42276 -0
  50. package/dist/window-context.d.ts +7 -0
  51. package/dist/window-host.d.ts +41 -0
  52. package/dist/wire-delta.d.ts +20 -0
  53. package/package.json +176 -5
  54. package/scripts/bundle-app.mjs +88 -0
  55. package/scripts/conformance-utils.mjs +293 -0
  56. package/scripts/hot-update-alias-plugin.mjs +88 -0
  57. package/scripts/native-package-exports-plugin.mjs +235 -0
  58. package/scripts/pixel.mjs +152 -0
  59. package/scripts/png.mjs +198 -0
  60. package/scripts/react-refresh-swc.mjs +35 -0
  61. package/scripts/reanimated-bun-plugin.mjs +188 -0
  62. package/scripts/service-binary.d.mts +27 -0
  63. package/scripts/service-binary.mjs +100 -0
  64. package/src/reanimated/colors-processor-shim.ts +119 -0
  65. package/src/reanimated/reanimated-host.ts +69 -0
  66. package/src/reanimated/rngpui-reanimated-prebuilt.d.ts +7 -0
  67. package/src/reanimated/seam.ts +566 -0
  68. package/src/reanimated/ui-entry.ts +80 -0
  69. package/src/reanimated/worklet-channel.ts +64 -0
  70. package/src/reanimated/worklet-runtime.ts +2569 -0
  71. package/src/reanimated/worklets.ts +1334 -0
  72. 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
+ }