@symbiote-native/engine 0.4.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +39 -14
  2. package/android/CMakeLists.txt +51 -0
  3. package/android/build.gradle +90 -0
  4. package/android/src/main/AndroidManifest.xml +1 -0
  5. package/android/src/main/cpp/SymbioteEngineJni.cpp +72 -0
  6. package/android/src/main/java/dev/symbiotenative/engine/SymbioteEngineModule.kt +43 -0
  7. package/android/src/main/java/dev/symbiotenative/engine/SymbioteEnginePackage.kt +35 -0
  8. package/build/accessibility-info/shared.js +1 -1
  9. package/build/accessibility-props.d.ts +1 -7
  10. package/build/accessibility-props.js +22 -21
  11. package/build/animated/animations/composition.d.ts +1 -1
  12. package/build/animated/animations/composition.js +18 -4
  13. package/build/animated/easing.d.ts +3 -2
  14. package/build/animated/easing.js +17 -88
  15. package/build/animated/event.js +6 -1
  16. package/build/animated/graph.d.ts +2 -0
  17. package/build/animated/graph.js +14 -0
  18. package/build/animated/host-binding.d.ts +39 -0
  19. package/build/animated/host-binding.js +278 -0
  20. package/build/animated/index.d.ts +1 -1
  21. package/build/animated/leaf-lifecycle.js +10 -22
  22. package/build/animated/mock.d.ts +1 -19
  23. package/build/animated/props.js +1 -1
  24. package/build/animated/rgba.js +16 -50
  25. package/build/events/index.js +123 -33
  26. package/build/fabric-props.d.ts +1 -1
  27. package/build/fabric-props.js +129 -182
  28. package/build/fabric.d.ts +10 -0
  29. package/build/fabric.js +40 -0
  30. package/build/host-access.d.ts +125 -0
  31. package/build/host-access.js +280 -0
  32. package/build/host-behavior.d.ts +107 -7
  33. package/build/host-behavior.js +309 -31
  34. package/build/image-source-write.d.ts +16 -0
  35. package/build/image-source-write.js +65 -0
  36. package/build/imperative.d.ts +49 -0
  37. package/build/imperative.js +258 -0
  38. package/build/index.d.ts +18 -10
  39. package/build/index.js +65 -10
  40. package/build/mutation-buffer.d.ts +222 -0
  41. package/build/mutation-buffer.js +491 -0
  42. package/build/native-engine.d.ts +182 -0
  43. package/build/native-engine.js +178 -0
  44. package/build/native-tree-host.d.ts +25 -0
  45. package/build/native-tree-host.js +66 -0
  46. package/build/node.d.ts +191 -60
  47. package/build/node.js +1034 -327
  48. package/build/pan-responder/index.d.ts +2 -2
  49. package/build/pan-responder/index.js +37 -56
  50. package/build/platform-color/index.d.ts +1 -1
  51. package/build/platform-color/index.js +11 -4
  52. package/build/process-background-image/index.js +30 -566
  53. package/build/process-background-longhands.d.ts +4 -0
  54. package/build/process-background-longhands.js +44 -0
  55. package/build/process-box-shadow/index.js +23 -187
  56. package/build/process-filter.js +27 -300
  57. package/build/process-transform/index.d.ts +1 -1
  58. package/build/process-transform/index.js +25 -107
  59. package/build/process-transform-origin/index.d.ts +1 -1
  60. package/build/process-transform-origin/index.js +29 -102
  61. package/build/registry.d.ts +36 -0
  62. package/build/registry.js +73 -0
  63. package/build/sound-manager/index.d.ts +3 -0
  64. package/build/sound-manager/index.js +36 -0
  65. package/build/structured-style.d.ts +10 -0
  66. package/build/structured-style.js +180 -0
  67. package/build/style-registry/index.d.ts +14 -0
  68. package/build/style-registry/index.js +60 -11
  69. package/build/styles.d.ts +5 -1
  70. package/build/surface.d.ts +31 -2
  71. package/build/surface.js +138 -42
  72. package/build/text-input-state.d.ts +1 -0
  73. package/build/text-input-state.js +17 -3
  74. package/build/tree-host.d.ts +307 -0
  75. package/build/tree-host.js +211 -0
  76. package/build/view-config.js +4 -4
  77. package/codegen-specs/NativeSymbioteEngine.ts +27 -0
  78. package/cpp/SymbioteDebug.cpp +51 -0
  79. package/cpp/SymbioteDebug.h +54 -0
  80. package/cpp/SymbioteEngineBindings.cpp +232 -0
  81. package/cpp/SymbioteEngineBindings.h +59 -0
  82. package/cpp/SymbioteFabricProps.cpp +2619 -0
  83. package/cpp/SymbioteFabricProps.h +223 -0
  84. package/cpp/SymbioteTree.cpp +2478 -0
  85. package/cpp/SymbioteTree.h +257 -0
  86. package/ios/SymbioteEngineModule.h +25 -0
  87. package/ios/SymbioteEngineModule.mm +44 -0
  88. package/package.json +31 -3
  89. package/react-native.config.cjs +23 -0
  90. package/symbiote-engine.podspec +42 -0
  91. package/build/animated/bezier.d.ts +0 -1
  92. package/build/animated/bezier.js +0 -102
  93. package/build/commit.d.ts +0 -49
  94. package/build/commit.js +0 -1030
  95. package/build/tags.d.ts +0 -2
  96. package/build/tags.js +0 -40
