@sigx/lynx-runtime 0.2.6 → 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,340 @@
1
+ /**
2
+ * Native gesture detector — BG-side wrapper around Lynx's
3
+ * `__SetGestureDetector(dom, id, type, config, relationMap)` PAPI.
4
+ *
5
+ * Mirrors the contract from upstream `@lynx-js/react/runtime/lib/gesture/`:
6
+ * - GestureType enum values match `GestureTypeInner`.
7
+ * - relationMap keys: `waitFor`, `simultaneous`, `continueWith`.
8
+ * - COMPOSED gestures are walked client-side and each base is registered
9
+ * with `__SetGestureDetector` separately (the platform receives bases).
10
+ *
11
+ * Public surface:
12
+ * - `Gesture.Pan() / .Tap() / .LongPress() / ...` — chainable builders.
13
+ * - `Gesture.Race(...) / .Simultaneous(...) / .Exclusive(...)` — composers.
14
+ * - `useGestureDetector(elRef, gesture)` — attaches the gesture to the
15
+ * element pointed at by elRef. Op-emit is deferred to `onMounted` so the
16
+ * SET_MT_REF op (pushed during the first JSX render) is applied before
17
+ * the SET_GESTURE_DETECTOR op tries to resolve the workletRefMap entry.
18
+ */
19
+ import { onMounted, onUnmounted } from '@sigx/runtime-core';
20
+ import { OP, pushOp, scheduleFlush } from '../op-queue.js';
21
+ import { registerWorkletCtx } from '../run-on-background.js';
22
+ import { sanitizeCaptured } from '../main-thread-ref.js';
23
+ // ---------------------------------------------------------------------------
24
+ // Gesture type enum
25
+ // ---------------------------------------------------------------------------
26
+ export const GestureType = {
27
+ COMPOSED: -1,
28
+ PAN: 0,
29
+ FLING: 1,
30
+ DEFAULT: 2,
31
+ TAP: 3,
32
+ LONGPRESS: 4,
33
+ ROTATION: 5,
34
+ PINCH: 6,
35
+ NATIVE: 7,
36
+ };
37
+ // ---------------------------------------------------------------------------
38
+ // Gesture id allocator (global counter — relations refer across components)
39
+ // ---------------------------------------------------------------------------
40
+ let nextGestureId = 1;
41
+ export function resetGestureIdCounter() {
42
+ nextGestureId = 1;
43
+ }
44
+ function allocGestureId() {
45
+ return nextGestureId++;
46
+ }
47
+ // ---------------------------------------------------------------------------
48
+ // Builder base — uses polymorphic `this` so subclass-specific config setters
49
+ // (`PanBuilder.axis`, `LongPressBuilder.duration`, …) chain off shared
50
+ // callback / relation methods without losing the concrete type.
51
+ // ---------------------------------------------------------------------------
52
+ class GestureBuilderBase {
53
+ gesture;
54
+ constructor(type) {
55
+ this.gesture = {
56
+ __isSerialized: true,
57
+ type,
58
+ id: allocGestureId(),
59
+ callbacks: {},
60
+ waitFor: [],
61
+ simultaneousWith: [],
62
+ continueWith: [],
63
+ };
64
+ }
65
+ setConfigKey(key, value) {
66
+ if (!this.gesture.config)
67
+ this.gesture.config = {};
68
+ this.gesture.config[key] = value;
69
+ return this;
70
+ }
71
+ onBegin(cb) {
72
+ this.gesture.callbacks['onBegin'] = cb;
73
+ return this;
74
+ }
75
+ onStart(cb) {
76
+ this.gesture.callbacks['onStart'] = cb;
77
+ return this;
78
+ }
79
+ onUpdate(cb) {
80
+ this.gesture.callbacks['onUpdate'] = cb;
81
+ return this;
82
+ }
83
+ onEnd(cb) {
84
+ this.gesture.callbacks['onEnd'] = cb;
85
+ return this;
86
+ }
87
+ onFinalize(cb) {
88
+ this.gesture.callbacks['onFinalize'] = cb;
89
+ return this;
90
+ }
91
+ waitFor(...gestures) {
92
+ this.gesture.waitFor.push(...gestures);
93
+ return this;
94
+ }
95
+ simultaneousWith(...gestures) {
96
+ this.gesture.simultaneousWith.push(...gestures);
97
+ return this;
98
+ }
99
+ continueWith(...gestures) {
100
+ this.gesture.continueWith.push(...gestures);
101
+ return this;
102
+ }
103
+ build() {
104
+ return this.gesture;
105
+ }
106
+ }
107
+ // ---------------------------------------------------------------------------
108
+ // Per-type builders
109
+ // ---------------------------------------------------------------------------
110
+ class PanBuilder extends GestureBuilderBase {
111
+ constructor() {
112
+ super(GestureType.PAN);
113
+ }
114
+ axis(a) {
115
+ return this.setConfigKey('axis', a);
116
+ }
117
+ minDistance(n) {
118
+ return this.setConfigKey('minDistance', n);
119
+ }
120
+ }
121
+ class FlingBuilder extends GestureBuilderBase {
122
+ constructor() {
123
+ super(GestureType.FLING);
124
+ }
125
+ minVelocity(n) {
126
+ return this.setConfigKey('minVelocity', n);
127
+ }
128
+ direction(d) {
129
+ return this.setConfigKey('direction', d);
130
+ }
131
+ }
132
+ class TapBuilder extends GestureBuilderBase {
133
+ constructor() {
134
+ super(GestureType.TAP);
135
+ }
136
+ numberOfTaps(n) {
137
+ return this.setConfigKey('numberOfTaps', n);
138
+ }
139
+ maxDistance(n) {
140
+ return this.setConfigKey('maxDistance', n);
141
+ }
142
+ maxDuration(ms) {
143
+ return this.setConfigKey('maxDuration', ms);
144
+ }
145
+ }
146
+ class LongPressBuilder extends GestureBuilderBase {
147
+ constructor() {
148
+ super(GestureType.LONGPRESS);
149
+ }
150
+ /**
151
+ * Minimum hold duration in ms before the gesture activates and `onStart`
152
+ * fires. Native iOS handler (`LynxLongPressGestureHandler`) reads the
153
+ * `minDuration` config key — defaults to 500 ms if not set.
154
+ */
155
+ minDuration(ms) {
156
+ return this.setConfigKey('minDuration', ms);
157
+ }
158
+ /**
159
+ * @deprecated alias for `minDuration` kept for source compatibility. The
160
+ * native handler only honours `minDuration`; this method now writes both
161
+ * keys so older call sites keep working until they migrate.
162
+ */
163
+ duration(ms) {
164
+ this.setConfigKey('duration', ms);
165
+ return this.setConfigKey('minDuration', ms);
166
+ }
167
+ maxDistance(n) {
168
+ return this.setConfigKey('maxDistance', n);
169
+ }
170
+ }
171
+ class PinchBuilder extends GestureBuilderBase {
172
+ constructor() {
173
+ super(GestureType.PINCH);
174
+ }
175
+ }
176
+ class RotationBuilder extends GestureBuilderBase {
177
+ constructor() {
178
+ super(GestureType.ROTATION);
179
+ }
180
+ }
181
+ class NativeBuilder extends GestureBuilderBase {
182
+ constructor() {
183
+ super(GestureType.NATIVE);
184
+ }
185
+ }
186
+ // ---------------------------------------------------------------------------
187
+ // Compose helpers
188
+ // ---------------------------------------------------------------------------
189
+ function asBaseGestures(g) {
190
+ const resolved = resolveGesture(g);
191
+ const out = [];
192
+ collectBases(resolved, out);
193
+ return out;
194
+ }
195
+ function collectBases(g, out) {
196
+ if (g.type === GestureType.COMPOSED) {
197
+ for (const sub of g.gestures)
198
+ collectBases(sub, out);
199
+ return;
200
+ }
201
+ out.push(g);
202
+ }
203
+ function resolveGesture(g) {
204
+ if (g && typeof g.build === 'function') {
205
+ if (g.__isSerialized !== true) {
206
+ return g.build();
207
+ }
208
+ }
209
+ return g;
210
+ }
211
+ function makeComposed(gestures) {
212
+ return {
213
+ __isSerialized: true,
214
+ type: GestureType.COMPOSED,
215
+ gestures,
216
+ };
217
+ }
218
+ // ---------------------------------------------------------------------------
219
+ // Public Gesture namespace
220
+ // ---------------------------------------------------------------------------
221
+ export const Gesture = {
222
+ Pan: () => new PanBuilder(),
223
+ Fling: () => new FlingBuilder(),
224
+ Tap: () => new TapBuilder(),
225
+ LongPress: () => new LongPressBuilder(),
226
+ Pinch: () => new PinchBuilder(),
227
+ Rotation: () => new RotationBuilder(),
228
+ Native: () => new NativeBuilder(),
229
+ /**
230
+ * Race — first recognizer to claim wins. Sibling bases mutually waitFor
231
+ * each other so the platform's gesture arena resolves the priority.
232
+ */
233
+ Race(...gs) {
234
+ const resolved = gs.map(resolveGesture);
235
+ const composed = makeComposed(resolved);
236
+ const bases = asBaseGestures(composed);
237
+ for (const a of bases) {
238
+ for (const b of bases) {
239
+ if (a !== b)
240
+ a.waitFor.push(b);
241
+ }
242
+ }
243
+ return composed;
244
+ },
245
+ /**
246
+ * Simultaneous — all recognizers can fire at once. Sibling bases declare
247
+ * mutual `simultaneousWith`.
248
+ */
249
+ Simultaneous(...gs) {
250
+ const resolved = gs.map(resolveGesture);
251
+ const composed = makeComposed(resolved);
252
+ const bases = asBaseGestures(composed);
253
+ for (const a of bases) {
254
+ for (const b of bases) {
255
+ if (a !== b)
256
+ a.simultaneousWith.push(b);
257
+ }
258
+ }
259
+ return composed;
260
+ },
261
+ /**
262
+ * Exclusive — sequential. Later items waitFor all earlier items.
263
+ */
264
+ Exclusive(...gs) {
265
+ const resolved = gs.map(resolveGesture);
266
+ const composed = makeComposed(resolved);
267
+ for (let i = 1; i < resolved.length; i++) {
268
+ const laterBases = [];
269
+ collectBases(resolved[i], laterBases);
270
+ const earlierBases = [];
271
+ for (let j = 0; j < i; j++)
272
+ collectBases(resolved[j], earlierBases);
273
+ for (const later of laterBases)
274
+ later.waitFor.push(...earlierBases);
275
+ }
276
+ return composed;
277
+ },
278
+ };
279
+ function appendUniqueBases(g, out, seen) {
280
+ if (g.type === GestureType.COMPOSED) {
281
+ for (const sub of g.gestures) {
282
+ appendUniqueBases(sub, out, seen);
283
+ }
284
+ return;
285
+ }
286
+ const base = g;
287
+ if (seen.has(base.id))
288
+ return;
289
+ seen.add(base.id);
290
+ out.push(base);
291
+ }
292
+ function buildSerializedConfig(base) {
293
+ const callbacks = [];
294
+ for (const name in base.callbacks) {
295
+ const cb = base.callbacks[name];
296
+ // Preserve every field the SWC transform emits — the platform's gesture
297
+ // arena may rely on `_workletType: 'main-thread'` (and any other markers)
298
+ // being present at __SetGestureDetector time. We had previously stripped
299
+ // to `{_wkltId,_c,_jsFn}`; that lost `_workletType`, and the gesture
300
+ // arena silently ignored the registration on-device. Spread first, then
301
+ // sanitize `_c` for wire-safety (MainThreadRef instances → wire shape).
302
+ const wireCtx = { ...cb };
303
+ if (cb._c)
304
+ wireCtx._c = sanitizeCaptured(cb._c);
305
+ // Stamp _execId via registerWorkletCtx so runOnBackground inside the
306
+ // gesture callback can route MT→BG dispatches back through the same
307
+ // pipeline used by SET_WORKLET_EVENT.
308
+ registerWorkletCtx(wireCtx);
309
+ callbacks.push({ name, callback: wireCtx });
310
+ }
311
+ const out = { callbacks };
312
+ if (base.config)
313
+ out.config = base.config;
314
+ return out;
315
+ }
316
+ export function useGestureDetector(elRef, gesture) {
317
+ const resolved = resolveGesture(gesture);
318
+ const bases = [];
319
+ appendUniqueBases(resolved, bases, new Set());
320
+ if (bases.length === 0)
321
+ return;
322
+ onMounted(() => {
323
+ for (const base of bases) {
324
+ const config = buildSerializedConfig(base);
325
+ const relationMap = {
326
+ waitFor: base.waitFor.map((g) => g.id),
327
+ simultaneous: base.simultaneousWith.map((g) => g.id),
328
+ continueWith: base.continueWith.map((g) => g.id),
329
+ };
330
+ pushOp(OP.SET_GESTURE_DETECTOR, elRef._wvid, base.id, base.type, config, relationMap);
331
+ }
332
+ scheduleFlush();
333
+ });
334
+ onUnmounted(() => {
335
+ for (const base of bases) {
336
+ pushOp(OP.REMOVE_GESTURE_DETECTOR, elRef._wvid, base.id);
337
+ }
338
+ scheduleFlush();
339
+ });
340
+ }
@@ -0,0 +1 @@
1
+ export { Gesture, GestureType, useGestureDetector, resetGestureIdCounter, } from './gesture-detector.js';
@@ -0,0 +1,319 @@
1
+ import { OP, pushOp, scheduleFlush } from './op-queue.js';
2
+ import { register, unregister } from './event-registry.js';
3
+ import { ShadowElement } from './shadow-element.js';
4
+ import { registerWorkletCtx } from './run-on-background.js';
5
+ function parseEventProp(key) {
6
+ // Main-thread event prefixes: main-thread-bind*, main-thread-catch*
7
+ if (key.startsWith('main-thread-bind')) {
8
+ return { type: 'bindEvent', name: key.slice('main-thread-bind'.length), mainThread: true };
9
+ }
10
+ if (key.startsWith('main-thread-catch')) {
11
+ return { type: 'catchEvent', name: key.slice('main-thread-catch'.length), mainThread: true };
12
+ }
13
+ // Alternative syntax: main-thread:bind*, main-thread:catch*
14
+ if (key.startsWith('main-thread:bind')) {
15
+ return { type: 'bindEvent', name: key.slice('main-thread:bind'.length), mainThread: true };
16
+ }
17
+ if (key.startsWith('main-thread:catch')) {
18
+ return { type: 'catchEvent', name: key.slice('main-thread:catch'.length), mainThread: true };
19
+ }
20
+ if (key.startsWith('global-bind')) {
21
+ return { type: 'bindGlobalEvent', name: key.slice('global-bind'.length) };
22
+ }
23
+ if (key.startsWith('global-catch')) {
24
+ return { type: 'catchGlobalEvent', name: key.slice('global-catch'.length) };
25
+ }
26
+ if (key.startsWith('catch')) {
27
+ return { type: 'catchEvent', name: key.slice('catch'.length) };
28
+ }
29
+ if (/^bind(?!ingx)/.test(key)) {
30
+ return { type: 'bindEvent', name: key.slice('bind'.length) };
31
+ }
32
+ if (/^on[A-Z]/.test(key)) {
33
+ // onTap → { type: 'bindEvent', name: 'tap' }
34
+ const name = key.slice(2, 3).toLowerCase() + key.slice(3);
35
+ return { type: 'bindEvent', name };
36
+ }
37
+ return null;
38
+ }
39
+ // ---------------------------------------------------------------------------
40
+ // Worklet placeholder detection — @lynx-js/react/transform replaces
41
+ // 'main thread' functions with { _wkltId, _c? } placeholders in the BG bundle.
42
+ // ---------------------------------------------------------------------------
43
+ import { sanitizeCaptured } from './main-thread-ref.js';
44
+ function isWorkletPlaceholder(v) {
45
+ return typeof v === 'object' && v !== null && '_wkltId' in v;
46
+ }
47
+ // Track sent worklet ids per (elementId, propKey) to skip redundant ops on re-render.
48
+ const sentWorklets = new Map();
49
+ // Track the sign registered for each (element, propKey) so we can unregister
50
+ // on prop removal / update.
51
+ const elementEventSigns = new Map();
52
+ // Track which sign owns the native event slot per (elementId, 'eventType:eventName').
53
+ // Lynx only supports one __AddEvent per (element, eventType, eventName). When multiple
54
+ // props resolve to the same native event (e.g. main-thread-bindtap + bindtap both map
55
+ // to bindEvent:tap), we keep one sign in __AddEvent and dispatch to all handlers from
56
+ // a multi-handler wrapper in the BG registry.
57
+ const nativeEventSlots = new Map();
58
+ // ---------------------------------------------------------------------------
59
+ // Style normalisation — numeric values → 'Npx' (Lynx requires units)
60
+ // ---------------------------------------------------------------------------
61
+ const DIMENSIONLESS = new Set([
62
+ 'flex',
63
+ 'flexGrow',
64
+ 'flexShrink',
65
+ 'flexOrder',
66
+ 'order',
67
+ 'opacity',
68
+ 'zIndex',
69
+ 'aspectRatio',
70
+ 'fontWeight',
71
+ 'lineClamp',
72
+ ]);
73
+ function normalizeStyle(style) {
74
+ const out = {};
75
+ for (const key of Object.keys(style)) {
76
+ const val = style[key];
77
+ if (typeof val === 'number' && !DIMENSIONLESS.has(key) && val !== 0) {
78
+ out[key] = `${val}px`;
79
+ }
80
+ else {
81
+ out[key] = val;
82
+ }
83
+ }
84
+ return out;
85
+ }
86
+ function shallowEqual(a, b) {
87
+ const ka = Object.keys(a);
88
+ const kb = Object.keys(b);
89
+ if (ka.length !== kb.length)
90
+ return false;
91
+ for (const k of ka) {
92
+ if (a[k] !== b[k])
93
+ return false;
94
+ }
95
+ return true;
96
+ }
97
+ // ---------------------------------------------------------------------------
98
+ // Class resolution — merges user :class with transition classes
99
+ // ---------------------------------------------------------------------------
100
+ export function resolveClass(el) {
101
+ if (el._transitionClasses.size === 0)
102
+ return el._baseClass;
103
+ const parts = [];
104
+ if (el._baseClass)
105
+ parts.push(el._baseClass);
106
+ for (const cls of el._transitionClasses)
107
+ parts.push(cls);
108
+ return parts.join(' ');
109
+ }
110
+ // ---------------------------------------------------------------------------
111
+ // RendererOptions implementation
112
+ // ---------------------------------------------------------------------------
113
+ export const nodeOps = {
114
+ createElement(type) {
115
+ const el = new ShadowElement(type);
116
+ pushOp(OP.CREATE, el.id, type);
117
+ scheduleFlush();
118
+ return el;
119
+ },
120
+ createText(text) {
121
+ const el = new ShadowElement('#text');
122
+ pushOp(OP.CREATE_TEXT, el.id);
123
+ if (text)
124
+ pushOp(OP.SET_TEXT, el.id, text);
125
+ scheduleFlush();
126
+ return el;
127
+ },
128
+ // Comment nodes are used as position anchors for conditionals / Fragment.
129
+ // Materialised as invisible placeholder elements on the Main Thread.
130
+ createComment(_text) {
131
+ const el = new ShadowElement('#comment');
132
+ pushOp(OP.CREATE, el.id, '__comment');
133
+ scheduleFlush();
134
+ return el;
135
+ },
136
+ setText(node, text) {
137
+ pushOp(OP.SET_TEXT, node.id, text);
138
+ scheduleFlush();
139
+ },
140
+ setElementText(el, text) {
141
+ // Remove all children from shadow tree
142
+ while (el.firstChild) {
143
+ const child = el.firstChild;
144
+ el.removeChild(child);
145
+ pushOp(OP.REMOVE, el.id, child.id);
146
+ }
147
+ // Set text content directly on the element
148
+ pushOp(OP.SET_TEXT, el.id, text);
149
+ scheduleFlush();
150
+ },
151
+ insert(child, parent, anchor) {
152
+ // Always update the shadow tree (the core renderer needs sync tree queries).
153
+ parent.insertBefore(child, anchor ?? null);
154
+ // Lynx's native <list> only accepts <list-item> children.
155
+ // Skip comment/text anchors to avoid NSInvalidArgumentException.
156
+ if (parent.type === 'list'
157
+ && (child.type === '#comment' || child.type === '#text')) {
158
+ return;
159
+ }
160
+ // If the anchor is a comment node inside a <list>, walk forward to find
161
+ // the next real sibling so the MT __InsertElementBefore has a valid ref.
162
+ let resolvedAnchor = anchor ?? null;
163
+ if (parent.type === 'list') {
164
+ while (resolvedAnchor
165
+ && (resolvedAnchor.type === '#comment'
166
+ || resolvedAnchor.type === '#text')) {
167
+ resolvedAnchor = resolvedAnchor.next;
168
+ }
169
+ }
170
+ const anchorId = resolvedAnchor ? resolvedAnchor.id : -1;
171
+ pushOp(OP.INSERT, parent.id, child.id, anchorId);
172
+ scheduleFlush();
173
+ },
174
+ remove(child) {
175
+ if (child.parent) {
176
+ const parentId = child.parent.id;
177
+ child.parent.removeChild(child);
178
+ pushOp(OP.REMOVE, parentId, child.id);
179
+ scheduleFlush();
180
+ }
181
+ },
182
+ patchProp(el, key, _prevValue, nextValue) {
183
+ // Handle main-thread:ref — bind a MainThreadRef to this element
184
+ if (key === 'main-thread:ref') {
185
+ if (nextValue != null) {
186
+ const mtRef = nextValue;
187
+ pushOp(OP.SET_MT_REF, el.id, mtRef._wvid);
188
+ }
189
+ scheduleFlush();
190
+ return;
191
+ }
192
+ const event = parseEventProp(key);
193
+ if (event) {
194
+ // Worklet placeholders ({ _wkltId, _c? }) emitted by @lynx-js/react/transform
195
+ // bypass the BG event-registry path entirely — the MT side dispatches.
196
+ if (event.mainThread && nextValue != null && isWorkletPlaceholder(nextValue)) {
197
+ let elWorklets = sentWorklets.get(el.id);
198
+ const prevId = elWorklets?.get(key);
199
+ if (prevId !== nextValue._wkltId) {
200
+ if (!elWorklets) {
201
+ elWorklets = new Map();
202
+ sentWorklets.set(el.id, elWorklets);
203
+ }
204
+ elWorklets.set(key, nextValue._wkltId);
205
+ const wireCtx = {
206
+ _wkltId: nextValue._wkltId,
207
+ };
208
+ if (nextValue._c)
209
+ wireCtx._c = sanitizeCaptured(nextValue._c);
210
+ if (nextValue._jsFn)
211
+ wireCtx._jsFn = nextValue._jsFn;
212
+ // Stamp _execId so MT can route runOnBackground dispatches back
213
+ // to the JsFnHandles inside _jsFn / _c.
214
+ registerWorkletCtx(wireCtx);
215
+ pushOp(OP.SET_WORKLET_EVENT, el.id, event.type, event.name, wireCtx);
216
+ scheduleFlush();
217
+ }
218
+ return;
219
+ }
220
+ let propSigns = elementEventSigns.get(el.id);
221
+ const nativeKey = `${event.type}:${event.name}`;
222
+ if (nextValue != null) {
223
+ const handler = nextValue;
224
+ // Get or create the native event slot for this (element, eventType, eventName).
225
+ let elSlots = nativeEventSlots.get(el.id);
226
+ if (!elSlots) {
227
+ elSlots = new Map();
228
+ nativeEventSlots.set(el.id, elSlots);
229
+ }
230
+ let slot = elSlots.get(nativeKey);
231
+ if (!slot) {
232
+ // First handler for this native event — register with Lynx.
233
+ const sign = register((data) => {
234
+ // Dispatch to all handlers registered for this slot.
235
+ const s = elSlots.get(nativeKey);
236
+ if (s) {
237
+ for (const h of s.handlers.values())
238
+ h(data);
239
+ }
240
+ });
241
+ slot = { sign, handlers: new Map() };
242
+ elSlots.set(nativeKey, slot);
243
+ pushOp(OP.SET_EVENT, el.id, event.type, event.name, sign);
244
+ }
245
+ // Add/update this prop's handler in the slot.
246
+ slot.handlers.set(key, handler);
247
+ // Track prop→sign for re-render updates.
248
+ if (!propSigns) {
249
+ propSigns = new Map();
250
+ elementEventSigns.set(el.id, propSigns);
251
+ }
252
+ propSigns.set(key, slot.sign);
253
+ }
254
+ else {
255
+ // Handler removed.
256
+ const elSlots = nativeEventSlots.get(el.id);
257
+ const slot = elSlots?.get(nativeKey);
258
+ if (slot) {
259
+ slot.handlers.delete(key);
260
+ if (slot.handlers.size === 0) {
261
+ // No more handlers — unregister from Lynx.
262
+ unregister(slot.sign);
263
+ elSlots.delete(nativeKey);
264
+ pushOp(OP.REMOVE_EVENT, el.id, event.type, event.name);
265
+ }
266
+ }
267
+ propSigns?.delete(key);
268
+ }
269
+ }
270
+ else if (key === 'style') {
271
+ const style = nextValue != null && typeof nextValue === 'object'
272
+ ? normalizeStyle(nextValue)
273
+ : {};
274
+ const effective = el._vShowHidden ? { ...style, display: 'none' } : style;
275
+ // Skip SET_STYLE when structurally unchanged. JSX inline `style={{...}}`
276
+ // creates a fresh object every render but its keys/values typically don't
277
+ // change between renders. Re-emitting SET_STYLE would overwrite any MT
278
+ // worklet's setStyleProperties calls (pink/scale on tap, etc.) on every
279
+ // unrelated signal change. Shallow-equal previous _style suffices since
280
+ // sigx normalises everything to a flat string→string|number map.
281
+ const prev = el._style;
282
+ const sameStyle = prev != null && shallowEqual(prev, effective);
283
+ el._style = style;
284
+ if (!sameStyle) {
285
+ pushOp(OP.SET_STYLE, el.id, effective);
286
+ }
287
+ }
288
+ else if (key === 'class') {
289
+ el._baseClass = nextValue ?? '';
290
+ const finalClass = resolveClass(el);
291
+ pushOp(OP.SET_CLASS, el.id, finalClass);
292
+ }
293
+ else if (key === 'id') {
294
+ pushOp(OP.SET_ID, el.id, nextValue);
295
+ }
296
+ else {
297
+ pushOp(OP.SET_PROP, el.id, key, nextValue);
298
+ }
299
+ scheduleFlush();
300
+ },
301
+ parentNode(node) {
302
+ return node.parent;
303
+ },
304
+ nextSibling(node) {
305
+ return node.next;
306
+ },
307
+ cloneNode(node) {
308
+ // Lynx has no native clone — create a new shadow element of the same type
309
+ const el = new ShadowElement(node.type);
310
+ if (node.type === '#text') {
311
+ pushOp(OP.CREATE_TEXT, el.id);
312
+ }
313
+ else {
314
+ pushOp(OP.CREATE, el.id, node.type);
315
+ }
316
+ scheduleFlush();
317
+ return el;
318
+ },
319
+ };