@symbiote-native/engine 0.5.0 → 1.1.0

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