@symbiote-native/engine 0.2.0 → 0.4.0

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 (75) hide show
  1. package/README.md +24 -11
  2. package/build/accessibility-props.d.ts +19 -0
  3. package/build/accessibility-props.js +209 -0
  4. package/build/action-sheet-ios/index.js +4 -1
  5. package/build/animated/animations/composition.js +3 -1
  6. package/build/animated/animations/spring-config.js +4 -1
  7. package/build/animated/animations/spring.js +23 -9
  8. package/build/animated/animations/timing.js +3 -2
  9. package/build/animated/bezier.js +2 -1
  10. package/build/animated/color.js +4 -2
  11. package/build/animated/event.js +7 -3
  12. package/build/animated/graph.js +8 -3
  13. package/build/animated/index.d.ts +2 -2
  14. package/build/animated/index.js +2 -2
  15. package/build/animated/interpolation.js +7 -2
  16. package/build/animated/mock.js +1 -1
  17. package/build/animated/native/native-animated.js +1 -1
  18. package/build/animated/operators.js +27 -6
  19. package/build/animated/props.js +1 -1
  20. package/build/animated/rgba.js +18 -3
  21. package/build/animated/style.js +13 -3
  22. package/build/animated/value-xy.d.ts +8 -5
  23. package/build/animated/value-xy.js +4 -1
  24. package/build/app-registry/index.d.ts +1 -0
  25. package/build/app-registry/index.js +15 -5
  26. package/build/appearance/index.js +4 -1
  27. package/build/commit.d.ts +35 -0
  28. package/build/commit.js +613 -74
  29. package/build/debug.js +10 -4
  30. package/build/events/index.js +142 -88
  31. package/build/fabric-props.js +171 -11
  32. package/build/fabric.d.ts +10 -3
  33. package/build/fabric.js +11 -2
  34. package/build/host-behavior.d.ts +47 -0
  35. package/build/host-behavior.js +262 -0
  36. package/build/host-instance/index.d.ts +2 -10
  37. package/build/host-instance/index.js +13 -46
  38. package/build/image-loader.js +3 -2
  39. package/build/index.d.ts +24 -17
  40. package/build/index.js +27 -13
  41. package/build/interaction-manager/index.js +2 -1
  42. package/build/layout-animation/index.js +12 -4
  43. package/build/native-events.js +2 -1
  44. package/build/native-modules/index.js +3 -1
  45. package/build/node.d.ts +121 -2
  46. package/build/node.js +623 -41
  47. package/build/pan-responder/index.js +20 -9
  48. package/build/permissions-android/index.js +3 -1
  49. package/build/platform/index.android.d.ts +1 -1
  50. package/build/platform/index.android.js +1 -1
  51. package/build/platform/index.ios.d.ts +1 -1
  52. package/build/platform-color/index.js +3 -1
  53. package/build/post-commit.d.ts +1 -0
  54. package/build/post-commit.js +6 -0
  55. package/build/process-background-image/index.js +30 -9
  56. package/build/process-filter.js +11 -4
  57. package/build/registry.js +10 -2
  58. package/build/report-error.js +4 -1
  59. package/build/share/index.android.js +1 -1
  60. package/build/share/index.ios.js +1 -1
  61. package/build/status-bar/index.android.js +3 -4
  62. package/build/status-bar/index.ios.js +1 -1
  63. package/build/style-registry/index.d.ts +11 -2
  64. package/build/style-registry/index.js +223 -169
  65. package/build/style-registry/scope.d.ts +15 -1
  66. package/build/style-registry/scope.js +29 -27
  67. package/build/styles.d.ts +11 -1
  68. package/build/surface.d.ts +1 -1
  69. package/build/surface.js +8 -0
  70. package/build/tags.d.ts +1 -0
  71. package/build/tags.js +31 -1
  72. package/build/touch-history.js +9 -2
  73. package/build/vibration/index.ios.js +1 -1
  74. package/build/view-config.js +20 -2
  75. package/package.json +12 -2
package/build/node.js CHANGED
@@ -4,9 +4,19 @@
4
4
  // tree mutable while the Fabric mirror stays persistent lets every adapter mutate
5
5
  // freely without touching Fabric's clone-on-write protocol directly, and it
6
6
  // lives here in shared so no adapter re-implements it.
7
+ import { isAriaAliasKey } from './accessibility-props.js';
7
8
  import { isEventFor } from './view-config.js';
8
- import { isClassNameValue, resolveClassName } from './style-registry/index.js';
9
+ import { canonicalClassName, isClassNameValue, resolveActiveClassName, resolveClassName, } from './style-registry/index.js';
9
10
  import { dlog } from './debug.js';
11
+ import { attachHostBehavior, hasHostBehaviors, markDetachCandidate, ownsListener, reattachHostBehaviors, stashAppListener, } from './host-behavior.js';
12
+ // A cycle, deliberately: commit.ts imports this module for the node shape, and the imperative
13
+ // methods below call back into it. Neither side touches the other at module-evaluation time -
14
+ // only inside a function body - so every loader (tsc, vitest, Metro) resolves it fine. The
15
+ // alternative was a load-time `SymbioteNode.prototype.measure = ...` installed from elsewhere,
16
+ // which is exactly the registration-side-effect shape Metro's inlineRequires silently drops in
17
+ // release builds (see CLAUDE.md, "Never make correctness depend on a module's load-time side
18
+ // effect").
19
+ import { measure as engineMeasure, measureInWindow as engineMeasureInWindow, measureLayout as engineMeasureLayout, setNativeProps as engineSetNativeProps, dispatchViewCommand, } from './commit.js';
10
20
  const BRAND = Symbol('symbiote.node');
11
21
  // A node carries the Fabric view name directly, so adding a primitive (Image,
12
22
  // ScrollView, TextInput) is just a new string from the adapter, no core change.
@@ -25,27 +35,116 @@ export function isSymbioteEvent(value) {
25
35
  const nativeEvent = Reflect.get(value, 'nativeEvent');
26
36
  return typeof nativeEvent === 'object' && nativeEvent !== null;
27
37
  }
