@symbiote-native/engine 1.3.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/build/accessibility-props.d.ts +0 -11
  2. package/build/accessibility-props.js +30 -68
  3. package/build/animated/graph.js +1 -1
  4. package/build/animated/leaf-lifecycle.js +2 -2
  5. package/build/asset-source-resolver.d.ts +2 -0
  6. package/build/asset-source-resolver.js +13 -0
  7. package/build/back-handler/index.d.ts +1 -5
  8. package/build/back-handler/index.js +0 -6
  9. package/build/debug.js +8 -22
  10. package/build/dispatch.js +3 -9
  11. package/build/events/index.js +2 -2
  12. package/build/fabric-props.js +74 -179
  13. package/build/fabric.d.ts +0 -13
  14. package/build/fabric.js +18 -38
  15. package/build/host-access.d.ts +1 -128
  16. package/build/host-access.js +96 -205
  17. package/build/host-behavior.d.ts +0 -100
  18. package/build/host-behavior.js +125 -311
  19. package/build/image-loader.js +10 -23
  20. package/build/image-source-resolver.js +3 -7
  21. package/build/image-source-write.d.ts +0 -11
  22. package/build/image-source-write.js +14 -34
  23. package/build/imperative.d.ts +0 -28
  24. package/build/imperative.js +42 -92
  25. package/build/index.d.ts +1 -0
  26. package/build/index.js +24 -37
  27. package/build/mutation-buffer.d.ts +0 -177
  28. package/build/mutation-buffer.js +137 -308
  29. package/build/native-engine.d.ts +0 -102
  30. package/build/native-engine.js +38 -98
  31. package/build/native-events.js +9 -18
  32. package/build/native-tree-host.d.ts +0 -21
  33. package/build/native-tree-host.js +14 -31
  34. package/build/node.d.ts +0 -212
  35. package/build/node.js +338 -774
  36. package/build/post-commit.js +3 -8
  37. package/build/process-aspect-ratio.js +3 -7
  38. package/build/process-background-longhands.js +10 -19
  39. package/build/process-filter.js +11 -19
  40. package/build/process-font-variant.js +3 -7
  41. package/build/registry.d.ts +0 -33
  42. package/build/registry.js +22 -57
  43. package/build/report-error.js +4 -18
  44. package/build/structured-style.d.ts +0 -9
  45. package/build/structured-style.js +16 -31
  46. package/build/styles.js +3 -6
  47. package/build/surface.d.ts +0 -26
  48. package/build/surface.js +29 -76
  49. package/build/text-input-state.js +4 -8
  50. package/build/touch-history.js +5 -11
  51. package/build/tree-host.d.ts +0 -270
  52. package/build/tree-host.js +63 -153
  53. package/build/view-config.js +17 -37
  54. package/package.json +2 -2
package/build/node.js CHANGED
@@ -1,12 +1,6 @@
1
1
  // The mutation API. Adapters call it; every call appends an OPCODE to `mutation-buffer.ts` and
2
2
  // nothing else. There is no tree here — no parent, no children, no props, no mirror. Turning the
3
- // buffer into a tree is the HOST's job (`tree-host.ts`): native on device, the TypeScript applier in
4
- // `@symbiote-native/test-utils` headlessly.
5
- //
6
- // What a node still legitimately owns is what the framework, not Fabric, put on it: the Fabric view
7
- // name it was created as, whether it is a text container, its JS listener map, the declarative
8
- // class/style halves the engine merges, and the two bookkeeping flags. An ADDRESS plus the state
9
- // that never crosses.
3
+ // buffer into a tree is the HOST's job (`tree-host.ts`).
10
4
  import { recordAppendChild, recordCreateAnchor, recordCreateVoid, noteHostSideChange, recordCreateElement, recordCreateRawText, recordInsertBefore, recordRemoveChild, recordSetComponent, recordSetOwnedListener, recordSetUnderlayShown, recordSetProp, recordSetText, } from './mutation-buffer.js';
11
5
  import { isEventFor } from './view-config.js';
12
6
  import { canonicalClassName, EMPTY_STYLE, isClassNameValue, resolveActiveClassName, resolveClassName, } from './style-registry/index.js';
@@ -16,12 +10,9 @@ import { configPayloadFold } from './registry.js';
16
10
  import { resolveStructuredStyle } from './structured-style.js';
17
11
  import { IMAGE_SOURCE_PROPS, IMAGE_LOAD_EVENT_NAMES, anyImageLoadEventListenerWired, resolveImageSourceProp, } from './image-source-write.js';
18
12
  import { Platform } from './platform';
19
- // A cycle, deliberately: `imperative.ts` imports this module for the node shape, and the prototype
20
- // methods below call back into it. Neither side touches the other at module-evaluation time - only
21
- // inside a function body - so every loader (tsc, vitest, Metro) resolves it fine. The alternative
22
- // was a load-time `SymbioteNode.prototype.measure = ...` installed from elsewhere, which is exactly
23
- // the registration-side-effect shape Metro's inlineRequires silently drops in release builds (see
24
- // CLAUDE.md, "Never make correctness depend on a module's load-time side effect").
13
+ // A cycle, deliberately: `imperative.ts` imports this module; the prototype methods below call
14
+ // back into it. Neither touches the other at module-evaluation time, only inside a function body,
15
+ // so every loader resolves it (CLAUDE.md's load-time side-effect rule).
25
16
  import { measure as engineMeasure, measureInWindow as engineMeasureInWindow, measureLayout as engineMeasureLayout, setNativeProps as engineSetNativeProps, dispatchViewCommand, } from './imperative.js';
26
17
  // The same deliberate cycle, for the same reason: `tree-host.ts` imports `takePropStats` from here
27
18
  // and `censusRetainedTree` below asks it for the census. Function bodies only, on both sides.
@@ -32,11 +23,9 @@ import { EMPTY_CENSUS, flushOps, treeHost, } from './tree-host.js';
32
23
  import { hasAnimatedNodes } from './animated/graph.js';
33
24
  import { bindAnimatedEvent, bindAnimatedValue, hasAnimatedBindings, reattachAnimatedProps, } from './animated/host-binding.js';
34
25
  const BRAND = Symbol('symbiote.node');
35
- // A node carries the Fabric view name directly, so adding a primitive (Image,
36
- // ScrollView, TextInput) is just a new string from the adapter, no core change.
37
- // The only name resolved at commit time is text: a <Text> nested inside another
38
- // <Text> becomes a virtual span. `isText` marks a text container so its
39
- // descendants pick the virtual variant.
26
+ // A node carries the Fabric view name directly, so adding a primitive is just a new string from
27
+ // the adapter, no core change. The only name resolved at commit time is text: a <Text> nested
28
+ // inside another <Text> becomes a virtual span; `isText` marks a container so descendants pick it.
40
29
  export const RAW_TEXT_COMPONENT = 'RCTRawText';
41
30
  export const TEXT_COMPONENT = 'RCTText';
42
31
  export const VIRTUAL_TEXT_COMPONENT = 'RCTVirtualText';
@@ -55,56 +44,31 @@ const BLUR_COMMAND = 'blur';
55
44
  const SCROLL_TO_COMMAND = 'scrollTo';
56
45
  const SCROLL_TO_END_COMMAND = 'scrollToEnd';
57
46
  const FLASH_SCROLL_INDICATORS_COMMAND = 'flashScrollIndicators';
58
- // The one shape every retained node has. A class, not an object literal, for two reasons: the six
59
- // imperative methods live on the shared prototype instead of being allocated per node (see
60
- // ISymbioteNode above), and both factories below mint the same hidden class.
61
- //
62
- // Fields are `declare`d and assigned in the constructor rather than written as class fields: with
63
- // ES2022 field semantics the two are equivalent in meaning but not in emit, and a plain
64
- // constructor assignment is the shape every engine (V8 and Hermes both) handles without a
65
- // define-per-field.
47
+ // The one shape every retained node has: a class, so the imperative methods share a prototype
48
+ // instead of being allocated per node, and both factories below mint the same hidden class. Fields
49
+ // are `declare`d and assigned in the constructor, the shape V8/Hermes handle best.
66
50
  class SymbioteNode {
67
51
  constructor(component, isText) {
52
+ // Every field below is assigned here, not lazily: present from the constructor, they all keep
53
+ // ONE hidden class for every node. `attachHostBehavior` raises several of them later for the
54
+ // rare node whose behavior declares that capability.
68
55
  this[BRAND] = true;
69
56
  this.component = component;
70
57
  this.isText = isText;
71
58
  this.listeners = undefined;
72
- // Assigned here, not lazily on first use: every slot present from the constructor keeps one
73
- // hidden class for every node. Adding one on demand buys a shape transition per node that needs
74
- // it, which is the opposite of what these fields are for.
75
- //
76
- // `attachHostBehavior` raises this a few lines later for the rare node whose behavior declares
77
- // the recurring hook.
78
59
  this.hasCommitHook = false;
79
- // Same again; `attachHostBehavior` raises it for the one behavior that declares it, Image's.
80
60
  this.resolvesImageSources = false;
81
- // Same again; `attachHostBehavior` raises it for the one behavior that declares it,
82
- // TouchableWithoutFeedback's.
83
61
  this.nativeIdWinsOverId = false;
84
62
  this.styleParts = undefined;
85
- // Assigned here for the same hidden-class reason as `hasAriaAlias` above; `attachHostBehavior`
86
- // overwrites it a few lines later for the rare node that has a behavior.
87
63
  this.payloadFold = undefined;
88
- // Same reason again, and the same writer: `attachHostBehavior` fills it for the rare node that
89
- // gets a behavior at all.
90
64
  this.hostBehavior = undefined;
91
- // Same reason again, and here it is load-bearing rather than tidy: the redirect below is read
92
- // on every append, so the slot must be a stable slot on one hidden class, not a property added
93
- // to a few nodes after the fact.
94
65
  this.childHost = undefined;
95
66
  this.wrapper = undefined;
96
- // Same hidden-class reason as the two above, and here it is the whole point: the fast path it
97
- // guards is read on every `childrenOf`, so it must be a stable slot rather than a property that
98
- // appears on some nodes later.
99
67
  this.mayHaveChildren = false;
100
- // Same hidden-class reason again, and the same measured one: every insert reads it and the
101
- // teardown sweep writes it per node.
102
68
  this.isTornDown = false;
103
- // Same hidden-class reason as every field above, and the most load-bearing of them: `slotOf`
104
- // reads this pair on EVERY handle operand of every op — about two hundred thousand times on a
105
- // thousand-row create — so it has to be a stable slot on one shape. `slotBatch` starts at a
106
- // value no batch ever carries, which is what makes an untouched node read as "not in this
107
- // batch" without a separate flag.
69
+ // `slotOf` reads this pair on EVERY handle operand of every op, so both must be stable slots.
70
+ // `slotBatch` starts at a value no real batch carries, so an untouched node reads as "not in
71
+ // this batch" with no separate flag.
108
72
  this.slot = 0;
109
73
  this.slotBatch = 0;
110
74
  }
@@ -150,58 +114,38 @@ class SymbioteNode {
150
114
  dispatchViewCommand(this, FLASH_SCROLL_INDICATORS_COMMAND, []);
151
115
  }
152
116
  }
153
- // The committed record — handle, tag, rootTag — is the host's to keep, and `committedRecordOf`
154
- // (tree-host.ts) is how the imperative APIs ask for it. `IMirror` and `IContribution` were the JS
155
- // re-implementations of `ShadowNode` and of the one thing `ShadowNode` cannot hold, an anchor. Both
156
- // are gone with the tree; the host answers about both.
157
- //
158
- // The identity check `committedOf` used to make is gone with them, and it was worth something: a Vue
159
- // `reactive()` / deep-`ref()` Proxy around a host element forwards a field read to its target, so a
160
- // wrapped node used to hand back a real record. It cannot now — the host keys on the handle OBJECT,
161
- // so a Proxy misses and every imperative call degrades to its "node not committed" log, which is
162
- // the WeakMap's old behaviour restored. Hold host nodes with `shallowRef` (vue-adapter-reactivity).
163
- /**
164
- * Mint an element and record its creation.
165
- *
166
- * The node object IS the handle: it is what the ops address, what the host attaches its native node
167
- * to, and what Fabric hands back as an event target. Nothing else is allocated.
168
- */
117
+ // The committed record — handle, tag, rootTag — is the host's; `committedRecordOf` (tree-host.ts)
118
+ // answers for it, keyed on the handle OBJECT. A Vue Proxy around a host element misses that key,
119
+ // so a wrapped node's calls degrade to "not committed" — hold host nodes with `shallowRef`.
120
+ // Mint an element and record its creation. The node object IS the handle: what the ops address,
121
+ // what the host attaches its native node to, what Fabric hands back as an event target.
169
122
  export function createElement(component, isText = false,
170
123
  // The intrinsic tag this node came from, when it differs from the Fabric view name above. The
171
- // behavior registry is keyed by tag and the node only ever carries the resolved name, so an
172
- // adapter creating a `<pressable>` has to hand the tag over here or the registration cannot fire
173
- // (host-behavior.ts, `attached`). Nothing is stored — the lookup happens once, right below.
124
+ // behavior registry is keyed by tag, so an adapter creating `<pressable>` must hand it over here
125
+ // or the registration cannot fire. Nothing is stored — the lookup happens once, right below.
174
126
  tag = component) {
175
127
  const node = new SymbioteNode(component, isText);
176
128
  // A primitive that commits NO VIEW resolves to the anchor component through `descriptorFor`
177
- // (`touchable-without-feedback`, `touchable-native-feedback`), and reaches this function rather
178
- // than `createAnchor` because the caller only knows it has a descriptor. The kind is an OPCODE
179
- // now, not a name the commit walk reads, so the name alone would give the host an ordinary
180
- // element called `#anchor` — one that really paints.
129
+ // (touchable-without-feedback, touchable-native-feedback), reaching this rather than
130
+ // `createAnchor` because the caller only knows it has a descriptor.
181
131
  if (component === ANCHOR_COMPONENT)
182
132
  recordCreateAnchor(node);
183
- // A primitive whose ENTIRE subtree must vanish on this platform (`input-accessory-view` on
184
- // Android, `InputAccessoryView.js`'s `return null`) resolves to the void component the same way —
185
- // through `descriptorFor`'s per-platform component-name table, never a per-call branch here. An
186
- // anchor hoists its children into Fabric in its place; a void node contributes neither itself nor
187
- // them.
133
+ // A primitive whose ENTIRE subtree must vanish on this platform (input-accessory-view on
134
+ // Android) resolves to the void component the same way. An anchor hoists its children into
135
+ // Fabric in its place; a void node contributes neither itself nor them.
188
136
  else if (component === VOID_COMPONENT)
189
137
  recordCreateVoid(node);
190
- // `instanceHandle` is the node itself: it round-trips through Fabric unchanged and comes back as
191
- // the event target, and the BRAND below is how the event handler confirms it is one of ours.
138
+ // `instanceHandle` is the node itself: it round-trips through Fabric unchanged and comes back
139
+ // as the event target, and the BRAND below confirms it is one of ours.
192
140
  else
193
141
  recordCreateElement(node, component, isText, node);
194
142
  // Gated on the boolean, not on the Map: this runs ~9 000 times per benchmark create, and an app
195
143
  // that registers nothing must pay one boolean read rather than a hash lookup per node.
196
144
  if (hasHostBehaviors())
197
145
  attachHostBehavior(node, tag);
198
- // A third-party view's own ViewConfig processors, as a fold. AFTER the behavior's, because that
199
- // is the order the reference ran them in — the behavior rewrites the wrapper-body props, and
200
- // `validAttributes[*].process` then converts what it produced. Composed rather than replaced:
201
- // one component can legitimately have both.
202
- //
203
- // Costs a `Set.has` per node for a built-in, which is where `resolve` bails, and nothing else:
204
- // the answer is cached per component name, not computed per node.
146
+ // A third-party view's own ViewConfig processors, as a fold, AFTER the behavior's — the behavior
147
+ // rewrites wrapper-body props, and `validAttributes[*].process` then converts what it produced.
148
+ // Costs one `Set.has` per node for a built-in, where `resolve` bails.
205
149
  const configFold = configPayloadFold(component);
206
150
  if (configFold !== undefined) {
207
151
  const behaviorFold = node.payloadFold;
@@ -212,28 +156,15 @@ tag = component) {
212
156
  }
213
157
  return node;
214
158
  }
