@symbiote-native/engine 1.3.0 → 1.4.0

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