@sigx/lynx-runtime-main 0.2.7 → 0.4.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.
@@ -0,0 +1,228 @@
1
+ /**
2
+ * MT-side SharedValue bridge — publishes MT-thread mutations to BG.
3
+ *
4
+ * Diffs every registered SharedValue against its last-published snapshot
5
+ * and dispatches one batched `Lynx.Sigx.AvPublish` event per flush boundary
6
+ * with the changed `[wvid, value]` tuples. The BG side ingests these via
7
+ * `@sigx/lynx-runtime/src/animated-bridge.ts` and writes them into the
8
+ * mirror signal so any sigx `effect` reading `sv.value` re-runs.
9
+ *
10
+ * Bridge state (`bridgedAvWvids` / `bridgedAvLastValues`) lives in
11
+ * `ops-apply.ts` because the BG→MT op handlers mutate it; this module
12
+ * imports the references and reads them on every flush.
13
+ *
14
+ * Two flush hook points:
15
+ * 1. `ops-apply.ts` calls `flushAvBridgePublishes()` at its tail (covers
16
+ * every BG-driven ops batch).
17
+ * 2. `installAvBridgeFlushHook()` wraps `globalThis.__FlushElementTree`
18
+ * so spontaneous MT writes (e.g. a touchmove worklet that eventually
19
+ * calls `setStyleProperties`) also trigger a publish on the same
20
+ * tick the native tree flushes. Called once from `entry-main.ts`
21
+ * after PAPI globals are present.
22
+ *
23
+ * Coalescing: `===` per-wvid diff. Identical writes are filtered. N writes
24
+ * within one flush window collapse to one BG event with N entries.
25
+ */
26
+ import { bridgedAvWvids, bridgedAvLastValues } from './ops-apply.js';
27
+ import { lookupMapper } from './animated-style-mappers.js';
28
+ const AV_PUBLISH = 'Lynx.Sigx.AvPublish';
29
+ /**
30
+ * Diff registered AVs against their last-published snapshots; dispatch one
31
+ * batched `Lynx.Sigx.AvPublish` event with all changed tuples. No-op when
32
+ * the bridge set is empty or when nothing has changed since the last call.
33
+ */
34
+ export function flushAvBridgePublishes() {
35
+ if (bridgedAvWvids.size === 0)
36
+ return;
37
+ const impl = globalThis.lynxWorkletImpl;
38
+ const refMap = impl?._refImpl?._workletRefMap;
39
+ if (!refMap)
40
+ return;
41
+ let updates;
42
+ for (const wvid of bridgedAvWvids) {
43
+ const ref = refMap[wvid];
44
+ if (!ref)
45
+ continue;
46
+ const v = ref.current?.value;
47
+ if (v !== bridgedAvLastValues.get(wvid)) {
48
+ (updates ??= []).push([wvid, v]);
49
+ bridgedAvLastValues.set(wvid, v);
50
+ }
51
+ }
52
+ if (!updates)
53
+ return;
54
+ const lynxObj = globalThis.lynx;
55
+ const ctx = lynxObj?.getJSContext?.();
56
+ if (!ctx?.dispatchEvent)
57
+ return;
58
+ let data;
59
+ try {
60
+ data = JSON.stringify(updates);
61
+ }
62
+ catch (e) {
63
+ console.log('[sigx-mt] av-bridge: JSON.stringify failed:', String(e));
64
+ return;
65
+ }
66
+ ctx.dispatchEvent({ type: AV_PUBLISH, data });
67
+ }
68
+ const animatedStyleBindings = new Map();
69
+ /**
70
+ * Register a binding (called from the OP.REGISTER_AV_STYLE_BINDING op
71
+ * handler in `ops-apply.ts`). Initializes `lastValue` to a sentinel so the
72
+ * first flush always applies the mapper, even when the AV is at its initial.
73
+ */
74
+ export function registerAnimatedStyleBinding(bindingId, elementWvid, avWvid, mapperName, params) {
75
+ // Sentinel — guaranteed not to equal any user value, so the first flush
76
+ // applies the mapper regardless of whether the AV ever gets written.
77
+ const sentinel = {};
78
+ animatedStyleBindings.set(bindingId, {
79
+ elementWvid,
80
+ avWvid,
81
+ mapperName,
82
+ params,
83
+ lastValue: sentinel,
84
+ });
85
+ }
86
+ export function unregisterAnimatedStyleBinding(bindingId) {
87
+ animatedStyleBindings.delete(bindingId);
88
+ }
89
+ export function resetAnimatedStyleBindings() {
90
+ animatedStyleBindings.clear();
91
+ }
92
+ export function animatedStyleBindingCount() {
93
+ return animatedStyleBindings.size;
94
+ }
95
+ /**
96
+ * For each element with at least one *dirty* binding (AV value changed since
97
+ * the last apply), re-run **all** of that element's bindings, merge their
98
+ * mapper outputs, and apply the result with a single `setStyleProperties`
99
+ * call. Called from the wrapped `__FlushElementTree` *before* the native
100
+ * tree flush.
101
+ *
102
+ * Why "all bindings on a dirty element" rather than "only changed bindings":
103
+ * - Multiple bindings on the same element can write the same style key
104
+ * (e.g. `translateX` + `translateY` both produce `transform`). If we
105
+ * applied only the changed ones, the unchanged-binding's contribution
106
+ * would be lost and the element would visibly snap. By re-running every
107
+ * binding on the dirty element and merging, all contributions land in
108
+ * the same `setStyleProperties` call.
109
+ *
110
+ * Merge semantics:
111
+ * - `transform` values from multiple bindings *concatenate* in registration
112
+ * order (e.g. `translateX(50px)` + `translateY(20px)` ->
113
+ * `translateX(50px) translateY(20px)`).
114
+ * - All other keys merge by last-write-wins; a binding registered later on
115
+ * the same element overwrites an earlier binding's same-key output.
116
+ *
117
+ * Skip cases (silent, by design):
118
+ * - AV ref missing in `_workletRefMap` (race with unregister).
119
+ * - Element ref's `current` is null (component not yet mounted, or
120
+ * unmounted before the binding's UNREGISTER op landed).
121
+ * - Mapper name not registered (typo or missing custom registration).
122
+ */
123
+ export function flushAnimatedStyleBindings() {
124
+ if (animatedStyleBindings.size === 0)
125
+ return;
126
+ const impl = globalThis.lynxWorkletImpl;
127
+ const refMap = impl?._refImpl?._workletRefMap;
128
+ if (!refMap)
129
+ return;
130
+ // Phase 1 — find which elements have at least one dirty binding. Update
131
+ // each binding's lastValue so the next flush only re-applies on further
132
+ // change. Skip bindings whose AV ref is missing.
133
+ let dirtyElements;
134
+ for (const binding of animatedStyleBindings.values()) {
135
+ const avRef = refMap[binding.avWvid];
136
+ if (!avRef)
137
+ continue;
138
+ const v = avRef.current?.value;
139
+ if (v === binding.lastValue)
140
+ continue;
141
+ binding.lastValue = v;
142
+ (dirtyElements ??= new Set()).add(binding.elementWvid);
143
+ }
144
+ if (!dirtyElements)
145
+ return;
146
+ // Phase 2 — for each dirty element, run *all* its bindings and merge the
147
+ // outputs into one style object. Iteration order over the Map is insertion
148
+ // order, which equals registration order — so transform concatenations
149
+ // come out in the order the user registered them.
150
+ const merged = new Map();
151
+ for (const binding of animatedStyleBindings.values()) {
152
+ if (!dirtyElements.has(binding.elementWvid))
153
+ continue;
154
+ const avRef = refMap[binding.avWvid];
155
+ if (!avRef)
156
+ continue;
157
+ const v = avRef.current?.value;
158
+ const mapper = lookupMapper(binding.mapperName);
159
+ if (!mapper)
160
+ continue;
161
+ let out;
162
+ try {
163
+ out = mapper(v, binding.params);
164
+ }
165
+ catch (e) {
166
+ console.log('[sigx-mt] av-style mapper threw:', binding.mapperName, String(e));
167
+ continue;
168
+ }
169
+ let acc = merged.get(binding.elementWvid);
170
+ if (!acc) {
171
+ acc = {};
172
+ merged.set(binding.elementWvid, acc);
173
+ }
174
+ for (const k in out) {
175
+ if (k === 'transform' && typeof acc.transform === 'string') {
176
+ acc.transform = `${acc.transform} ${String(out.transform)}`;
177
+ }
178
+ else {
179
+ acc[k] = out[k];
180
+ }
181
+ }
182
+ }
183
+ // Phase 3 — one setStyleProperties per dirty element.
184
+ for (const [elementWvid, styleObj] of merged) {
185
+ const elRef = refMap[elementWvid];
186
+ const el = elRef?.current;
187
+ if (!el?.setStyleProperties)
188
+ continue;
189
+ try {
190
+ el.setStyleProperties(styleObj);
191
+ }
192
+ catch (e) {
193
+ console.log('[sigx-mt] av-style setStyleProperties threw:', String(e));
194
+ }
195
+ }
196
+ }
197
+ const INSTALLED = Symbol.for('sigx.avBridgeFlushHookInstalled');
198
+ /**
199
+ * Wrap `globalThis.__FlushElementTree` once so every flush also runs the AV
200
+ * bridge publish step. Idempotent — safe to call across hot reloads. Test
201
+ * setups that `vi.stubGlobal('__FlushElementTree', ...)` AFTER this hook
202
+ * installs will replace our wrapper, which is the correct behavior for
203
+ * unit tests that drive `flushAvBridgePublishes` directly.
204
+ */
205
+ export function installAvBridgeFlushHook() {
206
+ const g = globalThis;
207
+ if (g[INSTALLED])
208
+ return;
209
+ const original = g['__FlushElementTree'];
210
+ if (typeof original !== 'function')
211
+ return;
212
+ g[INSTALLED] = true;
213
+ g['__FlushElementTree'] = function wrappedFlushElementTree(...args) {
214
+ try {
215
+ flushAvBridgePublishes();
216
+ }
217
+ catch (e) {
218
+ console.log('[sigx-mt] av-bridge flush threw:', String(e));
219
+ }
220
+ try {
221
+ flushAnimatedStyleBindings();
222
+ }
223
+ catch (e) {
224
+ console.log('[sigx-mt] av-style bindings flush threw:', String(e));
225
+ }
226
+ return original.apply(this, args);
227
+ };
228
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * MT-side mapper registry for `useAnimatedStyle`.
3
+ *
4
+ * Maps a `SharedValue`'s current scalar to a partial style object that the
5
+ * binding flush passes to `setStyleProperties` on the bound element. Keyed by
6
+ * a string name (`'translateX'`, `'scale'`, ...) so the SWC worklet transform
7
+ * can capture the selection trivially: a string is a primitive `_c` value
8
+ * with no special lifting required (unlike arbitrary functions, which can't
9
+ * be captured into a worklet's closure).
10
+ *
11
+ * Custom mappers can be registered via `registerMapper(name, fn)` from MT
12
+ * code (e.g. a `'main thread'`-marked module body in a user app). BG-side
13
+ * `useAnimatedStyle` validates the name only against the type union; a
14
+ * lookup mismatch on MT is a silent no-op at flush time.
15
+ *
16
+ * Param shapes are mapper-specific. The `MapperParams` type in
17
+ * `@sigx/lynx-runtime-internal` is the single source of truth — both
18
+ * BG-side `useAnimatedStyle` and the MT runtime import it from there.
19
+ *
20
+ * Range mapping: `translateX` / `translateY` / `scale` / `opacity` accept
21
+ * either their linear `factor`/`offset` shape or a `RangeParams` shape
22
+ * (`{ inputRange, outputRange, extrapolate? }`). The mapper picks the branch
23
+ * by looking for `inputRange` on the params.
24
+ */
25
+ function isRangeParams(p) {
26
+ return (typeof p === 'object' && p !== null
27
+ && 'inputRange' in p
28
+ && 'outputRange' in p);
29
+ }
30
+ /**
31
+ * Linear interpolation across a multi-stop range. Locates the input segment
32
+ * via simple linear scan (inputRange is small in practice — typically 2-4
33
+ * stops) and lerps within it. Out-of-range behavior controlled by
34
+ * `extrapolate`: `'clamp'` (default) caps at endpoint outputs; `'identity'`
35
+ * extends linearly using the slope of the nearest segment.
36
+ */
37
+ function interpolateLinear(v, inputRange, outputRange, extrapolate = 'clamp') {
38
+ const n = inputRange.length;
39
+ if (n < 2)
40
+ return outputRange[0] ?? v;
41
+ if (v <= inputRange[0]) {
42
+ if (extrapolate === 'clamp')
43
+ return outputRange[0];
44
+ const dx = inputRange[1] - inputRange[0];
45
+ const dy = outputRange[1] - outputRange[0];
46
+ return outputRange[0] + (v - inputRange[0]) * (dy / dx);
47
+ }
48
+ if (v >= inputRange[n - 1]) {
49
+ if (extrapolate === 'clamp')
50
+ return outputRange[n - 1];
51
+ const dx = inputRange[n - 1] - inputRange[n - 2];
52
+ const dy = outputRange[n - 1] - outputRange[n - 2];
53
+ return outputRange[n - 1] + (v - inputRange[n - 1]) * (dy / dx);
54
+ }
55
+ for (let i = 1; i < n; i++) {
56
+ if (v <= inputRange[i]) {
57
+ const t = (v - inputRange[i - 1]) / (inputRange[i] - inputRange[i - 1]);
58
+ return outputRange[i - 1] + t * (outputRange[i] - outputRange[i - 1]);
59
+ }
60
+ }
61
+ return outputRange[n - 1];
62
+ }
63
+ const mtMappers = {
64
+ translateX: (v, p) => {
65
+ if (isRangeParams(p)) {
66
+ const out = interpolateLinear(v, p.inputRange, p.outputRange, p.extrapolate);
67
+ return { transform: `translateX(${out}px)` };
68
+ }
69
+ const factor = p?.factor ?? 1;
70
+ return { transform: `translateX(${v * factor}px)` };
71
+ },
72
+ translateY: (v, p) => {
73
+ if (isRangeParams(p)) {
74
+ const out = interpolateLinear(v, p.inputRange, p.outputRange, p.extrapolate);
75
+ return { transform: `translateY(${out}px)` };
76
+ }
77
+ const factor = p?.factor ?? 1;
78
+ return { transform: `translateY(${v * factor}px)` };
79
+ },
80
+ translate: (v, p) => {
81
+ const params = p ?? {};
82
+ const fx = params.factorX ?? 1;
83
+ const fy = params.factorY ?? 1;
84
+ const xy = v;
85
+ return { transform: `translate(${xy.x * fx}px, ${xy.y * fy}px)` };
86
+ },
87
+ scale: (v, p) => {
88
+ if (isRangeParams(p)) {
89
+ const out = interpolateLinear(v, p.inputRange, p.outputRange, p.extrapolate);
90
+ return { transform: `scale(${out})` };
91
+ }
92
+ const offset = p?.offset ?? 0;
93
+ return { transform: `scale(${v + offset})` };
94
+ },
95
+ opacity: (v, p) => {
96
+ if (isRangeParams(p)) {
97
+ const raw = interpolateLinear(v, p.inputRange, p.outputRange, p.extrapolate);
98
+ const out = Math.max(0, Math.min(1, raw));
99
+ return { opacity: String(out) };
100
+ }
101
+ const params = p ?? {};
102
+ const factor = params.factor ?? 1;
103
+ const offset = params.offset ?? 0;
104
+ const out = Math.max(0, Math.min(1, v * factor + offset));
105
+ return { opacity: String(out) };
106
+ },
107
+ rotate: (v) => ({ transform: `rotate(${v}deg)` }),
108
+ paddingTop: (v, p) => ({ paddingTop: `${linearOrRange(v, p)}px` }),
109
+ paddingRight: (v, p) => ({ paddingRight: `${linearOrRange(v, p)}px` }),
110
+ paddingBottom: (v, p) => ({ paddingBottom: `${linearOrRange(v, p)}px` }),
111
+ paddingLeft: (v, p) => ({ paddingLeft: `${linearOrRange(v, p)}px` }),
112
+ marginTop: (v, p) => ({ marginTop: `${linearOrRange(v, p)}px` }),
113
+ marginRight: (v, p) => ({ marginRight: `${linearOrRange(v, p)}px` }),
114
+ marginBottom: (v, p) => ({ marginBottom: `${linearOrRange(v, p)}px` }),
115
+ marginLeft: (v, p) => ({ marginLeft: `${linearOrRange(v, p)}px` }),
116
+ };
117
+ function linearOrRange(v, p) {
118
+ if (isRangeParams(p)) {
119
+ return interpolateLinear(v, p.inputRange, p.outputRange, p.extrapolate);
120
+ }
121
+ const factor = p?.factor ?? 1;
122
+ return v * factor;
123
+ }
124
+ /**
125
+ * Look up a registered mapper by name. Returns `undefined` if the name
126
+ * isn't registered — the binding flush treats that as a no-op.
127
+ */
128
+ export function lookupMapper(name) {
129
+ return mtMappers[name];
130
+ }
131
+ /**
132
+ * Register a custom MT-side mapper. Idempotent on (name, fn) — last
133
+ * registration wins for the same name. Intended for `'main thread'`-marked
134
+ * user modules that ship project-specific styling math.
135
+ */
136
+ export function registerMapper(name, mapper) {
137
+ mtMappers[name] = mapper;
138
+ }
139
+ /**
140
+ * Reset hook — drops every custom mapper, reseats the built-ins. Used by
141
+ * the MT-side resetMainThreadState path (HMR / tests).
142
+ */
143
+ const BUILTIN_NAMES = new Set([
144
+ 'translateX', 'translateY', 'translate', 'scale', 'opacity', 'rotate',
145
+ 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft',
146
+ 'marginTop', 'marginRight', 'marginBottom', 'marginLeft',
147
+ ]);
148
+ export function resetMappers() {
149
+ for (const k in mtMappers) {
150
+ if (!BUILTIN_NAMES.has(k)) {
151
+ delete mtMappers[k];
152
+ }
153
+ }
154
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Element registry — maps BG-thread ShadowElement IDs to Lynx Main Thread
3
+ * element handles.
4
+ */
5
+ /** Map from BG-thread ShadowElement id to Lynx Main Thread element handle */
6
+ export const elements = new Map();
7
+ /**
8
+ * PAPI unique ID of the root PageElement.
9
+ * Passed as `parentComponentUniqueId` to element creation PAPI calls.
10
+ * `__SetCSSId` sets `css_style_sheet_manager_` directly on each element,
11
+ * so CSS rendering works without a ComponentElement ancestor.
12
+ */
13
+ export let pageUniqueId = 1;
14
+ export function setPageUniqueId(id) {
15
+ pageUniqueId = id;
16
+ }
@@ -1 +1,229 @@
1
- import "./entry-main-CBM2DHsU.js";
1
+ /**
2
+ * Main Thread (Lepus) bootstrap entry.
3
+ *
4
+ * Injected by @sigx/lynx-plugin as the sole content of the main-thread bundle.
5
+ * Sets up:
6
+ * - globalThis.processData — required by Lynx Lepus runtime (data processor)
7
+ * - globalThis.renderPage — creates the Lynx page root (id=1)
8
+ * - globalThis.updatePage — no-op stub (required by Lynx Lepus runtime)
9
+ * - globalThis.sigxPatchUpdate — receives ops from Background Thread
10
+ */
11
+ import { elements, setPageUniqueId } from './element-registry.js';
12
+ import { applyOps, resetMainThreadState, setPlaceholder } from './ops-apply.js';
13
+ import { invokeWorklet } from './worklet-events.js';
14
+ import { runOnBackground } from './run-on-background-mt.js';
15
+ import { installAvBridgeFlushHook } from './animated-bridge-mt.js';
16
+ const g = globalThis;
17
+ // CRITICAL: SystemInfo must be set BEFORE the @lynx-js/react/worklet-runtime
18
+ // IIFE evaluates (it reads SystemInfo as a free identifier at init time).
19
+ // lynx-plugin orders MT entries [entry-main, worklet-runtime, ...userImports]
20
+ // so this module body runs before the worklet-runtime entry. Keeping the
21
+ // install in module body (not an import) prevents vite from hoisting it.
22
+ if (g['SystemInfo'] === undefined) {
23
+ const lynxObj = g['lynx'];
24
+ g['SystemInfo'] = lynxObj?.SystemInfo ?? {};
25
+ }
26
+ /** PAGE_ROOT_ID must match the value used in the BG-thread renderer */
27
+ const PAGE_ROOT_ID = 1;
28
+ // Lynx Lepus runtime requires globalThis.processData to be set.
29
+ // It is called to transform initial data before renderPage runs.
30
+ // For sigx we have no data processors, so just pass data through.
31
+ g['processData'] = function (data, _processorName) {
32
+ return data ?? {};
33
+ };
34
+ // Lynx calls renderPage on the Main Thread first (before Background JS runs).
35
+ // We create the root page element and store it as id=1 so Background ops that
36
+ // target the root can resolve it correctly.
37
+ g['renderPage'] = function (_data) {
38
+ resetMainThreadState();
39
+ const page = __CreatePage('0', 0);
40
+ __SetCSSId([page], 0);
41
+ setPageUniqueId(__GetElementUniqueID(page));
42
+ elements.set(PAGE_ROOT_ID, page);
43
+ // Append a placeholder __CreateView under the page root so the host sees a
44
+ // non-empty tree immediately. Without this, the host's "no UI within timeout"
45
+ // check fires before the BG thread's first ops batch arrives, producing a
46
+ // phantom USER_RUNTIME_ERROR. The placeholder is removed on the first
47
+ // applyOps() call (see ops-apply.ts).
48
+ const placeholder = __CreateView(__GetElementUniqueID(page));
49
+ __SetCSSId([placeholder], 0);
50
+ __AppendElement(page, placeholder);
51
+ setPlaceholder(page, placeholder);
52
+ __FlushElementTree(page);
53
+ };
54
+ // Lynx may call updatePage / updateGlobalProps after data changes.
55
+ // We have no data binding on Main Thread, so these are no-ops.
56
+ g['updatePage'] = function (_data) {
57
+ // no-op
58
+ };
59
+ g['updateGlobalProps'] = function (_data) {
60
+ // no-op
61
+ };
62
+ // Called by the BG Thread via callLepusMethod('sigxHotReload', {}) when a
63
+ // webpack HMR update is about to be applied. Resets the Main Thread element
64
+ // registry and re-creates the page root so the next sigxPatchUpdate batch
65
+ // builds on a clean tree.
66
+ //
67
+ // NOTE: With component-level HMR, component file changes are self-accepted
68
+ // by the HMR loader and patched in-place on the BG thread — this handler
69
+ // is NOT involved. It exists as a safety net for future non-component
70
+ // reload scenarios (e.g., if a host decides to send sigxHotReload
71
+ // explicitly). See docs/hmr-investigation.md.
72
+ g['sigxHotReload'] = function () {
73
+ const existingPage = elements.get(PAGE_ROOT_ID);
74
+ resetMainThreadState();
75
+ const page = existingPage ?? __CreatePage('0', 0);
76
+ __SetCSSId([page], 0);
77
+ setPageUniqueId(__GetElementUniqueID(page));
78
+ elements.set(PAGE_ROOT_ID, page);
79
+ const placeholder = __CreateView(__GetElementUniqueID(page));
80
+ __SetCSSId([placeholder], 0);
81
+ __AppendElement(page, placeholder);
82
+ setPlaceholder(page, placeholder);
83
+ __FlushElementTree(page);
84
+ };
85
+ // Called by the BG Thread via callLepusMethod('sigxApplyMtHotUpdate', { code }).
86
+ // `code` is the concatenated `registerWorkletInternal(...)` calls extracted
87
+ // from the matching `main__main-thread.<hash>.hot-update.js` file. Eval'd in
88
+ // the existing realm so new content-hash worklet IDs land in the live
89
+ // `_workletMap` before the user taps a re-rendered button.
90
+ //
91
+ // See `lynx-runtime/src/mt-hmr-bridge.ts` for the BG-side fetch + forward.
92
+ g['sigxApplyMtHotUpdate'] = function ({ code }) {
93
+ if (!code)
94
+ return;
95
+ try {
96
+ new Function(code)();
97
+ }
98
+ catch (e) {
99
+ console.log('[sigx-mt] sigxApplyMtHotUpdate eval failed:', String(e));
100
+ }
101
+ };
102
+ // Called by the BG Thread via callLepusMethod('sigxPatchUpdate', { data }).
103
+ g['sigxPatchUpdate'] = function ({ data }) {
104
+ let ops;
105
+ try {
106
+ ops = JSON.parse(data);
107
+ }
108
+ catch (e) {
109
+ console.log('[sigx-mt] sigxPatchUpdate JSON parse failed:', String(e));
110
+ return;
111
+ }
112
+ try {
113
+ applyOps(ops);
114
+ }
115
+ catch (e) {
116
+ console.log('[sigx-mt] applyOps threw:', String(e));
117
+ }
118
+ // applyOps() already calls __FlushElementTree() at its tail.
119
+ };
120
+ // ---------------------------------------------------------------------------
121
+ // runOnMainThread bridge (BG → MT worklet invocation)
122
+ //
123
+ // Called by the BG Thread via callLepusMethod('sigxRunOnMT',
124
+ // { wkltId, args, captured }). When `captured` is supplied, route through
125
+ // upstream's `runWorklet({_wkltId, _c}, args)` so its `I()` walker hydrates
126
+ // the placeholders inside `_c` (resolves nested `{_wkltId}` worklet refs to
127
+ // callable functions and `{_wvid}` ref placeholders to live MainThreadRefs
128
+ // from `_workletRefMap`). This matches the path SET_WORKLET_EVENT uses for
129
+ // JSX-attached MT handlers, and is what makes captures like
130
+ // `runOnMainThread(() => { 'main thread'; withSpring(sv, 0); })` work
131
+ // (`withSpring` is a worklet placeholder that needs hydration before the
132
+ // destructure-and-call inside the body).
133
+ //
134
+ // `invokeWorklet` is kept as a fallback for the `captured === undefined`
135
+ // case (no captures to hydrate) and for direct-from-MT callers that already
136
+ // hand over a hydrated ctx.
137
+ // ---------------------------------------------------------------------------
138
+ /**
139
+ * Deep-clone a value into a fresh tree whose every object has
140
+ * `Object.prototype`.
141
+ *
142
+ * Why: PrimJS's `JSON.parse` produces null-prototype objects (a prototype-
143
+ * pollution safety measure that V8 / SpiderMonkey don't apply). Upstream's
144
+ * worklet runtime (`@lynx-js/react@0.121+`'s `workletRuntime.js`) walks
145
+ * the captured `_c`, identifies worklet placeholders by `'_wkltId' in
146
+ * subObj`, and then calls `new WeakRef(subObj)`. PrimJS's `WeakRef` rejects
147
+ * null-prototype objects with `TypeError: WeakRef: target must be an
148
+ * object`, so the worklet body never runs.
149
+ *
150
+ * `Object.setPrototypeOf` is a silent no-op on PrimJS's JSON.parse output
151
+ * (the proto slot is locked even with `isExtensible: true`), so the only
152
+ * way to get an `Object.prototype`-prototyped object is to rebuild it via
153
+ * `{}` literal. A fresh object literal always has `Object.prototype` by
154
+ * spec, so deep-cloning the captured tree produces a fully WeakRef-able
155
+ * shape that's semantically identical for the worklet body.
156
+ *
157
+ * JSON.parse output is acyclic by construction, so no cycle detection
158
+ * needed. Arrays handled separately to keep `Array.isArray` true downstream.
159
+ */
160
+ function rebuildWithObjectPrototype(value) {
161
+ if (typeof value !== 'object' || value === null)
162
+ return value;
163
+ if (Array.isArray(value)) {
164
+ return value.map(rebuildWithObjectPrototype);
165
+ }
166
+ // Use `Object.defineProperty` (not `out[k] = ...`) so a `__proto__` key in
167
+ // the captured payload doesn't trigger the prototype setter on `out` and
168
+ // re-null the prototype we just gave it. Belt-and-braces against accidental
169
+ // prototype pollution; in practice `_c` is shaped by `sanitizeCaptured` on
170
+ // BG and shouldn't carry `__proto__`, but the guarantee is cheap.
171
+ const out = {};
172
+ for (const k of Object.keys(value)) {
173
+ Object.defineProperty(out, k, {
174
+ value: rebuildWithObjectPrototype(value[k]),
175
+ enumerable: true,
176
+ writable: true,
177
+ configurable: true,
178
+ });
179
+ }
180
+ return out;
181
+ }
182
+ g['sigxRunOnMT'] = function ({ wkltId, args, captured }, callback) {
183
+ const argsArr = args ?? [];
184
+ let result;
185
+ const runWorkletFn = globalThis['runWorklet'];
186
+ if (captured && typeof runWorkletFn === 'function') {
187
+ // PrimJS quirk: `JSON.parse` produces null-prototype objects whose proto
188
+ // slot is locked (`setPrototypeOf` is a silent no-op). Upstream's worklet
189
+ // runtime calls `new WeakRef(subObj)` on every nested placeholder, and
190
+ // PrimJS's `WeakRef` rejects null-prototype objects → animations no-op.
191
+ //
192
+ // Rebuild the captured tree from scratch via object literals — every
193
+ // `{}` has `Object.prototype` by construction. Semantically identical
194
+ // for the worklet body; only upstream's `WeakRef` / `instanceof` checks
195
+ // are affected, which now succeed.
196
+ const fixedCaptured = rebuildWithObjectPrototype(captured);
197
+ try {
198
+ result = runWorkletFn({ _wkltId: wkltId, _c: fixedCaptured }, argsArr);
199
+ }
200
+ catch (e) {
201
+ console.log('[sigx-mt] sigxRunOnMT worklet threw:', String(e), 'wkltId=', wkltId);
202
+ result = undefined;
203
+ }
204
+ }
205
+ else {
206
+ result = invokeWorklet(wkltId, captured, argsArr);
207
+ }
208
+ if (typeof callback === 'function') {
209
+ callback(result);
210
+ }
211
+ };
212
+ // MT-side worklet event dispatch is handled natively: the SET_WORKLET_EVENT
213
+ // op handler in ops-apply.ts calls __AddEvent with `{ type: 'worklet', value }`,
214
+ // and Lynx native routes those to globalThis.runWorklet (installed by the
215
+ // @lynx-js/react/worklet-runtime side-effect import above).
216
+ // Install the SharedValue bridge flush hook. Wraps __FlushElementTree so
217
+ // every native flush also runs flushAvBridgePublishes (covers the
218
+ // touchmove path: worklet writes SharedValue → calls setStyleProperties →
219
+ // upstream queues a __FlushElementTree microtask → our wrapper publishes
220
+ // diffed SharedValues to BG before the tree flush). Idempotent.
221
+ installAvBridgeFlushHook();
222
+ // ---------------------------------------------------------------------------
223
+ // runOnBackground bridge (MT → BG worklet invocation)
224
+ //
225
+ // SWC's LEPUS pass leaves bare `runOnBackground(_jsFnK)` references in the
226
+ // extracted worklet body — they resolve as a free identifier. Install our
227
+ // MT-side dispatcher as a global so those calls reach the BG event bus.
228
+ // ---------------------------------------------------------------------------
229
+ g['runOnBackground'] = runOnBackground;