@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
package/build/host-behavior.js
CHANGED
|
@@ -26,6 +26,8 @@
|
|
|
26
26
|
//
|
|
27
27
|
// `registerHostBehavior` emits a `dlog` precisely so `DEBUG=1` answers "did my registration run at
|
|
28
28
|
// all" before anyone starts debugging the behavior itself.
|
|
29
|
+
import { parentsOf, subtreesOf } from './host-access.js';
|
|
30
|
+
import { recordSetTag } from './mutation-buffer.js';
|
|
29
31
|
import { dlog } from './debug.js';
|
|
30
32
|
const behaviors = new Map();
|
|
31
33
|
// Nodes that `removeChild` unlinked and that may or may not be coming back. See
|
|
@@ -39,7 +41,7 @@ const tornDown = new WeakSet();
|
|
|
39
41
|
//
|
|
40
42
|
// THE REGISTRY IS KEYED BY INTRINSIC TAG AND THE NODE IS NOT. `node.component` is the FABRIC view
|
|
41
43
|
// name: every adapter resolves the tag through `descriptorFor` before calling `createElement`, so
|
|
42
|
-
// `
|
|
44
|
+
// `view` arrives as `RCTView`. Keying the registry by Fabric name instead is not an
|
|
43
45
|
// option — a pressable resolves to `RCTView` like any other view, so the press machine would
|
|
44
46
|
// attach to every plain `View` in the app. So the tag alphabet is used EXACTLY ONCE, at
|
|
45
47
|
// `attachHostBehavior`, where the caller still holds it; every later lookup reads this map.
|
|
@@ -53,6 +55,9 @@ const attached = new WeakMap();
|
|
|
53
55
|
// ~1 000 calls on one benchmark row set), so neither may pay a Set insert for a feature no app
|
|
54
56
|
// uses yet. While this is false both paths cost one boolean read, the same discipline as `isDebug`.
|
|
55
57
|
let hasBehaviors = false;
|
|
58
|
+
// See `hasAttachedBehaviors`. A behavior TYPE existing and a behavior being ON a node are different
|
|
59
|
+
// questions, and the teardown sweep was asking the first one.
|
|
60
|
+
let hasAttached = false;
|
|
56
61
|
export function registerHostBehavior(component, behavior) {
|
|
57
62
|
dlog(`registerHostBehavior: ${component}`);
|
|
58
63
|
behaviors.set(component, behavior);
|
|
@@ -70,6 +75,107 @@ export function hostBehaviorFor(tag) {
|
|
|
70
75
|
export function hasHostBehaviors() {
|
|
71
76
|
return hasBehaviors;
|
|
72
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* Has a behavior ever ATTACHED to a node, as opposed to a behavior TYPE having been registered?
|
|
80
|
+
*
|
|
81
|
+
* `hasBehaviors` answers the second, and it is on from module load in every app: registering
|
|
82
|
+
* `Pressable` arms it whether or not one is ever mounted. It gates `createElement`'s attach probe
|
|
83
|
+
* correctly — a node has to be offered to the registry to find out. It gates the TEARDOWN SWEEP
|
|
84
|
+
* wrongly, and that is expensive: the sweep crosses every removed node into JS, which on a
|
|
85
|
+
* 1 000-row clear is ten thousand handles. Measured on `build-release`
|
|
86
|
+
* (`teardown-sweep-cost.itest.ts`): 1.8 ms with the sweep off against 6.3 ms with it on, i.e. 3.2x,
|
|
87
|
+
* all of it inside the commit. `Clear` is the one row where stock React Native beats every adapter.
|
|
88
|
+
*
|
|
89
|
+
* Nothing the sweep does can matter before the first attach, and the four collections say so:
|
|
90
|
+
* `attached` is written only by `attachHostBehavior`; `awaitingCommit` and `committedEachTime` are
|
|
91
|
+
* written only inside a `behavior.` branch; `parked` only by `detachAnimatedProps`, which has its
|
|
92
|
+
* own gate. The one remaining effect is marking `tornDown`, which exists so a later re-insert knows
|
|
93
|
+
* to re-arm — and there is nothing to re-arm.
|
|
94
|
+
*
|
|
95
|
+
* MONOTONE, deliberately: it turns on and never off, so it needs no accounting on a `WeakMap` that
|
|
96
|
+
* has no size and no destructor. Being late is the only way it can be wrong, and it cannot be late.
|
|
97
|
+
*/
|
|
98
|
+
export function hasAttachedBehaviors() {
|
|
99
|
+
return hasAttached;
|
|
100
|
+
}
|
|
101
|
+
// What an owner prop is called on the slot, or undefined when it belongs to the owner after all.
|
|
102
|
+
//
|
|
103
|
+
// Called from `routeProp` only for a node that HAS a slot (`node.childHost !== undefined`), which
|
|
104
|
+
// is what keeps a WeakMap probe off the hot path: every other node is turned away by one field
|
|
105
|
+
// read, the same gate `payloadFold` uses one layer down.
|
|
106
|
+
export function slotPropNameFor(node, key) {
|
|
107
|
+
const behavior = attached.get(node);
|
|
108
|
+
if (behavior === undefined)
|
|
109
|
+
return undefined;
|
|
110
|
+
const named = behavior.slotProps?.[key];
|
|
111
|
+
if (named !== undefined)
|
|
112
|
+
return named;
|
|
113
|
+
const except = behavior.slotPropsExcept;
|
|
114
|
+
if (except !== undefined && !except.includes(key))
|
|
115
|
+
return key;
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
// Does this owner's slot host the app's children, or is it a built sibling they land beside? See
|
|
119
|
+
// `slotTakesNoChildren`. Same `node.childHost` gate as every other probe here: the two callers ask
|
|
120
|
+
// only after the field said there is a slot at all.
|
|
121
|
+
export function slotTakesChildren(node) {
|
|
122
|
+
return attached.get(node)?.slotTakesNoChildren !== true;
|
|
123
|
+
}
|
|
124
|
+
// Nodes a behavior built that are NOT the slot, and whose payloads derive from the owner's props.
|
|
125
|
+
//
|
|
126
|
+
// `slotDerived` marks `node.childHost` and nothing else, which is one hop — enough for ScrollView,
|
|
127
|
+
// whose only derived node IS the slot, and not enough for a primitive whose `buildStructure` builds
|
|
128
|
+
// a chain. Button builds view > text > raw text and folds two of them from the same three owner
|
|
129
|
+
// props; without this the deeper nodes freeze at their mount values, and the workaround is to write
|
|
130
|
+
// them from inside a fold whose contract says it MUST be pure.
|
|
131
|
+
//
|
|
132
|
+
// A WeakMap rather than a field, for the reason `stashed` is one: this exists only for the handful
|
|
133
|
+
// of nodes a composed behavior built, and a field costs a shape transition on every node in every
|
|
134
|
+
// app. It is read only inside the `slotDerived` branch, which has already paid a WeakMap probe.
|
|
135
|
+
const derived = new WeakMap();
|
|
136
|
+
// Called from `buildStructure` for each node past the slot. Not idempotent-checked: structure is
|
|
137
|
+
// built exactly once (`attachHostBehavior`, never `reattachSubtree`), so a second call would be a
|
|
138
|
+
// bug worth seeing rather than one worth absorbing.
|
|
139
|
+
export function addDerivedNode(owner, node) {
|
|
140
|
+
const existing = derived.get(owner);
|
|
141
|
+
if (existing === undefined)
|
|
142
|
+
derived.set(owner, [node]);
|
|
143
|
+
else
|
|
144
|
+
existing.push(node);
|
|
145
|
+
}
|
|
146
|
+
export function derivedNodesOf(owner) {
|
|
147
|
+
return derived.get(owner);
|
|
148
|
+
}
|
|
149
|
+
// Called from `setEventListener` on a PRESENCE flip of an owned name, and only there — the caller
|
|
150
|
+
// has already established that this node owns the name, so the WeakMap probe is one it just paid.
|
|
151
|
+
export function notifyOwnedListenerChange(node, name, wired) {
|
|
152
|
+
attached.get(node)?.onOwnedListenerChange?.(node, name, wired);
|
|
153
|
+
}
|
|
154
|
+
// Called from the two inserts once the child is in place. See `onChildInserted`.
|
|
155
|
+
export function notifyChildInserted(node, child) {
|
|
156
|
+
attached.get(node)?.onChildInserted?.(node, child);
|
|
157
|
+
}
|
|
158
|
+
// What this owner does with a child of that Fabric component, or undefined when it does not claim
|
|
159
|
+
// it at all. See `claimedChildren`.
|
|
160
|
+
export function claimModeFor(node, component) {
|
|
161
|
+
return attached.get(node)?.claimedChildren?.[component];
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Every owner key feeds the slot. The spelling for a `cloneElement` primitive, whose slot is not
|
|
165
|
+
* derived from a named set at all — see `IHostBehavior.slotDerived`.
|
|
166
|
+
*
|
|
167
|
+
* A sentinel in the SAME array rather than a second field, so `slotDerivesFrom` stays one lookup and
|
|
168
|
+
* a behavior that wants both spellings cannot express a contradiction.
|
|
169
|
+
*/
|
|
170
|
+
export const SLOT_DERIVED_ALL = '*';
|
|
171
|
+
// Does this owner key feed the slot's payload? See `slotDerived`. Same `node.childHost` gate as
|
|
172
|
+
// above keeps the WeakMap probe off every node that has no slot.
|
|
173
|
+
export function slotDerivesFrom(node, key) {
|
|
174
|
+
const names = attached.get(node)?.slotDerived;
|
|
175
|
+
if (names === undefined)
|
|
176
|
+
return false;
|
|
177
|
+
return names.includes(SLOT_DERIVED_ALL) || names.includes(key);
|
|
178
|
+
}
|
|
73
179
|
// The app's listeners for names a behavior owns, per node. Not on the node: this exists only for
|
|
74
180
|
// nodes carrying a behavior, and adding a field for it would pay a shape transition on every node
|
|
75
181
|
// in every app for a feature almost none of them use.
|
|
@@ -104,14 +210,39 @@ export function attachHostBehavior(node, tag) {
|
|
|
104
210
|
if (behavior === undefined)
|
|
105
211
|
return;
|
|
106
212
|
attached.set(node, behavior);
|
|
213
|
+
hasAttached = true;
|
|
214
|
+
// The tag itself, over the wire, so the host can resolve this tag's PLATFORM props without a trip
|
|
215
|
+
// back into JS. Here rather than in `createElement` because here is where a tag is known to name
|
|
216
|
+
// something: an app's own `<div>`-equivalent would otherwise pay an intern and an op to tell the
|
|
217
|
+
// host a name it has no rule for.
|
|
218
|
+
recordSetTag(node, tag);
|
|
219
|
+
// BEFORE `attach` and before any prop is routed, which is the whole point: it changes how a
|
|
220
|
+
// WRITE is stored, so a source written to this node must never arrive ahead of it.
|
|
221
|
+
if (behavior.resolvesImageSources === true)
|
|
222
|
+
node.resolvesImageSources = true;
|
|
223
|
+
if (behavior.nativeIdWinsOverId === true)
|
|
224
|
+
node.nativeIdWinsOverId = true;
|
|
107
225
|
// A field rather than a lookup at payload-build time: `fabricProps` runs per node per commit and
|
|
108
226
|
// must not pay a Map probe to discover that almost nothing has a fold.
|
|
109
227
|
node.payloadFold = behavior.foldPayload;
|
|
228
|
+
// Shape before runtime: `attach` may want to read `node.childHost`, and nothing in `attach`'s
|
|
229
|
+
// contract depends on the node being childless. Deliberately NOT repeated in `reattachSubtree` —
|
|
230
|
+
// see `buildStructure`.
|
|
231
|
+
if (behavior.buildStructure !== undefined) {
|
|
232
|
+
node.childHost = behavior.buildStructure(node);
|
|
233
|
+
}
|
|
110
234
|
behavior.attach(node);
|
|
111
235
|
if (behavior.attachAfterCommit !== undefined)
|
|
112
236
|
awaitingCommit.add(node);
|
|
113
|
-
if (behavior.afterCommit !== undefined)
|
|
237
|
+
if (behavior.afterCommit !== undefined) {
|
|
114
238
|
committedEachTime.add(node);
|
|
239
|
+
node.hasCommitHook = true;
|
|
240
|
+
// Armed from the start, so the commit that first lands this node gives it a beat. A freshly
|
|
241
|
+
// created node has had no prop written through `setProp` yet — `createRawText`'s text is an OP,
|
|
242
|
+
// not a field — so nothing else would arm it, and its behavior would wait for a write that a
|
|
243
|
+
// purely declarative mount never makes.
|
|
244
|
+
noteCommitHookNodeChanged(node);
|
|
245
|
+
}
|
|
115
246
|
}
|
|
116
247
|
// Nodes whose behavior declared `afterCommit`. Separate from `awaitingCommit` because the two have
|
|
117
248
|
// opposite lifetimes: one empties as its nodes commit, this one holds until teardown.
|
|
@@ -132,26 +263,112 @@ const awaitingCommit = new Set();
|
|
|
132
263
|
* for the commit that lands it, and `detachSubtree` drops it if that commit never comes.
|
|
133
264
|
*/
|
|
134
265
|
export function runDeferredAttaches(isCommitted) {
|
|
135
|
-
// The gate: an app registering no behavior pays
|
|
266
|
+
// The gate: an app registering no behavior pays one Set-size read per commit, matching the
|
|
136
267
|
// discipline `hasBehaviors` sets for `createElement`.
|
|
137
|
-
if (awaitingCommit.size === 0
|
|
268
|
+
if (awaitingCommit.size === 0)
|
|
138
269
|
return;
|
|
139
|
-
// SETUP BEFORE THE RECURRING BEAT, and the order is load-bearing on the FIRST commit, where a
|
|
140
|
-
// node carrying both hooks is drained by both. `attachAfterCommit` is where a behavior seeds the
|
|
141
|
-
// mirrors that `afterCommit` then compares against; run them the other way round and the first
|
|
142
|
-
// beat compares against nothing and commands a redundant write down to native.
|
|
143
270
|
for (const node of awaitingCommit) {
|
|
144
271
|
if (!isCommitted(node))
|
|
145
272
|
continue;
|
|
146
273
|
awaitingCommit.delete(node);
|
|
274
|
+
// The answer is worth recording, not just acting on. `runCommittedHooks` runs immediately after
|
|
275
|
+
// this and asks the SAME question about the SAME node, and `isCommitted` crosses the host
|
|
276
|
+
// boundary — so a node carrying both hooks paid two crossings for one fact on the commit that
|
|
277
|
+
// landed it. Two per `<text-input>` on a 1 000-row create, measured at the call site.
|
|
278
|
+
everCommitted.add(node);
|
|
147
279
|
attached.get(node)?.attachAfterCommit?.(node);
|
|
148
280
|
}
|
|
149
|
-
|
|
150
|
-
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* The recurring beat. Split from `runDeferredAttaches` because the two answer different questions:
|
|
284
|
+
* `attachAfterCommit` needs a FRESH FABRIC TAG, so it belongs below `completeRoot` and must not run
|
|
285
|
+
* on a commit that made no native call; `afterCommit` needs only "props were published", which a
|
|
286
|
+
* no-op commit satisfies just as well.
|
|
287
|
+
*
|
|
288
|
+
* Keeping them together made `afterCommit` unreachable for exactly the props a behavior owns: a fold
|
|
289
|
+
* that STRIPS a prop makes its own commit byte-identical, `commitContainer` returns above the drain,
|
|
290
|
+
* and the hook never sees the flip. TouchableOpacity's re-settle on `disabled` is the case
|
|
291
|
+
* (`disabled` is a MACHINE_ONLY key), Button's `title`/`color` the other.
|
|
292
|
+
*
|
|
293
|
+
* SETUP STILL RUNS BEFORE THE BEAT on the first commit, and the order is load-bearing: a node
|
|
294
|
+
* carrying both hooks has `attachAfterCommit` seed the mirrors `afterCommit` compares against. The
|
|
295
|
+
* caller preserves it by calling this AFTER `runDeferredAttaches` on the changed path — the no-op
|
|
296
|
+
* path has no setup to run, since a node with no Fabric tag has not committed at all.
|
|
297
|
+
*/
|
|
298
|
+
/**
|
|
299
|
+
* Nodes with a recurring hook whose props were written since the last beat.
|
|
300
|
+
*
|
|
301
|
+
* THE POPULATION THE BEAT RUNS OVER, and narrowing it to this is the second half of F-66. The
|
|
302
|
+
* first half stopped the loop CROSSING to decide whether to run a hook; this stops it running the
|
|
303
|
+
* hook at all for a node that cannot have anything to do — and the hook BODY is where the rest of
|
|
304
|
+
* the cost was. TextInput's asks the host for its `value` to compare against its native mirror,
|
|
305
|
+
* which the work ledger measures at `propOf` × 1 000 per commit, in all four adapters.
|
|
306
|
+
*
|
|
307
|
+
* Sound because both behaviors that document why they need the beat need it for the same event, a
|
|
308
|
+
* prop written on their own node. `switch.ts` says so outright — "a check scheduled only from
|
|
309
|
+
* `onChange` never re-runs for a prop change with no preceding native event … `afterCommit` costs
|
|
310
|
+
* nothing extra (it fires only on a commit that already changed something)" — and TextInput's
|
|
311
|
+
* controlled handshake has two sources, the app moving `value` and the user typing, the second of
|
|
312
|
+
* which writes `mostRecentEventCount`. A fold that STRIPS a prop is covered too: the write
|
|
313
|
+
* happened, and it is the PAYLOAD that comes out byte-identical, which is the case this hook was
|
|
314
|
+
* split from `attachAfterCommit` for.
|
|
315
|
+
*/
|
|
316
|
+
const commitHookNodesChanged = new Set();
|
|
317
|
+
/**
|
|
318
|
+
* Arm a node's recurring hook for the next commit.
|
|
319
|
+
*
|
|
320
|
+
* Called from `setProp` — gated there on `node.hasCommitHook`, a boolean field beside
|
|
321
|
+
* `hasAriaAlias` on the same hidden class, so a node without a recurring hook pays one load and
|
|
322
|
+
* one branch per write and never reaches this.
|
|
323
|
+
*/
|
|
324
|
+
export function noteCommitHookNodeChanged(node) {
|
|
325
|
+
commitHookNodesChanged.add(node);
|
|
326
|
+
}
|
|
327
|
+
export function runCommittedHooks(isCommitted) {
|
|
328
|
+
if (commitHookNodesChanged.size === 0)
|
|
329
|
+
return;
|
|
330
|
+
const changed = [...commitHookNodesChanged];
|
|
331
|
+
commitHookNodesChanged.clear();
|
|
332
|
+
for (const node of changed) {
|
|
333
|
+
// Still in the set, i.e. still mounted with its behavior attached: `detachOne` removes a node
|
|
334
|
+
// from `committedEachTime`, and a write that armed it before it was torn down must not reach a
|
|
335
|
+
// hook whose `detach` has already run.
|
|
336
|
+
if (!committedEachTime.has(node))
|
|
151
337
|
continue;
|
|
338
|
+
if (!everCommitted.has(node)) {
|
|
339
|
+
// ARMED BUT NOT YET COMMITTED — put it back. A node is armed when its behavior attaches,
|
|
340
|
+
// which is at `createElement`, before it is in anyone's tree; dropping it here would mean the
|
|
341
|
+
// commit that finally lands it never gives it a beat. This is the one place the narrowed
|
|
342
|
+
// population can lose a node, and it is why the set is cleared by REMOVAL of what ran rather
|
|
343
|
+
// than wholesale.
|
|
344
|
+
if (!isCommitted(node)) {
|
|
345
|
+
commitHookNodesChanged.add(node);
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
everCommitted.add(node);
|
|
349
|
+
}
|
|
152
350
|
attached.get(node)?.afterCommit?.(node);
|
|
153
351
|
}
|
|
154
352
|
}
|
|
353
|
+
// Nodes of `committedEachTime` that have reached Fabric at least once.
|
|
354
|
+
//
|
|
355
|
+
// ASKED ONCE PER NODE, not once per node per commit. `isCommitted` is `getNativeTag`, which is
|
|
356
|
+
// `committedRecordOf` — a `flushOps()` and a CROSSING TO THE HOST. Before this, the loop above ran
|
|
357
|
+
// over every mounted node whose behavior declared `afterCommit`, on every commit of ANY surface, so
|
|
358
|
+
// a thousand-row list with a `<text-input>` per row paid a thousand crossings to select one row,
|
|
359
|
+
// and paid them again on a commit that changed nothing at all. Measured at 550 for a 550-node set
|
|
360
|
+
// (`__tests__/post-commit-hooks-are-not-the-tree.test.ts`).
|
|
361
|
+
//
|
|
362
|
+
// The cached answer is sound because within `committedEachTime` it is monotone: a node enters when
|
|
363
|
+
// its behavior attaches, leaves in `detachOne` when it is torn down, and a live node that has been
|
|
364
|
+
// committed keeps a Fabric record — a clone keeps the family. So the bit only ever goes TRUE for a
|
|
365
|
+
// node still in the set, which is F-18's `mayHaveChildren` shape: a stale FALSE costs one more
|
|
366
|
+
// crossing next commit, and a stale TRUE is impossible because leaving the set is what losing the
|
|
367
|
+
// record means.
|
|
368
|
+
//
|
|
369
|
+
// A WeakSet rather than a node field: nothing outside this module has any business reading it, and
|
|
370
|
+
// a node that leaves the tree takes its entry with it.
|
|
371
|
+
const everCommitted = new WeakSet();
|
|
155
372
|
// `removeChild` is NOT the destroy signal, and reading it as one is the bug this indirection
|
|
156
373
|
// exists to avoid. Engine-side it looks like one — a reorder goes through `detach` inside
|
|
157
374
|
// appendChild/insertBefore and never lands in removeChild — but a FRAMEWORK can spell a move as
|
|
@@ -182,26 +399,81 @@ export function markDetachCandidate(node) {
|
|
|
182
399
|
//
|
|
183
400
|
// The subtree walk lives here rather than at removal, and is cheaper for it: only the nodes that
|
|
184
401
|
// actually left are walked.
|
|
185
|
-
|
|
402
|
+
//
|
|
403
|
+
// `onDetached` runs for EVERY node of a genuinely-removed subtree, whether or not it carries a
|
|
404
|
+
// behavior — it is how the engine's other per-node lifetime state (an Animated subscription, see
|
|
405
|
+
// `animated/host-binding.ts`) gets the same "did it really leave" answer this sweep exists to
|
|
406
|
+
// compute. Passed in for the no-cycle reason `runDeferredAttaches`' predicate is: this module must
|
|
407
|
+
// keep pointing one way, and Metro's `inlineRequires` makes that a live hazard rather than taste.
|
|
408
|
+
/**
|
|
409
|
+
* Whether the sweep has anything to do — asked BEFORE its arguments are built.
|
|
410
|
+
*
|
|
411
|
+
* The sweep's own first line already returns on an empty candidate set, and that was not enough:
|
|
412
|
+
* its caller passes `surface.children`, which is a GETTER that crosses to the host, allocates the
|
|
413
|
+
* whole top-level list and filters it into a second array. On a surface holding four thousand rows
|
|
414
|
+
* that ran on every commit, including the ones with nothing to sweep, because an argument is
|
|
415
|
+
* evaluated before the guard inside the callee can decline. Same shape as the `dlog` arguments that
|
|
416
|
+
* cost Angular 5-10% while emitting nothing.
|
|
417
|
+
*/
|
|
418
|
+
export function hasDetachCandidates() {
|
|
419
|
+
return detachCandidates.size > 0;
|
|
420
|
+
}
|
|
421
|
+
export function sweepDetachedBehaviors(topLevel, onDetached) {
|
|
186
422
|
if (detachCandidates.size === 0)
|
|
187
423
|
return;
|
|
424
|
+
// TWO crossings for the whole sweep, whatever it is sweeping — one for the parents, one for the
|
|
425
|
+
// subtrees. Asked per node instead, a Clear of a thousand rows spent eleven thousand
|
|
426
|
+
// (`ITreeHost.parentsOf` carries the measurement).
|
|
427
|
+
const candidates = [...detachCandidates];
|
|
428
|
+
const parents = parentsOf(candidates);
|
|
188
429
|
// A surface's top-level nodes carry `parent === undefined` by design (surface.ts), and
|
|
189
430
|
// `commitChildren` re-lists them without going through appendChild — so for those the parent
|
|
190
431
|
// check alone would report a live node as gone.
|
|
191
|
-
const
|
|
192
|
-
for (const node of
|
|
193
|
-
|
|
194
|
-
continue;
|
|
195
|
-
detachSubtree(node, seen);
|
|
196
|
-
}
|
|
432
|
+
const left = candidates.filter((node, at) => parents[at] === undefined && !topLevel.includes(node));
|
|
433
|
+
for (const node of subtreesOf(left))
|
|
434
|
+
detachOne(node, onDetached);
|
|
197
435
|
detachCandidates.clear();
|
|
198
436
|
}
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
|
|
202
|
-
|
|
437
|
+
// Tear a subtree down unconditionally — the SURFACE teardown path, where there is nothing to
|
|
438
|
+
// decide: `disposeRoot` drops the root container, so every node under it has left for good whatever
|
|
439
|
+
// any framework intended.
|
|
440
|
+
//
|
|
441
|
+
// It exists because the sweep above cannot answer this. The sweep only sees nodes a `removeChild`
|
|
442
|
+
// NOMINATED, and an unmount removes nothing — the adapter drops the whole surface. So before this,
|
|
443
|
+
// `disposeRoot` touched no node at all: `committedOf` reads `node.committed`, a field on the node,
|
|
444
|
+
// so every node of a dead surface still answered `isCommitted` and stayed in `committedEachTime`,
|
|
445
|
+
// drained on every later commit anywhere in the process, with its timers still armed.
|
|
446
|
+
export function teardownSubtree(node, onDetached) {
|
|
447
|
+
for (const each of subtreesOf([node]))
|
|
448
|
+
detachOne(each, onDetached);
|
|
449
|
+
}
|
|
450
|
+
// `tornDown` guards BOTH overlaps, and it used to be helped by a per-call `seen` Set that guarded
|
|
451
|
+
// only the first of them:
|
|
452
|
+
//
|
|
453
|
+
// within one call a removed parent and a removed descendant are both nominated, so the
|
|
454
|
+
// descendant arrives twice
|
|
455
|
+
// across calls a node the sweep released and that `disposeRoot` then walks again, the
|
|
456
|
+
// ordinary shape of an unmount after the framework emptied the tree
|
|
457
|
+
//
|
|
458
|
+
// `seen` was redundant for the first: `tornDown.add(node)` runs unconditionally two lines below the
|
|
459
|
+
// guard, in the same call, so a second arrival takes the same early return. The only behaviour it
|
|
460
|
+
// changed was after a THROWING `onDetached`, where the node would be retried — and a sweep that
|
|
461
|
+
// threw half way has already left the tree in a state no retry repairs.
|
|
462
|
+
//
|
|
463
|
+
// It cost a Set allocation and two hash operations per node, against a teardown that visits every
|
|
464
|
+
// removed node: 10 000 of them on a 1 000-row clear. Removing it is a simplification and NOT a
|
|
465
|
+
// speed-up — measured on `build-release`, the sweep stayed at 4.4-4.6 ms either way
|
|
466
|
+
// (`teardown-sweep-cost.itest.ts`). Whatever holds that time is not the bookkeeping per node.
|
|
467
|
+
//
|
|
468
|
+
// The subtree arrives FLAT, in one host read, instead of a `childrenOf` recursion. The recursion
|
|
469
|
+
// stopped descending at an already-torn-down node where this skips it and carries on; the two agree
|
|
470
|
+
// because both marks are whole-subtree — the sweep adds every descendant and `reattachSubtree`
|
|
471
|
+
// removes every descendant — so a node in `tornDown` has its own descendants in it, and each of them
|
|
472
|
+
// takes the same early return below.
|
|
473
|
+
function detachOne(node, onDetached) {
|
|
474
|
+
if (tornDown.has(node))
|
|
203
475
|
return;
|
|
204
|
-
|
|
476
|
+
onDetached(node);
|
|
205
477
|
// Marked whether or not THIS node carries a behavior: the mark is what tells a later insert to
|
|
206
478
|
// walk, and the node re-inserted is usually a plain container whose DESCENDANT holds the
|
|
207
479
|
// machine. Gating the mark on `behaviors.has` made the row wrapper unmarked and the whole walk
|
|
@@ -217,14 +489,9 @@ function detachSubtree(node, seen) {
|
|
|
217
489
|
// The recurring hook stops with the node, and unlike the deferral above this one has a visible
|
|
218
490
|
// consequence if forgotten: a torn-down node would keep being asked to reconcile props against a
|
|
219
491
|
// subtree that has left the tree, on every commit, forever.
|
|
220
|
-
// The recurring hook stops with the node, and unlike the deferral above this one has a visible
|
|
221
|
-
// consequence if forgotten: a torn-down node would keep being asked to reconcile props against a
|
|
222
|
-
// subtree that has left the tree, on every commit, forever.
|
|
223
492
|
committedEachTime.delete(node);
|
|
224
493
|
// The map, not the registry: by here only the Fabric name is left on the node.
|
|
225
494
|
attached.get(node)?.detach(node);
|
|
226
|
-
for (const child of node.children)
|
|
227
|
-
detachSubtree(child, seen);
|
|
228
495
|
}
|
|
229
496
|
// Re-arms a node the sweep tore down but that the framework put back. Called from appendChild and
|
|
230
497
|
// insertBefore, and it is a WeakSet miss — no walk at all — for every node in a freshly built
|
|
@@ -234,7 +501,13 @@ export function reattachHostBehaviors(node) {
|
|
|
234
501
|
return;
|
|
235
502
|
reattachSubtree(node);
|
|
236
503
|
}
|
|
237
|
-
|
|
504
|
+
// Flat for the same reason the detach walk is, and with nothing to reconcile: this one always
|
|
505
|
+
// descended into every child, whatever the node's own mark said.
|
|
506
|
+
function reattachSubtree(root) {
|
|
507
|
+
for (const node of subtreesOf([root]))
|
|
508
|
+
reattachOne(node);
|
|
509
|
+
}
|
|
510
|
+
function reattachOne(node) {
|
|
238
511
|
if (tornDown.has(node)) {
|
|
239
512
|
tornDown.delete(node);
|
|
240
513
|
const behavior = attached.get(node);
|
|
@@ -245,11 +518,15 @@ function reattachSubtree(node) {
|
|
|
245
518
|
// half-initialised, which is the failure this seam exists to prevent.
|
|
246
519
|
if (behavior?.attachAfterCommit !== undefined)
|
|
247
520
|
awaitingCommit.add(node);
|
|
248
|
-
if (behavior?.afterCommit !== undefined)
|
|
521
|
+
if (behavior?.afterCommit !== undefined) {
|
|
249
522
|
committedEachTime.add(node);
|
|
523
|
+
node.hasCommitHook = true;
|
|
524
|
+
// A node coming back out of the park has not necessarily had a prop written since, and its
|
|
525
|
+
// mirror may have moved while it was away. Arm it once so the next commit gives it a beat —
|
|
526
|
+
// the narrowed population must not turn a RETURNING node into a silently skipped one.
|
|
527
|
+
noteCommitHookNodeChanged(node);
|
|
528
|
+
}
|
|
250
529
|
}
|
|
251
|
-
for (const child of node.children)
|
|
252
|
-
reattachSubtree(child);
|
|
253
530
|
}
|
|
254
531
|
// Test-only. A registry is module state, so a suite that registers a behavior leaks it into every
|
|
255
532
|
// later test in the same file unless it is cleared.
|
|
@@ -259,4 +536,5 @@ export function clearHostBehaviors() {
|
|
|
259
536
|
awaitingCommit.clear();
|
|
260
537
|
committedEachTime.clear();
|
|
261
538
|
hasBehaviors = false;
|
|
539
|
+
hasAttached = false;
|
|
262
540
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export declare const IMAGE_SOURCE_PROPS: ReadonlySet<string>;
|
|
2
|
+
/**
|
|
3
|
+
* Resolve a source prop and normalise it to the ARRAY shape native expects.
|
|
4
|
+
*
|
|
5
|
+
* Always an array, including for the single-object and asset-id cases: a bare object reaching
|
|
6
|
+
* Fabric paints nothing and reports nothing, which is worse than an image that is simply absent.
|
|
7
|
+
* The rule downstream then has one shape to reason about instead of three.
|
|
8
|
+
*
|
|
9
|
+
* A value this cannot make sense of comes back UNTOUCHED rather than wrapped. `routeProp` writes
|
|
10
|
+
* whatever it is handed, and a tag with no image behavior must keep its props verbatim — the
|
|
11
|
+
* control case in `image-payload.itest.ts` is what holds that line.
|
|
12
|
+
*/
|
|
13
|
+
export declare function resolveImageSourceProp(value: unknown): unknown;
|
|
14
|
+
export declare const IMAGE_LOAD_EVENT_NAMES: ReadonlySet<string>;
|
|
15
|
+
/** Whether at least one of the four still has a listener installed on the node. */
|
|
16
|
+
export declare function anyImageLoadEventListenerWired(listeners: ReadonlyMap<string, unknown> | undefined): boolean;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Image sources, resolved on the way IN rather than on the way out.
|
|
2
|
+
//
|
|
3
|
+
// WHY IT IS HERE AND NOT IN THE PAYLOAD BUILDER, which is where every other part of Image's rule
|
|
4
|
+
// now lives. `resolveImageSource` asks METRO'S ASSET REGISTRY — the table `require('./logo.png')`
|
|
5
|
+
// indexes into, populated at bundle time, in JavaScript. There is no such table in C++ and there
|
|
6
|
+
// should not be: it is the bundler's, not the platform's.
|
|
7
|
+
//
|
|
8
|
+
// This is the same seam and the same argument as `structured-style.ts`, which resolves
|
|
9
|
+
// `boxShadow`/`filter`/`transform` at write time for the identical reason — a value resolved at
|
|
10
|
+
// PAYLOAD-BUILD time is resolved HEADLESS ONLY, because the C++ builder has no JS to call, and the
|
|
11
|
+
// device then commits the raw input and Fabric drops it in silence. Moving the lookup one step
|
|
12
|
+
// earlier costs nothing and leaves the rest of the rule pure, which is what let it move at all.
|
|
13
|
+
//
|
|
14
|
+
// The three names are Image's: `source` is the real one, `defaultSource` the placeholder, and
|
|
15
|
+
// `loadingIndicatorSource` Android's spinner.
|
|
16
|
+
import { resolveImageSource } from './image-source-resolver.js';
|
|
17
|
+
export const IMAGE_SOURCE_PROPS = new Set([
|
|
18
|
+
'source',
|
|
19
|
+
'defaultSource',
|
|
20
|
+
'loadingIndicatorSource',
|
|
21
|
+
]);
|
|
22
|
+
/**
|
|
23
|
+
* Resolve a source prop and normalise it to the ARRAY shape native expects.
|
|
24
|
+
*
|
|
25
|
+
* Always an array, including for the single-object and asset-id cases: a bare object reaching
|
|
26
|
+
* Fabric paints nothing and reports nothing, which is worse than an image that is simply absent.
|
|
27
|
+
* The rule downstream then has one shape to reason about instead of three.
|
|
28
|
+
*
|
|
29
|
+
* A value this cannot make sense of comes back UNTOUCHED rather than wrapped. `routeProp` writes
|
|
30
|
+
* whatever it is handed, and a tag with no image behavior must keep its props verbatim — the
|
|
31
|
+
* control case in `image-payload.itest.ts` is what holds that line.
|
|
32
|
+
*/
|
|
33
|
+
export function resolveImageSourceProp(value) {
|
|
34
|
+
if (value === undefined || value === null)
|
|
35
|
+
return value;
|
|
36
|
+
if (typeof value !== 'number' && typeof value !== 'object')
|
|
37
|
+
return value;
|
|
38
|
+
const resolved = resolveImageSource(value);
|
|
39
|
+
return Array.isArray(resolved) ? resolved : [resolved];
|
|
40
|
+
}
|
|
41
|
+
// `ReactImageView.setShouldNotifyLoadEvents` (Android) — `downloadListener` stays `null`, and
|
|
42
|
+
// none of these four ever fires, until this prop is `true`. `Image.android.js` sets it whenever
|
|
43
|
+
// ANY one of them is authored; iOS's native side has no such gate and never sets it.
|
|
44
|
+
//
|
|
45
|
+
// These four are real Fabric events (`view-config.ts`'s `COMPONENT_EVENTS.RCTImageView`), so
|
|
46
|
+
// `routeProp` diverts them through `setEventListener`/`node.listeners`, never through `writeProp`
|
|
47
|
+
// — unlike an ordinary function prop, they never reach the `functionProps` stash. Named here in
|
|
48
|
+
// LISTENER form (post `listenerName()`: `onLoad` -> `load`), which is what `node.listeners` keys
|
|
49
|
+
// on. Same shape `GATED_EVENT_PROPS` uses for `onLayout`, applied to a name no host behavior owns.
|
|
50
|
+
export const IMAGE_LOAD_EVENT_NAMES = new Set([
|
|
51
|
+
'loadStart',
|
|
52
|
+
'load',
|
|
53
|
+
'loadEnd',
|
|
54
|
+
'error',
|
|
55
|
+
]);
|
|
56
|
+
/** Whether at least one of the four still has a listener installed on the node. */
|
|
57
|
+
export function anyImageLoadEventListenerWired(listeners) {
|
|
58
|
+
if (listeners === undefined)
|
|
59
|
+
return false;
|
|
60
|
+
for (const name of IMAGE_LOAD_EVENT_NAMES) {
|
|
61
|
+
if (listeners.has(name))
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { IMeasureInWindowOnSuccess, IMeasureLayoutOnSuccess, IMeasureOnSuccess, IRootTag } from './fabric';
|
|
2
|
+
import { type ISymbioteNode } from './node';
|
|
3
|
+
export declare function registerSurfaceCommit(commit: (rootTag: IRootTag) => void, forget: (rootTag: IRootTag) => void): void;
|
|
4
|
+
export declare function flushNativeProps(): void;
|
|
5
|
+
/**
|
|
6
|
+
* Publish a node whose props changed OUTSIDE any renderer mutation.
|
|
7
|
+
*
|
|
8
|
+
* Recording is not publishing. Every other write reaches Fabric because the framework's own commit
|
|
9
|
+
* follows it; a change driven by a NATIVE EVENT has no such follow-up — the press path's
|
|
10
|
+
* `setNodePressed` is the first caller that is not `setNativeProps`.
|
|
11
|
+
*
|
|
12
|
+
* Queued rather than committed on the spot: several writes in one task publish together at the
|
|
13
|
+
* microtask boundary, one commit per surface.
|
|
14
|
+
*/
|
|
15
|
+
export declare function requestCommitFor(node: ISymbioteNode): void;
|
|
16
|
+
export declare function disposeRoot(rootTag: IRootTag): void;
|
|
17
|
+
/** The committed reactTag, stable across clone-on-write — what the native Animated driver binds. */
|
|
18
|
+
export declare function getNativeTag(node: ISymbioteNode): number | undefined;
|
|
19
|
+
/**
|
|
20
|
+
* The node's current native handle, in kind identical to React's `stateNode.node`.
|
|
21
|
+
*
|
|
22
|
+
* OPAQUE. Under the native host it is a `ShadowNode`, under a headless one whatever that host
|
|
23
|
+
* committed — see `ICommittedRecord.handle`. It used to be typed `IFabricNode`, a brand with no
|
|
24
|
+
* members, so a caller can do exactly as much with it as before.
|
|
25
|
+
*/
|
|
26
|
+
export declare function getNativeNode(node: ISymbioteNode): object | undefined;
|
|
27
|
+
/** Called by the commit path once a batch has published. */
|
|
28
|
+
export declare function notifyCommitted(): void;
|
|
29
|
+
/**
|
|
30
|
+
* Run `action` once `node` has a committed Fabric handle — immediately if it already does, else
|
|
31
|
+
* after the commit that assigns one. Returns a cancel fn (drop the retry, e.g. on unmount).
|
|
32
|
+
*/
|
|
33
|
+
export declare function whenCommitted(node: ISymbioteNode, action: () => void): () => void;
|
|
34
|
+
export declare function dispatchViewCommand(node: ISymbioteNode, commandName: string, args: readonly unknown[]): void;
|
|
35
|
+
export declare function sendAccessibilityEvent(node: ISymbioteNode, eventType: string): void;
|
|
36
|
+
export declare function measure(node: ISymbioteNode, callback: IMeasureOnSuccess): void;
|
|
37
|
+
export declare function measureInWindow(node: ISymbioteNode, callback: IMeasureInWindowOnSuccess): void;
|
|
38
|
+
export declare function measureLayout(node: ISymbioteNode, relativeTo: ISymbioteNode, onSuccess: IMeasureLayoutOnSuccess, onFail?: () => void): void;
|
|
39
|
+
/**
|
|
40
|
+
* Tell native that JS has taken the gesture, or given it up.
|
|
41
|
+
*
|
|
42
|
+
* Through the HOST like the five above it, never through the Fabric slot: under the native tree
|
|
43
|
+
* host the committed handle is our placeholder, and `nativeFabricUIManager.setIsJSResponder` unwraps
|
|
44
|
+
* only a `ShadowNode` reference it minted itself.
|
|
45
|
+
*/
|
|
46
|
+
export declare function setIsJSResponder(node: ISymbioteNode, isResponder: boolean, blockNativeResponder: boolean): void;
|
|
47
|
+
/** The node's CURRENT prop value, as the host holds it. The behaviors' one read. */
|
|
48
|
+
export declare function propOf(node: ISymbioteNode, key: string): unknown;
|
|
49
|
+
export declare function setNativeProps(node: ISymbioteNode, partial: Record<string, unknown>): void;
|