28
- export function createElement(component, isText = false) {
29
- return {
30
- [BRAND]: true,
31
- component,
32
- isText,
33
- props: {},
34
- listeners: undefined,
35
- children: [],
36
- parent: undefined,
37
- };
38
+ const FOCUS_COMMAND = 'focus';
39
+ const BLUR_COMMAND = 'blur';
40
+ // The one shape every retained node has. A class, not an object literal, for two reasons: the six
41
+ // imperative methods live on the shared prototype instead of being allocated per node (see
42
+ // ISymbioteNode above), and both factories below mint the same hidden class.
43
+ //
44
+ // Fields are `declare`d and assigned in the constructor rather than written as class fields: with
45
+ // ES2022 field semantics the two are equivalent in meaning but not in emit, and a plain
46
+ // constructor assignment is the shape every engine (V8 and Hermes both) handles without a
47
+ // define-per-field.
48
+ class SymbioteNode {
49
+ constructor(component, isText, props) {
50
+ this[BRAND] = true;
51
+ this.component = component;
52
+ this.isText = isText;
53
+ this.props = props;
54
+ this.listeners = undefined;
55
+ this.children = [];
56
+ this.parent = undefined;
57
+ // A node that has never committed must never take a fast path built on "the mirror already
58
+ // agrees with me", so all three flags start raised - including for createRawText, whose props
59
+ // are assigned here rather than through setText.
60
+ this.dirty = true;
61
+ this.propsDirty = true;
62
+ // Assigned here, not lazily on first use: every slot present from the constructor keeps one
63
+ // hidden class for every node. Adding it on demand buys a shape transition per aria-bearing
64
+ // node, which is the opposite of what this field is for.
65
+ //
66
+ // Starts false, and that is COMPLETE rather than optimistic: the only two constructions are
67
+ // `createElement`'s `{}` and `createRawText`'s `{ text }`, so no aria key can arrive here. It
68
+ // was first written as `hasAriaAliases(props)` — a probe that reads as a safeguard and can
69
+ // never fire, which the break-test caught by staying green with it removed. If a construction
70
+ // path is ever added that passes real props, this line owes that probe back.
71
+ this.hasAriaAlias = false;
72
+ this.structureDirty = true;
73
+ this.committed = undefined;
74
+ this.styleParts = undefined;
75
+ // Assigned here for the same hidden-class reason as `hasAriaAlias` above; `attachHostBehavior`
76
+ // overwrites it a few lines later for the rare node that has a behavior.
77
+ this.payloadFold = undefined;
78
+ }
79
+ measure(callback) {
80
+ engineMeasure(this, callback);
81
+ }
82
+ measureInWindow(callback) {
83
+ engineMeasureInWindow(this, callback);
84
+ }
85
+ measureLayout(relativeToNativeNode, onSuccess, onFail) {
86
+ if (!isSymbioteNode(relativeToNativeNode)) {
87
+ dlog('measureLayout: relative target must be a host ref');
88
+ return;
89
+ }
90
+ engineMeasureLayout(this, relativeToNativeNode, onSuccess, onFail);
91
+ }
92
+ setNativeProps(nativeProps) {
93
+ engineSetNativeProps(this, nativeProps);
94
+ }
95
+ focus() {
96
+ dispatchViewCommand(this, FOCUS_COMMAND, []);
97
+ }
98
+ blur() {
99
+ dispatchViewCommand(this, BLUR_COMMAND, []);
100
+ }
101
+ }
102
+ /**
103
+ * The committed record for `node`, or `undefined` if it has never been committed - or if `node` is
104
+ * not the raw retained node at all.
105
+ *
106
+ * That second case is the reason this is a function rather than a bare `node.committed` read. The
107
+ * engine identifies a node BY IDENTITY, and the classic way to break that is to hand the engine a
108
+ * wrapper instead of the node: a Vue `reactive()`/deep-`ref()` Proxy around a host element is the
109
+ * one that actually happens (see the vue-adapter-reactivity skill; `shallowRef` is the fix).
110
+ *
111
+ * The old WeakMap caught this for free - a Proxy is a different object, so `mirror.get(proxy)` missed
112
+ * and every imperative API bailed with a clear "node not committed". A plain property read does NOT:
113
+ * a Proxy forwards `proxy.committed` straight to the target and hands back a real record, whose
114
+ * `handle` Vue would then deep-wrap on the way out. That handle is a JSI host object; a Proxy around
115
+ * it reaches `cloneNodeWithNewProps` and fails somewhere deep in native, far from the cause.
116
+ *
117
+ * So the identity check that was implicit in the WeakMap is explicit here: a record written on the
118
+ * raw node names it, and `record.owner !== node` means whatever we were handed is not that node.
119
+ * One reference comparison, and the wrap now fails LOUDER than it used to rather than quieter.
120
+ */
121
+ export function committedOf(node) {
122
+ const record = node.committed;
123
+ if (record === undefined)
124
+ return undefined;
125
+ if (record.owner !== node) {
126
+ dlog(`node identity mismatch: committed record belongs to node=${debugNodeId(record.owner)}, ` +
127
+ `not to the object handed in. A wrapped/proxied node (Vue reactive() or deep ref() around ` +
128
+ `a host element) is the usual cause - hold host nodes with shallowRef.`);
129
+ return undefined;
130
+ }
131
+ return record;
132
+ }
133
+ export function createElement(component, isText = false,
134
+ // The intrinsic tag this node came from, when it differs from the Fabric view name above. The
135
+ // behavior registry is keyed by tag and the node only ever carries the resolved name, so an
136
+ // adapter lowering `<Pressable>` has to hand the tag over here or the registration cannot fire
137
+ // (host-behavior.ts, `attached`). Nothing is stored — the lookup happens once, right below.
138
+ tag = component) {
139
+ const node = new SymbioteNode(component, isText, {});
140
+ // Gated on the boolean, not on the Map: this runs ~9 000 times per benchmark create, and an app
141
+ // that registers nothing must pay one boolean read rather than a hash lookup per node.
142
+ if (hasHostBehaviors())
143
+ attachHostBehavior(node, tag);
144
+ return node;
38
145
  }
39
146
  export function createRawText(text) {
40
- return {
41
- [BRAND]: true,
42
- component: RAW_TEXT_COMPONENT,
43
- isText: false,
44
- props: { text },
45
- listeners: undefined,
46
- children: [],
47
- parent: undefined,
48
- };
147
+ return new SymbioteNode(RAW_TEXT_COMPONENT, false, { text });
49
148
  }
50
149
  // `instanceHandle` round-trips through Fabric unchanged: the object we pass to
51
150
  // createNode comes back as the event target. We brand our nodes so the event
@@ -80,30 +179,221 @@ export function createAnchor() {
80
179
  export function isAnchor(node) {
81
180
  return node.component === ANCHOR_COMPONENT;
82
181
  }
182
+ // A raw text with no characters must not reach Fabric. Its fragment is dropped by
183
+ // AttributedString::appendFragment, but the text walk has already flagged "the last child was raw
184
+ // text", so the NEXT raw sibling merges into `fragments.back()` of an empty vector and the process
185
+ // aborts. The commit walk skips such a node exactly as it skips an anchor (commit.ts,
186
+ // renderableChildren); an empty string paints nothing either way, so nothing is lost. `''` only —
187
+ // a whitespace-only string is real content inside a <Text>.
188
+ export function isEmptyRawText(node) {
189
+ return node.component === RAW_TEXT_COMPONENT && node.props.text === '';
190
+ }
191
+ // Dirty-marking: lets reconcile return an untouched subtree by reference instead of rebuilding
192
+ // every node's Fabric props and deep-comparing them against the mirror. That walk costs ~13 us per
193
+ // node on device (Hermes, iOS Debug, examples/react benchmark screen), so without the flag a
194
+ // 1200-node tree burns a whole 16.6 ms frame no matter how small the change - the cost tracks TREE
195
+ // SIZE, not change size.
196
+ //
197
+ // Marking walks up to the first ALREADY-dirty ancestor and stops, so a burst of mutations under one
198
+ // subtree pays for one chain walk rather than one per mutation. Reconcile clears every node it
199
+ // visits, which keeps the invariant "an ancestor of a dirty node is dirty" across commits.
200
+ //
201
+ // Listener changes deliberately do NOT mark. `node.listeners` never reaches Fabric (event dispatch
202
+ // reads it straight off the retained node) and React hands us a fresh handler closure on nearly
203
+ // every render, so marking there would re-dirty the whole tree every commit and hand the win back.
204
+ // The one listener that DOES change a Fabric prop, `layout`, raises `onLayout` through setProp
205
+ // below and is marked that way.
206
+ /**
207
+ * Change which Fabric view a node commits as, keeping the node's identity.
208
+ *
209
+ * The commit walk already re-creates a node whose `viewName` no longer matches its committed one —
210
+ * that is how a `<Text>` moving in or out of another `<Text>` flips between RCTText and
211
+ * RCTVirtualText (`commit.ts`, reason `view-kind`). This exposes the same door for a prop-driven
212
+ * view choice, so `intrinsicWhen` is honoured on UPDATE and not only at create.
213
+ *
214
+ * The POLICY stays out of the engine: which prop decides, and which view it decides between, lives
215
+ * in `HOST_PRIMITIVES` and is read by `resolveIntrinsicTag` in `@symbiote-native/components`. The
216
+ * engine only knows how to swap the name — the same split every other spec-driven fold has here.
217
+ *
218
+ * A no-op when the name is unchanged, so a renderer may call it on every update without comparing
219
+ * first.
220
+ */
221
+ export function setNodeComponent(node, component) {
222
+ if (node.component === component)
223
+ return;
224
+ node.component = component;
225
+ // `dirty` alone is not enough: the walk's reuse test also requires the node be visited at all,
226
+ // and a node whose own props did not change this tick is exactly the case that would be skipped.
227
+ markDirty(node);
228
+ markPropsDirty(node);
229
+ }
230
+ export function markDirty(node) {
231
+ let current = node;
232
+ while (current !== undefined && !current.dirty) {
233
+ current.dirty = true;
234
+ current = current.parent;
235
+ }
236
+ }
237
+ // The prop-write twin of markDirty: raises this node's OWN props flag and then bubbles the subtree
238
+ // flag as usual. Every path that writes `node.props` must come through here - setProp and setText
239
+ // below, setNativeProps in commit.ts (which writes the record directly and so owes its own mark).
240
+ //
241
+ // Note the two flags are raised INDEPENDENTLY rather than one implying the other. markDirty stops
242
+ // at the first already-dirty ancestor, so a node dirtied a moment ago by a child's change would
243
+ // otherwise have its own prop write silently dropped: the walk would exit before setting anything
244
+ // here. Setting propsDirty first, unconditionally, is what makes that ordering safe.
245
+ export function markPropsDirty(node) {
246
+ node.propsDirty = true;
247
+ markDirty(node);
248
+ }
249
+ // The structural twin. Raised on the PARENT whose child list changed - never on the moved child,
250
+ // for the same reason markDirty is not (see the structural ops below).
251
+ //
252
+ // Every caller must reach here BEFORE mutating `parent.children`, and that ordering is now
253
+ // load-bearing rather than stylistic. reconcile stores the reconciled child list in the committed
254
+ // record BY REFERENCE, so for a parent holding no anchors the record ALIASES `parent.children`;
255
+ // this call is the last moment the committed list can still be read. Taking the copy here means it
256
+ // is taken once per parent per commit->mutation cycle, and only for parents that actually change,
257
+ // instead of once per node per commit - 9 002 arrays on a 1 000-row create, all but a handful
258
+ // allocated only to be discarded unread.
259
+ //
260
+ // The identity test is what keeps it honest: a record whose `children` is NOT `parent.children`
261
+ // either already holds a copy (this cycle's first structural op ran) or holds the private array
262
+ // renderableChildren built to flatten anchors away, which nobody mutates. Neither needs saving.
263
+ export function markStructureDirty(parent) {
264
+ const record = parent.committed;
265
+ if (record !== undefined && record.children === parent.children) {
266
+ record.children = parent.children.slice();
267
+ }
268
+ parent.structureDirty = true;
269
+ markDirty(parent);
270
+ }
271
+ // How many prop writes actually landed, and how many the no-op guard below turned away.
272
+ // Read-and-zeroed through readCommitProfile() (commit.ts), which folds them into the same window
273
+ // as the walk numbers so one read prices both halves: `propNoops` is the waste an adapter is
274
+ // generating above the engine, `nodesVisited` is what that waste costs below it.
275
+ //
276
+ // Not gated behind isDebug(), for the same reason the commit profile is not: an integer increment
277
+ // is noise next to the prop write it counts, and the figure is only meaningful from a release
278
+ // build. A per-call dlog was the obvious alternative and is deliberately NOT here - the Angular
279
+ // screen that motivated the guard emitted 90 000 no-op writes on one press, and a log line each
280
+ // would measure the logging rather than the code (see the `perf-claims-need-numbers` rule).
281
+ const propStats = { writes: 0, noops: 0 };
282
+ export function takePropStats() {
283
+ const snapshot = { writes: propStats.writes, noops: propStats.noops };
284
+ propStats.writes = 0;
285
+ propStats.noops = 0;
286
+ return snapshot;
287
+ }
83
288
  // A pure prop set: no event inference. `onTintColor` is a Switch prop and reaches
84
289
  // Fabric like any other; the event-vs-prop decision is made by routeProp, never by
85
290
  // the key's name.
291
+ //
292
+ // Writing a value the node already holds is a NO-OP and returns before markDirty. Fabric never saw
293
+ // a difference either way - reconcile rebuilds the node's Fabric props and `propsEqual` finds them
294
+ // identical, so no clone is emitted - but the mark itself is not free: it walks to the first
295
+ // already-dirty ancestor and strips every one of them of the commit walk's early exit, so an
296
+ // otherwise untouched subtree gets re-walked purely to prove it is untouched. The guard makes that
297
+ // whole bug class free for every adapter instead of each one having to remember to diff first
298
+ // (measured: Angular's Pressable host bag pushed 104 000 setProp calls for a screen Solid built in
299
+ // 12 000, 90 000 of them writing `undefined` over a key that was not there).
300
+ //
301
+ // Three deliberate choices:
302
+ //
303
+ // - `Object.hasOwn`, not `node.props[key] === undefined`. A key explicitly present holding
304
+ // `undefined` is not an absent key: `delete` genuinely changes the record's shape, and
305
+ // `setNativeProps` writes node.props directly and can leave exactly such a key behind. Fabric
306
+ // itself cannot tell the two apart (fabricProps skips undefined values), but node.props is also
307
+ // read outside the commit path, so the retained tree keeps the shape callers asked for.
308
+ // - `Object.is`, not a deep compare. A style object, an array, or a handler closure is a fresh
309
+ // reference on nearly every render, so the guard simply never fires for them - correct, since an
310
+ // adapter is free to hand back the SAME reference with mutated contents and identity cannot see
311
+ // that. A deep compare on every prop write would cost more than the walk it saves.
312
+ // - The in-place-mutation hazard that leaves is already instrumented: a node skipped as clean whose
313
+ // props have drifted is exactly what `warnIfStale` reports as DIRTY-MISS under DEBUG (commit.ts).
86
314
  export function setProp(node, key, value) {
87
315
  if (value === undefined) {
316
+ if (!Object.hasOwn(node.props, key)) {
317
+ propStats.noops += 1;
318
+ return;
319
+ }
88
320
  delete node.props[key];
89
321
  }
90
322
  else {
323
+ if (Object.is(node.props[key], value)) {
324
+ propStats.noops += 1;
325
+ return;
326
+ }
91
327
  node.props[key] = value;
92
328
  }
329
+ // The single choke point for the aria gate. `routeProp`'s other branches — class, style,
330
+ // activeStyle, on* — return before reaching here and none of them can carry an alias, so every
331
+ // `role` / `aria-*` write in the engine passes through this line.
332
+ if (!node.hasAriaAlias && isAriaAliasKey(key))
333
+ node.hasAriaAlias = true;
334
+ propStats.writes += 1;
335
+ markPropsDirty(node);
93
336
  }
94
- // Fabric gates layout events behind a boolean prop (BaseViewProps.onLayout): unlike
95
- // scroll / touch / change, which the native component emits unconditionally, a
96
- // layout event fires only when the shadow node is flagged. So a `layout` listener
97
- // must also raise that prop, mirroring RN's `onLayout: true` validAttribute;
98
- // otherwise onLayout never fires and anything measuring its own box (VirtualizedList
99
- // viewport) stays at zero.
100
- const LAYOUT_EVENT = 'layout';
101
- const LAYOUT_FLAG_PROP = 'onLayout';
337
+ // Fabric gates a handful of events behind a BOOLEAN prop: unlike scroll / touch / change, which
338
+ // the native component emits unconditionally, these fire only when the shadow node carries the
339
+ // flag. RN raises them with an `on*: true` validAttribute; we drop function props from the
340
+ // payload, so a gated handler attaches on our side and the native event simply never arrives.
341
+ // That is silent - a test asserting the listener is present passes, and only a device shows it.
342
+ //
343
+ // The list is exhaustive as of react-native 0.86: every `bool on*` field in Fabric's C++ props
344
+ // (`ReactCommon/react/renderer/components/**`), each read behind an `if` before the emitter runs:
345
+ //
346
+ // BaseViewProps.onLayout ParagraphShadowNode.cpp / RCTViewComponentView
347
+ // AccessibilityProps.onAccessibilityTap RCTViewComponentView.mm:1603
348
+ // AccessibilityProps.onAccessibilityMagicTap RCTViewComponentView.mm:1613
349
+ // AccessibilityProps.onAccessibilityEscape RCTViewComponentView.mm:1623
350
+ // AccessibilityProps.onAccessibilityAction RCTViewComponentView.mm:1633
351
+ // BaseParagraphProps.onTextLayout ParagraphShadowNode.cpp:351
352
+ //
353
+ // Keyed by the post-`listenerName` event name, valued with the payload key. `magicTap` maps to
354
+ // `onMagicTap` and NOT to the C++ member name `onAccessibilityMagicTap`, because `onMagicTap` is
355
+ // what RN's own view config declares (BaseViewConfig.ios.js) - the two disagree upstream, and
356
+ // matching stock is the only defensible choice until RN resolves it.
357
+ const GATED_EVENT_PROPS = new Map([
358
+ ['layout', 'onLayout'],
359
+ ['textLayout', 'onTextLayout'],
360
+ ['accessibilityTap', 'onAccessibilityTap'],
361
+ ['magicTap', 'onMagicTap'],
362
+ ['accessibilityEscape', 'onAccessibilityEscape'],
363
+ ['accessibilityAction', 'onAccessibilityAction'],
364
+ ]);
102
365
  // The explicit event channel. Structural adapters (Svelte addEventListener, Angular
103
366
  // Renderer2.listen) call this directly with an already-known event name; flat-bag
104
367
  // adapters reach it through routeProp. A non-function value clears the listener.
368
+ /**
369
+ * Install a listener the BEHAVIOR owns, bypassing the ownership check.
370
+ *
371
+ * `setEventListener` diverts an owned name into the stash, which is right for an app listener and
372
+ * circular for the behavior's own dispatcher — it would stash itself and never occupy the slot it
373
+ * exists to hold. This is the one writer allowed past that gate.
374
+ */
375
+ export function setBehaviorListener(node, name, listener) {
376
+ (node.listeners ??= new Map()).set(name, listener);
377
+ const flagProp = GATED_EVENT_PROPS.get(name);
378
+ if (flagProp !== undefined)
379
+ setProp(node, flagProp, true);
380
+ }
105
381
  export function setEventListener(node, name, value) {
106
382
  const isHandler = typeof value === 'function';
383
+ // A name a host behavior OWNS never reaches `node.listeners` — the behavior's dispatcher holds
384
+ // that slot and the app's callback is stashed beside it. `node.listeners` is single-slot, so
385
+ // without this the two evict each other and the last writer wins with no diagnostic; and the
386
+ // keys at stake are the ones a gesture STARTS on, so the loser is silently pressless. The
387
+ // component wrapper used to mediate this by destructuring the app's callbacks out before they
388
+ // reached the node; lowering removes the mediator. Gated on the boolean first, so an app with no
389
+ // behavior registered pays one read.
390
+ if (hasHostBehaviors() && ownsListener(node, name)) {
391
+ stashAppListener(node, name, isHandler ? value : undefined);
392
+ const flagged = GATED_EVENT_PROPS.get(name);
393
+ if (flagged !== undefined)
394
+ setProp(node, flagged, isHandler ? true : undefined);
395
+ return;
396
+ }
107
397
  if (isHandler) {
108
398
  const handler = value;
109
399
  const listeners = (node.listeners ??= new Map());
@@ -112,8 +402,9 @@ export function setEventListener(node, name, value) {
112
402
  else {
113
403
  node.listeners?.delete(name);
114
404
  }
115
- if (name === LAYOUT_EVENT)
116
- setProp(node, LAYOUT_FLAG_PROP, isHandler ? true : undefined);
405
+ const flagProp = GATED_EVENT_PROPS.get(name);
406
+ if (flagProp !== undefined)
407
+ setProp(node, flagProp, isHandler ? true : undefined);
117
408
  }
118
409
  const ON_PREFIX = /^on[A-Z]/;
119
410
  // onChange -> change
@@ -148,19 +439,193 @@ const RESPONDER_EVENTS = new Set([
148
439
  // dynamic" (the instance holds functions) and the surface paints black, while iOS silently
149
440
  // drops it. SFC/template authoring never produces them. Strip them here, once, so no
150
441
  // adapter leaks React JSX dev metadata to the host, mirroring React's host config.
151
- const REACT_JSX_DEV_PROPS = new Set(['__self', '__source']);
152
- const classStyleParts = new WeakMap();
153
- function commitClassStyle(node, patch) {
154
- const entry = { ...classStyleParts.get(node), ...patch };
155
- classStyleParts.set(node, entry);
156
- setProp(node, 'style', [entry.classStyle, entry.explicitStyle]);
442
+ const REACT_JSX_DEV_PROPS = new Set([
443
+ '__self',
444
+ '__source',
445
+ ]);
446
+ // All slots are present from the start rather than added as they are written: one hidden class for
447
+ // every styled node in the app, instead of a shape transition per slot.
448
+ // Narrowed rather than cast: `routeProp` takes `unknown`, and a bare `typeof v === 'function'`
449
+ // leaves TS with `Function`, which is callable with anything. This states the shape the contract
450
+ // actually promises.
451
+ function isStyleCallback(value) {
452
+ return typeof value === 'function';
453
+ }
454
+ function stylePartsOf(node) {
455
+ return (node.styleParts ??= {
456
+ classStyle: undefined,
457
+ explicitStyle: undefined,
458
+ hiddenStyle: undefined,
459
+ className: undefined,
460
+ isPressed: false,
461
+ activeStyle: undefined,
462
+ activeStyleFromCallback: false,
463
+ });
464
+ }
465
+ // What belongs in slot 0 right now. The pressed variant is a complete REPLACEMENT rather than an
466
+ // overlay: `resolveActiveClassName` resolves the element's tokens PLUS `:active` through the same
467
+ // matcher, so a `.btn:active` rule joins the cascade exactly as its specificity says and the
468
+ // result already contains everything `.btn` gave. That is why pressing needs no extra style slot
469
+ // and leaves the published array's SHAPE untouched.
470
+ //
471
+ // Resolved LAZILY, at press time, never beside `classStyle`. Eager would mean two resolutions per
472
+ // class WRITE — ~14 000 of them on one benchmark create — and twice the distinct keys in a cache
473
+ // that clears whole on overflow, to serve a state almost no node is ever in. A press is one event
474
+ // on one node, so the second lookup is invisible there.
475
+ //
476
+ // `:active` applies only to a class that reaches the engine as a STRING, and the reason it is a
477
+ // footnote rather than a gap is that essentially nothing delivers anything else.
478
+ //
479
+ // Vue createVNode normalises class to a string before patchProp ever sees it — in
480
+ // @vue/runtime-core, `if (klass && !isString(klass)) props.class =
481
+ // normalizeClass(klass)`. So `:class="{btn:true}"` arrives as `"btn"`. Cited by the
482
+ // expression, not a line: the package ships several builds of that file and the same
483
+ // statement sits on a different line in each, so two readers comparing notes see a
484
+ // contradiction that is not one.
485
+ // Angular Ivy compiles every class form to per-token addClass/removeClass, and the renderer
486
+ // joins the accumulated tokens into ONE string before routeProp.
487
+ // React `className` is a string by convention.
488
+ // Svelte `normalizeSvelteClass` (adapters/svelte/src/class-value.ts) joins a clsx-shaped
489
+ // value, and hands anything else through UNCHANGED — so Svelte never sends a class
490
+ // MAP, but it does send a non-string class, deliberately, and it is the one live
491
+ // producer of the branch below.
492
+ //
493
+ // An OBJECT here is not a class map at all — `IClassNameValue` types it as an IResolvedStyle, the
494
+ // channel ScrollView / VirtualizedList / FlatList / ImageBackground use to hand a style through
495
+ // the class prop, and Svelte's `resolveSvelteClass` exists to feed it. Canonicalising that into
496
+ // tokens would not have been a category error only in theory: it would have hit a live producer on
497
+ // four components, and they would have silently lost their styling. Do not "simplify" the object
498
+ // branch away.
499
+ //
500
+ // What remains is an ARRAY of plain strings, which no adapter produces today and which reduces
501
+ // fresh on every call, so it gets neither a pressed variant nor `isAlreadyPublished`. Narrow, and
502
+ // closable by joining an all-string array before the string path — not done here.
503
+ //
504
+ // The identity reasoning underneath: the registry memoises a class STRING to the same object, and
505
+ // `isAlreadyPublished` compares slot 0 with Object.is. A variant built from a value that resolves
506
+ // fresh each call could never be turned away by the guard, and 1 000 unpressed rows would
507
+ // republish and re-dirty — the storm the guard exists to stop.
508
+ // Slot 1's twin of `baseStyleOf`. The variant stands in for the AUTHORED style, so it replaces
509
+ // slot 1 and not slot 0 — it must beat the class cascade exactly the way the authored style does,
510
+ // and a `:active` class rule must still be able to win slot 0 underneath it.
511
+ function explicitStyleOf(parts) {
512
+ return parts.isPressed && parts.activeStyle !== undefined
513
+ ? parts.activeStyle
514
+ : parts.explicitStyle;
515
+ }
516
+ function baseStyleOf(parts) {
517
+ return parts.isPressed && typeof parts.className === 'string'
518
+ ? resolveActiveClassName(parts.className)
519
+ : parts.classStyle;
520
+ }
521
+ // Republish the merged style after one half changed. The halves are written IN PLACE by the
522
+ // callers below - there is no patch object and no spread, because this is the hottest function in
523
+ // the mutation API (9.3 ms self time and a large share of GC on a 4 000-row create, when it still
524
+ // allocated a patch literal plus a merged copy per write).
525
+ //
526
+ // The fresh ARRAY is the one allocation that stays, and that is DELIBERATE - do not "finish the
527
+ // optimization" by skipping when both halves are unchanged. The parts are a shadow copy of the
528
+ // declarative style, and setNativeProps bypasses them (it writes node.props.style directly,
529
+ // merging an Animated frame onto whatever is there). An app that hands over a hoisted style
530
+ // constant - StyleSheet.create, a module-level object - would then re-push an identity-equal half,
531
+ // get skipped by setProp's Object.is guard, and never restore the declarative style the animation
532
+ // overwrote. The re-push IS the restore path.
533
+ // Would `pushClassStyle` republish an array byte-identical to the one already standing? Reads the
534
+ // last published array back out of `node.props.style` rather than remembering it in a field: that
535
+ // array IS the record of what was published, so there is nothing to keep in sync, and no shape
536
+ // change to the node or to IClassStyleParts.
537
+ //
538
+ // Sound because `pushClassStyle` is the ONLY writer of an array into that slot — both routeProp
539
+ // branches and setNodeHidden funnel through it — so a foreign array cannot be mistaken for ours,
540
+ // and a node whose props are still empty holds `undefined`, which is not an array, so the first
541
+ // write can never be swallowed.
542
+ function isAlreadyPublished(node, parts) {
543
+ const published = node.props.style;
544
+ if (!Array.isArray(published))
545
+ return false;
546
+ // `baseStyleOf`, not `parts.classStyle` — the guard and the publication must read slot 0 the
547
+ // same way or a press is turned away as already-published and silently does nothing on device
548
+ // while the behavior fires correctly and nothing goes red.
549
+ if (!Object.is(published[0], baseStyleOf(parts)))
550
+ return false;
551
+ // Through the resolver for the same reason as slot 0 above: guard and publication must agree, or
552
+ // a press is turned away as already-published and does nothing on device with nothing red.
553
+ if (!Object.is(published[1], explicitStyleOf(parts)))
554
+ return false;
555
+ return parts.hiddenStyle === undefined
556
+ ? published.length === 2
557
+ : published.length === 3 && Object.is(published[2], parts.hiddenStyle);
558
+ }
559
+ function pushClassStyle(node, parts) {
560
+ // The fresh array below can never be turned away by setProp's Object.is guard, so without this
561
+ // an UNCHANGED class still lands as a write AND marks the node dirty. Costs React / Vue / Svelte
562
+ // nothing — each diffs props before calling the engine — but Solid has no diff: a fine-grained
563
+ // effect re-runs whenever any signal it reads changes, so a list-wide signal makes every row
564
+ // re-push its own unchanged class. Measured on device 2026-08-23 (examples/solid, after
565
+ // host-primitive lowering): selecting one row of 1 000 read WRITES 1001 and a 10.3 ms reconcile
566
+ // window against Fabric's unmoved 0/0/10 — a thousand-node dirty walk for two nodes of change.
567
+ // Before lowering, the View component's splitProps/mergeProps memos had been absorbing it.
568
+ //
569
+ // This is NOT the naive skip the paragraph above forbids, and the array check is the difference.
570
+ // Skipping on "the parts are unchanged" alone would break the restore path, because
571
+ // setNativeProps writes node.props.style directly and a hoisted style constant would then never
572
+ // be restored. But setNativeProps writes an OBJECT (commit.ts: `{...flattenStyle(prev),
573
+ // ...flattenStyle(value)}`), never an array — so after any bypass isAlreadyPublished is false,
574
+ // the re-push happens exactly as before, and the restore path is untouched.
575
+ //
576
+ // Exact rather than approximate: resolveClassName memoizes a class STRING to the same object, so
577
+ // an unchanged class yields an identity-equal classStyle. It deliberately does not fire for an
578
+ // object/array class value, which resolves fresh every call — the same place setProp's Object.is
579
+ // already gives up on a style object, so no new asymmetry appears.
580
+ if (isAlreadyPublished(node, parts))
581
+ return;
582
+ // The third slot is APPENDED ONLY WHILE HIDDEN. Writing a permanent three-element array would
583
+ // change the style payload of every node in every app for a state almost none of them are ever
584
+ // in — and this project spent a day removing per-frame allocations, so a slot that is undefined
585
+ // 99.9% of the time does not get to ride along on every style write.
586
+ setProp(node, 'style', parts.hiddenStyle === undefined
587
+ ? [baseStyleOf(parts), explicitStyleOf(parts)]
588
+ : [baseStyleOf(parts), explicitStyleOf(parts), parts.hiddenStyle]);
589
+ }
590
+ // `display: 'none'` is a real RN style value (Yoga's DisplayNone), so a hidden node keeps its
591
+ // place in the tree, its state and its children — it just stops laying out and painting.
592
+ const HIDDEN_STYLE = { display: 'none' };
593
+ /**
594
+ * Stop a node painting without unmounting it, or let it paint again.
595
+ *
596
+ * The seam React's `Activity`/`Suspense` reach for through `hideInstance`/`unhideInstance`. It
597
+ * lives in the engine rather than an adapter because the reversibility problem — restoring the
598
+ * author's style byte for byte — belongs to whoever owns the style merge, and that is here.
599
+ */
600
+ export function setNodeHidden(node, hidden) {
601
+ const parts = stylePartsOf(node);
602
+ parts.hiddenStyle = hidden ? HIDDEN_STYLE : undefined;
603
+ pushClassStyle(node, parts);
604
+ }
605
+ /**
606
+ * Put a node into (or out of) its pressed state, so `.x:active` rules apply.
607
+ *
608
+ * The engine-owned half of what `:active` is on the web: the press state resolves BELOW the
609
+ * framework and never crosses into it, which is what lets a pressable be an intrinsic tag rather
610
+ * than a component (`.claude/rules/host-primitive-tier.md`, tier 2). A component is forced only
611
+ * when the TEMPLATE must read the state — `v-slot="{ pressed }"` and the function form of `style`
612
+ * — and this exists so the common case does not have to.
613
+ *
614
+ * Costs nothing when no `:active` rule is registered anywhere: `resolveActiveClassName` hands back
615
+ * the very same object the unpressed path returns, so `isAlreadyPublished` turns the re-push away
616
+ * and the node is never dirtied.
617
+ */
618
+ export function setNodePressed(node, pressed) {
619
+ const parts = stylePartsOf(node);
620
+ parts.isPressed = pressed;
621
+ pushClassStyle(node, parts);
157
622
  }
158
623
  // The explicit (non-class-derived) style half, for an adapter that builds its style prop up
159
624
  // key-by-key (Angular's Ivy ɵɵstyleProp/setStyle) instead of handing over one whole object —
160
625
  // it must merge onto this, not onto node.props.style directly, which may be the
161
626
  // [classStyle, explicitStyle] array commitClassStyle writes above.
162
627
  export function getExplicitStyle(node) {
163
- return classStyleParts.get(node)?.explicitStyle;
628
+ return node.styleParts?.explicitStyle;
164
629
  }
165
630
  const CLASS_PROP_KEYS = new Set(['class', 'className']);
166
631
  // The flat-bag split (React / Vue / Solid): an `onX` prop becomes an event listener
@@ -171,13 +636,62 @@ export function routeProp(node, key, value) {
171
636
  if (REACT_JSX_DEV_PROPS.has(key))
172
637
  return;
173
638
  if (CLASS_PROP_KEYS.has(key)) {
174
- commitClassStyle(node, {
175
- classStyle: resolveClassName(isClassNameValue(value) ? value : undefined),
176
- });
639
+ const parts = stylePartsOf(node);
640
+ // Canonicalised HERE so the stored value is what everything downstream keys on: an all-string
641
+ // array becomes one string, and then the pressed variant and isAlreadyPublished work on it
642
+ // exactly as on an authored string. One `typeof` for the common case.
643
+ parts.className = canonicalClassName(isClassNameValue(value) ? value : undefined);
644
+ parts.classStyle = resolveClassName(parts.className);
645
+ pushClassStyle(node, parts);
177
646
  return;
178
647
  }
179
648
  if (key === 'style') {
180
- commitClassStyle(node, { explicitStyle: value });
649
+ const parts = stylePartsOf(node);
650
+ // A FUNCTION `style` is `style={({pressed}) => …}`, the idiom this ecosystem actually writes.
651
+ // A lowering transform normally splits it at build time into `style` + `activeStyle`, so the
652
+ // engine never sees the callback — but a PUBLIC primitive tag has no transform in front of it
653
+ // on three adapters, and there the callback arrives here intact. Resolving it makes the
654
+ // compile-time split an OPTIMIZATION rather than the mechanism, the same relationship
655
+ // `foldHostBag` has with the compile-time prop folds.
656
+ //
657
+ // Without this the failure is silent and total: a function is not an `on*` name, so it misses
658
+ // `setEventListener`, lands in `setProp` as a function value, and `fabricProps` drops function
659
+ // props — the node commits with NO style at all. Traced by the Solid session, 2026-09-01.
660
+ //
661
+ // The callback must be PURE in `pressed`: its result is read once per state, here and under
662
+ // every transform's emission (`core/components/src/state-style.ts` carries the same contract).
663
+ if (isStyleCallback(value)) {
664
+ parts.explicitStyle = value({ pressed: false });
665
+ parts.activeStyle = value({ pressed: true });
666
+ parts.activeStyleFromCallback = true;
667
+ }
668
+ else {
669
+ parts.explicitStyle = value;
670
+ // Only a variant WE derived is stale now. `style` switching from a callback to a plain value
671
+ // must not leave the old pressed look standing, and an `activeStyle` the transform wrote must
672
+ // survive a `style` write, because the two arrive as independent props in an unspecified
673
+ // order.
674
+ if (parts.activeStyleFromCallback) {
675
+ parts.activeStyle = undefined;
676
+ parts.activeStyleFromCallback = false;
677
+ }
678
+ }
679
+ pushClassStyle(node, parts);
680
+ return;
681
+ }
682
+ // Ours, never Fabric's — it is consumed here and must not reach the payload, or every pressable
683
+ // in the app carries an unknown key to native.
684
+ if (key === 'activeStyle') {
685
+ const parts = stylePartsOf(node);
686
+ parts.activeStyle = value;
687
+ // Slot 1 is no longer ours, by definition — whatever a callback derived earlier has just been
688
+ // replaced. Without this the flag outlives the value it describes: a callback sets it, this
689
+ // branch overwrites the slot silently, and a later plain `style` then clears a variant the
690
+ // engine never derived. Not reachable from a lowering transform (it emits either a callback or
691
+ // an explicit pair, never both for one node), but a flat-bag adapter routes a bag key by key
692
+ // and can deliver exactly that sequence.
693
+ parts.activeStyleFromCallback = false;
694
+ pushClassStyle(node, parts);
181
695
  return;
182
696
  }
183
697
  if (ON_PREFIX.test(key)) {
@@ -199,32 +713,100 @@ export function routeProp(node, key, value) {
199
713
  }
200
714
  setProp(node, key, value);
201
715
  }
716
+ // The same no-op guard as setProp, and here it is strictly stronger: `text` is a string, so
717
+ // `Object.is` is a real value comparison rather than the reference check it degrades to for a style
718
+ // object or a handler. A framework that re-renders a subtree and hands back an unchanged label -
719
+ // every list row whose text did not move, on every update - stops stripping its ancestors of the
720
+ // commit walk's early exit. Counted in the same propStats, because a text write IS a prop write:
721
+ // it lands in node.props.text and reaches Fabric as RCTRawText's only prop.
722
+ //
723
+ // Safe against the one ordering hazard worth naming: a raw-text node REPARENTED under a <Text>
724
+ // commits as RCTVirtualText instead of RCTText (viewNameFor, commit.ts). That flip is not driven
725
+ // from here - a structural op marks the parent chain, and reconcile re-checks `committed.parent` on
726
+ // its early-exit path - so a same-text write not marking cannot hide it.
202
727
  export function setText(node, text) {
728
+ if (Object.is(node.props.text, text)) {
729
+ propStats.noops += 1;
730
+ return;
731
+ }
203
732
  node.props.text = text;
733
+ propStats.writes += 1;
734
+ markPropsDirty(node);
204
735
  }
736
+ // Structural ops mark the PARENT chain (both the old and the new one), never the moved child:
737
+ // a child that only changed position may legitimately still be clean, and reconcile re-checks
738
+ // `committed.parent` on its early-exit path, so a reparent is caught there rather than by a flag.
739
+ //
740
+ // Each marks BEFORE touching `parent.children`, never after: the committed record may be aliasing
741
+ // that array, and markStructureDirty is what copies it out of the way. See there.
205
742
  function detach(child) {
206
743
  const parent = child.parent;
207
744
  if (!parent)
208
745
  return;
746
+ markStructureDirty(parent);
209
747
  const index = parent.children.indexOf(child);
210
748
  if (index >= 0)
211
749
  parent.children.splice(index, 1);
212
750
  child.parent = undefined;
213
751
  }
214
752
  export function appendChild(parent, child) {
753
+ // A node the sweep tore down can be put back — Svelte parks live subtrees offscreen across
754
+ // commits. A WeakSet miss for anything freshly built, so the create path pays nothing.
755
+ if (hasHostBehaviors())
756
+ reattachHostBehaviors(child);
215
757
  detach(child);
758
+ markStructureDirty(parent);
216
759
  child.parent = parent;
217
760
  parent.children.push(child);
218
761
  }
219
762
  export function insertBefore(parent, child, beforeChild) {
763
+ if (hasHostBehaviors())
764
+ reattachHostBehaviors(child);
220
765
  detach(child);
766
+ markStructureDirty(parent);
221
767
  child.parent = parent;
222
768
  const index = parent.children.indexOf(beforeChild);
223
769
  parent.children.splice(index < 0 ? parent.children.length : index, 0, child);
224
770
  }
771
+ // Removal only NOMINATES a behavior for teardown; the commit sweep decides. A framework may spell
772
+ // a move as remove-then-reinsert (Solid does), so tearing down here kills the machine of a node
773
+ // that comes back alive in the same batch — see host-behavior.ts's markDetachCandidate.
225
774
  export function removeChild(parent, child) {
775
+ if (hasHostBehaviors())
776
+ markDetachCandidate(child);
777
+ markStructureDirty(parent);
226
778
  const index = parent.children.indexOf(child);
227
779
  if (index >= 0)
228
780
  parent.children.splice(index, 1);
229
781
  child.parent = undefined;
230
782
  }
783
+ export function censusRetainedTree(roots) {
784
+ const census = {
785
+ nodes: 0,
786
+ anchors: 0,
787
+ emptyRawTexts: 0,
788
+ renderable: 0,
789
+ flattenWidths: [],
790
+ };
791
+ // Explicit stack, not recursion: a deep list under a benchmark screen would blow the JS stack
792
+ // on the very tree this is meant to measure.
793
+ const stack = [...roots];
794
+ while (stack.length > 0) {
795
+ const node = stack.pop();
796
+ if (node === undefined)
797
+ break;
798
+ census.nodes += 1;
799
+ if (isAnchor(node))
800
+ census.anchors += 1;
801
+ else if (isEmptyRawText(node))
802
+ census.emptyRawTexts += 1;
803
+ else
804
+ census.renderable += 1;
805
+ if (node.children.some(child => isAnchor(child) || isEmptyRawText(child)))
806
+ census.flattenWidths.push(node.children.length);
807
+ for (const child of node.children)
808
+ stack.push(child);
809
+ }
810
+ census.flattenWidths.sort((left, right) => right - left);
811
+ return census;
812
+ }