@@ -0,0 +1,2478 @@
1
+ #include "SymbioteTree.h"
2
+
3
+ #include "SymbioteFabricProps.h"
4
+
5
+ #include <folly/dynamic.h>
6
+ #include <jsi/JSIDynamic.h>
7
+ #include <react/featureflags/ReactNativeFeatureFlags.h>
8
+ #include <react/renderer/core/InstanceHandle.h>
9
+ #include <react/renderer/core/RawProps.h>
10
+ #include <react/renderer/core/ShadowNode.h>
11
+ #include <react/renderer/core/LayoutableShadowNode.h>
12
+ #include <react/renderer/core/ShadowNodeFragment.h>
13
+ #include <react/renderer/mounting/ShadowTree.h>
14
+ #include <react/renderer/mounting/ShadowTreeRegistry.h>
15
+ #include <react/renderer/telemetry/TransactionTelemetry.h>
16
+ #include <react/renderer/uimanager/UIManager.h>
17
+ #include <react/renderer/uimanager/UIManagerBinding.h>
18
+ #include <react/renderer/uimanager/primitives.h>
19
+
20
+ // `react/renderer/dom/` is NOT among the header folders ReactAndroid copies into its prefab
21
+ // (`ReactAndroid/build.gradle.kts` lists uimanager, mounting, core, … and no dom), so including it
22
+ // unconditionally breaks the Android build of this same file. iOS compiles against the full
23
+ // ReactCommon tree and has it.
24
+ //
25
+ // Detected rather than branched on `__ANDROID__`, because the fact is about the TOOLCHAIN'S HEADERS,
26
+ // not about the platform — and it self-heals the day upstream exports the folder. The three
27
+ // `measure*` throw where it is absent; everything else needs none of it and works everywhere.
28
+ #if __has_include(<react/renderer/dom/DOM.h>)
29
+ #define SYMBIOTE_HAS_DOM_MEASURE 1
30
+ #include <react/renderer/dom/DOM.h>
31
+ #include <react/renderer/uimanager/consistency/ShadowTreeRevisionProvider.h>
32
+ #endif
33
+
34
+ #include <algorithm>
35
+ #include <atomic>
36
+ #include <chrono>
37
+ #include <memory>
38
+ #include <optional>
39
+ #include <string>
40
+ #include <string_view>
41
+ #include <unordered_set>
42
+ #include <utility>
43
+ #include <vector>
44
+
45
+ namespace symbiote {
46
+
47
+ using namespace facebook;
48
+
49
+ namespace {
50
+
51
+ // ── THE WIRE FORMAT ──────────────────────────────────────────────────────────────────────────────
52
+ //
53
+ // Mirrors `core/engine/src/mutation-buffer.ts`. These numbers ARE the contract: renumbering them
54
+ // there without renumbering them here commits a different tree, silently, and no test in either
55
+ // language can see across the boundary.
56
+ constexpr size_t kOpStride = 6;
57
+
58
+ constexpr int32_t kOpCreateElement = 0;
59
+ constexpr int32_t kOpCreateRawText = 1;
60
+ constexpr int32_t kOpCreateAnchor = 2;
61
+ constexpr int32_t kOpAppendChild = 3;
62
+ constexpr int32_t kOpInsertBefore = 4;
63
+ constexpr int32_t kOpRemoveChild = 5;
64
+ constexpr int32_t kOpSetProp = 6;
65
+ constexpr int32_t kOpSetText = 7;
66
+ constexpr int32_t kOpCommit = 8;
67
+ constexpr int32_t kOpSetComponent = 9;
68
+ constexpr int32_t kOpSetTag = 10;
69
+ constexpr int32_t kOpSetOwnedListener = 11;
70
+ constexpr int32_t kOpSetUnderlayShown = 12;
71
+ constexpr int32_t kOpCreateVoid = 13;
72
+
73
+ // The owned listener names any platform rule reads, one bit each — see `Node::pressListeners`.
74
+ // `press` is deliberately bit 0 so the `focusable` question is the cheapest of the two.
75
+ constexpr uint8_t kPressListenerPress = 1u << 0;
76
+
77
+ /** 0 for a name the host has no rule for, which is nearly all of them. */
78
+ uint8_t pressListenerBit(const std::string &name) {
79
+ if (name == "press") return kPressListenerPress;
80
+ if (name == "pressIn") return 1u << 1;
81
+ if (name == "pressOut") return 1u << 2;
82
+ if (name == "longPress") return 1u << 3;
83
+ return 0;
84
+ }
85
+
86
+ constexpr int32_t kKindElement = 0;
87
+ constexpr int32_t kKindRawText = 1;
88
+ constexpr int32_t kKindAnchor = 2;
89
+ // A node whose entire subtree contributes nothing to Fabric — unlike an anchor, which hoists its
90
+ // children up in its own place, a void node's children never reach `appendRenderable` either. See
91
+ // `OP_CREATE_VOID` (mutation-buffer.ts) for why: `InputAccessoryView.js` renders `null` on Android.
92
+ constexpr int32_t kKindVoid = 3;
93
+
94
+ // A `setProp` whose value slot is this DELETES the key. `null` cannot carry it: null is a legitimate
95
+ // Fabric value meaning "reset to the default", and a merge-based clone needs the two distinguished.
96
+ constexpr int32_t kNoValue = -1;
97
+
98
+ // The one position-dependent view name. A text element inside another text element commits as a
99
+ // virtual span — and the flag is STICKY, so `<Text><View><Text>` is virtual too. That is
100
+ // `commit.ts:964`'s `node.isText || hasTextAncestor`, and it is the reason the name is resolved
101
+ // while the child set is built rather than when a node is inserted: an insert cannot see the whole
102
+ // chain, and a reparent would have to rewrite a subtree.
103
+ // A `std::string` and not a `const char *` so `materialize` can bind a REFERENCE to either this or
104
+ // the node's own name. As a `const char *` the ternary there has no common type but `std::string`,
105
+ // so every call constructed one — see there.
106
+ const std::string kVirtualTextViewName = "RCTVirtualText";
107
+
108
+ // Tags identify a node to Fabric and must not collide with a surface's root tag. The JS side used to
109
+ // allocate them; there is no reason for that to cross a boundary, so the counter lives here. The
110
+ // base is far above any root tag a host hands out, and the step is 2 to keep our tags out of any
111
+ // contiguous range another allocator might use.
112
+ std::atomic<int32_t> nextTag_{1 << 20};
113
+
114
+ int32_t allocateTag() {
115
+ return nextTag_.fetch_add(2, std::memory_order_relaxed);
116
+ }
117
+
118
+ // The ceiling on ONE JSI->folly conversion, counted in object entries. Two orders of magnitude
119
+ // above anything real: the widest style bag on the benchmark row is ~30 keys.
120
+ constexpr size_t kMaxDynamicEntries = 10000;
121
+
122
+ // `jsi::dynamicFromValue`, bounded. Call this; never the raw one.
123
+ //
124
+ // The raw walk uses an EXPLICIT STACK and keeps no visited set (JSIDynamic.cpp), so a CYCLIC value
125
+ // is not a stack overflow — it is an endless `while (!stack.empty())` allocating one
126
+ // `folly::dynamic` entry per turn. The JS thread never returns from `applyOps` and the heap grows
127
+ // without bound. Measured 2026-09-09 on `examples/solid`: one press on an Animated control, 15 GB,
128
+ // JS thread dead. Every JS-side counter read zero throughout — a saturated thread delivers no
129
+ // console line, so the silence was the instrument, not the finding.
130
+ //
131
+ // An AnimatedNode graph is circular by construction (`leaf-lifecycle.ts` says so where it refuses
132
+ // to deep-compare props), so one reaching a prop write is enough.
133
+ //
134
+ // `filterObjectKeys` is invoked once per key of every object the walk expands, which bounds it
135
+ // without reimplementing it. Throwing names the value: a hang becomes a report.
136
+ //
137
+ // `describe` is a CALLABLE and not the message, because the message is built at the call site out of
138
+ // the prop key and the view name and the throw essentially never happens. Taking a `const
139
+ // std::string &` made every caller compose it eagerly: two temporaries and a result per prop write,
140
+ // 7 003 of them on a 1 000-row Solid create, and a bench of that arm alone put the concatenation at
141
+ // 48.8% of the decode path. Same class as the `dlog` arguments `CLAUDE.md` records on the Angular
142
+ // renderer — the guard has to be at the ARGUMENT, not inside the function.
143
+ template <typename Describe>
144
+ folly::dynamic boundedDynamicFrom(
145
+ jsi::Runtime &runtime,
146
+ const jsi::Value &value,
147
+ Describe &&describe) {
148
+ size_t entries = 0;
149
+ return jsi::dynamicFromValue(runtime, value, [&](const std::string &) {
150
+ if (++entries > kMaxDynamicEntries) {
151
+ throw jsi::JSError(
152
+ runtime,
153
+ "symbiote engine: " + describe() + " expanded past " +
154
+ std::to_string(kMaxDynamicEntries) +
155
+ " entries — the value is cyclic or not serialisable");
156
+ }
157
+ return false;
158
+ });
159
+ }
160
+
161
+ struct Node;
162
+ using NodePtr = std::shared_ptr<Node>;
163
+ using ChildSet = std::vector<std::shared_ptr<const react::ShadowNode>>;
164
+
165
+ /**
166
+ * A node in OUR tree, which is not Fabric's tree.
167
+ *
168
+ * The distinction is load-bearing and it is the one thing `IMirror` was genuinely for: an ANCHOR has
169
+ * no Fabric counterpart at all, so a store that WAS the Fabric tree could not hold one, and the
170
+ * frameworks put anchors everywhere (`{#if}`, a fragment, a `{@render}` slot).
171
+ *
172
+ * Ownership: `children` is strong and `parent` is raw. So a node is alive while a parent holds it OR
173
+ * while JS names it through its handle's `NativeState`, and dead otherwise — the browser's rule, and
174
+ * the reason nothing here needs an explicit release. Raw upward also means no cycle to leak.
175
+ */
176
+ // Whether this node's JS handle carries a `payloadFold`, once anything has looked.
177
+ enum class FoldProbe : uint8_t { unknown, absent, present };
178
+
179
+ // `jsi::NativeState` so a node can be attached to its JS placeholder without a wrapper object — see
180
+ // the note above `nodeFrom`. The base is empty apart from a virtual destructor, so the only cost is
181
+ // the vtable pointer, against one heap allocation per node saved.
182
+ struct Node : jsi::NativeState {
183
+ int32_t kind = kKindElement;
184
+ bool isText = false;
185
+ // Whether the APP has a callback wired to `press`, a name the behavior owns and that therefore
186
+ // never becomes a prop (`setEventListener` diverts it into a JS stash). `focusable` on a touchable
187
+ // is `focusable !== false && onPress !== undefined && !disabled` — two props and this.
188
+ //
189
+ // BOOLS rather than a set of names, and the choice is the same one `stashed` made on the JS side
190
+ // for the same reason: a container here would cost 24 bytes on EVERY node in every app to serve
191
+ // the handful that own a listener, where these land in padding that already existed. This said
192
+ // "`press` is the only owned name any platform rule reads; a second would be a second bool, and
193
+ // only a third would be worth a bitmask" — and the second came due the same day, so here it is.
194
+ // Names the host has no rule for are still dropped at the op, which is also the browser's
195
+ // arrangement: a UA tracks the listeners its own rules consult.
196
+ //
197
+ // A MASK rather than two bools, and it is not a preference — one bool per QUESTION cannot be
198
+ // maintained. "Any of four is wired" can go DOWN when one name departs, and the other three might
199
+ // still be there; nothing on this side can check, because the listeners live in JS and the op is a
200
+ // notification about ONE name. A bit per name is the smallest state that answers both questions
201
+ // from what the ops actually carry, in one byte.
202
+ //
203
+ // The two questions are NOT the same and collapsing them would be a real bug in both directions.
204
+ // `focusable` asks whether the app can be ACTIVATED, which is `onPress` alone
205
+ // (`TouchableOpacity.js:336-339`). TouchableHighlight's underlay asks whether the control reacts
206
+ // to a touch AT ALL, which RN spells as any of four (`_hasPressHandler`, `:296-302`) — so a
207
+ // `<TouchableHighlight onPressIn={…}>` with no `onPress` must flash and must not be a focus stop.
208
+ uint8_t pressListeners = 0;
209
+ // TouchableHighlight's underlay is showing. Set by `kOpSetUnderlayShown`, read by
210
+ // `foldTouchableHighlightUnderlay`. It LAGS the press: RN holds the underlay past release through
211
+ // a `delayPressOut` timer that stays in JS, which is why this is its own bit and not the press
212
+ // state that drives `:active` class resolution.
213
+ bool underlayShown = false;
214
+ // As the adapter authored it. `committedViewName` below is what was actually sent, which differs
215
+ // exactly when the virtual-text rule fired.
216
+ std::string viewName;
217
+ // The INTRINSIC TAG — `pressable`, `text-input` — or empty for the ~all of them that carry no host
218
+ // behavior. It is what `fabricProps` keys this tag's platform props off, and it is not derivable
219
+ // from `viewName`: a pressable commits as `RCTView` like any other view. Arrives once, at
220
+ // `attachHostBehavior`, and never changes — a tag is what the node IS.
221
+ std::string tagName;
222
+ react::Tag tag = 0;
223
+ folly::dynamic props = folly::dynamic::object();
224
+ std::shared_ptr<const react::InstanceHandle> instanceHandle;
225
+
226
+ // MAY CONTAIN NULL HOLES between a detach and the next read — see `compactChildren`. Every reader
227
+ // of this vector calls it first; the destructor below is the one place that tolerates a hole
228
+ // instead, because it must not allocate or renumber while the node is being torn down.
229
+ std::vector<NodePtr> children;
230
+ /** How many holes are standing. Zero means the vector is dense and every index is meaningful. */
231
+ size_t holes = 0;
232
+ /**
233
+ * Where this node sits in its parent's vector — a HINT, validated before it is believed.
234
+ *
235
+ * It is what makes a detach O(1): without it, removing a child means scanning the parent's whole
236
+ * child list to find it, so clearing a list of N costs N scans. Measured before this existed
237
+ * (`child-list-scaling.itest.ts`): clearing 1 000 / 2 000 / 4 000 children took 0.45 / 1.27 /
238
+ * 4.29 ms, i.e. doubling factors of 2.8 and 3.4 against the 2.0 linear work would give.
239
+ */
240
+ size_t slotInParent = 0;
241
+ Node *parent = nullptr;
242
+
243
+ // The placeholder object this node was published on, WEAK — what the structural reads hand back,
244
+ // since JS compares the answer by IDENTITY against the host node it is already holding.
245
+ //
246
+ // Weak is forced, and the reference applier is what shows why the two sides differ here: its
247
+ // `nodes` is a `WeakMap<handle, node>` with a STRONG `node.handle` back-edge, so a node held by a
248
+ // parent keeps its handle alive. Inverting that here is not available — the handle owns the node
249
+ // through `NativeState`, so a strong edge back is a cycle across the GC boundary, which is the one
250
+ // shape this file's header spends four paragraphs refusing. RN solves the identical problem the
251
+ // identical way: `InstanceHandle` holds a `jsi::WeakObject` for the JS instance a shadow node
252
+ // names.
253
+ std::optional<jsi::WeakObject> handle;
254
+
255
+ // The same placeholder again, STRONG, held for exactly as long as a parent holds this node.
256
+ //
257
+ // The weak edge above is right about ownership and wrong about lifetime, and the gap is what a
258
+ // structural read falls into: JS does not have to name a node to ASK about it — it asks the
259
+ // PARENT for its children. Vue's `setElementText` builds a raw text, appends it and drops it on
260
+ // the same line; solid-js/universal re-derives every position through `childrenOf`. Once the
261
+ // placeholder is collected, `handleOf` answers undefined and the child vanishes from an answer
262
+ // it belongs in — so Vue appends a second raw text over the first (text visibly accumulates,
263
+ // randomly, on GC's schedule) and Solid cannot find a node it placed. React and Svelte never
264
+ // navigate the host, which is why only the two adapters that do went wrong.
265
+ //
266
+ // The cycle this creates — object -> NativeState -> node -> object — is broken by the tree
267
+ // itself: a removed node drops this, and `~Node` drops it for every child, so the collection
268
+ // that frees a subtree cascades down it. Being IN the tree is what pins the handle, exactly as a
269
+ // node in the document is reachable in a browser.
270
+ std::optional<jsi::Object> attachedHandle;
271
+
272
+ // ── Commit state ───────────────────────────────────────────────────────────────────────────────
273
+ std::shared_ptr<const react::ShadowNode> committed;
274
+ // What the last commit actually sent, so the next payload can be a minimal diff.
275
+ // `cloneNodeWithNewProps` MERGES rather than replaces (`commit.ts:222-230`), so re-sending an
276
+ // unchanged key re-invokes its native setter — and some ViewManagers rebuild the view on any set.
277
+ folly::dynamic committedProps = folly::dynamic::object();
278
+ std::string committedViewName;
279
+ // The node that held this one in FABRIC at the last commit — the nearest non-anchor ancestor,
280
+ // since an anchor hoists and has no Fabric counterpart. `nullptr` means the surface's child set.
281
+ //
282
+ // A Fabric node belongs to one FAMILY, so a node handed to a different parent must be re-created
283
+ // rather than cloned. A move is therefore the one case where a node can be perfectly CLEAN and
284
+ // still need rebuilding, which is why this is checked apart from the dirty pair. Raw, and safe:
285
+ // it is only ever compared for identity, never dereferenced.
286
+ const Node *committedParent = nullptr;
287
+ // How many Fabric FAMILIES this node has minted, and the parent generation it was last attached
288
+ // under. `committedParent` cannot answer this: a rebuilt node keeps its tag and its identity as a
289
+ // tree node, so a child comparing parents sees no change, takes the reuse path, and is appended
290
+ // into the new family while still holding the old one. `ShadowNodeFamily::setParent` asserts a
291
+ // family has one parent for life — Debug aborts, and Release takes the `hasParent_` early return
292
+ // and leaves the child silently attached to a family that has left the tree.
293
+ //
294
+ // Both rest at 0 and a minted generation is always >= 1, so a node with no Fabric parent — the
295
+ // surface — compares equal forever and is never rebuilt for this.
296
+ unsigned familyGeneration = 0;
297
+ unsigned committedUnderGeneration = 0;
298
+ // The text ancestry and the surface this node last committed UNDER — context taken ABOVE it that
299
+ // its own subtree depends on, and neither is derivable from anything else here. A plain `<View>`
300
+ // moved under a `<Text>` keeps its name and its parent and still has to rebuild, so the `<Text>`
301
+ // beneath IT can go out virtual; and a top-level node moving between two surfaces has
302
+ // `committedParent == nullptr` on both sides, so the surface id is the only thing separating them.
303
+ bool committedTextAncestor = false;
304
+ react::SurfaceId committedSurfaceId = 0;
305
+ // The Fabric children it last handed over, so a rebuild that produces the identical list can
306
+ // decline to clone. A node is DIRTY whenever an op named it, and an op is not a change: an adapter
307
+ // that re-renders the same content writes a fresh object for an unchanged style, which every
308
+ // identity guard above `diffProps` must let through by design.
309
+ ChildSet committedChildren;
310
+ // A SURFACE only: the root child set it last handed to `completeSurface`, so a commit that
311
+ // rebuilds the identical list can decline to complete the root at all. Empty on every other node.
312
+ ChildSet committedRenderable;
313
+ bool hasCommittedRenderable = false;
314
+
315
+ // `selfDirty`: this node's own props or child list changed. `pathDirty`: something at or below it
316
+ // did. The pair is what lets an untouched sibling subtree hand back its committed node without
317
+ // being walked at all, which is the whole reason a commit is cheap.
318
+ bool selfDirty = true;
319
+ bool pathDirty = true;
320
+ // Whether the JS handle carries a `payloadFold`. See `foldFor` for why one probe settles it.
321
+ FoldProbe foldProbe = FoldProbe::unknown;
322
+
323
+ // A child OUTLIVES its parent whenever JS still names it — `children` is strong and the handle's
324
+ // `NativeState` is a second strong owner, so dropping the parent's reference is not the last one.
325
+ // Its `parent` would then point at freed memory, and two paths dereference it: `markDirty` walks
326
+ // upward through it and `parentOf` hands it to JS.
327
+ //
328
+ // The reference applier has no such window (a JS child's `parent` reference keeps the parent
329
+ // alive), so nothing headless can reach this and there is no test to write for it.
330
+ ~Node() {
331
+ for (const NodePtr &child : children) {
332
+ // A HOLE, from a detach nothing has read past yet. The destructor is the one reader that does
333
+ // not compact first: compaction renumbers, and renumbering a vector whose owner is being
334
+ // destroyed buys nothing.
335
+ if (child == nullptr) continue;
336
+ child->parent = nullptr;
337
+ // Its parent is gone, so nothing pins its placeholder any more. Dropping the strong edge here
338
+ // is what makes the release CASCADE: a child JS no longer names becomes unreachable from both
339
+ // sides, and the collection that frees it runs this same loop one level down.
340
+ child->attachedHandle.reset();
341
+ }
342
+ }
343
+ };
344
+
345
+ // A node IS its own native state — there is no wrapper.
346
+ //
347
+ // There used to be a `NodeState : jsi::NativeState` holding a `NodePtr`, which cost one
348
+ // `make_shared` per created node: 10 002 allocations on a 1 000-row create, every one of them
349
+ // existing only to be a second pointer to something already heap-allocated. Measured on
350
+ // `build-release`, `setNativeState` alone swung 1.2-8.5 ms across runs of the same fixture — the
351
+ // spread being the allocator and the collector, which is what an extra allocation per node buys.
352
+ //
353
+ // `Node` declares the inheritance instead (see the struct). The only thing the wrapper gave was a
354
+ // distinct type for `dynamic_pointer_cast` to fail on when a handle carries somebody else's state,
355
+ // and casting to `Node` fails exactly as well.
356
+
357
+ /**
358
+ * The node a placeholder owns.
359
+ *
360
+ * `hasNativeState` is asked first because reading state from an object that has none is not
361
+ * something JSI promises anything about, and because the two failures mean different things: no
362
+ * state at all is a batch naming a node it never created.
363
+ */
364
+ NodePtr nodeFrom(jsi::Runtime &runtime, const jsi::Object &handle, const char *what) {
365
+ if (!handle.hasNativeState(runtime)) {
366
+ throw jsi::JSError(
367
+ runtime, std::string(what) + ": names a node this batch never created");
368
+ }
369
+ auto node = std::dynamic_pointer_cast<Node>(handle.getNativeState(runtime));
370
+ if (node == nullptr) {
371
+ throw jsi::JSError(runtime, std::string(what) + ": handle carries foreign native state");
372
+ }
373
+ return node;
374
+ }
375
+
376
+ /**
377
+ * The JS object a node is published on, or `undefined` for a node that is in no tree and that
378
+ * nothing in JS names any more.
379
+ *
380
+ * A node WITH a parent always answers, because the parent pins the placeholder (`attachedHandle`).
381
+ * That is not a nicety: the structural reads are asked about children, and a caller does not have
382
+ * to hold a node to ask about it. This used to say the miss was unreachable, which cost Vue and
383
+ * Solid a silently truncated child list.
384
+ */
385
+ jsi::Value handleOf(jsi::Runtime &runtime, const Node &node) {
386
+ if (!node.handle.has_value()) return jsi::Value::undefined();
387
+ return node.handle->lock(runtime);
388
+ }
389
+
390
+ /** Pin the placeholder for as long as a parent holds this node. Idempotent, O(1), no recursion:
391
+ * every node is attached to its own parent at some point and is pinned there. */
392
+ void holdHandle(jsi::Runtime &runtime, Node &node) {
393
+ if (node.attachedHandle.has_value() || !node.handle.has_value()) return;
394
+ auto live = node.handle->lock(runtime);
395
+ if (live.isObject()) node.attachedHandle.emplace(live.getObject(runtime));
396
+ }
397
+
398
+ react::UIManager &uiManagerFor(jsi::Runtime &runtime, const char *what) {
399
+ auto binding = react::UIManagerBinding::getBinding(runtime);
400
+ if (binding == nullptr) {
401
+ throw jsi::JSError(
402
+ runtime,
403
+ std::string(what) + ": nativeFabricUIManager is not installed on this runtime");
404
+ }
405
+ return binding->getUIManager();
406
+ }
407
+
408
+ /**
409
+ * A zero-copy view over a JS `Int32Array`.
410
+ *
411
+ * `ArrayBuffer::data` hands back the backing store, so the commands never become JS values — which
412
+ * is the entire reason the format is flat. `byteOffset` is read rather than assumed: a typed array
413
+ * need not start at the head of its buffer.
414
+ */
415
+ const int32_t *int32ArrayData(jsi::Runtime &runtime, const jsi::Value &value, size_t &lengthOut) {
416
+ auto typedArray = value.asObject(runtime);
417
+ auto buffer = typedArray.getPropertyAsObject(runtime, "buffer").getArrayBuffer(runtime);
418
+ auto byteOffset = static_cast<size_t>(typedArray.getProperty(runtime, "byteOffset").asNumber());
419
+ lengthOut = static_cast<size_t>(typedArray.getProperty(runtime, "length").asNumber());
420
+ return reinterpret_cast<const int32_t *>(buffer.data(runtime) + byteOffset);
421
+ }
422
+
423
+ // ── DIRTY MARKING ────────────────────────────────────────────────────────────────────────────────
424
+
425
+ /**
426
+ * Mark a node changed and raise `pathDirty` to the root.
427
+ *
428
+ * The climb stops at the first ancestor already marked, which keeps the invariant "pathDirty implies
429
+ * pathDirty on every ancestor" and makes the whole thing O(1) amortised: after the first op in a
430
+ * subtree, the rest cost one comparison. A reparent is the one case that could break the invariant —
431
+ * a dirty node moved under a clean parent — so every structural op marks the PARENT, which repairs
432
+ * it by construction.
433
+ *
434
+ * The early stop is only sound while EVERY node the commit walks clears its flags, and the nodes the
435
+ * walk contributes nothing for are the ones that would not: an anchor and an empty raw text never
436
+ * reach `materialize`. So `appendRenderable` clears them itself. Without that an anchor keeps
437
+ * `pathDirty` forever after its first commit, the climb halts AT it, and the element above it is
438
+ * never marked — every mutation inside an `{#if}` that already committed is silently dropped.
439
+ */
440
+ void markDirty(Node &node) {
441
+ node.selfDirty = true;
442
+ for (Node *at = &node; at != nullptr && !at->pathDirty; at = at->parent) {
443
+ at->pathDirty = true;
444
+ }
445
+ }
446
+
447
+ /**
448
+ * Close the holes a run of detaches left, and renumber what survived.
449
+ *
450
+ * Called by every reader of `node.children` before it walks. Costs nothing on a dense vector — one
451
+ * load and one branch — and one linear pass on a vector that was just emptied, which is what makes
452
+ * a clear of N children O(N) in total rather than O(N) per removal.
453
+ *
454
+ * The alternative was `erase` per removal, and it is quadratic from either end: `std::remove` scans
455
+ * the whole range whatever it finds, and `erase` then shifts the tail. Removing from the front pays
456
+ * the shift, removing from the back pays the scan.
457
+ */
458
+ void compactChildren(Node &node) {
459
+ if (node.holes == 0) return;
460
+ auto &children = node.children;
461
+ children.erase(
462
+ std::remove(children.begin(), children.end(), nullptr), children.end());
463
+ for (size_t at = 0; at < children.size(); at += 1) children[at]->slotInParent = at;
464
+ node.holes = 0;
465
+ }
466
+
467
+ void detachFromParent(const NodePtr &child) {
468
+ Node *parent = child->parent;
469
+ if (parent == nullptr) return;
470
+ auto &siblings = parent->children;
471
+ // O(1) THROUGH THE HINT, and the scan below is the safety net rather than the design: a hint is
472
+ // only ever stale if a path that moved a child forgot to set it, and a wrong hint must not silently
473
+ // punch a hole in the wrong slot.
474
+ const size_t hinted = child->slotInParent;
475
+ if (hinted < siblings.size() && siblings[hinted] == child) {
476
+ siblings[hinted] = nullptr;
477
+ parent->holes += 1;
478
+ } else {
479
+ auto found = std::find(siblings.begin(), siblings.end(), child);
480
+ if (found != siblings.end()) {
481
+ *found = nullptr;
482
+ parent->holes += 1;
483
+ }
484
+ }
485
+ child->parent = nullptr;
486
+ markDirty(*parent);
487
+ }
488
+
489
+ // ── COMMIT ───────────────────────────────────────────────────────────────────────────────────────
490
+
491
+ /** Element-wise identity. Fabric is clone-on-write, so an unchanged node IS the same object. */
492
+ bool sameNodes(const ChildSet &previous, const ChildSet &next) {
493
+ if (previous.size() != next.size()) return false;
494
+ for (size_t at = 0; at < previous.size(); at += 1) {
495
+ if (previous[at] != next[at]) return false;
496
+ }
497
+ return true;
498
+ }
499
+
500
+ /**
501
+ * Whether a changed child list can be applied one slot at a time instead of handed over whole.
502
+ *
503
+ * THE POINT. Handing Fabric a child list costs `updateYogaChildren()` — `adoptYogaChild` per child,
504
+ * and a child still owned by the previous revision is `clone({})`d outright. Measured on Yoga alone
505
+ * (`core/engine/bench/replace-child-equivalence.cpp`): replacing one child of a thousand costs 1 000
506
+ * children touched and **999 yoga clones** that way, against 1 and 0 this way, with the two arms
507
+ * producing the identical tree. Through a real adapter the same quantity reads 1 001 children handed
508
+ * over for 2 moved positions on a select — 500x (each adapter's `work-ledger.probe.test`).
509
+ *
510
+ * THIS IS NOT AVAILABLE TO A JS RENDERER. `nativeFabricUIManager` exposes three clone forms and no
511
+ * `replaceChild` (`UIManagerBinding.cpp`); it is a `ShadowNode` method, reachable only because this
512
+ * applier lives on the native side and `UIManager::cloneNode` hands back a NON-const node. React's
513
+ * own renderer cannot do this.
514
+ *
515
+ * Three conditions, and each rules out a real case rather than a hypothetical one:
516
+ *
517
+ * SAME LENGTH an insert or a removal has no slot to rewrite. The whole list goes over, as
518
+ * before, and the work ledger reports those steps at 1.0x for exactly this
519
+ * reason.
520
+ * SOMETHING MOVED a list held whole is already served by `childrenPlaceholder()` above.
521
+ * VIEW CULLING OFF and this one is an UPSTREAM BUG, not a preference. `ShadowNode::appendChild`
522
+ * ends with `propagateUncullableTraitsFromChildren()`;
523
+ * `ShadowNode::replaceChild` has that same call placed AFTER both of its
524
+ * `return`s, so it is dead on every success path. With culling on, a parent
525
+ * whose child was replaced would keep a stale `Unstable_uncullableTrace` where
526
+ * the list hand-over refreshes it. `enableViewCulling()` defaults to FALSE in
527
+ * 0.86 so the divergence is latent today — this guard is what keeps it latent
528
+ * on the day someone flips the flag.
529
+ */
530
+ /**
531
+ * How many positions moved, or `kWidthChanged` when the list is not the same length.
532
+ *
533
+ * ONE pass, and it answers both questions the clone branch asks. It first did not: `sameNodes` said
534
+ * whether anything moved and `canReplaceInPlace` then walked the same vector again to ask how much —
535
+ * a second O(width) pass added by the very change that removes O(width) work. Caught by reading this
536
+ * file the way it asks everything else to be read.
537
+ */
538
+ constexpr size_t kWidthChanged = static_cast<size_t>(-1);
539
+
540
+ size_t countChangedPositions(const ChildSet &previous, const ChildSet &next) {
541
+ if (previous.size() != next.size()) return kWidthChanged;
542
+ size_t changed = 0;
543
+ for (size_t at = 0; at < next.size(); at += 1) {
544
+ if (previous[at] != next[at]) changed += 1;
545
+ }
546
+ return changed;
547
+ }
548
+
549
+ /**
550
+ * The renderable children a parent collected, plus the one fact about their kinds the replace rule
551
+ * needs.
552
+ *
553
+ * One object rather than a vector and a loose count, so the count cannot drift from the vector it
554
+ * describes — the same reason `work-ledger.ts` owns its columns instead of four probes each keeping
555
+ * their own.
556
+ */
557
+ struct IOwnerTally {
558
+ std::vector<Node *> nodes;
559
+ size_t rawTexts = 0;
560
+ };
561
+
562
+ /**
563
+ * Whether a `children_` index is also a valid `yogaLayoutableChildren_` index for this parent.
564
+ *
565
+ * The Yoga override validates `suggestedIndex` against `yogaLayoutableChildren_` and falls back to a
566
+ * `find_if` when it does not match — slow, never wrong. The two vectors diverge only for a MIXED
567
+ * parent, because `RawTextShadowNode` extends plain `ShadowNode` and is the one child kind that is
568
+ * not Yoga-layoutable. All-layoutable and none-layoutable both align: in the second case the yoga
569
+ * vector is EMPTY, the scan finds nothing and returns immediately, which is O(1) rather than a fall
570
+ * back to anything.
571
+ *
572
+ * F-40 stood in for this check with a half-width bound, on the grounds that nothing enforces the
573
+ * shape. Nothing does — but the shape is READABLE from our own tree, which is strictly better than a
574
+ * bound that turns away work it did not have to.
575
+ */
576
+ bool childIndicesAlign(const IOwnerTally &owners, const ChildSet &next) {
577
+ if (owners.nodes.size() != next.size()) return false;
578
+ // A COUNT, not a scan. This walked the owners vector a second time to recover kinds the loop that
579
+ // BUILT it had already seen — 1 001 owners re-examined per select on a 1 000-row list, and a full
580
+ // scan even on an append, where it is an argument the rule then throws away. F-41 fused the same
581
+ // shape once already; F-49 is that lesson arriving at the pass F-43 introduced.
582
+ return owners.rawTexts == 0 || owners.rawTexts == owners.nodes.size();
583
+ }
584
+
585
+ /**
586
+ * Whether every replacement leaves the layout alone.
587
+ *
588
+ * THE CONDITION THE TARGETED PATH CANNOT BE CORRECT WITHOUT, and F-51 is the measurement that says
589
+ * so. Replacing in place leaves the standing children owned by the PREVIOUS revision, so the first
590
+ * layout pass that does work on this parent clones every one of them
591
+ * (`yoga::Node::cloneChildrenIfNeeded` → `cloneChildInPlace`) and swaps them in behind us. The child
592
+ * list `adoptLandedChildren` recorded at commit time then names nodes that are no longer there —
593
+ * measured at 999 of 1 000 — and the NEXT commit's `ShadowNode::replaceChild` cannot find the child
594
+ * it was asked to replace. That path ends in `react_native_assert(false && "Child to replace was not
595
+ * found.")`, which is nothing at all in a Release build: the function returns having replaced
596
+ * nothing and the mutation is silently dropped.
597
+ *
598
+ * A layout pass only does that work when the parent is dirty, and a parent goes dirty because a
599
+ * child did. So the targeted path is safe exactly when no replacement moved layout — which is also
600
+ * the only case where F-45 says it saves any clones. The two conditions coinciding is the reason to
601
+ * trust the rule rather than a coincidence to note.
602
+ */
603
+ bool replacementsAreLayoutClean(const ChildSet &previous, const ChildSet &next) {
604
+ for (size_t at = 0; at < next.size(); at += 1) {
605
+ if (previous[at] == next[at]) continue;
606
+ const auto *layoutable =
607
+ dynamic_cast<const react::LayoutableShadowNode *>(next[at].get());
608
+ if (layoutable != nullptr && !layoutable->getIsLayoutClean()) return false;
609
+ }
610
+ return true;
611
+ }
612
+
613
+ /**
614
+ * No replacement may be a node this parent is ALREADY holding.
615
+ *
616
+ * `YogaLayoutableShadowNode::replaceChild` asserts `YGNodeGetOwner(&newChild->yogaNode_) == nullptr`
617
+ * (`YogaLayoutableShadowNode.cpp:303`) and then claims ownership. A node standing in this very child
618
+ * set is owned by this very parent, so handing it back at another index aborts — and in Release
619
+ * silently corrupts the yoga tree, since the owner is overwritten while the old slot still points at
620
+ * it. That is a REORDER, which is what every list swap emits.
621
+ *
622
+ * The full child-list handover has no such restriction: `updateYogaChildren` re-adopts the lot.
623
+ *
624
+ * TWO CHILD LISTS, AND THE ASYMMETRY IS THE WHOLE POINT — see `liveChildrenOf`. `recorded` decides
625
+ * WHICH slots moved, because `next` was derived from it and only the two together are consistent.
626
+ * `standing` decides WHAT THIS PARENT ACTUALLY HOLDS, because a replacement already owned by this
627
+ * parent is an owner conflict whether or not our record knows the node is there.
628
+ */
629
+ bool replacementsAreFresh(
630
+ const ChildSet &recorded,
631
+ const ChildSet &standing,
632
+ const ChildSet &next) {
633
+ std::unordered_set<const react::ShadowNode *> held;
634
+ held.reserve(standing.size());
635
+ for (const auto &child : standing) held.insert(child.get());
636
+ for (size_t at = 0; at < next.size(); at += 1) {
637
+ if (recorded[at] == next[at]) continue;
638
+ if (held.count(next[at].get()) != 0) return false;
639
+ }
640
+ return true;
641
+ }
642
+
643
+ /**
644
+ * What this parent's children ARE right now, as opposed to what we recorded them to be.
645
+ *
646
+ * THE LAYOUT PASS MUTATES A STANDING PARENT IN PLACE, and that is the fact this whole path was
647
+ * disabled over. `YogaLayoutableShadowNode::cloneChildInPlace` clones a child and calls
648
+ * `replaceChild(childNode, clonedChildNode, layoutableChildIndex)` on the parent it is already
649
+ * holding — so the PARENT's own pointer never changes while its children vector does. That is what
650
+ * defeats `adoptCommitted`'s `node.committed == landed` stop: the pointer is identical, the subtree
651
+ * is not, and Fabric's "an identical child pointer means an identical subtree" invariant does not
652
+ * hold across a layout pass. Our record then names a node that is no longer in the list, and
653
+ * `ShadowNode::replaceChild` ends in `react_native_assert(false && "Child to replace was not
654
+ * found.")` — silent in Release, where the mutation is simply dropped.
655
+ *
656
+ * The rule the disabling comment asked for is therefore not a predicate over which nodes Fabric may
657
+ * substitute — it is to stop needing one. A record can go stale; the parent cannot be wrong about
658
+ * its own children. Note `cloneChildInPlace` substitutes AT THE SAME INDEX, which is what makes
659
+ * position the stable key both sides can agree on.
660
+ */
661
+ const ChildSet &liveChildrenOf(const Node &node) {
662
+ static const ChildSet kNone;
663
+ return node.committed == nullptr ? kNone : node.committed->getChildren();
664
+ }
665
+
666
+ /**
667
+ * How many parents took the targeted path since this was last read.
668
+ *
669
+ * A LIVENESS counter, and it exists because every test in this repository stays green when the path
670
+ * is off — that is how it spent eighteen months disabled with a comment claiming a 500x on the line
671
+ * above it. Correctness here is the fuzzer's job; this answers the other question, which no
672
+ * correctness test can: did the fast path RUN. A guard tightened by accident shows up as a zero
673
+ * rather than as nothing at all.
674
+ *
675
+ * Process-wide and zeroed on read, the same deal `readCommitProfile`'s counters make in JS.
676
+ */
677
+ size_t targetedReplaces_ = 0;
678
+
679
+ /**
680
+ * `materialize`'s own stopwatch, because the walk is invisible to every clock React Native owns.
681
+ *
682
+ * `TransactionTelemetry` times `ShadowTree::commit` and Yoga, and `materialize` runs in `kOpCommit`
683
+ * BEFORE `completeSurface` is called at all — so the walk sits in neither window. Priced by
684
+ * subtraction on 2026-09-17 (`raw-fabric-vs-engine.itest.ts`: our commit 229 ms against a Fabric
685
+ * `commitMs` of 26.7, and a bare-JSI arm whose whole `completeRoot` was 28 ms), which put ~200 ms of
686
+ * a 327 ms create inside this function and named nothing inside it. A number reached by subtracting
687
+ * two others is a budget, not an address.
688
+ *
689
+ * Nanoseconds, accumulated across the whole walk and zeroed when read. `steady_clock::now()` costs
690
+ * ~20 ns here against phases of tens of milliseconds, and it is read at most five times per node.
691
+ */
692
+ struct IWalkCost {
693
+ double walkNs = 0;
694
+ double propsNs = 0;
695
+ // `propsNs` is two unrelated things billed together — assembling the payload, and asking the node
696
+ // whether it even has a `payloadFold`. The second is a JSI property read plus, when the answer is
697
+ // yes, a `jsi::Function` allocation and a round trip through JS with the whole bag converted both
698
+ // ways. `foldProbe` caches only the NO, so a folding node pays the round trip every commit. Split
699
+ // out because two adapters building the identical tree disagreed 4x on `propsNs` and the sum
700
+ // cannot say which half moved.
701
+ double foldLookupNs = 0;
702
+ size_t foldsFound = 0;
703
+ // Inside a fold that runs: converting the bag out, the JS call, converting the result back.
704
+ double foldToJsNs = 0;
705
+ double foldCallNs = 0;
706
+ double foldFromJsNs = 0;
707
+ // The two `kOpSetProp` early exits, counted apart because they are not the same kind of waste.
708
+ // A delete of an ABSENT key leaves before the value conversion and costs a hash lookup. A write of
709
+ // an UNCHANGED value leaves after it, so the adapter has already paid the JSI -> `folly::dynamic`
710
+ // crossing for a value that changes nothing — which is the expensive one, and the one the device
711
+ // benchmark's `WRITES n/m` second figure has been reporting for React alone.
712
+ size_t deletesOfAbsent = 0;
713
+ size_t writesOfUnchanged = 0;
714
+ double rawPropsNs = 0;
715
+ double createNs = 0;
716
+ double appendNs = 0;
717
+ double diffNs = 0;
718
+ size_t created = 0;
719
+ size_t cloned = 0;
720
+ size_t reused = 0;
721
+ // `applyOps`' own half, which is a different question from the walk's: the walk asks what Fabric
722
+ // charges, this asks what OUR decode charges to turn one op into one node. On `build-release` the
723
+ // decode came out the same size as the per-node JSI calls it exists to replace, which is the one
724
+ // result that would make the buffer architecture pointless — so it gets named from the inside too.
725
+ double decodeNs = 0;
726
+ double instanceHandleNs = 0;
727
+ double publishNs = 0;
728
+ double nativeStateNs = 0;
729
+ size_t decoded = 0;
730
+ // `kOpSetProp`, and inside it the JS value -> `folly::dynamic` conversion. NOT counted on the two
731
+ // early exits (an absent key being deleted, and a value that compares equal to the standing one) —
732
+ // both leave before the accumulate, and both are the cheap paths, so the sum is an under-count of
733
+ // a case that is already small when it exits early. `propConvertNs` has no such hole.
734
+ double setPropNs = 0;
735
+ double propConvertNs = 0;
736
+ size_t setProps = 0;
737
+ // How well the interning actually worked: entries in the batch's value table against conversions
738
+ // performed. `setProps` / `valueEntries` is the dedup the buffer achieved, and
739
+ // `valueConversions` / `valueEntries` says how much of the table the ops even reached.
740
+ size_t valueEntries = 0;
741
+ size_t valueConversions = 0;
742
+ // The rest of `applyOps`, so the phase's books close. `applyNs` is the whole call; the string
743
+ // table is decoded once up front; `structureNs` is every append/insert/remove op together.
744
+ double applyNs = 0;
745
+ double stringDecodeNs = 0;
746
+ double structureNs = 0;
747
+ // The BATCHED HOST READS, which are not on a commit path and are timed anyway: the teardown sweep
748
+ // calls `subtreesOf` once per commit that removed anything, and it hands back a handle for every
749
+ // node in every removed subtree — 10 000 of them on a 1 000-row clear, which is the row stock
750
+ // React Native wins. Knowing whether that time is the crossing or the JS loop above it is the
751
+ // difference between moving the mark into C++ and leaving it alone.
752
+ double hostReadNs = 0;
753
+ size_t hostReadHandles = 0;
754
+ // How many times `applyOps` was entered. The string and value tables are interned PER BATCH, so a
755
+ // driver that flushes in many small batches cannot fold a repeated value across them — and the
756
+ // count is the only thing that distinguishes "this adapter sends more values" from "this adapter
757
+ // sends the same values in more batches".
758
+ size_t applyCalls = 0;
759
+ // Inside `structureNs`: promoting a node's WEAK handle reference to a strong one when it acquires
760
+ // a parent. One `jsi::WeakObject::lock` plus one `jsi::Object` per node, i.e. real JSI work on an
761
+ // op that otherwise touches nothing but our own vectors.
762
+ double holdHandleNs = 0;
763
+ };
764
+ IWalkCost walkCost_;
765
+
766
+ using ISteadyClock = std::chrono::steady_clock;
767
+
768
+ double nanosSince(const ISteadyClock::time_point &startedAt) {
769
+ return std::chrono::duration<double, std::nano>(ISteadyClock::now() - startedAt).count();
770
+ }
771
+
772
+ bool canReplaceInPlace(
773
+ const Node &node,
774
+ const ChildSet &standing,
775
+ const ChildSet &next,
776
+ size_t changed,
777
+ bool indicesAlign) {
778
+ // ON since 2026-09-17, after eighteen months of this comment saying OFF. What changed is not
779
+ // another guard — it is where the old child comes from.
780
+ //
781
+ // This path rewrites a standing parent's moved slots instead of handing Fabric a whole child list.
782
+ // Handing the list over ends in `YogaLayoutableShadowNode::updateYogaChildren`, which re-adopts and
783
+ // re-clones EVERY standing child, so the cost of touching one row is the width of the list it sits
784
+ // in. Measured through the real engine on a real JSI runtime
785
+ // (`core/engine/cpp/tests/js/create-append-phase-split.itest.ts`), one prop on one row of a list
786
+ // 4 000 wide, node count held constant at 20 000: **405 ms with this path off, 3 ms with it on**,
787
+ // and flat in width instead of rising with it. That is F-65's "500x on a select", recovered.
788
+ //
789
+ // WHY IT WAS OFF, AND WHY THE TWO FAILURES WERE ONE FAILURE. It was disabled after a device abort
790
+ // in `ShadowNode::replaceChild` that nothing headless could reproduce; the fuzzer here then found
791
+ // two, and the older version of this comment read them as separate problems:
792
+ //
793
+ // `YogaLayoutableShadowNode.cpp:303` — a replacement whose yoga node already has an owner.
794
+ // `ShadowNode.cpp:281` — "Child to replace was not found."
795
+ //
796
+ // Both are the same cause. `YogaLayoutableShadowNode::cloneChildInPlace` clones a child during
797
+ // LAYOUT and calls `replaceChild` on the parent it is already holding, so the parent's own pointer
798
+ // is unchanged while its children vector is not. `adoptCommitted`'s `node.committed == landed` stop
799
+ // therefore never fires for that parent, our `committedChildren` keeps the pre-layout pointers, and
800
+ // the next commit names a node that left the list. Fabric's "an identical child pointer means an
801
+ // identical subtree" invariant simply does not hold across a layout pass.
802
+ //
803
+ // THE RULE, and it is smaller than the one this comment used to ask for. It asked for a predicate
804
+ // over which nodes Fabric may substitute behind us. There is none worth writing: the answer is to
805
+ // stop keeping a record that can disagree with Fabric. `recorded` still decides WHICH slots moved,
806
+ // because `next` was derived from it and only those two are consistent with each other; but the
807
+ // node to name is read from the parent itself (`liveChildrenOf`, `replacedChangedChildren`), and a
808
+ // parent cannot be wrong about its own children. `cloneChildInPlace` substitutes at the SAME index,
809
+ // which is what leaves position as a key both sides still agree on.
810
+ //
811
+ // The fuzzer is the evidence, and it is the same fuzzer that condemned this path: 300 random op
812
+ // programs, each comparing the committed shape against the oracle, all green. Re-read
813
+ // `replacementsAreFresh` before weakening anything here — its membership set is the LIVE children
814
+ // for this same reason, and that is what closed the owner assert.
815
+ if (react::ReactNativeFeatureFlags::enableViewCulling()) return false;
816
+ if (changed == kWidthChanged || node.committedChildren.empty()) return false;
817
+ // The two lists must agree on WIDTH before position can be used as a key between them. Layout
818
+ // substitutes in place and never changes the count, so this holds wherever the rest of the guard
819
+ // does — it is here because `standing` is read from Fabric rather than maintained by us, and a
820
+ // rule that rests on an index must say out loud which index space it means.
821
+ if (standing.size() != node.committedChildren.size()) return false;
822
+ // A props change of our OWN can dirty us through `updateYogaProps`, and a dirty parent is what
823
+ // sends the layout pass into the children this path declined to re-adopt. The clone is checked
824
+ // again after it exists, because `completeClone` dirties a measurable node whatever its props did.
825
+ if (node.selfDirty) return false;
826
+ // A PARENT THAT DERIVES ITS OWN PAYLOAD FROM ITS CHILDREN CANNOT HAVE THEM SWAPPED SILENTLY.
827
+ //
828
+ // `LeafYogaNode` is Fabric's own name for a node whose children are CONTENT rather than laid-out
829
+ // children — `ParagraphShadowNode` is the one that matters here: its `AttributedString` is built
830
+ // from its children and published as STATE during layout
831
+ // (`updateStateIfNeeded<ParagraphState>`, ParagraphShadowNode.cpp:336). The targeted path hands
832
+ // `childrenPlaceholder()` and rewrites one slot, which changes the content and dirties NOTHING, so
833
+ // the paragraph keeps the state it measured last time. The tree is then correct and the screen is
834
+ // stale, because the differ compares ShadowViews and a ShadowView carries state: with the old state
835
+ // still standing it sees no change and tells the platform nothing.
836
+ //
837
+ // Measured, not reasoned: `react-state-reaches-the-screen.itest.tsx` reads
838
+ // `Update {type: "Paragraph"}` on every round with this path off and NOTHING with it on, while the
839
+ // committed shadow tree carries the new text in both arms. That is the whole device regression —
840
+ // a label stuck at 50% under a moving thumb.
841
+ //
842
+ // `replacedChangedChildren`'s own comment came within one word of this: it argues the parent needs
843
+ // no dirtying because "`completeClone` sets one only for a measurable node, which a `<View>` list
844
+ // parent is not". True of a `<View>`, and the reason the 500x list case is safe — and exactly
845
+ // false of a `<Text>`.
846
+ if (node.committed->getTraits().check(
847
+ react::ShadowNodeTraits::Trait::LeafYogaNode)) {
848
+ return false;
849
+ }
850
+ if (!replacementsAreLayoutClean(node.committedChildren, next)) return false;
851
+ if (!replacementsAreFresh(node.committedChildren, standing, next)) return false;
852
+ // Indices align, so every `replaceChild` is O(1) however many of them there are, and the bound
853
+ // below has nothing left to protect.
854
+ if (indicesAlign) return changed > 0;
855
+ // A MIXED parent, the one case where `suggestedIndex` genuinely cannot be trusted. Bound the share
856
+ // of the width so k replacements cannot become O(N*k); this is a safety property, not a knob.
857
+ //
858
+ // The Yoga override validates `suggestedIndex` against `yogaLayoutableChildren_` and falls back to
859
+ // a `find_if` when it does not match — SLOW, never wrong, which is the failure mode that ships.
860
+ // The two vectors diverge whenever a child is not Yoga-layoutable, and one is:
861
+ // `RawTextShadowNode` extends plain `ShadowNode`. Our commit walk only ever puts raw text under a
862
+ // text element, where the yoga vector is EMPTY and the scan is free — but nothing enforces that
863
+ // shape, and k replacements over a width-N parent would be O(N*k) if it ever stopped holding.
864
+ //
865
+ // Capping the moved share keeps that product bounded and costs nothing real: the win is
866
+ // concentrated exactly where few positions move (a select on 1 000 rows moves 2 and saves 500x),
867
+ // while a list whose every child moved measures 1.7x — the marginal case, and the one carrying the
868
+ // risk. It goes over whole, as before.
869
+ return changed > 0 && changed * 2 <= next.size();
870
+ }
871
+
872
+ // WHY THERE IS NO APPEND PATH HERE, since the shape obviously invites one.
873
+ //
874
+ // `ShadowNode::appendChild` is public, virtual, O(1) per child in the Yoga override, and absent from
875
+ // the JSI surface — the same lever `replaceChild` is. F-48 built it and measured it: same tree, same
876
+ // layout, and the children a commit walks halved for a 1 000-onto-1 000 append.
877
+ //
878
+ // F-51 withdrew it, and unlike the targeted replace it has no safe case to narrow to. Appending
879
+ // leaves the standing children owned by the previous revision, exactly as replacing does — but an
880
+ // append CHANGES THE CHILD COUNT, so the parent is dirty by construction, the layout pass always
881
+ // does work on it, and it always clones every standing child. `core/engine/bench/replace-child-
882
+ // layout-clones.cpp` reads 1 000 of 1 004 recorded slots stale afterwards, and the next commit's
883
+ // `replaceChild` cannot find the child it was told to replace. There is no condition to guard with:
884
+ // the unsafe case IS the case.
885
+ //
886
+ // `core/engine/bench/append-child-equivalence.cpp` stays as the record of what it was worth.
887
+
888
+ /**
889
+ * Rewrite every moved slot of `parent`, leaving the rest of its children untouched.
890
+ *
891
+ * The index is passed as `suggestedIndex` and is always the real one, since we built both vectors:
892
+ * `ShadowNode::replaceChild` and its Yoga override each VALIDATE it and fall back to a linear scan,
893
+ * so a wrong index would be slow rather than incorrect — but there is no reason to hand them one.
894
+ *
895
+ * THE DIRTY FLAG IS THE HALF THAT IS EASY TO MISS, and this function no longer touches it because
896
+ * `canReplaceInPlace` now refuses the case entirely. Handing a child list over ends in
897
+ * `YogaLayoutableShadowNode::updateYogaChildren`, whose last line is `yogaNode_.setDirty(!isClean)`
898
+ * — a dirty child dirties its parent, one level per clone, so a row whose height moved reaches the
899
+ * layout pass. Nothing here does that: `yoga::Node::replaceChild` sets no flag, and `completeClone`
900
+ * sets one only for a measurable node, which a `<View>` list parent is not.
901
+ *
902
+ * F-46 answered that by dirtying the parent from here. F-51 found the deeper problem the flag could
903
+ * not fix — a layout pass on this parent clones the children this path declined to re-adopt, and the
904
+ * commit's own record of them goes stale — and moved the answer into the guard: no replacement may
905
+ * move layout. With that in force there is nothing left to dirty, and a flag that can never be set
906
+ * is worse than no flag, because it reads as protection.
907
+ *
908
+ * Measured rather than argued, in `core/engine/bench/replace-child-layout-clones.cpp`.
909
+ */
910
+ void replacedChangedChildren(
911
+ react::ShadowNode &parent,
912
+ const ChildSet &recorded,
913
+ const ChildSet &next) {
914
+ // THE OLD CHILD IS READ OFF THE PARENT, NOT OFF OUR RECORD, and the split is the fix that let this
915
+ // path come back on. `recorded` is what `next` was derived from, so it is the only list that can
916
+ // answer "did this slot move"; but it can name a node the LAYOUT pass has since replaced in place
917
+ // (`liveChildrenOf`), and naming that node is precisely the "Child to replace was not found" abort.
918
+ // The parent is never wrong about its own children, so the node to replace is read from there.
919
+ //
920
+ // A slot that moved AND was substituted resolves correctly under both readings: the replacement is
921
+ // a clone of our own (pre-layout) node, which is in the same family, and the subtree is dirty by
922
+ // construction so the layout metrics it drops are recomputed on this very commit.
923
+ const ChildSet &standing = parent.getChildren();
924
+ for (size_t at = 0; at < next.size() && at < standing.size(); at += 1) {
925
+ if (recorded[at] == next[at]) continue;
926
+ parent.replaceChild(*standing[at], next[at], at);
927
+ }
928
+ }
929
+
930
+ /**
931
+ * The family generation a child attached under `parent` should be carrying.
932
+ *
933
+ * `nullptr` is the surface's child set, which is not a node and mints no family — so a top-level
934
+ * node answers against 0, its own resting value, and is never rebuilt for this.
935
+ */
936
+ unsigned generationOf(const Node *parent) {
937
+ return parent == nullptr ? 0u : parent->familyGeneration;
938
+ }
939
+
940
+ /** Whether this node's own text makes it invisible. An empty `RCTRawText` would actually paint. */
941
+ bool isEmptyRawText(const Node &node) {
942
+ if (node.kind != kKindRawText) return false;
943
+ const auto *text = node.props.get_ptr("text");
944
+ return text == nullptr || !text->isString() || text->asString().empty();
945
+ }
946
+
947
+ /**
948
+ * The minimal payload for a CLONE.
949
+ *
950
+ * Fabric merges a clone's raw props onto the node's existing ones, so a key the node no longer has
951
+ * must be sent as an explicit `null` to reset it to the default, and an unchanged key must not be
952
+ * sent at all — re-sending re-invokes that prop's native setter, and AndroidProgressBar's
953
+ * `styleAttr` setter rebuilds the whole view. Mirrors React's own `diffProperties`.
954
+ */
955
+ folly::dynamic diffProps(const folly::dynamic &previous, const folly::dynamic &next) {
956
+ folly::dynamic out = folly::dynamic::object();
957
+ for (const auto &pair : next.items()) {
958
+ const auto *before = previous.get_ptr(pair.first);
959
+ if (before == nullptr || *before != pair.second) out[pair.first] = pair.second;
960
+ }
961
+ for (const auto &pair : previous.items()) {
962
+ if (next.get_ptr(pair.first) == nullptr) out[pair.first] = nullptr;
963
+ }
964
+ return out;
965
+ }
966
+
967
+ std::shared_ptr<const react::ShadowNode> materialize(
968
+ jsi::Runtime &runtime,
969
+ react::UIManager &uiManager,
970
+ Node &node,
971
+ bool hasTextAncestor,
972
+ react::SurfaceId surfaceId,
973
+ const Node *fabricParent);
974
+
975
+ /**
976
+ * The node's own payload fold, reached through the JS handle it is published on.
977
+ *
978
+ * Empty for the ~all of them that carry none, and the PROBE is what that costs: a `WeakObject` lock
979
+ * plus a property read, on the commit path. So the answer is cached — `foldProbe` — and the
980
+ * assumption that makes caching sound is that a fold is assigned before the node's first commit.
981
+ * Every one is: `attachHostBehavior` writes `node.payloadFold` immediately after `createElement`,
982
+ * and the per-node folds a behavior builds itself (`stickyFold`, `contentFold`) are written in
983
+ * `attach` / `buildStructure`, which run there too. A fold assigned after the first commit is
984
+ * ignored, silently — the reason this paragraph exists rather than a shorter one.
985
+ *
986
+ * The returned closure captures `runtime` and the handle by value; it lives only for the duration of
987
+ * one `fabricProps` call, so nothing here outlives the commit that made it. Deliberately NOT stored
988
+ * on the Node: the closure names the handle, the handle owns the Node through `NativeState`, and a
989
+ * `Node -> Function -> handle -> Node` edge is the GC-boundary cycle this file's header refuses.
990
+ */
991
+ /**
992
+ * The nearest ANCESTOR carrying `tag`, for `IAncestorLookup`. Never the node itself.
993
+ *
994
+ * `ownerProps` answers "my parent", which is what almost every derived rule wants. Button's label is
995
+ * the exception: its style is a function of the BUTTON's `color` and `disabled`, and the button is
996
+ * its grandparent on iOS (`button -> view -> text`) and its parent on Android. Asking for "two up"
997
+ * would encode one platform's tree shape into a rule; asking for the nearest button is the same
998
+ * question a CSS ancestor selector asks and is true on both.
999
+ *
1000
+ * Unbounded in principle and short in practice: the one rule that uses it looks one or two hops up,
1001
+ * and a MISS walks to the root. That is affordable because nothing calls this per node — it is
1002
+ * reached only from inside a tag rule that has already matched.
1003
+ */
1004
+ /**
1005
+ * The parent as `IOwner` — its props, its TAG, and its press bit.
1006
+ *
1007
+ * The tag is the half that is new (2026-09-18) and it is what lets a rule be a DESCENDANT rule:
1008
+ * `TouchableNativeFeedback` and `TouchableWithoutFeedback` commit an anchor and clone their props
1009
+ * onto whatever child the app wrote, and that child usually carries no tag of its own.
1010
+ *
1011
+ * `c_str()` on `Node::tagName`, whose storage outlives the `fabricProps` call it is handed to —
1012
+ * the node is alive for the whole commit. A default-constructed `IOwner` at a root, so a rule that
1013
+ * asks about its parent gets the same answer as one whose parent is nameless.
1014
+ */
1015
+ IOwner ownerOf(const Node &node) {
1016
+ if (node.parent == nullptr) return {};
1017
+ return IOwner{
1018
+ &node.parent->props,
1019
+ node.parent->tagName.c_str(),
1020
+ (node.parent->pressListeners & kPressListenerPress) != 0,
1021
+ node.parent->underlayShown,
1022
+ node.parent->pressListeners != 0};
1023
+ }
1024
+
1025
+ /** The node's own non-prop bits, unpacked from the mask the ops maintain. See `ISelf`. */
1026
+ ISelf selfOf(const Node &node) {
1027
+ return ISelf{
1028
+ (node.pressListeners & kPressListenerPress) != 0,
1029
+ node.pressListeners != 0,
1030
+ node.underlayShown};
1031
+ }
1032
+
1033
+ /**
1034
+ * The first child as `IFirstChild` — the mirror of `ownerOf`, and the only read here that goes DOWN.
1035
+ *
1036
+ * SKIPS HOLES RATHER THAN ASSUMING COMPACTION. `Node::children` may hold nulls between a detach and
1037
+ * the next read (`compactChildren`), and both `fabricProps` call sites sit inside a walk that
1038
+ * compacts — but relying on that would make a correct rule depend on the caller's order, which is
1039
+ * the kind of coupling `compactChildren` was introduced to remove. One branch per entry, and the
1040
+ * loop stops at the first live one.
1041
+ */
1042
+ IFirstChild firstChildOf(const Node &node) {
1043
+ for (const auto &child : node.children) {
1044
+ if (child == nullptr) continue;
1045
+ return IFirstChild{&child->props, child->tagName.c_str()};
1046
+ }
1047
+ return {};
1048
+ }
1049
+
1050
+ const folly::dynamic *ancestorPropsOf(const void *context, const char *tag) {
1051
+ const auto *node = static_cast<const Node *>(context);
1052
+ if (node == nullptr || tag == nullptr) return nullptr;
1053
+ for (const Node *up = node->parent; up != nullptr; up = up->parent) {
1054
+ if (up->tagName == tag) return &up->props;
1055
+ }
1056
+ return nullptr;
1057
+ }
1058
+
1059
+ IPayloadFold foldFor(jsi::Runtime &runtime, Node &node) {
1060
+ if (node.foldProbe == FoldProbe::absent) return {};
1061
+
1062
+ jsi::Value handle = handleOf(runtime, node);
1063
+ if (!handle.isObject()) {
1064
+ // A collected handle is one no JS caller holds, so nothing can be waiting on its fold. Not
1065
+ // cached as `absent`: the miss is about the handle, not about the node.
1066
+ return {};
1067
+ }
1068
+ jsi::Value fold = handle.getObject(runtime).getProperty(runtime, "payloadFold");
1069
+ if (!fold.isObject() || !fold.getObject(runtime).isFunction(runtime)) {
1070
+ node.foldProbe = FoldProbe::absent;
1071
+ return {};
1072
+ }
1073
+ node.foldProbe = FoldProbe::present;
1074
+
1075
+ // Through a `shared_ptr` because `IPayloadFold` is a `std::function`, which requires a COPYABLE
1076
+ // callable, and `jsi::Function` is move-only. Capturing it by value does not compile.
1077
+ auto function = std::make_shared<jsi::Function>(fold.getObject(runtime).getFunction(runtime));
1078
+ return [&runtime, function](const folly::dynamic &props) {
1079
+ // Billed in three because the fold's CONTRACT is bag in, bag out, and that is a different cost
1080
+ // from the fold's own work: both conversions walk every key of the node whatever the fold reads.
1081
+ // If the conversions dominate, the fix is to narrow the contract; if the call does, the fix is to
1082
+ // not have a fold. The split is the only thing that can say which.
1083
+ auto startedAt = ISteadyClock::now();
1084
+ jsi::Value argument = jsi::valueFromDynamic(runtime, props);
1085
+ walkCost_.foldToJsNs += nanosSince(startedAt);
1086
+
1087
+ startedAt = ISteadyClock::now();
1088
+ jsi::Value result = function->call(runtime, std::move(argument));
1089
+ walkCost_.foldCallNs += nanosSince(startedAt);
1090
+
1091
+ startedAt = ISteadyClock::now();
1092
+ folly::dynamic folded = boundedDynamicFrom(
1093
+ runtime, std::move(result), [] { return std::string("the payloadFold result"); });
1094
+ walkCost_.foldFromJsNs += nanosSince(startedAt);
1095
+ return folded;
1096
+ };
1097
+ }
1098
+
1099
+ /**
1100
+ * Put `node`'s Fabric contribution into `out` — which is zero, one, or several nodes.
1101
+ *
1102
+ * An ANCHOR contributes its own children in its place, recursively: it is a position marker the
1103
+ * framework inserted and it has no Fabric counterpart. An empty raw text contributes nothing. A
1104
+ * SURFACE reaches here too, as an anchor, which is why the commit needs no `rootTag -> node` map.
1105
+ */
1106
+ void appendRenderable(
1107
+ jsi::Runtime &runtime,
1108
+ react::UIManager &uiManager,
1109
+ ChildSet &out,
1110
+ // Which of OUR nodes contributed `out[i]`, parallel and always the same length. It exists so a
1111
+ // parent can adopt back what Fabric actually kept — see `adoptLandedChildren`. It cannot be
1112
+ // derived afterwards: an anchor contributes its children in its place, recursively, so the
1113
+ // mapping is not `node.children[i]`.
1114
+ //
1115
+ // The raw-text count rides along for the same reason: this walk already holds every child's
1116
+ // kind, and recovering it later cost a full second pass (F-49).
1117
+ IOwnerTally &owners,
1118
+ Node &node,
1119
+ bool hasTextAncestor,
1120
+ react::SurfaceId surfaceId,
1121
+ const Node *fabricParent) {
1122
+ if (node.kind == kKindAnchor) {
1123
+ // The anchor is transparent, so its children's Fabric parent is the anchor's, not the anchor.
1124
+ compactChildren(node);
1125
+ for (const auto &child : node.children) {
1126
+ appendRenderable(
1127
+ runtime, uiManager, out, owners, *child, hasTextAncestor, surfaceId, fabricParent);
1128
+ }
1129
+ // Walked, so its flags clear here — `materialize` never sees it. See `markDirty`.
1130
+ node.selfDirty = false;
1131
+ node.pathDirty = false;
1132
+ return;
1133
+ }
1134
+ if (node.kind == kKindVoid) {
1135
+ // Unlike an anchor, a void node's children are NOT recursed into — they contribute nothing,
1136
+ // recursively, which is the whole point (`InputAccessoryView.js`'s Android `return null`). Its
1137
+ // own flags clear the same way an anchor's do, so `materialize` never sees it either.
1138
+ node.selfDirty = false;
1139
+ node.pathDirty = false;
1140
+ return;
1141
+ }
1142
+ if (isEmptyRawText(node)) {
1143
+ node.selfDirty = false;
1144
+ node.pathDirty = false;
1145
+ return;
1146
+ }
1147
+ out.push_back(materialize(runtime, uiManager, node, hasTextAncestor, surfaceId, fabricParent));
1148
+ owners.nodes.push_back(&node);
1149
+ if (node.kind == kKindRawText) owners.rawTexts += 1;
1150
+ }
1151
+
1152
+ // Take back the children Fabric actually kept, because it does NOT always keep the ones it was
1153
+ // given.
1154
+ //
1155
+ // `YogaLayoutableShadowNode::adoptYogaChild` clones a child that is still owned by its previous
1156
+ // parent's yoga node and swaps the clone into the list with `replaceChild` — RN's own comment there
1157
+ // says "At this point, React has wrong reference to the node. (T138668036)". So after a clone or an
1158
+ // append loop, our `Node::committed` can name a node that is NOT in the committed tree.
1159
+ //
1160
+ // Left uncorrected, that costs the whole commit: `updateMountedFlag` (`updateMountedFlag.cpp:57`)
1161
+ // and `progressState` (`ShadowTree.cpp:135`) both skip a subtree only when the child pointer is
1162
+ // IDENTICAL between revisions. Handing back the orphan makes every child differ on the next commit,
1163
+ // so both walks — and the differ behind them — descend the entire tree for a one-row change.
1164
+ //
1165
+ void adoptLandedChildren(Node &node, const std::vector<Node *> &owners) {
1166
+ // The generation every owner is now attached under. Its own loop, over ALL of them rather than
1167
+ // the min below: a child Fabric did not keep is still a child we handed over, and leaving it on a
1168
+ // stale generation would rebuild it forever. Recorded here rather than at the append, because the
1169
+ // clone paths never append at all and their children are attached just the same — and `owners`
1170
+ // is what carries anchors' hoisted children.
1171
+ for (Node *owner : owners) owner->committedUnderGeneration = node.familyGeneration;
1172
+ const ChildSet &landed = node.committed->getChildren();
1173
+ const size_t count = std::min(owners.size(), landed.size());
1174
+ for (size_t index = 0; index < count; index++) {
1175
+ if (owners[index]->committed == landed[index]) continue;
1176
+ owners[index]->committed = landed[index];
1177
+ }
1178
+ // What Fabric HOLDS, not what we offered — `sameNodes` on the next commit has to compare against
1179
+ // the tree that exists, or an unchanged list reads as changed forever.
1180
+ node.committedChildren = landed;
1181
+ }
1182
+
1183
+ // `appendRenderable`'s traversal with the materialising taken out: which of our nodes contribute
1184
+ // `node`'s Fabric children, in order. Anchors hoist theirs, an empty raw text contributes nothing.
1185
+ void collectRenderableOwners(Node &node, std::vector<Node *> &owners) {
1186
+ compactChildren(node);
1187
+ for (const auto &child : node.children) {
1188
+ if (child->kind == kKindAnchor) {
1189
+ collectRenderableOwners(*child, owners);
1190
+ continue;
1191
+ }
1192
+ // A void node contributes no owners of its own AND none of its children's — the same asymmetry
1193
+ // with the anchor branch above as `appendRenderable`'s.
1194
+ if (child->kind == kKindVoid) continue;
1195
+ if (isEmptyRawText(*child)) continue;
1196
+ owners.push_back(child.get());
1197
+ }
1198
+ }
1199
+
1200
+ // Re-point the retained tree at the nodes Fabric ACTUALLY COMMITTED, after the commit.
1201
+ //
1202
+ // This is the repair for the one number that never moved: the measurable text nodes still DIRTY in
1203
+ // the tree about to be committed read 4 971 on every step, even right after a full layout. Yoga
1204
+ // clears a node's dirty flag on the object
1205
+ // it laid out (`CalculateLayout.cpp`, `setDirty(false)` under `performLayout`), so ours staying
1206
+ // dirty forever means the objects that got laid out were not ours — Fabric substituted clones
1207
+ // inside the commit and we went on holding the originals. Every later commit then handed it a tree
1208
+ // whose every measurable leaf was dirty, which is why a 33-write Swap re-measured 2 867 texts while
1209
+ // React's own renderer, on the identical tree in the same binary, re-measured none.
1210
+ //
1211
+ // `adoptLandedChildren` already did this DURING the walk, and that is why it is kept — but it only
1212
+ // ever sees children of nodes the walk materialised, which on a one-row change is a handful. The
1213
+ // other 999 rows kept stale pointers. This pass closes that, and it has to run after the commit
1214
+ // because substitution happens inside it.
1215
+ //
1216
+ // O(changed), not O(tree): a ShadowNode is immutable, so an identical pointer means an identical
1217
+ // subtree and the descent stops there.
1218
+ void adoptCommitted(Node &node, const std::shared_ptr<const react::ShadowNode> &landed) {
1219
+ if (node.committed == nullptr || landed == nullptr) return;
1220
+ // A stranger is not adopted. `completeSurface` returns void, so a commit that was cancelled or
1221
+ // lost a race leaves the registry holding the PREVIOUS revision — and walking that would drag our
1222
+ // pointers backwards, which is worse than the staleness this exists to fix. Family identity is
1223
+ // what tells the two apart.
1224
+ if (!react::ShadowNode::sameFamily(*node.committed, *landed)) return;
1225
+ if (node.committed == landed) return;
1226
+
1227
+ node.committed = landed;
1228
+ std::vector<Node *> owners;
1229
+ collectRenderableOwners(node, owners);
1230
+ const ChildSet &children = landed->getChildren();
1231
+ const size_t count = std::min(owners.size(), children.size());
1232
+ for (size_t index = 0; index < count; index++) {
1233
+ adoptCommitted(*owners[index], children[index]);
1234
+ }
1235
+ node.committedChildren = children;
1236
+ }
1237
+
1238
+ std::shared_ptr<const react::ShadowNode> materialize(
1239
+ jsi::Runtime &runtime,
1240
+ react::UIManager &uiManager,
1241
+ Node &node,
1242
+ bool hasTextAncestor,
1243
+ react::SurfaceId surfaceId,
1244
+ const Node *fabricParent) {
1245
+ // A REFERENCE, and the `&` is the whole point. This ran before the reuse fast path below and
1246
+ // constructed a `std::string` on every call — including the ~all of them that are about to return
1247
+ // the committed node untouched. Counted through Solid on a 1 000-row list
1248
+ // (`adapters/solid/src/work-ledger.probe.test.tsx`): 9 002 calls on a create, 1 005 on a select
1249
+ // that rebuilds 3 nodes, 10 002 on an append. Most view names fit libc++'s 22-byte inline buffer,
1250
+ // but `RCTSinglelineTextInputView` is 26 and heap-allocates, so the benchmark row pays a malloc
1251
+ // and a free per TextInput per commit for a name it already holds.
1252
+ //
1253
+ // It cannot move below the fast path: `needsFreshFamily` is one of the fast path's own conditions
1254
+ // and reads it. Binding a reference is what makes it free rather than what makes it later.
1255
+ const std::string &viewName =
1256
+ (node.isText && hasTextAncestor) ? kVirtualTextViewName : node.viewName;
1257
+ // Two things force a FRESH FAMILY rather than a clone, and neither is visible in the dirty pair.
1258
+ //
1259
+ // A node whose view name flipped cannot be cloned into the other one — no prop write moves a node
1260
+ // between native views. And a node handed to a different parent cannot either: a Fabric node
1261
+ // belongs to one family, so a MOVE rebuilds even when the node itself is perfectly clean. That
1262
+ // second one is why `fabricParent` is threaded at all, and it is not theoretical — the fake host
1263
+ // asserts it (`fake-fabric.ts`'s `assertSameFamily`) and found it in the reference applier.
1264
+ //
1265
+ // THE THIRD is the PARENT's rebuild, and it is what aborted a Debug build inside
1266
+ // `ShadowNodeFamily::setParent`. A parent that flips its view name keeps its tag and its node
1267
+ // identity, so `committedParent` sees no change and the child takes the reuse path — into a
1268
+ // family it does not belong to. Generations see it, and they see it even for a child that slept
1269
+ // through the rebuild: an empty raw text is skipped entirely and never updates its own.
1270
+ const bool needsFreshFamily = node.committed != nullptr &&
1271
+ (viewName != node.committedViewName || node.committedParent != fabricParent ||
1272
+ node.committedUnderGeneration != generationOf(fabricParent));
1273
+
1274
+ // The reuse fast path needs the node to be clean AND its CONTEXT to be the one it committed under.
1275
+ // Those are two different questions: the dirty pair is about ops that named this subtree, and the
1276
+ // context is about a decision taken above it that the subtree's payload depends on. A node that
1277
+ // moved keeps both flags false.
1278
+ //
1279
+ // Text ancestry is the sharp one, because a rebuild here is not about this node at all — a plain
1280
+ // `<View>` carried under a `<Text>` sends the identical payload under an identical name, and the
1281
+ // `<Text>` UNDER it has to switch to `RCTVirtualText`. Reuse and the whole subtree keeps the old
1282
+ // name, and only the first intermediate node has to be clean for it to happen.
1283
+ const bool contextHeld =
1284
+ node.committedTextAncestor == hasTextAncestor && node.committedSurfaceId == surfaceId;
1285
+
1286
+ if (!node.selfDirty && !node.pathDirty && node.committed != nullptr && !needsFreshFamily &&
1287
+ contextHeld) {
1288
+ walkCost_.reused += 1;
1289
+ return node.committed;
1290
+ }
1291
+
1292
+ auto children = std::make_shared<ChildSet>();
1293
+ IOwnerTally owners;
1294
+ // STICKY, per `commit.ts:964` — once inside a text element everything below is virtual, including
1295
+ // through a non-text element in between.
1296
+ const bool childHasTextAncestor = hasTextAncestor || node.isText;
1297
+ // BUMPED BEFORE THE CHILDREN ARE WALKED, which is the whole trick: the create branch below has
1298
+ // not run yet, so a child asking about its parent's family has to be told what it is ABOUT to be.
1299
+ // Same condition that branch tests — a node with no committed form is minting its first family,
1300
+ // which is a rebuild from a child's point of view exactly as a re-creation is.
1301
+ if (node.committed == nullptr || needsFreshFamily) node.familyGeneration += 1;
1302
+ compactChildren(node);
1303
+ for (const auto &child : node.children) {
1304
+ appendRenderable(
1305
+ runtime, uiManager, *children, owners, *child, childHasTextAncestor, surfaceId, &node);
1306
+ }
1307
+
1308
+ if (node.committed == nullptr || needsFreshFamily) {
1309
+ if (node.tag == 0) node.tag = allocateTag();
1310
+ // `node.viewName`, NOT the resolved `viewName` above: the fold keys its processors on the
1311
+ // AUTHORED component, which a nested `<Text>` never has rewritten to `RCTVirtualText`. Passing
1312
+ // the local would silently change which processors run on every nested text node.
1313
+ //
1314
+ auto startedAt = ISteadyClock::now();
1315
+ IPayloadFold fold = foldFor(runtime, node);
1316
+ walkCost_.foldLookupNs += nanosSince(startedAt);
1317
+ if (fold) walkCost_.foldsFound += 1;
1318
+
1319
+ startedAt = ISteadyClock::now();
1320
+ // The parent's props, for the rules that are DERIVED from the node above (ScrollView's content
1321
+ // view takes `collapsableChildren` from the scroller). A pointer hop, because the tree is here —
1322
+ // the same question cost a JS closure and a crossing while the fold lived on the other side.
1323
+ folly::dynamic payload = fabricProps(
1324
+ node.viewName,
1325
+ node.tagName,
1326
+ node.props,
1327
+ fold,
1328
+ ownerOf(node),
1329
+ selfOf(node),
1330
+ IAncestorLookup{&ancestorPropsOf, &node},
1331
+ firstChildOf(node));
1332
+ walkCost_.propsNs += nanosSince(startedAt);
1333
+ // The payload is needed TWICE and only one of those needs a copy. `RawProps` takes its
1334
+ // `folly::dynamic` BY VALUE (`RawProps.h:65`) and consumes it, so Fabric's half is a copy no
1335
+ // matter what; the baseline `diffProps` will read on the next commit is the other half, and it
1336
+ // used to be a SECOND deep copy because `payload` was const. Every key and every value of every
1337
+ // created node, twice — 32 001 entries on a 1 000-row Solid create rather than 32 001 plus a
1338
+ // pointer swap. The update path below already moved both of its halves; only create did not.
1339
+ startedAt = ISteadyClock::now();
1340
+ folly::dynamic forFabric = payload;
1341
+ walkCost_.rawPropsNs += nanosSince(startedAt);
1342
+
1343
+ startedAt = ISteadyClock::now();
1344
+ auto created = uiManager.createNode(
1345
+ node.tag,
1346
+ viewName,
1347
+ surfaceId,
1348
+ react::RawProps(std::move(forFabric)),
1349
+ node.instanceHandle);
1350
+ walkCost_.createNs += nanosSince(startedAt);
1351
+
1352
+ startedAt = ISteadyClock::now();
1353
+ for (const auto &child : *children) uiManager.appendChild(created, child);
1354
+ walkCost_.appendNs += nanosSince(startedAt);
1355
+ walkCost_.created += 1;
1356
+ node.committed = created;
1357
+ node.committedProps = std::move(payload);
1358
+ } else {
1359
+ // DIRTY is not CHANGED, and this is the only place that can tell them apart. An op names a node
1360
+ // whether or not it moved a value: a framework re-rendering identical content hands back a fresh
1361
+ // object for an unchanged style, and identity is all `setProp` has to go on, so it correctly
1362
+ // lets that through. `diffProps` compares by VALUE and is the first thing that can see there is
1363
+ // nothing to send. Cloning anyway is not merely wasted work — a new node propagates to the root
1364
+ // and makes the surface commit, so an app re-rendering the same tree pays a full
1365
+ // `ShadowTree::commit`, with layout and a mount pass, per render.
1366
+ // Folded before diffing, so `committedProps` holds the same alphabet on both paths — diffing a
1367
+ // folded baseline against a raw bag reports every hoisted style key as vanished, every commit.
1368
+ folly::dynamic next = folly::dynamic::object();
1369
+ folly::dynamic payload = folly::dynamic::object();
1370
+ if (node.selfDirty) {
1371
+ auto startedAt = ISteadyClock::now();
1372
+ IPayloadFold fold = foldFor(runtime, node);
1373
+ walkCost_.foldLookupNs += nanosSince(startedAt);
1374
+ if (fold) walkCost_.foldsFound += 1;
1375
+
1376
+ startedAt = ISteadyClock::now();
1377
+ next = fabricProps(
1378
+ node.viewName,
1379
+ node.tagName,
1380
+ node.props,
1381
+ fold,
1382
+ ownerOf(node),
1383
+ selfOf(node),
1384
+ IAncestorLookup{&ancestorPropsOf, &node},
1385
+ firstChildOf(node));
1386
+ walkCost_.propsNs += nanosSince(startedAt);
1387
+ startedAt = ISteadyClock::now();
1388
+ payload = diffProps(node.committedProps, next);
1389
+ walkCost_.diffNs += nanosSince(startedAt);
1390
+ }
1391
+ walkCost_.cloned += 1;
1392
+ const size_t changedPositions = countChangedPositions(node.committedChildren, *children);
1393
+ const bool childrenHeld = changedPositions == 0;
1394
+ const bool sendsNothing = payload.empty() && childrenHeld;
1395
+ if (!sendsNothing) {
1396
+ // A CHILD LIST IS NOT A FREE ARGUMENT — hand it over only when it actually changed.
1397
+ //
1398
+ // `fragment.children` is read as a flag three times inside the clone, and every one of them is
1399
+ // expensive on a list that did not move (`YogaLayoutableShadowNode.cpp`): it forces
1400
+ // `updateYogaChildren()`, which calls `adoptYogaChild` per child, and a child already owned by
1401
+ // its previous parent's yoga node is CLONED and swapped in by `replaceChild` — RN's own TODO
1402
+ // there says the caller is left holding the wrong reference. It also drops
1403
+ // `yogaTreeHasBeenConfigured_`, forcing a full `configureYogaTree` descent, and dirties
1404
+ // measurement in `completeClone`. So a props-only change on a parent of a thousand rows
1405
+ // re-clones all thousand, every commit, forever.
1406
+ //
1407
+ // The previous JS engine drew this line and this file had lost it: a props-only change went
1408
+ // through `cloneNodeWithNewProps`, with no child list at all (`commit.ts:569`).
1409
+ //
1410
+ // AND WHEN IT DID CHANGE, IT STILL DOES NOT HAVE TO BE HANDED OVER WHOLE. See
1411
+ // `replacedChangedChildren` below — the same argument taken one step further.
1412
+ auto rawProps =
1413
+ node.selfDirty ? react::RawProps(std::move(payload)) : react::RawProps();
1414
+ if (canReplaceInPlace(
1415
+ node,
1416
+ liveChildrenOf(node),
1417
+ *children,
1418
+ changedPositions,
1419
+ childIndicesAlign(owners, *children))) {
1420
+ // The clone gets the PLACEHOLDER, so `fragment.children` is null and `updateYogaChildren()`
1421
+ // never runs (`YogaLayoutableShadowNode.cpp:149`) — nothing is re-adopted and nothing is
1422
+ // cloned. Then one slot per moved position is rewritten.
1423
+ //
1424
+ // `rawProps` is EMPTY here and not by luck: `canReplaceInPlace` declines a self-dirty node,
1425
+ // which is what lets the fallback below re-clone from the original without rebuilding it.
1426
+ auto cloned = uiManager.cloneNode(
1427
+ *node.committed, react::ShadowNodeFragment::childrenPlaceholder(), std::move(rawProps));
1428
+ // THE LAST GUARD, and it needs the clone to exist. `canReplaceInPlace` ruled out every way
1429
+ // this node's own props could dirty it, but `completeClone` dirties a MeasurableYogaNode on
1430
+ // any clone whatever its props did — and a dirty parent is what sends the layout pass into
1431
+ // the children this path just declined to re-adopt (F-51). Reading the answer costs one
1432
+ // virtual call; guessing it from traits would be a second copy of Fabric's rule.
1433
+ const auto *layoutable =
1434
+ dynamic_cast<const react::LayoutableShadowNode *>(cloned.get());
1435
+ if (layoutable == nullptr || layoutable->getIsLayoutClean()) {
1436
+ replacedChangedChildren(*cloned, node.committedChildren, *children);
1437
+ node.committed = std::move(cloned);
1438
+ targetedReplaces_ += 1;
1439
+ } else {
1440
+ node.committed =
1441
+ uiManager.cloneNode(*node.committed, children, react::RawProps());
1442
+ }
1443
+ } else {
1444
+ std::shared_ptr<const ChildSet> handedChildren =
1445
+ react::ShadowNodeFragment::childrenPlaceholder();
1446
+ if (!childrenHeld) handedChildren = children;
1447
+ node.committed =
1448
+ uiManager.cloneNode(*node.committed, handedChildren, std::move(rawProps));
1449
+ }
1450
+ }
1451
+ if (node.selfDirty) node.committedProps = std::move(next);
1452
+ }
1453
+
1454
+ adoptLandedChildren(node, owners.nodes);
1455
+ node.committedViewName = viewName;
1456
+ node.committedParent = fabricParent;
1457
+ node.committedTextAncestor = hasTextAncestor;
1458
+ node.committedSurfaceId = surfaceId;
1459
+ node.selfDirty = false;
1460
+ node.pathDirty = false;
1461
+ return node.committed;
1462
+ }
1463
+
1464
+ // A telemetry interval, or zero when either end was never stamped. `TelemetryClock` IS
1465
+ // `steady_clock` (`react/utils/Telemetry.h`), so no conversion is involved — but an unstamped point
1466
+ // is `TimePoint::max()`, and subtracting it yields a plausible-looking enormous number rather than
1467
+ // an error. A commit that skipped layout must read 0, not centuries.
1468
+ double millisBetween(
1469
+ react::TelemetryTimePoint started,
1470
+ react::TelemetryTimePoint ended) {
1471
+ if (started == react::kTelemetryUndefinedTimePoint ||
1472
+ ended == react::kTelemetryUndefinedTimePoint) {
1473
+ return 0;
1474
+ }
1475
+ return std::chrono::duration<double, std::milli>(ended - started).count();
1476
+ }
1477
+
1478
+ } // namespace
1479
+
1480
+ jsi::Value Tree::applyOps(jsi::Runtime &runtime, const jsi::Value *arguments, size_t count) {
1481
+ if (count < 5) {
1482
+ throw jsi::JSError(
1483
+ runtime,
1484
+ "symbiote engine: expected applyOps(ops, strings, values, instanceHandles, handles)");
1485
+ }
1486
+
1487
+ const auto applyStartedAt = ISteadyClock::now();
1488
+ walkCost_.applyCalls += 1;
1489
+ auto &uiManager = uiManagerFor(runtime, "applyOps");
1490
+
1491
+ size_t opsLength = 0;
1492
+ const int32_t *ops = int32ArrayData(runtime, arguments[0], opsLength);
1493
+ auto strings = arguments[1].asObject(runtime).asArray(runtime);
1494
+ auto values = arguments[2].asObject(runtime).asArray(runtime);
1495
+ auto instanceHandles = arguments[3].asObject(runtime).asArray(runtime);
1496
+ auto handles = arguments[4].asObject(runtime).asArray(runtime);
1497
+ const size_t slotCount = handles.size(runtime);
1498
+
1499
+ // Slot -> node, resolved at most ONCE per batch and usually not at all: a slot this batch creates
1500
+ // is written by its own create op and never read from JS, which on a create-shaped commit is
1501
+ // nearly every slot. Only a slot naming a node an EARLIER batch created costs a read, and it costs
1502
+ // exactly one however many ops go on to name it.
1503
+ std::vector<NodePtr> bySlot(slotCount);
1504
+
1505
+ auto checkSlot = [&](int32_t slot) -> size_t {
1506
+ if (slot < 0 || static_cast<size_t>(slot) >= slotCount) {
1507
+ throw jsi::JSError(
1508
+ runtime,
1509
+ "applyOps: op names slot " + std::to_string(slot) +
1510
+ ", which is outside this batch's handle array");
1511
+ }
1512
+ return static_cast<size_t>(slot);
1513
+ };
1514
+
1515
+ auto nodeAt = [&](int32_t slot) -> const NodePtr & {
1516
+ auto at = checkSlot(slot);
1517
+ auto &cached = bySlot[at];
1518
+ if (cached == nullptr) {
1519
+ cached = nodeFrom(runtime, handles.getValueAtIndex(runtime, at).asObject(runtime), "applyOps");
1520
+ }
1521
+ return cached;
1522
+ };
1523
+
1524
+ // Hand a freshly built node to its placeholder. THIS is where it acquires an owner, and it is the
1525
+ // only place one is taken — afterwards the object JS already holds is the handle, and when JS and
1526
+ // the parent both let go, the node goes.
1527
+ auto publish = [&](int32_t slot, NodePtr node) {
1528
+ const auto publishStartedAt = ISteadyClock::now();
1529
+ auto at = checkSlot(slot);
1530
+ auto object = handles.getValueAtIndex(runtime, at).asObject(runtime);
1531
+ // Taken once, here, for the same reason the node is: this is the only moment both halves are in
1532
+ // hand. Every later op resolves the node THROUGH the object, so the edge back can never be
1533
+ // re-derived from anything the ops carry.
1534
+ node->handle.emplace(runtime, object);
1535
+ const auto stateStartedAt = ISteadyClock::now();
1536
+ object.setNativeState(runtime, node);
1537
+ walkCost_.nativeStateNs += nanosSince(stateStartedAt);
1538
+ bySlot[at] = std::move(node);
1539
+ walkCost_.publishNs += nanosSince(publishStartedAt);
1540
+ };
1541
+
1542
+ // Decoded ONCE per batch, not once per op that names a string.
1543
+ //
1544
+ // This used to read the JSI array and allocate a fresh `std::string` inside the op loop, which
1545
+ // spent exactly the saving `mutation-buffer.ts` interns for: its own comment says a 1 000-row
1546
+ // create emits about a dozen distinct view names across 10 000 elements and draws every prop key
1547
+ // from a set of a few hundred, and none of that reached here. Counted through a real adapter
1548
+ // (`adapters/solid/src/batch-decode-census.probe.test.tsx`): 16 005 decodes against a table of
1549
+ // 2 008 entries on a create, and the same 8.0x on an append.
1550
+ //
1551
+ // Two costs go, and only one of them is measurable without a device. The allocation half a bench
1552
+ // puts at 3.26x for the whole path (`core/engine/bench/batch-string-decode.cpp`); the other half
1553
+ // is 13 997 JSI crossings that simply stop happening, and nothing headless can price those.
1554
+ std::vector<std::string> decodedStrings;
1555
+ {
1556
+ const auto stringsStartedAt = ISteadyClock::now();
1557
+ const size_t count = strings.size(runtime);
1558
+ decodedStrings.reserve(count);
1559
+ for (size_t at = 0; at < count; ++at) {
1560
+ decodedStrings.push_back(
1561
+ strings.getValueAtIndex(runtime, at).asString(runtime).utf8(runtime));
1562
+ }
1563
+ walkCost_.stringDecodeNs += nanosSince(stringsStartedAt);
1564
+ }
1565
+
1566
+ // Prop VALUES, converted at most once per entry per batch — the other half of the buffer's
1567
+ // interning, and useless without it. `mutation-buffer.ts` gives one entry to one object however
1568
+ // many nodes were handed it, so a `StyleSheet.create` style shared by a thousand rows arrives as
1569
+ // one entry; this is what turns that into one conversion instead of a thousand identical ones.
1570
+ //
1571
+ // LAZY rather than eager, unlike the strings above: a batch's value table can hold entries no
1572
+ // surviving op names — a prop written and then overwritten in the same batch — and converting one
1573
+ // eagerly would charge for work the ops do not ask for. The strings table has no such shape.
1574
+ //
1575
+ // One consequence worth knowing when a conversion throws: `boundedDynamicFrom`'s message names the
1576
+ // prop and view of the FIRST op to reach a given entry, not every op that shares it.
1577
+ std::vector<folly::dynamic> convertedValues(values.size(runtime));
1578
+ std::vector<bool> valueIsConverted(convertedValues.size(), false);
1579
+ walkCost_.valueEntries += convertedValues.size();
1580
+
1581
+ // Bounds-checked, which the per-op version got for free from `getValueAtIndex` throwing. A vector
1582
+ // would not throw — it would read past the end — so the check moves here with the decode.
1583
+ auto stringAt = [&](int32_t index) -> const std::string & {
1584
+ if (index < 0 || static_cast<size_t>(index) >= decodedStrings.size()) {
1585
+ throw jsi::JSError(
1586
+ runtime,
1587
+ "applyOps: op names string " + std::to_string(index) +
1588
+ ", which is outside this batch's strings table");
1589
+ }
1590
+ return decodedStrings[static_cast<size_t>(index)];
1591
+ };
1592
+
1593
+ // `auto &&describe` and not a `std::function`: the description must stay a lambda the compiler can
1594
+ // inline away, for the reason `boundedDynamicFrom`'s own comment gives — building the string
1595
+ // eagerly was 48.8% of the decode path once, and a `std::function` per op would allocate to
1596
+ // reintroduce half of it.
1597
+ auto valueAt = [&](int32_t index, auto &&describe) -> const folly::dynamic & {
1598
+ if (index < 0 || static_cast<size_t>(index) >= convertedValues.size()) {
1599
+ throw jsi::JSError(
1600
+ runtime,
1601
+ "applyOps: op names value " + std::to_string(index) +
1602
+ ", which is outside this batch's values table");
1603
+ }
1604
+ const auto at = static_cast<size_t>(index);
1605
+ if (!valueIsConverted[at]) {
1606
+ const auto convertStartedAt = ISteadyClock::now();
1607
+ convertedValues[at] =
1608
+ boundedDynamicFrom(runtime, values.getValueAtIndex(runtime, at), describe);
1609
+ walkCost_.propConvertNs += nanosSince(convertStartedAt);
1610
+ walkCost_.valueConversions += 1;
1611
+ valueIsConverted[at] = true;
1612
+ }
1613
+ return convertedValues[at];
1614
+ };
1615
+
1616
+ for (size_t at = 0; at + kOpStride <= opsLength; at += kOpStride) {
1617
+ switch (ops[at]) {
1618
+ case kOpCreateElement: {
1619
+ const auto decodeStartedAt = ISteadyClock::now();
1620
+ auto node = std::make_shared<Node>();
1621
+ node->kind = kKindElement;
1622
+ node->viewName = stringAt(ops[at + 2]);
1623
+ node->isText = ops[at + 3] != 0;
1624
+ node->tag = allocateTag();
1625
+ const auto handleStartedAt = ISteadyClock::now();
1626
+ node->instanceHandle = std::make_shared<const react::InstanceHandle>(
1627
+ runtime,
1628
+ instanceHandles.getValueAtIndex(runtime, static_cast<size_t>(ops[at + 4])),
1629
+ node->tag);
1630
+ walkCost_.instanceHandleNs += nanosSince(handleStartedAt);
1631
+ publish(ops[at + 1], std::move(node));
1632
+ walkCost_.decodeNs += nanosSince(decodeStartedAt);
1633
+ walkCost_.decoded += 1;
1634
+ break;
1635
+ }
1636
+ case kOpCreateRawText: {
1637
+ auto node = std::make_shared<Node>();
1638
+ node->kind = kKindRawText;
1639
+ node->viewName = "RCTRawText";
1640
+ node->props["text"] = stringAt(ops[at + 2]);
1641
+ publish(ops[at + 1], std::move(node));
1642
+ break;
1643
+ }
1644
+ case kOpCreateAnchor: {
1645
+ auto node = std::make_shared<Node>();
1646
+ node->kind = kKindAnchor;
1647
+ publish(ops[at + 1], std::move(node));
1648
+ break;
1649
+ }
1650
+ case kOpCreateVoid: {
1651
+ auto node = std::make_shared<Node>();
1652
+ node->kind = kKindVoid;
1653
+ publish(ops[at + 1], std::move(node));
1654
+ break;
1655
+ }
1656
+ case kOpAppendChild: {
1657
+ const auto structureStartedAt = ISteadyClock::now();
1658
+ const auto &parent = nodeAt(ops[at + 1]);
1659
+ auto child = nodeAt(ops[at + 2]);
1660
+ detachFromParent(child);
1661
+ child->parent = parent.get();
1662
+ const auto holdStartedAt = ISteadyClock::now();
1663
+ holdHandle(runtime, *child);
1664
+ walkCost_.holdHandleNs += nanosSince(holdStartedAt);
1665
+ // The hint the detach path reads back. Appending past a hole is harmless — the hole keeps
1666
+ // its place until the next read compacts, and order is preserved either way.
1667
+ child->slotInParent = parent->children.size();
1668
+ parent->children.push_back(std::move(child));
1669
+ markDirty(*parent);
1670
+ walkCost_.structureNs += nanosSince(structureStartedAt);
1671
+ break;
1672
+ }
1673
+ case kOpInsertBefore: {
1674
+ const auto &parent = nodeAt(ops[at + 1]);
1675
+ auto child = nodeAt(ops[at + 2]);
1676
+ const auto &before = nodeAt(ops[at + 3]);
1677
+ // A MOVE WITHIN THE SAME PARENT ERASES RATHER THAN PUNCHING A HOLE, and the reason is that
1678
+ // an insert shifts this vector anyway: a hole would force a compaction pass on top of the
1679
+ // shift, which measured 2.4x worse on a 4 000-row reorder than simply erasing. A move to a
1680
+ // DIFFERENT parent holes the old one as usual — nothing is about to shift it.
1681
+ if (child->parent == parent.get()) {
1682
+ auto &standing = parent->children;
1683
+ const size_t hinted = child->slotInParent;
1684
+ auto at = hinted < standing.size() && standing[hinted] == child
1685
+ ? standing.begin() + static_cast<std::ptrdiff_t>(hinted)
1686
+ : std::find(standing.begin(), standing.end(), child);
1687
+ if (at != standing.end()) standing.erase(at);
1688
+ child->parent = nullptr;
1689
+ markDirty(*parent);
1690
+ } else {
1691
+ detachFromParent(child);
1692
+ }
1693
+ child->parent = parent.get();
1694
+ holdHandle(runtime, *child);
1695
+ // An insert has to land at a POSITION and a hole is not one. Free when the parent is dense,
1696
+ // which after the branch above it is in the move-within-a-parent case.
1697
+ compactChildren(*parent);
1698
+ auto &siblings = parent->children;
1699
+ const size_t hinted = before->slotInParent;
1700
+ const size_t index = hinted < siblings.size() && siblings[hinted] == before
1701
+ ? hinted
1702
+ : static_cast<size_t>(
1703
+ std::find(siblings.begin(), siblings.end(), before) - siblings.begin());
1704
+ siblings.insert(siblings.begin() + static_cast<std::ptrdiff_t>(index), std::move(child));
1705
+ siblings[index]->slotInParent = index;
1706
+ // THE TAIL'S HINTS ARE NOW ONE TOO LOW, AND THEY ARE DELIBERATELY LEFT THAT WAY.
1707
+ //
1708
+ // Renumbering them is O(width) per insert, which was tried and made a reorder of 4 000 rows
1709
+ // 34.6 ms against 6.7 — five times worse, to keep a hint exact that nothing requires to be.
1710
+ // `detachFromParent` validates before it believes (`siblings[hinted] == child`) and falls
1711
+ // back to a scan, so a stale hint costs one detach its old price and never costs correctness.
1712
+ //
1713
+ // What IS still linear here is the vector insert itself. Finding the anchor is a load now;
1714
+ // making the insert a load needs a different container, not a different search.
1715
+ markDirty(*parent);
1716
+ break;
1717
+ }
1718
+ case kOpRemoveChild: {
1719
+ const auto &parent = nodeAt(ops[at + 1]);
1720
+ auto child = nodeAt(ops[at + 2]);
1721
+ // Named rather than implied: `detachFromParent` reads the child's OWN parent pointer, which
1722
+ // is the truth even when the adapter names a stale parent — frameworks spell a move as
1723
+ // remove-then-insert and can arrive here after the insert already re-parented the node.
1724
+ if (child->parent == parent.get()) {
1725
+ detachFromParent(child);
1726
+ // Out of the tree, so nothing pins its placeholder any more. Released HERE and not inside
1727
+ // `detachFromParent`, which the two attach ops also call to spell a MOVE: dropping the
1728
+ // pin there would leave a window, mid-batch, where the node is in no tree and a
1729
+ // collection could take the handle a re-attach is about to need.
1730
+ child->attachedHandle.reset();
1731
+ }
1732
+ break;
1733
+ }
1734
+ // Writing a value the node already holds is a NO-OP and returns before `markDirty`. Fabric
1735
+ // never saw a difference either way — `diffProps` would find the key unchanged and drop it —
1736
+ // but the mark is not free: it climbs to the first already-dirty ancestor and strips every one
1737
+ // of them of the reuse fast path, so an otherwise untouched subtree gets rebuilt purely to
1738
+ // prove it is untouched. Measured: Angular's Pressable host bag pushed 104 000 setProp calls
1739
+ // for a screen Solid built in 12 000, 90 000 of them writing `undefined` over an absent key.
1740
+ //
1741
+ // The guard lives HERE and not in the engine's `setProp` because it needs the value the node
1742
+ // already holds — a read JS would have to make over the wire, ~44 001 times on a 1 000-row
1743
+ // create, which is exactly the traffic this design removes.
1744
+ //
1745
+ // ONE DELIBERATE ASYMMETRY with the reference applier, and it is in the safe direction. TS
1746
+ // compares with `Object.is`, so for a style object or a handler the guard simply never fires:
1747
+ // an adapter may hand back the SAME reference with mutated contents, and identity cannot see
1748
+ // that. Here the value is a fresh `folly::dynamic` copied off the JSI value, so nothing can
1749
+ // mutate it behind us and a deep compare is both available and correct. It therefore turns
1750
+ // away strictly MORE writes than TS does. That changes the work, never the committed tree —
1751
+ // `diffProps` drops an unchanged key either way — so the two still agree on output.
1752
+ case kOpSetProp: {
1753
+ const auto setPropStartedAt = ISteadyClock::now();
1754
+ const auto &node = nodeAt(ops[at + 1]);
1755
+ const auto &key = stringAt(ops[at + 2]);
1756
+ if (ops[at + 3] == kNoValue) {
1757
+ // An absent key is not a key holding null: deleting one that is not there changes nothing,
1758
+ // while deleting one that is there changes what the next `diffProps` sends, since a
1759
+ // vanished key has to go out as an explicit null.
1760
+ if (node->props.get_ptr(key) == nullptr) {
1761
+ walkCost_.deletesOfAbsent += 1;
1762
+ break;
1763
+ }
1764
+ node->props.erase(key);
1765
+ } else {
1766
+ const auto &value = valueAt(ops[at + 3], [&] {
1767
+ return "prop \"" + key + "\" on <" + node->viewName + ">";
1768
+ });
1769
+ const auto *existing = node->props.get_ptr(key);
1770
+ if (existing != nullptr && *existing == value) {
1771
+ walkCost_.writesOfUnchanged += 1;
1772
+ break;
1773
+ }
1774
+ // A COPY, where this used to move: the entry is shared by every node the same object was
1775
+ // handed to, so it has to survive this op. One `folly::dynamic` copy against one JS ->
1776
+ // dynamic conversion, and the conversion is the JSI crossing.
1777
+ node->props[key] = value;
1778
+ }
1779
+ markDirty(*node);
1780
+ walkCost_.setPropNs += nanosSince(setPropStartedAt);
1781
+ walkCost_.setProps += 1;
1782
+ break;
1783
+ }
1784
+ // The same guard, and here it is strictly stronger than a reference check even in TS: `text`
1785
+ // is a string, so this is a real value comparison. A framework that re-renders a subtree and
1786
+ // hands back an unchanged label — every list row whose text did not move, on every update —
1787
+ // stops dirtying its ancestors.
1788
+ // The one op that changes what a node IS rather than what it holds. `materialize`'s
1789
+ // `needsFreshFamily` already covers the consequence — a name differing from
1790
+ // `committedViewName` re-creates the node and re-parents its children — so this only moves the
1791
+ // name and marks. `TextInput`'s `multiline` flip is the whole reason it exists.
1792
+ case kOpSetComponent: {
1793
+ const auto &node = nodeAt(ops[at + 1]);
1794
+ const auto &viewName = stringAt(ops[at + 2]);
1795
+ if (node->viewName == viewName) break;
1796
+ node->viewName = viewName;
1797
+ markDirty(*node);
1798
+ break;
1799
+ }
1800
+ // No `markDirty`: this arrives at `createElement`, before any prop is routed and long before
1801
+ // the node's first commit, so the payload it changes has not been built yet.
1802
+ case kOpSetTag: {
1803
+ const auto &node = nodeAt(ops[at + 1]);
1804
+ node->tagName = stringAt(ops[at + 2]);
1805
+ break;
1806
+ }
1807
+ // `markDirty`, unlike `kOpSetTag` above, and the difference is WHEN each arrives. A tag is set
1808
+ // at `attachHostBehavior`, before any prop is routed and before the node's first commit, so
1809
+ // there is no payload yet to invalidate. A listener can flip at any point in a screen's life —
1810
+ // a row that becomes pressable once its data loads — and the key it decides is already
1811
+ // committed by then. Without this the control renders permanently unfocusable while visibly
1812
+ // interactive, and nothing else in the batch would mark it: a listener is not a prop write.
1813
+ case kOpSetOwnedListener: {
1814
+ const auto &node = nodeAt(ops[at + 1]);
1815
+ // Only the names a platform rule actually reads. Anything else is a JS-side concern that
1816
+ // happened to cross, and dropping it here costs one comparison.
1817
+ const uint8_t bit = pressListenerBit(stringAt(ops[at + 2]));
1818
+ if (bit == 0) break;
1819
+ const uint8_t next = ops[at + 3] != 0 ? uint8_t(node->pressListeners | bit)
1820
+ : uint8_t(node->pressListeners & ~bit);
1821
+ if (node->pressListeners == next) break;
1822
+ node->pressListeners = next;
1823
+ markDirty(*node);
1824
+ break;
1825
+ }
1826
+ // A gesture-rate flip, so it marks dirty like the listener op and unlike `kOpSetTag`: the
1827
+ // payload it invalidates is already committed by the time a finger lands.
1828
+ case kOpSetUnderlayShown: {
1829
+ const auto &node = nodeAt(ops[at + 1]);
1830
+ const bool shown = ops[at + 2] != 0;
1831
+ if (node->underlayShown == shown) break;
1832
+ node->underlayShown = shown;
1833
+ markDirty(*node);
1834
+ // AND THE CHILD, because this bit drives a rule on BOTH nodes: the container takes the
1835
+ // background and the child takes the opacity (`foldTouchableHighlightChild`). A descendant
1836
+ // rule runs when ITS node is dirty, so without this the child would freeze in its unpressed
1837
+ // shape and never dim — the hazard `ownerProps` already carries for ScrollView's content.
1838
+ //
1839
+ // FIRST child and no walk: RN takes `React.Children.only` (`TouchableHighlight.js:306`), so
1840
+ // one is the whole population rather than a simplification. Twice per tap, not per frame.
1841
+ if (!node->children.empty()) {
1842
+ compactChildren(*node);
1843
+ if (!node->children.empty() && node->children.front() != nullptr)
1844
+ markDirty(*node->children.front());
1845
+ }
1846
+ break;
1847
+ }
1848
+ case kOpSetText: {
1849
+ const auto &node = nodeAt(ops[at + 1]);
1850
+ const auto &text = stringAt(ops[at + 2]);
1851
+ const auto *existing = node->props.get_ptr("text");
1852
+ if (existing != nullptr && existing->isString() && existing->asString() == text) break;
1853
+ // Read BEFORE the write, and only a FLIP marks the parent.
1854
+ //
1855
+ // A write to or from '' takes this node out of its parent's renderable child list or puts it
1856
+ // back, which is a structural change to the PARENT that nothing else here would record.
1857
+ // Marking unconditionally made every ordinary relabel do it too, and `markDirty` sets the
1858
+ // parent's SELF-dirty bit — which forces a full `fabricProps` + `diffProps` on a node whose
1859
+ // own props did not move. Counted through three adapters on a 1 000-row relabel
1860
+ // (`adapters/*/src/work-ledger.probe.test.*`): 3 000 payload keys rebuilt to send 1 000.
1861
+ // The walk still reaches this node either way, because `markDirty(*node)` raises
1862
+ // `pathDirty` on every ancestor.
1863
+ const bool wasEmpty =
1864
+ existing == nullptr || !existing->isString() || existing->asString().empty();
1865
+ node->props["text"] = text;
1866
+ markDirty(*node);
1867
+ if (node->parent != nullptr && wasEmpty != text.empty()) markDirty(*node->parent);
1868
+ break;
1869
+ }
1870
+ case kOpCommit: {
1871
+ const auto surfaceId = static_cast<react::SurfaceId>(ops[at + 1]);
1872
+ const auto &surface = nodeAt(ops[at + 2]);
1873
+ auto childSet = std::make_shared<ChildSet>();
1874
+ // The surface NODE is contributed, not its children — it is the AppContainer view
1875
+ // (`createSurfaceRoot`, `flex: 1` + `box-none`) and it commits. Routing it through the same
1876
+ // call keeps the two shapes one path: an ANCHOR in this position hoists its children.
1877
+ //
1878
+ // `nullptr` as the Fabric parent: the root CHILD SET is not a node. So a top-level node
1879
+ // moving between two surfaces is NOT caught by the parent comparison — both sides are
1880
+ // `nullptr` — and the surface id is what separates them, which is why `materialize`
1881
+ // compares that too.
1882
+ // The root child set goes to `completeSurface`, which commits it through a transaction we
1883
+ // never see the result of — so there is nothing to adopt back here, and these owners are
1884
+ // collected only because `appendRenderable` needs somewhere to put them.
1885
+ IOwnerTally rootOwners;
1886
+ // THE ONE TIMER THAT IS NOT PER NODE, and it has to be here rather than inside
1887
+ // `materialize`: the walk is recursive, so a timer around the recursive call would count
1888
+ // every ancestor's time again for every descendant. This is the walk's single entry point.
1889
+ const auto walkStartedAt = ISteadyClock::now();
1890
+ appendRenderable(
1891
+ runtime, uiManager, *childSet, rootOwners, *surface, false, surfaceId, nullptr);
1892
+ walkCost_.walkNs += nanosSince(walkStartedAt);
1893
+ // SKIPPED when the root child set comes back identical. `materialize` already declines to
1894
+ // clone a node nothing changed, so an unchanged tree produces the same handles — and
1895
+ // `completeSurface` on them is a full `ShadowTree::commit`, with layout and a mount pass,
1896
+ // for no change at all.
1897
+ //
1898
+ // This is what makes the JS side's commit fan-out free: every commit names every live root,
1899
+ // because a cross-surface mutation dirties a surface whose renderer nobody is holding
1900
+ // (`commitSurfaceOps` in tree-host.ts). An untouched root reaches here with an identical
1901
+ // list and stops.
1902
+ if (surface->hasCommittedRenderable &&
1903
+ sameNodes(surface->committedRenderable, *childSet)) {
1904
+ break;
1905
+ }
1906
+ surface->committedRenderable = *childSet;
1907
+ surface->hasCommittedRenderable = true;
1908
+ // `completeSurface` runs `ShadowTree::commit` itself, with a lambda that REPLACES the root's
1909
+ // children outright — so a retry against a moved root is harmless and there is nothing to
1910
+ // rebase. That is why this needs neither a commit hook nor a retained pending root.
1911
+ uiManager.completeSurface(
1912
+ surfaceId,
1913
+ childSet,
1914
+ {.enableStateReconciliation = true,
1915
+ .mountSynchronously = false,
1916
+ .source = react::ShadowTree::CommitSource::React});
1917
+ uiManager.getShadowTreeRegistry().visit(
1918
+ surfaceId, [&rootOwners](const react::ShadowTree &shadowTree) {
1919
+ // THE REPAIR, and it must run here rather than in `materialize`: substitution happens
1920
+ // INSIDE the commit, so the only tree that can be believed is the one the registry
1921
+ // holds once `completeSurface` has returned. See `adoptCommitted`.
1922
+ const ChildSet &landedRoot =
1923
+ shadowTree.getCurrentRevision().rootShadowNode->getChildren();
1924
+ const size_t rootCount =
1925
+ std::min(rootOwners.nodes.size(), landedRoot.size());
1926
+ for (size_t index = 0; index < rootCount; index++) {
1927
+ adoptCommitted(*rootOwners.nodes[index], landedRoot[index]);
1928
+ }
1929
+ });
1930
+ break;
1931
+ }
1932
+ default:
1933
+ throw jsi::JSError(runtime, "applyOps: unknown opcode " + std::to_string(ops[at]));
1934
+ }
1935
+ }
1936
+
1937
+ walkCost_.applyNs += nanosSince(applyStartedAt);
1938
+ return jsi::Value::undefined();
1939
+ }
1940
+
1941
+ jsi::Value Tree::getProp(jsi::Runtime &runtime, const jsi::Value *arguments, size_t count) {
1942
+ if (count < 2) {
1943
+ throw jsi::JSError(runtime, "symbiote engine: expected getProp(handle, key)");
1944
+ }
1945
+ const auto node = nodeFrom(runtime, arguments[0].asObject(runtime), "getProp");
1946
+ const auto *found = node->props.get_ptr(arguments[1].asString(runtime).utf8(runtime));
1947
+ if (found == nullptr) return jsi::Value::undefined();
1948
+ return jsi::valueFromDynamic(runtime, *found);
1949
+ }
1950
+
1951
+ jsi::Value Tree::getProps(jsi::Runtime &runtime, const jsi::Value *arguments, size_t count) {
1952
+ if (count < 1) {
1953
+ throw jsi::JSError(runtime, "symbiote engine: expected getProps(handle)");
1954
+ }
1955
+ const auto node = nodeFrom(runtime, arguments[0].asObject(runtime), "getProps");
1956
+ // The whole bag in one crossing. The per-key alternative needs the key list first, which is a
1957
+ // crossing of its own, and a payload fold reads most of what it is handed.
1958
+ return jsi::valueFromDynamic(runtime, node->props);
1959
+ }
1960
+
1961
+ jsi::Value Tree::markPropsDirty(jsi::Runtime &runtime, const jsi::Value *arguments, size_t count) {
1962
+ if (count < 1) {
1963
+ throw jsi::JSError(runtime, "symbiote engine: expected markPropsDirty(handle)");
1964
+ }
1965
+ const auto node = nodeFrom(runtime, arguments[0].asObject(runtime), "markPropsDirty");
1966
+ // The same mark an op leaves. A behavior whose payload is DERIVED — the sticky header's
1967
+ // translateY lives in its own runtime, not in the node's props — writes nothing, so without this
1968
+ // the commit skips the node it is about to change.
1969
+ markDirty(*node);
1970
+ return jsi::Value::undefined();
1971
+ }
1972
+
1973
+ jsi::Value Tree::getViewName(jsi::Runtime &runtime, const jsi::Value *arguments, size_t count) {
1974
+ if (count < 1) {
1975
+ throw jsi::JSError(runtime, "symbiote engine: expected getViewName(handle)");
1976
+ }
1977
+ const auto node = nodeFrom(runtime, arguments[0].asObject(runtime), "getViewName");
1978
+ // The COMMITTED name when there is one, because that is the answer the virtual-text rule may have
1979
+ // changed; the authored name before a first commit, when the rule has not been asked yet.
1980
+ return jsi::String::createFromUtf8(
1981
+ runtime, node->committedViewName.empty() ? node->viewName : node->committedViewName);
1982
+ }
1983
+
1984
+ // ── THE STRUCTURAL READS ─────────────────────────────────────────────────────────────────────────
1985
+ //
1986
+ // Mirroring `tree-applier.ts`'s `parentHandleOf` / `childHandlesOf` / `committedRecordOf`, which the
1987
+ // suite exercises. Each divergence below is deliberate and named; nothing else may differ.
1988
+
1989
+ jsi::Value Tree::parentOf(jsi::Runtime &runtime, const jsi::Value *arguments, size_t count) {
1990
+ if (count < 1) {
1991
+ throw jsi::JSError(runtime, "symbiote engine: expected parentOf(handle)");
1992
+ }
1993
+ const auto node = nodeFrom(runtime, arguments[0].asObject(runtime), "parentOf");
1994
+ // The node's OWN parent, a SURFACE included. Stopping at a surface is `host-access.ts`'s job, and
1995
+ // it does it by reading the answer's `component` — three adapters depend on the miss meaning "not
1996
+ // attached to anything I placed" rather than "has no parent".
1997
+ if (node->parent == nullptr) return jsi::Value::undefined();
1998
+ return handleOf(runtime, *node->parent);
1999
+ }
2000
+
2001
+ /**
2002
+ * The node that follows this one in its parent's child list.
2003
+ *
2004
+ * Its own call rather than `parentOf` + `childrenOf` in JS, and the reason is a measurement: Vue's
2005
+ * renderer names `nextSibling` once per row while patching a keyed list, and the JS spelling read
2006
+ * the WHOLE sibling list to find one entry. On a 1 000-row append that was 1 002 001 handles
2007
+ * marshalled across the boundary — quadratic, and every one of those handles a JSI object built and
2008
+ * thrown away. Here the scan is a pointer comparison over a vector and exactly one handle crosses.
2009
+ *
2010
+ * A SURFACE parent answers like any other: the surface is an ordinary node in this tree, so a
2011
+ * top-level node's siblings are its children. That is what lets `host-access.ts` stop passing the
2012
+ * surface for this question.
2013
+ */
2014
+ jsi::Value Tree::nextSiblingOf(jsi::Runtime &runtime, const jsi::Value *arguments, size_t count) {
2015
+ if (count < 1) {
2016
+ throw jsi::JSError(runtime, "symbiote engine: expected nextSiblingOf(handle)");
2017
+ }
2018
+ const auto node = nodeFrom(runtime, arguments[0].asObject(runtime), "nextSiblingOf");
2019
+ if (node->parent == nullptr) return jsi::Value::undefined();
2020
+ compactChildren(*node->parent);
2021
+ const auto &siblings = node->parent->children;
2022
+ auto at = std::find_if(siblings.begin(), siblings.end(), [&](const NodePtr &sibling) {
2023
+ return sibling.get() == node.get();
2024
+ });
2025
+ if (at == siblings.end() || std::next(at) == siblings.end()) {
2026
+ return jsi::Value::undefined();
2027
+ }
2028
+ return handleOf(runtime, **std::next(at));
2029
+ }
2030
+
2031
+ jsi::Value Tree::childrenOf(jsi::Runtime &runtime, const jsi::Value *arguments, size_t count) {
2032
+ if (count < 1) {
2033
+ throw jsi::JSError(runtime, "symbiote engine: expected childrenOf(handle)");
2034
+ }
2035
+ const auto node = nodeFrom(runtime, arguments[0].asObject(runtime), "childrenOf");
2036
+
2037
+ // ANCHORS INCLUDED. The commit skips them; traversal must not, or a framework runtime desyncs from
2038
+ // the tree it built — solid-js/universal keeps its own record of what it inserted and re-derives
2039
+ // positions through this call, so a node it placed has to be a node it can find.
2040
+ //
2041
+ // Built into a vector first because the length is not known until the locks are done: a child
2042
+ // whose handle is gone contributes nothing rather than an `undefined` hole, since the ABI's answer
2043
+ // is an array of objects. The two are indistinguishable downstream — `childrenOf` in
2044
+ // `host-access.ts` filters anything that is not one of our nodes — so the typed one wins.
2045
+ std::vector<jsi::Value> live;
2046
+ compactChildren(*node);
2047
+ live.reserve(node->children.size());
2048
+ for (const auto &child : node->children) {
2049
+ auto handle = handleOf(runtime, *child);
2050
+ if (handle.isUndefined()) continue;
2051
+ live.push_back(std::move(handle));
2052
+ }
2053
+
2054
+ auto out = jsi::Array(runtime, live.size());
2055
+ for (size_t at = 0; at < live.size(); at += 1) {
2056
+ out.setValueAtIndex(runtime, at, std::move(live[at]));
2057
+ }
2058
+ return out;
2059
+ }
2060
+
2061
+ // A node whose handle is gone contributes neither itself nor its descendants, which is what the JS
2062
+ // recursion this replaces did: `host-access.ts` filters a dead handle out of the child list, so the
2063
+ // walk never reached what was under it. Kept identical on purpose — this is a cost fix, and a sweep
2064
+ // that suddenly tears down MORE nodes than before would be a behaviour change wearing one.
2065
+ void collectSubtree(jsi::Runtime &runtime, const NodePtr &node, std::vector<jsi::Value> &into) {
2066
+ auto handle = handleOf(runtime, *node);
2067
+ if (handle.isUndefined()) return;
2068
+ into.push_back(std::move(handle));
2069
+ compactChildren(*node);
2070
+ for (const auto &child : node->children) collectSubtree(runtime, child, into);
2071
+ }
2072
+
2073
+ jsi::Value Tree::ancestorsOf(jsi::Runtime &runtime, const jsi::Value *arguments, size_t count) {
2074
+ if (count < 1) {
2075
+ throw jsi::JSError(runtime, "symbiote engine: expected ancestorsOf(handle)");
2076
+ }
2077
+ const auto node = nodeFrom(runtime, arguments[0].asObject(runtime), "ancestorsOf");
2078
+
2079
+ // Counted first so the array is built once at its final size. A chain is short — a screen's depth,
2080
+ // not a tree's — so the second walk costs nothing against an array that grows.
2081
+ // `.get()` because `nodeFrom` hands back the owning pointer while `parent` is a raw one — the
2082
+ // chain is walked as raw pointers, which is what `parentOf` next door does too.
2083
+ size_t depth = 0;
2084
+ for (const Node *each = node.get(); each != nullptr; each = each->parent) {
2085
+ depth += 1;
2086
+ }
2087
+
2088
+ auto out = jsi::Array(runtime, depth);
2089
+ size_t at = 0;
2090
+ // DEEPEST FIRST, the node itself included and a SURFACE included. The order is the contract: the
2091
+ // caller reads it both ways, capture reversed and bubble forward, off the one array.
2092
+ for (Node *each = node.get(); each != nullptr; each = each->parent, at += 1) {
2093
+ out.setValueAtIndex(runtime, at, handleOf(runtime, *each));
2094
+ }
2095
+ return out;
2096
+ }
2097
+
2098
+ jsi::Value Tree::parentsOf(jsi::Runtime &runtime, const jsi::Value *arguments, size_t count) {
2099
+ if (count < 1) {
2100
+ throw jsi::JSError(runtime, "symbiote engine: expected parentsOf(handles)");
2101
+ }
2102
+ auto handles = arguments[0].asObject(runtime).asArray(runtime);
2103
+ const size_t length = handles.size(runtime);
2104
+
2105
+ auto out = jsi::Array(runtime, length);
2106
+ for (size_t at = 0; at < length; at += 1) {
2107
+ const auto node =
2108
+ nodeFrom(runtime, handles.getValueAtIndex(runtime, at).asObject(runtime), "parentsOf");
2109
+ // `undefined` per element, never a shorter array: the caller reads this positionally against the
2110
+ // list it passed, so a dropped entry would silently shift every answer after it onto the wrong
2111
+ // node. Same answer `parentOf` gives for a root — a SURFACE included, since stopping at one is
2112
+ // `host-access.ts`'s job and it reads the answer's `component` to do it.
2113
+ out.setValueAtIndex(
2114
+ runtime,
2115
+ at,
2116
+ node->parent == nullptr ? jsi::Value::undefined() : handleOf(runtime, *node->parent));
2117
+ }
2118
+ return out;
2119
+ }
2120
+
2121
+ jsi::Value Tree::subtreesOf(jsi::Runtime &runtime, const jsi::Value *arguments, size_t count) {
2122
+ if (count < 1) {
2123
+ throw jsi::JSError(runtime, "symbiote engine: expected subtreesOf(roots)");
2124
+ }
2125
+ const auto readStartedAt = ISteadyClock::now();
2126
+ auto roots = arguments[0].asObject(runtime).asArray(runtime);
2127
+ const size_t length = roots.size(runtime);
2128
+
2129
+ // PRE-ORDER, each root followed by its own descendants. It is the order the JS recursion visited
2130
+ // in, and `onDetached` runs per node in exactly that sequence.
2131
+ //
2132
+ // Concatenated rather than nested: the caller has no use for the grouping — it tears every node
2133
+ // down the same way — and an array of arrays costs an allocation per root to express that.
2134
+ std::vector<jsi::Value> flat;
2135
+ for (size_t at = 0; at < length; at += 1) {
2136
+ const auto root =
2137
+ nodeFrom(runtime, roots.getValueAtIndex(runtime, at).asObject(runtime), "subtreesOf");
2138
+ collectSubtree(runtime, root, flat);
2139
+ }
2140
+
2141
+ auto out = jsi::Array(runtime, flat.size());
2142
+ for (size_t at = 0; at < flat.size(); at += 1) {
2143
+ out.setValueAtIndex(runtime, at, std::move(flat[at]));
2144
+ }
2145
+ walkCost_.hostReadNs += nanosSince(readStartedAt);
2146
+ walkCost_.hostReadHandles += flat.size();
2147
+ return out;
2148
+ }
2149
+
2150
+ jsi::Value Tree::committedRecordOf(
2151
+ jsi::Runtime &runtime,
2152
+ const jsi::Value *arguments,
2153
+ size_t count) {
2154
+ if (count < 1) {
2155
+ throw jsi::JSError(runtime, "symbiote engine: expected committedRecordOf(handle)");
2156
+ }
2157
+ const auto node = nodeFrom(runtime, arguments[0].asObject(runtime), "committedRecordOf");
2158
+ // `undefined` before the first commit is the ORDINARY answer, not an error: an adapter that wires
2159
+ // an imperative call at lifecycle time runs before the commit under an async-batched renderer, and
2160
+ // every caller either defers or logs.
2161
+ if (node->committed == nullptr) return jsi::Value::undefined();
2162
+
2163
+ auto record = jsi::Object(runtime);
2164
+ // The PLACEHOLDER, not the `ShadowNode`. The reference applier answers with its fake Fabric node
2165
+ // because that is what its slot's imperative calls accept; here the imperative five below accept
2166
+ // this object, so it is the same field playing the same role. There is no JS value for a
2167
+ // `shared_ptr<const ShadowNode>` that anything downstream could use.
2168
+ record.setProperty(runtime, "handle", jsi::Value(runtime, arguments[0]));
2169
+ record.setProperty(runtime, "tag", jsi::Value(static_cast<double>(node->tag)));
2170
+ record.setProperty(
2171
+ runtime, "rootTag", jsi::Value(static_cast<double>(node->committedSurfaceId)));
2172
+ return record;
2173
+ }
2174
+
2175
+ jsi::Value Tree::committedPayloadOf(
2176
+ jsi::Runtime &runtime,
2177
+ const jsi::Value *arguments,
2178
+ size_t count) {
2179
+ if (count < 1) {
2180
+ throw jsi::JSError(runtime, "symbiote engine: expected committedPayloadOf(handle)");
2181
+ }
2182
+ const auto node = nodeFrom(runtime, arguments[0].asObject(runtime), "committedPayloadOf");
2183
+ // Before the first commit there is no payload, and that is an ANSWER rather than an error — the
2184
+ // same shape `committedRecordOf` gives for the same state.
2185
+ if (node->committed == nullptr) return jsi::Value::undefined();
2186
+ return jsi::valueFromDynamic(runtime, node->committedProps);
2187
+ }
2188
+
2189
+ // ── THE IMPERATIVE FIVE ──────────────────────────────────────────────────────────────────────────
2190
+ //
2191
+ // See the header for why they are here rather than beside `Applier`. The one shape they all share:
2192
+ // a node with no committed `ShadowNode` is answered exactly like a surface with no revision.
2193
+
2194
+ jsi::Value Tree::dispatchCommand(
2195
+ jsi::Runtime &runtime,
2196
+ const jsi::Value *arguments,
2197
+ size_t count) {
2198
+ if (count < 3) {
2199
+ throw jsi::JSError(
2200
+ runtime, "symbiote engine: expected dispatchCommand(handle, commandName, args)");
2201
+ }
2202
+ const auto node = nodeFrom(runtime, arguments[0].asObject(runtime), "dispatchCommand");
2203
+ if (node->committed == nullptr) return jsi::Value::undefined();
2204
+ uiManagerFor(runtime, "dispatchCommand")
2205
+ .dispatchCommand(
2206
+ node->committed,
2207
+ arguments[1].asString(runtime).utf8(runtime),
2208
+ react::commandArgsFromValue(runtime, arguments[2]));
2209
+ return jsi::Value::undefined();
2210
+ }
2211
+
2212
+ jsi::Value Tree::sendAccessibilityEvent(
2213
+ jsi::Runtime &runtime,
2214
+ const jsi::Value *arguments,
2215
+ size_t count) {
2216
+ if (count < 2) {
2217
+ throw jsi::JSError(
2218
+ runtime, "symbiote engine: expected sendAccessibilityEvent(handle, eventType)");
2219
+ }
2220
+ const auto node = nodeFrom(runtime, arguments[0].asObject(runtime), "sendAccessibilityEvent");
2221
+ if (node->committed == nullptr) return jsi::Value::undefined();
2222
+ uiManagerFor(runtime, "sendAccessibilityEvent")
2223
+ .sendAccessibilityEvent(node->committed, arguments[1].asString(runtime).utf8(runtime));
2224
+ return jsi::Value::undefined();
2225
+ }
2226
+
2227
+ // A JS responder that never reaches native loses the gesture to any scroll view above it, silently:
2228
+ // the UIScrollView keeps competing and every move after the first arrives as `topScroll`.
2229
+ jsi::Value Tree::setIsJSResponder(
2230
+ jsi::Runtime &runtime,
2231
+ const jsi::Value *arguments,
2232
+ size_t count) {
2233
+ if (count < 3) {
2234
+ throw jsi::JSError(
2235
+ runtime,
2236
+ "symbiote engine: expected setIsJSResponder(handle, isResponder, blockNativeResponder)");
2237
+ }
2238
+ const auto node = nodeFrom(runtime, arguments[0].asObject(runtime), "setIsJSResponder");
2239
+ if (node->committed == nullptr) return jsi::Value::undefined();
2240
+ uiManagerFor(runtime, "setIsJSResponder")
2241
+ .setIsJSResponder(node->committed, arguments[1].getBool(), arguments[2].getBool());
2242
+ return jsi::Value::undefined();
2243
+ }
2244
+
2245
+ #ifndef SYMBIOTE_HAS_DOM_MEASURE
2246
+
2247
+ // The header is absent on this toolchain (Android's prefab does not export `react/renderer/dom/`).
2248
+ // Throwing is the only honest answer: returning zeroes would be indistinguishable from a node with
2249
+ // no layout, and an app reading a size of 0 lays out wrongly with nothing to diagnose.
2250
+ jsi::Value Tree::measure(jsi::Runtime &runtime, const jsi::Value *, size_t) {
2251
+ throw jsi::JSError(runtime, "symbiote engine: measure is not built on this platform");
2252
+ }
2253
+
2254
+ jsi::Value Tree::measureInWindow(jsi::Runtime &runtime, const jsi::Value *, size_t) {
2255
+ throw jsi::JSError(runtime, "symbiote engine: measureInWindow is not built on this platform");
2256
+ }
2257
+
2258
+ jsi::Value Tree::measureLayout(jsi::Runtime &runtime, const jsi::Value *, size_t) {
2259
+ throw jsi::JSError(runtime, "symbiote engine: measureLayout is not built on this platform");
2260
+ }
2261
+
2262
+ #else
2263
+
2264
+ jsi::Value Tree::measure(jsi::Runtime &runtime, const jsi::Value *arguments, size_t count) {
2265
+ if (count < 2) {
2266
+ throw jsi::JSError(runtime, "symbiote engine: expected measure(handle, callback)");
2267
+ }
2268
+ const auto node = nodeFrom(runtime, arguments[0].asObject(runtime), "measure");
2269
+ auto callback = arguments[1].asObject(runtime).asFunction(runtime);
2270
+
2271
+ auto revision = node->committed == nullptr
2272
+ ? nullptr
2273
+ : uiManagerFor(runtime, "measure")
2274
+ .getShadowTreeRevisionProvider()
2275
+ ->getCurrentRevision(node->committed->getSurfaceId());
2276
+ if (revision == nullptr) {
2277
+ // Six zeroes, matching the binding: a surface that has not committed yet is not an error, and an
2278
+ // app that asked where a node is must get an answer rather than a throw.
2279
+ callback.call(runtime, {0, 0, 0, 0, 0, 0});
2280
+ return jsi::Value::undefined();
2281
+ }
2282
+
2283
+ auto rect = react::dom::measure(revision, *node->committed);
2284
+ callback.call(
2285
+ runtime,
2286
+ {jsi::Value{runtime, rect.x},
2287
+ jsi::Value{runtime, rect.y},
2288
+ jsi::Value{runtime, rect.width},
2289
+ jsi::Value{runtime, rect.height},
2290
+ jsi::Value{runtime, rect.pageX},
2291
+ jsi::Value{runtime, rect.pageY}});
2292
+ return jsi::Value::undefined();
2293
+ }
2294
+
2295
+ jsi::Value Tree::measureInWindow(
2296
+ jsi::Runtime &runtime,
2297
+ const jsi::Value *arguments,
2298
+ size_t count) {
2299
+ if (count < 2) {
2300
+ throw jsi::JSError(runtime, "symbiote engine: expected measureInWindow(handle, callback)");
2301
+ }
2302
+ const auto node = nodeFrom(runtime, arguments[0].asObject(runtime), "measureInWindow");
2303
+ auto callback = arguments[1].asObject(runtime).asFunction(runtime);
2304
+
2305
+ auto revision = node->committed == nullptr
2306
+ ? nullptr
2307
+ : uiManagerFor(runtime, "measureInWindow")
2308
+ .getShadowTreeRevisionProvider()
2309
+ ->getCurrentRevision(node->committed->getSurfaceId());
2310
+ if (revision == nullptr) {
2311
+ callback.call(runtime, {0, 0, 0, 0});
2312
+ return jsi::Value::undefined();
2313
+ }
2314
+
2315
+ auto rect = react::dom::measureInWindow(revision, *node->committed);
2316
+ callback.call(
2317
+ runtime,
2318
+ {jsi::Value{runtime, rect.x},
2319
+ jsi::Value{runtime, rect.y},
2320
+ jsi::Value{runtime, rect.width},
2321
+ jsi::Value{runtime, rect.height}});
2322
+ return jsi::Value::undefined();
2323
+ }
2324
+
2325
+ jsi::Value Tree::measureLayout(
2326
+ jsi::Runtime &runtime,
2327
+ const jsi::Value *arguments,
2328
+ size_t count) {
2329
+ if (count < 4) {
2330
+ throw jsi::JSError(
2331
+ runtime, "symbiote engine: expected measureLayout(handle, relativeTo, onFail, onSuccess)");
2332
+ }
2333
+ const auto node = nodeFrom(runtime, arguments[0].asObject(runtime), "measureLayout");
2334
+ const auto relativeTo = nodeFrom(runtime, arguments[1].asObject(runtime), "measureLayout");
2335
+ auto onFail = arguments[2].asObject(runtime).asFunction(runtime);
2336
+ auto onSuccess = arguments[3].asObject(runtime).asFunction(runtime);
2337
+
2338
+ auto revision = node->committed == nullptr || relativeTo->committed == nullptr
2339
+ ? nullptr
2340
+ : uiManagerFor(runtime, "measureLayout")
2341
+ .getShadowTreeRevisionProvider()
2342
+ ->getCurrentRevision(node->committed->getSurfaceId());
2343
+ if (revision == nullptr) {
2344
+ onFail.call(runtime);
2345
+ return jsi::Value::undefined();
2346
+ }
2347
+
2348
+ auto maybeRect =
2349
+ react::dom::measureLayout(revision, *node->committed, *relativeTo->committed);
2350
+ if (!maybeRect) {
2351
+ onFail.call(runtime);
2352
+ return jsi::Value::undefined();
2353
+ }
2354
+
2355
+ auto rect = maybeRect.value();
2356
+ onSuccess.call(
2357
+ runtime,
2358
+ {jsi::Value{runtime, rect.x},
2359
+ jsi::Value{runtime, rect.y},
2360
+ jsi::Value{runtime, rect.width},
2361
+ jsi::Value{runtime, rect.height}});
2362
+ return jsi::Value::undefined();
2363
+ }
2364
+
2365
+ #endif // SYMBIOTE_HAS_DOM_MEASURE
2366
+
2367
+ jsi::Value Tree::readSurfaceTelemetry(
2368
+ jsi::Runtime &runtime,
2369
+ const jsi::Value *arguments,
2370
+ size_t count) {
2371
+ if (count < 1) {
2372
+ throw jsi::JSError(runtime, "symbiote engine: expected readSurfaceTelemetry(surfaceId)");
2373
+ }
2374
+ const auto surfaceId = static_cast<react::SurfaceId>(arguments[0].asNumber());
2375
+ auto result = jsi::Object(runtime);
2376
+ double layoutMs = 0;
2377
+ double textMs = 0;
2378
+ double commitMs = 0;
2379
+ int layoutNodes = 0;
2380
+ int textMeasures = 0;
2381
+ // ANY surface, not only one this host drives. Anything accumulated inside our own `kOpCommit`
2382
+ // describes only a tree we committed; to answer "does React's own renderer pay this too" the
2383
+ // telemetry has to be readable for a surface React drove. `getCurrentRevision()` is public and
2384
+ // carries the
2385
+ // `TransactionTelemetry` of whichever commit produced the revision, whoever produced it.
2386
+ //
2387
+ // Read on demand rather than accumulated: there is no hook of ours in a foreign commit, so the
2388
+ // caller reads once the commit it timed has settled and gets that commit's revision.
2389
+ uiManagerFor(runtime, "readSurfaceTelemetry")
2390
+ .getShadowTreeRegistry()
2391
+ .visit(surfaceId, [&](const react::ShadowTree &shadowTree) {
2392
+ const react::TransactionTelemetry telemetry = shadowTree.getCurrentRevision().telemetry;
2393
+ layoutNodes = telemetry.getAffectedLayoutNodesCount();
2394
+ // GATED ON WORK HAVING HAPPENED, because the getter is not safe to ask otherwise:
2395
+ // `getLayoutStartTime()` is `react_native_assert(layoutStartTime_ != kTelemetry-
2396
+ // UndefinedTimePoint)` and a commit that dirtied no layout never stamps it. `millisBetween`
2397
+ // below handles the undefined sentinel, but it only ever sees it in a build where the assert
2398
+ // is compiled out — so in Debug this aborted the process instead. Found by the first itest to
2399
+ // commit a layout-neutral change, which is exactly the commit shape the targeted-replace path
2400
+ // is FOR, so the diagnostic was unusable precisely where it is most interesting.
2401
+ if (layoutNodes > 0) {
2402
+ layoutMs = millisBetween(telemetry.getLayoutStartTime(), telemetry.getLayoutEndTime());
2403
+ textMs =
2404
+ std::chrono::duration<double, std::milli>(telemetry.getTextMeasureTime()).count();
2405
+ }
2406
+ // `ShadowTree::commit`'s own window, and **`materialize` IS NOT IN IT.** This comment used to
2407
+ // say it was, and three rounds of investigation (F-80, F-81, F-82) read the number that way
2408
+ // and concluded the native pipeline was small. `materialize` runs in `kOpCommit` BEFORE
2409
+ // `uiManager.completeSurface` is called at all, so every `createNode`/`cloneNode`/
2410
+ // `appendChild` it makes is outside both this window and layout's. To price our own walk,
2411
+ // time `applyOps` from JS and subtract these two — see
2412
+ // `core/engine/cpp/tests/js/create-append-phase-split.itest.ts`.
2413
+ commitMs = millisBetween(telemetry.getCommitStartTime(), telemetry.getCommitEndTime());
2414
+ textMeasures = telemetry.getNumberOfTextMeasurements();
2415
+ });
2416
+ result.setProperty(runtime, "layoutMs", jsi::Value(layoutMs));
2417
+ result.setProperty(runtime, "textMs", jsi::Value(textMs));
2418
+ result.setProperty(runtime, "commitMs", jsi::Value(commitMs));
2419
+ result.setProperty(runtime, "layoutNodes", jsi::Value(static_cast<double>(layoutNodes)));
2420
+ result.setProperty(runtime, "textMeasures", jsi::Value(static_cast<double>(textMeasures)));
2421
+ // OURS, not RN's, and the only field here that is not read off `TransactionTelemetry`. Zeroed on
2422
+ // read, so a caller that samples per step gets disjoint windows. See `targetedReplaces_`.
2423
+ result.setProperty(
2424
+ runtime, "targetedReplaces", jsi::Value(static_cast<double>(targetedReplaces_)));
2425
+ targetedReplaces_ = 0;
2426
+ // OURS TOO, and for the same reason: `materialize` runs outside every window above, so without
2427
+ // these the walk can only be priced by subtracting `commitMs` from a JS stopwatch. See `IWalkCost`.
2428
+ const auto millis = [](double nanos) { return jsi::Value(nanos / 1e6); };
2429
+ result.setProperty(runtime, "walkMs", millis(walkCost_.walkNs));
2430
+ result.setProperty(runtime, "propsMs", millis(walkCost_.propsNs));
2431
+ result.setProperty(runtime, "foldLookupMs", millis(walkCost_.foldLookupNs));
2432
+ result.setProperty(runtime, "foldToJsMs", millis(walkCost_.foldToJsNs));
2433
+ result.setProperty(runtime, "foldCallMs", millis(walkCost_.foldCallNs));
2434
+ result.setProperty(runtime, "foldFromJsMs", millis(walkCost_.foldFromJsNs));
2435
+ result.setProperty(
2436
+ runtime, "foldsFound", static_cast<double>(walkCost_.foldsFound));
2437
+ result.setProperty(runtime, "rawPropsMs", millis(walkCost_.rawPropsNs));
2438
+ result.setProperty(runtime, "createNodeMs", millis(walkCost_.createNs));
2439
+ result.setProperty(runtime, "appendChildMs", millis(walkCost_.appendNs));
2440
+ result.setProperty(runtime, "diffPropsMs", millis(walkCost_.diffNs));
2441
+ result.setProperty(
2442
+ runtime, "nodesCreated", jsi::Value(static_cast<double>(walkCost_.created)));
2443
+ result.setProperty(runtime, "nodesCloned", jsi::Value(static_cast<double>(walkCost_.cloned)));
2444
+ result.setProperty(runtime, "nodesReused", jsi::Value(static_cast<double>(walkCost_.reused)));
2445
+ result.setProperty(runtime, "decodeMs", millis(walkCost_.decodeNs));
2446
+ result.setProperty(runtime, "instanceHandleMs", millis(walkCost_.instanceHandleNs));
2447
+ result.setProperty(runtime, "publishMs", millis(walkCost_.publishNs));
2448
+ result.setProperty(runtime, "nativeStateMs", millis(walkCost_.nativeStateNs));
2449
+ result.setProperty(runtime, "nodesDecoded", jsi::Value(static_cast<double>(walkCost_.decoded)));
2450
+ result.setProperty(runtime, "setPropMs", millis(walkCost_.setPropNs));
2451
+ result.setProperty(runtime, "propConvertMs", millis(walkCost_.propConvertNs));
2452
+ result.setProperty(runtime, "setProps", jsi::Value(static_cast<double>(walkCost_.setProps)));
2453
+ result.setProperty(
2454
+ runtime,
2455
+ "deletesOfAbsent",
2456
+ jsi::Value(static_cast<double>(walkCost_.deletesOfAbsent)));
2457
+ result.setProperty(
2458
+ runtime,
2459
+ "writesOfUnchanged",
2460
+ jsi::Value(static_cast<double>(walkCost_.writesOfUnchanged)));
2461
+ result.setProperty(
2462
+ runtime, "valueEntries", jsi::Value(static_cast<double>(walkCost_.valueEntries)));
2463
+ result.setProperty(
2464
+ runtime, "valueConversions", jsi::Value(static_cast<double>(walkCost_.valueConversions)));
2465
+ result.setProperty(runtime, "applyMs", millis(walkCost_.applyNs));
2466
+ result.setProperty(runtime, "stringDecodeMs", millis(walkCost_.stringDecodeNs));
2467
+ result.setProperty(runtime, "structureMs", millis(walkCost_.structureNs));
2468
+ result.setProperty(runtime, "holdHandleMs", millis(walkCost_.holdHandleNs));
2469
+ result.setProperty(runtime, "hostReadMs", millis(walkCost_.hostReadNs));
2470
+ result.setProperty(
2471
+ runtime, "hostReadHandles", jsi::Value(static_cast<double>(walkCost_.hostReadHandles)));
2472
+ result.setProperty(
2473
+ runtime, "applyCalls", jsi::Value(static_cast<double>(walkCost_.applyCalls)));
2474
+ walkCost_ = IWalkCost{};
2475
+ return result;
2476
+ }
2477
+
2478
+ } // namespace symbiote