@symbiote-native/engine 0.5.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 -8
- package/build/accessibility-props.js +13 -16
- 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/host-binding.d.ts +1 -1
- package/build/animated/host-binding.js +19 -4
- package/build/animated/index.d.ts +1 -1
- 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 +88 -40
- package/build/fabric-props.d.ts +1 -1
- package/build/fabric-props.js +116 -184
- package/build/fabric.d.ts +9 -0
- package/build/fabric.js +32 -0
- package/build/host-access.d.ts +125 -0
- package/build/host-access.js +280 -0
- package/build/host-behavior.d.ts +84 -21
- package/build/host-behavior.js +196 -30
- 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 +14 -7
- package/build/index.js +53 -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 +172 -57
- package/build/node.js +839 -383
- package/build/pan-responder/index.js +27 -52
- 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/surface.d.ts +31 -2
- package/build/surface.js +138 -56
- 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 -1058
- package/build/tags.d.ts +0 -2
- package/build/tags.js +0 -40
package/build/commit.js
DELETED
|
@@ -1,1058 +0,0 @@
|
|
|
1
|
-
// The clone-on-write engine. Fabric is persistent: you never mutate a committed
|
|
2
|
-
// node, you clone it with new props/children and atomically hand a fresh child
|
|
3
|
-
// set to completeRoot.
|
|
4
|
-
//
|
|
5
|
-
// Incremental strategy: each retained node keeps, in its own `committed` field (node.ts), a
|
|
6
|
-
// mirror of what Fabric currently holds for it: its handle, the flat props last sent, the child
|
|
7
|
-
// identities last committed, and the resolved view name. On commit we walk the
|
|
8
|
-
// retained tree and only clone the nodes that actually changed; an untouched
|
|
9
|
-
// sibling subtree is reused by reference. That both skips work and preserves the
|
|
10
|
-
// native view state (scroll offset, text cursor) that a full rebuild would wipe
|
|
11
|
-
// on every commit. A change bubbles up: re-cloning a leaf
|
|
12
|
-
// forces each ancestor to re-clone too, because a persistent parent holds
|
|
13
|
-
// references to specific child handles. That bubble is inherent to a persistent
|
|
14
|
-
// tree and is exactly what React's own Fabric renderer does.
|
|
15
|
-
import { getSlot, } from './fabric.js';
|
|
16
|
-
import { createElement, debugNodeId, isAnchor, isEmptyRawText, committedOf, markDirty, markPropsDirty, markStructureDirty, takePropStats, VIRTUAL_TEXT_COMPONENT, } from './node.js';
|
|
17
|
-
import { dlog, isDebug } from './debug.js';
|
|
18
|
-
import { flattenStyle } from './style/index.js';
|
|
19
|
-
import { nextTag } from './tags.js';
|
|
20
|
-
import { registerPostCommit, runPostCommitHooks } from './post-commit.js';
|
|
21
|
-
import { fabricProps } from './fabric-props.js';
|
|
22
|
-
import { isRecord } from './type-guards.js';
|
|
23
|
-
import { isAriaAliasKey } from './accessibility-props.js';
|
|
24
|
-
import { runCommittedHooks, runDeferredAttaches, sweepDetachedBehaviors, teardownSubtree, } from './host-behavior.js';
|
|
25
|
-
import { detachAnimatedProps } from './animated/host-binding.js';
|
|
26
|
-
// Re-exported from ./platform-color so callers don't need to change their import path.
|
|
27
|
-
export { processColor, setColorProcessor } from './platform-color/index.js';
|
|
28
|
-
// Per-commit work counters, surfaced via dlog so a device run can prove the
|
|
29
|
-
// engine is incremental (created=0 with clones after the first mount).
|
|
30
|
-
const stats = { created: 0, cloneProps: 0, cloneChildren: 0, reused: 0 };
|
|
31
|
-
// One append vs one clone is a wash by call count and a pure loss by allocation, so a parent with a
|
|
32
|
-
// single child never takes the batched route whatever the switch says.
|
|
33
|
-
const CREATE_BATCH_MIN_CHILDREN = 2;
|
|
34
|
-
let batchCreate = false;
|
|
35
|
-
// Cumulative cost of the reconcile walk. Unlike `stats` (per-commit, zeroed at the top of every
|
|
36
|
-
// commit), this ACCUMULATES: one scroll frame produces a burst of commits and the frame's real cost
|
|
37
|
-
// is only visible as their sum. Read-and-zeroed via readCommitProfile().
|
|
38
|
-
//
|
|
39
|
-
// Deliberately NOT gated behind isDebug(): two performance.now() calls per commit are noise next to
|
|
40
|
-
// the walk they measure, and the number is only meaningful from a RELEASE build - dev-mode JS
|
|
41
|
-
// drowns the signal. The dlog below stays gated as usual.
|
|
42
|
-
// `propsBuilt` / `propsReused` price the props half of the walk, and they exist because that half
|
|
43
|
-
// is INVISIBLE in the output: reusing the mirror's payload by reference and rebuilding a
|
|
44
|
-
// byte-identical one emit the same Fabric calls, so only a counter separates a working fast lane
|
|
45
|
-
// from a silently reverted one. `propsBuilt` counts fabricProps() calls made by the update path
|
|
46
|
-
// (a fresh object plus a recursive propsEqual); `propsReused` counts nodes that re-cloned for a
|
|
47
|
-
// child's sake and carried the committed payload through untouched. On an ordinary update the
|
|
48
|
-
// second should dominate - that ratio IS the clone-bubble.
|
|
49
|
-
//
|
|
50
|
-
// The create path is deliberately not counted in either: it has no committed payload to reuse, so
|
|
51
|
-
// its fabricProps() calls are not a cost any flag could remove and would only dilute the ratio.
|
|
52
|
-
const profile = {
|
|
53
|
-
commits: 0,
|
|
54
|
-
walkMs: 0,
|
|
55
|
-
nodesVisited: 0,
|
|
56
|
-
propsBuilt: 0,
|
|
57
|
-
propsReused: 0,
|
|
58
|
-
};
|
|
59
|
-
// What `renderableChildren` costs, accumulated over the same window as `profile`. Anchors are the
|
|
60
|
-
// only reason that function is not free, and how many a tree carries is a property of the ADAPTER,
|
|
61
|
-
// not of the app: a Vue/React/Svelte/Solid component is a function returning children and allocates
|
|
62
|
-
// no node, while an Angular component is bound to a host ELEMENT and therefore always has one
|
|
63
|
-
// (anchor-host-registry.ts keeps it from painting). So the same screen is a no-anchor tree under
|
|
64
|
-
// four adapters and an anchor-per-composed-component tree under the fifth, and only a counter tells
|
|
65
|
-
// them apart at runtime - a source-level grep for "anchor" measures instrumentation density, not
|
|
66
|
-
// trees.
|
|
67
|
-
//
|
|
68
|
-
// `scans` counts invocations; `probed` counts children actually examined by the fast-path probe,
|
|
69
|
-
// which is why that probe is a counted loop rather than `.some()` (a `.some` short-circuits at the
|
|
70
|
-
// first anchor, so children.length would overstate a defeated scan by however much it skipped).
|
|
71
|
-
// `flattens` is the probe defeated: a fresh array, a second pass, and a recursion into every
|
|
72
|
-
// anchor. `widest` is the widest single flatten in the window, because a defeated scan over 3
|
|
73
|
-
// children is noise and one over 1000 is not - a count alone cannot separate them.
|
|
74
|
-
//
|
|
75
|
-
// Not gated behind isDebug(), for the same reason the rest of the profile is not: integer
|
|
76
|
-
// increments are noise next to the array work they price, and the number is only meaningful from a
|
|
77
|
-
// release build.
|
|
78
|
-
const childScan = {
|
|
79
|
-
scans: 0,
|
|
80
|
-
probed: 0,
|
|
81
|
-
flattens: 0,
|
|
82
|
-
flattenProbed: 0,
|
|
83
|
-
widest: 0,
|
|
84
|
-
};
|
|
85
|
-
// Diagnostic (gated): Fabric serializes props to folly::dynamic, which rejects a JS
|
|
86
|
-
// Symbol or function with "JS Symbols are not convertible to dynamic". A hard native
|
|
87
|
-
// throw deep in cloneNode*. Walk a props payload and return the dotted path of the
|
|
88
|
-
// first non-serializable leaf (Symbol / function), or undefined when clean, so the
|
|
89
|
-
// offending key is named in logcat instead of a bare stack at the JSI boundary.
|
|
90
|
-
//
|
|
91
|
-
// Bounded on purpose: a real Fabric prop tree is shallow (style/transform ~depth 3) and
|
|
92
|
-
// a leaked React element trips at depth 2 (`children` -> element -> $$typeof). `seen`
|
|
93
|
-
// breaks reference cycles and DEPTH caps runaway nesting, so the diagnostic itself can
|
|
94
|
-
// never overflow the stack on cyclic props (an event-carrying handler, a self-referential
|
|
95
|
-
// style). A crashing guard would be worse than the bug it hunts.
|
|
96
|
-
const NON_SERIALIZABLE_SCAN_DEPTH = 6;
|
|
97
|
-
function firstNonSerializablePath(value, path, depth, seen) {
|
|
98
|
-
const kind = typeof value;
|
|
99
|
-
if (kind === 'symbol' || kind === 'function')
|
|
100
|
-
return `${path}=<${kind}>`;
|
|
101
|
-
if (depth >= NON_SERIALIZABLE_SCAN_DEPTH)
|
|
102
|
-
return undefined;
|
|
103
|
-
if (Array.isArray(value)) {
|
|
104
|
-
if (seen.has(value))
|
|
105
|
-
return undefined;
|
|
106
|
-
seen.add(value);
|
|
107
|
-
for (let index = 0; index < value.length; index += 1) {
|
|
108
|
-
const found = firstNonSerializablePath(value[index], `${path}[${index}]`, depth + 1, seen);
|
|
109
|
-
if (found !== undefined)
|
|
110
|
-
return found;
|
|
111
|
-
}
|
|
112
|
-
return undefined;
|
|
113
|
-
}
|
|
114
|
-
if (isRecord(value)) {
|
|
115
|
-
if (seen.has(value))
|
|
116
|
-
return undefined;
|
|
117
|
-
seen.add(value);
|
|
118
|
-
for (const key of Object.keys(value)) {
|
|
119
|
-
const next = path === '' ? key : `${path}.${key}`;
|
|
120
|
-
const found = firstNonSerializablePath(value[key], next, depth + 1, seen);
|
|
121
|
-
if (found !== undefined)
|
|
122
|
-
return found;
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
return undefined;
|
|
126
|
-
}
|
|
127
|
-
// Name the offending prop before the JSI boundary throws. Gated, so the deep walk only
|
|
128
|
-
// runs while debugging; in production the clone proceeds straight to native.
|
|
129
|
-
function guardSerializable(propsDiff, viewName, tag) {
|
|
130
|
-
if (!isDebug())
|
|
131
|
-
return;
|
|
132
|
-
const bad = firstNonSerializablePath(propsDiff, '', 0, new WeakSet());
|
|
133
|
-
if (bad !== undefined)
|
|
134
|
-
dlog(`NON-SERIALIZABLE prop on ${viewName}#${tag}: ${bad}`);
|
|
135
|
-
}
|
|
136
|
-
function viewNameFor(node, hasTextAncestor) {
|
|
137
|
-
// The only position-dependent name: a <Text> inside another <Text> becomes a
|
|
138
|
-
// virtual span. Everything else is the component string the adapter chose.
|
|
139
|
-
return node.isText && hasTextAncestor
|
|
140
|
-
? VIRTUAL_TEXT_COMPONENT
|
|
141
|
-
: node.component;
|
|
142
|
-
}
|
|
143
|
-
// Fabric's clone*WithNewProps MERGES the raw payload onto the node's existing props,
|
|
144
|
-
// so the payload must be a MINIMAL diff: only the keys that actually changed, plus any
|
|
145
|
-
// key the node held last time but no longer has, sent as `null` so Fabric resets it to
|
|
146
|
-
// default (e.g. `opacity` when a pressed style releases). Mirror React's diffProperties
|
|
147
|
-
// exactly: re-sending an UNCHANGED key is not a no-op, it re-invokes that prop's native
|
|
148
|
-
// setter, and some ViewManagers rebuild on any set. AndroidProgressBar's `styleAttr`
|
|
149
|
-
// setter recreates the whole ProgressBar via setStyle(), so re-sending it on an
|
|
150
|
-
// animating-only toggle dropped and rebuilt the spinner each time, and it never came
|
|
151
|
-
// back. Only matters for clones: a fresh createNode starts from nothing.
|
|
152
|
-
function diffProps(previous, next) {
|
|
153
|
-
const out = {};
|
|
154
|
-
for (const key of Object.keys(next)) {
|
|
155
|
-
if (!jsonEqual(previous[key], next[key]))
|
|
156
|
-
out[key] = next[key];
|
|
157
|
-
}
|
|
158
|
-
for (const key of Object.keys(previous)) {
|
|
159
|
-
if (!(key in next))
|
|
160
|
-
out[key] = null;
|
|
161
|
-
}
|
|
162
|
-
return out;
|
|
163
|
-
}
|
|
164
|
-
// Deep structural equality over the JSON-shaped props payload (Fabric props are
|
|
165
|
-
// serializable: primitives, arrays, plain objects). Used to decide whether a
|
|
166
|
-
// node's props actually changed: `fabricProps` builds a fresh object each
|
|
167
|
-
// commit, so a reference check would report every node as dirty.
|
|
168
|
-
function propsEqual(a, b) {
|
|
169
|
-
return jsonEqual(a, b);
|
|
170
|
-
}
|
|
171
|
-
function jsonEqual(a, b) {
|
|
172
|
-
if (Object.is(a, b))
|
|
173
|
-
return true;
|
|
174
|
-
const aArray = Array.isArray(a);
|
|
175
|
-
const bArray = Array.isArray(b);
|
|
176
|
-
if (aArray && bArray) {
|
|
177
|
-
if (a.length !== b.length)
|
|
178
|
-
return false;
|
|
179
|
-
return a.every((value, index) => jsonEqual(value, b[index]));
|
|
180
|
-
}
|
|
181
|
-
if (aArray || bArray)
|
|
182
|
-
return false;
|
|
183
|
-
if (!isRecord(a) || !isRecord(b))
|
|
184
|
-
return false;
|
|
185
|
-
const keys = Object.keys(a);
|
|
186
|
-
if (keys.length !== Object.keys(b).length)
|
|
187
|
-
return false;
|
|
188
|
-
return keys.every(key => key in b && jsonEqual(a[key], b[key]));
|
|
189
|
-
}
|
|
190
|
-
// The committed-state record (IMirror) and its guarded accessor (committedOf) live on the node
|
|
191
|
-
// itself, in node.ts - see the `committed` field there for why the side table was collapsed into a
|
|
192
|
-
// field, and committedOf's doc comment for the node-identity check that replaced the WeakMap miss.
|
|
193
|
-
// Everything below reads it exclusively through committedOf and writes it as `node.committed`.
|
|
194
|
-
// The predicate both behavior drains take. Passed in rather than imported by `host-behavior.ts`,
|
|
195
|
-
// keeping that dependency one-directional — this module already imports from it, and a cycle is a
|
|
196
|
-
// live hazard under Metro's `inlineRequires`. Hoisted to module scope because two call sites need
|
|
197
|
-
// the identical question.
|
|
198
|
-
const isNodeCommitted = (node) => committedOf(node) !== undefined;
|
|
199
|
-
function isSkippedAtCommit(node) {
|
|
200
|
-
return isAnchor(node) || isEmptyRawText(node);
|
|
201
|
-
}
|
|
202
|
-
function renderableChildren(node) {
|
|
203
|
-
// Anchor nodes (Vue fragment/v-if/v-for placeholders, Angular component hosts that should
|
|
204
|
-
// not paint) live in the retained tree for sibling ordering but never become Fabric views.
|
|
205
|
-
// When an anchor owns children, flatten them into the parent's renderable list: this lets a
|
|
206
|
-
// DOM-less framework use an anchor as a fragment/component host without adding a native
|
|
207
|
-
// wrapper node. Fast path: no anchors reuses the array, so the common case allocates nothing.
|
|
208
|
-
// An empty raw text is skipped for a different reason and with no flattening: it has no
|
|
209
|
-
// children, and committing it aborts the app inside Fabric's text walk (isEmptyRawText).
|
|
210
|
-
//
|
|
211
|
-
// The probe is a counted loop rather than `.some()` only so childScan can price it honestly;
|
|
212
|
-
// see the childScan declaration for what the five counters mean and what they were built to
|
|
213
|
-
// settle.
|
|
214
|
-
childScan.scans += 1;
|
|
215
|
-
const total = node.children.length;
|
|
216
|
-
let index = 0;
|
|
217
|
-
while (index < total && !isSkippedAtCommit(node.children[index]))
|
|
218
|
-
index += 1;
|
|
219
|
-
// The defeating child was examined too, so it counts.
|
|
220
|
-
childScan.probed += index === total ? total : index + 1;
|
|
221
|
-
if (index === total)
|
|
222
|
-
return node.children;
|
|
223
|
-
childScan.flattens += 1;
|
|
224
|
-
childScan.flattenProbed += total;
|
|
225
|
-
if (total > childScan.widest)
|
|
226
|
-
childScan.widest = total;
|
|
227
|
-
const children = [];
|
|
228
|
-
for (const child of node.children) {
|
|
229
|
-
if (isSkippedAtCommit(child)) {
|
|
230
|
-
// A skipped child is flattened away here and never reaches reconcile, so this is the only
|
|
231
|
-
// place that can clear its dirty flag. Leaving it set would be a silent stale-UI bug:
|
|
232
|
-
// markDirty stops at the first dirty ancestor, so a permanently-dirty skipped node swallows
|
|
233
|
-
// every later mark - from an anchor's subtree, or from the setText that turns an empty raw
|
|
234
|
-
// text back into real content - and the real parent never learns anything changed.
|
|
235
|
-
child.dirty = false;
|
|
236
|
-
if (isAnchor(child))
|
|
237
|
-
children.push(...renderableChildren(child));
|
|
238
|
-
}
|
|
239
|
-
else
|
|
240
|
-
children.push(child);
|
|
241
|
-
}
|
|
242
|
-
return children;
|
|
243
|
-
}
|
|
244
|
-
function childrenIdentical(kids, committed) {
|
|
245
|
-
if (kids.length !== committed.length)
|
|
246
|
-
return false;
|
|
247
|
-
return kids.every((child, index) => child === committed[index]);
|
|
248
|
-
}
|
|
249
|
-
// Diagnostic seam (gated): a ScrollView on Android must hold exactly ONE direct
|
|
250
|
-
// child (its content container), or the native mount aborts with "ScrollView can
|
|
251
|
-
// host only one direct child". Logged after children reconcile so each child's
|
|
252
|
-
// committed tag/view-name is resolved. A `MULTI!!` line names the exact extra
|
|
253
|
-
// node (tag + view-name) that pushed the scroll view past one child.
|
|
254
|
-
function logScrollChildren(node, viewName, selfTag) {
|
|
255
|
-
// The whole function is a dlog, and it is called on every reconciled node, so the cheap boolean
|
|
256
|
-
// goes first: with logging off this is one property read instead of two string scans per node.
|
|
257
|
-
if (!isDebug())
|
|
258
|
-
return;
|
|
259
|
-
if (!viewName.includes('Scroll') || viewName.includes('Content'))
|
|
260
|
-
return;
|
|
261
|
-
const kids = node.children.map(child => {
|
|
262
|
-
const committed = committedOf(child);
|
|
263
|
-
return `${committed?.viewName ?? child.component}#${committed?.tag ?? 'NEW'}`;
|
|
264
|
-
});
|
|
265
|
-
const flag = kids.length === 1 ? 'OK' : 'MULTI!!';
|
|
266
|
-
dlog(`SCROLL-${flag} ${viewName} tag=${selfTag} children(${kids.length})=[${kids.join(',')}]`);
|
|
267
|
-
}
|
|
268
|
-
// The failure mode dirty-marking introduces: a mutation path that forgets to markDirty leaves a
|
|
269
|
-
// node whose desired props have drifted from what Fabric holds, and the screen silently keeps
|
|
270
|
-
// showing the old value - no error, no crash, nothing to grep for. So under DEBUG pay back the full
|
|
271
|
-
// price just saved and verify the skip was honest, naming the node loudly enough to find the
|
|
272
|
-
// missing mark.
|
|
273
|
-
//
|
|
274
|
-
// Two lanes reach it, and naming which one fired matters when reading a log: `subtree` is the whole
|
|
275
|
-
// node skipped because nothing under it was marked, `props` is the node re-cloned for a child's
|
|
276
|
-
// sake but its own payload reused because no prop write was recorded on it. They point at
|
|
277
|
-
// different missing marks - markDirty vs markPropsDirty - so the line says which.
|
|
278
|
-
function warnIfStale(node, committed, lane) {
|
|
279
|
-
if (!isDebug())
|
|
280
|
-
return;
|
|
281
|
-
const fresh = fabricProps(node);
|
|
282
|
-
if (propsEqual(committed.props, fresh))
|
|
283
|
-
return;
|
|
284
|
-
dlog(`DIRTY-MISS(${lane}) ${committed.viewName}#${committed.tag} node=${debugNodeId(node)} ` +
|
|
285
|
-
`treated as clean but props differ: committed=${JSON.stringify(committed.props)} ` +
|
|
286
|
-
`desired=${JSON.stringify(fresh)}`);
|
|
287
|
-
}
|
|
288
|
-
function reconcile(slot, node, rootTag, hasTextAncestor, renderableParent, forceFreshFamily) {
|
|
289
|
-
profile.nodesVisited += 1;
|
|
290
|
-
const viewName = viewNameFor(node, hasTextAncestor);
|
|
291
|
-
const committed = committedOf(node);
|
|
292
|
-
// Nothing under here changed: hand back the committed handle without rebuilding this node's
|
|
293
|
-
// Fabric props or descending into it at all. The walk below costs ~13 us/node on device, and an
|
|
294
|
-
// untouched sibling subtree would pay it on every commit. `committed.parent` is re-checked rather
|
|
295
|
-
// than trusted to the flag because a MOVED node can still be legitimately clean (see node.ts's
|
|
296
|
-
// structural ops), and a reparent must fall through to the fresh-family path below.
|
|
297
|
-
if (!forceFreshFamily &&
|
|
298
|
-
!node.dirty &&
|
|
299
|
-
committed !== undefined &&
|
|
300
|
-
committed.parent === renderableParent &&
|
|
301
|
-
committed.viewName === viewName) {
|
|
302
|
-
stats.reused += 1;
|
|
303
|
-
warnIfStale(node, committed, 'subtree');
|
|
304
|
-
return { handle: committed.handle, changed: false };
|
|
305
|
-
}
|
|
306
|
-
node.dirty = false;
|
|
307
|
-
// Read before clearing. The create path below ignores it - a node Fabric has never seen needs its
|
|
308
|
-
// whole payload built regardless - so this only ever gates the update path.
|
|
309
|
-
const ownPropsChanged = node.propsDirty;
|
|
310
|
-
node.propsDirty = false;
|
|
311
|
-
// Cleared here because this call is what re-snapshots `committed.children` below, on both the
|
|
312
|
-
// create and the update path. Anything that reads that snapshot afterwards is reading a current
|
|
313
|
-
// one until the next structural op raises the flag again.
|
|
314
|
-
node.structureDirty = false;
|
|
315
|
-
const childInText = node.isText || hasTextAncestor;
|
|
316
|
-
const kids = renderableChildren(node);
|
|
317
|
-
// First mount, or the view kind flipped (RCTText <-> RCTVirtualText when a
|
|
318
|
-
// <Text> moves in or out of another <Text>): a different native component
|
|
319
|
-
// can't be cloned across, so create a fresh node from scratch.
|
|
320
|
-
const parentChanged = committed !== undefined && committed.parent !== renderableParent;
|
|
321
|
-
if (forceFreshFamily ||
|
|
322
|
-
committed === undefined ||
|
|
323
|
-
committed.viewName !== viewName ||
|
|
324
|
-
parentChanged) {
|
|
325
|
-
stats.created += 1;
|
|
326
|
-
// Full payload: there is no committed props object to reuse, whatever propsDirty said.
|
|
327
|
-
const props = fabricProps(node);
|
|
328
|
-
const tag = nextTag();
|
|
329
|
-
// One gate for the whole diagnostic block, not three dlog calls. This runs once per CREATED
|
|
330
|
-
// node - 9 000 of them on one benchmark press - and dlog cannot help here: its argument is
|
|
331
|
-
// built at the CALL SITE (see debug.ts), so an eager template pays in full with logging off,
|
|
332
|
-
// and a thunk trades that for a closure allocation per node. A plain `if` costs neither.
|
|
333
|
-
if (isDebug()) {
|
|
334
|
-
const reason = committed === undefined
|
|
335
|
-
? 'mount'
|
|
336
|
-
: forceFreshFamily
|
|
337
|
-
? 'fresh-parent'
|
|
338
|
-
: committed.viewName !== viewName
|
|
339
|
-
? 'view-kind'
|
|
340
|
-
: 'reparent';
|
|
341
|
-
dlog(`commit root=${rootTag} createNode tag=${tag} view=${viewName} reason=${reason}`);
|
|
342
|
-
if (viewName === 'RCTView' || viewName === 'RCTText') {
|
|
343
|
-
dlog(`commit root=${rootTag} colorProbe tag=${tag} view=${viewName} ` +
|
|
344
|
-
`bg=${JSON.stringify(props.backgroundColor)} color=${JSON.stringify(props.color)} ` +
|
|
345
|
-
`opacity=${JSON.stringify(props.opacity)}`);
|
|
346
|
-
}
|
|
347
|
-
if (viewName === 'AndroidSwipeRefreshLayout' ||
|
|
348
|
-
viewName === 'RCTScrollView') {
|
|
349
|
-
dlog(`commit root=${rootTag} layoutProbe tag=${tag} view=${viewName} ` +
|
|
350
|
-
`flex=${JSON.stringify(props.flex)} height=${JSON.stringify(props.height)} ` +
|
|
351
|
-
`width=${JSON.stringify(props.width)} minHeight=${JSON.stringify(props.minHeight)} ` +
|
|
352
|
-
`flexGrow=${JSON.stringify(props.flexGrow)}`);
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
const created = slot.createNode(tag, viewName, rootTag, props, node);
|
|
356
|
-
// `createNode` takes no children (UIManagerBinding.cpp gives it 5 params), so a fresh parent
|
|
357
|
-
// can only receive them one `appendChild` at a time — unless we spend a clone to hand the whole
|
|
358
|
-
// list over at once. That is a TRADE, not a win: it removes N-1 JSI crossings and adds one
|
|
359
|
-
// discarded ShadowNode per parent, and which side wins is a native-side question no headless
|
|
360
|
-
// bench can answer. Hence the switch: off, this is byte-for-byte the append loop.
|
|
361
|
-
//
|
|
362
|
-
// Below the threshold the trade is a wash by call count (1 append vs 1 clone) and pure loss by
|
|
363
|
-
// allocation, so a single-child parent never takes it.
|
|
364
|
-
let handle = created;
|
|
365
|
-
if (batchCreate && kids.length >= CREATE_BATCH_MIN_CHILDREN) {
|
|
366
|
-
const childHandles = [];
|
|
367
|
-
for (const child of kids) {
|
|
368
|
-
childHandles.push(reconcile(slot, child, rootTag, childInText, node, true).handle);
|
|
369
|
-
}
|
|
370
|
-
handle = slot.cloneNodeWithNewChildren(created, childHandles);
|
|
371
|
-
}
|
|
372
|
-
else {
|
|
373
|
-
for (const child of kids) {
|
|
374
|
-
slot.appendChild(created, reconcile(slot, child, rootTag, childInText, node, true).handle);
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
logScrollChildren(node, viewName, tag);
|
|
378
|
-
// Investigation instrumentation (search-bar-ref "node not committed" bug): scoped to RNS* so
|
|
379
|
-
// it can be directly compared against the ref-attach log in stack.ts and the dispatch-miss
|
|
380
|
-
// log below — same debugNodeId on both sides proves/disproves an identity mismatch. Kept
|
|
381
|
-
// behind DEBUG per <keep_logs_gate_behind_DEBUG>, never removed.
|
|
382
|
-
if (viewName.startsWith('RNS')) {
|
|
383
|
-
dlog(`committed (create) node=${debugNodeId(node)} tag=${tag} view=${viewName}`);
|
|
384
|
-
}
|
|
385
|
-
// `kids` is stored BY REFERENCE, not copied. With no anchors it IS `node.children`, so the
|
|
386
|
-
// record aliases the live array until the next structural op, which copies it out of the way
|
|
387
|
-
// (markStructureDirty, node.ts). Slicing here instead cost one array per node per commit -
|
|
388
|
-
// 9 002 on a 1 000-row create - and all but the handful of nodes that go on to change threw
|
|
389
|
-
// theirs away unread.
|
|
390
|
-
node.committed = {
|
|
391
|
-
handle,
|
|
392
|
-
tag,
|
|
393
|
-
rootTag,
|
|
394
|
-
props,
|
|
395
|
-
children: kids,
|
|
396
|
-
viewName,
|
|
397
|
-
parent: renderableParent,
|
|
398
|
-
owner: node,
|
|
399
|
-
};
|
|
400
|
-
return { handle, changed: true };
|
|
401
|
-
}
|
|
402
|
-
// Reconcile children first; a child that re-cloned forces this node to re-clone
|
|
403
|
-
// too, since Fabric parents point at specific child handles.
|
|
404
|
-
const childHandles = [];
|
|
405
|
-
let descendantChanged = false;
|
|
406
|
-
for (const child of kids) {
|
|
407
|
-
const result = reconcile(slot, child, rootTag, childInText, node, false);
|
|
408
|
-
childHandles.push(result.handle);
|
|
409
|
-
if (result.changed)
|
|
410
|
-
descendantChanged = true;
|
|
411
|
-
}
|
|
412
|
-
logScrollChildren(node, viewName, committed.tag);
|
|
413
|
-
const childrenChanged = !childrenIdentical(kids, committed.children) || descendantChanged;
|
|
414
|
-
// The fast lane. No prop write was recorded on this node since its last commit, so its Fabric
|
|
415
|
-
// payload is by construction the one the mirror already holds: reuse that object by reference -
|
|
416
|
-
// no rebuild, no allocation, no deep compare - and carry it into the mirror below untouched.
|
|
417
|
-
//
|
|
418
|
-
// This is what propsDirty (node.ts) exists for. Every node on the clone-bubble path from a
|
|
419
|
-
// changed leaf up to the root takes this branch, as does the synthetic container that
|
|
420
|
-
// commitContainer dirties at every single entry. They still re-clone - a persistent parent must
|
|
421
|
-
// point at the new child handles - they just stop paying `fabricProps` + a recursive `propsEqual`
|
|
422
|
-
// to rediscover that nobody touched them.
|
|
423
|
-
//
|
|
424
|
-
// Under DEBUG the saving is handed straight back to check it was honest: an in-place mutation of
|
|
425
|
-
// a style object or of node.props, which no flag can observe, is the same hazard warnIfStale
|
|
426
|
-
// already guards on the skip path, and this lane is open to it identically.
|
|
427
|
-
let props;
|
|
428
|
-
let propsChanged;
|
|
429
|
-
if (ownPropsChanged) {
|
|
430
|
-
profile.propsBuilt += 1;
|
|
431
|
-
props = fabricProps(node);
|
|
432
|
-
propsChanged = !propsEqual(committed.props, props);
|
|
433
|
-
}
|
|
434
|
-
else {
|
|
435
|
-
profile.propsReused += 1;
|
|
436
|
-
props = committed.props;
|
|
437
|
-
propsChanged = false;
|
|
438
|
-
warnIfStale(node, committed, 'props');
|
|
439
|
-
}
|
|
440
|
-
if (!childrenChanged && !propsChanged) {
|
|
441
|
-
stats.reused += 1;
|
|
442
|
-
return { handle: committed.handle, changed: false };
|
|
443
|
-
}
|
|
444
|
-
let handle;
|
|
445
|
-
if (childrenChanged) {
|
|
446
|
-
stats.cloneChildren += 1;
|
|
447
|
-
// A clone comes back with an EMPTY child list, so every sibling handle has to be handed back
|
|
448
|
-
// one by one — that loop is why touching one row of a thousand costs a thousand JSI crossings
|
|
449
|
-
// at every level up to the root. Where the host accepts the list in the clone call itself it
|
|
450
|
-
// becomes ONE crossing (see supportsCloneWithChildren in fabric.ts).
|
|
451
|
-
const batched = slot.supportsCloneWithChildren;
|
|
452
|
-
if (propsChanged) {
|
|
453
|
-
const propsDiff = diffProps(committed.props, props);
|
|
454
|
-
guardSerializable(propsDiff, viewName, committed.tag);
|
|
455
|
-
handle = slot.cloneNodeWithNewChildrenAndProps(committed.handle, propsDiff, batched ? childHandles : undefined);
|
|
456
|
-
}
|
|
457
|
-
else {
|
|
458
|
-
handle = slot.cloneNodeWithNewChildren(committed.handle, batched ? childHandles : undefined);
|
|
459
|
-
}
|
|
460
|
-
if (!batched) {
|
|
461
|
-
for (const childHandle of childHandles) {
|
|
462
|
-
slot.appendChild(handle, childHandle);
|
|
463
|
-
}
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
else {
|
|
467
|
-
stats.cloneProps += 1;
|
|
468
|
-
const propsDiff = diffProps(committed.props, props);
|
|
469
|
-
guardSerializable(propsDiff, viewName, committed.tag);
|
|
470
|
-
handle = slot.cloneNodeWithNewProps(committed.handle, propsDiff);
|
|
471
|
-
}
|
|
472
|
-
// Investigation instrumentation (search-bar-ref "node not committed" bug): see the create-path
|
|
473
|
-
// dlog above. Kept behind DEBUG per <keep_logs_gate_behind_DEBUG>, never removed.
|
|
474
|
-
if (viewName.startsWith('RNS')) {
|
|
475
|
-
dlog(`committed (update) node=${debugNodeId(node)} tag=${committed.tag} view=${viewName}`);
|
|
476
|
-
}
|
|
477
|
-
// Written IN PLACE rather than as a fresh record. The node is the same node, its tag and owner
|
|
478
|
-
// are unchanged by a clone (the clone keeps the family), so replacing the object bought nothing
|
|
479
|
-
// and cost one allocation per changed node per commit - and on an ordinary update that is the
|
|
480
|
-
// whole clone-bubble from the changed leaf up to the root.
|
|
481
|
-
committed.handle = handle;
|
|
482
|
-
committed.rootTag = rootTag;
|
|
483
|
-
committed.props = props;
|
|
484
|
-
// The same flattened child list we diffed against, by reference (see the create path above for
|
|
485
|
-
// why it is not copied). Anchors are retained-tree bookkeeping only; keeping raw node.children
|
|
486
|
-
// here makes every anchored subtree look structurally changed on the next commit and can
|
|
487
|
-
// re-append already-parented Fabric ShadowNode families under a cloned parent.
|
|
488
|
-
committed.children = kids;
|
|
489
|
-
committed.viewName = viewName;
|
|
490
|
-
committed.parent = renderableParent;
|
|
491
|
-
return { handle, changed: true };
|
|
492
|
-
}
|
|
493
|
-
// One persistent synthetic root container per surface, mirroring RN's AppContainer
|
|
494
|
-
// (renderApplication wraps the app in `<View style={{flex:1}} pointerEvents="box-none">`).
|
|
495
|
-
// Without it a non-flex root view collapses to content height, and touches outside the
|
|
496
|
-
// app's children have no box-none escape. Keeping it here (not in each adapter's
|
|
497
|
-
// mount()) gives every framework a full-screen flex root for free and keeps layout in
|
|
498
|
-
// shared (adapters_stay_thin). The container is just another persistent node in the
|
|
499
|
-
// clone-on-write engine: stable identity, so an unchanged subtree leaves it un-cloned.
|
|
500
|
-
const ROOT_VIEW_COMPONENT = 'RCTView';
|
|
501
|
-
const ROOT_CONTAINER_STYLE = { flex: 1 };
|
|
502
|
-
const ROOT_CONTAINER_POINTER_EVENTS = 'box-none';
|
|
503
|
-
const rootContainers = new Map();
|
|
504
|
-
function rootContainerFor(rootTag) {
|
|
505
|
-
let container = rootContainers.get(rootTag);
|
|
506
|
-
if (container === undefined) {
|
|
507
|
-
container = createElement(ROOT_VIEW_COMPONENT);
|
|
508
|
-
container.props = {
|
|
509
|
-
style: ROOT_CONTAINER_STYLE,
|
|
510
|
-
pointerEvents: ROOT_CONTAINER_POINTER_EVENTS,
|
|
511
|
-
};
|
|
512
|
-
rootContainers.set(rootTag, container);
|
|
513
|
-
dlog(`root container created root=${rootTag} (flex:1, box-none)`);
|
|
514
|
-
}
|
|
515
|
-
return container;
|
|
516
|
-
}
|
|
517
|
-
// Drop a surface's persistent root container so the NEXT mount on this rootTag starts
|
|
518
|
-
// from scratch (fresh tags, fresh mirror) instead of cloning handles that belonged to a
|
|
519
|
-
// now-stopped surface. Called from unmount (the bridgeless surface-stop path): the host stops then restarts a
|
|
520
|
-
// surface (Fast Refresh, focus/lifecycle) reusing the same rootTag, and a stale root
|
|
521
|
-
// container would re-clone dead handles into the new surface -> a blank screen. The old
|
|
522
|
-
// container's descendants fall out of every reference and are collected with the committed
|
|
523
|
-
// records they carry.
|
|
524
|
-
export function disposeRoot(rootTag) {
|
|
525
|
-
// Drop any setNativeProps writes still queued for this surface: their flush is a microtask away
|
|
526
|
-
// and would otherwise commit into a container that no longer exists, re-creating it from scratch.
|
|
527
|
-
pendingByRoot.delete(rootTag);
|
|
528
|
-
const container = rootContainers.get(rootTag);
|
|
529
|
-
if (container === undefined)
|
|
530
|
-
return;
|
|
531
|
-
// BEFORE the delete, which is the only reason the subtree is still reachable. An unmount removes
|
|
532
|
-
// no child, so `sweepDetachedBehaviors` never hears about these nodes — without this every one of
|
|
533
|
-
// them keeps its `afterCommit` registration and its timers, and a restarted surface's commits
|
|
534
|
-
// drain the dead one's hooks forever (`.claude/rules/unmount-does-not-sweep-host-behaviors.md`).
|
|
535
|
-
teardownSubtree(container, detachAnimatedProps);
|
|
536
|
-
rootContainers.delete(rootTag);
|
|
537
|
-
dlog(`root container disposed root=${rootTag}`);
|
|
538
|
-
}
|
|
539
|
-
export function commitChildren(rootTag, children) {
|
|
540
|
-
// The wrapper holds the surface's top-level children; reconcile walks from it so the
|
|
541
|
-
// whole tree, synthetic root included, goes through the same clone-on-write path.
|
|
542
|
-
const container = rootContainerFor(rootTag);
|
|
543
|
-
container.children = children.slice();
|
|
544
|
-
markStructureDirty(container);
|
|
545
|
-
commitContainer(rootTag);
|
|
546
|
-
}
|
|
547
|
-
// Re-run the scoped commit for a surface from its synthetic root container, reusing
|
|
548
|
-
// whatever top-level children it currently holds. The shared half of the engine: both
|
|
549
|
-
// a full mutation->commit and a single-node Animated frame (setNativeProps) funnel here.
|
|
550
|
-
function commitContainer(rootTag) {
|
|
551
|
-
const slot = getSlot();
|
|
552
|
-
batchCreate =
|
|
553
|
-
globalThis.__SYMBIOTE_BATCH_CREATE__ === true &&
|
|
554
|
-
slot.supportsCloneWithChildren;
|
|
555
|
-
const container = rootContainerFor(rootTag);
|
|
556
|
-
// The synthetic container is dirtied here, at the one entry point, because markDirty can never
|
|
557
|
-
// bubble up to it: a surface's top-level nodes carry `parent === undefined` (surface.ts sets it
|
|
558
|
-
// deliberately), so a mark stops at the top-level node and the container above it stays clean -
|
|
559
|
-
// it would then early-exit and swallow the whole commit. Marking unconditionally costs one node's
|
|
560
|
-
// props rebuild per commit and closes the hole for both callers, mutation commit and
|
|
561
|
-
// setNativeProps alike.
|
|
562
|
-
markDirty(container);
|
|
563
|
-
// Before the walk, and before either early return: mutations for this tick are done, so a node
|
|
564
|
-
// that `removeChild` unlinked is now either back under a parent (a framework spelling a move as
|
|
565
|
-
// remove-then-reinsert) or gone for good. Costs one Set-size read until an app registers its
|
|
566
|
-
// first host behavior. See host-behavior.ts for why removal cannot answer this itself.
|
|
567
|
-
sweepDetachedBehaviors(container.children, detachAnimatedProps);
|
|
568
|
-
stats.created = 0;
|
|
569
|
-
stats.cloneProps = 0;
|
|
570
|
-
stats.cloneChildren = 0;
|
|
571
|
-
stats.reused = 0;
|
|
572
|
-
// Entry seam: brackets reconcile with the `reconciled` line below. If `start` prints
|
|
573
|
-
// but `reconciled` never does, the stall is inside reconcile (a JS loop/cycle in the
|
|
574
|
-
// tree walk); if `start` itself never prints, the stall is upstream: React's commit
|
|
575
|
-
// phase or the mutation ops before we are even called.
|
|
576
|
-
dlog(`commit root=${rootTag} start children=${container.children.length}`);
|
|
577
|
-
const walkStart = performance.now();
|
|
578
|
-
const result = reconcile(slot, container, rootTag, false, undefined, false);
|
|
579
|
-
const walkMs = performance.now() - walkStart;
|
|
580
|
-
profile.walkMs += walkMs;
|
|
581
|
-
profile.commits += 1;
|
|
582
|
-
// Boundary seam: prints once reconcile returns. If a commit hangs and this line
|
|
583
|
-
// never appears, the stall is inside reconcile (JS); if it appears but the
|
|
584
|
-
// post-completeRoot line below never does, the stall is inside the native commit.
|
|
585
|
-
dlog(`commit root=${rootTag} reconciled changed=${result.changed}`);
|
|
586
|
-
// The container's identity is stable, so its un-cloned flag is the no-op signal:
|
|
587
|
-
// an over-scheduled commit that touched nothing makes zero native calls.
|
|
588
|
-
//
|
|
589
|
-
// TRAP FOR BEHAVIOR AUTHORS, and it cost two iterations to find: this return is the gate on
|
|
590
|
-
// `runPostCommitHooks` and `runDeferredAttaches` below. Both exist to retry once FRESH FABRIC TAGS
|
|
591
|
-
// are assigned, and a commit that made zero native calls assigned none — so gating them here is
|
|
592
|
-
// correct, and hoisting them above this line would run every deferred hook on every
|
|
593
|
-
// over-scheduled commit, which is the common case.
|
|
594
|
-
//
|
|
595
|
-
// `afterCommit` is NOT in that group and was gated with them by accident until 2026-09-10. It asks
|
|
596
|
-
// only "props were published", which a no-op commit satisfies, and gating it made it unreachable
|
|
597
|
-
// for exactly the props a behavior OWNS: a fold that strips a prop (TouchableOpacity's `disabled`,
|
|
598
|
-
// Button's `title`/`color`) makes its own commit byte-identical, so the hook that must react to
|
|
599
|
-
// the flip is the one the flip cannot wake. It is drained on both paths now.
|
|
600
|
-
//
|
|
601
|
-
// A behavior needing a turn of the loop with nothing to write at all may still schedule its own
|
|
602
|
-
// (`queueMicrotask`, as Switch's snap-back and Angular's `snapBackIfNeeded` both do).
|
|
603
|
-
if (!result.changed) {
|
|
604
|
-
dlog(`commit root=${rootTag} no-op (skipped completeRoot)`);
|
|
605
|
-
runCommittedHooks(isNodeCommitted);
|
|
606
|
-
return;
|
|
607
|
-
}
|
|
608
|
-
const childSet = slot.createChildSet(rootTag);
|
|
609
|
-
slot.appendChildToSet(childSet, result.handle);
|
|
610
|
-
dlog(`commit root=${rootTag} pre-completeRoot`);
|
|
611
|
-
slot.completeRoot(rootTag, childSet);
|
|
612
|
-
// Fresh Fabric tags are now assigned: let any consumer that needed a committed tag
|
|
613
|
-
// and ran too early (the Animated native driver binding a props node to a view under
|
|
614
|
-
// an async-batched commit) retry now. No-op when nothing is pending.
|
|
615
|
-
runPostCommitHooks();
|
|
616
|
-
// The same moment, for the half of a host behavior that could not run at `attach`. A behavior
|
|
617
|
-
// whose setup needs a Fabric tag (a view command, a native Animated binding, an event attach)
|
|
618
|
-
// declares `attachAfterCommit` and is drained here.
|
|
619
|
-
runDeferredAttaches(isNodeCommitted);
|
|
620
|
-
// After the setup half, never before it: a node carrying both hooks has `attachAfterCommit` seed
|
|
621
|
-
// the mirrors `afterCommit` then compares against. See `runCommittedHooks`.
|
|
622
|
-
runCommittedHooks(isNodeCommitted);
|
|
623
|
-
if (isDebug()) {
|
|
624
|
-
const mode = stats.created > 0 && stats.reused === 0 ? 'full' : 'incremental';
|
|
625
|
-
dlog(`commit root=${rootTag} ${mode} ` +
|
|
626
|
-
`created=${stats.created} cloneProps=${stats.cloneProps} ` +
|
|
627
|
-
`cloneChildren=${stats.cloneChildren} reused=${stats.reused} ` +
|
|
628
|
-
`propsBuilt=${profile.propsBuilt} propsReused=${profile.propsReused} ` +
|
|
629
|
-
`walk=${walkMs.toFixed(3)}ms`);
|
|
630
|
-
}
|
|
631
|
-
}
|
|
632
|
-
export function readCommitProfile() {
|
|
633
|
-
const props = takePropStats();
|
|
634
|
-
const snapshot = {
|
|
635
|
-
commits: profile.commits,
|
|
636
|
-
walkMs: profile.walkMs,
|
|
637
|
-
nodesVisited: profile.nodesVisited,
|
|
638
|
-
propsBuilt: profile.propsBuilt,
|
|
639
|
-
propsReused: profile.propsReused,
|
|
640
|
-
propWrites: props.writes,
|
|
641
|
-
propNoops: props.noops,
|
|
642
|
-
childScans: childScan.scans,
|
|
643
|
-
childScanProbed: childScan.probed,
|
|
644
|
-
childFlattens: childScan.flattens,
|
|
645
|
-
childFlattenProbed: childScan.flattenProbed,
|
|
646
|
-
childFlattenWidest: childScan.widest,
|
|
647
|
-
};
|
|
648
|
-
profile.commits = 0;
|
|
649
|
-
profile.walkMs = 0;
|
|
650
|
-
profile.nodesVisited = 0;
|
|
651
|
-
profile.propsBuilt = 0;
|
|
652
|
-
profile.propsReused = 0;
|
|
653
|
-
childScan.scans = 0;
|
|
654
|
-
childScan.probed = 0;
|
|
655
|
-
childScan.flattens = 0;
|
|
656
|
-
childScan.flattenProbed = 0;
|
|
657
|
-
childScan.widest = 0;
|
|
658
|
-
return snapshot;
|
|
659
|
-
}
|
|
660
|
-
function commitTargeted(nodes) {
|
|
661
|
-
// ── PLAN FIRST, MUTATE SECOND ──────────────────────────────────────────────────────────────
|
|
662
|
-
// Every precondition is checked and every sibling handle resolved before a single native call,
|
|
663
|
-
// so a bail costs nothing and leaves the tree exactly as it was.
|
|
664
|
-
//
|
|
665
|
-
// This is not hypothetical tidiness: an earlier version validated the ancestor chain up front but
|
|
666
|
-
// resolved SIBLING handles inside the clone loop, so a bail half-way had already re-pointed a
|
|
667
|
-
// node's committed record at a clone that was never handed to completeRoot, and had already
|
|
668
|
-
// cleared its dirty flags - so the general-path fallback then skipped the node as clean and
|
|
669
|
-
// committed an orphan handle. The fallback test in animated-commit-cost.test.ts is what caught it.
|
|
670
|
-
const writes = [];
|
|
671
|
-
for (const node of nodes) {
|
|
672
|
-
const record = committedOf(node);
|
|
673
|
-
if (record === undefined)
|
|
674
|
-
return false;
|
|
675
|
-
// The node's own children must be the ones Fabric holds: this path never descends, so a
|
|
676
|
-
// pending structural change below would be published as if it had not happened.
|
|
677
|
-
if (node.structureDirty)
|
|
678
|
-
return false;
|
|
679
|
-
// THE TWIN OF THE CHECK ABOVE, and the one that was missing. `dirty` is a SUBTREE flag — it
|
|
680
|
-
// means "this node or something under it needs work" — while this path clears it as if it were
|
|
681
|
-
// a self flag. Clearing it over a dirty descendant strands that descendant permanently: the
|
|
682
|
-
// general commit reconciles from the root, finds a clean chain, and never descends again.
|
|
683
|
-
//
|
|
684
|
-
// Direct children are enough, and that is not an approximation. `markDirty` walks up from the
|
|
685
|
-
// dirtied node and STOPS at the first already-dirty ancestor; in this scenario that ancestor is
|
|
686
|
-
// this node, so the chain from any dirty descendant up to here is fully marked — which makes a
|
|
687
|
-
// dirty direct child a certainty whenever a dirty descendant exists. So the check is O(children)
|
|
688
|
-
// rather than a subtree walk, on a path that runs once per animation frame.
|
|
689
|
-
//
|
|
690
|
-
// Reproduced 2026-08-24 by a peer's flag dump after tier-2 made a press ask for its own commit:
|
|
691
|
-
// press the node, dirty its child in the same tick, and the child never commits again. Vue and
|
|
692
|
-
// Solid did not show it because their schedulers rewrite the prop on the node itself and
|
|
693
|
-
// re-dirty the chain — an accident of those schedulers, not a property of this contract.
|
|
694
|
-
if (node.children.some(child => child.dirty)) {
|
|
695
|
-
dlog('commit targeted: dirty descendant, using the general path');
|
|
696
|
-
return false;
|
|
697
|
-
}
|
|
698
|
-
// Counted like the general path's rebuild, because it is one: the meter must not read as though
|
|
699
|
-
// an animation frame builds no payload just because it took the short route.
|
|
700
|
-
profile.propsBuilt += 1;
|
|
701
|
-
const props = fabricProps(node);
|
|
702
|
-
const diff = diffProps(record.props, props);
|
|
703
|
-
if (Object.keys(diff).length === 0) {
|
|
704
|
-
// Fabric already holds these values. Not an error and not a fallback: the general path would
|
|
705
|
-
// reach the same conclusion, after walking the tree to get here. Dropping it from the batch
|
|
706
|
-
// is safe even if a LATER node bails the whole thing — its props genuinely match Fabric, so
|
|
707
|
-
// the general commit skipping it as clean publishes the same tree.
|
|
708
|
-
node.dirty = false;
|
|
709
|
-
node.propsDirty = false;
|
|
710
|
-
dlog(`commit targeted tag=${record.tag} no-op (props identical)`);
|
|
711
|
-
continue;
|
|
712
|
-
}
|
|
713
|
-
writes.push({ node, record, props, diff });
|
|
714
|
-
}
|
|
715
|
-
if (writes.length === 0)
|
|
716
|
-
return true;
|
|
717
|
-
// Build the union of the ancestor chains. `changed` is the whole union keyed by node, so a shared
|
|
718
|
-
// ancestor is entered ONCE however many of the batch's leaves sit under it — which is the entire
|
|
719
|
-
// saving over committing each leaf separately.
|
|
720
|
-
const changed = new Map();
|
|
721
|
-
let unionRoot;
|
|
722
|
-
for (const write of writes) {
|
|
723
|
-
let child = write.node;
|
|
724
|
-
let ancestor = write.record.parent;
|
|
725
|
-
while (ancestor !== undefined) {
|
|
726
|
-
const record = committedOf(ancestor);
|
|
727
|
-
if (record === undefined)
|
|
728
|
-
return false;
|
|
729
|
-
// THE precondition, and the one an earlier version of this function missed. `record.children`
|
|
730
|
-
// is a SNAPSHOT from the last commit. If this ancestor's real child list has moved on - a row
|
|
731
|
-
// added, removed, or reordered - rebuilding its child set from that snapshot silently
|
|
732
|
-
// publishes the OLD structure, with no error anywhere. Caught by the fallback row in
|
|
733
|
-
// animated-commit-cost.test.ts, which appends a sibling and then animates: without this check
|
|
734
|
-
// the new sibling never reached Fabric and every other assertion still passed.
|
|
735
|
-
if (ancestor.structureDirty) {
|
|
736
|
-
dlog('commit targeted: ancestor child list moved on, using the general path');
|
|
737
|
-
return false;
|
|
738
|
-
}
|
|
739
|
-
const seen = changed.get(ancestor);
|
|
740
|
-
if (seen !== undefined) {
|
|
741
|
-
// Already in the union via another leaf. Everything ABOVE it is therefore already in too,
|
|
742
|
-
// and already names this node as changed, so the walk stops here.
|
|
743
|
-
seen.add(child);
|
|
744
|
-
break;
|
|
745
|
-
}
|
|
746
|
-
changed.set(ancestor, new Set([child]));
|
|
747
|
-
if (record.parent === undefined)
|
|
748
|
-
unionRoot = ancestor;
|
|
749
|
-
child = ancestor;
|
|
750
|
-
ancestor = record.parent;
|
|
751
|
-
}
|
|
752
|
-
}
|
|
753
|
-
// Resolve every branch's child handles now, so the clone pass below cannot fail part-way.
|
|
754
|
-
const branches = new Map();
|
|
755
|
-
for (const [node, changedChildren] of changed) {
|
|
756
|
-
const record = committedOf(node);
|
|
757
|
-
if (record === undefined)
|
|
758
|
-
return false;
|
|
759
|
-
const handles = [];
|
|
760
|
-
const slots = new Map();
|
|
761
|
-
for (const child of record.children) {
|
|
762
|
-
const childRecord = committedOf(child);
|
|
763
|
-
// A sibling with no committed record means a structural change is pending, which this path
|
|
764
|
-
// cannot express. Bail before touching anything.
|
|
765
|
-
if (childRecord === undefined) {
|
|
766
|
-
dlog('commit targeted: uncommitted sibling, using the general path');
|
|
767
|
-
return false;
|
|
768
|
-
}
|
|
769
|
-
if (changedChildren.has(child))
|
|
770
|
-
slots.set(child, handles.length);
|
|
771
|
-
handles.push(childRecord.handle);
|
|
772
|
-
}
|
|
773
|
-
if (slots.size !== changedChildren.size) {
|
|
774
|
-
// A changed child is not in its parent's committed child list — an uncommitted move.
|
|
775
|
-
dlog('commit targeted: child not in committed set, using the general path');
|
|
776
|
-
return false;
|
|
777
|
-
}
|
|
778
|
-
branches.set(node, { record, handles, slots });
|
|
779
|
-
}
|
|
780
|
-
// Every write is on one surface (the caller batches by rootTag), so the chains converge on that
|
|
781
|
-
// surface's synthetic container and nowhere else. Checked HERE, the last statement of the plan,
|
|
782
|
-
// so the clone pass below has no way left to bail after it has started mutating.
|
|
783
|
-
const rootBranch = unionRoot === undefined ? undefined : branches.get(unionRoot);
|
|
784
|
-
if (unionRoot === undefined || rootBranch === undefined)
|
|
785
|
-
return false;
|
|
786
|
-
const rootTag = rootBranch.record.rootTag;
|
|
787
|
-
// ── MUTATE ─────────────────────────────────────────────────────────────────────────────────
|
|
788
|
-
const slot = getSlot();
|
|
789
|
-
const walkStart = performance.now();
|
|
790
|
-
const cloned = new Map();
|
|
791
|
-
for (const write of writes) {
|
|
792
|
-
guardSerializable(write.diff, write.record.viewName, write.record.tag);
|
|
793
|
-
const handle = slot.cloneNodeWithNewProps(write.record.handle, write.diff);
|
|
794
|
-
stats.cloneProps += 1;
|
|
795
|
-
write.record.handle = handle;
|
|
796
|
-
write.record.props = write.props;
|
|
797
|
-
write.node.dirty = false;
|
|
798
|
-
write.node.propsDirty = false;
|
|
799
|
-
cloned.set(write.node, handle);
|
|
800
|
-
}
|
|
801
|
-
// Re-clone each branch exactly once, deepest first. The recursion is bounded by the tree depth,
|
|
802
|
-
// and every branch it touches is in the plan above, so nothing here can bail.
|
|
803
|
-
const cloneBranch = (node) => {
|
|
804
|
-
const done = cloned.get(node);
|
|
805
|
-
if (done !== undefined)
|
|
806
|
-
return done;
|
|
807
|
-
const branch = branches.get(node);
|
|
808
|
-
// Unreachable by construction: cloneBranch is only ever called on a node the plan put in
|
|
809
|
-
// `branches` or the write loop put in `cloned`. Leaving the committed handle in place is the
|
|
810
|
-
// safe direction if that ever stops being true, and the oracle row would report it.
|
|
811
|
-
if (branch === undefined)
|
|
812
|
-
return undefined;
|
|
813
|
-
for (const [child, index] of branch.slots) {
|
|
814
|
-
const childHandle = cloneBranch(child);
|
|
815
|
-
if (childHandle !== undefined)
|
|
816
|
-
branch.handles[index] = childHandle;
|
|
817
|
-
}
|
|
818
|
-
const batched = slot.supportsCloneWithChildren;
|
|
819
|
-
const handle = slot.cloneNodeWithNewChildren(branch.record.handle, batched ? branch.handles : undefined);
|
|
820
|
-
stats.cloneChildren += 1;
|
|
821
|
-
if (!batched)
|
|
822
|
-
for (const childHandle of branch.handles)
|
|
823
|
-
slot.appendChild(handle, childHandle);
|
|
824
|
-
branch.record.handle = handle;
|
|
825
|
-
cloned.set(node, handle);
|
|
826
|
-
return handle;
|
|
827
|
-
};
|
|
828
|
-
const rootHandle = cloneBranch(unionRoot) ?? rootBranch.record.handle;
|
|
829
|
-
const childSet = slot.createChildSet(rootTag);
|
|
830
|
-
slot.appendChildToSet(childSet, rootHandle);
|
|
831
|
-
slot.completeRoot(rootTag, childSet);
|
|
832
|
-
profile.walkMs += performance.now() - walkStart;
|
|
833
|
-
profile.commits += 1;
|
|
834
|
-
profile.nodesVisited += cloned.size;
|
|
835
|
-
runPostCommitHooks();
|
|
836
|
-
// Same reasoning as the container path's drain, reached from the other end: this commit
|
|
837
|
-
// published props, which is all `afterCommit` asks. Missing here it was unreachable for a
|
|
838
|
-
// behavior whose only write is its own bookkeeping — no framework prop changes, so no container
|
|
839
|
-
// commit follows to drain it. `runDeferredAttaches` deliberately stays out: a targeted commit
|
|
840
|
-
// only happens after a container commit has already assigned tags and drained them.
|
|
841
|
-
runCommittedHooks(isNodeCommitted);
|
|
842
|
-
dlog(`commit targeted root=${rootTag} leaves=${writes.length} ` +
|
|
843
|
-
`union=${branches.size}`);
|
|
844
|
-
return true;
|
|
845
|
-
}
|
|
846
|
-
// A JS-driven Animated frame lands in setNativeProps once per animated leaf. Five animations on
|
|
847
|
-
// five rows of one list therefore used to mean FIVE commits per frame: five completeRoots, and
|
|
848
|
-
// every shared ancestor re-cloned - with its whole child list re-appended - five times over. The
|
|
849
|
-
// dirty-set census (`symbiote-perf-measurement` skill) measured that appendChild is the real floor
|
|
850
|
-
// of a commit and that it multiplies almost exactly with the commit count. So the win here is not a
|
|
851
|
-
// faster commit, it is FEWER of them.
|
|
852
|
-
//
|
|
853
|
-
// The batch never drops a value, and that is a rule, not a hope. Merging writes to DIFFERENT nodes
|
|
854
|
-
// is free - each carries its own value and one commit publishes them all. The single case where
|
|
855
|
-
// merging WOULD lose a value is a second write to a node already pending, so that case does not
|
|
856
|
-
// merge: it publishes the pending batch first, synchronously, and opens a new one. Hence:
|
|
857
|
-
//
|
|
858
|
-
// N writes to N different nodes in one task -> one completeRoot, all N values land
|
|
859
|
-
// two writes to the SAME node in one task -> two completeRoots, both values land
|
|
860
|
-
//
|
|
861
|
-
// The second row costs nothing in practice: an Animated.Value ticks its props node exactly once per
|
|
862
|
-
// rAF (animations/timing.ts's onFrame calls onUpdate once, then schedules the next frame), so
|
|
863
|
-
// concurrent animations are always distinct nodes. It exists for the paths that genuinely can write
|
|
864
|
-
// twice - two animations bound to one prop of one node, or Animated.event when the host delivers
|
|
865
|
-
// two scroll events in a single task.
|
|
866
|
-
const pendingByRoot = new Map();
|
|
867
|
-
let flushScheduled = false;
|
|
868
|
-
// Publish every pending write: one commit per surface. A surface with exactly one pending node
|
|
869
|
-
// takes the targeted chain clone (4.5x, §4a); with several it takes the general walk, which already
|
|
870
|
-
// visits only dirty paths and reaches all of them under a single completeRoot - which is the point.
|
|
871
|
-
export function flushNativeProps() {
|
|
872
|
-
flushScheduled = false;
|
|
873
|
-
if (pendingByRoot.size === 0)
|
|
874
|
-
return;
|
|
875
|
-
const batches = [...pendingByRoot];
|
|
876
|
-
pendingByRoot.clear();
|
|
877
|
-
for (const [rootTag, nodes] of batches) {
|
|
878
|
-
if (commitTargeted(nodes))
|
|
879
|
-
continue;
|
|
880
|
-
dlog(`flush native props root=${rootTag} nodes=${nodes.size} (general)`);
|
|
881
|
-
commitContainer(rootTag);
|
|
882
|
-
}
|
|
883
|
-
}
|
|
884
|
-
// Targeted per-frame prop write for the JS-driven Animated path. RN flushes an
|
|
885
|
-
// animation frame with an in-place `instance.setNativeProps(...)`; we have no
|
|
886
|
-
// in-place mutation (Fabric is persistent), so a frame is one scoped commit: mutate
|
|
887
|
-
// the node's desired props, then re-reconcile its surface. The engine clones only this
|
|
888
|
-
// node (props differ), bubbles the re-clone to the root, reuses every sibling subtree
|
|
889
|
-
// by reference, and emits a single completeRoot. This is the "slow tier", viable for a
|
|
890
|
-
// single shallow animation; driving the animation natively is the answer for scale.
|
|
891
|
-
export function setNativeProps(node, partial) {
|
|
892
|
-
const record = committedOf(node);
|
|
893
|
-
if (record === undefined) {
|
|
894
|
-
dlog('setNativeProps skipped: node not committed');
|
|
895
|
-
return;
|
|
896
|
-
}
|
|
897
|
-
// BEFORE the prop writes below, deliberately: the pending batch still has to publish the value
|
|
898
|
-
// this node holds right now, and mutating first would overwrite the very thing being preserved.
|
|
899
|
-
if (pendingByRoot.get(record.rootTag)?.has(node) === true) {
|
|
900
|
-
dlog(`setNativeProps tag=${record.tag} written twice in one task, flushing`);
|
|
901
|
-
flushNativeProps();
|
|
902
|
-
}
|
|
903
|
-
for (const [key, value] of Object.entries(partial)) {
|
|
904
|
-
if (key === 'style') {
|
|
905
|
-
// A partial style override MERGES onto the declarative style (RN semantics):
|
|
906
|
-
// setNativeProps({style:{backgroundColor}}) recolors without dropping height
|
|
907
|
-
// or radius. Transient: the next React commit re-applies the full style.
|
|
908
|
-
node.props.style = {
|
|
909
|
-
...flattenStyle(node.props.style),
|
|
910
|
-
...flattenStyle(value),
|
|
911
|
-
};
|
|
912
|
-
}
|
|
913
|
-
else {
|
|
914
|
-
node.props[key] = value;
|
|
915
|
-
// Writes `node.props` directly, so it owes the aria gate for the same reason it owes the
|
|
916
|
-
// props mark below: `setProp` is where that flag is normally raised and this path bypasses
|
|
917
|
-
// it. An `aria-*` arriving only through setNativeProps would otherwise never be folded.
|
|
918
|
-
if (!node.hasAriaAlias && isAriaAliasKey(key))
|
|
919
|
-
node.hasAriaAlias = true;
|
|
920
|
-
}
|
|
921
|
-
}
|
|
922
|
-
// Writes node.props directly rather than through setProp, so it owes its own mark - and it owes
|
|
923
|
-
// the PROPS mark specifically: markDirty alone would send the node down the fast lane above,
|
|
924
|
-
// which reuses the mirror's payload by reference and would drop the Animated frame entirely.
|
|
925
|
-
markPropsDirty(node);
|
|
926
|
-
dlog(`setNativeProps root=${record.rootTag} tag=${record.tag} keys=${Object.keys(partial)}`);
|
|
927
|
-
// Queued, not committed: every write made in this task publishes together at the microtask
|
|
928
|
-
// boundary. See the batching note above commitTargeted's caller block for why that loses nothing.
|
|
929
|
-
requestCommitFor(node);
|
|
930
|
-
}
|
|
931
|
-
/**
|
|
932
|
-
* Publish a node whose props were changed OUTSIDE any renderer mutation.
|
|
933
|
-
*
|
|
934
|
-
* Dirtying is not publishing. Every other write reaches Fabric because the framework's own commit
|
|
935
|
-
* follows it; a change driven by a NATIVE EVENT has no such follow-up — `native-events.ts`
|
|
936
|
-
* requests no commit and no adapter does either, so a node marked dirty from an event handler
|
|
937
|
-
* simply stays dirty. The host-behavior press path (`setNodePressed` for `:active`) is the first
|
|
938
|
-
* caller that is not `setNativeProps`, and `setNodeHidden`'s React twin never needed it because
|
|
939
|
-
* the reconciler is already in its commit phase when it calls.
|
|
940
|
-
*
|
|
941
|
-
* Queued rather than committed on the spot, sharing `setNativeProps`' batch: several writes in one
|
|
942
|
-
* task publish together at the microtask boundary, one commit per surface.
|
|
943
|
-
*/
|
|
944
|
-
export function requestCommitFor(node) {
|
|
945
|
-
const record = committedOf(node);
|
|
946
|
-
if (record === undefined) {
|
|
947
|
-
dlog('requestCommitFor skipped: node not committed');
|
|
948
|
-
return;
|
|
949
|
-
}
|
|
950
|
-
let pending = pendingByRoot.get(record.rootTag);
|
|
951
|
-
if (pending === undefined) {
|
|
952
|
-
pending = new Set();
|
|
953
|
-
pendingByRoot.set(record.rootTag, pending);
|
|
954
|
-
}
|
|
955
|
-
pending.add(node);
|
|
956
|
-
if (!flushScheduled) {
|
|
957
|
-
flushScheduled = true;
|
|
958
|
-
queueMicrotask(flushNativeProps);
|
|
959
|
-
}
|
|
960
|
-
}
|
|
961
|
-
// The committed reactTag of a node (stable across clone-on-write), for binding the
|
|
962
|
-
// native Animated driver via connectAnimatedNodeToView. Undefined until the node
|
|
963
|
-
// has been committed at least once.
|
|
964
|
-
export function getNativeTag(node) {
|
|
965
|
-
return committedOf(node)?.tag;
|
|
966
|
-
}
|
|
967
|
-
// Actions waiting for their node's first commit. An adapter that wires an imperative/native call at
|
|
968
|
-
// lifecycle time (autoFocus, a native Animated.event attach) can run BEFORE completeRoot under an
|
|
969
|
-
// async-batched commit (Vue/Svelte schedule it on a microtask), so the node has no tag yet and the
|
|
970
|
-
// call silently no-ops. Each waiter retries after a commit may have assigned the tag and is dropped
|
|
971
|
-
// once it runs. React commits synchronously, so its actions run inline and never land here.
|
|
972
|
-
const pendingCommitWaiters = new Set();
|
|
973
|
-
registerPostCommit(() => {
|
|
974
|
-
for (const waiter of pendingCommitWaiters) {
|
|
975
|
-
if (waiter())
|
|
976
|
-
pendingCommitWaiters.delete(waiter);
|
|
977
|
-
}
|
|
978
|
-
});
|
|
979
|
-
// Run `action` once `node` has a committed Fabric tag - immediately if it already does, else after
|
|
980
|
-
// the commit that assigns it. The canonical fix for the Vue async-commit race: defer instead of
|
|
981
|
-
// silently no-opping. Returns a cancel fn (drop the pending retry, e.g. on unmount).
|
|
982
|
-
export function whenCommitted(node, action) {
|
|
983
|
-
const attempt = () => {
|
|
984
|
-
if (committedOf(node) === undefined)
|
|
985
|
-
return false;
|
|
986
|
-
action();
|
|
987
|
-
return true;
|
|
988
|
-
};
|
|
989
|
-
if (!attempt())
|
|
990
|
-
pendingCommitWaiters.add(attempt);
|
|
991
|
-
return () => {
|
|
992
|
-
pendingCommitWaiters.delete(attempt);
|
|
993
|
-
};
|
|
994
|
-
}
|
|
995
|
-
// The node's current Fabric handle (the createNode/clone return value), identical in
|
|
996
|
-
// kind to React's stateNode.node, for the native driver's ShadowNodeFamily path.
|
|
997
|
-
export function getNativeNode(node) {
|
|
998
|
-
return committedOf(node)?.handle;
|
|
999
|
-
}
|
|
1000
|
-
// Imperative view command (e.g. TextInput's setTextAndSelection / focus / blur),
|
|
1001
|
-
// aimed at a node's CURRENT Fabric handle. Only valid once the node has been
|
|
1002
|
-
// committed at least once; its handle is read from the node's committed record.
|
|
1003
|
-
export function dispatchViewCommand(node, commandName, args) {
|
|
1004
|
-
const record = committedOf(node);
|
|
1005
|
-
if (record === undefined) {
|
|
1006
|
-
// node=... compares directly against the `committed (create|update)` logs above (same
|
|
1007
|
-
// debugNodeId scheme) to prove/disprove an identity mismatch — see the note there.
|
|
1008
|
-
dlog(`dispatchViewCommand "${commandName}" skipped: node not committed (node=${debugNodeId(node)} component=${node.component})`);
|
|
1009
|
-
return;
|
|
1010
|
-
}
|
|
1011
|
-
dlog(`dispatchViewCommand "${commandName}" (node=${debugNodeId(node)})`);
|
|
1012
|
-
getSlot().dispatchCommand(record.handle, commandName, args);
|
|
1013
|
-
}
|
|
1014
|
-
// Emit an accessibility event (focus/click/viewHoverEnter/windowStateChange) at a node's
|
|
1015
|
-
// CURRENT Fabric handle, routed through the slot exactly like dispatchViewCommand. RN's
|
|
1016
|
-
// Fabric path hands the public-instance handle to nativeFabricUIManager.sendAccessibilityEvent
|
|
1017
|
-
// with the STRING eventType; the C++ side maps it to the platform's accessibility-event kind.
|
|
1018
|
-
// A no-op (logged) until the node is committed; there is no handle yet.
|
|
1019
|
-
export function sendAccessibilityEvent(node, eventType) {
|
|
1020
|
-
const record = committedOf(node);
|
|
1021
|
-
if (record === undefined) {
|
|
1022
|
-
dlog(`sendAccessibilityEvent "${eventType}" skipped: node not committed`);
|
|
1023
|
-
return;
|
|
1024
|
-
}
|
|
1025
|
-
dlog(`sendAccessibilityEvent "${eventType}"`);
|
|
1026
|
-
getSlot().sendAccessibilityEvent(record.handle, eventType);
|
|
1027
|
-
}
|
|
1028
|
-
// Imperative measurement against a node's CURRENT Fabric handle (the public-instance
|
|
1029
|
-
// measure family that reanimated / gesture-handler / scroll-to reach through). A
|
|
1030
|
-
// no-op with a dlog until the node is committed; there is no handle to measure yet.
|
|
1031
|
-
export function measure(node, callback) {
|
|
1032
|
-
const record = committedOf(node);
|
|
1033
|
-
if (record === undefined) {
|
|
1034
|
-
dlog('measure skipped: node not committed');
|
|
1035
|
-
return;
|
|
1036
|
-
}
|
|
1037
|
-
getSlot().measure(record.handle, callback);
|
|
1038
|
-
}
|
|
1039
|
-
export function measureInWindow(node, callback) {
|
|
1040
|
-
const record = committedOf(node);
|
|
1041
|
-
if (record === undefined) {
|
|
1042
|
-
dlog('measureInWindow skipped: node not committed');
|
|
1043
|
-
return;
|
|
1044
|
-
}
|
|
1045
|
-
getSlot().measureInWindow(record.handle, callback);
|
|
1046
|
-
}
|
|
1047
|
-
// Measure `node`'s frame relative to `relativeTo`. Both must be committed; RN's public
|
|
1048
|
-
// signature is (relative, onSuccess, onFail) but the native slot wants the fail
|
|
1049
|
-
// callback before success, so the order is swapped here.
|
|
1050
|
-
export function measureLayout(node, relativeTo, onSuccess, onFail = () => { }) {
|
|
1051
|
-
const record = committedOf(node);
|
|
1052
|
-
const relativeRecord = committedOf(relativeTo);
|
|
1053
|
-
if (record === undefined || relativeRecord === undefined) {
|
|
1054
|
-
dlog('measureLayout skipped: a node is not committed');
|
|
1055
|
-
return;
|
|
1056
|
-
}
|
|
1057
|
-
getSlot().measureLayout(record.handle, relativeRecord.handle, onFail, onSuccess);
|
|
1058
|
-
}
|