@symbiote-native/engine 0.5.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.md +39 -14
  2. package/android/CMakeLists.txt +51 -0
  3. package/android/build.gradle +90 -0
  4. package/android/src/main/AndroidManifest.xml +1 -0
  5. package/android/src/main/cpp/SymbioteEngineJni.cpp +72 -0
  6. package/android/src/main/java/dev/symbiotenative/engine/SymbioteEngineModule.kt +43 -0
  7. package/android/src/main/java/dev/symbiotenative/engine/SymbioteEnginePackage.kt +35 -0
  8. package/build/accessibility-info/shared.js +1 -1
  9. package/build/accessibility-props.d.ts +1 -8
  10. package/build/accessibility-props.js +13 -16
  11. package/build/animated/animations/composition.d.ts +1 -1
  12. package/build/animated/animations/composition.js +18 -4
  13. package/build/animated/easing.d.ts +3 -2
  14. package/build/animated/easing.js +17 -88
  15. package/build/animated/event.js +6 -1
  16. package/build/animated/host-binding.d.ts +1 -1
  17. package/build/animated/host-binding.js +19 -4
  18. package/build/animated/index.d.ts +1 -1
  19. package/build/animated/mock.d.ts +1 -19
  20. package/build/animated/props.js +1 -1
  21. package/build/animated/rgba.js +16 -50
  22. package/build/events/index.js +88 -40
  23. package/build/fabric-props.d.ts +1 -1
  24. package/build/fabric-props.js +116 -184
  25. package/build/fabric.d.ts +9 -0
  26. package/build/fabric.js +32 -0
  27. package/build/host-access.d.ts +145 -0
  28. package/build/host-access.js +315 -0
  29. package/build/host-behavior.d.ts +84 -21
  30. package/build/host-behavior.js +236 -51
  31. package/build/image-source-write.d.ts +16 -0
  32. package/build/image-source-write.js +65 -0
  33. package/build/imperative.d.ts +49 -0
  34. package/build/imperative.js +258 -0
  35. package/build/index.d.ts +14 -7
  36. package/build/index.js +53 -10
  37. package/build/mutation-buffer.d.ts +238 -0
  38. package/build/mutation-buffer.js +513 -0
  39. package/build/native-engine.d.ts +185 -0
  40. package/build/native-engine.js +182 -0
  41. package/build/native-tree-host.d.ts +25 -0
  42. package/build/native-tree-host.js +68 -0
  43. package/build/node.d.ts +195 -58
  44. package/build/node.js +852 -383
  45. package/build/pan-responder/index.js +27 -52
  46. package/build/platform-color/index.d.ts +1 -1
  47. package/build/platform-color/index.js +11 -4
  48. package/build/process-background-image/index.js +30 -566
  49. package/build/process-background-longhands.d.ts +4 -0
  50. package/build/process-background-longhands.js +44 -0
  51. package/build/process-box-shadow/index.js +23 -187
  52. package/build/process-filter.js +27 -300
  53. package/build/process-transform/index.d.ts +1 -1
  54. package/build/process-transform/index.js +25 -107
  55. package/build/process-transform-origin/index.d.ts +1 -1
  56. package/build/process-transform-origin/index.js +29 -102
  57. package/build/registry.d.ts +36 -0
  58. package/build/registry.js +73 -0
  59. package/build/sound-manager/index.d.ts +3 -0
  60. package/build/sound-manager/index.js +36 -0
  61. package/build/structured-style.d.ts +10 -0
  62. package/build/structured-style.js +180 -0
  63. package/build/style-registry/index.d.ts +14 -0
  64. package/build/style-registry/index.js +60 -11
  65. package/build/surface.d.ts +31 -2
  66. package/build/surface.js +138 -56
  67. package/build/text-input-state.d.ts +1 -0
  68. package/build/text-input-state.js +17 -3
  69. package/build/tree-host.d.ts +322 -0
  70. package/build/tree-host.js +211 -0
  71. package/build/view-config.js +4 -4
  72. package/codegen-specs/NativeSymbioteEngine.ts +27 -0
  73. package/cpp/SymbioteDebug.cpp +51 -0
  74. package/cpp/SymbioteDebug.h +54 -0
  75. package/cpp/SymbioteEngineBindings.cpp +234 -0
  76. package/cpp/SymbioteEngineBindings.h +59 -0
  77. package/cpp/SymbioteFabricProps.cpp +2619 -0
  78. package/cpp/SymbioteFabricProps.h +223 -0
  79. package/cpp/SymbioteTree.cpp +2593 -0
  80. package/cpp/SymbioteTree.h +294 -0
  81. package/ios/SymbioteEngineModule.h +25 -0
  82. package/ios/SymbioteEngineModule.mm +44 -0
  83. package/package.json +31 -3
  84. package/react-native.config.cjs +23 -0
  85. package/symbiote-engine.podspec +42 -0
  86. package/build/animated/bezier.d.ts +0 -1
  87. package/build/animated/bezier.js +0 -102
  88. package/build/commit.d.ts +0 -49
  89. package/build/commit.js +0 -1058
  90. package/build/tags.d.ts +0 -2
  91. package/build/tags.js +0 -40
package/build/node.js CHANGED
@@ -1,22 +1,30 @@
1
- // The retained shadow-tree. Adapters mutate this cheap in-memory tree through a
2
- // tiny API; the commit engine (commit.ts) later walks it and translates the
3
- // whole thing into Fabric's clone-on-write child sets. Keeping the retained
4
- // tree mutable while the Fabric mirror stays persistent lets every adapter mutate
5
- // freely without touching Fabric's clone-on-write protocol directly, and it
6
- // lives here in shared so no adapter re-implements it.
7
- import { isAriaAliasKey } from './accessibility-props.js';
1
+ // The mutation API. Adapters call it; every call appends an OPCODE to `mutation-buffer.ts` and
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.
10
+ import { recordAppendChild, recordCreateAnchor, recordCreateVoid, noteHostSideChange, recordCreateElement, recordCreateRawText, recordInsertBefore, recordRemoveChild, recordSetComponent, recordSetOwnedListener, recordSetUnderlayShown, recordSetProp, recordSetText, } from './mutation-buffer.js';
8
11
  import { isEventFor } from './view-config.js';
9
- import { canonicalClassName, isClassNameValue, resolveActiveClassName, resolveClassName, } from './style-registry/index.js';
10
- import { dlog } from './debug.js';
11
- import { appListenerFor, attachHostBehavior, claimModeFor, hasHostBehaviors, markDetachCandidate, notifyChildInserted, notifyOwnedListenerChange, notifyWrapChange, ownsListener, reattachHostBehaviors, derivedNodesOf, slotDerivesFrom, slotPropNameFor, slotTakesChildren, stashAppListener, } from './host-behavior.js';
12
- // A cycle, deliberately: commit.ts imports this module for the node shape, and the imperative
13
- // methods below call back into it. Neither side touches the other at module-evaluation time -
14
- // only inside a function body - so every loader (tsc, vitest, Metro) resolves it fine. The
15
- // alternative was a load-time `SymbioteNode.prototype.measure = ...` installed from elsewhere,
16
- // which is exactly the registration-side-effect shape Metro's inlineRequires silently drops in
17
- // release builds (see CLAUDE.md, "Never make correctness depend on a module's load-time side
18
- // effect").
19
- import { measure as engineMeasure, measureInWindow as engineMeasureInWindow, measureLayout as engineMeasureLayout, setNativeProps as engineSetNativeProps, dispatchViewCommand, } from './commit.js';
12
+ import { canonicalClassName, EMPTY_STYLE, isClassNameValue, resolveActiveClassName, resolveClassName, } from './style-registry/index.js';
13
+ import { dlog, isDebug } from './debug.js';
14
+ import { appListenerFor, attachHostBehavior, claimModeFor, hasAttachedBehaviors, hasHostBehaviors, markDetachCandidate, notifyChildInserted, notifyOwnedListenerChange, noteCommitHookNodeChanged, ownsListener, reattachHostBehaviors, derivedNodesOf, slotDerivesFrom, slotPropNameFor, slotTakesChildren, stashAppListener, } from './host-behavior.js';
15
+ import { configPayloadFold } from './registry.js';
16
+ import { resolveStructuredStyle } from './structured-style.js';
17
+ 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").
24
+ import { measure as engineMeasure, measureInWindow as engineMeasureInWindow, measureLayout as engineMeasureLayout, setNativeProps as engineSetNativeProps, dispatchViewCommand, } from './imperative.js';
25
+ // The same deliberate cycle, for the same reason: `tree-host.ts` imports `takePropStats` from here
26
+ // and `censusRetainedTree` below asks it for the census. Function bodies only, on both sides.
27
+ import { EMPTY_CENSUS, flushOps, treeHost, } from './tree-host.js';
20
28
  // The same deliberate cycle, for the same reason: `routeProp` resolves an AnimatedNode written
21
29
  // into a prop, and the module that owns that resolution reaches back here for `setProp`. See
22
30
  // `animated/host-binding.ts`'s header.
@@ -55,40 +63,49 @@ const FLASH_SCROLL_INDICATORS_COMMAND = 'flashScrollIndicators';
55
63
  // constructor assignment is the shape every engine (V8 and Hermes both) handles without a
56
64
  // define-per-field.
