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