@symbiote-native/engine 0.4.0 → 1.0.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 (96) 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 -7
  10. package/build/accessibility-props.js +22 -21
  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/graph.d.ts +2 -0
  17. package/build/animated/graph.js +14 -0
  18. package/build/animated/host-binding.d.ts +39 -0
  19. package/build/animated/host-binding.js +278 -0
  20. package/build/animated/index.d.ts +1 -1
  21. package/build/animated/leaf-lifecycle.js +10 -22
  22. package/build/animated/mock.d.ts +1 -19
  23. package/build/animated/props.js +1 -1
  24. package/build/animated/rgba.js +16 -50
  25. package/build/events/index.js +123 -33
  26. package/build/fabric-props.d.ts +1 -1
  27. package/build/fabric-props.js +129 -182
  28. package/build/fabric.d.ts +10 -0
  29. package/build/fabric.js +40 -0
  30. package/build/host-access.d.ts +125 -0
  31. package/build/host-access.js +280 -0
  32. package/build/host-behavior.d.ts +107 -7
  33. package/build/host-behavior.js +309 -31
  34. package/build/image-source-write.d.ts +16 -0
  35. package/build/image-source-write.js +65 -0
  36. package/build/imperative.d.ts +49 -0
  37. package/build/imperative.js +258 -0
  38. package/build/index.d.ts +18 -10
  39. package/build/index.js +65 -10
  40. package/build/mutation-buffer.d.ts +222 -0
  41. package/build/mutation-buffer.js +491 -0
  42. package/build/native-engine.d.ts +182 -0
  43. package/build/native-engine.js +178 -0
  44. package/build/native-tree-host.d.ts +25 -0
  45. package/build/native-tree-host.js +66 -0
  46. package/build/node.d.ts +191 -60
  47. package/build/node.js +1034 -327
  48. package/build/pan-responder/index.d.ts +2 -2
  49. package/build/pan-responder/index.js +37 -56
  50. package/build/platform-color/index.d.ts +1 -1
  51. package/build/platform-color/index.js +11 -4
  52. package/build/process-background-image/index.js +30 -566
  53. package/build/process-background-longhands.d.ts +4 -0
  54. package/build/process-background-longhands.js +44 -0
  55. package/build/process-box-shadow/index.js +23 -187
  56. package/build/process-filter.js +27 -300
  57. package/build/process-transform/index.d.ts +1 -1
  58. package/build/process-transform/index.js +25 -107
  59. package/build/process-transform-origin/index.d.ts +1 -1
  60. package/build/process-transform-origin/index.js +29 -102
  61. package/build/registry.d.ts +36 -0
  62. package/build/registry.js +73 -0
  63. package/build/sound-manager/index.d.ts +3 -0
  64. package/build/sound-manager/index.js +36 -0
  65. package/build/structured-style.d.ts +10 -0
  66. package/build/structured-style.js +180 -0
  67. package/build/style-registry/index.d.ts +14 -0
  68. package/build/style-registry/index.js +60 -11
  69. package/build/styles.d.ts +5 -1
  70. package/build/surface.d.ts +31 -2
  71. package/build/surface.js +138 -42
  72. package/build/text-input-state.d.ts +1 -0
  73. package/build/text-input-state.js +17 -3
  74. package/build/tree-host.d.ts +307 -0
  75. package/build/tree-host.js +211 -0
  76. package/build/view-config.js +4 -4
  77. package/codegen-specs/NativeSymbioteEngine.ts +27 -0
  78. package/cpp/SymbioteDebug.cpp +51 -0
  79. package/cpp/SymbioteDebug.h +54 -0
  80. package/cpp/SymbioteEngineBindings.cpp +232 -0
  81. package/cpp/SymbioteEngineBindings.h +59 -0
  82. package/cpp/SymbioteFabricProps.cpp +2619 -0
  83. package/cpp/SymbioteFabricProps.h +223 -0
  84. package/cpp/SymbioteTree.cpp +2478 -0
  85. package/cpp/SymbioteTree.h +257 -0
  86. package/ios/SymbioteEngineModule.h +25 -0
  87. package/ios/SymbioteEngineModule.mm +44 -0
  88. package/package.json +31 -3
  89. package/react-native.config.cjs +23 -0
  90. package/symbiote-engine.podspec +42 -0
  91. package/build/animated/bezier.d.ts +0 -1
  92. package/build/animated/bezier.js +0 -102
  93. package/build/commit.d.ts +0 -49
  94. package/build/commit.js +0 -1030
  95. package/build/tags.d.ts +0 -2
  96. package/build/tags.js +0 -40
package/build/node.js CHANGED
@@ -1,22 +1,35 @@
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 { attachHostBehavior, hasHostBehaviors, markDetachCandidate, ownsListener, reattachHostBehaviors, stashAppListener, } from './host-behavior.js';
12
- // A cycle, deliberately: commit.ts imports this module for the node shape, and the imperative
13
- // methods below call back into it. Neither side touches the other at module-evaluation time -
14
- // only inside a function body - so every loader (tsc, vitest, Metro) resolves it fine. The
15
- // alternative was a load-time `SymbioteNode.prototype.measure = ...` installed from elsewhere,
16
- // which is exactly the registration-side-effect shape Metro's inlineRequires silently drops in
17
- // release builds (see CLAUDE.md, "Never make correctness depend on a module's load-time side
18
- // effect").
19
- import { measure as engineMeasure, measureInWindow as engineMeasureInWindow, measureLayout as engineMeasureLayout, setNativeProps as engineSetNativeProps, dispatchViewCommand, } from './commit.js';
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';
28
+ // The same deliberate cycle, for the same reason: `routeProp` resolves an AnimatedNode written
29
+ // into a prop, and the module that owns that resolution reaches back here for `setProp`. See
30
+ // `animated/host-binding.ts`'s header.
31
+ import { hasAnimatedNodes } from './animated/graph.js';
32
+ import { bindAnimatedEvent, bindAnimatedValue, hasAnimatedBindings, reattachAnimatedProps, } from './animated/host-binding.js';
20
33
  const BRAND = Symbol('symbiote.node');
21
34
  // A node carries the Fabric view name directly, so adding a primitive (Image,
22
35
  // ScrollView, TextInput) is just a new string from the adapter, no core change.
@@ -37,6 +50,10 @@ export function isSymbioteEvent(value) {
37
50
  }
38
51
  const FOCUS_COMMAND = 'focus';
39
52
  const BLUR_COMMAND = 'blur';
53
+ // Names and arg order mirror RN's ScrollViewCommands.
54
+ const SCROLL_TO_COMMAND = 'scrollTo';
55
+ const SCROLL_TO_END_COMMAND = 'scrollToEnd';
56
+ const FLASH_SCROLL_INDICATORS_COMMAND = 'flashScrollIndicators';
40
57
  // The one shape every retained node has. A class, not an object literal, for two reasons: the six
41
58
  // imperative methods live on the shared prototype instead of being allocated per node (see
42
59
  // ISymbioteNode above), and both factories below mint the same hidden class.
@@ -46,35 +63,36 @@ const BLUR_COMMAND = 'blur';
46
63
  // constructor assignment is the shape every engine (V8 and Hermes both) handles without a
47
64
  // define-per-field.