57
65
  class SymbioteNode {
58
- constructor(component, isText, props) {
66
+ constructor(component, isText) {
59
67
  this[BRAND] = true;
60
68
  this.component = component;
61
69
  this.isText = isText;
62
- this.props = props;
63
70
  this.listeners = undefined;
64
- this.children = [];
65
- this.parent = undefined;
66
- // A node that has never committed must never take a fast path built on "the mirror already
67
- // agrees with me", so all three flags start raised - including for createRawText, whose props
68
- // are assigned here rather than through setText.
69
- this.dirty = true;
70
- this.propsDirty = true;
71
71
  // Assigned here, not lazily on first use: every slot present from the constructor keeps one
72
- // hidden class for every node. Adding it on demand buys a shape transition per aria-bearing
73
- // node, which is the opposite of what this field is for.
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
74
  //
75
- // Starts false, and that is COMPLETE rather than optimistic: the only two constructions are
76
- // `createElement`'s `{}` and `createRawText`'s `{ text }`, so no aria key can arrive here. It
77
- // was first written as `hasAriaAliases(props)` — a probe that reads as a safeguard and can
78
- // never fire, which the break-test caught by staying green with it removed. If a construction
79
- // path is ever added that passes real props, this line owes that probe back.
80
- this.hasAriaAlias = false;
81
- this.structureDirty = true;
82
- this.committed = undefined;
75
+ // `attachHostBehavior` raises this a few lines later for the rare node whose behavior declares
76
+ // the recurring hook.
77
+ this.hasCommitHook = false;
78
+ // Same again; `attachHostBehavior` raises it for the one behavior that declares it, Image's.
79
+ this.resolvesImageSources = false;
80
+ // Same again; `attachHostBehavior` raises it for the one behavior that declares it,
81
+ // TouchableWithoutFeedback's.
82
+ this.nativeIdWinsOverId = false;
83
83
  this.styleParts = undefined;
84
84
  // Assigned here for the same hidden-class reason as `hasAriaAlias` above; `attachHostBehavior`
85
85
  // overwrites it a few lines later for the rare node that has a behavior.
86
86
  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
+ this.hostBehavior = undefined;
87
90
  // Same reason again, and here it is load-bearing rather than tidy: the redirect below is read
88
91
  // on every append, so the slot must be a stable slot on one hidden class, not a property added
89
92
  // to a few nodes after the fact.
90
93
  this.childHost = undefined;
91
94
  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
+ 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
+ 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.
107
+ this.slot = 0;
108
+ this.slotBatch = 0;
92
109
  }
93
110
  measure(callback) {
94
111
  engineMeasure(this, callback);
@@ -113,8 +130,8 @@ class SymbioteNode {
113
130
  dispatchViewCommand(this, BLUR_COMMAND, []);
114
131
  }
115
132
  // The defaults live HERE and nowhere else. `buildScrollViewHandle`
116
- // (`@symbiote-native/components`) used to own them and now delegates, so the wrapper's handle and
117
- // a lowered element's node cannot drift on what `scrollTo()` with no argument means.
133
+ // (`@symbiote-native/components`) delegates here, so a built handle and a node cannot drift on
134
+ // what `scrollTo()` with no argument means.
118
135
  scrollTo(options) {
119
136
  const x = options?.x ?? 0;
120
137
  const y = options?.y ?? 0;
@@ -132,52 +149,93 @@ class SymbioteNode {
132
149
  dispatchViewCommand(this, FLASH_SCROLL_INDICATORS_COMMAND, []);
133
150
  }
134
151
  }
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).
135
162
  /**
136
- * The committed record for `node`, or `undefined` if it has never been committed - or if `node` is
137
- * not the raw retained node at all.
138
- *
139
- * That second case is the reason this is a function rather than a bare `node.committed` read. The
140
- * engine identifies a node BY IDENTITY, and the classic way to break that is to hand the engine a
141
- * wrapper instead of the node: a Vue `reactive()`/deep-`ref()` Proxy around a host element is the
142
- * one that actually happens (see the vue-adapter-reactivity skill; `shallowRef` is the fix).
143
- *
144
- * The old WeakMap caught this for free - a Proxy is a different object, so `mirror.get(proxy)` missed
145
- * and every imperative API bailed with a clear "node not committed". A plain property read does NOT:
146
- * a Proxy forwards `proxy.committed` straight to the target and hands back a real record, whose
147
- * `handle` Vue would then deep-wrap on the way out. That handle is a JSI host object; a Proxy around
148
- * it reaches `cloneNodeWithNewProps` and fails somewhere deep in native, far from the cause.
163
+ * Mint an element and record its creation.
149
164
  *
150
- * So the identity check that was implicit in the WeakMap is explicit here: a record written on the
151
- * raw node names it, and `record.owner !== node` means whatever we were handed is not that node.
152
- * One reference comparison, and the wrap now fails LOUDER than it used to rather than quieter.
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.
153
167
  */
154
- export function committedOf(node) {
155
- const record = node.committed;
156
- if (record === undefined)
157
- return undefined;
158
- if (record.owner !== node) {
159
- dlog(`node identity mismatch: committed record belongs to node=${debugNodeId(record.owner)}, ` +
160
- `not to the object handed in. A wrapped/proxied node (Vue reactive() or deep ref() around ` +
161
- `a host element) is the usual cause - hold host nodes with shallowRef.`);
162
- return undefined;
163
- }
164
- return record;
165
- }
166
168
  export function createElement(component, isText = false,
167
169
  // The intrinsic tag this node came from, when it differs from the Fabric view name above. The
168
170
  // behavior registry is keyed by tag and the node only ever carries the resolved name, so an
169
- // adapter lowering `<Pressable>` has to hand the tag over here or the registration cannot fire
171
+ // adapter creating a `<pressable>` has to hand the tag over here or the registration cannot fire
170
172
  // (host-behavior.ts, `attached`). Nothing is stored — the lookup happens once, right below.
171
173
  tag = component) {
172
- const node = new SymbioteNode(component, isText, {});
174
+ const node = new SymbioteNode(component, isText);
175
+ // 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.
180
+ if (component === ANCHOR_COMPONENT)
181
+ 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.
187
+ else if (component === VOID_COMPONENT)
188
+ 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.
191
+ else
192
+ recordCreateElement(node, component, isText, node);
173
193
  // Gated on the boolean, not on the Map: this runs ~9 000 times per benchmark create, and an app
174
194
  // that registers nothing must pay one boolean read rather than a hash lookup per node.
175
195
  if (hasHostBehaviors())
176
196
  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.
204
+ const configFold = configPayloadFold(component);
205
+ if (configFold !== undefined) {
206
+ const behaviorFold = node.payloadFold;
207
+ node.payloadFold =
208
+ behaviorFold === undefined
209
+ ? configFold
210
+ : props => configFold(behaviorFold(props));
211
+ }
177
212
  return node;
178
213
  }
179
- export function createRawText(text) {
180
- return new SymbioteNode(RAW_TEXT_COMPONENT, false, { text });
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
+ */
227
+ export function createRawText(text, tag = RAW_TEXT_COMPONENT) {
228
+ const node = new SymbioteNode(RAW_TEXT_COMPONENT, false);
229
+ 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.
236
+ if (tag !== RAW_TEXT_COMPONENT && hasHostBehaviors())
237
+ attachHostBehavior(node, tag);
238
+ return node;
181
239
  }
182
240
  // `instanceHandle` round-trips through Fabric unchanged: the object we pass to
183
241
  // createNode comes back as the event target. We brand our nodes so the event
@@ -207,163 +265,147 @@ export function debugNodeId(node) {
207
265
  // not a new field, so the hot SymbioteNode shape is untouched.
208
266
  export const ANCHOR_COMPONENT = '#anchor';
209
267
  export function createAnchor() {
210
- return createElement(ANCHOR_COMPONENT);
268
+ const node = new SymbioteNode(ANCHOR_COMPONENT, false);
269
+ recordCreateAnchor(node);
270
+ return node;
271
+ }
272
+ // 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.
276
+ export const VOID_COMPONENT = '#void';
277
+ export function createVoid() {
278
+ const node = new SymbioteNode(VOID_COMPONENT, false);
279
+ recordCreateVoid(node);
280
+ return node;
281
+ }
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
+ */
293
+ 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
+ */
307
+ export function createSurfaceRoot() {
308
+ const node = new SymbioteNode(SURFACE_COMPONENT, false);
309
+ recordCreateElement(node, 'RCTView', false, node);
310
+ // Recorded straight, not through `routeProp`: these are literal Fabric props, not props an app
311
+ // authored, so they want none of the class merging or event routing that path exists for.
312
+ recordSetProp(node, 'style', { flex: 1 });
313
+ recordSetProp(node, 'pointerEvents', 'box-none');
314
+ return node;
211
315
  }
212
316
  export function isAnchor(node) {
213
317
  return node.component === ANCHOR_COMPONENT;
214
318
  }
215
- // A raw text with no characters must not reach Fabric. Its fragment is dropped by
216
- // AttributedString::appendFragment, but the text walk has already flagged "the last child was raw
217
- // text", so the NEXT raw sibling merges into `fragments.back()` of an empty vector and the process
218
- // aborts. The commit walk skips such a node exactly as it skips an anchor (commit.ts,
219
- // renderableChildren); an empty string paints nothing either way, so nothing is lost. `''` only —
220
- // a whitespace-only string is real content inside a <Text>.
221
- export function isEmptyRawText(node) {
222
- return node.component === RAW_TEXT_COMPONENT && node.props.text === '';
223
- }
224
- // Dirty-marking: lets reconcile return an untouched subtree by reference instead of rebuilding
225
- // every node's Fabric props and deep-comparing them against the mirror. That walk costs ~13 us per
226
- // node on device (Hermes, iOS Debug, examples/react benchmark screen), so without the flag a
227
- // 1200-node tree burns a whole 16.6 ms frame no matter how small the change - the cost tracks TREE
228
- // SIZE, not change size.
229
- //
230
- // Marking walks up to the first ALREADY-dirty ancestor and stops, so a burst of mutations under one
231
- // subtree pays for one chain walk rather than one per mutation. Reconcile clears every node it
232
- // visits, which keeps the invariant "an ancestor of a dirty node is dirty" across commits.
233
- //
234
- // Listener changes deliberately do NOT mark. `node.listeners` never reaches Fabric (event dispatch
235
- // reads it straight off the retained node) and React hands us a fresh handler closure on nearly
236
- // every render, so marking there would re-dirty the whole tree every commit and hand the win back.
237
- // The one listener that DOES change a Fabric prop, `layout`, raises `onLayout` through setProp
238
- // below and is marked that way.
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`
328
+ // through `setProp` below.
239
329
  /**
240
330
  * Change which Fabric view a node commits as, keeping the node's identity.
241
331
  *
242
- * The commit walk already re-creates a node whose `viewName` no longer matches its committed one —
243
- * that is how a `<Text>` moving in or out of another `<Text>` flips between RCTText and
244
- * RCTVirtualText (`commit.ts`, reason `view-kind`). This exposes the same door for a prop-driven
245
- * view choice, so `intrinsicWhen` is honoured on UPDATE and not only at create.
246
- *
247
332
  * The POLICY stays out of the engine: which prop decides, and which view it decides between, lives
248
333
  * in `HOST_PRIMITIVES` and is read by `resolveIntrinsicTag` in `@symbiote-native/components`. The
249
334
  * engine only knows how to swap the name — the same split every other spec-driven fold has here.
250
335
  *
251
336
  * A no-op when the name is unchanged, so a renderer may call it on every update without comparing
252
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.
253
342
  */
254
343
  export function setNodeComponent(node, component) {
255
344
  if (node.component === component)
256
345
  return;
257
346
  node.component = component;
258
- // `dirty` alone is not enough: the walk's reuse test also requires the node be visited at all,
259
- // and a node whose own props did not change this tick is exactly the case that would be skipped.
260
- markDirty(node);
261
- markPropsDirty(node);
262
- }
263
- export function markDirty(node) {
264
- let current = node;
265
- while (current !== undefined && !current.dirty) {
266
- current.dirty = true;
267
- current = current.parent;
268
- }
347
+ recordSetComponent(node, component);
269
348
  }
270
- // The prop-write twin of markDirty: raises this node's OWN props flag and then bubbles the subtree
271
- // flag as usual. Every path that writes `node.props` must come through here - setProp and setText
272
- // below, setNativeProps in commit.ts (which writes the record directly and so owes its own mark).
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.
354
+ // 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.
273
356
  //
274
- // Note the two flags are raised INDEPENDENTLY rather than one implying the other. markDirty stops
275
- // at the first already-dirty ancestor, so a node dirtied a moment ago by a child's change would
276
- // otherwise have its own prop write silently dropped: the walk would exit before setting anything
277
- // here. Setting propsDirty first, unconditionally, is what makes that ordering safe.
278
- export function markPropsDirty(node) {
279
- node.propsDirty = true;
280
- markDirty(node);
281
- }
282
- // The structural twin. Raised on the PARENT whose child list changed - never on the moved child,
283
- // for the same reason markDirty is not (see the structural ops below).
284
- //
285
- // Every caller must reach here BEFORE mutating `parent.children`, and that ordering is now
286
- // load-bearing rather than stylistic. reconcile stores the reconciled child list in the committed
287
- // record BY REFERENCE, so for a parent holding no anchors the record ALIASES `parent.children`;
288
- // this call is the last moment the committed list can still be read. Taking the copy here means it
289
- // is taken once per parent per commit->mutation cycle, and only for parents that actually change,
290
- // instead of once per node per commit - 9 002 arrays on a 1 000-row create, all but a handful
291
- // allocated only to be discarded unread.
292
- //
293
- // The identity test is what keeps it honest: a record whose `children` is NOT `parent.children`
294
- // either already holds a copy (this cycle's first structural op ran) or holds the private array
295
- // renderableChildren built to flatten anchors away, which nobody mutates. Neither needs saving.
296
- export function markStructureDirty(parent) {
297
- const record = parent.committed;
298
- if (record !== undefined && record.children === parent.children) {
299
- record.children = parent.children.slice();
300
- }
301
- parent.structureDirty = true;
302
- markDirty(parent);
303
- }
304
- // How many prop writes actually landed, and how many the no-op guard below turned away.
305
- // Read-and-zeroed through readCommitProfile() (commit.ts), which folds them into the same window
306
- // as the walk numbers so one read prices both halves: `propNoops` is the waste an adapter is
307
- // generating above the engine, `nodesVisited` is what that waste costs below it.
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.
308
363
  //
309
364
  // Not gated behind isDebug(), for the same reason the commit profile is not: an integer increment
310
365
  // is noise next to the prop write it counts, and the figure is only meaningful from a release
311
- // build. A per-call dlog was the obvious alternative and is deliberately NOT here - the Angular
312
- // screen that motivated the guard emitted 90 000 no-op writes on one press, and a log line each
313
- // would measure the logging rather than the code (see the `perf-claims-need-numbers` rule).
314
- const propStats = { writes: 0, noops: 0 };
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).
368
+ const propStats = { writes: 0 };
315
369
  export function takePropStats() {
316
- const snapshot = { writes: propStats.writes, noops: propStats.noops };
370
+ const snapshot = { writes: propStats.writes };
317
371
  propStats.writes = 0;
318
- propStats.noops = 0;
372
+ return snapshot;
373
+ }
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`).
380
+ let propKeyTally;
381
+ export function takePropKeyTally() {
382
+ const snapshot = propKeyTally ?? new Map();
383
+ propKeyTally = undefined;
319
384
  return snapshot;
320
385
  }
321
386
  // A pure prop set: no event inference. `onTintColor` is a Switch prop and reaches
322
387
  // Fabric like any other; the event-vs-prop decision is made by routeProp, never by
323
388
  // the key's name.
324
389
  //
325
- // Writing a value the node already holds is a NO-OP and returns before markDirty. Fabric never saw
326
- // a difference either way - reconcile rebuilds the node's Fabric props and `propsEqual` finds them
327
- // identical, so no clone is emitted - but the mark itself is not free: it walks to the first
328
- // already-dirty ancestor and strips every one of them of the commit walk's early exit, so an
329
- // otherwise untouched subtree gets re-walked purely to prove it is untouched. The guard makes that
330
- // whole bug class free for every adapter instead of each one having to remember to diff first
331
- // (measured: Angular's Pressable host bag pushed 104 000 setProp calls for a screen Solid built in
332
- // 12 000, 90 000 of them writing `undefined` over a key that was not there).
333
- //
334
- // Three deliberate choices:
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.
335
394
  //
336
- // - `Object.hasOwn`, not `node.props[key] === undefined`. A key explicitly present holding
337
- // `undefined` is not an absent key: `delete` genuinely changes the record's shape, and
338
- // `setNativeProps` writes node.props directly and can leave exactly such a key behind. Fabric
339
- // itself cannot tell the two apart (fabricProps skips undefined values), but node.props is also
340
- // read outside the commit path, so the retained tree keeps the shape callers asked for.
341
- // - `Object.is`, not a deep compare. A style object, an array, or a handler closure is a fresh
342
- // reference on nearly every render, so the guard simply never fires for them - correct, since an
343
- // adapter is free to hand back the SAME reference with mutated contents and identity cannot see
344
- // that. A deep compare on every prop write would cost more than the walk it saves.
345
- // - The in-place-mutation hazard that leaves is already instrumented: a node skipped as clean whose
346
- // props have drifted is exactly what `warnIfStale` reports as DIRTY-MISS under DEBUG (commit.ts).
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).
347
402
  export function setProp(node, key, value) {
348
- if (value === undefined) {
349
- if (!Object.hasOwn(node.props, key)) {
350
- propStats.noops += 1;
351
- return;
352
- }
353
- delete node.props[key];
354
- }
355
- else {
356
- if (Object.is(node.props[key], value)) {
357
- propStats.noops += 1;
358
- return;
359
- }
360
- node.props[key] = value;
361
- }
362
- // The single choke point for the aria gate. `routeProp`'s other branches — class, style,
363
- // activeStyle, on* — return before reaching here and none of them can carry an alias, so every
364
- // `role` / `aria-*` write in the engine passes through this line.
365
- if (!node.hasAriaAlias && isAriaAliasKey(key))
366
- node.hasAriaAlias = true;
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
+ //
367
409
  // A composed primitive's slot — and its wrapper, where it has one — can carry a value DERIVED
368
410
  // from an owner prop, and `markPropsDirty` bubbles up, so neither ever learns. Here rather than
369
411
  // in `routeProp` because this is the one choke point every writer passes (a structural adapter's
@@ -381,7 +423,121 @@ export function setProp(node, key, value) {
381
423
  markPropsDirty(node.wrapper);
382
424
  }
383
425
  propStats.writes += 1;
384
- markPropsDirty(node);
426
+ if (isDebug()) {
427
+ propKeyTally ??= new Map();
428
+ const tallyKey = `${node.component}.${key}`;
429
+ propKeyTally.set(tallyKey, (propKeyTally.get(tallyKey) ?? 0) + 1);
430
+ }
431
+ writeProp(node, key, value);
432
+ }
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.
450
+ 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
+ */
457
+ export function writeProp(node, key, value) {
458
+ // 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.
463
+ if (REACT_JSX_DEV_PROPS.has(key))
464
+ 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.
471
+ if (node.hasCommitHook)
472
+ 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.
483
+ let written = value;
484
+ if (key === 'style' || key === 'activeStyle') {
485
+ written = resolveStructuredStyle(value);
486
+ }
487
+ else if (node.resolvesImageSources && IMAGE_SOURCE_PROPS.has(key)) {
488
+ written = resolveImageSourceProp(value);
489
+ }
490
+ if (typeof written === 'function') {
491
+ let bag = functionProps.get(node);
492
+ if (bag === undefined) {
493
+ bag = new Map();
494
+ functionProps.set(node, bag);
495
+ }
496
+ bag.set(key, value);
497
+ // The host must not be left holding whatever stood under this key before — a stale value read
498
+ // back through `propOf` would beat the function this write just stashed.
499
+ recordSetProp(node, key, undefined);
500
+ return;
501
+ }
502
+ // Written over with a non-function: the stash must let go, or it keeps answering.
503
+ const bag = functionProps.get(node);
504
+ if (bag !== undefined)
505
+ bag.delete(key);
506
+ recordSetProp(node, key, written);
507
+ }
508
+ /** What `propOf` consults before asking the host. `undefined` when nothing was stashed. */
509
+ export function functionPropOf(node, key) {
510
+ return functionProps.get(node)?.get(key);
511
+ }
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
+ */
518
+ export function functionPropsOf(node) {
519
+ return functionProps.get(node);
520
+ }
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
+ */
530
+ export function markPropsDirty(node) {
531
+ flushOps();
532
+ // Announced to the buffer even though it writes no op: this is the one route that dirties a node
533
+ // without one, and a commit that cannot see it would skip itself as idle.
534
+ noteHostSideChange();
535
+ // The other way a node's payload is rebuilt, and the beat's population must cover both or a
536
+ // behavior whose payload is DERIVED — the sticky header's debounced translateY has no prop to
537
+ // write — would be armed by nothing.
538
+ if (node.hasCommitHook)
539
+ noteCommitHookNodeChanged(node);
540
+ treeHost()?.markPropsDirty(node);
385
541
  }
386
542
  // Fabric gates a handful of events behind a BOOLEAN prop: unlike scroll / touch / change, which
387
543
  // the native component emits unconditionally, these fire only when the shadow node carries the
@@ -442,16 +598,22 @@ export function setEventListener(node, name, value) {
442
598
  // without this the two evict each other and the last writer wins with no diagnostic; and the
443
599
  // keys at stake are the ones a gesture STARTS on, so the loser is silently pressless. The
444
600
  // component wrapper used to mediate this by destructuring the app's callbacks out before they
445
- // reached the node; lowering removes the mediator. Gated on the boolean first, so an app with no
446
- // behavior registered pays one read.
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.
447
603
  if (hasHostBehaviors() && ownsListener(node, name)) {
448
604
  // The PRESENCE only, never the identity: listeners deliberately do not notify (a framework
449
605
  // hands a fresh closure nearly every render — see `markDirty`'s note on why that must stay
450
606
  // free). A flip is a mount-time event, not a per-render one.
451
607
  const wasWired = appListenerFor(node, name) !== undefined;
452
608
  stashAppListener(node, name, isHandler ? value : undefined);
453
- if (wasWired !== isHandler)
609
+ 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.
614
+ recordSetOwnedListener(node, name, isHandler);
454
615
  notifyOwnedListenerChange(node, name, isHandler);
616
+ }
455
617
  const flagged = GATED_EVENT_PROPS.get(name);
456
618
  if (flagged !== undefined)
457
619
  setProp(node, flagged, isHandler ? true : undefined);
@@ -468,8 +630,28 @@ export function setEventListener(node, name, value) {
468
630
  const flagProp = GATED_EVENT_PROPS.get(name);
469
631
  if (flagProp !== undefined)
470
632
  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.
637
+ if (node.resolvesImageSources && IMAGE_LOAD_EVENT_NAMES.has(name)) {
638
+ setProp(node, 'shouldNotifyLoadEvents', anyImageLoadEventListenerWired(node.listeners) ? true : undefined);
639
+ }
640
+ }
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.
649
+ function isOnEventName(key) {
650
+ if (key.charCodeAt(0) !== 111 || key.charCodeAt(1) !== 110)
651
+ return false;
652
+ const third = key.charCodeAt(2);
653
+ return third >= 65 && third <= 90;
471
654
  }
472
- const ON_PREFIX = /^on[A-Z]/;
473
655
  // onChange -> change
474
656
  function listenerName(propName) {
475
657
  return propName.charAt(2).toLowerCase() + propName.slice(3);
@@ -499,9 +681,17 @@ const RESPONDER_EVENTS = new Set([
499
681
  // consumes both and never forwards them. A JSX-based adapter (Vue JSX, Solid JSX) instead
500
682
  // carries them onto the vnode as ordinary props, so they reach setProp and then Fabric,
501
683
  // where Android's folly::dynamic rejects __self with "JS Functions are not convertible to
502
- // dynamic" (the instance holds functions) and the surface paints black, while iOS silently
503
- // drops it. SFC/template authoring never produces them. Strip them here, once, so no
504
- // adapter leaks React JSX dev metadata to the host, mirroring React's host config.
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.
505
695
  const REACT_JSX_DEV_PROPS = new Set([
506
696
  '__self',
507
697
  '__source',
@@ -523,6 +713,7 @@ function stylePartsOf(node) {
523
713
  isPressed: false,
524
714
  activeStyle: undefined,
525
715
  activeStyleFromCallback: false,
716
+ published: undefined,
526
717
  });
527
718
  }
528
719
  // What belongs in slot 0 right now. The pressed variant is a complete REPLACEMENT rather than an
@@ -593,19 +784,149 @@ function baseStyleOf(parts) {
593
784
  // constant - StyleSheet.create, a module-level object - would then re-push an identity-equal half,
594
785
  // get skipped by setProp's Object.is guard, and never restore the declarative style the animation
595
786
  // overwrote. The re-push IS the restore path.
596
- // Would `pushClassStyle` republish an array byte-identical to the one already standing? Reads the
597
- // last published array back out of `node.props.style` rather than remembering it in a field: that
598
- // array IS the record of what was published, so there is nothing to keep in sync, and no shape
599
- // change to the node or to IClassStyleParts.
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.
797
+ const PUBLISHED_NOTHING = Object.freeze([]);
798
+ // ── ONE ARRAY PER DISTINCT PAIR, SHARED ACROSS NODES ─────────────────────────────────────────────
600
799
  //
601
- // Sound because `pushClassStyle` is the ONLY writer of an array into that slot — both routeProp
602
- // branches and setNodeHidden funnel through it — so a foreign array cannot be mistaken for ours,
603
- // and a node whose props are still empty holds `undefined`, which is not an array, so the first
604
- // write can never be swallowed.
605
- function isAlreadyPublished(node, parts) {
606
- const published = node.props.style;
607
- if (!Array.isArray(published))
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.
815
+ const sharedPairByExplicit = new WeakMap();
816
+ 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
+ */
824
+ function sharedStylePair(base, explicit) {
825
+ const baseIsKeyable = typeof base === 'object' && base !== null;
826
+ const explicitIsKeyable = typeof explicit === 'object' && explicit !== null;
827
+ if (base === undefined && explicitIsKeyable) {
828
+ const cached = sharedPairByExplicit.get(explicit);
829
+ if (cached !== undefined)
830
+ return cached;
831
+ const made = [base, explicit];
832
+ sharedPairByExplicit.set(explicit, made);
833
+ return made;
834
+ }
835
+ if (!baseIsKeyable)
836
+ return undefined;
837
+ if (explicit === undefined) {
838
+ const cached = sharedPairByBase.get(base);
839
+ if (Array.isArray(cached))
840
+ return cached;
841
+ if (cached === undefined) {
842
+ const made = [base, explicit];
843
+ sharedPairByBase.set(base, made);
844
+ return made;
845
+ }
846
+ // A base that has already been seen WITH an explicit half holds the second-level map here, and
847
+ // the base-only array has nowhere to live beside it. Rare enough not to earn a third map.
848
+ return undefined;
849
+ }
850
+ if (!explicitIsKeyable)
851
+ return undefined;
852
+ const existing = sharedPairByBase.get(base);
853
+ const byExplicit = existing instanceof WeakMap ? existing : new WeakMap();
854
+ if (existing === undefined)
855
+ sharedPairByBase.set(base, byExplicit);
856
+ // Same clash as above, the other way round: this base is holding its base-only array. Leave it.
857
+ if (Array.isArray(existing))
858
+ return undefined;
859
+ const cached = byExplicit.get(explicit);
860
+ if (cached !== undefined)
861
+ return cached;
862
+ const made = [base, explicit];
863
+ byExplicit.set(explicit, made);
864
+ return made;
865
+ }
866
+ // 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. */
871
+ function isStyleRecord(value) {
872
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
873
+ }
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
+ */
895
+ export function isSameShallowStyle(next, standing) {
896
+ if (!isStyleRecord(next) || !isStyleRecord(standing))
897
+ return false;
898
+ const keys = Object.keys(next);
899
+ if (keys.length !== Object.keys(standing).length)
900
+ return false;
901
+ for (const key of keys) {
902
+ const value = next[key];
903
+ if (value === undefined || isStyleRecord(value) || Array.isArray(value)) {
904
+ return false;
905
+ }
906
+ if (!Object.is(value, standing[key]))
907
+ return false;
908
+ }
909
+ return true;
910
+ }
911
+ function contributesNothing(slot) {
912
+ return slot === undefined || slot === EMPTY_STYLE;
913
+ }
914
+ // Does this node have a style at all? Read through the same two resolvers as the publication, for
915
+ // the reason the guard below states: guard and publication disagreeing is a silent wrong screen.
916
+ function hasNothingToPublish(parts) {
917
+ return (parts.hiddenStyle === undefined &&
918
+ contributesNothing(baseStyleOf(parts)) &&
919
+ contributesNothing(explicitStyleOf(parts)));
920
+ }
921
+ function isAlreadyPublished(parts) {
922
+ const published = parts.published;
923
+ if (published === undefined)
608
924
  return false;
925
+ // The delete is already standing. Asked before the slot comparisons because an empty array would
926
+ // otherwise pass both of them on `undefined` and then fail the length check, republishing a
927
+ // delete the host already performed.
928
+ if (published.length === 0)
929
+ return hasNothingToPublish(parts);
609
930
  // `baseStyleOf`, not `parts.classStyle` — the guard and the publication must read slot 0 the
610
931
  // same way or a press is turned away as already-published and silently does nothing on device
611
932
  // while the behavior fires correctly and nothing goes red.
@@ -624,31 +945,49 @@ function pushClassStyle(node, parts) {
624
945
  // an UNCHANGED class still lands as a write AND marks the node dirty. Costs React / Vue / Svelte
625
946
  // nothing — each diffs props before calling the engine — but Solid has no diff: a fine-grained
626
947
  // effect re-runs whenever any signal it reads changes, so a list-wide signal makes every row
627
- // re-push its own unchanged class. Measured on device 2026-08-23 (examples/solid, after
628
- // host-primitive lowering): selecting one row of 1 000 read WRITES 1001 and a 10.3 ms reconcile
948
+ // re-push its own unchanged class. Measured on device 2026-08-23 (examples/solid, once its
949
+ // primitives were tags): selecting one row of 1 000 read WRITES 1001 and a 10.3 ms reconcile
629
950
  // window against Fabric's unmoved 0/0/10 — a thousand-node dirty walk for two nodes of change.
630
- // Before lowering, the View component's splitProps/mergeProps memos had been absorbing it.
631
951
  //
632
- // This is NOT the naive skip the paragraph above forbids, and the array check is the difference.
633
- // Skipping on "the parts are unchanged" alone would break the restore path, because
634
- // setNativeProps writes node.props.style directly and a hoisted style constant would then never
635
- // be restored. But setNativeProps writes an OBJECT (commit.ts: `{...flattenStyle(prev),
636
- // ...flattenStyle(value)}`), never an array — so after any bypass isAlreadyPublished is false,
637
- // the re-push happens exactly as before, and the restore path is untouched.
952
+ // This is NOT the naive skip the paragraph above forbids, and the published marker is the
953
+ // difference. Skipping on "the parts are unchanged" alone would break the restore path, because
954
+ // setNativeProps writes the style slot past this function and a hoisted style constant would then
955
+ // never be restored. But setNativeProps CLEARS `parts.published` — so after any bypass
956
+ // isAlreadyPublished is false, the re-push happens exactly as before, and the restore path is
957
+ // untouched.
638
958
  //
639
959
  // Exact rather than approximate: resolveClassName memoizes a class STRING to the same object, so
640
960
  // an unchanged class yields an identity-equal classStyle. It deliberately does not fire for an
641
- // object/array class value, which resolves fresh every call — the same place setProp's Object.is
642
- // already gives up on a style object, so no new asymmetry appears.
643
- if (isAlreadyPublished(node, parts))
961
+ // object/array class value, which resolves fresh every call — the same place the host's own
962
+ // Object.is guard gives up on a style object, so no new asymmetry appears.
963
+ if (isAlreadyPublished(parts))
644
964
  return;
965
+ // NOTHING RESOLVED, so say nothing. The buffer spells an absent prop as `NO_VALUE` and the host
966
+ // then takes a path that costs it literally one branch — `if (props.get_ptr(key) == nullptr)
967
+ // break` — while `[undefined, undefined]` is a real value it must convert into a `folly::dynamic`
968
+ // array, store, and re-compare on every later commit. Both are behaviourally "no style": the
969
+ // payload builder flattens the pair of undefineds into no keys at all.
970
+ //
971
+ // This is NOT the naive skip the note above forbids, and the distinction is the same one the
972
+ // published marker makes. A restore after `setNativeProps` arrives here with `published` cleared
973
+ // to `undefined`, so it is never turned away — and when the authored style is nothing, restoring
974
+ // it means DELETING the slot the imperative write put there, which is what this emits.
975
+ if (hasNothingToPublish(parts)) {
976
+ parts.published = PUBLISHED_NOTHING;
977
+ setProp(node, 'style', undefined);
978
+ return;
979
+ }
645
980
  // The third slot is APPENDED ONLY WHILE HIDDEN. Writing a permanent three-element array would
646
981
  // change the style payload of every node in every app for a state almost none of them are ever
647
982
  // in — and this project spent a day removing per-frame allocations, so a slot that is undefined
648
983
  // 99.9% of the time does not get to ride along on every style write.
649
- setProp(node, 'style', parts.hiddenStyle === undefined
650
- ? [baseStyleOf(parts), explicitStyleOf(parts)]
651
- : [baseStyleOf(parts), explicitStyleOf(parts), parts.hiddenStyle]);
984
+ const base = baseStyleOf(parts);
985
+ const explicit = explicitStyleOf(parts);
986
+ const published = parts.hiddenStyle === undefined
987
+ ? (sharedStylePair(base, explicit) ?? [base, explicit])
988
+ : [base, explicit, parts.hiddenStyle];
989
+ parts.published = published;
990
+ setProp(node, 'style', published);
652
991
  }
653
992
  // `display: 'none'` is a real RN style value (Yoga's DisplayNone), so a hidden node keeps its
654
993
  // place in the tree, its state and its children — it just stops laying out and painting.
@@ -683,6 +1022,35 @@ export function setNodePressed(node, pressed) {
683
1022
  parts.isPressed = pressed;
684
1023
  pushClassStyle(node, parts);
685
1024
  }
1025
+ /**
1026
+ * Tell the host a behavior's FEEDBACK is showing — TouchableHighlight's underlay, and only that.
1027
+ *
1028
+ * The sibling of `setNodePressed` and deliberately NOT the same bit. Press state drives `:active`
1029
+ * class resolution, which happens in JS because a class name resolves against a JS registry; this
1030
+ * drives a rule that lives in C++ (`foldTouchableHighlightUnderlay`), so it crosses as one op rather
1031
+ * than resolving to a style here. And the two are genuinely different facts: RN holds the underlay
1032
+ * past release so a fast tap still flashes, so `shown` LAGS `pressed` by a `delayPressOut` timer.
1033
+ *
1034
+ * No style is computed on this side at all, which is the whole point — the two props the rule reads
1035
+ * (`underlayColor`, `activeOpacity`) are ones the engine already strips from the payload, so the
1036
+ * values and their defaults live in one place instead of being erased in C++ and reached around for
1037
+ * in JS.
1038
+ */
1039
+ export function setNodeUnderlayShown(node, shown) {
1040
+ recordSetUnderlayShown(node, shown);
1041
+ }
1042
+ /**
1043
+ * Forget what was last published, so the next `pushClassStyle` cannot be turned away.
1044
+ *
1045
+ * The one caller is `setNativeProps` (imperative.ts), which writes the style slot past this file —
1046
+ * see the note on `IClassStyleParts.published` for why the restore path depends on this. A no-op for
1047
+ * a node nobody has styled, which is why it is not `stylePartsOf(node).published = undefined`: that
1048
+ * would allocate the parts on a node that has none.
1049
+ */
1050
+ export function clearPublishedStyle(node) {
1051
+ if (node.styleParts !== undefined)
1052
+ node.styleParts.published = undefined;
1053
+ }
686
1054
  // The explicit (non-class-derived) style half, for an adapter that builds its style prop up
687
1055
  // key-by-key (Angular's Ivy ɵɵstyleProp/setStyle) instead of handing over one whole object —
688
1056
  // it must merge onto this, not onto node.props.style directly, which may be the
@@ -690,11 +1058,76 @@ export function setNodePressed(node, pressed) {
690
1058
  export function getExplicitStyle(node) {
691
1059
  return node.styleParts?.explicitStyle;
692
1060
  }
1061
+ /**
1062
+ * The `[classStyle, explicitStyle]` pair the node currently PUBLISHES — the same value
1063
+ * `commitClassStyle` writes, in the same order, so `flattenStyle` collapses it the way Fabric will.
1064
+ *
1065
+ * For a caller that wants the merged answer without a host: the pair reaches the payload as an op,
1066
+ * and only a host holds ops. A test that has not installed one — `core/css-parser` reaches into the
1067
+ * engine by relative path and depends on neither package — can read it here instead of reaching
1068
+ * into `styleParts`, which is engine-owned and not a shape anything outside may bind to.
1069
+ */
1070
+ export function getPublishedStyle(node) {
1071
+ const parts = node.styleParts;
1072
+ if (parts === undefined)
1073
+ return [];
1074
+ return [parts.classStyle, parts.explicitStyle];
1075
+ }
693
1076
  const CLASS_PROP_KEYS = new Set(['class', 'className']);
694
1077
  // The flat-bag split (React / Vue / Solid): an `onX` prop becomes an event listener
695
1078
  // ONLY when the node's component actually declares `x` as an event (per the shared
696
1079
  // ViewConfig). Otherwise it is a plain prop, so `onTintColor` on a Switch, whose
697
1080
  // only event is `change`, routes to setProp and reaches Fabric.
1081
+ // `id` is RN's W3C-named alias for `nativeID` and it WINS when both are set
1082
+ // (`View.js:77-79` — `processedProps.nativeID = id`). A raw `id` is declared by no ViewConfig, so
1083
+ // Fabric drops it in SILENCE: a rename that half-works loses the nativeID with nothing red anywhere.
1084
+ const ID_ALIAS_FROM = 'id';
1085
+ const ID_ALIAS_TO = 'nativeID';
1086
+ /**
1087
+ * The ONE place this rename happens, as of 2026-09-18. It used to happen in SEVEN.
1088
+ *
1089
+ * `foldHostBag` did it for React, Svelte and Angular off `HOST_PRIMITIVES[*].aliases` (seventeen
1090
+ * identical entries); Vue's `patchProp`, Solid's renderer and Angular's own `PROP_ALIASES` each did
1091
+ * it again per key; and `foldIdAlias` did it a seventh time in C++. Three different coverage sets,
1092
+ * so the answer depended on which adapter you were on and whether the node's tag happened to carry a
1093
+ * registered behavior — `core/engine/cpp/tests/js/id-alias-coverage.itest.ts` measured that split
1094
+ * before this collapsed it.
1095
+ *
1096
+ * HERE because this is the funnel: every adapter's prop write ends at `routeProp`, whatever shape it
1097
+ * starts in. A bag fold cannot serve the per-key renderers and a per-key fold cannot serve the bag
1098
+ * ones; the seam they SHARE can serve both.
1099
+ *
1100
+ * PRECEDENCE IS WHY THIS NEEDS STATE. Upstream reads both props at once, so `id ?? nativeID` is
1101
+ * decided in one expression. A per-key writer never sees both, so precedence would otherwise fall
1102
+ * out of write ORDER — `<view id nativeID>` keeping the stale legacy value while `<view nativeID id>`
1103
+ * did not. Solid had already built exactly this memory for exactly this reason; it is one copy now.
1104
+ *
1105
+ * The authored `nativeID` is REMEMBERED rather than discarded, so clearing the `id` hands the slot
1106
+ * back instead of latching. A framework that unsets a prop between renders must get the other
1107
+ * source back.
1108
+ *
1109
+ * PRECEDENCE ITSELF IS PER-COMPONENT. `View.js`'s `id ?? nativeID` is the default (`idWins` below),
1110
+ * but `TouchableWithoutFeedback.js`'s clone composes that and then runs a `PASSTHROUGH_PROPS` loop
1111
+ * that unconditionally overwrites `nativeID` with the raw authored value when set (`:279-282`) — an
1112
+ * app authoring both ends up with `nativeID` winning there. `node.nativeIdWinsOverId`, set by
1113
+ * `attachHostBehavior` for the one behavior that declares it, flips which side wins.
1114
+ */
1115
+ const idAliased = new WeakMap();
1116
+ function routeIdAlias(node, key, value) {
1117
+ const state = idAliased.get(node) ?? {
1118
+ idValue: undefined,
1119
+ nativeIdValue: undefined,
1120
+ };
1121
+ if (key === ID_ALIAS_FROM)
1122
+ state.idValue = value;
1123
+ else
1124
+ state.nativeIdValue = value;
1125
+ idAliased.set(node, state);
1126
+ const published = node.nativeIdWinsOverId
1127
+ ? (state.nativeIdValue ?? state.idValue)
1128
+ : (state.idValue ?? state.nativeIdValue);
1129
+ setProp(node, ID_ALIAS_TO, published);
1130
+ }
698
1131
  export function routeProp(node, key, value) {
699
1132
  if (REACT_JSX_DEV_PROPS.has(key))
700
1133
  return;
@@ -720,6 +1153,16 @@ export function routeProp(node, key, value) {
720
1153
  return;
721
1154
  }
722
1155
  }
1156
+ // AFTER the slot redirect, and that order is load-bearing rather than tidy. A composed primitive
1157
+ // forwards most of its bag to an internal node — ImageBackground spreads everything but `style`
1158
+ // onto its image, exactly as RN does — so an `id` written on the OWNER belongs to the node the
1159
+ // redirect sends it to. Resolved before the redirect, the alias would publish a `nativeID` on the
1160
+ // wrapper and the inner node would never see it: the owner would answer to a testID the app
1161
+ // pointed at the image.
1162
+ if (key === ID_ALIAS_FROM || key === ID_ALIAS_TO) {
1163
+ routeIdAlias(node, key, value);
1164
+ return;
1165
+ }
723
1166
  // An AnimatedNode written straight into a prop — `<view style={{opacity: value}}/>` — is
724
1167
  // resolved here into the value to PUBLISH, with the engine holding the subscription. Same
725
1168
  // shape as the `style` callback below: a value the engine interprets rather than forwards.
@@ -745,11 +1188,9 @@ export function routeProp(node, key, value) {
745
1188
  if (key === 'style') {
746
1189
  const parts = stylePartsOf(node);
747
1190
  // A FUNCTION `style` is `style={({pressed}) => …}`, the idiom this ecosystem actually writes.
748
- // A lowering transform normally splits it at build time into `style` + `activeStyle`, so the
749
- // engine never sees the callback — but a PUBLIC primitive tag has no transform in front of it
750
- // on three adapters, and there the callback arrives here intact. Resolving it makes the
751
- // compile-time split an OPTIMIZATION rather than the mechanism, the same relationship
752
- // `foldHostBag` has with the compile-time prop folds.
1191
+ // Nothing stands between an app and the tag, so the callback arrives here intact and is
1192
+ // resolved at both states — writing `style` + `activeStyle` as an explicit pair is the same
1193
+ // thing said by hand, and cheaper by one call per recompute.
753
1194
  //
754
1195
  // Without this the failure is silent and total: a function is not an `on*` name, so it misses
755
1196
  // `setEventListener`, lands in `setProp` as a function value, and `fabricProps` drops function
@@ -763,11 +1204,25 @@ export function routeProp(node, key, value) {
763
1204
  parts.activeStyleFromCallback = true;
764
1205
  }
765
1206
  else {
1207
+ // A REBUILT LITERAL EQUAL TO WHAT IS STANDING IS NOT A CHANGE — see `isSameShallowStyle`.
1208
+ //
1209
+ // Gated on something being PUBLISHED, which is what keeps the restore path intact: a
1210
+ // `setNativeProps` write bypasses the parts and clears `parts.published`, and after that this
1211
+ // must never turn a write away — the re-push IS the restore. Same mechanism `isAlreadyPublished`
1212
+ // relies on, and the same reason.
1213
+ //
1214
+ // Gated on the previous write NOT having come from a callback, because that one owns
1215
+ // `parts.activeStyle` and the branch below has to clear it. Returning early would leave the old
1216
+ // pressed look standing under a plain style.
1217
+ if (parts.published !== undefined &&
1218
+ !parts.activeStyleFromCallback &&
1219
+ isSameShallowStyle(resolved, parts.explicitStyle)) {
1220
+ return;
1221
+ }
766
1222
  parts.explicitStyle = resolved;
767
1223
  // Only a variant WE derived is stale now. `style` switching from a callback to a plain value
768
- // must not leave the old pressed look standing, and an `activeStyle` the transform wrote must
769
- // survive a `style` write, because the two arrive as independent props in an unspecified
770
- // order.
1224
+ // must not leave the old pressed look standing, and an AUTHORED `activeStyle` must survive a
1225
+ // `style` write, because the two arrive as independent props in an unspecified order.
771
1226
  if (parts.activeStyleFromCallback) {
772
1227
  parts.activeStyle = undefined;
773
1228
  parts.activeStyleFromCallback = false;
@@ -784,14 +1239,37 @@ export function routeProp(node, key, value) {
784
1239
  // Slot 1 is no longer ours, by definition — whatever a callback derived earlier has just been
785
1240
  // replaced. Without this the flag outlives the value it describes: a callback sets it, this
786
1241
  // branch overwrites the slot silently, and a later plain `style` then clears a variant the
787
- // engine never derived. Not reachable from a lowering transform (it emits either a callback or
788
- // an explicit pair, never both for one node), but a flat-bag adapter routes a bag key by key
789
- // and can deliver exactly that sequence.
1242
+ // engine never derived. An author writes either a callback or an explicit pair, never both for
1243
+ // one node — but a flat-bag adapter routes a bag key by key and can deliver that sequence.
790
1244
  parts.activeStyleFromCallback = false;
791
1245
  pushClassStyle(node, parts);
792
1246
  return;
793
1247
  }
794
- if (ON_PREFIX.test(key)) {
1248
+ // RN's snapshot affordance (`Pressable.js:222` seeds `usePressState` with it): render the control
1249
+ // pressed with no gesture. It selects `activeStyle` and any `:active` class, which is exactly what
1250
+ // `isPressed` already decides — so it belongs beside `activeStyle` rather than in a behavior.
1251
+ //
1252
+ // HERE RATHER THAN IN `attachAfterCommit`, and the census caught the difference. A behavior hook
1253
+ // reading this prop costs a post-commit WAITER on every pressable in the app — a real boundary
1254
+ // crossing per node, which `adapters/solid/src/crossing-and-payload-census.probe.test.tsx` budgets
1255
+ // at two and which went to six. This branch crosses nothing: it is one string compare on a write
1256
+ // that already reached the tail of `routeProp`, and it lands on the FIRST commit rather than the
1257
+ // second. A JS compare is not a crossing, and weighing it as one is what sent the first attempt to
1258
+ // the wrong seam.
1259
+ //
1260
+ // TouchableHighlight's half of the same prop is NOT here: it PAINTS an underlay, so it is a rule
1261
+ // in `SymbioteFabricProps.cpp`. Two mechanisms, one prop name, because that is what upstream has.
1262
+ // A SIDE EFFECT AND A PASSTHROUGH, not a consume, and the difference is load-bearing in both
1263
+ // directions. The pressed state is set here; the prop ALSO goes on to `node.props`, because
1264
+ // TouchableHighlight's rule reads it off the authored bag to paint its underlay
1265
+ // (`foldTouchableHighlightUnderlay`). Returning early — the first spelling — left that rule blind
1266
+ // and turned three of its cases red. Keeping it out of the PAYLOAD is a separate job and already
1267
+ // done, by `kPressableMachineKeys` in `SymbioteFabricProps.cpp`.
1268
+ //
1269
+ // The same shape `GATED_EVENT_PROPS` uses above: act, then let the write continue.
1270
+ if (key === 'testOnly_pressed')
1271
+ setNodePressed(node, resolved === true);
1272
+ if (isOnEventName(key)) {
795
1273
  // A native-driven `Animated.event` needs the native module as well as the listener map, and
796
1274
  // registers under the PROP name — see `bindAnimatedEvent`, which no-ops for anything else.
797
1275
  if (hasAnimatedNodes())
@@ -814,53 +1292,35 @@ export function routeProp(node, key, value) {
814
1292
  }
815
1293
  setProp(node, key, resolved);
816
1294
  }
817
- // The same no-op guard as setProp, and here it is strictly stronger: `text` is a string, so
818
- // `Object.is` is a real value comparison rather than the reference check it degrades to for a style
819
- // object or a handler. A framework that re-renders a subtree and hands back an unchanged label -
820
- // every list row whose text did not move, on every update - stops stripping its ancestors of the
821
- // commit walk's early exit. Counted in the same propStats, because a text write IS a prop write:
822
- // it lands in node.props.text and reaches Fabric as RCTRawText's only prop.
1295
+ // Counted in the same propStats, because a text write IS a prop write: it reaches Fabric as
1296
+ // RCTRawText's only prop.
823
1297
  //
824
- // Safe against the one ordering hazard worth naming: a raw-text node REPARENTED under a <Text>
825
- // commits as RCTVirtualText instead of RCTText (viewNameFor, commit.ts). That flip is not driven
826
- // from here - a structural op marks the parent chain, and reconcile re-checks `committed.parent` on
827
- // its early-exit path - so a same-text write not marking cannot hide it.
1298
+ // Unguarded, unlike the old version, and for the same reason `setProp` is: comparing against the
1299
+ // standing text would mean reading it back from the host. The host holds it as a local field and
1300
+ // dedupes there. Two consequences it also absorbs, both of which used to be spelled here — a write
1301
+ // to or from `''` takes this node out of its parent's renderable child list or puts it back, and a
1302
+ // raw text REPARENTED under a `<Text>` commits as RCTVirtualText instead of RCTText.
828
1303
  export function setText(node, text) {
829
- if (Object.is(node.props.text, text)) {
830
- propStats.noops += 1;
831
- return;
832
- }
833
- node.props.text = text;
834
1304
  propStats.writes += 1;
835
- markPropsDirty(node);
1305
+ recordSetText(node, text);
836
1306
  }
837
- // Structural ops mark the PARENT chain (both the old and the new one), never the moved child:
838
- // a child that only changed position may legitimately still be clean, and reconcile re-checks
839
- // `committed.parent` on its early-exit path, so a reparent is caught there rather than by a flag.
1307
+ // The structural ops. Each is one op and nothing else: the host detaches the child from whatever
1308
+ // parent it currently has before linking it, which is the truth even when an adapter names a stale
1309
+ // one — frameworks spell a MOVE as remove-then-insert and can arrive after the insert already
1310
+ // re-parented the node. That is why there is no `detach` here any more; JS does not know the old
1311
+ // parent and does not need to.
840
1312
  //
841
- // Each marks BEFORE touching `parent.children`, never after: the committed record may be aliasing
842
- // that array, and markStructureDirty is what copies it out of the way. See there.
843
- function detach(child) {
844
- const parent = child.parent;
845
- if (!parent)
846
- return;
847
- markStructureDirty(parent);
848
- const index = parent.children.indexOf(child);
849
- if (index >= 0)
850
- parent.children.splice(index, 1);
851
- child.parent = undefined;
1313
+ // What they DO still decide in JS is which node an op names, and there are two such redirects — a
1314
+ // composed primitive's slot and a wrap claim. Both are read off a field on the node, so a tree with
1315
+ // neither pays one load and one branch per op.
1316
+ // The host's raw answer, surface INCLUDED — unlike `parentOf` (host-access.ts), which reports a
1317
+ // top-level node as parentless by design. The two swaps below have to NAME the holder in an op, and
1318
+ // for a wrapped node sitting directly under a surface that holder is the surface node.
1319
+ function holderOf(node) {
1320
+ flushOps();
1321
+ const parent = treeHost()?.parentOf(node);
1322
+ return isSymbioteNode(parent) ? parent : undefined;
852
1323
  }
853
- // The one place a composed primitive's slot is honoured. See `ISymbioteNode.childHost`: the adapter
854
- // always names the OWNER, and a node whose behavior built an internal subtree redirects the app's
855
- // children into it.
856
- //
857
- // SINGLE HOP, not a loop, and the field's own comment says why — a chain would put a walk on the
858
- // engine's hottest path to express a depth no primitive has. A behavior needing depth points
859
- // `childHost` at the innermost node itself.
860
- //
861
- // Reads a field that is `undefined` on every node in every app that registers no composed
862
- // primitive, so the cost is one load and one branch — deliberately NOT behind `hasHostBehaviors()`,
863
- // which would be a second read to save nothing.
864
1324
  // Which node a child actually lands on. See `ISymbioteNode.childHost`: the adapter always names the
865
1325
  // OWNER, and a node whose behavior built an internal subtree redirects the app's children into it —
866
1326
  // unless the behavior CLAIMS this particular child, which keeps it on the owner (`claimedChildren`).
@@ -883,6 +1343,52 @@ function hostFor(parent, child) {
883
1343
  return parent;
884
1344
  return claimModeFor(parent, child.component) === undefined ? slot : parent;
885
1345
  }
1346
+ // The node a child must be inserted BEFORE, or `undefined` for an ordinary append.
1347
+ //
1348
+ // A host that STILL has a slot at this point is an owner taking a CLAIMED child, and that child
1349
+ // goes before the slot whatever the framework asked for. RN renders `{refreshControl}{content}` in
1350
+ // that order, and the node a framework names as `beforeChild` lives inside the slot, so the host
1351
+ // could not place against it here anyway.
1352
+ //
1353
+ // A SIBLING slot is the opposite placement: RN paints the background image first and the app's
1354
+ // children over it (ImageBackground.js:80-102), so they append past it rather than in front of it —
1355
+ // which is what `undefined` here leaves alone.
1356
+ function slotAnchorOf(host) {
1357
+ const slot = host.childHost;
1358
+ if (slot === undefined || !slotTakesChildren(host))
1359
+ return undefined;
1360
+ return slot;
1361
+ }
1362
+ // ── the two structural recorders, and why nothing here calls the raw ones ───────────────────────
1363
+ //
1364
+ // `mayHaveChildren` is only sound if EVERY op that gives a node a child raises it. There are five
1365
+ // such call sites in this file and a sixth is a plausible future edit, so the bit is raised here
1366
+ // rather than at each of them: a site that forgets would make `childrenOf` answer "empty" for a node
1367
+ // that has children, which is a wrong ANSWER rather than a slow one. `node.ts` is the only module
1368
+ // that records a structural op, so these two are a complete funnel.
1369
+ /**
1370
+ * Arm a parent's recurring post-commit hook for a STRUCTURAL change.
1371
+ *
1372
+ * A prop write is not the only thing a behavior can be waiting for, and the ScrollView sticky-header
1373
+ * machine is the case that proves it: it drops a wrapper when the framework takes the wrapped child
1374
+ * away, which writes no prop on the wrapper's owner at all. Narrowing the beat to prop writes alone
1375
+ * left it holding a wrapper around nothing, and its own test said so — the third behavior needing
1376
+ * the beat, and the only one whose source comment does not say why.
1377
+ */
1378
+ function armCommitHookForChildChange(parent) {
1379
+ if (parent.hasCommitHook)
1380
+ noteCommitHookNodeChanged(parent);
1381
+ }
1382
+ function recordAppendInto(parent, child) {
1383
+ parent.mayHaveChildren = true;
1384
+ armCommitHookForChildChange(parent);
1385
+ recordAppendChild(parent, child);
1386
+ }
1387
+ function recordInsertInto(parent, child, beforeChild) {
1388
+ parent.mayHaveChildren = true;
1389
+ armCommitHookForChildChange(parent);
1390
+ recordInsertBefore(parent, child, beforeChild);
1391
+ }
886
1392
  // What actually occupies this node's place in its parent's child list. See `ISymbioteNode.wrapper`:
887
1393
  // a wrapped owner is what the adapter names and the wrapper is what the tree holds, so every
888
1394
  // structural op takes the owner and moves the wrapper.
@@ -893,9 +1399,9 @@ function placedNode(node) {
893
1399
  // two inserts fall through to the ordinary path on one call.
894
1400
  //
895
1401
  // The owner being UNATTACHED is the normal case rather than the edge one: every adapter fills a
896
- // node's children before appending it to its own parent, so the wrap usually happens while
897
- // `owner.parent` is undefined and the swap below is skipped. The later `appendChild(root, owner)`
898
- // then inserts the wrapper instead, because `placedNode` says so.
1402
+ // node's children before appending it to its own parent, so the wrap usually happens while the
1403
+ // owner has no holder and only the second op runs. The later `appendChild(root, owner)` then
1404
+ // inserts the wrapper instead, because `placedNode` says so.
899
1405
  function wrapsOwner(owner, child) {
900
1406
  if (owner.childHost === undefined)
901
1407
  return false;
@@ -905,18 +1411,13 @@ function wrapsOwner(owner, child) {
905
1411
  reattachHostBehaviors(child);
906
1412
  if (hasAnimatedBindings())
907
1413
  reattachAnimatedProps(child);
908
- detach(child);
909
- const outerParent = owner.parent;
910
- if (outerParent !== undefined) {
911
- markStructureDirty(outerParent);
912
- outerParent.children[outerParent.children.indexOf(owner)] = child;
913
- child.parent = outerParent;
914
- }
1414
+ const holder = holderOf(owner);
1415
+ // Wrapper takes the owner's place first, then the owner moves under it — the host's own detach
1416
+ // on link is what unlinks the owner from `holder`, so no removal op is needed.
1417
+ if (holder !== undefined)
1418
+ recordInsertInto(holder, child, owner);
915
1419
  owner.wrapper = child;
916
- owner.parent = child;
917
- markStructureDirty(child);
918
- child.children.push(owner);
919
- notifyWrapChange(owner, child);
1420
+ recordAppendInto(child, owner);
920
1421
  return true;
921
1422
  }
922
1423
  // Put the owner back where its wrapper stood — the mirror of `wrapsOwner`. It must leave the owner
@@ -924,37 +1425,19 @@ function wrapsOwner(owner, child) {
924
1425
  function unwrapsOwner(owner, child) {
925
1426
  if (owner.wrapper !== child)
926
1427
  return false;
927
- const outerParent = child.parent;
928
1428
  owner.wrapper = undefined;
929
- if (outerParent !== undefined) {
930
- markStructureDirty(outerParent);
931
- outerParent.children[outerParent.children.indexOf(child)] = owner;
1429
+ const holder = holderOf(child);
1430
+ if (holder === undefined) {
1431
+ // The wrapper never reached a parent, so there is no place to take back — the owner simply
1432
+ // stops hanging off it.
1433
+ recordRemoveChild(child, owner);
1434
+ }
1435
+ else {
1436
+ recordInsertInto(holder, owner, child);
1437
+ recordRemoveChild(holder, child);
932
1438
  }
933
- owner.parent = outerParent;
934
- child.parent = undefined;
935
- child.children.length = 0;
936
- notifyWrapChange(owner, undefined);
937
1439
  return true;
938
1440
  }
939
- // Where the child goes in its host's list.
940
- //
941
- // A host that STILL has a slot at this point is an owner taking a CLAIMED child, and that child
942
- // goes before the slot whatever the framework asked for. RN renders `{refreshControl}{content}` in
943
- // that order, and the node a framework names as `beforeChild` lives inside the slot, so `indexOf`
944
- // could not find it here anyway.
945
- function indexFor(host, beforeChild) {
946
- const slot = host.childHost;
947
- // A sibling slot is the OPPOSITE placement: RN paints the background image first and the app's
948
- // children over it (ImageBackground.js:80-102), so they append past it rather than in front of
949
- // it. Falls through to the ordinary index below, which is what leaves their relative order alone.
950
- if (slot !== undefined && slotTakesChildren(host))
951
- return host.children.indexOf(slot);
952
- // `null` is Solid's spelling of "append"; `undefined` is `appendChild`'s own. Both end up here.
953
- if (beforeChild === undefined || beforeChild === null)
954
- return host.children.length;
955
- const index = host.children.indexOf(beforeChild);
956
- return index < 0 ? host.children.length : index;
957
- }
958
1441
  export function appendChild(requestedParent, child) {
959
1442
  if (wrapsOwner(requestedParent, child))
960
1443
  return;
@@ -966,22 +1449,19 @@ export function appendChild(requestedParent, child) {
966
1449
  if (hasAnimatedBindings())
967
1450
  reattachAnimatedProps(child);
968
1451
  const placed = placedNode(child);
969
- detach(placed);
970
- markStructureDirty(parent);
971
- placed.parent = parent;
972
- if (parent.childHost !== undefined) {
973
- parent.children.splice(indexFor(parent, undefined), 0, placed);
974
- }
975
- else {
976
- parent.children.push(placed);
977
- }
1452
+ const anchor = slotAnchorOf(parent);
1453
+ if (anchor === undefined)
1454
+ recordAppendInto(parent, placed);
1455
+ else
1456
+ recordInsertInto(parent, placed, anchor);
978
1457
  if (hasHostBehaviors())
979
1458
  notifyChildInserted(parent, placed);
980
1459
  }
981
- // `beforeChild` is genuinely nullable and the signature used to say otherwise: Solid's renderer
982
- // spells "append" as `insertBefore(parent, child, null)`, which worked by accident because
983
- // `indexOf(null)` is -1 and the old fallback appended. Reading a field off it is what made the lie
984
- // fatal, so the type now says what the callers do.
1460
+ // NO ANCHOR MEANS APPEND, and it is a real call rather than a defensive guard: solid-js/universal
1461
+ // spells "insert at the end" as `insertNode(parent, node, null)` and Vue's runtime-core passes
1462
+ // `anchor` straight through as `null`. The old retained tree collapsed it silently — `indexOf(null)`
1463
+ // is -1, and the insert fell through to a push. On the wire it cannot: a slot has to name a node, so
1464
+ // an unanchored insert IS an append and is recorded as one.
985
1465
  export function insertBefore(requestedParent, child, beforeChild) {
986
1466
  if (wrapsOwner(requestedParent, child))
987
1467
  return;
@@ -991,10 +1471,14 @@ export function insertBefore(requestedParent, child, beforeChild) {
991
1471
  if (hasAnimatedBindings())
992
1472
  reattachAnimatedProps(child);
993
1473
  const placed = placedNode(child);
994
- detach(placed);
995
- markStructureDirty(parent);
996
- placed.parent = parent;
997
- parent.children.splice(indexFor(parent, beforeChild === null ? null : placedNode(beforeChild)), 0, placed);
1474
+ const anchor = slotAnchorOf(parent) ??
1475
+ (beforeChild === null || beforeChild === undefined
1476
+ ? undefined
1477
+ : placedNode(beforeChild));
1478
+ if (anchor === undefined)
1479
+ recordAppendInto(parent, placed);
1480
+ else
1481
+ recordInsertInto(parent, placed, anchor);
998
1482
  if (hasHostBehaviors())
999
1483
  notifyChildInserted(parent, placed);
1000
1484
  }
@@ -1002,62 +1486,47 @@ export function insertBefore(requestedParent, child, beforeChild) {
1002
1486
  // a move as remove-then-reinsert (Solid does), so tearing down here kills the machine of a node
1003
1487
  // that comes back alive in the same batch — see host-behavior.ts's markDetachCandidate.
1004
1488
  export function removeChild(requestedParent, child) {
1005
- // Redirected for the same reason the two inserts are: the adapter removes from the node it
1006
- // appended to, which is the OWNER, while the child actually lives in the slot. Without this the
1007
- // `indexOf` misses, the splice no-ops, and the child stays committed under the slot forever
1008
- // while the framework believes it is gone — a leak with nothing red anywhere.
1009
- //
1010
1489
  // A wrap claim leaving: the owner takes its own place back and stays in the tree. Nominated for
1011
1490
  // teardown like any other removed node, because the wrapper IS leaving.
1012
1491
  if (unwrapsOwner(requestedParent, child)) {
1013
- if (hasHostBehaviors() || hasAnimatedBindings())
1492
+ if (hasAttachedBehaviors() || hasAnimatedBindings())
1014
1493
  markDetachCandidate(child);
1015
1494
  return;
1016
1495
  }
1017
1496
  // A slot that IS the child being removed stops being one. Only a behavior that adopts an APP
1018
1497
  // child as its slot can reach this (`onChildInserted`); a `buildStructure` slot is internal and
1019
1498
  // no framework removes it. Without the clear, `hostFor` below redirects the removal INTO the very
1020
- // node being removed, `indexOf` misses, the splice no-ops, and the child stays committed under a
1021
- // parent the framework believes it left — and the NEXT child appended nests inside the orphan.
1499
+ // node being removed, and the child stays committed under a parent the framework believes it
1500
+ // left — and the NEXT child appended nests inside the orphan.
1022
1501
  if (requestedParent.childHost === child)
1023
1502
  requestedParent.childHost = undefined;
1503
+ // Redirected for the same reason the two inserts are: the adapter removes from the node it
1504
+ // appended to, which is the OWNER, while the child actually lives in the slot.
1024
1505
  const parent = hostFor(requestedParent, child);
1025
- if (hasHostBehaviors() || hasAnimatedBindings())
1506
+ // `hasAttachedBehaviors`, NOT `hasHostBehaviors`: the second is on from module load in every app,
1507
+ // because registering `Pressable` as a TYPE arms it. Nominating a candidate makes the commit sweep
1508
+ // cross every removed node into JS — 10 000 handles on a 1 000-row clear, measured at 3.2x the
1509
+ // whole teardown — and none of it can matter before a behavior has actually attached to something.
1510
+ if (hasAttachedBehaviors() || hasAnimatedBindings())
1026
1511
  markDetachCandidate(child);
1027
- markStructureDirty(parent);
1028
- const placed = placedNode(child);
1029
- const index = parent.children.indexOf(placed);
1030
- if (index >= 0)
1031
- parent.children.splice(index, 1);
1032
- child.parent = undefined;
1512
+ // BOTH, and the owner is the one that matters: a composed primitive's behavior lives on the node
1513
+ // the adapter named, while `hostFor` redirects the mutation into its internal slot. Arming only
1514
+ // the slot arms a node that has no behavior at all.
1515
+ armCommitHookForChildChange(requestedParent);
1516
+ armCommitHookForChildChange(parent);
1517
+ recordRemoveChild(parent, placedNode(child));
1033
1518
  }
1519
+ /**
1520
+ * A structural census of the tree the HOST holds — see `ITreeCensus` (tree-host.ts) for what each
1521
+ * number is for and why the anchor count says more about the adapter than about the app.
1522
+ *
1523
+ * It walks nothing here: the walk needs `props.text` to tell an empty raw text from a real one, and
1524
+ * a child list to measure a flatten width, and JS has neither. `undefined` from `treeHost()` means
1525
+ * nothing is installed, and the empty census is the honest answer — every probe that reads this
1526
+ * asserts against a mounted tree, so a zero from an uninstalled host cannot be mistaken for one from
1527
+ * an empty one.
1528
+ */
1034
1529
  export function censusRetainedTree(roots) {
1035
- const census = {
1036
- nodes: 0,
1037
- anchors: 0,
1038
- emptyRawTexts: 0,
1039
- renderable: 0,
1040
- flattenWidths: [],
1041
- };
1042
- // Explicit stack, not recursion: a deep list under a benchmark screen would blow the JS stack
1043
- // on the very tree this is meant to measure.
1044
- const stack = [...roots];
1045
- while (stack.length > 0) {
1046
- const node = stack.pop();
1047
- if (node === undefined)
1048
- break;
1049
- census.nodes += 1;
1050
- if (isAnchor(node))
1051
- census.anchors += 1;
1052
- else if (isEmptyRawText(node))
1053
- census.emptyRawTexts += 1;
1054
- else
1055
- census.renderable += 1;
1056
- if (node.children.some(child => isAnchor(child) || isEmptyRawText(child)))
1057
- census.flattenWidths.push(node.children.length);
1058
- for (const child of node.children)
1059
- stack.push(child);
1060
- }
1061
- census.flattenWidths.sort((left, right) => right - left);
1062
- return census;
1530
+ flushOps();
1531
+ return treeHost()?.census(roots) ?? EMPTY_CENSUS;
1063
1532
  }