215
- /**
216
- * `tag` mirrors `createElement`'s, and a raw text needs it for the same reason an element does: the
217
- * behavior registry is keyed by tag, so a node that does not hand one over cannot have a rule.
218
- *
219
- * A raw text carrying a tag looks odd and is not. It has no props an app can write — its whole
220
- * payload is `text` — but its CONTENT can still be a function of the platform rather than of the
221
- * app: Button renders its title uppercased on Android (`Button.js:352-353`), which is a user-agent
222
- * decision about a control, not anything the app asked for. That rule needs the node to be
223
- * identifiable, and a tag is how this codebase identifies one.
224
- *
225
- * Defaulted to the raw-text component, so every existing caller is unchanged and pays the same
226
- * lookup miss `createElement` already pays for a node nobody registered.
227
- */
159
+ // `tag` mirrors `createElement`'s: the behavior registry is keyed by tag, and a raw text's CONTENT
160
+ // can still be a function of the platform (Button renders its title uppercased on Android), even
161
+ // with no props an app can write. Defaulted to the raw-text component for existing callers.
228
162
  export function createRawText(text, tag = RAW_TEXT_COMPONENT) {
229
163
  const node = new SymbioteNode(RAW_TEXT_COMPONENT, false);
230
164
  recordCreateRawText(node, text);
231
- // The TAG check comes first, and it is not the same guard `createElement` uses. There it asks
232
- // `hasHostBehaviors()` because every element legitimately might have a behavior. Here almost none
233
- // do — a raw text is the leaf under every `<Text>` on a screen, thousands of them, and exactly one
234
- // kind is tagged. So an untagged raw text must pay a reference comparison against the default and
235
- // not a registry lookup: `tag` is the same string literal in that case, so the compare is pointer
236
- // equality and the intern, the op and the miss are all skipped.
165
+ // TAG check first, not `hasHostBehaviors()`: almost no raw text is tagged (thousands are leaves
166
+ // under a `<Text>`), so an untagged one pays a pointer-equality compare against the default,
167
+ // not a registry lookup.
237
168
  if (tag !== RAW_TEXT_COMPONENT && hasHostBehaviors())
238
169
  attachHostBehavior(node, tag);
239
170
  return node;
@@ -244,11 +175,9 @@ export function createRawText(text, tag = RAW_TEXT_COMPONENT) {
244
175
  export function isSymbioteNode(value) {
245
176
  return typeof value === 'object' && value !== null && BRAND in value;
246
177
  }
247
- // Investigation instrumentation (HeaderOptionsScreen search-bar-ref "node not committed" bug):
248
- // a WeakMap can't be logged, so this gives every node a small human-readable id, assigned lazily
249
- // on first call — lets a dlog at ref-attach time and a dlog at commit/dispatch time be compared
250
- // directly to prove whether they're the SAME node object or two different ones. Kept behind
251
- // DEBUG per <keep_logs_gate_behind_DEBUG>, never removed.
178
+ // A WeakMap can't be logged, so this gives every node a small human-readable id, assigned lazily
179
+ // on first call — lets a dlog at ref-attach time and one at commit/dispatch time be compared to
180
+ // prove whether they're the SAME node object. Kept behind DEBUG per <keep_logs_gate_behind_DEBUG>.
252
181
  const debugIds = new WeakMap();
253
182
  let nextDebugId = 1;
254
183
  export function debugNodeId(node) {
@@ -259,11 +188,9 @@ export function debugNodeId(node) {
259
188
  }
260
189
  return id;
261
190
  }
262
- // Vue's runtime-core needs comment/anchor nodes (fragments, v-if, v-for) to track
263
- // sibling order; Fabric has no such concept. An anchor is a real retained node so
264
- // insert/nextSibling/parentNode ordering stays correct, but the commit walk SKIPS it
265
- // (commit.ts): no native view is ever created. Marked by a sentinel component name,
266
- // not a new field, so the hot SymbioteNode shape is untouched.
191
+ // Vue's runtime-core needs comment/anchor nodes (fragments, v-if, v-for) to track sibling order;
192
+ // Fabric has no such concept. An anchor is a real retained node so insert/nextSibling/parentNode
193
+ // ordering stays correct, but the commit walk SKIPS it — no native view is ever created.
267
194
  export const ANCHOR_COMPONENT = '#anchor';
268
195
  export function createAnchor() {
269
196
  const node = new SymbioteNode(ANCHOR_COMPONENT, false);
@@ -271,40 +198,24 @@ export function createAnchor() {
271
198
  return node;
272
199
  }
273
200
  // The sentinel a primitive resolves to when its ENTIRE subtree must vanish from Fabric on this
274
- // platform — `input-accessory-view` on Android, mirroring `InputAccessoryView.js`'s `return null`.
275
- // Unlike `ANCHOR_COMPONENT`, whose node hoists its children up in its own place, a void node's
276
- // children never reach Fabric either: the commit walk stops at it, recursively.
201
+ // platform (input-accessory-view on Android). Unlike ANCHOR_COMPONENT, whose node hoists children
202
+ // up, a void node's children never reach Fabric either — the commit walk stops there, recursively.
277
203
  export const VOID_COMPONENT = '#void';
278
204
  export function createVoid() {
279
205
  const node = new SymbioteNode(VOID_COMPONENT, false);
280
206
  recordCreateVoid(node);
281
207
  return node;
282
208
  }
283
- /**
284
- * The sentinel a SURFACE's own root node carries, so `parentOf` can stop there.
285
- *
286
- * A top-level node must answer `undefined` for its parent, and adapters depend on the exact miss:
287
- * Angular reads `null` as "defer, `<ng-content>` will place this" (answering the surface once
288
- * mounted every FlatList cell at top level), while Vue and Solid spell `?? surface` at their call
289
- * sites and would be handed an object that is not the `SymbioteSurface` they compare against. One
290
- * component name, read in JS, keeps all three right without a second structure.
291
- *
292
- * It is a JS-side name only. What goes over the wire is `RCTView`, because this node is REAL.
293
- */
209
+ // The sentinel a SURFACE's own root node carries, so `parentOf` can stop there. A top-level node
210
+ // must answer `undefined` for its parent — Angular reads `null` as "defer, ng-content will place
211
+ // this", while Vue/Solid spell `?? surface` and need the exact object to compare against.
212
+ // JS-side name only: what goes over the wire is `RCTView`, because this node is real.
294
213
  export const SURFACE_COMPONENT = '#surface';
295
- /**
296
- * One persistent root view per surface, mirroring RN's own AppContainer — `renderApplication` wraps
297
- * the app in `<View style={{flex:1}} pointerEvents="box-none">`.
298
- *
299
- * It is not decoration. Without `flex: 1` a non-flex root collapses to content height, and without
300
- * `box-none` a touch landing outside the app's own children has no escape. Living here rather than
301
- * in each adapter's `mount()` gives every framework a full-screen root for free and keeps layout in
302
- * the shared layer (`<adapters_stay_thin>`).
303
- *
304
- * The surface therefore commits as ONE node rather than hoisting its children into the child set —
305
- * which is why the host materializes the node `OP_COMMIT` names instead of walking its children. An
306
- * anchor in that position still hoists, so the host handles both without a special case.
307
- */
214
+ // One persistent root view per surface, mirroring RN's own AppContainer: `renderApplication` wraps
215
+ // the app in `<View style={{flex:1}} pointerEvents="box-none">`. Not decoration — without flex:1 a
216
+ // non-flex root collapses to content height, and without box-none an outside touch has no escape.
217
+ // Commits as ONE node rather than hoisting children into the child set, so the host materializes
218
+ // the node OP_COMMIT names instead of walking its children (an anchor there still hoists).
308
219
  export function createSurfaceRoot() {
309
220
  const node = new SymbioteNode(SURFACE_COMPONENT, false);
310
221
  recordCreateElement(node, 'RCTView', false, node);
@@ -317,101 +228,57 @@ export function createSurfaceRoot() {
317
228
  export function isAnchor(node) {
318
229
  return node.component === ANCHOR_COMPONENT;
319
230
  }
320
- // `isEmptyRawText` was here and is GONE: it read `node.props.text`, which JS no longer holds. The
321
- // rule it expressed — a raw text with no characters must not reach Fabric, because
322
- // AttributedString::appendFragment drops the fragment while the text walk has already flagged "the
323
- // last child was raw text", so the NEXT raw sibling merges into `fragments.back()` of an empty
324
- // vector and the process aborts — is now the host's, applied where the child set is built.
325
- // Dirty-marking is GONE, along with the walk it existed to skip. An op names the node it changed,
326
- // so the host marks exactly that node and its own ancestors; nothing on this side has to guess.
327
- // Listener changes still record nothing, for the reason they always did: `node.listeners` never
328
- // reaches Fabric, and the one listener that DOES change a Fabric prop, `layout`, raises `onLayout`
231
+ // A raw text with no characters must not reach Fabric — AttributedString::appendFragment drops the
232
+ // fragment while the text walk has already flagged "last child was raw text", so the next raw
233
+ // sibling merges into an empty `fragments.back()` and aborts. Enforced by the host, not here.
234
+ // No dirty-marking here: an op names the node it changed, so the host marks that node and its
235
+ // ancestors. `node.listeners` never reaches Fabric, except `layout`, which raises `onLayout`
329
236
  // through `setProp` below.
330
- /**
331
- * Change which Fabric view a node commits as, keeping the node's identity.
332
- *
333
- * The POLICY stays out of the engine: which prop decides, and which view it decides between, lives
334
- * in `HOST_PRIMITIVES` and is read by `resolveIntrinsicTag` in `@symbiote-native/components`. The
335
- * engine only knows how to swap the name — the same split every other spec-driven fold has here.
336
- *
337
- * A no-op when the name is unchanged, so a renderer may call it on every update without comparing
338
- * first.
339
- *
340
- * The JS field and the op BOTH move, and both are load-bearing. `node.component` is what the aria
341
- * fold, the behavior registry and `fabricProps` key on; the op is what makes the host re-create the
342
- * node under the new name, since no prop write moves a node between native views.
343
- */
237
+ // Change which Fabric view a node commits as, keeping the node's identity. Policy stays out of the
238
+ // engine — which prop decides, and between which views, lives in HOST_PRIMITIVES; the engine only
239
+ // swaps the name. A no-op when unchanged, so a renderer may call it on every update.
240
+ // Both the JS field and the op move: `node.component` is what the aria fold and fabricProps key
241
+ // on, the op is what makes the host re-create the node, since no prop write moves it between views.
344
242
  export function setNodeComponent(node, component) {
345
243
  if (node.component === component)
346
244
  return;
347
245
  node.component = component;
348
246
  recordSetComponent(node, component);
349
247
  }
350
- // `isSkippedAtCommit`, `markPresenceIfFlipped`, `markDirty`, `markPropsDirty`, `markStructureDirty`,
351
- // `markRenderableAncestor`, `markChildOp`, `markChildRemoved` and `markChildAppended` all lived here
352
- // and are all GONE. Every one of them answered a question about a tree — which ancestor went stale,
353
- // whether a node's PRESENCE in its parent's renderable list flipped, which anchor to climb past —
354
- // and the host is the only thing that can answer those now. It marks from the ops themselves.
248
+ // Tree-staleness marking (which ancestor went stale, presence flips, anchor climbs) lives entirely
249
+ // on the host now, derived from the ops themselves — nothing here has to answer those questions.
355
250
  // How many prop writes an adapter pushed at the engine. Read-and-zeroed through
356
- // readCommitProfile() (tree-host.ts), which prices the layer ABOVE the host.
357
- //
358
- // `noops` used to sit beside it and counted the writes the `Object.is` guard turned away — the
359
- // Angular Pressable bag that pushed 104 000 setProp calls for a screen Solid built in 12 000, 90 000
360
- // of them writing `undefined` over a key that was not there. The guard moved into the host, because
361
- // keeping it here would mean reading the previous value BACK over the wire — ~44 001 reads on a
362
- // 1 000-row create, exactly the crossings this design removes. So the number is no longer visible
363
- // from JS, and it is not faked as a zero it would have to keep re-earning.
364
- //
365
- // Not gated behind isDebug(), for the same reason the commit profile is not: an integer increment
366
- // is noise next to the prop write it counts, and the figure is only meaningful from a release
367
- // build. A per-call dlog was the obvious alternative and is deliberately NOT here - a log line per
368
- // write would measure the logging rather than the code (see the `perf-claims-need-numbers` rule).
251
+ // readCommitProfile() (tree-host.ts), which prices the layer ABOVE the host. No `noops` count: the
252
+ // `Object.is` guard lives in the host, which would need the previous value back over the wire.
253
+ // Not gated behind isDebug(): an integer increment is noise next to the prop write it counts, and
254
+ // the figure is only meaningful from a release build. No per-call dlog either — a log line per
255
+ // write would measure the logging rather than the code.
369
256
  const propStats = { writes: 0 };
370
257
  export function takePropStats() {
371
258
  const snapshot = { writes: propStats.writes };
372
259
  propStats.writes = 0;
373
260
  return snapshot;
374
261
  }
375
- // `<component>.<key>` -> write count, gated behind `isDebug()` (a Map lookup per write is not the
376
- // "log line per write" the comment above rules out, but it is still real cost on the hottest path,
377
- // so it only runs when someone asked). Answers F-79's own recommended next step — "instrument
378
- // recordSetProp call sites directly, not just before/after counts" — by naming exactly which
379
- // (view, key) pair an adapter comparison's aggregate delta is hiding, the same ledger shape F-75's
380
- // payload census already uses (`RCTView.accessible 2000`).
262
+ // `<component>.<key>` -> write count, gated behind `isDebug()`: a Map lookup per write is real
263
+ // cost on the hottest path, so it only runs when someone asked. Names exactly which (view, key)
264
+ // pair an adapter comparison's aggregate delta is hiding.
381
265
  let propKeyTally;
382
266
  export function takePropKeyTally() {
383
267
  const snapshot = propKeyTally ?? new Map();
384
268
  propKeyTally = undefined;
385
269
  return snapshot;
386
270
  }
387
- // A pure prop set: no event inference. `onTintColor` is a Switch prop and reaches
388
- // Fabric like any other; the event-vs-prop decision is made by routeProp, never by
389
- // the key's name.
390
- //
391
- // `undefined` DELETES the key, which is the collapse this function has always performed and which
392
- // the wire now spells (`NO_VALUE`, mutation-buffer.ts). `null` is NOT the same thing: it is a
393
- // legitimate Fabric value meaning "reset to the default", and a merge-on-clone host has to be able
394
- // to tell a removed key from one that was never there.
395
- //
396
- // THE `Object.is` DEDUPE IS NOT HERE ANY MORE, and that is the one behaviour change in this file.
397
- // It needed the value the node already holds, and JS no longer holds it — reading it back would be
398
- // ~44 001 crossings on a 1 000-row create, which is the cost this whole design exists to remove. The
399
- // guard lives in the host's `OP_SET_PROP` instead, where the previous value is a local field: same
400
- // comparison, same `Object.is` reasoning (a style object or a handler closure is a fresh reference
401
- // on nearly every render, so it simply never fires for them, which is correct because an adapter may
402
- // hand back the SAME reference with mutated contents and identity cannot see that).
271
+ // A pure prop set: no event inference. `onTintColor` is a Switch prop and reaches Fabric like any
272
+ // other; the event-vs-prop decision is made by routeProp, never by the key's name.
273
+ // `undefined` DELETES the key (`NO_VALUE` on the wire, mutation-buffer.ts). `null` is NOT the same:
274
+ // it's a legitimate Fabric value meaning "reset to default", and a merge-on-clone host must tell a
275
+ // removed key from one that was never there.
276
+ // No `Object.is` DEDUPE HERE: it needs the value the node already holds, which JS doesn't. The
277
+ // guard lives in the host's `OP_SET_PROP` instead, where the previous value is a local field.
403
278
  export function setProp(node, key, value) {
404
- // THE ARIA GATE WAS THIS LINE AND IT IS GONE (2026-09-18). `node.hasAriaAlias` existed to let the
405
- // headless payload builder skip `foldAriaProps` on the ~99% of nodes carrying no alias; that
406
- // builder no longer folds aria at all — the rule is the device's, in `SymbioteFabricProps.cpp`,
407
- // which recomputes the gate from the bag it already holds. So the flag became write-only, and its
408
- // write ran `isAriaAliasKey` on EVERY prop write in the engine to maintain something nothing read.
409
- //
410
279
  // A composed primitive's slot — and its wrapper, where it has one — can carry a value DERIVED
411
- // from an owner prop, and `markPropsDirty` bubbles up, so neither ever learns. Here rather than
412
- // in `routeProp` because this is the one choke point every writer passes (a structural adapter's
413
- // `setProperty` does not go through routeProp), and past the identity guard so a re-render
414
- // writing an unchanged value costs them nothing. See `IHostBehavior.slotDerived`.
280
+ // from an owner prop, so `markPropsDirty` bubbles up or neither ever learns. Here, not in
281
+ // `routeProp`: this is the one choke point every writer passes. See `IHostBehavior.slotDerived`.
415
282
  if (node.childHost !== undefined && slotDerivesFrom(node, key)) {
416
283
  markPropsDirty(node.childHost);
417
284
  // Past the slot: a `buildStructure` that builds a CHAIN registers the deeper nodes here, and
@@ -431,56 +298,31 @@ export function setProp(node, key, value) {
431
298
  }
432
299
  writeProp(node, key, value);
433
300
  }
434
- // Function props that never left JS, keyed by node.
435
- //
436
- // A function CANNOT cross this wire. The host stores props as a `folly::dynamic` and
437
- // `jsi::dynamicFromValue` THROWS on a callable — "JS Functions are not convertible to dynamic" —
438
- // so a single function prop kills the whole batch, and with it the commit that carried it.
439
- //
440
- // It has always been unsendable and it used to be unreachable, because `routeProp` diverts every
441
- // REGISTERED `on*` name into the listener stash before this point. Two paths get past that and both
442
- // are real: an unregistered `on*` that is an ordinary prop by design (`onValueChange`, which
443
- // `fabricProps` drops on the way to native and a behavior reads back), and `setNativeProps`, which
444
- // bypasses `routeProp` entirely. Device-found 2026-09-08 through the second: an `Animated.View`
445
- // spread with `panResponder.panHandlers` hands `AnimatedProps.__getValue()` a bag of callbacks, and
446
- // it copies every key it holds.
447
- //
448
- // So they live here, exactly as listeners already do — and `propOf` looks here first, which is what
449
- // keeps `onValueChange` readable. Nothing is lost on the native side: `fabricProps` dropped function
450
- // props on both hosts anyway, so the payload is byte-identical either way.
301
+ // Function props that never left JS, keyed by node. A function CANNOT cross this wire:
302
+ // `jsi::dynamicFromValue` THROWS on a callable, killing the whole batch.
303
+ // Two paths reach here past `routeProp`'s registered-`on*` diversion: an unregistered `on*` that's
304
+ // an ordinary prop (`onValueChange`), and `setNativeProps`, which bypasses `routeProp` entirely —
305
+ // e.g. `Animated.View` spreading `panResponder.panHandlers` copies every key it holds.
306
+ // Live here like listeners do; `propOf` looks here first, keeping `onValueChange` readable.
307
+ // `fabricProps` drops function props on both hosts, so the payload is byte-identical either way.
451
308
  const functionProps = new WeakMap();
452
- /**
453
- * The one place a prop reaches the wire, and the only place that can keep a function off it.
454
- *
455
- * `setNativeProps` calls this rather than `recordSetProp` for that reason — it is the path that has
456
- * no `routeProp` in front of it.
457
- */
309
+ // The one place a prop reaches the wire, and the only place that can keep a function off it.
310
+ // `setNativeProps` calls this rather than `recordSetProp` for that reason — the path with no
311
+ // `routeProp` in front of it.
458
312
  export function writeProp(node, key, value) {
459
313
  // The same strip `routeProp` does, repeated because THIS is the path with no `routeProp` in
460
- // front of it (`imperative.ts` says so). One filter on the declarative path was never enough:
461
- // `AnimatedProps` is built from a RAW prop bag and re-sends every key it holds on every frame,
462
- // so on a JSX adapter `__self` rode straight past the strip into the host. See
463
- // REACT_JSX_DEV_PROPS for what that costs on each platform.
314
+ // front of it: `AnimatedProps` re-sends its whole raw prop bag every frame, so on a JSX adapter
315
+ // `__self` rides straight past the declarative-path filter into the host.
464
316
  if (REACT_JSX_DEV_PROPS.has(key))
465
317
  return;
466
- // Arms the node's recurring post-commit hook, for the rare node that has one. HERE rather than in
467
- // `setProp`, because this is where both paths meet: `setNativeProps` reaches the wire through
468
- // this function and not through that one, and a hook armed only by the declarative path missed
469
- // the imperative write entirely (`__tests__/after-commit-lifecycle.test.ts` said so). The field
470
- // read is the same shape as `hasAriaAlias` and for the same reason — this is the hottest path in
471
- // the engine, and a Set lookup per write is not something it can carry.
318
+ // Arms the node's recurring post-commit hook, for the rare node that has one. HERE, not in
319
+ // `setProp`: this is where both the declarative and `setNativeProps` paths meet, so a hook armed
320
+ // only by the former misses the imperative write entirely.
472
321
  if (node.hasCommitHook)
473
322
  noteCommitHookNodeChanged(node);
474
- // Resolved on the way IN, for the same reason the strip above lives here: this is where both
475
- // paths meet. `boxShadow` / `filter` / `transform` and the four beside them are parsed in JS,
476
- // and the C++ payload builder has no JS — so a value resolved at payload-build time is resolved
477
- // headless only, and the device commits the raw CSS string, which Fabric drops in silence. See
478
- // `structured-style.ts`; it hands the same object back when nothing needed resolving, which is
479
- // what keeps the host's identity guard and `pushClassStyle` working.
480
- // Image's three source props, resolved on the way in for the same reason and at the same seam —
481
- // the asset lookup is Metro's registry, which exists only in JS. See `image-source-write.ts`.
482
- // Gated on the node: the resolution normalises to Image's ARRAY shape, and a `WebView` spells
483
- // `source` too.
323
+ // `boxShadow`/`filter`/`transform` and Image's source props resolve on the way IN, at this same
324
+ // choke point: the C++ payload builder has no JS to do it headless. `structured-style.ts` hands
325
+ // back the same object when nothing needed resolving, keeping the host's identity guard intact.
484
326
  let written = value;
485
327
  if (key === 'style' || key === 'activeStyle') {
486
328
  written = resolveStructuredStyle(value);
@@ -510,24 +352,15 @@ export function writeProp(node, key, value) {
510
352
  export function functionPropOf(node, key) {
511
353
  return functionProps.get(node)?.get(key);
512
354
  }
513
- /**
514
- * The same stash, whole — what `propsOf` layers over the host's answer.
515
- *
516
- * `undefined` rather than an empty Map for a node that stashed nothing, which is nearly every node:
517
- * the caller then hands back the host's own object instead of copying it.
518
- */
355
+ // The same stash, whole — what `propsOf` layers over the host's answer. `undefined` rather than an
356
+ // empty Map for a node that stashed nothing (nearly every node), so the caller hands back the
357
+ // host's own object instead of copying it.
519
358
  export function functionPropsOf(node) {
520
359
  return functionProps.get(node);
521
360
  }
522
- /**
523
- * "Rebuild this node's payload — the fold reads state I just changed."
524
- *
525
- * A behavior whose payload is DERIVED has no prop to write: the sticky header's debounced
526
- * translateY lives in its own runtime, not on the node, so nothing names the node and the host
527
- * never marks it. This is the one route that says so directly.
528
- *
529
- * Dirtying is not publishing — pair it with `requestCommitFor` (imperative.ts).
530
- */
361
+ // "Rebuild this node's payload — the fold reads state I just changed." A behavior whose payload is
362
+ // DERIVED has no prop to write (the sticky header's debounced translateY lives in its own
363
+ // runtime), so this is the one route that dirties it directly. Pair with requestCommitFor.
531
364
  export function markPropsDirty(node) {
532
365
  flushOps();
533
366
  // Announced to the buffer even though it writes no op: this is the one route that dirties a node
@@ -540,26 +373,14 @@ export function markPropsDirty(node) {
540
373
  noteCommitHookNodeChanged(node);
541
374
  treeHost()?.markPropsDirty(node);
542
375
  }
543
- // Fabric gates a handful of events behind a BOOLEAN prop: unlike scroll / touch / change, which
544
- // the native component emits unconditionally, these fire only when the shadow node carries the
545
- // flag. RN raises them with an `on*: true` validAttribute; we drop function props from the
546
- // payload, so a gated handler attaches on our side and the native event simply never arrives.
547
- // That is silent - a test asserting the listener is present passes, and only a device shows it.
548
- //
549
- // The list is exhaustive as of react-native 0.86: every `bool on*` field in Fabric's C++ props
550
- // (`ReactCommon/react/renderer/components/**`), each read behind an `if` before the emitter runs:
551
- //
552
- // BaseViewProps.onLayout ParagraphShadowNode.cpp / RCTViewComponentView
553
- // AccessibilityProps.onAccessibilityTap RCTViewComponentView.mm:1603
554
- // AccessibilityProps.onAccessibilityMagicTap RCTViewComponentView.mm:1613
555
- // AccessibilityProps.onAccessibilityEscape RCTViewComponentView.mm:1623
556
- // AccessibilityProps.onAccessibilityAction RCTViewComponentView.mm:1633
557
- // BaseParagraphProps.onTextLayout ParagraphShadowNode.cpp:351
558
- //
559
- // Keyed by the post-`listenerName` event name, valued with the payload key. `magicTap` maps to
560
- // `onMagicTap` and NOT to the C++ member name `onAccessibilityMagicTap`, because `onMagicTap` is
561
- // what RN's own view config declares (BaseViewConfig.ios.js) - the two disagree upstream, and
562
- // matching stock is the only defensible choice until RN resolves it.
376
+ // Fabric gates a handful of events behind a BOOLEAN prop: unlike scroll/touch/change, these fire
377
+ // only when the shadow node carries the flag. We drop function props from the payload, so a gated
378
+ // handler attaches on our side and the native event silently never arrives without this map.
379
+ // Exhaustive as of react-native 0.86: every `bool on*` field in Fabric's C++ props
380
+ // (ReactCommon/react/renderer/components/**), keyed by the post-`listenerName` event name.
381
+ // `magicTap` maps to `onMagicTap`, not the C++ member name `onAccessibilityMagicTap` — RN's own
382
+ // view config (BaseViewConfig.ios.js) disagrees with its C++ prop name, and matching stock is the
383
+ // only defensible choice until RN resolves it.
563
384
  const GATED_EVENT_PROPS = new Map([
564
385
  ['layout', 'onLayout'],
565
386
  ['textLayout', 'onTextLayout'],
@@ -571,18 +392,10 @@ const GATED_EVENT_PROPS = new Map([
571
392
  // The explicit event channel. Structural adapters (Svelte addEventListener, Angular
572
393
  // Renderer2.listen) call this directly with an already-known event name; flat-bag
573
394
  // adapters reach it through routeProp. A non-function value clears the listener.
574
- /**
575
- * Install a listener the BEHAVIOR owns, bypassing the ownership check.
576
- *
577
- * `setEventListener` diverts an owned name into the stash, which is right for an app listener and
578
- * circular for the behavior's own dispatcher — it would stash itself and never occupy the slot it
579
- * exists to hold. This is the one writer allowed past that gate.
580
- *
581
- * `undefined` removes it, gate flag included. A behavior whose dispatcher is conditional needs
582
- * that as much as it needs the install: ScrollView takes the owner's `layout` only while the app
583
- * or an inverted sticky header wants it, and a one-way installer leaves `onLayout: true` standing
584
- * in the payload of a ScrollView that no longer reads the event.
585
- */
395
+ // Install a listener the BEHAVIOR owns, bypassing the ownership check: `setEventListener` diverts
396
+ // an owned name into the stash, which would be circular for the behavior's own dispatcher.
397
+ // `undefined` removes it, gate flag included — ScrollView takes the owner's `layout` only while
398
+ // something wants it, and a one-way installer would leave `onLayout: true` stuck in the payload.
586
399
  export function setBehaviorListener(node, name, listener) {
587
400
  if (listener === undefined)
588
401
  node.listeners?.delete(name);
@@ -600,24 +413,16 @@ const PRESSABILITY_NAMES = new Set([
600
413
  ]);
601
414
  export function setEventListener(node, name, value) {
602
415
  const isHandler = typeof value === 'function';
603
- // A name a host behavior OWNS never reaches `node.listeners` — the behavior's dispatcher holds
604
- // that slot and the app's callback is stashed beside it. `node.listeners` is single-slot, so
605
- // without this the two evict each other and the last writer wins with no diagnostic; and the
606
- // keys at stake are the ones a gesture STARTS on, so the loser is silently pressless. The
607
- // component wrapper used to mediate this by destructuring the app's callbacks out before they
608
- // reached the node; a tag has no mediator. Gated on the boolean first, so an app with no behavior
609
- // registered pays one read.
416
+ // A name a host behavior OWNS never reaches `node.listeners`: the behavior's dispatcher holds
417
+ // that slot, and `node.listeners` is single-slot, so without this the app's callback would evict
418
+ // it with no diagnostic — on the keys a gesture STARTS on, silently pressless.
610
419
  if (hasHostBehaviors() && ownsListener(node, name)) {
611
- // The PRESENCE only, never the identity: listeners deliberately do not notify (a framework
612
- // hands a fresh closure nearly every render — see `markDirty`'s note on why that must stay
613
- // free). A flip is a mount-time event, not a per-render one.
420
+ // The PRESENCE only, never the identity: a fresh closure nearly every render must not notify.
614
421
  const wasWired = appListenerFor(node, name) !== undefined;
615
422
  stashAppListener(node, name, isHandler ? value : undefined);
616
423
  if (wasWired !== isHandler) {
617
- // The BIT, on the flip only. A platform rule can then resolve a key that depends on whether
618
- // the app wired anything — `focusable` on a touchable is the case that needed it — without
619
- // the closure ever leaving JS. Ordered before the notify so a behavior that re-commits from
620
- // that callback finds the host already holding the new answer.
424
+ // The BIT, on the flip only, so a platform rule (`focusable` on a touchable) can resolve a
425
+ // key depending on whether the app wired anything, without the closure leaving JS.
621
426
  recordSetOwnedListener(node, name, isHandler);
622
427
  notifyOwnedListenerChange(node, name, isHandler);
623
428
  }
@@ -627,8 +432,8 @@ export function setEventListener(node, name, value) {
627
432
  return;
628
433
  }
629
434
  // A plain `<text>` presses through the engine's own synthesis, with no behavior owning the
630
- // names, yet its payload depends on whether it is pressable (Text.js:145-163: Android
631
- // `accessible`, the `link` role). Same bit as the owned path, on the flip only.
435
+ // names, yet its payload depends on whether it is pressable (Android `accessible`, the `link`
436
+ // role). Same bit as the owned path, on the flip only.
632
437
  if (PRESSABILITY_NAMES.has(name)) {
633
438
  const wasWired = node.listeners?.has(name) === true;
634
439
  if (wasWired !== isHandler)
@@ -645,22 +450,17 @@ export function setEventListener(node, name, value) {
645
450
  const flagProp = GATED_EVENT_PROPS.get(name);
646
451
  if (flagProp !== undefined)
647
452
  setProp(node, flagProp, isHandler ? true : undefined);
648
- // `onLoad`/`onLoadStart`/`onLoadEnd`/`onError` are real Fabric events on `RCTImageView`
649
- // (`view-config.ts`), so they land here rather than in `writeProp` — see
650
- // `image-source-write.ts` for why Android's `shouldNotifyLoadEvents` has to be synthesized
651
- // from the listener map instead of from a stashed function value.
453
+ // `onLoad`/`onLoadStart`/`onLoadEnd`/`onError` are real Fabric events on `RCTImageView`, so they
454
+ // land here, not in `writeProp` — see image-source-write.ts for why Android's
455
+ // `shouldNotifyLoadEvents` is synthesized from the listener map, not a stashed function value.
652
456
  if (node.resolvesImageSources && IMAGE_LOAD_EVENT_NAMES.has(name)) {
653
457
  setProp(node, 'shouldNotifyLoadEvents', anyImageLoadEventListenerWired(node.listeners) ? true : undefined);
654
458
  }
655
459
  }
656
- // `/^on[A-Z]/` spelled out, because this runs on EVERY prop write and a regex is the one guard in
657
- // that sequence that is not obviously cheap. Priced on `build-release`
658
- // (`mutation-api-fill-cost.itest.ts`): 0.09 us for the regex against 0.03 for the character reads,
659
- // on a prop write that costs 0.88 us end to end — so ~7% of a write, ~1% of a create. Small, and it
660
- // is free: the boundary is pinned by its own tests in `node.test.ts`.
661
- //
662
- // 111 is 'o', 110 is 'n', and 65-90 is A-Z. `charCodeAt` past the end answers NaN, which fails every
663
- // comparison — so a two-character `on` needs no length check.
460
+ // `/^on[A-Z]/` spelled out: this runs on EVERY prop write, and a regex costs measurably more than
461
+ // the character reads (mutation-api-fill-cost.itest.ts). The boundary is pinned by node.test.ts.
462
+ // 111 is 'o', 110 is 'n', 65-90 is A-Z. `charCodeAt` past the end answers NaN, which fails every
463
+ // comparison, so a two-character `on` needs no length check.
664
464
  function isOnEventName(key) {
665
465
  if (key.charCodeAt(0) !== 111 || key.charCodeAt(1) !== 110)
666
466
  return false;
@@ -671,11 +471,9 @@ function isOnEventName(key) {
671
471
  function listenerName(propName) {
672
472
  return propName.charAt(2).toLowerCase() + propName.slice(3);
673
473
  }
674
- // The responder-negotiation events (PanResponder's panHandlers). They are a
675
- // JS-side protocol the event layer synthesizes from raw touches, NOT Fabric
676
- // ViewConfig events, so isEventFor never reports them. Treat them as listeners on
677
- // any node so PanResponder's handlers actually attach (rather than routing to
678
- // setProp and reaching Fabric as dead props). Names are post-listenerName.
474
+ // The responder-negotiation events (PanResponder's panHandlers): a JS-side protocol synthesized
475
+ // from raw touches, not Fabric ViewConfig events, so `isEventFor` never reports them. Treated as
476
+ // listeners on any node so the handlers attach instead of reaching Fabric as dead props.
679
477
  const RESPONDER_EVENTS = new Set([
680
478
  'startShouldSetResponder',
681
479
  'startShouldSetResponderCapture',
@@ -690,32 +488,21 @@ const RESPONDER_EVENTS = new Set([
690
488
  'responderTerminate',
691
489
  'responderTerminationRequest',
692
490
  ]);
693
- // React's JSX dev transform (transform-react-jsx-self / -source, injected by RN's babel
694
- // preset whenever dev=true) annotates every element with __self (the component instance)
695
- // and __source ({ fileName, lineNumber, columnNumber }). React's own Fabric host config
696
- // consumes both and never forwards them. A JSX-based adapter (Vue JSX, Solid JSX) instead
697
- // carries them onto the vnode as ordinary props, so they reach setProp and then Fabric,
698
- // where Android's folly::dynamic rejects __self with "JS Functions are not convertible to
699
- // dynamic" (the instance holds functions) and the surface paints black.
700
- //
701
- // "WHILE IOS SILENTLY DROPS IT" IS WHAT THIS COMMENT USED TO SAY, AND IT IS WRONG. iOS converts
702
- // the same value with `jsi::dynamicFromValue`, whose walk keeps no visited set, and `__self` is a
703
- // module `this` — cyclic. That is not a drop, it is an endless walk inside `applyOps` that never
704
- // returns and allocates as it goes: measured 2026-09-09 on examples/solid, one press on an
705
- // Animated control, RAM to 15 GB and a dead JS thread. Android's loud rejection is the FRIENDLIER
706
- // of the two platforms here.
707
- //
708
- // SFC/template authoring never produces them. Strip them here, once, so no adapter leaks React
709
- // JSX dev metadata to the host, mirroring React's host config.
491
+ // React's JSX dev transform annotates every element with __self (the component instance) and
492
+ // __source. React's own Fabric host config consumes both and never forwards them; a JSX-based
493
+ // adapter (Vue JSX, Solid JSX) instead carries them as ordinary props, reaching Fabric.
494
+ // Both platforms reject it, not just Android: iOS's `jsi::dynamicFromValue` keeps no visited set,
495
+ // and `__self` is a cyclic module `this` — an endless walk inside `applyOps` that allocates until
496
+ // RAM is exhausted, worse than Android's loud `folly::dynamic` rejection.
497
+ // SFC/template authoring never produces them. Strip them here, once, mirroring React's host config.
710
498
  const REACT_JSX_DEV_PROPS = new Set([
711
499
  '__self',
712
500
  '__source',
713
501
  ]);
714
- // All slots are present from the start rather than added as they are written: one hidden class for
715
- // every styled node in the app, instead of a shape transition per slot.
502
+ // All slots are present from the start rather than added as they are written: one hidden class
503
+ // for every styled node, instead of a shape transition per slot.
716
504
  // Narrowed rather than cast: `routeProp` takes `unknown`, and a bare `typeof v === 'function'`
717
- // leaves TS with `Function`, which is callable with anything. This states the shape the contract
718
- // actually promises.
505
+ // leaves TS with `Function`, callable with anything. This states the shape the contract promises.
719
506
  function isStyleCallback(value) {
720
507
  return typeof value === 'function';
721
508
  }
@@ -731,52 +518,25 @@ function stylePartsOf(node) {
731
518
  published: undefined,
732
519
  });
733
520
  }
734
- // What belongs in slot 0 right now. The pressed variant is a complete REPLACEMENT rather than an
735
- // overlay: `resolveActiveClassName` resolves the element's tokens PLUS `:active` through the same
736
- // matcher, so a `.btn:active` rule joins the cascade exactly as its specificity says and the
737
- // result already contains everything `.btn` gave. That is why pressing needs no extra style slot
738
- // and leaves the published array's SHAPE untouched.
739
- //
740
- // Resolved LAZILY, at press time, never beside `classStyle`. Eager would mean two resolutions per
741
- // class WRITE — ~14 000 of them on one benchmark create — and twice the distinct keys in a cache
742
- // that clears whole on overflow, to serve a state almost no node is ever in. A press is one event
743
- // on one node, so the second lookup is invisible there.
744
- //
745
- // `:active` applies only to a class that reaches the engine as a STRING, and the reason it is a
746
- // footnote rather than a gap is that essentially nothing delivers anything else.
747
- //
748
- // Vue createVNode normalises class to a string before patchProp ever sees it — in
749
- // @vue/runtime-core, `if (klass && !isString(klass)) props.class =
750
- // normalizeClass(klass)`. So `:class="{btn:true}"` arrives as `"btn"`. Cited by the
751
- // expression, not a line: the package ships several builds of that file and the same
752
- // statement sits on a different line in each, so two readers comparing notes see a
753
- // contradiction that is not one.
754
- // Angular Ivy compiles every class form to per-token addClass/removeClass, and the renderer
755
- // joins the accumulated tokens into ONE string before routeProp.
756
- // React `className` is a string by convention.
757
- // Svelte `normalizeSvelteClass` (adapters/svelte/src/class-value.ts) joins a clsx-shaped
758
- // value, and hands anything else through UNCHANGED — so Svelte never sends a class
759
- // MAP, but it does send a non-string class, deliberately, and it is the one live
760
- // producer of the branch below.
761
- //
762
- // An OBJECT here is not a class map at all — `IClassNameValue` types it as an IResolvedStyle, the
763
- // channel ScrollView / VirtualizedList / FlatList / ImageBackground use to hand a style through
764
- // the class prop, and Svelte's `resolveSvelteClass` exists to feed it. Canonicalising that into
765
- // tokens would not have been a category error only in theory: it would have hit a live producer on
766
- // four components, and they would have silently lost their styling. Do not "simplify" the object
767
- // branch away.
768
- //
769
- // What remains is an ARRAY of plain strings, which no adapter produces today and which reduces
770
- // fresh on every call, so it gets neither a pressed variant nor `isAlreadyPublished`. Narrow, and
771
- // closable by joining an all-string array before the string path — not done here.
772
- //
773
- // The identity reasoning underneath: the registry memoises a class STRING to the same object, and
774
- // `isAlreadyPublished` compares slot 0 with Object.is. A variant built from a value that resolves
775
- // fresh each call could never be turned away by the guard, and 1 000 unpressed rows would
776
- // republish and re-dirty — the storm the guard exists to stop.
777
- // Slot 1's twin of `baseStyleOf`. The variant stands in for the AUTHORED style, so it replaces
778
- // slot 1 and not slot 0 — it must beat the class cascade exactly the way the authored style does,
779
- // and a `:active` class rule must still be able to win slot 0 underneath it.
521
+ // The pressed variant is a complete REPLACEMENT, not an overlay: `resolveActiveClassName` resolves
522
+ // the element's tokens PLUS `:active` through the same matcher, so the result already contains
523
+ // everything the base class gave. No extra slot needed; the published array's shape stays put.
524
+ // Resolved LAZILY, at press time, never beside `classStyle` — eager would double the resolutions
525
+ // on every class WRITE to serve a state almost no node is ever in. A press is one event on one
526
+ // node, so the extra lookup there is invisible.
527
+ // `:active` applies only to a class reaching the engine as a STRING — true for nearly every
528
+ // producer: Vue/Angular/React all normalize `class` to a string before it reaches here, except
529
+ // Svelte's `normalizeSvelteClass`, the one live producer of a non-string class below.
530
+ // An OBJECT here is not a class map — `IClassNameValue` types it as an IResolvedStyle, the channel
531
+ // ScrollView/VirtualizedList/FlatList/ImageBackground use to hand a style through the class prop.
532
+ // Canonicalising it into tokens would silently break their styling — do not "simplify" it away.
533
+ // An ARRAY of plain strings: no adapter produces this today, and it reduces fresh every call, so
534
+ // it gets neither a pressed variant nor `isAlreadyPublished`.
535
+ // The registry memoises a class STRING to the same object; `isAlreadyPublished` compares slot 0
536
+ // with Object.is. A variant resolving fresh each call could never be turned away by that guard, so
537
+ // unpressed rows would republish and re-dirty forever — the storm the guard exists to stop.
538
+ // Slot 1's twin of `baseStyleOf`: the variant replaces slot 1, not slot 0, so it beats the class
539
+ // cascade the way the authored style does, while a `:active` rule can still win slot 0 underneath.
780
540
  function explicitStyleOf(parts) {
781
541
  return parts.isPressed && parts.activeStyle !== undefined
782
542
  ? parts.activeStyle
@@ -787,55 +547,30 @@ function baseStyleOf(parts) {
787
547
  ? resolveActiveClassName(parts.className)
788
548
  : parts.classStyle;
789
549
  }
790
- // Republish the merged style after one half changed. The halves are written IN PLACE by the
791
- // callers below - there is no patch object and no spread, because this is the hottest function in
792
- // the mutation API (9.3 ms self time and a large share of GC on a 4 000-row create, when it still
793
- // allocated a patch literal plus a merged copy per write).
794
- //
795
- // The fresh ARRAY is the one allocation that stays, and that is DELIBERATE - do not "finish the
796
- // optimization" by skipping when both halves are unchanged. The parts are a shadow copy of the
797
- // declarative style, and setNativeProps bypasses them (it writes node.props.style directly,
798
- // merging an Animated frame onto whatever is there). An app that hands over a hoisted style
799
- // constant - StyleSheet.create, a module-level object - would then re-push an identity-equal half,
800
- // get skipped by setProp's Object.is guard, and never restore the declarative style the animation
801
- // overwrote. The re-push IS the restore path.
802
- // Would `pushClassStyle` republish an array byte-identical to the one already standing?
803
- //
804
- // Sound because `pushClassStyle` is the ONLY writer of `parts.published` — both routeProp branches
805
- // and setNodeHidden funnel through it — so a node that has published nothing holds `undefined` and
806
- // the first write can never be swallowed.
807
- // What a node publishes when NOTHING resolves — an unstyled node, or the benchmark row's
808
- // `style={isSelected ? {…} : undefined}` on the 999 rows that are not selected. Length 0 is the
809
- // marker and needs no second field: `pushClassStyle` never publishes an empty array otherwise, so
810
- // the state is unambiguous, and it is distinct from `undefined`, which means "nothing published
811
- // yet" and must never be turned away.
550
+ // Republish the merged style after one half changed. Halves are written IN PLACE by the callers
551
+ // below — no patch object, no spread — because this is the hottest function in the mutation API.
552
+ // The fresh ARRAY allocation stays, DELIBERATELY: `setNativeProps` bypasses these parts, so an app
553
+ // handing over a hoisted style constant would get skipped by the Object.is guard and never
554
+ // restore the declarative style an animation overwrote. The re-push IS the restore path.
555
+ // Sound because `pushClassStyle` is the ONLY writer of `parts.published` — so a node that has
556
+ // published nothing holds `undefined`, and the first write can never be swallowed as "unchanged".
557
+ // What a node publishes when NOTHING resolves. Length 0 is the marker, needing no second field:
558
+ // `pushClassStyle` never otherwise publishes an empty array, and it's distinct from `undefined`
559
+ // ("nothing published yet", which must never be turned away).
812
560
  const PUBLISHED_NOTHING = Object.freeze([]);
813
561
  // ── ONE ARRAY PER DISTINCT PAIR, SHARED ACROSS NODES ─────────────────────────────────────────────
814
- //
815
- // The array above is per-node and identical for every node styled the same way, which is the normal
816
- // case for a list: one `StyleSheet.create` object, or one resolved CSS class, across a thousand rows.
817
- // `mutation-buffer.ts` interns the values it is handed BY IDENTITY, so a thousand distinct-but-equal
818
- // arrays are a thousand entries and a thousand JS -> `folly::dynamic` conversions on the far side.
819
- // Measured on `build-release`: 4 000 of 12 005 `setProp` ops refused to fold, and they were exactly
820
- // these.
821
- //
822
- // `WeakMap`, and both levels of it, so nothing here can grow without bound: a caller that builds a
823
- // fresh style object per render gets a fresh cache entry that dies with the object. That caller also
824
- // gets no sharing, which is correct — two structurally equal objects are two values to whoever reads
825
- // them, and a deep compare would make every prop write cost the size of the style.
826
- //
827
- // The three-slot (hidden) form is deliberately NOT cached. `display: 'none'` is a state almost no
828
- // node is ever in, so a third map would be paid for on every write to serve a case that is rare by
829
- // construction.
562
+ // `mutation-buffer.ts` interns values BY IDENTITY, so distinct-but-equal arrays across a list
563
+ // styled the same way cost distinct entries and separate JS -> `folly::dynamic` conversions.
564
+ // `WeakMap`, both levels, so nothing grows unbounded: a fresh style object per render gets a fresh
565
+ // cache entry that dies with the object, and correctly gets no sharing — two structurally equal
566
+ // objects are two values to whoever reads them.
567
+ // The three-slot (hidden) form is deliberately NOT cached: `display: 'none'` is rare by
568
+ // construction, so a third map would be paid for on every write to serve it.
830
569
  const sharedPairByExplicit = new WeakMap();
831
570
  const sharedPairByBase = new WeakMap();
832
- /**
833
- * The published array for this pair — the same object every time the same two parts are handed in.
834
- *
835
- * `undefined` when the pair cannot be keyed (a primitive half, or the hidden form), and the caller
836
- * then builds its own array exactly as before. Sharing is an optimization here, never a requirement:
837
- * every reader of `published` compares its SLOTS by identity, never the array itself.
838
- */
571
+ // The published array for this pair — same object every time the same two parts are handed in.
572
+ // `undefined` when the pair can't be keyed (a primitive half, or the hidden form), and the caller
573
+ // then builds its own array; every reader compares slots by identity, never the array itself.
839
574
  function sharedStylePair(base, explicit) {
840
575
  const baseIsKeyable = typeof base === 'object' && base !== null;
841
576
  const explicitIsKeyable = typeof explicit === 'object' && explicit !== null;
@@ -879,49 +614,28 @@ function sharedStylePair(base, explicit) {
879
614
  return made;
880
615
  }
881
616
  // A slot that contributes no keys to the payload: absent, or the registry's shared "this class
882
- // styles nothing" object. An IDENTITY compare rather than a key count — `Object.keys(x).length`
883
- // allocates an array, and this runs on every class and style write, ~14 000 times on one benchmark
884
- // create. That is the F-12 shape: an expensive guard in front of cheap work.
885
- /** A plain style bag — not an array of styles, not a callback, not null. */
617
+ // styles nothing" object. An IDENTITY compare, not a key count — `Object.keys(x).length` allocates
618
+ // an array, and this runs on every class and style write.
619
+ // A plain style bag — not an array of styles, not a callback, not null.
886
620
  function isStyleRecord(value) {
887
621
  return typeof value === 'object' && value !== null && !Array.isArray(value);
888
622
  }
889
- /**
890
- * Is this rebuilt style the same style, key for key?
891
- *
892
- * A component body that writes its style inline hands over a FRESH object every render, equal to
893
- * the one already standing — the commonest shape any app produces, and one `Object.is` cannot see.
894
- * Without this the write crosses into the host, becomes a `folly::dynamic`, and is only THEN found
895
- * to be unchanged. Measured on `build-release` (`no-op-rerender-cost.itest.ts`), 1 000 rows
896
- * re-rendered with nothing changed: 9.7 ms against 0.3 ms for the same app with its style hoisted,
897
- * and 5.2 ms of that was the conversion. The cheapest place to refuse a write is the earliest place
898
- * that can see it is a no-op.
899
- *
900
- * SHALLOW AND CONSERVATIVE, both deliberately. A nested value (a transform list, a shadow, a style
901
- * array) reports "not the same" rather than being compared deeply, because a deep compare makes this
902
- * guard cost the size of the style — which is the cost it exists to avoid. Those keep crossing and
903
- * the host's own `diffProps` refuses them exactly as before, so being wrong here is slow, never
904
- * incorrect.
905
- *
906
- * `undefined` on either side also reports "not the same", which is what lets the key COUNT stand in
907
- * for a key-set comparison: equal counts plus every key of `next` matching a defined value in
908
- * `standing` cannot leave a key unaccounted for.
909
- */
623
+ // Is this rebuilt style the same style, key for key? A component body writing its style inline
624
+ // hands over a fresh object every render, equal to the one already standing, which Object.is
625
+ // cannot see — without this the write crosses into the host and is only found unchanged there.
626
+ // Shallow and conservative, deliberately: a nested value (transform list, shadow, style array)
627
+ // reports "not the same" rather than being deep-compared, so being wrong here is slow, never
628
+ // incorrect — the host's own diffProps still refuses those exactly as before.
629
+ // `undefined` on either side also reports "not the same", which lets the key COUNT stand in for a
630
+ // key-set comparison: equal counts plus every key of `next` matching a defined value in `standing`
631
+ // cannot leave a key unaccounted for.
910
632
  export function isSameShallowStyle(next, standing) {
911
- // THE SAME OBJECT IS THE SAME STYLE, and saying so first is worth a line: without it a re-push of
912
- // a hoisted constant — the commonest shape there is, and what Solid does on every signal change
913
- // because it has no diff — allocates TWO key arrays and walks them to reach the same answer.
914
- // Measured on Hermes (`style-write-cost.itest.ts`): the walk to this guard cost 1.12 us
915
- // per write against a 0.84 us buffer write, so the compare was dearer than the write it protects.
916
- // With this line, 0.32.
917
- //
918
- // IT CHANGES NOTHING OBSERVABLE, and that was break-tested rather than assumed. Inverting it — an
919
- // identical object reporting "changed" — leaves all 1 044 unit tests and all 512 itests green,
920
- // because `pushClassStyle`'s own `isAlreadyPublished` catches the republish downstream:
921
- // `sharedStylePair` memoizes the pair by the explicit object, so the array it rebuilds is
922
- // identity-equal to the one standing. The saving is the two key arrays and the walk, and nothing
923
- // else. A speed change with no behaviour signature has to be justified by a measurement alone
924
- // (method §8), which is why the number above is in this comment rather than in a commit message.
633
+ // THE SAME OBJECT IS THE SAME STYLE, checked first: without it, a re-push of a hoisted constant
634
+ // (what Solid does on every signal change, having no diff) allocates two key arrays and walks
635
+ // them to reach the same answer the identity check gives for free.
636
+ // Changes nothing OBSERVABLE — break-tested, not assumed: inverting this line (identical object
637
+ // reporting "changed") leaves the full test suite green, because `pushClassStyle`'s own
638
+ // `isAlreadyPublished` catches the republish downstream via `sharedStylePair`'s memoization.
925
639
  if (next === standing)
926
640
  return isStyleRecord(next);
927
641
  if (!isStyleRecord(next) || !isStyleRecord(standing))
@@ -972,46 +686,21 @@ function isAlreadyPublished(parts) {
972
686
  : published.length === 3 && Object.is(published[2], parts.hiddenStyle);
973
687
  }
974
688
  function pushClassStyle(node, parts) {
975
- // The fresh array below can never be turned away by setProp's Object.is guard, so without this
976
- // an UNCHANGED class still lands as a write AND marks the node dirty. Costs React / Vue / Svelte
977
- // nothing — each diffs props before calling the engine — but Solid has no diff: a fine-grained
978
- // effect re-runs whenever any signal it reads changes, so a list-wide signal makes every row
979
- // re-push its own unchanged class. Measured on device 2026-08-23 (examples/solid, once its
980
- // primitives were tags): selecting one row of 1 000 read WRITES 1001 and a 10.3 ms reconcile
981
- // window against Fabric's unmoved 0/0/10 — a thousand-node dirty walk for two nodes of change.
982
- //
983
- // This is NOT the naive skip the paragraph above forbids, and the published marker is the
984
- // difference. Skipping on "the parts are unchanged" alone would break the restore path, because
985
- // setNativeProps writes the style slot past this function and a hoisted style constant would then
986
- // never be restored. But setNativeProps CLEARS `parts.published` — so after any bypass
987
- // isAlreadyPublished is false, the re-push happens exactly as before, and the restore path is
988
- // untouched.
989
- //
990
- // Exact rather than approximate: resolveClassName memoizes a class STRING to the same object, so
991
- // an unchanged class yields an identity-equal classStyle. It deliberately does not fire for an
992
- // object/array class value, which resolves fresh every call — the same place the host's own
993
- // Object.is guard gives up on a style object, so no new asymmetry appears.
689
+ // An unchanged class still reaches this write — Solid has no diff, so a list-wide signal
690
+ // re-pushes every row's class and dirties the whole tree. Guard keys off `published`, which
691
+ // setNativeProps clears, so an imperative restore still re-publishes correctly.
994
692
  if (isAlreadyPublished(parts))
995
693
  return;
996
- // NOTHING RESOLVED, so say nothing. The buffer spells an absent prop as `NO_VALUE` and the host
997
- // then takes a path that costs it literally one branch — `if (props.get_ptr(key) == nullptr)
998
- // break` — while `[undefined, undefined]` is a real value it must convert into a `folly::dynamic`
999
- // array, store, and re-compare on every later commit. Both are behaviourally "no style": the
1000
- // payload builder flattens the pair of undefineds into no keys at all.
1001
- //
1002
- // This is NOT the naive skip the note above forbids, and the distinction is the same one the
1003
- // published marker makes. A restore after `setNativeProps` arrives here with `published` cleared
1004
- // to `undefined`, so it is never turned away — and when the authored style is nothing, restoring
1005
- // it means DELETING the slot the imperative write put there, which is what this emits.
694
+ // Emits NO_VALUE rather than `[undefined, undefined]` — the host skips a real array with one
695
+ // pointer check instead of building and diffing a `folly::dynamic`. Same published-marker guard
696
+ // as above keeps the restore path working after setNativeProps clears it.
1006
697
  if (hasNothingToPublish(parts)) {
1007
698
  parts.published = PUBLISHED_NOTHING;
1008
699
  setProp(node, 'style', undefined);
1009
700
  return;
1010
701
  }
1011
- // The third slot is APPENDED ONLY WHILE HIDDEN. Writing a permanent three-element array would
1012
- // change the style payload of every node in every app for a state almost none of them are ever
1013
- // in — and this project spent a day removing per-frame allocations, so a slot that is undefined
1014
- // 99.9% of the time does not get to ride along on every style write.
702
+ // Third slot only appended while hidden — a permanent 3-element array would add an allocation
703
+ // to every style write for a state most nodes never enter.
1015
704
  const base = baseStyleOf(parts);
1016
705
  const explicit = explicitStyleOf(parts);
1017
706
  const published = parts.hiddenStyle === undefined
@@ -1023,81 +712,49 @@ function pushClassStyle(node, parts) {
1023
712
  // `display: 'none'` is a real RN style value (Yoga's DisplayNone), so a hidden node keeps its
1024
713
  // place in the tree, its state and its children — it just stops laying out and painting.
1025
714
  const HIDDEN_STYLE = { display: 'none' };
1026
- /**
1027
- * Stop a node painting without unmounting it, or let it paint again.
1028
- *
1029
- * The seam React's `Activity`/`Suspense` reach for through `hideInstance`/`unhideInstance`. It
1030
- * lives in the engine rather than an adapter because the reversibility problem — restoring the
1031
- * author's style byte for byte — belongs to whoever owns the style merge, and that is here.
1032
- */
715
+ // Stop a node painting without unmounting it, or let it paint again — the seam React's
716
+ // Activity/Suspense reach for through hideInstance/unhideInstance. Lives in the engine, not an
717
+ // adapter, since restoring the author's style byte belongs to whoever owns the style merge.
1033
718
  export function setNodeHidden(node, hidden) {
1034
719
  const parts = stylePartsOf(node);
1035
720
  parts.hiddenStyle = hidden ? HIDDEN_STYLE : undefined;
1036
721
  pushClassStyle(node, parts);
1037
722
  }
1038
- /**
1039
- * Put a node into (or out of) its pressed state, so `.x:active` rules apply.
1040
- *
1041
- * The engine-owned half of what `:active` is on the web: the press state resolves BELOW the
1042
- * framework and never crosses into it, which is what lets a pressable be an intrinsic tag rather
1043
- * than a component (`.claude/rules/host-primitive-tier.md`, tier 2). A component is forced only
1044
- * when the TEMPLATE must read the state — `v-slot="{ pressed }"` and the function form of `style`
1045
- * — and this exists so the common case does not have to.
1046
- *
1047
- * Costs nothing when no `:active` rule is registered anywhere: `resolveActiveClassName` hands back
1048
- * the very same object the unpressed path returns, so `isAlreadyPublished` turns the re-push away
1049
- * and the node is never dirtied.
1050
- */
723
+ // Put a node into (or out of) its pressed state, so `:active` rules apply. The engine-owned half:
724
+ // press state resolves below the framework, which is what lets a pressable stay an intrinsic tag
725
+ // instead of a component — a component is forced only when the template must read the state.
726
+ // Costs nothing when no `:active` rule is registered: resolveActiveClassName hands back the same
727
+ // object the unpressed path returns, so isAlreadyPublished turns the re-push away.
1051
728
  export function setNodePressed(node, pressed) {
1052
729
  const parts = stylePartsOf(node);
1053
730
  parts.isPressed = pressed;
1054
731
  pushClassStyle(node, parts);
1055
732
  }
1056
- /**
1057
- * Tell the host a behavior's FEEDBACK is showing — TouchableHighlight's underlay, and only that.
1058
- *
1059
- * The sibling of `setNodePressed` and deliberately NOT the same bit. Press state drives `:active`
1060
- * class resolution, which happens in JS because a class name resolves against a JS registry; this
1061
- * drives a rule that lives in C++ (`foldTouchableHighlightUnderlay`), so it crosses as one op rather
1062
- * than resolving to a style here. And the two are genuinely different facts: RN holds the underlay
1063
- * past release so a fast tap still flashes, so `shown` LAGS `pressed` by a `delayPressOut` timer.
1064
- *
1065
- * No style is computed on this side at all, which is the whole point — the two props the rule reads
1066
- * (`underlayColor`, `activeOpacity`) are ones the engine already strips from the payload, so the
1067
- * values and their defaults live in one place instead of being erased in C++ and reached around for
1068
- * in JS.
1069
- */
733
+ // Tell the host a behavior's FEEDBACK is showing — TouchableHighlight's underlay, and only that.
734
+ // Deliberately not the same bit as setNodePressed: this drives a rule living in C++
735
+ // (foldTouchableHighlightUnderlay), crossing as one op, since `shown` lags `pressed` by a timer.
736
+ // No style computed here at all: the two props the rule reads (underlayColor, activeOpacity) are
737
+ // ones the engine already strips from the payload, so their defaults live in one place.
1070
738
  export function setNodeUnderlayShown(node, shown) {
1071
739
  recordSetUnderlayShown(node, shown);
1072
740
  }
1073
- /**
1074
- * Forget what was last published, so the next `pushClassStyle` cannot be turned away.
1075
- *
1076
- * The one caller is `setNativeProps` (imperative.ts), which writes the style slot past this file —
1077
- * see the note on `IClassStyleParts.published` for why the restore path depends on this. A no-op for
1078
- * a node nobody has styled, which is why it is not `stylePartsOf(node).published = undefined`: that
1079
- * would allocate the parts on a node that has none.
1080
- */
741
+ // Forget what was last published, so the next pushClassStyle cannot be turned away. The one
742
+ // caller is setNativeProps, which writes the style slot past this file. A no-op for a node nobody
743
+ // has styled — that's why this isn't `stylePartsOf(node).published = undefined`.
1081
744
  export function clearPublishedStyle(node) {
1082
745
  if (node.styleParts !== undefined)
1083
746
  node.styleParts.published = undefined;
1084
747
  }
1085
- // The explicit (non-class-derived) style half, for an adapter that builds its style prop up
1086
- // key-by-key (Angular's Ivy ɵɵstyleProp/setStyle) instead of handing over one whole object —
1087
- // it must merge onto this, not onto node.props.style directly, which may be the
1088
- // [classStyle, explicitStyle] array commitClassStyle writes above.
748
+ // The explicit (non-class) style half — an adapter that builds style key-by-key (Angular's
749
+ // ɵɵstyleProp/setStyle) merges onto this, not node.props.style directly, which may hold the
750
+ // [classStyle, explicitStyle] pair pushClassStyle publishes.
1089
751
  export function getExplicitStyle(node) {
1090
752
  return node.styleParts?.explicitStyle;
1091
753
  }
1092
- /**
1093
- * The `[classStyle, explicitStyle]` pair the node currently PUBLISHES — the same value
1094
- * `commitClassStyle` writes, in the same order, so `flattenStyle` collapses it the way Fabric will.
1095
- *
1096
- * For a caller that wants the merged answer without a host: the pair reaches the payload as an op,
1097
- * and only a host holds ops. A test that has not installed one — `core/css-parser` reaches into the
1098
- * engine by relative path and depends on neither package — can read it here instead of reaching
1099
- * into `styleParts`, which is engine-owned and not a shape anything outside may bind to.
1100
- */
754
+ // The `[classStyle, explicitStyle]` pair the node currently publishes, same order pushClassStyle
755
+ // writes, so flattenStyle collapses it the way Fabric will.
756
+ // For a caller that wants the merged answer without a host: the pair reaches the payload as an op,
757
+ // and only a host holds ops — core/css-parser reads it here instead of reaching into styleParts.
1101
758
  export function getPublishedStyle(node) {
1102
759
  const parts = node.styleParts;
1103
760
  if (parts === undefined)
@@ -1105,44 +762,23 @@ export function getPublishedStyle(node) {
1105
762
  return [parts.classStyle, parts.explicitStyle];
1106
763
  }
1107
764
  const CLASS_PROP_KEYS = new Set(['class', 'className']);
1108
- // The flat-bag split (React / Vue / Solid): an `onX` prop becomes an event listener
1109
- // ONLY when the node's component actually declares `x` as an event (per the shared
1110
- // ViewConfig). Otherwise it is a plain prop, so `onTintColor` on a Switch, whose
1111
- // only event is `change`, routes to setProp and reaches Fabric.
1112
- // `id` is RN's W3C-named alias for `nativeID` and it WINS when both are set
1113
- // (`View.js:77-79` — `processedProps.nativeID = id`). A raw `id` is declared by no ViewConfig, so
1114
- // Fabric drops it in SILENCE: a rename that half-works loses the nativeID with nothing red anywhere.
765
+ // Flat-bag split (React/Vue/Solid): `onX` becomes a listener only when the component's ViewConfig
766
+ // declares `x` as an event — otherwise it's a plain prop, so `onTintColor` on a Switch (whose only
767
+ // event is `change`) routes to setProp and reaches Fabric untouched.
768
+ // `id` is RN's alias for `nativeID` and wins when both are set (View.js: `nativeID = id`). No
769
+ // ViewConfig declares raw `id`, so Fabric drops it — a half-working rename loses nativeID with
770
+ // nothing red anywhere.
1115
771
  const ID_ALIAS_FROM = 'id';
1116
772
  const ID_ALIAS_TO = 'nativeID';
1117
- /**
1118
- * The ONE place this rename happens, as of 2026-09-18. It used to happen in SEVEN.
1119
- *
1120
- * `foldHostBag` did it for React, Svelte and Angular off `HOST_PRIMITIVES[*].aliases` (seventeen
1121
- * identical entries); Vue's `patchProp`, Solid's renderer and Angular's own `PROP_ALIASES` each did
1122
- * it again per key; and `foldIdAlias` did it a seventh time in C++. Three different coverage sets,
1123
- * so the answer depended on which adapter you were on and whether the node's tag happened to carry a
1124
- * registered behavior — `core/engine/cpp/tests/js/id-alias-coverage.itest.ts` measured that split
1125
- * before this collapsed it.
1126
- *
1127
- * HERE because this is the funnel: every adapter's prop write ends at `routeProp`, whatever shape it
1128
- * starts in. A bag fold cannot serve the per-key renderers and a per-key fold cannot serve the bag
1129
- * ones; the seam they SHARE can serve both.
1130
- *
1131
- * PRECEDENCE IS WHY THIS NEEDS STATE. Upstream reads both props at once, so `id ?? nativeID` is
1132
- * decided in one expression. A per-key writer never sees both, so precedence would otherwise fall
1133
- * out of write ORDER — `<view id nativeID>` keeping the stale legacy value while `<view nativeID id>`
1134
- * did not. Solid had already built exactly this memory for exactly this reason; it is one copy now.
1135
- *
1136
- * The authored `nativeID` is REMEMBERED rather than discarded, so clearing the `id` hands the slot
1137
- * back instead of latching. A framework that unsets a prop between renders must get the other
1138
- * source back.
1139
- *
1140
- * PRECEDENCE ITSELF IS PER-COMPONENT. `View.js`'s `id ?? nativeID` is the default (`idWins` below),
1141
- * but `TouchableWithoutFeedback.js`'s clone composes that and then runs a `PASSTHROUGH_PROPS` loop
1142
- * that unconditionally overwrites `nativeID` with the raw authored value when set (`:279-282`) — an
1143
- * app authoring both ends up with `nativeID` winning there. `node.nativeIdWinsOverId`, set by
1144
- * `attachHostBehavior` for the one behavior that declares it, flips which side wins.
1145
- */
773
+ // The ONE place this rename happens: HERE because every adapter's prop write ends at routeProp,
774
+ // whatever shape it starts in — a bag fold can't serve the per-key renderers and a per-key fold
775
+ // can't serve the bag ones, but the seam they share can serve both.
776
+ // Precedence needs state because upstream decides `id ?? nativeID` in one expression; a per-key
777
+ // writer never sees both, so unmemoized precedence would fall out of write order instead. The
778
+ // authored nativeID is remembered, so clearing `id` hands the slot back rather than latching.
779
+ // Precedence is per-component: View.js's `id ?? nativeID` is the default, but
780
+ // TouchableWithoutFeedback's clone unconditionally overwrites nativeID with the authored value
781
+ // when set. `node.nativeIdWinsOverId`, set for the one behavior that declares it, flips the winner.
1146
782
  const idAliased = new WeakMap();
1147
783
  function routeIdAlias(node, key, value) {
1148
784
  const state = idAliased.get(node) ?? {
@@ -1162,47 +798,34 @@ function routeIdAlias(node, key, value) {
1162
798
  export function routeProp(node, key, value) {
1163
799
  if (REACT_JSX_DEV_PROPS.has(key))
1164
800
  return;
1165
- // The prop twin of the child redirect in `appendChild`. A composed primitive's owner is written
1166
- // with props that belong to its internal slot — `contentContainerStyle` on a ScrollView styles
1167
- // the content view — and the adapter names the OWNER for a prop for the same reason it names the
1168
- // owner for a child: that is where the app wrote it.
1169
- //
1170
- // Gated on the FIELD, so a node with no slot pays one load and one branch and never touches the
1171
- // registry. The redirected write recurses into the slot's own `routeProp`, which is single-hop
1172
- // by construction: a slot has no slot of its own (`childHost` is documented single-hop, and
1173
- // `buildStructure` is what would have to nest one).
801
+ // Prop twin of the child redirect in `appendChild` — a composed primitive's owner receives props
802
+ // that belong to its internal slot (`contentContainerStyle` on ScrollView styles the content
803
+ // view), same reason the owner is named for a child: that's where the app wrote it.
804
+ // Gated on the field, so a node with no slot pays one load+branch and never touches the registry.
805
+ // The redirect recurses into the slot's own routeProp, single-hop by construction — a slot has no
806
+ // slot of its own (`childHost` is documented single-hop).
1174
807
  if (node.childHost !== undefined) {
1175
808
  const slotKey = slotPropNameFor(node, key);
1176
809
  if (slotKey !== undefined) {
1177
- // A class NAME is a legal spelling of `contentContainerStyle` — every canary writes
1178
- // `contentContainerStyle="scroll-content"` — so a string has to land on the slot as a
1179
- // CLASS. Only the class branch consults the registry; renaming it verbatim would publish a
1180
- // `style` holding a string, which is not a style and is dropped with nothing red. React's
1181
- // wrapper resolves the name itself (components/scroll-view/shared.ts), so this gap could
1182
- // only ever show on the tag path.
810
+ // A class name is a legal spelling of `contentContainerStyle`, so a string must land on the
811
+ // slot as `class`, not `style` — renamed verbatim it would publish a style holding a string,
812
+ // dropped with nothing red. React's wrapper resolves the name itself; this only matters here.
1183
813
  const slotValueFor = node.hostBehavior?.slotValueFor;
1184
814
  routeProp(node.childHost, slotKey === 'style' && typeof value === 'string' ? 'class' : slotKey, slotValueFor === undefined ? value : slotValueFor(slotKey, value));
1185
815
  return;
1186
816
  }
1187
817
  }
1188
- // AFTER the slot redirect, and that order is load-bearing rather than tidy. A composed primitive
1189
- // forwards most of its bag to an internal node — ImageBackground spreads everything but `style`
1190
- // onto its image, exactly as RN does — so an `id` written on the OWNER belongs to the node the
1191
- // redirect sends it to. Resolved before the redirect, the alias would publish a `nativeID` on the
1192
- // wrapper and the inner node would never see it: the owner would answer to a testID the app
1193
- // pointed at the image.
818
+ // After the slot redirect on purpose: a composed primitive forwards most of its bag to an
819
+ // internal node (ImageBackground spreads everything but `style` onto its image), so an `id` on
820
+ // the owner belongs there. Resolved earlier, nativeID would land on the wrapper unseen.
1194
821
  if (key === ID_ALIAS_FROM || key === ID_ALIAS_TO) {
1195
822
  routeIdAlias(node, key, value);
1196
823
  return;
1197
824
  }
1198
- // An AnimatedNode written straight into a prop — `<view style={{opacity: value}}/>` — is
1199
- // resolved here into the value to PUBLISH, with the engine holding the subscription. Same
1200
- // shape as the `style` callback below: a value the engine interprets rather than forwards.
1201
- // Returns its input by identity when nothing is animated, so every branch under this line is
1202
- // unchanged. See `animated/host-binding.ts`; the gate is one boolean for an app that animates
1203
- // nothing.
1204
- //
1205
- // AFTER the slot redirect, so an animated `contentContainerStyle` binds on the node that
825
+ // An AnimatedNode in a prop (`style={{opacity: value}}`) resolves here to the value to publish,
826
+ // the engine holding the subscription. Returns its input by identity when nothing is animated,
827
+ // so every branch below is unchanged (animated/host-binding.ts).
828
+ // After the slot redirect, so an animated `contentContainerStyle` binds on the node that
1206
829
  // actually carries the style.
1207
830
  const resolved = hasAnimatedNodes()
1208
831
  ? bindAnimatedValue(node, key, value)
@@ -1219,32 +842,26 @@ export function routeProp(node, key, value) {
1219
842
  }
1220
843
  if (key === 'style') {
1221
844
  const parts = stylePartsOf(node);
1222
- // A FUNCTION `style` is `style={({pressed}) => …}`, the idiom this ecosystem actually writes.
1223
- // Nothing stands between an app and the tag, so the callback arrives here intact and is
1224
- // resolved at both states — writing `style` + `activeStyle` as an explicit pair is the same
1225
- // thing said by hand, and cheaper by one call per recompute.
1226
- //
1227
- // Without this the failure is silent and total: a function is not an `on*` name, so it misses
1228
- // `setEventListener`, lands in `setProp` as a function value, and `fabricProps` drops function
1229
- // props — the node commits with NO style at all. Traced by the Solid session, 2026-09-01.
1230
- //
1231
- // The callback must be PURE in `pressed`: its result is read once per state, here and under
1232
- // every transform's emission (`core/components/src/state-style.ts` carries the same contract).
845
+ // A function `style` (`style={({pressed}) => …}`) arrives here intact and is resolved at both
846
+ // states — writing `style` + `activeStyle` as an explicit pair, cheaper by one call per
847
+ // recompute than the app doing it by hand.
848
+ // Without this the failure is silent: a function isn't an `on*` name, misses setEventListener,
849
+ // lands in setProp as a function value, and fabricProps drops function props — the node commits
850
+ // with no style at all.
851
+ // The callback must be pure in `pressed` — read once per state, here and under every
852
+ // transform's emission (core/components/src/state-style.ts carries the same contract).
1233
853
  if (isStyleCallback(resolved)) {
1234
854
  parts.explicitStyle = resolved({ pressed: false });
1235
855
  parts.activeStyle = resolved({ pressed: true });
1236
856
  parts.activeStyleFromCallback = true;
1237
857
  }
1238
858
  else {
1239
- // A REBUILT LITERAL EQUAL TO WHAT IS STANDING IS NOT A CHANGE — see `isSameShallowStyle`.
1240
- //
1241
- // Gated on something being PUBLISHED, which is what keeps the restore path intact: a
1242
- // `setNativeProps` write bypasses the parts and clears `parts.published`, and after that this
1243
- // must never turn a write away — the re-push IS the restore. Same mechanism `isAlreadyPublished`
1244
- // relies on, and the same reason.
1245
- //
1246
- // Gated on the previous write NOT having come from a callback, because that one owns
1247
- // `parts.activeStyle` and the branch below has to clear it. Returning early would leave the old
859
+ // A rebuilt literal equal to what is standing is not a change — see `isSameShallowStyle`.
860
+ // Gated on something being published, which keeps the restore path intact: a setNativeProps
861
+ // write clears `parts.published`, and after that this must never turn a write away — the
862
+ // re-push IS the restore, same mechanism isAlreadyPublished relies on.
863
+ // Gated on the previous write not coming from a callback, since that one owns
864
+ // `parts.activeStyle` and this branch must clear it — returning early would leave the old
1248
865
  // pressed look standing under a plain style.
1249
866
  if (parts.published !== undefined &&
1250
867
  !parts.activeStyleFromCallback &&
@@ -1268,37 +885,23 @@ export function routeProp(node, key, value) {
1268
885
  if (key === 'activeStyle') {
1269
886
  const parts = stylePartsOf(node);
1270
887
  parts.activeStyle = resolved;
1271
- // Slot 1 is no longer ours, by definition — whatever a callback derived earlier has just been
1272
- // replaced. Without this the flag outlives the value it describes: a callback sets it, this
1273
- // branch overwrites the slot silently, and a later plain `style` then clears a variant the
1274
- // engine never derived. An author writes either a callback or an explicit pair, never both for
1275
- // one node — but a flat-bag adapter routes a bag key by key and can deliver that sequence.
888
+ // Slot 1 is no longer ours — whatever a callback derived has just been replaced. Without this
889
+ // the flag outlives its value: this branch overwrites the slot silently and a later plain
890
+ // `style` clears a variant the engine never derived, a sequence a flat-bag adapter can deliver.
1276
891
  parts.activeStyleFromCallback = false;
1277
892
  pushClassStyle(node, parts);
1278
893
  return;
1279
894
  }
1280
- // RN's snapshot affordance (`Pressable.js:222` seeds `usePressState` with it): render the control
1281
- // pressed with no gesture. It selects `activeStyle` and any `:active` class, which is exactly what
1282
- // `isPressed` already decides — so it belongs beside `activeStyle` rather than in a behavior.
1283
- //
1284
- // HERE RATHER THAN IN `attachAfterCommit`, and the census caught the difference. A behavior hook
1285
- // reading this prop costs a post-commit WAITER on every pressable in the app — a real boundary
1286
- // crossing per node, which `adapters/solid/src/crossing-and-payload-census.probe.test.tsx` budgets
1287
- // at two and which went to six. This branch crosses nothing: it is one string compare on a write
1288
- // that already reached the tail of `routeProp`, and it lands on the FIRST commit rather than the
1289
- // second. A JS compare is not a crossing, and weighing it as one is what sent the first attempt to
1290
- // the wrong seam.
1291
- //
1292
- // TouchableHighlight's half of the same prop is NOT here: it PAINTS an underlay, so it is a rule
1293
- // in `SymbioteFabricProps.cpp`. Two mechanisms, one prop name, because that is what upstream has.
1294
- // A SIDE EFFECT AND A PASSTHROUGH, not a consume, and the difference is load-bearing in both
1295
- // directions. The pressed state is set here; the prop ALSO goes on to `node.props`, because
1296
- // TouchableHighlight's rule reads it off the authored bag to paint its underlay
1297
- // (`foldTouchableHighlightUnderlay`). Returning early — the first spelling — left that rule blind
1298
- // and turned three of its cases red. Keeping it out of the PAYLOAD is a separate job and already
1299
- // done, by `kPressableMachineKeys` in `SymbioteFabricProps.cpp`.
1300
- //
1301
- // The same shape `GATED_EVENT_PROPS` uses above: act, then let the write continue.
895
+ // RN's snapshot affordance (Pressable.js seeds usePressState with it): render the control
896
+ // pressed with no gesture. Selects activeStyle and any `:active` class — exactly what isPressed
897
+ // already decides, so it belongs beside activeStyle rather than in a behavior.
898
+ // Here rather than in attachAfterCommit: a behavior hook reading this prop costs a post-commit
899
+ // crossing per pressable node (budgeted in crossing-and-payload-census.probe.test.tsx). This
900
+ // branch is one string compare on the first commit — no crossing at all.
901
+ // TouchableHighlight's half of the same prop paints an underlay, so it's a separate rule in
902
+ // SymbioteFabricProps.cpp — a side effect AND a passthrough. Returning early here left that rule
903
+ // blind; keeping it out of the payload is kPressableMachineKeys's job, done separately.
904
+ // Same shape GATED_EVENT_PROPS uses above: act, then let the write continue.
1302
905
  if (key === 'testOnly_pressed')
1303
906
  setNodePressed(node, resolved === true);
1304
907
  if (isOnEventName(key)) {
@@ -1308,11 +911,9 @@ export function routeProp(node, key, value) {
1308
911
  bindAnimatedEvent(node, key, resolved);
1309
912
  const name = listenerName(key);
1310
913
  const isRegisteredEvent = RESPONDER_EVENTS.has(name) || isEventFor(node.component, name);
1311
- // Investigation instrumentation (HeaderOptionsScreen unresponsive-buttons bug): RNS* views
1312
- // derive their events from react-native-screens' own codegen ViewConfig (registry.ts), so an
1313
- // unregistered event silently falls through to setProp below — a dead prop Fabric ignores,
1314
- // indistinguishable from "the button did nothing" at the UI. Scoped to RNS* to avoid noise
1315
- // from the rest of the app. Kept behind DEBUG per <keep_logs_gate_behind_DEBUG>, never removed.
914
+ // RNS* views derive events from react-native-screens' own codegen ViewConfig, so an
915
+ // unregistered event falls through to setProp as a dead prop Fabric ignores — indistinguishable
916
+ // from "the button did nothing" at the UI. Scoped to RNS* to avoid noise; gated behind DEBUG.
1316
917
  if (node.component.startsWith('RNS')) {
1317
918
  dlog(`routeProp: ${node.component} "${key}" -> listener "${name}" ` +
1318
919
  `registered=${isRegisteredEvent} at t=${Date.now()}`);
@@ -1324,27 +925,19 @@ export function routeProp(node, key, value) {
1324
925
  }
1325
926
  setProp(node, key, resolved);
1326
927
  }
1327
- // Counted in the same propStats, because a text write IS a prop write: it reaches Fabric as
1328
- // RCTRawText's only prop.
1329
- //
1330
- // Unguarded, unlike the old version, and for the same reason `setProp` is: comparing against the
1331
- // standing text would mean reading it back from the host. The host holds it as a local field and
1332
- // dedupes there. Two consequences it also absorbs, both of which used to be spelled here — a write
1333
- // to or from `''` takes this node out of its parent's renderable child list or puts it back, and a
1334
- // raw text REPARENTED under a `<Text>` commits as RCTVirtualText instead of RCTText.
928
+ // Counted in propStats — a text write IS a prop write, reaching Fabric as RCTRawText's only prop.
929
+ // Unguarded, same reason setProp is: comparing against the standing text means reading it back
930
+ // from the host, which holds it locally and dedupes there — including empty-string child-list
931
+ // transitions and reparenting under `<Text>` (RCTVirtualText vs RCTText).
1335
932
  export function setText(node, text) {
1336
933
  propStats.writes += 1;
1337
934
  recordSetText(node, text);
1338
935
  }
1339
- // The structural ops. Each is one op and nothing else: the host detaches the child from whatever
1340
- // parent it currently has before linking it, which is the truth even when an adapter names a stale
1341
- // one — frameworks spell a MOVE as remove-then-insert and can arrive after the insert already
1342
- // re-parented the node. That is why there is no `detach` here any more; JS does not know the old
1343
- // parent and does not need to.
1344
- //
1345
- // What they DO still decide in JS is which node an op names, and there are two such redirects — a
1346
- // composed primitive's slot and a wrap claim. Both are read off a field on the node, so a tree with
1347
- // neither pays one load and one branch per op.
936
+ // Structural ops: each is one op and nothing else — the host detaches a child from whatever
937
+ // parent it currently has before linking it, true even when an adapter names a stale one (a MOVE
938
+ // spells as remove-then-insert). JS doesn't track the old parent and doesn't need to.
939
+ // What JS still decides is which node an op names — two redirects, a composed primitive's slot and
940
+ // a wrap claim, both read off a field so a plain node pays one load and one branch per op.
1348
941
  // The host's raw answer, surface INCLUDED — unlike `parentOf` (host-access.ts), which reports a
1349
942
  // top-level node as parentless by design. The two swaps below have to NAME the holder in an op, and
1350
943
  // for a wrapped node sitting directly under a surface that holder is the surface node.
@@ -1356,15 +949,12 @@ function holderOf(node) {
1356
949
  // Which node a child actually lands on. See `ISymbioteNode.childHost`: the adapter always names the
1357
950
  // OWNER, and a node whose behavior built an internal subtree redirects the app's children into it —
1358
951
  // unless the behavior CLAIMS this particular child, which keeps it on the owner (`claimedChildren`).
1359
- //
1360
952
  // SINGLE HOP, not a loop, and the field's own comment says why — a chain would put a walk on the
1361
953
  // engine's hottest path to express a depth no primitive has. A behavior needing depth points
1362
954
  // `childHost` at the innermost node itself.
1363
- //
1364
- // Reads a field that is `undefined` on every node in every app that registers no composed
1365
- // primitive, so the cost is one load and one branch — deliberately NOT behind `hasHostBehaviors()`,
1366
- // which would be a second read to save nothing. The claim check sits BEHIND that branch, so only a
1367
- // slot-bearing node ever pays the registry probe.
955
+ // Reads a field undefined on every node with no composed primitive — one load, one branch.
956
+ // Deliberately not behind hasHostBehaviors() (a second read for nothing); the claim check sits
957
+ // behind that branch, so only a slot-bearing node pays the registry probe.
1368
958
  function hostFor(parent, child) {
1369
959
  const slot = parent.childHost;
1370
960
  if (slot === undefined)
@@ -1375,16 +965,13 @@ function hostFor(parent, child) {
1375
965
  return parent;
1376
966
  return claimModeFor(parent, child.component) === undefined ? slot : parent;
1377
967
  }
1378
- // The node a child must be inserted BEFORE, or `undefined` for an ordinary append.
1379
- //
1380
- // A host that STILL has a slot at this point is an owner taking a CLAIMED child, and that child
1381
- // goes before the slot whatever the framework asked for. RN renders `{refreshControl}{content}` in
1382
- // that order, and the node a framework names as `beforeChild` lives inside the slot, so the host
1383
- // could not place against it here anyway.
1384
- //
1385
- // A SIBLING slot is the opposite placement: RN paints the background image first and the app's
1386
- // children over it (ImageBackground.js:80-102), so they append past it rather than in front of it —
1387
- // which is what `undefined` here leaves alone.
968
+ // The node a child must be inserted before, or `undefined` for an ordinary append.
969
+ // A host that still has a slot is an owner taking a claimed child, which goes before the slot
970
+ // whatever the framework asked — RN renders `{refreshControl}{content}` in that order, and
971
+ // `beforeChild` lives inside the slot anyway.
972
+ // A sibling slot is the opposite: RN paints the background image first and children over it
973
+ // (ImageBackground.js), so they append past it rather than in front — what `undefined` leaves
974
+ // alone.
1388
975
  function slotAnchorOf(host) {
1389
976
  const slot = host.childHost;
1390
977
  if (slot === undefined || !slotTakesChildren(host))
@@ -1392,21 +979,12 @@ function slotAnchorOf(host) {
1392
979
  return slot;
1393
980
  }
1394
981
  // ── the two structural recorders, and why nothing here calls the raw ones ───────────────────────
1395
- //
1396
- // `mayHaveChildren` is only sound if EVERY op that gives a node a child raises it. There are five
1397
- // such call sites in this file and a sixth is a plausible future edit, so the bit is raised here
1398
- // rather than at each of them: a site that forgets would make `childrenOf` answer "empty" for a node
1399
- // that has children, which is a wrong ANSWER rather than a slow one. `node.ts` is the only module
1400
- // that records a structural op, so these two are a complete funnel.
1401
- /**
1402
- * Arm a parent's recurring post-commit hook for a STRUCTURAL change.
1403
- *
1404
- * A prop write is not the only thing a behavior can be waiting for, and the ScrollView sticky-header
1405
- * machine is the case that proves it: it drops a wrapper when the framework takes the wrapped child
1406
- * away, which writes no prop on the wrapper's owner at all. Narrowing the beat to prop writes alone
1407
- * left it holding a wrapper around nothing, and its own test said so — the third behavior needing
1408
- * the beat, and the only one whose source comment does not say why.
1409
- */
982
+ // `mayHaveChildren` is only sound if every op that gives a node a child raises it — raised here
983
+ // rather than at each of the five call sites, since a forgotten one would make childrenOf answer
984
+ // "empty" for a node that has children: a wrong answer, not a slow one.
985
+ // Arm a parent's recurring post-commit hook for a STRUCTURAL change, not just a prop write: the
986
+ // ScrollView sticky-header machine drops a wrapper when the framework takes the wrapped child
987
+ // away, writing no prop on the wrapper's owner at all — narrowing the beat to props left it stuck.
1410
988
  function armCommitHookForChildChange(parent) {
1411
989
  if (parent.hasCommitHook)
1412
990
  noteCommitHookNodeChanged(parent);
@@ -1429,11 +1007,9 @@ function placedNode(node) {
1429
1007
  }
1430
1008
  // Make `child` the owner's parent, in place. Returns false when this is not a wrap claim, so the
1431
1009
  // two inserts fall through to the ordinary path on one call.
1432
- //
1433
- // The owner being UNATTACHED is the normal case rather than the edge one: every adapter fills a
1434
- // node's children before appending it to its own parent, so the wrap usually happens while the
1435
- // owner has no holder and only the second op runs. The later `appendChild(root, owner)` then
1436
- // inserts the wrapper instead, because `placedNode` says so.
1010
+ // The owner being unattached is the normal case: every adapter fills a node's children before
1011
+ // appending it to its own parent, so the wrap usually happens with no holder yet and only the
1012
+ // second op runs — the later appendChild(root, owner) inserts the wrapper via placedNode.
1437
1013
  function wrapsOwner(owner, child) {
1438
1014
  if (owner.childHost === undefined)
1439
1015
  return false;
@@ -1489,11 +1065,9 @@ export function appendChild(requestedParent, child) {
1489
1065
  if (hasHostBehaviors())
1490
1066
  notifyChildInserted(parent, placed);
1491
1067
  }
1492
- // NO ANCHOR MEANS APPEND, and it is a real call rather than a defensive guard: solid-js/universal
1493
- // spells "insert at the end" as `insertNode(parent, node, null)` and Vue's runtime-core passes
1494
- // `anchor` straight through as `null`. The old retained tree collapsed it silently — `indexOf(null)`
1495
- // is -1, and the insert fell through to a push. On the wire it cannot: a slot has to name a node, so
1496
- // an unanchored insert IS an append and is recorded as one.
1068
+ // No anchor means append, and it's a real case, not a defensive guard: solid-js/universal spells
1069
+ // "insert at end" as `insertNode(parent, node, null)`, and Vue passes `anchor` through as `null`.
1070
+ // A slot has to name a node, so an unanchored insert IS an append and is recorded as one.
1497
1071
  export function insertBefore(requestedParent, child, beforeChild) {
1498
1072
  if (wrapsOwner(requestedParent, child))
1499
1073
  return;
@@ -1525,20 +1099,17 @@ export function removeChild(requestedParent, child) {
1525
1099
  markDetachCandidate(child);
1526
1100
  return;
1527
1101
  }
1528
- // A slot that IS the child being removed stops being one. Only a behavior that adopts an APP
1529
- // child as its slot can reach this (`onChildInserted`); a `buildStructure` slot is internal and
1530
- // no framework removes it. Without the clear, `hostFor` below redirects the removal INTO the very
1531
- // node being removed, and the child stays committed under a parent the framework believes it
1532
- // left — and the NEXT child appended nests inside the orphan.
1102
+ // A slot that IS the child being removed stops being one. Only a behavior that adopts an app
1103
+ // child as its slot reaches this; without the clear, hostFor below redirects the removal into
1104
+ // the node being removed, and the next child appended nests inside the orphan.
1533
1105
  if (requestedParent.childHost === child)
1534
1106
  requestedParent.childHost = undefined;
1535
1107
  // Redirected for the same reason the two inserts are: the adapter removes from the node it
1536
1108
  // appended to, which is the OWNER, while the child actually lives in the slot.
1537
1109
  const parent = hostFor(requestedParent, child);
1538
- // `hasAttachedBehaviors`, NOT `hasHostBehaviors`: the second is on from module load in every app,
1539
- // because registering `Pressable` as a TYPE arms it. Nominating a candidate makes the commit sweep
1540
- // cross every removed node into JS — 10 000 handles on a 1 000-row clear, measured at 3.2x the
1541
- // whole teardown — and none of it can matter before a behavior has actually attached to something.
1110
+ // hasAttachedBehaviors, not hasHostBehaviors: the latter is on from module load in every app just
1111
+ // from registering Pressable as a type. Nominating a candidate crosses every removed node into JS
1112
+ // on the commit sweep, which can't matter before a behavior has actually attached to anything.
1542
1113
  if (hasAttachedBehaviors() || hasAnimatedBindings())
1543
1114
  markDetachCandidate(child);
1544
1115
  // BOTH, and the owner is the one that matters: a composed primitive's behavior lives on the node
@@ -1548,16 +1119,9 @@ export function removeChild(requestedParent, child) {
1548
1119
  armCommitHookForChildChange(parent);
1549
1120
  recordRemoveChild(parent, placedNode(child));
1550
1121
  }
1551
- /**
1552
- * A structural census of the tree the HOST holds — see `ITreeCensus` (tree-host.ts) for what each
1553
- * number is for and why the anchor count says more about the adapter than about the app.
1554
- *
1555
- * It walks nothing here: the walk needs `props.text` to tell an empty raw text from a real one, and
1556
- * a child list to measure a flatten width, and JS has neither. `undefined` from `treeHost()` means
1557
- * nothing is installed, and the empty census is the honest answer — every probe that reads this
1558
- * asserts against a mounted tree, so a zero from an uninstalled host cannot be mistaken for one from
1559
- * an empty one.
1560
- */
1122
+ // A structural census of the tree the HOST holds — see ITreeCensus (tree-host.ts). Walks nothing
1123
+ // here: the walk needs props.text and a child list, which JS has neither. `undefined` from
1124
+ // treeHost() means nothing installed, so the empty census can't be mistaken for a real zero.
1561
1125
  export function censusRetainedTree(roots) {
1562
1126
  flushOps();
1563
1127
  return treeHost()?.census(roots) ?? EMPTY_CENSUS;