48
65
  class SymbioteNode {
49
- constructor(component, isText, props) {
66
+ constructor(component, isText) {
50
67
  this[BRAND] = true;
51
68
  this.component = component;
52
69
  this.isText = isText;
53
- this.props = props;
54
70
  this.listeners = undefined;
55
- this.children = [];
56
- this.parent = undefined;
57
- // A node that has never committed must never take a fast path built on "the mirror already
58
- // agrees with me", so all three flags start raised - including for createRawText, whose props
59
- // are assigned here rather than through setText.
60
- this.dirty = true;
61
- this.propsDirty = true;
62
71
  // Assigned here, not lazily on first use: every slot present from the constructor keeps one
63
- // hidden class for every node. Adding it on demand buys a shape transition per aria-bearing
64
- // node, which is the opposite of what this field is for.
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.
65
74
  //
66
- // Starts false, and that is COMPLETE rather than optimistic: the only two constructions are
67
- // `createElement`'s `{}` and `createRawText`'s `{ text }`, so no aria key can arrive here. It
68
- // was first written as `hasAriaAliases(props)` — a probe that reads as a safeguard and can
69
- // never fire, which the break-test caught by staying green with it removed. If a construction
70
- // path is ever added that passes real props, this line owes that probe back.
71
- this.hasAriaAlias = false;
72
- this.structureDirty = true;
73
- this.committed = undefined;
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;
74
83
  this.styleParts = undefined;
75
84
  // Assigned here for the same hidden-class reason as `hasAriaAlias` above; `attachHostBehavior`
76
85
  // overwrites it a few lines later for the rare node that has a behavior.
77
86
  this.payloadFold = undefined;
87
+ // Same reason again, and here it is load-bearing rather than tidy: the redirect below is read
88
+ // on every append, so the slot must be a stable slot on one hidden class, not a property added
89
+ // to a few nodes after the fact.
90
+ this.childHost = undefined;
91
+ this.wrapper = undefined;
92
+ // Same hidden-class reason as the two above, and here it is the whole point: the fast path it
93
+ // guards is read on every `childrenOf`, so it must be a stable slot rather than a property that
94
+ // appears on some nodes later.
95
+ this.mayHaveChildren = false;
78
96
  }
79
97
  measure(callback) {
80
98
  engineMeasure(this, callback);
@@ -98,53 +116,113 @@ class SymbioteNode {
98
116
  blur() {
99
117
  dispatchViewCommand(this, BLUR_COMMAND, []);
100
118
  }
119
+ // The defaults live HERE and nowhere else. `buildScrollViewHandle`
120
+ // (`@symbiote-native/components`) delegates here, so a built handle and a node cannot drift on
121
+ // what `scrollTo()` with no argument means.
122
+ scrollTo(options) {
123
+ const x = options?.x ?? 0;
124
+ const y = options?.y ?? 0;
125
+ const animated = options?.animated ?? true;
126
+ dlog(`ScrollView.scrollTo x=${x} y=${y} animated=${animated}`);
127
+ dispatchViewCommand(this, SCROLL_TO_COMMAND, [x, y, animated]);
128
+ }
129
+ scrollToEnd(options) {
130
+ const animated = options?.animated ?? true;
131
+ dlog(`ScrollView.scrollToEnd animated=${animated}`);
132
+ dispatchViewCommand(this, SCROLL_TO_END_COMMAND, [animated]);
133
+ }
134
+ flashScrollIndicators() {
135
+ dlog('ScrollView.flashScrollIndicators');
136
+ dispatchViewCommand(this, FLASH_SCROLL_INDICATORS_COMMAND, []);
137
+ }
101
138
  }
139
+ // The committed record — handle, tag, rootTag — is the host's to keep, and `committedRecordOf`
140
+ // (tree-host.ts) is how the imperative APIs ask for it. `IMirror` and `IContribution` were the JS
141
+ // re-implementations of `ShadowNode` and of the one thing `ShadowNode` cannot hold, an anchor. Both
142
+ // are gone with the tree; the host answers about both.
143
+ //
144
+ // The identity check `committedOf` used to make is gone with them, and it was worth something: a Vue
145
+ // `reactive()` / deep-`ref()` Proxy around a host element forwards a field read to its target, so a
146
+ // wrapped node used to hand back a real record. It cannot now — the host keys on the handle OBJECT,
147
+ // so a Proxy misses and every imperative call degrades to its "node not committed" log, which is
148
+ // the WeakMap's old behaviour restored. Hold host nodes with `shallowRef` (vue-adapter-reactivity).
102
149
  /**
103
- * The committed record for `node`, or `undefined` if it has never been committed - or if `node` is
104
- * not the raw retained node at all.
105
- *
106
- * That second case is the reason this is a function rather than a bare `node.committed` read. The
107
- * engine identifies a node BY IDENTITY, and the classic way to break that is to hand the engine a
108
- * wrapper instead of the node: a Vue `reactive()`/deep-`ref()` Proxy around a host element is the
109
- * one that actually happens (see the vue-adapter-reactivity skill; `shallowRef` is the fix).
110
- *
111
- * The old WeakMap caught this for free - a Proxy is a different object, so `mirror.get(proxy)` missed
112
- * and every imperative API bailed with a clear "node not committed". A plain property read does NOT:
113
- * a Proxy forwards `proxy.committed` straight to the target and hands back a real record, whose
114
- * `handle` Vue would then deep-wrap on the way out. That handle is a JSI host object; a Proxy around
115
- * it reaches `cloneNodeWithNewProps` and fails somewhere deep in native, far from the cause.
116
- *
117
- * So the identity check that was implicit in the WeakMap is explicit here: a record written on the
118
- * raw node names it, and `record.owner !== node` means whatever we were handed is not that node.
119
- * One reference comparison, and the wrap now fails LOUDER than it used to rather than quieter.
150
+ * Mint an element and record its creation.
151
+ *
152
+ * The node object IS the handle: it is what the ops address, what the host attaches its native node
153
+ * to, and what Fabric hands back as an event target. Nothing else is allocated.
120
154
  */
121
- export function committedOf(node) {
122
- const record = node.committed;
123
- if (record === undefined)
124
- return undefined;
125
- if (record.owner !== node) {
126
- dlog(`node identity mismatch: committed record belongs to node=${debugNodeId(record.owner)}, ` +
127
- `not to the object handed in. A wrapped/proxied node (Vue reactive() or deep ref() around ` +
128
- `a host element) is the usual cause - hold host nodes with shallowRef.`);
129
- return undefined;
130
- }
131
- return record;
132
- }
133
155
  export function createElement(component, isText = false,
134
156
  // The intrinsic tag this node came from, when it differs from the Fabric view name above. The
135
157
  // behavior registry is keyed by tag and the node only ever carries the resolved name, so an
136
- // adapter lowering `<Pressable>` has to hand the tag over here or the registration cannot fire
158
+ // adapter creating a `<pressable>` has to hand the tag over here or the registration cannot fire
137
159
  // (host-behavior.ts, `attached`). Nothing is stored — the lookup happens once, right below.
138
160
  tag = component) {
139
- const node = new SymbioteNode(component, isText, {});
161
+ const node = new SymbioteNode(component, isText);
162
+ // A primitive that commits NO VIEW resolves to the anchor component through `descriptorFor`
163
+ // (`touchable-without-feedback`, `touchable-native-feedback`), and reaches this function rather
164
+ // than `createAnchor` because the caller only knows it has a descriptor. The kind is an OPCODE
165
+ // now, not a name the commit walk reads, so the name alone would give the host an ordinary
166
+ // element called `#anchor` — one that really paints.
167
+ if (component === ANCHOR_COMPONENT)
168
+ recordCreateAnchor(node);
169
+ // A primitive whose ENTIRE subtree must vanish on this platform (`input-accessory-view` on
170
+ // Android, `InputAccessoryView.js`'s `return null`) resolves to the void component the same way —
171
+ // through `descriptorFor`'s per-platform component-name table, never a per-call branch here. An
172
+ // anchor hoists its children into Fabric in its place; a void node contributes neither itself nor
173
+ // them.
174
+ else if (component === VOID_COMPONENT)
175
+ recordCreateVoid(node);
176
+ // `instanceHandle` is the node itself: it round-trips through Fabric unchanged and comes back as
177
+ // the event target, and the BRAND below is how the event handler confirms it is one of ours.
178
+ else
179
+ recordCreateElement(node, component, isText, node);
140
180
  // Gated on the boolean, not on the Map: this runs ~9 000 times per benchmark create, and an app
141
181
  // that registers nothing must pay one boolean read rather than a hash lookup per node.
142
182
  if (hasHostBehaviors())
143
183
  attachHostBehavior(node, tag);
184
+ // A third-party view's own ViewConfig processors, as a fold. AFTER the behavior's, because that
185
+ // is the order the reference ran them in — the behavior rewrites the wrapper-body props, and
186
+ // `validAttributes[*].process` then converts what it produced. Composed rather than replaced:
187
+ // one component can legitimately have both.
188
+ //
189
+ // Costs a `Set.has` per node for a built-in, which is where `resolve` bails, and nothing else:
190
+ // the answer is cached per component name, not computed per node.
191
+ const configFold = configPayloadFold(component);
192
+ if (configFold !== undefined) {
193
+ const behaviorFold = node.payloadFold;
194
+ node.payloadFold =
195
+ behaviorFold === undefined
196
+ ? configFold
197
+ : props => configFold(behaviorFold(props));
198
+ }
144
199
  return node;
145
200
  }
146
- export function createRawText(text) {
147
- return new SymbioteNode(RAW_TEXT_COMPONENT, false, { text });
201
+ /**
202
+ * `tag` mirrors `createElement`'s, and a raw text needs it for the same reason an element does: the
203
+ * behavior registry is keyed by tag, so a node that does not hand one over cannot have a rule.
204
+ *
205
+ * A raw text carrying a tag looks odd and is not. It has no props an app can write — its whole
206
+ * payload is `text` — but its CONTENT can still be a function of the platform rather than of the
207
+ * app: Button renders its title uppercased on Android (`Button.js:352-353`), which is a user-agent
208
+ * decision about a control, not anything the app asked for. That rule needs the node to be
209
+ * identifiable, and a tag is how this codebase identifies one.
210
+ *
211
+ * Defaulted to the raw-text component, so every existing caller is unchanged and pays the same
212
+ * lookup miss `createElement` already pays for a node nobody registered.
213
+ */
214
+ export function createRawText(text, tag = RAW_TEXT_COMPONENT) {
215
+ const node = new SymbioteNode(RAW_TEXT_COMPONENT, false);
216
+ recordCreateRawText(node, text);
217
+ // The TAG check comes first, and it is not the same guard `createElement` uses. There it asks
218
+ // `hasHostBehaviors()` because every element legitimately might have a behavior. Here almost none
219
+ // do — a raw text is the leaf under every `<Text>` on a screen, thousands of them, and exactly one
220
+ // kind is tagged. So an untagged raw text must pay a reference comparison against the default and
221
+ // not a registry lookup: `tag` is the same string literal in that case, so the compare is pointer
222
+ // equality and the intern, the op and the miss are all skipped.
223
+ if (tag !== RAW_TEXT_COMPONENT && hasHostBehaviors())
224
+ attachHostBehavior(node, tag);
225
+ return node;
148
226
  }
149
227
  // `instanceHandle` round-trips through Fabric unchanged: the object we pass to
150
228
  // createNode comes back as the event target. We brand our nodes so the event
@@ -174,165 +252,279 @@ export function debugNodeId(node) {
174
252
  // not a new field, so the hot SymbioteNode shape is untouched.
175
253
  export const ANCHOR_COMPONENT = '#anchor';
176
254
  export function createAnchor() {
177
- return createElement(ANCHOR_COMPONENT);
255
+ const node = new SymbioteNode(ANCHOR_COMPONENT, false);
256
+ recordCreateAnchor(node);
257
+ return node;
258
+ }
259
+ // The sentinel a primitive resolves to when its ENTIRE subtree must vanish from Fabric on this
260
+ // platform — `input-accessory-view` on Android, mirroring `InputAccessoryView.js`'s `return null`.
261
+ // Unlike `ANCHOR_COMPONENT`, whose node hoists its children up in its own place, a void node's
262
+ // children never reach Fabric either: the commit walk stops at it, recursively.
263
+ export const VOID_COMPONENT = '#void';
264
+ export function createVoid() {
265
+ const node = new SymbioteNode(VOID_COMPONENT, false);
266
+ recordCreateVoid(node);
267
+ return node;
268
+ }
269
+ /**
270
+ * The sentinel a SURFACE's own root node carries, so `parentOf` can stop there.
271
+ *
272
+ * A top-level node must answer `undefined` for its parent, and adapters depend on the exact miss:
273
+ * Angular reads `null` as "defer, `<ng-content>` will place this" (answering the surface once
274
+ * mounted every FlatList cell at top level), while Vue and Solid spell `?? surface` at their call
275
+ * sites and would be handed an object that is not the `SymbioteSurface` they compare against. One
276
+ * component name, read in JS, keeps all three right without a second structure.
277
+ *
278
+ * It is a JS-side name only. What goes over the wire is `RCTView`, because this node is REAL.
279
+ */
280
+ export const SURFACE_COMPONENT = '#surface';
281
+ /**
282
+ * One persistent root view per surface, mirroring RN's own AppContainer — `renderApplication` wraps
283
+ * the app in `<View style={{flex:1}} pointerEvents="box-none">`.
284
+ *
285
+ * It is not decoration. Without `flex: 1` a non-flex root collapses to content height, and without
286
+ * `box-none` a touch landing outside the app's own children has no escape. Living here rather than
287
+ * in each adapter's `mount()` gives every framework a full-screen root for free and keeps layout in
288
+ * the shared layer (`<adapters_stay_thin>`).
289
+ *
290
+ * The surface therefore commits as ONE node rather than hoisting its children into the child set —
291
+ * which is why the host materializes the node `OP_COMMIT` names instead of walking its children. An
292
+ * anchor in that position still hoists, so the host handles both without a special case.
293
+ */
294
+ export function createSurfaceRoot() {
295
+ const node = new SymbioteNode(SURFACE_COMPONENT, false);
296
+ recordCreateElement(node, 'RCTView', false, node);
297
+ // Recorded straight, not through `routeProp`: these are literal Fabric props, not props an app
298
+ // authored, so they want none of the class merging or event routing that path exists for.
299
+ recordSetProp(node, 'style', { flex: 1 });
300
+ recordSetProp(node, 'pointerEvents', 'box-none');
301
+ return node;
178
302
  }
179
303
  export function isAnchor(node) {
180
304
  return node.component === ANCHOR_COMPONENT;
181
305
  }
182
- // A raw text with no characters must not reach Fabric. Its fragment is dropped by
183
- // AttributedString::appendFragment, but the text walk has already flagged "the last child was raw
184
- // text", so the NEXT raw sibling merges into `fragments.back()` of an empty vector and the process
185
- // aborts. The commit walk skips such a node exactly as it skips an anchor (commit.ts,
186
- // renderableChildren); an empty string paints nothing either way, so nothing is lost. `''` only —
187
- // a whitespace-only string is real content inside a <Text>.
188
- export function isEmptyRawText(node) {
189
- return node.component === RAW_TEXT_COMPONENT && node.props.text === '';
190
- }
191
- // Dirty-marking: lets reconcile return an untouched subtree by reference instead of rebuilding
192
- // every node's Fabric props and deep-comparing them against the mirror. That walk costs ~13 us per
193
- // node on device (Hermes, iOS Debug, examples/react benchmark screen), so without the flag a
194
- // 1200-node tree burns a whole 16.6 ms frame no matter how small the change - the cost tracks TREE
195
- // SIZE, not change size.
196
- //
197
- // Marking walks up to the first ALREADY-dirty ancestor and stops, so a burst of mutations under one
198
- // subtree pays for one chain walk rather than one per mutation. Reconcile clears every node it
199
- // visits, which keeps the invariant "an ancestor of a dirty node is dirty" across commits.
200
- //
201
- // Listener changes deliberately do NOT mark. `node.listeners` never reaches Fabric (event dispatch
202
- // reads it straight off the retained node) and React hands us a fresh handler closure on nearly
203
- // every render, so marking there would re-dirty the whole tree every commit and hand the win back.
204
- // The one listener that DOES change a Fabric prop, `layout`, raises `onLayout` through setProp
205
- // below and is marked that way.
306
+ // `isEmptyRawText` was here and is GONE: it read `node.props.text`, which JS no longer holds. The
307
+ // rule it expressed — a raw text with no characters must not reach Fabric, because
308
+ // AttributedString::appendFragment drops the fragment while the text walk has already flagged "the
309
+ // last child was raw text", so the NEXT raw sibling merges into `fragments.back()` of an empty
310
+ // vector and the process aborts — is now the host's, applied where the child set is built.
311
+ // Dirty-marking is GONE, along with the walk it existed to skip. An op names the node it changed,
312
+ // so the host marks exactly that node and its own ancestors; nothing on this side has to guess.
313
+ // Listener changes still record nothing, for the reason they always did: `node.listeners` never
314
+ // reaches Fabric, and the one listener that DOES change a Fabric prop, `layout`, raises `onLayout`
315
+ // through `setProp` below.
206
316
  /**
207
317
  * Change which Fabric view a node commits as, keeping the node's identity.
208
318
  *
209
- * The commit walk already re-creates a node whose `viewName` no longer matches its committed one —
210
- * that is how a `<Text>` moving in or out of another `<Text>` flips between RCTText and
211
- * RCTVirtualText (`commit.ts`, reason `view-kind`). This exposes the same door for a prop-driven
212
- * view choice, so `intrinsicWhen` is honoured on UPDATE and not only at create.
213
- *
214
319
  * The POLICY stays out of the engine: which prop decides, and which view it decides between, lives
215
320
  * in `HOST_PRIMITIVES` and is read by `resolveIntrinsicTag` in `@symbiote-native/components`. The
216
321
  * engine only knows how to swap the name — the same split every other spec-driven fold has here.
217
322
  *
218
323
  * A no-op when the name is unchanged, so a renderer may call it on every update without comparing
219
324
  * first.
325
+ *
326
+ * The JS field and the op BOTH move, and both are load-bearing. `node.component` is what the aria
327
+ * fold, the behavior registry and `fabricProps` key on; the op is what makes the host re-create the
328
+ * node under the new name, since no prop write moves a node between native views.
220
329
  */
221
330
  export function setNodeComponent(node, component) {
222
331
  if (node.component === component)
223
332
  return;
224
333
  node.component = component;
225
- // `dirty` alone is not enough: the walk's reuse test also requires the node be visited at all,
226
- // and a node whose own props did not change this tick is exactly the case that would be skipped.
227
- markDirty(node);
228
- markPropsDirty(node);
229
- }
230
- export function markDirty(node) {
231
- let current = node;
232
- while (current !== undefined && !current.dirty) {
233
- current.dirty = true;
234
- current = current.parent;
235
- }
236
- }
237
- // The prop-write twin of markDirty: raises this node's OWN props flag and then bubbles the subtree
238
- // flag as usual. Every path that writes `node.props` must come through here - setProp and setText
239
- // below, setNativeProps in commit.ts (which writes the record directly and so owes its own mark).
240
- //
241
- // Note the two flags are raised INDEPENDENTLY rather than one implying the other. markDirty stops
242
- // at the first already-dirty ancestor, so a node dirtied a moment ago by a child's change would
243
- // otherwise have its own prop write silently dropped: the walk would exit before setting anything
244
- // here. Setting propsDirty first, unconditionally, is what makes that ordering safe.
245
- export function markPropsDirty(node) {
246
- node.propsDirty = true;
247
- markDirty(node);
248
- }
249
- // The structural twin. Raised on the PARENT whose child list changed - never on the moved child,
250
- // for the same reason markDirty is not (see the structural ops below).
251
- //
252
- // Every caller must reach here BEFORE mutating `parent.children`, and that ordering is now
253
- // load-bearing rather than stylistic. reconcile stores the reconciled child list in the committed
254
- // record BY REFERENCE, so for a parent holding no anchors the record ALIASES `parent.children`;
255
- // this call is the last moment the committed list can still be read. Taking the copy here means it
256
- // is taken once per parent per commit->mutation cycle, and only for parents that actually change,
257
- // instead of once per node per commit - 9 002 arrays on a 1 000-row create, all but a handful
258
- // allocated only to be discarded unread.
259
- //
260
- // The identity test is what keeps it honest: a record whose `children` is NOT `parent.children`
261
- // either already holds a copy (this cycle's first structural op ran) or holds the private array
262
- // renderableChildren built to flatten anchors away, which nobody mutates. Neither needs saving.
263
- export function markStructureDirty(parent) {
264
- const record = parent.committed;
265
- if (record !== undefined && record.children === parent.children) {
266
- record.children = parent.children.slice();
267
- }
268
- parent.structureDirty = true;
269
- markDirty(parent);
270
- }
271
- // How many prop writes actually landed, and how many the no-op guard below turned away.
272
- // Read-and-zeroed through readCommitProfile() (commit.ts), which folds them into the same window
273
- // as the walk numbers so one read prices both halves: `propNoops` is the waste an adapter is
274
- // generating above the engine, `nodesVisited` is what that waste costs below it.
334
+ recordSetComponent(node, component);
335
+ }
336
+ // `isSkippedAtCommit`, `markPresenceIfFlipped`, `markDirty`, `markPropsDirty`, `markStructureDirty`,
337
+ // `markRenderableAncestor`, `markChildOp`, `markChildRemoved` and `markChildAppended` all lived here
338
+ // and are all GONE. Every one of them answered a question about a tree — which ancestor went stale,
339
+ // whether a node's PRESENCE in its parent's renderable list flipped, which anchor to climb past —
340
+ // and the host is the only thing that can answer those now. It marks from the ops themselves.
341
+ // How many prop writes an adapter pushed at the engine. Read-and-zeroed through
342
+ // readCommitProfile() (tree-host.ts), which prices the layer ABOVE the host.
343
+ //
344
+ // `noops` used to sit beside it and counted the writes the `Object.is` guard turned away — the
345
+ // Angular Pressable bag that pushed 104 000 setProp calls for a screen Solid built in 12 000, 90 000
346
+ // of them writing `undefined` over a key that was not there. The guard moved into the host, because
347
+ // keeping it here would mean reading the previous value BACK over the wire — ~44 001 reads on a
348
+ // 1 000-row create, exactly the crossings this design removes. So the number is no longer visible
349
+ // from JS, and it is not faked as a zero it would have to keep re-earning.
275
350
  //
276
351
  // Not gated behind isDebug(), for the same reason the commit profile is not: an integer increment
277
352
  // is noise next to the prop write it counts, and the figure is only meaningful from a release
278
- // build. A per-call dlog was the obvious alternative and is deliberately NOT here - the Angular
279
- // screen that motivated the guard emitted 90 000 no-op writes on one press, and a log line each
280
- // would measure the logging rather than the code (see the `perf-claims-need-numbers` rule).
281
- const propStats = { writes: 0, noops: 0 };
353
+ // build. A per-call dlog was the obvious alternative and is deliberately NOT here - a log line per
354
+ // write would measure the logging rather than the code (see the `perf-claims-need-numbers` rule).
355
+ const propStats = { writes: 0 };
282
356
  export function takePropStats() {
283
- const snapshot = { writes: propStats.writes, noops: propStats.noops };
357
+ const snapshot = { writes: propStats.writes };
284
358
  propStats.writes = 0;
285
- propStats.noops = 0;
359
+ return snapshot;
360
+ }
361
+ // `<component>.<key>` -> write count, gated behind `isDebug()` (a Map lookup per write is not the
362
+ // "log line per write" the comment above rules out, but it is still real cost on the hottest path,
363
+ // so it only runs when someone asked). Answers F-79's own recommended next step — "instrument
364
+ // recordSetProp call sites directly, not just before/after counts" — by naming exactly which
365
+ // (view, key) pair an adapter comparison's aggregate delta is hiding, the same ledger shape F-75's
366
+ // payload census already uses (`RCTView.accessible 2000`).
367
+ let propKeyTally;
368
+ export function takePropKeyTally() {
369
+ const snapshot = propKeyTally ?? new Map();
370
+ propKeyTally = undefined;
286
371
  return snapshot;
287
372
  }
288
373
  // A pure prop set: no event inference. `onTintColor` is a Switch prop and reaches
289
374
  // Fabric like any other; the event-vs-prop decision is made by routeProp, never by
290
375
  // the key's name.
291
376
  //
292
- // Writing a value the node already holds is a NO-OP and returns before markDirty. Fabric never saw
293
- // a difference either way - reconcile rebuilds the node's Fabric props and `propsEqual` finds them
294
- // identical, so no clone is emitted - but the mark itself is not free: it walks to the first
295
- // already-dirty ancestor and strips every one of them of the commit walk's early exit, so an
296
- // otherwise untouched subtree gets re-walked purely to prove it is untouched. The guard makes that
297
- // whole bug class free for every adapter instead of each one having to remember to diff first
298
- // (measured: Angular's Pressable host bag pushed 104 000 setProp calls for a screen Solid built in
299
- // 12 000, 90 000 of them writing `undefined` over a key that was not there).
300
- //
301
- // Three deliberate choices:
302
- //
303
- // - `Object.hasOwn`, not `node.props[key] === undefined`. A key explicitly present holding
304
- // `undefined` is not an absent key: `delete` genuinely changes the record's shape, and
305
- // `setNativeProps` writes node.props directly and can leave exactly such a key behind. Fabric
306
- // itself cannot tell the two apart (fabricProps skips undefined values), but node.props is also
307
- // read outside the commit path, so the retained tree keeps the shape callers asked for.
308
- // - `Object.is`, not a deep compare. A style object, an array, or a handler closure is a fresh
309
- // reference on nearly every render, so the guard simply never fires for them - correct, since an
310
- // adapter is free to hand back the SAME reference with mutated contents and identity cannot see
311
- // that. A deep compare on every prop write would cost more than the walk it saves.
312
- // - The in-place-mutation hazard that leaves is already instrumented: a node skipped as clean whose
313
- // props have drifted is exactly what `warnIfStale` reports as DIRTY-MISS under DEBUG (commit.ts).
377
+ // `undefined` DELETES the key, which is the collapse this function has always performed and which
378
+ // the wire now spells (`NO_VALUE`, mutation-buffer.ts). `null` is NOT the same thing: it is a
379
+ // legitimate Fabric value meaning "reset to the default", and a merge-on-clone host has to be able
380
+ // to tell a removed key from one that was never there.
381
+ //
382
+ // THE `Object.is` DEDUPE IS NOT HERE ANY MORE, and that is the one behaviour change in this file.
383
+ // It needed the value the node already holds, and JS no longer holds it — reading it back would be
384
+ // ~44 001 crossings on a 1 000-row create, which is the cost this whole design exists to remove. The
385
+ // guard lives in the host's `OP_SET_PROP` instead, where the previous value is a local field: same
386
+ // comparison, same `Object.is` reasoning (a style object or a handler closure is a fresh reference
387
+ // on nearly every render, so it simply never fires for them, which is correct because an adapter may
388
+ // hand back the SAME reference with mutated contents and identity cannot see that).
314
389
  export function setProp(node, key, value) {
315
- if (value === undefined) {
316
- if (!Object.hasOwn(node.props, key)) {
317
- propStats.noops += 1;
318
- return;
319
- }
320
- delete node.props[key];
390
+ // THE ARIA GATE WAS THIS LINE AND IT IS GONE (2026-09-18). `node.hasAriaAlias` existed to let the
391
+ // headless payload builder skip `foldAriaProps` on the ~99% of nodes carrying no alias; that
392
+ // builder no longer folds aria at all — the rule is the device's, in `SymbioteFabricProps.cpp`,
393
+ // which recomputes the gate from the bag it already holds. So the flag became write-only, and its
394
+ // write ran `isAriaAliasKey` on EVERY prop write in the engine to maintain something nothing read.
395
+ //
396
+ // A composed primitive's slot — and its wrapper, where it has one — can carry a value DERIVED
397
+ // from an owner prop, and `markPropsDirty` bubbles up, so neither ever learns. Here rather than
398
+ // in `routeProp` because this is the one choke point every writer passes (a structural adapter's
399
+ // `setProperty` does not go through routeProp), and past the identity guard so a re-render
400
+ // writing an unchanged value costs them nothing. See `IHostBehavior.slotDerived`.
401
+ if (node.childHost !== undefined && slotDerivesFrom(node, key)) {
402
+ markPropsDirty(node.childHost);
403
+ // Past the slot: a `buildStructure` that builds a CHAIN registers the deeper nodes here, and
404
+ // each keeps its own pure fold reading the owner. See `addDerivedNode`.
405
+ const derived = derivedNodesOf(node);
406
+ if (derived !== undefined)
407
+ for (const each of derived)
408
+ markPropsDirty(each);
409
+ if (node.wrapper !== undefined)
410
+ markPropsDirty(node.wrapper);
321
411
  }
322
- else {
323
- if (Object.is(node.props[key], value)) {
324
- propStats.noops += 1;
325
- return;
412
+ propStats.writes += 1;
413
+ if (isDebug()) {
414
+ propKeyTally ??= new Map();
415
+ const tallyKey = `${node.component}.${key}`;
416
+ propKeyTally.set(tallyKey, (propKeyTally.get(tallyKey) ?? 0) + 1);
417
+ }
418
+ writeProp(node, key, value);
419
+ }
420
+ // Function props that never left JS, keyed by node.
421
+ //
422
+ // A function CANNOT cross this wire. The host stores props as a `folly::dynamic` and
423
+ // `jsi::dynamicFromValue` THROWS on a callable — "JS Functions are not convertible to dynamic" —
424
+ // so a single function prop kills the whole batch, and with it the commit that carried it.
425
+ //
426
+ // It has always been unsendable and it used to be unreachable, because `routeProp` diverts every
427
+ // REGISTERED `on*` name into the listener stash before this point. Two paths get past that and both
428
+ // are real: an unregistered `on*` that is an ordinary prop by design (`onValueChange`, which
429
+ // `fabricProps` drops on the way to native and a behavior reads back), and `setNativeProps`, which
430
+ // bypasses `routeProp` entirely. Device-found 2026-09-08 through the second: an `Animated.View`
431
+ // spread with `panResponder.panHandlers` hands `AnimatedProps.__getValue()` a bag of callbacks, and
432
+ // it copies every key it holds.
433
+ //
434
+ // So they live here, exactly as listeners already do — and `propOf` looks here first, which is what
435
+ // keeps `onValueChange` readable. Nothing is lost on the native side: `fabricProps` dropped function
436
+ // props on both hosts anyway, so the payload is byte-identical either way.
437
+ const functionProps = new WeakMap();
438
+ /**
439
+ * The one place a prop reaches the wire, and the only place that can keep a function off it.
440
+ *
441
+ * `setNativeProps` calls this rather than `recordSetProp` for that reason — it is the path that has
442
+ * no `routeProp` in front of it.
443
+ */
444
+ export function writeProp(node, key, value) {
445
+ // The same strip `routeProp` does, repeated because THIS is the path with no `routeProp` in
446
+ // front of it (`imperative.ts` says so). One filter on the declarative path was never enough:
447
+ // `AnimatedProps` is built from a RAW prop bag and re-sends every key it holds on every frame,
448
+ // so on a JSX adapter `__self` rode straight past the strip into the host. See
449
+ // REACT_JSX_DEV_PROPS for what that costs on each platform.
450
+ if (REACT_JSX_DEV_PROPS.has(key))
451
+ return;
452
+ // Arms the node's recurring post-commit hook, for the rare node that has one. HERE rather than in
453
+ // `setProp`, because this is where both paths meet: `setNativeProps` reaches the wire through
454
+ // this function and not through that one, and a hook armed only by the declarative path missed
455
+ // the imperative write entirely (`__tests__/after-commit-lifecycle.test.ts` said so). The field
456
+ // read is the same shape as `hasAriaAlias` and for the same reason — this is the hottest path in
457
+ // the engine, and a Set lookup per write is not something it can carry.
458
+ if (node.hasCommitHook)
459
+ noteCommitHookNodeChanged(node);
460
+ // Resolved on the way IN, for the same reason the strip above lives here: this is where both
461
+ // paths meet. `boxShadow` / `filter` / `transform` and the four beside them are parsed in JS,
462
+ // and the C++ payload builder has no JS — so a value resolved at payload-build time is resolved
463
+ // headless only, and the device commits the raw CSS string, which Fabric drops in silence. See
464
+ // `structured-style.ts`; it hands the same object back when nothing needed resolving, which is
465
+ // what keeps the host's identity guard and `pushClassStyle` working.
466
+ // Image's three source props, resolved on the way in for the same reason and at the same seam —
467
+ // the asset lookup is Metro's registry, which exists only in JS. See `image-source-write.ts`.
468
+ // Gated on the node: the resolution normalises to Image's ARRAY shape, and a `WebView` spells
469
+ // `source` too.
470
+ let written = value;
471
+ if (key === 'style' || key === 'activeStyle') {
472
+ written = resolveStructuredStyle(value);
473
+ }
474
+ else if (node.resolvesImageSources && IMAGE_SOURCE_PROPS.has(key)) {
475
+ written = resolveImageSourceProp(value);
476
+ }
477
+ if (typeof written === 'function') {
478
+ let bag = functionProps.get(node);
479
+ if (bag === undefined) {
480
+ bag = new Map();
481
+ functionProps.set(node, bag);
326
482
  }
327
- node.props[key] = value;
483
+ bag.set(key, value);
484
+ // The host must not be left holding whatever stood under this key before — a stale value read
485
+ // back through `propOf` would beat the function this write just stashed.
486
+ recordSetProp(node, key, undefined);
487
+ return;
328
488
  }
329
- // The single choke point for the aria gate. `routeProp`'s other branches — class, style,
330
- // activeStyle, on* — return before reaching here and none of them can carry an alias, so every
331
- // `role` / `aria-*` write in the engine passes through this line.
332
- if (!node.hasAriaAlias && isAriaAliasKey(key))
333
- node.hasAriaAlias = true;
334
- propStats.writes += 1;
335
- markPropsDirty(node);
489
+ // Written over with a non-function: the stash must let go, or it keeps answering.
490
+ const bag = functionProps.get(node);
491
+ if (bag !== undefined)
492
+ bag.delete(key);
493
+ recordSetProp(node, key, written);
494
+ }
495
+ /** What `propOf` consults before asking the host. `undefined` when nothing was stashed. */
496
+ export function functionPropOf(node, key) {
497
+ return functionProps.get(node)?.get(key);
498
+ }
499
+ /**
500
+ * The same stash, whole — what `propsOf` layers over the host's answer.
501
+ *
502
+ * `undefined` rather than an empty Map for a node that stashed nothing, which is nearly every node:
503
+ * the caller then hands back the host's own object instead of copying it.
504
+ */
505
+ export function functionPropsOf(node) {
506
+ return functionProps.get(node);
507
+ }
508
+ /**
509
+ * "Rebuild this node's payload — the fold reads state I just changed."
510
+ *
511
+ * A behavior whose payload is DERIVED has no prop to write: the sticky header's debounced
512
+ * translateY lives in its own runtime, not on the node, so nothing names the node and the host
513
+ * never marks it. This is the one route that says so directly.
514
+ *
515
+ * Dirtying is not publishing — pair it with `requestCommitFor` (imperative.ts).
516
+ */
517
+ export function markPropsDirty(node) {
518
+ flushOps();
519
+ // Announced to the buffer even though it writes no op: this is the one route that dirties a node
520
+ // without one, and a commit that cannot see it would skip itself as idle.
521
+ noteHostSideChange();
522
+ // The other way a node's payload is rebuilt, and the beat's population must cover both or a
523
+ // behavior whose payload is DERIVED — the sticky header's debounced translateY has no prop to
524
+ // write — would be armed by nothing.
525
+ if (node.hasCommitHook)
526
+ noteCommitHookNodeChanged(node);
527
+ treeHost()?.markPropsDirty(node);
336
528
  }
337
529
  // Fabric gates a handful of events behind a BOOLEAN prop: unlike scroll / touch / change, which
338
530
  // the native component emits unconditionally, these fire only when the shadow node carries the
@@ -371,12 +563,20 @@ const GATED_EVENT_PROPS = new Map([
371
563
  * `setEventListener` diverts an owned name into the stash, which is right for an app listener and
372
564
  * circular for the behavior's own dispatcher — it would stash itself and never occupy the slot it
373
565
  * exists to hold. This is the one writer allowed past that gate.
566
+ *
567
+ * `undefined` removes it, gate flag included. A behavior whose dispatcher is conditional needs
568
+ * that as much as it needs the install: ScrollView takes the owner's `layout` only while the app
569
+ * or an inverted sticky header wants it, and a one-way installer leaves `onLayout: true` standing
570
+ * in the payload of a ScrollView that no longer reads the event.
374
571
  */
375
572
  export function setBehaviorListener(node, name, listener) {
376
- (node.listeners ??= new Map()).set(name, listener);
573
+ if (listener === undefined)
574
+ node.listeners?.delete(name);
575
+ else
576
+ (node.listeners ??= new Map()).set(name, listener);
377
577
  const flagProp = GATED_EVENT_PROPS.get(name);
378
578
  if (flagProp !== undefined)
379
- setProp(node, flagProp, true);
579
+ setProp(node, flagProp, listener === undefined ? undefined : true);
380
580
  }
381
581
  export function setEventListener(node, name, value) {
382
582
  const isHandler = typeof value === 'function';
@@ -385,10 +585,22 @@ export function setEventListener(node, name, value) {
385
585
  // without this the two evict each other and the last writer wins with no diagnostic; and the
386
586
  // keys at stake are the ones a gesture STARTS on, so the loser is silently pressless. The
387
587
  // component wrapper used to mediate this by destructuring the app's callbacks out before they
388
- // reached the node; lowering removes the mediator. Gated on the boolean first, so an app with no
389
- // behavior registered pays one read.
588
+ // reached the node; a tag has no mediator. Gated on the boolean first, so an app with no behavior
589
+ // registered pays one read.
390
590
  if (hasHostBehaviors() && ownsListener(node, name)) {
591
+ // The PRESENCE only, never the identity: listeners deliberately do not notify (a framework
592
+ // hands a fresh closure nearly every render — see `markDirty`'s note on why that must stay
593
+ // free). A flip is a mount-time event, not a per-render one.
594
+ const wasWired = appListenerFor(node, name) !== undefined;
391
595
  stashAppListener(node, name, isHandler ? value : undefined);
596
+ if (wasWired !== isHandler) {
597
+ // The BIT, on the flip only. A platform rule can then resolve a key that depends on whether
598
+ // the app wired anything — `focusable` on a touchable is the case that needed it — without
599
+ // the closure ever leaving JS. Ordered before the notify so a behavior that re-commits from
600
+ // that callback finds the host already holding the new answer.
601
+ recordSetOwnedListener(node, name, isHandler);
602
+ notifyOwnedListenerChange(node, name, isHandler);
603
+ }
392
604
  const flagged = GATED_EVENT_PROPS.get(name);
393
605
  if (flagged !== undefined)
394
606
  setProp(node, flagged, isHandler ? true : undefined);
@@ -405,8 +617,28 @@ export function setEventListener(node, name, value) {
405
617
  const flagProp = GATED_EVENT_PROPS.get(name);
406
618
  if (flagProp !== undefined)
407
619
  setProp(node, flagProp, isHandler ? true : undefined);
620
+ // `onLoad`/`onLoadStart`/`onLoadEnd`/`onError` are real Fabric events on `RCTImageView`
621
+ // (`view-config.ts`), so they land here rather than in `writeProp` — see
622
+ // `image-source-write.ts` for why Android's `shouldNotifyLoadEvents` has to be synthesized
623
+ // from the listener map instead of from a stashed function value.
624
+ if (node.resolvesImageSources && IMAGE_LOAD_EVENT_NAMES.has(name)) {
625
+ setProp(node, 'shouldNotifyLoadEvents', anyImageLoadEventListenerWired(node.listeners) ? true : undefined);
626
+ }
627
+ }
628
+ // `/^on[A-Z]/` spelled out, because this runs on EVERY prop write and a regex is the one guard in
629
+ // that sequence that is not obviously cheap. Priced on `build-release`
630
+ // (`mutation-api-fill-cost.itest.ts`): 0.09 us for the regex against 0.03 for the character reads,
631
+ // on a prop write that costs 0.88 us end to end — so ~7% of a write, ~1% of a create. Small, and it
632
+ // is free: the boundary is pinned by its own tests in `node.test.ts`.
633
+ //
634
+ // 111 is 'o', 110 is 'n', and 65-90 is A-Z. `charCodeAt` past the end answers NaN, which fails every
635
+ // comparison — so a two-character `on` needs no length check.
636
+ function isOnEventName(key) {
637
+ if (key.charCodeAt(0) !== 111 || key.charCodeAt(1) !== 110)
638
+ return false;
639
+ const third = key.charCodeAt(2);
640
+ return third >= 65 && third <= 90;
408
641
  }
409
- const ON_PREFIX = /^on[A-Z]/;
410
642
  // onChange -> change
411
643
  function listenerName(propName) {
412
644
  return propName.charAt(2).toLowerCase() + propName.slice(3);
@@ -436,9 +668,17 @@ const RESPONDER_EVENTS = new Set([
436
668
  // consumes both and never forwards them. A JSX-based adapter (Vue JSX, Solid JSX) instead
437
669
  // carries them onto the vnode as ordinary props, so they reach setProp and then Fabric,
438
670
  // where Android's folly::dynamic rejects __self with "JS Functions are not convertible to
439
- // dynamic" (the instance holds functions) and the surface paints black, while iOS silently
440
- // drops it. SFC/template authoring never produces them. Strip them here, once, so no
441
- // adapter leaks React JSX dev metadata to the host, mirroring React's host config.
671
+ // dynamic" (the instance holds functions) and the surface paints black.
672
+ //
673
+ // "WHILE IOS SILENTLY DROPS IT" IS WHAT THIS COMMENT USED TO SAY, AND IT IS WRONG. iOS converts
674
+ // the same value with `jsi::dynamicFromValue`, whose walk keeps no visited set, and `__self` is a
675
+ // module `this` — cyclic. That is not a drop, it is an endless walk inside `applyOps` that never
676
+ // returns and allocates as it goes: measured 2026-09-09 on examples/solid, one press on an
677
+ // Animated control, RAM to 15 GB and a dead JS thread. Android's loud rejection is the FRIENDLIER
678
+ // of the two platforms here.
679
+ //
680
+ // SFC/template authoring never produces them. Strip them here, once, so no adapter leaks React
681
+ // JSX dev metadata to the host, mirroring React's host config.
442
682
  const REACT_JSX_DEV_PROPS = new Set([
443
683
  '__self',
444
684
  '__source',
@@ -460,6 +700,7 @@ function stylePartsOf(node) {
460
700
  isPressed: false,
461
701
  activeStyle: undefined,
462
702
  activeStyleFromCallback: false,
703
+ published: undefined,
463
704
  });
464
705
  }
465
706
  // What belongs in slot 0 right now. The pressed variant is a complete REPLACEMENT rather than an
@@ -530,19 +771,149 @@ function baseStyleOf(parts) {
530
771
  // constant - StyleSheet.create, a module-level object - would then re-push an identity-equal half,
531
772
  // get skipped by setProp's Object.is guard, and never restore the declarative style the animation
532
773
  // overwrote. The re-push IS the restore path.
533
- // Would `pushClassStyle` republish an array byte-identical to the one already standing? Reads the
534
- // last published array back out of `node.props.style` rather than remembering it in a field: that
535
- // array IS the record of what was published, so there is nothing to keep in sync, and no shape
536
- // change to the node or to IClassStyleParts.
537
- //
538
- // Sound because `pushClassStyle` is the ONLY writer of an array into that slot — both routeProp
539
- // branches and setNodeHidden funnel through it — so a foreign array cannot be mistaken for ours,
540
- // and a node whose props are still empty holds `undefined`, which is not an array, so the first
541
- // write can never be swallowed.
542
- function isAlreadyPublished(node, parts) {
543
- const published = node.props.style;
544
- if (!Array.isArray(published))
774
+ // Would `pushClassStyle` republish an array byte-identical to the one already standing?
775
+ //
776
+ // Sound because `pushClassStyle` is the ONLY writer of `parts.published` — both routeProp branches
777
+ // and setNodeHidden funnel through it — so a node that has published nothing holds `undefined` and
778
+ // the first write can never be swallowed.
779
+ // What a node publishes when NOTHING resolves — an unstyled node, or the benchmark row's
780
+ // `style={isSelected ? {…} : undefined}` on the 999 rows that are not selected. Length 0 is the
781
+ // marker and needs no second field: `pushClassStyle` never publishes an empty array otherwise, so
782
+ // the state is unambiguous, and it is distinct from `undefined`, which means "nothing published
783
+ // yet" and must never be turned away.
784
+ const PUBLISHED_NOTHING = Object.freeze([]);
785
+ // ── ONE ARRAY PER DISTINCT PAIR, SHARED ACROSS NODES ─────────────────────────────────────────────
786
+ //
787
+ // The array above is per-node and identical for every node styled the same way, which is the normal
788
+ // case for a list: one `StyleSheet.create` object, or one resolved CSS class, across a thousand rows.
789
+ // `mutation-buffer.ts` interns the values it is handed BY IDENTITY, so a thousand distinct-but-equal
790
+ // arrays are a thousand entries and a thousand JS -> `folly::dynamic` conversions on the far side.
791
+ // Measured on `build-release`: 4 000 of 12 005 `setProp` ops refused to fold, and they were exactly
792
+ // these.
793
+ //
794
+ // `WeakMap`, and both levels of it, so nothing here can grow without bound: a caller that builds a
795
+ // fresh style object per render gets a fresh cache entry that dies with the object. That caller also
796
+ // gets no sharing, which is correct — two structurally equal objects are two values to whoever reads
797
+ // them, and a deep compare would make every prop write cost the size of the style.
798
+ //
799
+ // The three-slot (hidden) form is deliberately NOT cached. `display: 'none'` is a state almost no
800
+ // node is ever in, so a third map would be paid for on every write to serve a case that is rare by
801
+ // construction.
802
+ const sharedPairByExplicit = new WeakMap();
803
+ const sharedPairByBase = new WeakMap();
804
+ /**
805
+ * The published array for this pair — the same object every time the same two parts are handed in.
806
+ *
807
+ * `undefined` when the pair cannot be keyed (a primitive half, or the hidden form), and the caller
808
+ * then builds its own array exactly as before. Sharing is an optimization here, never a requirement:
809
+ * every reader of `published` compares its SLOTS by identity, never the array itself.
810
+ */
811
+ function sharedStylePair(base, explicit) {
812
+ const baseIsKeyable = typeof base === 'object' && base !== null;
813
+ const explicitIsKeyable = typeof explicit === 'object' && explicit !== null;
814
+ if (base === undefined && explicitIsKeyable) {
815
+ const cached = sharedPairByExplicit.get(explicit);
816
+ if (cached !== undefined)
817
+ return cached;
818
+ const made = [base, explicit];
819
+ sharedPairByExplicit.set(explicit, made);
820
+ return made;
821
+ }
822
+ if (!baseIsKeyable)
823
+ return undefined;
824
+ if (explicit === undefined) {
825
+ const cached = sharedPairByBase.get(base);
826
+ if (Array.isArray(cached))
827
+ return cached;
828
+ if (cached === undefined) {
829
+ const made = [base, explicit];
830
+ sharedPairByBase.set(base, made);
831
+ return made;
832
+ }
833
+ // A base that has already been seen WITH an explicit half holds the second-level map here, and
834
+ // the base-only array has nowhere to live beside it. Rare enough not to earn a third map.
835
+ return undefined;
836
+ }
837
+ if (!explicitIsKeyable)
838
+ return undefined;
839
+ const existing = sharedPairByBase.get(base);
840
+ const byExplicit = existing instanceof WeakMap ? existing : new WeakMap();
841
+ if (existing === undefined)
842
+ sharedPairByBase.set(base, byExplicit);
843
+ // Same clash as above, the other way round: this base is holding its base-only array. Leave it.
844
+ if (Array.isArray(existing))
845
+ return undefined;
846
+ const cached = byExplicit.get(explicit);
847
+ if (cached !== undefined)
848
+ return cached;
849
+ const made = [base, explicit];
850
+ byExplicit.set(explicit, made);
851
+ return made;
852
+ }
853
+ // A slot that contributes no keys to the payload: absent, or the registry's shared "this class
854
+ // styles nothing" object. An IDENTITY compare rather than a key count — `Object.keys(x).length`
855
+ // allocates an array, and this runs on every class and style write, ~14 000 times on one benchmark
856
+ // create. That is the F-12 shape: an expensive guard in front of cheap work.
857
+ /** A plain style bag — not an array of styles, not a callback, not null. */
858
+ function isStyleRecord(value) {
859
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
860
+ }
861
+ /**
862
+ * Is this rebuilt style the same style, key for key?
863
+ *
864
+ * A component body that writes its style inline hands over a FRESH object every render, equal to
865
+ * the one already standing — the commonest shape any app produces, and one `Object.is` cannot see.
866
+ * Without this the write crosses into the host, becomes a `folly::dynamic`, and is only THEN found
867
+ * to be unchanged. Measured on `build-release` (`no-op-rerender-cost.itest.ts`), 1 000 rows
868
+ * re-rendered with nothing changed: 9.7 ms against 0.3 ms for the same app with its style hoisted,
869
+ * and 5.2 ms of that was the conversion. The cheapest place to refuse a write is the earliest place
870
+ * that can see it is a no-op.
871
+ *
872
+ * SHALLOW AND CONSERVATIVE, both deliberately. A nested value (a transform list, a shadow, a style
873
+ * array) reports "not the same" rather than being compared deeply, because a deep compare makes this
874
+ * guard cost the size of the style — which is the cost it exists to avoid. Those keep crossing and
875
+ * the host's own `diffProps` refuses them exactly as before, so being wrong here is slow, never
876
+ * incorrect.
877
+ *
878
+ * `undefined` on either side also reports "not the same", which is what lets the key COUNT stand in
879
+ * for a key-set comparison: equal counts plus every key of `next` matching a defined value in
880
+ * `standing` cannot leave a key unaccounted for.
881
+ */
882
+ export function isSameShallowStyle(next, standing) {
883
+ if (!isStyleRecord(next) || !isStyleRecord(standing))
884
+ return false;
885
+ const keys = Object.keys(next);
886
+ if (keys.length !== Object.keys(standing).length)
545
887
  return false;
888
+ for (const key of keys) {
889
+ const value = next[key];
890
+ if (value === undefined || isStyleRecord(value) || Array.isArray(value)) {
891
+ return false;
892
+ }
893
+ if (!Object.is(value, standing[key]))
894
+ return false;
895
+ }
896
+ return true;
897
+ }
898
+ function contributesNothing(slot) {
899
+ return slot === undefined || slot === EMPTY_STYLE;
900
+ }
901
+ // Does this node have a style at all? Read through the same two resolvers as the publication, for
902
+ // the reason the guard below states: guard and publication disagreeing is a silent wrong screen.
903
+ function hasNothingToPublish(parts) {
904
+ return (parts.hiddenStyle === undefined &&
905
+ contributesNothing(baseStyleOf(parts)) &&
906
+ contributesNothing(explicitStyleOf(parts)));
907
+ }
908
+ function isAlreadyPublished(parts) {
909
+ const published = parts.published;
910
+ if (published === undefined)
911
+ return false;
912
+ // The delete is already standing. Asked before the slot comparisons because an empty array would
913
+ // otherwise pass both of them on `undefined` and then fail the length check, republishing a
914
+ // delete the host already performed.
915
+ if (published.length === 0)
916
+ return hasNothingToPublish(parts);
546
917
  // `baseStyleOf`, not `parts.classStyle` — the guard and the publication must read slot 0 the
547
918
  // same way or a press is turned away as already-published and silently does nothing on device
548
919
  // while the behavior fires correctly and nothing goes red.
@@ -561,31 +932,49 @@ function pushClassStyle(node, parts) {
561
932
  // an UNCHANGED class still lands as a write AND marks the node dirty. Costs React / Vue / Svelte
562
933
  // nothing — each diffs props before calling the engine — but Solid has no diff: a fine-grained
563
934
  // effect re-runs whenever any signal it reads changes, so a list-wide signal makes every row
564
- // re-push its own unchanged class. Measured on device 2026-08-23 (examples/solid, after
565
- // host-primitive lowering): selecting one row of 1 000 read WRITES 1001 and a 10.3 ms reconcile
935
+ // re-push its own unchanged class. Measured on device 2026-08-23 (examples/solid, once its
936
+ // primitives were tags): selecting one row of 1 000 read WRITES 1001 and a 10.3 ms reconcile
566
937
  // window against Fabric's unmoved 0/0/10 — a thousand-node dirty walk for two nodes of change.
567
- // Before lowering, the View component's splitProps/mergeProps memos had been absorbing it.
568
938
  //
569
- // This is NOT the naive skip the paragraph above forbids, and the array check is the difference.
570
- // Skipping on "the parts are unchanged" alone would break the restore path, because
571
- // setNativeProps writes node.props.style directly and a hoisted style constant would then never
572
- // be restored. But setNativeProps writes an OBJECT (commit.ts: `{...flattenStyle(prev),
573
- // ...flattenStyle(value)}`), never an array — so after any bypass isAlreadyPublished is false,
574
- // the re-push happens exactly as before, and the restore path is untouched.
939
+ // This is NOT the naive skip the paragraph above forbids, and the published marker is the
940
+ // difference. Skipping on "the parts are unchanged" alone would break the restore path, because
941
+ // setNativeProps writes the style slot past this function and a hoisted style constant would then
942
+ // never be restored. But setNativeProps CLEARS `parts.published` — so after any bypass
943
+ // isAlreadyPublished is false, the re-push happens exactly as before, and the restore path is
944
+ // untouched.
575
945
  //
576
946
  // Exact rather than approximate: resolveClassName memoizes a class STRING to the same object, so
577
947
  // an unchanged class yields an identity-equal classStyle. It deliberately does not fire for an
578
- // object/array class value, which resolves fresh every call — the same place setProp's Object.is
579
- // already gives up on a style object, so no new asymmetry appears.
580
- if (isAlreadyPublished(node, parts))
948
+ // object/array class value, which resolves fresh every call — the same place the host's own
949
+ // Object.is guard gives up on a style object, so no new asymmetry appears.
950
+ if (isAlreadyPublished(parts))
581
951
  return;
952
+ // NOTHING RESOLVED, so say nothing. The buffer spells an absent prop as `NO_VALUE` and the host
953
+ // then takes a path that costs it literally one branch — `if (props.get_ptr(key) == nullptr)
954
+ // break` — while `[undefined, undefined]` is a real value it must convert into a `folly::dynamic`
955
+ // array, store, and re-compare on every later commit. Both are behaviourally "no style": the
956
+ // payload builder flattens the pair of undefineds into no keys at all.
957
+ //
958
+ // This is NOT the naive skip the note above forbids, and the distinction is the same one the
959
+ // published marker makes. A restore after `setNativeProps` arrives here with `published` cleared
960
+ // to `undefined`, so it is never turned away — and when the authored style is nothing, restoring
961
+ // it means DELETING the slot the imperative write put there, which is what this emits.
962
+ if (hasNothingToPublish(parts)) {
963
+ parts.published = PUBLISHED_NOTHING;
964
+ setProp(node, 'style', undefined);
965
+ return;
966
+ }
582
967
  // The third slot is APPENDED ONLY WHILE HIDDEN. Writing a permanent three-element array would
583
968
  // change the style payload of every node in every app for a state almost none of them are ever
584
969
  // in — and this project spent a day removing per-frame allocations, so a slot that is undefined
585
970
  // 99.9% of the time does not get to ride along on every style write.
586
- setProp(node, 'style', parts.hiddenStyle === undefined
587
- ? [baseStyleOf(parts), explicitStyleOf(parts)]
588
- : [baseStyleOf(parts), explicitStyleOf(parts), parts.hiddenStyle]);
971
+ const base = baseStyleOf(parts);
972
+ const explicit = explicitStyleOf(parts);
973
+ const published = parts.hiddenStyle === undefined
974
+ ? (sharedStylePair(base, explicit) ?? [base, explicit])
975
+ : [base, explicit, parts.hiddenStyle];
976
+ parts.published = published;
977
+ setProp(node, 'style', published);
589
978
  }
590
979
  // `display: 'none'` is a real RN style value (Yoga's DisplayNone), so a hidden node keeps its
591
980
  // place in the tree, its state and its children — it just stops laying out and painting.
@@ -620,6 +1009,35 @@ export function setNodePressed(node, pressed) {
620
1009
  parts.isPressed = pressed;
621
1010
  pushClassStyle(node, parts);
622
1011
  }
1012
+ /**
1013
+ * Tell the host a behavior's FEEDBACK is showing — TouchableHighlight's underlay, and only that.
1014
+ *
1015
+ * The sibling of `setNodePressed` and deliberately NOT the same bit. Press state drives `:active`
1016
+ * class resolution, which happens in JS because a class name resolves against a JS registry; this
1017
+ * drives a rule that lives in C++ (`foldTouchableHighlightUnderlay`), so it crosses as one op rather
1018
+ * than resolving to a style here. And the two are genuinely different facts: RN holds the underlay
1019
+ * past release so a fast tap still flashes, so `shown` LAGS `pressed` by a `delayPressOut` timer.
1020
+ *
1021
+ * No style is computed on this side at all, which is the whole point — the two props the rule reads
1022
+ * (`underlayColor`, `activeOpacity`) are ones the engine already strips from the payload, so the
1023
+ * values and their defaults live in one place instead of being erased in C++ and reached around for
1024
+ * in JS.
1025
+ */
1026
+ export function setNodeUnderlayShown(node, shown) {
1027
+ recordSetUnderlayShown(node, shown);
1028
+ }
1029
+ /**
1030
+ * Forget what was last published, so the next `pushClassStyle` cannot be turned away.
1031
+ *
1032
+ * The one caller is `setNativeProps` (imperative.ts), which writes the style slot past this file —
1033
+ * see the note on `IClassStyleParts.published` for why the restore path depends on this. A no-op for
1034
+ * a node nobody has styled, which is why it is not `stylePartsOf(node).published = undefined`: that
1035
+ * would allocate the parts on a node that has none.
1036
+ */
1037
+ export function clearPublishedStyle(node) {
1038
+ if (node.styleParts !== undefined)
1039
+ node.styleParts.published = undefined;
1040
+ }
623
1041
  // The explicit (non-class-derived) style half, for an adapter that builds its style prop up
624
1042
  // key-by-key (Angular's Ivy ɵɵstyleProp/setStyle) instead of handing over one whole object —
625
1043
  // it must merge onto this, not onto node.props.style directly, which may be the
@@ -627,20 +1045,129 @@ export function setNodePressed(node, pressed) {
627
1045
  export function getExplicitStyle(node) {
628
1046
  return node.styleParts?.explicitStyle;
629
1047
  }
1048
+ /**
1049
+ * The `[classStyle, explicitStyle]` pair the node currently PUBLISHES — the same value
1050
+ * `commitClassStyle` writes, in the same order, so `flattenStyle` collapses it the way Fabric will.
1051
+ *
1052
+ * For a caller that wants the merged answer without a host: the pair reaches the payload as an op,
1053
+ * and only a host holds ops. A test that has not installed one — `core/css-parser` reaches into the
1054
+ * engine by relative path and depends on neither package — can read it here instead of reaching
1055
+ * into `styleParts`, which is engine-owned and not a shape anything outside may bind to.
1056
+ */
1057
+ export function getPublishedStyle(node) {
1058
+ const parts = node.styleParts;
1059
+ if (parts === undefined)
1060
+ return [];
1061
+ return [parts.classStyle, parts.explicitStyle];
1062
+ }
630
1063
  const CLASS_PROP_KEYS = new Set(['class', 'className']);
631
1064
  // The flat-bag split (React / Vue / Solid): an `onX` prop becomes an event listener
632
1065
  // ONLY when the node's component actually declares `x` as an event (per the shared
633
1066
  // ViewConfig). Otherwise it is a plain prop, so `onTintColor` on a Switch, whose
634
1067
  // only event is `change`, routes to setProp and reaches Fabric.
1068
+ // `id` is RN's W3C-named alias for `nativeID` and it WINS when both are set
1069
+ // (`View.js:77-79` — `processedProps.nativeID = id`). A raw `id` is declared by no ViewConfig, so
1070
+ // Fabric drops it in SILENCE: a rename that half-works loses the nativeID with nothing red anywhere.
1071
+ const ID_ALIAS_FROM = 'id';
1072
+ const ID_ALIAS_TO = 'nativeID';
1073
+ /**
1074
+ * The ONE place this rename happens, as of 2026-09-18. It used to happen in SEVEN.
1075
+ *
1076
+ * `foldHostBag` did it for React, Svelte and Angular off `HOST_PRIMITIVES[*].aliases` (seventeen
1077
+ * identical entries); Vue's `patchProp`, Solid's renderer and Angular's own `PROP_ALIASES` each did
1078
+ * it again per key; and `foldIdAlias` did it a seventh time in C++. Three different coverage sets,
1079
+ * so the answer depended on which adapter you were on and whether the node's tag happened to carry a
1080
+ * registered behavior — `core/engine/cpp/tests/js/id-alias-coverage.itest.ts` measured that split
1081
+ * before this collapsed it.
1082
+ *
1083
+ * HERE because this is the funnel: every adapter's prop write ends at `routeProp`, whatever shape it
1084
+ * starts in. A bag fold cannot serve the per-key renderers and a per-key fold cannot serve the bag
1085
+ * ones; the seam they SHARE can serve both.
1086
+ *
1087
+ * PRECEDENCE IS WHY THIS NEEDS STATE. Upstream reads both props at once, so `id ?? nativeID` is
1088
+ * decided in one expression. A per-key writer never sees both, so precedence would otherwise fall
1089
+ * out of write ORDER — `<view id nativeID>` keeping the stale legacy value while `<view nativeID id>`
1090
+ * did not. Solid had already built exactly this memory for exactly this reason; it is one copy now.
1091
+ *
1092
+ * The authored `nativeID` is REMEMBERED rather than discarded, so clearing the `id` hands the slot
1093
+ * back instead of latching. A framework that unsets a prop between renders must get the other
1094
+ * source back.
1095
+ *
1096
+ * PRECEDENCE ITSELF IS PER-COMPONENT. `View.js`'s `id ?? nativeID` is the default (`idWins` below),
1097
+ * but `TouchableWithoutFeedback.js`'s clone composes that and then runs a `PASSTHROUGH_PROPS` loop
1098
+ * that unconditionally overwrites `nativeID` with the raw authored value when set (`:279-282`) — an
1099
+ * app authoring both ends up with `nativeID` winning there. `node.nativeIdWinsOverId`, set by
1100
+ * `attachHostBehavior` for the one behavior that declares it, flips which side wins.
1101
+ */
1102
+ const idAliased = new WeakMap();
1103
+ function routeIdAlias(node, key, value) {
1104
+ const state = idAliased.get(node) ?? {
1105
+ idValue: undefined,
1106
+ nativeIdValue: undefined,
1107
+ };
1108
+ if (key === ID_ALIAS_FROM)
1109
+ state.idValue = value;
1110
+ else
1111
+ state.nativeIdValue = value;
1112
+ idAliased.set(node, state);
1113
+ const published = node.nativeIdWinsOverId
1114
+ ? (state.nativeIdValue ?? state.idValue)
1115
+ : (state.idValue ?? state.nativeIdValue);
1116
+ setProp(node, ID_ALIAS_TO, published);
1117
+ }
635
1118
  export function routeProp(node, key, value) {
636
1119
  if (REACT_JSX_DEV_PROPS.has(key))
637
1120
  return;
1121
+ // The prop twin of the child redirect in `appendChild`. A composed primitive's owner is written
1122
+ // with props that belong to its internal slot — `contentContainerStyle` on a ScrollView styles
1123
+ // the content view — and the adapter names the OWNER for a prop for the same reason it names the
1124
+ // owner for a child: that is where the app wrote it.
1125
+ //
1126
+ // Gated on the FIELD, so a node with no slot pays one load and one branch and never touches the
1127
+ // registry. The redirected write recurses into the slot's own `routeProp`, which is single-hop
1128
+ // by construction: a slot has no slot of its own (`childHost` is documented single-hop, and
1129
+ // `buildStructure` is what would have to nest one).
1130
+ if (node.childHost !== undefined) {
1131
+ const slotKey = slotPropNameFor(node, key);
1132
+ if (slotKey !== undefined) {
1133
+ // A class NAME is a legal spelling of `contentContainerStyle` — every canary writes
1134
+ // `contentContainerStyle="scroll-content"` — so a string has to land on the slot as a
1135
+ // CLASS. Only the class branch consults the registry; renaming it verbatim would publish a
1136
+ // `style` holding a string, which is not a style and is dropped with nothing red. React's
1137
+ // wrapper resolves the name itself (components/scroll-view/shared.ts), so this gap could
1138
+ // only ever show on the tag path.
1139
+ routeProp(node.childHost, slotKey === 'style' && typeof value === 'string' ? 'class' : slotKey, value);
1140
+ return;
1141
+ }
1142
+ }
1143
+ // AFTER the slot redirect, and that order is load-bearing rather than tidy. A composed primitive
1144
+ // forwards most of its bag to an internal node — ImageBackground spreads everything but `style`
1145
+ // onto its image, exactly as RN does — so an `id` written on the OWNER belongs to the node the
1146
+ // redirect sends it to. Resolved before the redirect, the alias would publish a `nativeID` on the
1147
+ // wrapper and the inner node would never see it: the owner would answer to a testID the app
1148
+ // pointed at the image.
1149
+ if (key === ID_ALIAS_FROM || key === ID_ALIAS_TO) {
1150
+ routeIdAlias(node, key, value);
1151
+ return;
1152
+ }
1153
+ // An AnimatedNode written straight into a prop — `<view style={{opacity: value}}/>` — is
1154
+ // resolved here into the value to PUBLISH, with the engine holding the subscription. Same
1155
+ // shape as the `style` callback below: a value the engine interprets rather than forwards.
1156
+ // Returns its input by identity when nothing is animated, so every branch under this line is
1157
+ // unchanged. See `animated/host-binding.ts`; the gate is one boolean for an app that animates
1158
+ // nothing.
1159
+ //
1160
+ // AFTER the slot redirect, so an animated `contentContainerStyle` binds on the node that
1161
+ // actually carries the style.
1162
+ const resolved = hasAnimatedNodes()
1163
+ ? bindAnimatedValue(node, key, value)
1164
+ : value;
638
1165
  if (CLASS_PROP_KEYS.has(key)) {
639
1166
  const parts = stylePartsOf(node);
640
1167
  // Canonicalised HERE so the stored value is what everything downstream keys on: an all-string
641
1168
  // array becomes one string, and then the pressed variant and isAlreadyPublished work on it
642
1169
  // exactly as on an authored string. One `typeof` for the common case.
643
- parts.className = canonicalClassName(isClassNameValue(value) ? value : undefined);
1170
+ parts.className = canonicalClassName(isClassNameValue(resolved) ? resolved : undefined);
644
1171
  parts.classStyle = resolveClassName(parts.className);
645
1172
  pushClassStyle(node, parts);
646
1173
  return;
@@ -648,11 +1175,9 @@ export function routeProp(node, key, value) {
648
1175
  if (key === 'style') {
649
1176
  const parts = stylePartsOf(node);
650
1177
  // A FUNCTION `style` is `style={({pressed}) => …}`, the idiom this ecosystem actually writes.
651
- // A lowering transform normally splits it at build time into `style` + `activeStyle`, so the
652
- // engine never sees the callback — but a PUBLIC primitive tag has no transform in front of it
653
- // on three adapters, and there the callback arrives here intact. Resolving it makes the
654
- // compile-time split an OPTIMIZATION rather than the mechanism, the same relationship
655
- // `foldHostBag` has with the compile-time prop folds.
1178
+ // Nothing stands between an app and the tag, so the callback arrives here intact and is
1179
+ // resolved at both states — writing `style` + `activeStyle` as an explicit pair is the same
1180
+ // thing said by hand, and cheaper by one call per recompute.
656
1181
  //
657
1182
  // Without this the failure is silent and total: a function is not an `on*` name, so it misses
658
1183
  // `setEventListener`, lands in `setProp` as a function value, and `fabricProps` drops function
@@ -660,17 +1185,31 @@ export function routeProp(node, key, value) {
660
1185
  //
661
1186
  // The callback must be PURE in `pressed`: its result is read once per state, here and under
662
1187
  // every transform's emission (`core/components/src/state-style.ts` carries the same contract).
663
- if (isStyleCallback(value)) {
664
- parts.explicitStyle = value({ pressed: false });
665
- parts.activeStyle = value({ pressed: true });
1188
+ if (isStyleCallback(resolved)) {
1189
+ parts.explicitStyle = resolved({ pressed: false });
1190
+ parts.activeStyle = resolved({ pressed: true });
666
1191
  parts.activeStyleFromCallback = true;
667
1192
  }
668
1193
  else {
669
- parts.explicitStyle = value;
1194
+ // A REBUILT LITERAL EQUAL TO WHAT IS STANDING IS NOT A CHANGE — see `isSameShallowStyle`.
1195
+ //
1196
+ // Gated on something being PUBLISHED, which is what keeps the restore path intact: a
1197
+ // `setNativeProps` write bypasses the parts and clears `parts.published`, and after that this
1198
+ // must never turn a write away — the re-push IS the restore. Same mechanism `isAlreadyPublished`
1199
+ // relies on, and the same reason.
1200
+ //
1201
+ // Gated on the previous write NOT having come from a callback, because that one owns
1202
+ // `parts.activeStyle` and the branch below has to clear it. Returning early would leave the old
1203
+ // pressed look standing under a plain style.
1204
+ if (parts.published !== undefined &&
1205
+ !parts.activeStyleFromCallback &&
1206
+ isSameShallowStyle(resolved, parts.explicitStyle)) {
1207
+ return;
1208
+ }
1209
+ parts.explicitStyle = resolved;
670
1210
  // Only a variant WE derived is stale now. `style` switching from a callback to a plain value
671
- // must not leave the old pressed look standing, and an `activeStyle` the transform wrote must
672
- // survive a `style` write, because the two arrive as independent props in an unspecified
673
- // order.
1211
+ // must not leave the old pressed look standing, and an AUTHORED `activeStyle` must survive a
1212
+ // `style` write, because the two arrive as independent props in an unspecified order.
674
1213
  if (parts.activeStyleFromCallback) {
675
1214
  parts.activeStyle = undefined;
676
1215
  parts.activeStyleFromCallback = false;
@@ -683,18 +1222,45 @@ export function routeProp(node, key, value) {
683
1222
  // in the app carries an unknown key to native.
684
1223
  if (key === 'activeStyle') {
685
1224
  const parts = stylePartsOf(node);
686
- parts.activeStyle = value;
1225
+ parts.activeStyle = resolved;
687
1226
  // Slot 1 is no longer ours, by definition — whatever a callback derived earlier has just been
688
1227
  // replaced. Without this the flag outlives the value it describes: a callback sets it, this
689
1228
  // branch overwrites the slot silently, and a later plain `style` then clears a variant the
690
- // engine never derived. Not reachable from a lowering transform (it emits either a callback or
691
- // an explicit pair, never both for one node), but a flat-bag adapter routes a bag key by key
692
- // and can deliver exactly that sequence.
1229
+ // engine never derived. An author writes either a callback or an explicit pair, never both for
1230
+ // one node — but a flat-bag adapter routes a bag key by key and can deliver that sequence.
693
1231
  parts.activeStyleFromCallback = false;
694
1232
  pushClassStyle(node, parts);
695
1233
  return;
696
1234
  }
697
- if (ON_PREFIX.test(key)) {
1235
+ // RN's snapshot affordance (`Pressable.js:222` seeds `usePressState` with it): render the control
1236
+ // pressed with no gesture. It selects `activeStyle` and any `:active` class, which is exactly what
1237
+ // `isPressed` already decides — so it belongs beside `activeStyle` rather than in a behavior.
1238
+ //
1239
+ // HERE RATHER THAN IN `attachAfterCommit`, and the census caught the difference. A behavior hook
1240
+ // reading this prop costs a post-commit WAITER on every pressable in the app — a real boundary
1241
+ // crossing per node, which `adapters/solid/src/crossing-and-payload-census.probe.test.tsx` budgets
1242
+ // at two and which went to six. This branch crosses nothing: it is one string compare on a write
1243
+ // that already reached the tail of `routeProp`, and it lands on the FIRST commit rather than the
1244
+ // second. A JS compare is not a crossing, and weighing it as one is what sent the first attempt to
1245
+ // the wrong seam.
1246
+ //
1247
+ // TouchableHighlight's half of the same prop is NOT here: it PAINTS an underlay, so it is a rule
1248
+ // in `SymbioteFabricProps.cpp`. Two mechanisms, one prop name, because that is what upstream has.
1249
+ // A SIDE EFFECT AND A PASSTHROUGH, not a consume, and the difference is load-bearing in both
1250
+ // directions. The pressed state is set here; the prop ALSO goes on to `node.props`, because
1251
+ // TouchableHighlight's rule reads it off the authored bag to paint its underlay
1252
+ // (`foldTouchableHighlightUnderlay`). Returning early — the first spelling — left that rule blind
1253
+ // and turned three of its cases red. Keeping it out of the PAYLOAD is a separate job and already
1254
+ // done, by `kPressableMachineKeys` in `SymbioteFabricProps.cpp`.
1255
+ //
1256
+ // The same shape `GATED_EVENT_PROPS` uses above: act, then let the write continue.
1257
+ if (key === 'testOnly_pressed')
1258
+ setNodePressed(node, resolved === true);
1259
+ if (isOnEventName(key)) {
1260
+ // A native-driven `Animated.event` needs the native module as well as the listener map, and
1261
+ // registers under the PROP name — see `bindAnimatedEvent`, which no-ops for anything else.
1262
+ if (hasAnimatedNodes())
1263
+ bindAnimatedEvent(node, key, resolved);
698
1264
  const name = listenerName(key);
699
1265
  const isRegisteredEvent = RESPONDER_EVENTS.has(name) || isEventFor(node.component, name);
700
1266
  // Investigation instrumentation (HeaderOptionsScreen unresponsive-buttons bug): RNS* views
@@ -707,106 +1273,247 @@ export function routeProp(node, key, value) {
707
1273
  `registered=${isRegisteredEvent} at t=${Date.now()}`);
708
1274
  }
709
1275
  if (isRegisteredEvent) {
710
- setEventListener(node, name, value);
1276
+ setEventListener(node, name, resolved);
711
1277
  return;
712
1278
  }
713
1279
  }
714
- setProp(node, key, value);
1280
+ setProp(node, key, resolved);
715
1281
  }
716
- // The same no-op guard as setProp, and here it is strictly stronger: `text` is a string, so
717
- // `Object.is` is a real value comparison rather than the reference check it degrades to for a style
718
- // object or a handler. A framework that re-renders a subtree and hands back an unchanged label -
719
- // every list row whose text did not move, on every update - stops stripping its ancestors of the
720
- // commit walk's early exit. Counted in the same propStats, because a text write IS a prop write:
721
- // it lands in node.props.text and reaches Fabric as RCTRawText's only prop.
1282
+ // Counted in the same propStats, because a text write IS a prop write: it reaches Fabric as
1283
+ // RCTRawText's only prop.
722
1284
  //
723
- // Safe against the one ordering hazard worth naming: a raw-text node REPARENTED under a <Text>
724
- // commits as RCTVirtualText instead of RCTText (viewNameFor, commit.ts). That flip is not driven
725
- // from here - a structural op marks the parent chain, and reconcile re-checks `committed.parent` on
726
- // its early-exit path - so a same-text write not marking cannot hide it.
1285
+ // Unguarded, unlike the old version, and for the same reason `setProp` is: comparing against the
1286
+ // standing text would mean reading it back from the host. The host holds it as a local field and
1287
+ // dedupes there. Two consequences it also absorbs, both of which used to be spelled here — a write
1288
+ // to or from `''` takes this node out of its parent's renderable child list or puts it back, and a
1289
+ // raw text REPARENTED under a `<Text>` commits as RCTVirtualText instead of RCTText.
727
1290
  export function setText(node, text) {
728
- if (Object.is(node.props.text, text)) {
729
- propStats.noops += 1;
730
- return;
731
- }
732
- node.props.text = text;
733
1291
  propStats.writes += 1;
734
- markPropsDirty(node);
1292
+ recordSetText(node, text);
735
1293
  }
736
- // Structural ops mark the PARENT chain (both the old and the new one), never the moved child:
737
- // a child that only changed position may legitimately still be clean, and reconcile re-checks
738
- // `committed.parent` on its early-exit path, so a reparent is caught there rather than by a flag.
1294
+ // The structural ops. Each is one op and nothing else: the host detaches the child from whatever
1295
+ // parent it currently has before linking it, which is the truth even when an adapter names a stale
1296
+ // one — frameworks spell a MOVE as remove-then-insert and can arrive after the insert already
1297
+ // re-parented the node. That is why there is no `detach` here any more; JS does not know the old
1298
+ // parent and does not need to.
739
1299
  //
740
- // Each marks BEFORE touching `parent.children`, never after: the committed record may be aliasing
741
- // that array, and markStructureDirty is what copies it out of the way. See there.
742
- function detach(child) {
743
- const parent = child.parent;
744
- if (!parent)
745
- return;
746
- markStructureDirty(parent);
747
- const index = parent.children.indexOf(child);
748
- if (index >= 0)
749
- parent.children.splice(index, 1);
750
- child.parent = undefined;
1300
+ // What they DO still decide in JS is which node an op names, and there are two such redirects — a
1301
+ // composed primitive's slot and a wrap claim. Both are read off a field on the node, so a tree with
1302
+ // neither pays one load and one branch per op.
1303
+ // The host's raw answer, surface INCLUDED — unlike `parentOf` (host-access.ts), which reports a
1304
+ // top-level node as parentless by design. The two swaps below have to NAME the holder in an op, and
1305
+ // for a wrapped node sitting directly under a surface that holder is the surface node.
1306
+ function holderOf(node) {
1307
+ flushOps();
1308
+ const parent = treeHost()?.parentOf(node);
1309
+ return isSymbioteNode(parent) ? parent : undefined;
1310
+ }
1311
+ // Which node a child actually lands on. See `ISymbioteNode.childHost`: the adapter always names the
1312
+ // OWNER, and a node whose behavior built an internal subtree redirects the app's children into it —
1313
+ // unless the behavior CLAIMS this particular child, which keeps it on the owner (`claimedChildren`).
1314
+ //
1315
+ // SINGLE HOP, not a loop, and the field's own comment says why — a chain would put a walk on the
1316
+ // engine's hottest path to express a depth no primitive has. A behavior needing depth points
1317
+ // `childHost` at the innermost node itself.
1318
+ //
1319
+ // Reads a field that is `undefined` on every node in every app that registers no composed
1320
+ // primitive, so the cost is one load and one branch — deliberately NOT behind `hasHostBehaviors()`,
1321
+ // which would be a second read to save nothing. The claim check sits BEHIND that branch, so only a
1322
+ // slot-bearing node ever pays the registry probe.
1323
+ function hostFor(parent, child) {
1324
+ const slot = parent.childHost;
1325
+ if (slot === undefined)
1326
+ return parent;
1327
+ // A slot that is a built SIBLING rather than a container — ImageBackground's absolutely-filled
1328
+ // image — keeps the app's children on the owner. See `IHostBehavior.slotTakesNoChildren`.
1329
+ if (!slotTakesChildren(parent))
1330
+ return parent;
1331
+ return claimModeFor(parent, child.component) === undefined ? slot : parent;
1332
+ }
1333
+ // The node a child must be inserted BEFORE, or `undefined` for an ordinary append.
1334
+ //
1335
+ // A host that STILL has a slot at this point is an owner taking a CLAIMED child, and that child
1336
+ // goes before the slot whatever the framework asked for. RN renders `{refreshControl}{content}` in
1337
+ // that order, and the node a framework names as `beforeChild` lives inside the slot, so the host
1338
+ // could not place against it here anyway.
1339
+ //
1340
+ // A SIBLING slot is the opposite placement: RN paints the background image first and the app's
1341
+ // children over it (ImageBackground.js:80-102), so they append past it rather than in front of it —
1342
+ // which is what `undefined` here leaves alone.
1343
+ function slotAnchorOf(host) {
1344
+ const slot = host.childHost;
1345
+ if (slot === undefined || !slotTakesChildren(host))
1346
+ return undefined;
1347
+ return slot;
1348
+ }
1349
+ // ── the two structural recorders, and why nothing here calls the raw ones ───────────────────────
1350
+ //
1351
+ // `mayHaveChildren` is only sound if EVERY op that gives a node a child raises it. There are five
1352
+ // such call sites in this file and a sixth is a plausible future edit, so the bit is raised here
1353
+ // rather than at each of them: a site that forgets would make `childrenOf` answer "empty" for a node
1354
+ // that has children, which is a wrong ANSWER rather than a slow one. `node.ts` is the only module
1355
+ // that records a structural op, so these two are a complete funnel.
1356
+ /**
1357
+ * Arm a parent's recurring post-commit hook for a STRUCTURAL change.
1358
+ *
1359
+ * A prop write is not the only thing a behavior can be waiting for, and the ScrollView sticky-header
1360
+ * machine is the case that proves it: it drops a wrapper when the framework takes the wrapped child
1361
+ * away, which writes no prop on the wrapper's owner at all. Narrowing the beat to prop writes alone
1362
+ * left it holding a wrapper around nothing, and its own test said so — the third behavior needing
1363
+ * the beat, and the only one whose source comment does not say why.
1364
+ */
1365
+ function armCommitHookForChildChange(parent) {
1366
+ if (parent.hasCommitHook)
1367
+ noteCommitHookNodeChanged(parent);
1368
+ }
1369
+ function recordAppendInto(parent, child) {
1370
+ parent.mayHaveChildren = true;
1371
+ armCommitHookForChildChange(parent);
1372
+ recordAppendChild(parent, child);
1373
+ }
1374
+ function recordInsertInto(parent, child, beforeChild) {
1375
+ parent.mayHaveChildren = true;
1376
+ armCommitHookForChildChange(parent);
1377
+ recordInsertBefore(parent, child, beforeChild);
1378
+ }
1379
+ // What actually occupies this node's place in its parent's child list. See `ISymbioteNode.wrapper`:
1380
+ // a wrapped owner is what the adapter names and the wrapper is what the tree holds, so every
1381
+ // structural op takes the owner and moves the wrapper.
1382
+ function placedNode(node) {
1383
+ return node.wrapper ?? node;
751
1384
  }
752
- export function appendChild(parent, child) {
1385
+ // Make `child` the owner's parent, in place. Returns false when this is not a wrap claim, so the
1386
+ // two inserts fall through to the ordinary path on one call.
1387
+ //
1388
+ // The owner being UNATTACHED is the normal case rather than the edge one: every adapter fills a
1389
+ // node's children before appending it to its own parent, so the wrap usually happens while the
1390
+ // owner has no holder and only the second op runs. The later `appendChild(root, owner)` then
1391
+ // inserts the wrapper instead, because `placedNode` says so.
1392
+ function wrapsOwner(owner, child) {
1393
+ if (owner.childHost === undefined)
1394
+ return false;
1395
+ if (claimModeFor(owner, child.component) !== 'wrap')
1396
+ return false;
1397
+ if (hasHostBehaviors())
1398
+ reattachHostBehaviors(child);
1399
+ if (hasAnimatedBindings())
1400
+ reattachAnimatedProps(child);
1401
+ const holder = holderOf(owner);
1402
+ // Wrapper takes the owner's place first, then the owner moves under it — the host's own detach
1403
+ // on link is what unlinks the owner from `holder`, so no removal op is needed.
1404
+ if (holder !== undefined)
1405
+ recordInsertInto(holder, child, owner);
1406
+ owner.wrapper = child;
1407
+ recordAppendInto(child, owner);
1408
+ return true;
1409
+ }
1410
+ // Put the owner back where its wrapper stood — the mirror of `wrapsOwner`. It must leave the owner
1411
+ // ATTACHED: the framework is removing the RefreshControl, not the ScrollView.
1412
+ function unwrapsOwner(owner, child) {
1413
+ if (owner.wrapper !== child)
1414
+ return false;
1415
+ owner.wrapper = undefined;
1416
+ const holder = holderOf(child);
1417
+ if (holder === undefined) {
1418
+ // The wrapper never reached a parent, so there is no place to take back — the owner simply
1419
+ // stops hanging off it.
1420
+ recordRemoveChild(child, owner);
1421
+ }
1422
+ else {
1423
+ recordInsertInto(holder, owner, child);
1424
+ recordRemoveChild(holder, child);
1425
+ }
1426
+ return true;
1427
+ }
1428
+ export function appendChild(requestedParent, child) {
1429
+ if (wrapsOwner(requestedParent, child))
1430
+ return;
1431
+ const parent = hostFor(requestedParent, child);
753
1432
  // A node the sweep tore down can be put back — Svelte parks live subtrees offscreen across
754
1433
  // commits. A WeakSet miss for anything freshly built, so the create path pays nothing.
755
1434
  if (hasHostBehaviors())
756
1435
  reattachHostBehaviors(child);
757
- detach(child);
758
- markStructureDirty(parent);
759
- child.parent = parent;
760
- parent.children.push(child);
1436
+ if (hasAnimatedBindings())
1437
+ reattachAnimatedProps(child);
1438
+ const placed = placedNode(child);
1439
+ const anchor = slotAnchorOf(parent);
1440
+ if (anchor === undefined)
1441
+ recordAppendInto(parent, placed);
1442
+ else
1443
+ recordInsertInto(parent, placed, anchor);
1444
+ if (hasHostBehaviors())
1445
+ notifyChildInserted(parent, placed);
761
1446
  }
762
- export function insertBefore(parent, child, beforeChild) {
1447
+ // NO ANCHOR MEANS APPEND, and it is a real call rather than a defensive guard: solid-js/universal
1448
+ // spells "insert at the end" as `insertNode(parent, node, null)` and Vue's runtime-core passes
1449
+ // `anchor` straight through as `null`. The old retained tree collapsed it silently — `indexOf(null)`
1450
+ // is -1, and the insert fell through to a push. On the wire it cannot: a slot has to name a node, so
1451
+ // an unanchored insert IS an append and is recorded as one.
1452
+ export function insertBefore(requestedParent, child, beforeChild) {
1453
+ if (wrapsOwner(requestedParent, child))
1454
+ return;
1455
+ const parent = hostFor(requestedParent, child);
763
1456
  if (hasHostBehaviors())
764
1457
  reattachHostBehaviors(child);
765
- detach(child);
766
- markStructureDirty(parent);
767
- child.parent = parent;
768
- const index = parent.children.indexOf(beforeChild);
769
- parent.children.splice(index < 0 ? parent.children.length : index, 0, child);
1458
+ if (hasAnimatedBindings())
1459
+ reattachAnimatedProps(child);
1460
+ const placed = placedNode(child);
1461
+ const anchor = slotAnchorOf(parent) ??
1462
+ (beforeChild === null || beforeChild === undefined
1463
+ ? undefined
1464
+ : placedNode(beforeChild));
1465
+ if (anchor === undefined)
1466
+ recordAppendInto(parent, placed);
1467
+ else
1468
+ recordInsertInto(parent, placed, anchor);
1469
+ if (hasHostBehaviors())
1470
+ notifyChildInserted(parent, placed);
770
1471
  }
771
1472
  // Removal only NOMINATES a behavior for teardown; the commit sweep decides. A framework may spell
772
1473
  // a move as remove-then-reinsert (Solid does), so tearing down here kills the machine of a node
773
1474
  // that comes back alive in the same batch — see host-behavior.ts's markDetachCandidate.
774
- export function removeChild(parent, child) {
775
- if (hasHostBehaviors())
1475
+ export function removeChild(requestedParent, child) {
1476
+ // A wrap claim leaving: the owner takes its own place back and stays in the tree. Nominated for
1477
+ // teardown like any other removed node, because the wrapper IS leaving.
1478
+ if (unwrapsOwner(requestedParent, child)) {
1479
+ if (hasAttachedBehaviors() || hasAnimatedBindings())
1480
+ markDetachCandidate(child);
1481
+ return;
1482
+ }
1483
+ // A slot that IS the child being removed stops being one. Only a behavior that adopts an APP
1484
+ // child as its slot can reach this (`onChildInserted`); a `buildStructure` slot is internal and
1485
+ // no framework removes it. Without the clear, `hostFor` below redirects the removal INTO the very
1486
+ // node being removed, and the child stays committed under a parent the framework believes it
1487
+ // left — and the NEXT child appended nests inside the orphan.
1488
+ if (requestedParent.childHost === child)
1489
+ requestedParent.childHost = undefined;
1490
+ // Redirected for the same reason the two inserts are: the adapter removes from the node it
1491
+ // appended to, which is the OWNER, while the child actually lives in the slot.
1492
+ const parent = hostFor(requestedParent, child);
1493
+ // `hasAttachedBehaviors`, NOT `hasHostBehaviors`: the second is on from module load in every app,
1494
+ // because registering `Pressable` as a TYPE arms it. Nominating a candidate makes the commit sweep
1495
+ // cross every removed node into JS — 10 000 handles on a 1 000-row clear, measured at 3.2x the
1496
+ // whole teardown — and none of it can matter before a behavior has actually attached to something.
1497
+ if (hasAttachedBehaviors() || hasAnimatedBindings())
776
1498
  markDetachCandidate(child);
777
- markStructureDirty(parent);
778
- const index = parent.children.indexOf(child);
779
- if (index >= 0)
780
- parent.children.splice(index, 1);
781
- child.parent = undefined;
1499
+ // BOTH, and the owner is the one that matters: a composed primitive's behavior lives on the node
1500
+ // the adapter named, while `hostFor` redirects the mutation into its internal slot. Arming only
1501
+ // the slot arms a node that has no behavior at all.
1502
+ armCommitHookForChildChange(requestedParent);
1503
+ armCommitHookForChildChange(parent);
1504
+ recordRemoveChild(parent, placedNode(child));
782
1505
  }
1506
+ /**
1507
+ * A structural census of the tree the HOST holds — see `ITreeCensus` (tree-host.ts) for what each
1508
+ * number is for and why the anchor count says more about the adapter than about the app.
1509
+ *
1510
+ * It walks nothing here: the walk needs `props.text` to tell an empty raw text from a real one, and
1511
+ * a child list to measure a flatten width, and JS has neither. `undefined` from `treeHost()` means
1512
+ * nothing is installed, and the empty census is the honest answer — every probe that reads this
1513
+ * asserts against a mounted tree, so a zero from an uninstalled host cannot be mistaken for one from
1514
+ * an empty one.
1515
+ */
783
1516
  export function censusRetainedTree(roots) {
784
- const census = {
785
- nodes: 0,
786
- anchors: 0,
787
- emptyRawTexts: 0,
788
- renderable: 0,
789
- flattenWidths: [],
790
- };
791
- // Explicit stack, not recursion: a deep list under a benchmark screen would blow the JS stack
792
- // on the very tree this is meant to measure.
793
- const stack = [...roots];
794
- while (stack.length > 0) {
795
- const node = stack.pop();
796
- if (node === undefined)
797
- break;
798
- census.nodes += 1;
799
- if (isAnchor(node))
800
- census.anchors += 1;
801
- else if (isEmptyRawText(node))
802
- census.emptyRawTexts += 1;
803
- else
804
- census.renderable += 1;
805
- if (node.children.some(child => isAnchor(child) || isEmptyRawText(child)))
806
- census.flattenWidths.push(node.children.length);
807
- for (const child of node.children)
808
- stack.push(child);
809
- }
810
- census.flattenWidths.sort((left, right) => right - left);
811
- return census;
1517
+ flushOps();
1518
+ return treeHost()?.census(roots) ?? EMPTY_CENSUS;
812
1519
  }