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