@symbiote-native/engine 1.3.0 → 1.4.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/build/accessibility-props.d.ts +0 -11
- package/build/accessibility-props.js +30 -68
- package/build/animated/graph.js +1 -1
- package/build/animated/leaf-lifecycle.js +2 -2
- package/build/asset-source-resolver.d.ts +2 -0
- package/build/asset-source-resolver.js +13 -0
- package/build/back-handler/index.d.ts +1 -5
- package/build/back-handler/index.js +0 -6
- package/build/debug.js +8 -22
- package/build/dispatch.js +3 -9
- package/build/events/delivery.d.ts +9 -0
- package/build/events/delivery.js +143 -0
- package/build/events/index.js +199 -660
- package/build/events/names.d.ts +24 -0
- package/build/events/names.js +80 -0
- package/build/events/press.d.ts +20 -0
- package/build/events/press.js +89 -0
- package/build/events/responder.d.ts +6 -0
- package/build/events/responder.js +124 -0
- package/build/fabric-props.js +74 -179
- package/build/fabric.d.ts +0 -13
- package/build/fabric.js +18 -38
- package/build/host-access.d.ts +1 -128
- package/build/host-access.js +96 -205
- package/build/host-behavior.d.ts +0 -100
- package/build/host-behavior.js +125 -311
- package/build/image-loader.js +10 -23
- package/build/image-source-resolver.js +3 -7
- package/build/image-source-write.d.ts +0 -11
- package/build/image-source-write.js +14 -34
- package/build/imperative.d.ts +2 -28
- package/build/imperative.js +60 -93
- package/build/index.d.ts +6 -2
- package/build/index.js +29 -39
- package/build/mutation-buffer.d.ts +3 -177
- package/build/mutation-buffer.js +162 -316
- package/build/native-engine.d.ts +6 -102
- package/build/native-engine.js +60 -141
- package/build/native-events.js +9 -18
- package/build/native-tree-host.d.ts +0 -21
- package/build/native-tree-host.js +15 -31
- package/build/node-events.d.ts +11 -0
- package/build/node-events.js +145 -0
- package/build/node-instance.d.ts +8 -0
- package/build/node-instance.js +168 -0
- package/build/node-props.d.ts +13 -0
- package/build/node-props.js +131 -0
- package/build/node-route.d.ts +2 -0
- package/build/node-route.js +151 -0
- package/build/node-style.d.ts +15 -0
- package/build/node-style.js +214 -0
- package/build/node-tree.d.ts +6 -0
- package/build/node-tree.js +159 -0
- package/build/node-types.d.ts +70 -0
- package/build/node-types.js +36 -0
- package/build/node.d.ts +7 -309
- package/build/node.js +9 -1564
- package/build/post-commit.js +3 -8
- package/build/process-aspect-ratio.js +3 -7
- package/build/process-background-longhands.js +10 -19
- package/build/process-filter.js +11 -19
- package/build/process-font-variant.js +3 -7
- package/build/registry.d.ts +0 -33
- package/build/registry.js +22 -57
- package/build/report-error.js +4 -18
- package/build/structured-style.d.ts +0 -9
- package/build/structured-style.js +16 -31
- package/build/styles.js +3 -6
- package/build/surface.d.ts +0 -26
- package/build/surface.js +29 -76
- package/build/text-input-state.js +4 -8
- package/build/touch-history.js +5 -11
- package/build/tree-host.d.ts +7 -270
- package/build/tree-host.js +63 -153
- package/build/view-config.js +17 -37
- package/cpp/SymbioteEngineBindings.cpp +19 -18
- package/cpp/SymbioteTree.cpp +81 -156
- package/cpp/SymbioteTree.h +6 -0
- package/package.json +2 -2
package/build/host-behavior.js
CHANGED
|
@@ -1,31 +1,13 @@
|
|
|
1
|
-
// Per-
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// WHY A REGISTRY RATHER THAN A DIRECT IMPORT. `@symbiote-native/components` depends on
|
|
12
|
-
// `@symbiote-native/engine`, never the reverse, so the engine cannot import `createPressHandlers`
|
|
13
|
-
// and friends. CLAUDE.md's preferred answer to a registration problem — delete the indirection —
|
|
14
|
-
// is therefore unavailable here; the inversion is forced, not chosen.
|
|
15
|
-
//
|
|
16
|
-
// WHICH MEANS THE REGISTRATION ITSELF IS THE HAZARD, and it is the one CLAUDE.md spells out:
|
|
17
|
-
// Metro turns on `inlineRequires` for production only, moving a `require` down to the first place
|
|
18
|
-
// its binding is used as a VALUE, and a barrel's `export { X } from './x'` compiles to a lazy
|
|
19
|
-
// getter. A module whose only job is to call `registerHostBehavior` is never named as a value, so
|
|
20
|
-
// re-exporting it from a barrel means it NEVER EVALUATES in a release build — dev is perfect,
|
|
21
|
-
// release silently has no behavior. The one shape that works is a bare side-effect import that is
|
|
22
|
-
// never re-exported from that barrel, the pattern in
|
|
23
|
-
// `packages/slider/src/{react,vue,svelte,angular}/index.ts`:
|
|
24
|
-
//
|
|
25
|
-
// import '../register'; // in the adapter entry — NOT `export * from '../register'`
|
|
26
|
-
//
|
|
27
|
-
// `registerHostBehavior` emits a `dlog` precisely so `DEBUG=1` answers "did my registration run at
|
|
28
|
-
// all" before anyone starts debugging the behavior itself.
|
|
1
|
+
// Per-tag behavior on an engine node — lets a primitive's state machine live below the framework
|
|
2
|
+
// instead of inside a framework component, which would otherwise charge it a per-instance cost in
|
|
3
|
+
// every framework touching element subtrees (see .claude/rules/host-primitive-tier.md, tier 2).
|
|
4
|
+
// A registry, not a direct import: components depends on engine and never the reverse, so the
|
|
5
|
+
// engine cannot import createPressHandlers directly. The inversion is forced, not chosen.
|
|
6
|
+
// The registration call itself is the hazard: Metro's inlineRequires makes a barrel re-export
|
|
7
|
+
// lazy, so a module whose only job is registerHostBehavior() never evaluates in release unless
|
|
8
|
+
// it's a bare side-effect import (`import '../register'`), never re-exported — see packages/slider.
|
|
9
|
+
// registerHostBehavior emits a dlog so DEBUG=1 answers "did my registration run" before anyone
|
|
10
|
+
// starts debugging the behavior itself.
|
|
29
11
|
import { parentsOf, teardownSubtreesOf } from './host-access.js';
|
|
30
12
|
import { recordSetTag } from './mutation-buffer.js';
|
|
31
13
|
import { dlog } from './debug.js';
|
|
@@ -33,33 +15,17 @@ const behaviors = new Map();
|
|
|
33
15
|
// Nodes that `removeChild` unlinked and that may or may not be coming back. See
|
|
34
16
|
// `sweepDetachedBehaviors` for why the answer is not known until commit.
|
|
35
17
|
const detachCandidates = new Set();
|
|
36
|
-
// A node the sweep has torn down carries
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
// question whose answer is almost always "none".
|
|
48
|
-
//
|
|
49
|
-
// THE REGISTRY IS KEYED BY INTRINSIC TAG AND THE NODE IS NOT. `node.component` is the FABRIC view
|
|
50
|
-
// name: every adapter resolves the tag through `descriptorFor` before calling `createElement`, so
|
|
51
|
-
// `view` arrives as `RCTView`. Keying the registry by Fabric name instead is not an
|
|
52
|
-
// option — a pressable resolves to `RCTView` like any other view, so the press machine would
|
|
53
|
-
// attach to every plain `View` in the app. So the tag alphabet is used EXACTLY ONCE, at
|
|
54
|
-
// `attachHostBehavior`, where the caller still holds it; every later lookup reads this map.
|
|
55
|
-
//
|
|
56
|
-
// Found by a peer session probing the installed shape, not by a unit test: the tests built their
|
|
57
|
-
// subject with `createElement(PRESSABLE_TAG)`, which passes the tag AS the Fabric name and makes
|
|
58
|
-
// the key match by accident. No adapter constructs a node that way, so the registration could
|
|
59
|
-
// never have fired in an app while all six break-tests kept failing correctly on their own axes.
|
|
60
|
-
// The gate. `createElement` and `removeChild` are the two hottest paths in the engine (9 002 and
|
|
61
|
-
// ~1 000 calls on one benchmark row set), so neither may pay a Set insert for a feature no app
|
|
62
|
-
// uses yet. While this is false both paths cost one boolean read, the same discipline as `isDebug`.
|
|
18
|
+
// A node the sweep has torn down carries node.isTornDown — it can still be re-inserted (see
|
|
19
|
+
// reattachHostBehaviors), and the bit tells an insert whether it must walk at all, so the common
|
|
20
|
+
// case (building a fresh tree) never walks anything. A field, since both readers are per-node.
|
|
21
|
+
// The behavior a node actually got lives on the node, as node.hostBehavior, remembered from its
|
|
22
|
+
// one and only registry lookup — a field rather than a WeakMap, since a probe is the dearest way
|
|
23
|
+
// to ask a question whose answer is almost always "none".
|
|
24
|
+
// The registry is keyed by intrinsic tag, and the node is not: node.component is the resolved
|
|
25
|
+
// Fabric view name (`view` arrives as `RCTView`), and keying by that would attach the press
|
|
26
|
+
// machine to every plain View. The tag alphabet is used exactly once, at attachHostBehavior.
|
|
27
|
+
// The gate: createElement and removeChild are the engine's hottest paths, so neither may pay a
|
|
28
|
+
// Set insert for a feature no app uses yet. While this is false both cost one boolean read.
|
|
63
29
|
let hasBehaviors = false;
|
|
64
30
|
// See `hasAttachedBehaviors`. A behavior TYPE existing and a behavior being ON a node are different
|
|
65
31
|
// questions, and the teardown sweep was asking the first one.
|
|
@@ -69,46 +35,29 @@ export function registerHostBehavior(component, behavior) {
|
|
|
69
35
|
behaviors.set(component, behavior);
|
|
70
36
|
hasBehaviors = true;
|
|
71
37
|
}
|
|
72
|
-
// Read access to the registry, so an audit can
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
// reads the stash, never sees it. That set difference is a test
|
|
76
|
-
// (`core/components/src/behaviors/owned-listeners-are-routable.test.ts`) and it needs this to stay
|
|
77
|
-
// derived rather than becoming another hand-written list.
|
|
38
|
+
// Read access to the registry, so an audit can derive what a behavior owns rather than restating
|
|
39
|
+
// it — a name in ownedListeners is only reachable if routeProp also treats it as a registered
|
|
40
|
+
// event, and owned-listeners-are-routable.test.ts checks that gap stays empty.
|
|
78
41
|
export function hostBehaviorFor(tag) {
|
|
79
42
|
return behaviors.get(tag);
|
|
80
43
|
}
|
|
81
44
|
export function hasHostBehaviors() {
|
|
82
45
|
return hasBehaviors;
|
|
83
46
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
* (`teardown-sweep-cost.itest.ts`): 1.8 ms with the sweep off against 6.3 ms with it on, i.e. 3.2x,
|
|
93
|
-
* all of it inside the commit. `Clear` is the one row where stock React Native beats every adapter.
|
|
94
|
-
*
|
|
95
|
-
* Nothing the sweep does can matter before the first attach, and the four collections say so:
|
|
96
|
-
* `node.hostBehavior` is written only by `attachHostBehavior`; `awaitingCommit` and `committedEachTime` are
|
|
97
|
-
* written only inside a `behavior.` branch; `parked` only by `detachAnimatedProps`, which has its
|
|
98
|
-
* own gate. The one remaining effect is marking `isTornDown`, which exists so a later re-insert knows
|
|
99
|
-
* to re-arm — and there is nothing to re-arm.
|
|
100
|
-
*
|
|
101
|
-
* MONOTONE, deliberately: it turns on and never off, so it needs no accounting on a `WeakMap` that
|
|
102
|
-
* has no size and no destructor. Being late is the only way it can be wrong, and it cannot be late.
|
|
103
|
-
*/
|
|
47
|
+
// Has a behavior ever attached to a node, as opposed to a behavior type having been registered?
|
|
48
|
+
// hasBehaviors answers the second and is on from module load in every app — correct for gating
|
|
49
|
+
// createElement's attach probe, wrong (and costly) for gating the teardown sweep.
|
|
50
|
+
// Nothing the sweep does can matter before the first attach: every collection it touches is
|
|
51
|
+
// written only from inside a behavior branch, and the only other effect (marking isTornDown) has
|
|
52
|
+
// nothing to re-arm yet either.
|
|
53
|
+
// Monotone, deliberately: turns on and never off, so it needs no accounting on a destructor-less
|
|
54
|
+
// WeakMap. Being late is the only way it can be wrong, and it cannot be late.
|
|
104
55
|
export function hasAttachedBehaviors() {
|
|
105
56
|
return hasAttached;
|
|
106
57
|
}
|
|
107
58
|
// What an owner prop is called on the slot, or undefined when it belongs to the owner after all.
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
// is what keeps a WeakMap probe off the hot path: every other node is turned away by one field
|
|
111
|
-
// read, the same gate `payloadFold` uses one layer down.
|
|
59
|
+
// Called from routeProp only for a node that has a slot, which keeps a WeakMap probe off the hot
|
|
60
|
+
// path — every other node is turned away by one field read, the same gate payloadFold uses.
|
|
112
61
|
export function slotPropNameFor(node, key) {
|
|
113
62
|
const behavior = node.hostBehavior;
|
|
114
63
|
if (behavior === undefined)
|
|
@@ -128,16 +77,10 @@ export function slotTakesChildren(node) {
|
|
|
128
77
|
return node.hostBehavior?.slotTakesNoChildren !== true;
|
|
129
78
|
}
|
|
130
79
|
// Nodes a behavior built that are NOT the slot, and whose payloads derive from the owner's props.
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
// props; without this the deeper nodes freeze at their mount values, and the workaround is to write
|
|
136
|
-
// them from inside a fold whose contract says it MUST be pure.
|
|
137
|
-
//
|
|
138
|
-
// A WeakMap rather than a field, for the reason `stashed` is one: this exists only for the handful
|
|
139
|
-
// of nodes a composed behavior built, and a field costs a shape transition on every node in every
|
|
140
|
-
// app. It is read only inside the `slotDerived` branch, which has already paid a WeakMap probe.
|
|
80
|
+
// slotDerived marks only node.childHost, which is one hop — not enough for Button's view > text >
|
|
81
|
+
// raw text chain, whose deeper nodes would otherwise freeze at their mount values.
|
|
82
|
+
// A WeakMap rather than a field: this exists only for the handful of nodes a composed behavior
|
|
83
|
+
// built, and a field would cost a shape transition on every node in every app.
|
|
141
84
|
const derived = new WeakMap();
|
|
142
85
|
// Called from `buildStructure` for each node past the slot. Not idempotent-checked: structure is
|
|
143
86
|
// built exactly once (`attachHostBehavior`, never `reattachSubtree`), so a second call would be a
|
|
@@ -166,13 +109,9 @@ export function notifyChildInserted(node, child) {
|
|
|
166
109
|
export function claimModeFor(node, component) {
|
|
167
110
|
return node.hostBehavior?.claimedChildren?.[component];
|
|
168
111
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
*
|
|
173
|
-
* A sentinel in the SAME array rather than a second field, so `slotDerivesFrom` stays one lookup and
|
|
174
|
-
* a behavior that wants both spellings cannot express a contradiction.
|
|
175
|
-
*/
|
|
112
|
+
// Every owner key feeds the slot — the spelling for a cloneElement primitive, whose slot isn't
|
|
113
|
+
// derived from a named set at all (see IHostBehavior.slotDerived). A sentinel in the same array
|
|
114
|
+
// rather than a second field, so slotDerivesFrom stays one lookup.
|
|
176
115
|
export const SLOT_DERIVED_ALL = '*';
|
|
177
116
|
// Does this owner key feed the slot's payload? See `slotDerived`. Same `node.childHost` gate as
|
|
178
117
|
// above keeps the WeakMap probe off every node that has no slot.
|
|
@@ -207,20 +146,18 @@ export function stashAppListener(node, name, listener) {
|
|
|
207
146
|
export function appListenerFor(node, name) {
|
|
208
147
|
return stashed.get(node)?.get(name);
|
|
209
148
|
}
|
|
210
|
-
// `tag` is the
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
// the default simply never matches and costs one failed lookup.
|
|
149
|
+
// `tag` is the intrinsic tag the adapter started from, not the resolved Fabric name on the node —
|
|
150
|
+
// callers default it to node.component for an adapter not yet taught to pass it, which costs one
|
|
151
|
+
// harmless failed lookup since no behavior is registered under a Fabric name.
|
|
214
152
|
export function attachHostBehavior(node, tag) {
|
|
215
153
|
const behavior = behaviors.get(tag);
|
|
216
154
|
if (behavior === undefined)
|
|
217
155
|
return;
|
|
218
156
|
node.hostBehavior = behavior;
|
|
219
157
|
hasAttached = true;
|
|
220
|
-
// The tag itself, over the wire, so the host can resolve
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
// host a name it has no rule for.
|
|
158
|
+
// The tag itself, over the wire, so the host can resolve its platform props without a trip back
|
|
159
|
+
// into JS. Here rather than in createElement, since here is where a tag is known to name a rule
|
|
160
|
+
// the host actually has — an app's own arbitrary tag would pay an op for nothing.
|
|
224
161
|
recordSetTag(node, tag);
|
|
225
162
|
// BEFORE `attach` and before any prop is routed, which is the whole point: it changes how a
|
|
226
163
|
// WRITE is stored, so a source written to this node must never arrive ahead of it.
|
|
@@ -243,10 +180,8 @@ export function attachHostBehavior(node, tag) {
|
|
|
243
180
|
if (behavior.afterCommit !== undefined) {
|
|
244
181
|
committedEachTime.add(node);
|
|
245
182
|
node.hasCommitHook = true;
|
|
246
|
-
// Armed from the start, so the commit that first lands this node gives it a beat
|
|
247
|
-
// created node has had no
|
|
248
|
-
// not a field — so nothing else would arm it, and its behavior would wait for a write that a
|
|
249
|
-
// purely declarative mount never makes.
|
|
183
|
+
// Armed from the start, so the commit that first lands this node gives it a beat — a freshly
|
|
184
|
+
// created node has had no setProp write yet, so nothing else would arm it.
|
|
250
185
|
noteCommitHookNodeChanged(node);
|
|
251
186
|
}
|
|
252
187
|
}
|
|
@@ -254,20 +189,12 @@ export function attachHostBehavior(node, tag) {
|
|
|
254
189
|
// opposite lifetimes: one empties as its nodes commit, this one holds until teardown.
|
|
255
190
|
const committedEachTime = new Set();
|
|
256
191
|
// Nodes whose behavior declared `attachAfterCommit` and whose first commit has not happened yet.
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
// reaching back for it would close an import cycle. Metro's `inlineRequires` has made module
|
|
260
|
-
// evaluation order a real hazard here rather than a theoretical one (see this file's own
|
|
261
|
-
// registration comment), so the dependency stays one-directional and commit DRAINS this instead.
|
|
192
|
+
// A plain Set rather than a call into whenCommitted: commit.ts already imports this module, so
|
|
193
|
+
// reaching back for it would close an import cycle (see this file's own registration comment).
|
|
262
194
|
const awaitingCommit = new Set();
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
*
|
|
267
|
-
* `isCommitted` is passed in for the same no-cycle reason — `committedOf` lives in `commit.ts`. A
|
|
268
|
-
* still-uncommitted node stays in the set: a create superseded before it ever reached Fabric waits
|
|
269
|
-
* for the commit that lands it, and `detachSubtree` drops it if that commit never comes.
|
|
270
|
-
*/
|
|
195
|
+
// Run the deferred half of every behavior whose node has now been committed, called right after
|
|
196
|
+
// completeRoot assigns fresh Fabric tags. `isCommitted` is passed in for the same no-cycle reason.
|
|
197
|
+
// A still-uncommitted node stays in the set until its commit lands, or detachSubtree drops it.
|
|
271
198
|
export function runDeferredAttaches(isCommitted) {
|
|
272
199
|
// The gate: an app registering no behavior pays one Set-size read per commit, matching the
|
|
273
200
|
// discipline `hasBehaviors` sets for `createElement`.
|
|
@@ -277,56 +204,26 @@ export function runDeferredAttaches(isCommitted) {
|
|
|
277
204
|
if (!isCommitted(node))
|
|
278
205
|
continue;
|
|
279
206
|
awaitingCommit.delete(node);
|
|
280
|
-
//
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
// landed it. Two per `<text-input>` on a 1 000-row create, measured at the call site.
|
|
207
|
+
// Worth recording, not just acting on: runCommittedHooks asks the same isCommitted question
|
|
208
|
+
// right after, and that crosses to the host — a node with both hooks would otherwise pay two
|
|
209
|
+
// crossings for one fact on the commit that landed it.
|
|
284
210
|
everCommitted.add(node);
|
|
285
211
|
node.hostBehavior?.attachAfterCommit?.(node);
|
|
286
212
|
}
|
|
287
213
|
}
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
* (`disabled` is a MACHINE_ONLY key), Button's `title`/`color` the other.
|
|
298
|
-
*
|
|
299
|
-
* SETUP STILL RUNS BEFORE THE BEAT on the first commit, and the order is load-bearing: a node
|
|
300
|
-
* carrying both hooks has `attachAfterCommit` seed the mirrors `afterCommit` compares against. The
|
|
301
|
-
* caller preserves it by calling this AFTER `runDeferredAttaches` on the changed path — the no-op
|
|
302
|
-
* path has no setup to run, since a node with no Fabric tag has not committed at all.
|
|
303
|
-
*/
|
|
304
|
-
/**
|
|
305
|
-
* Nodes with a recurring hook whose props were written since the last beat.
|
|
306
|
-
*
|
|
307
|
-
* THE POPULATION THE BEAT RUNS OVER, and narrowing it to this is the second half of F-66. The
|
|
308
|
-
* first half stopped the loop CROSSING to decide whether to run a hook; this stops it running the
|
|
309
|
-
* hook at all for a node that cannot have anything to do — and the hook BODY is where the rest of
|
|
310
|
-
* the cost was. TextInput's asks the host for its `value` to compare against its native mirror,
|
|
311
|
-
* which the work ledger measures at `propOf` × 1 000 per commit, in all four adapters.
|
|
312
|
-
*
|
|
313
|
-
* Sound because both behaviors that document why they need the beat need it for the same event, a
|
|
314
|
-
* prop written on their own node. `switch.ts` says so outright — "a check scheduled only from
|
|
315
|
-
* `onChange` never re-runs for a prop change with no preceding native event … `afterCommit` costs
|
|
316
|
-
* nothing extra (it fires only on a commit that already changed something)" — and TextInput's
|
|
317
|
-
* controlled handshake has two sources, the app moving `value` and the user typing, the second of
|
|
318
|
-
* which writes `mostRecentEventCount`. A fold that STRIPS a prop is covered too: the write
|
|
319
|
-
* happened, and it is the PAYLOAD that comes out byte-identical, which is the case this hook was
|
|
320
|
-
* split from `attachAfterCommit` for.
|
|
321
|
-
*/
|
|
214
|
+
// The recurring beat, split from runDeferredAttaches: attachAfterCommit needs a fresh Fabric tag
|
|
215
|
+
// and must not run on a no-op commit; afterCommit needs only "props were published", which a
|
|
216
|
+
// no-op commit (a fold that stripped a prop, making the commit byte-identical) satisfies too.
|
|
217
|
+
// Setup still runs before the beat on the first commit — a node carrying both hooks has
|
|
218
|
+
// attachAfterCommit seed the mirrors afterCommit compares against, preserved by calling this after
|
|
219
|
+
// runDeferredAttaches on the changed path only.
|
|
220
|
+
// Nodes with a recurring hook whose props were written since the last beat — narrowing the
|
|
221
|
+
// population the beat runs over, since the hook body itself (TextInput reading its native mirror)
|
|
222
|
+
// is where the real cost is, not the loop deciding whether to run it.
|
|
322
223
|
const commitHookNodesChanged = new Set();
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
* Called from `setProp` — gated there on `node.hasCommitHook`, a boolean field beside
|
|
327
|
-
* `hasAriaAlias` on the same hidden class, so a node without a recurring hook pays one load and
|
|
328
|
-
* one branch per write and never reaches this.
|
|
329
|
-
*/
|
|
224
|
+
// Arm a node's recurring hook for the next commit. Called from setProp, gated on node.hasCommitHook
|
|
225
|
+
// (a boolean field beside hasAriaAlias on the same hidden class), so a node without a recurring
|
|
226
|
+
// hook pays one load and one branch per write and never reaches this.
|
|
330
227
|
export function noteCommitHookNodeChanged(node) {
|
|
331
228
|
commitHookNodesChanged.add(node);
|
|
332
229
|
}
|
|
@@ -342,11 +239,9 @@ export function runCommittedHooks(isCommitted) {
|
|
|
342
239
|
if (!committedEachTime.has(node))
|
|
343
240
|
continue;
|
|
344
241
|
if (!everCommitted.has(node)) {
|
|
345
|
-
//
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
// population can lose a node, and it is why the set is cleared by REMOVAL of what ran rather
|
|
349
|
-
// than wholesale.
|
|
242
|
+
// Armed but not yet committed — put it back. A node is armed at createElement, before it is
|
|
243
|
+
// in anyone's tree, so dropping it here would mean the commit that finally lands it never
|
|
244
|
+
// gives it a beat.
|
|
350
245
|
if (!isCommitted(node)) {
|
|
351
246
|
commitHookNodesChanged.add(node);
|
|
352
247
|
continue;
|
|
@@ -356,173 +251,95 @@ export function runCommittedHooks(isCommitted) {
|
|
|
356
251
|
node.hostBehavior?.afterCommit?.(node);
|
|
357
252
|
}
|
|
358
253
|
}
|
|
359
|
-
// Nodes of
|
|
360
|
-
//
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
// and paid them again on a commit that changed nothing at all. Measured at 550 for a 550-node set
|
|
366
|
-
// (`__tests__/post-commit-hooks-are-not-the-tree.test.ts`).
|
|
367
|
-
//
|
|
368
|
-
// The cached answer is sound because within `committedEachTime` it is monotone: a node enters when
|
|
369
|
-
// its behavior attaches, leaves in `detachOne` when it is torn down, and a live node that has been
|
|
370
|
-
// committed keeps a Fabric record — a clone keeps the family. So the bit only ever goes TRUE for a
|
|
371
|
-
// node still in the set, which is F-18's `mayHaveChildren` shape: a stale FALSE costs one more
|
|
372
|
-
// crossing next commit, and a stale TRUE is impossible because leaving the set is what losing the
|
|
373
|
-
// record means.
|
|
374
|
-
//
|
|
375
|
-
// A WeakSet rather than a node field: nothing outside this module has any business reading it, and
|
|
376
|
-
// a node that leaves the tree takes its entry with it.
|
|
254
|
+
// Nodes of committedEachTime that have reached Fabric at least once. Asked once per node, not
|
|
255
|
+
// once per node per commit — isCommitted crosses to the host, and the loop above used to pay that
|
|
256
|
+
// crossing for every mounted node with afterCommit, on every commit of any surface.
|
|
257
|
+
// Sound because within committedEachTime it is monotone: a node enters when its behavior attaches,
|
|
258
|
+
// leaves in detachOne when torn down, and a committed live node keeps its Fabric record — so the
|
|
259
|
+
// bit only ever goes true for a node still in the set. A WeakSet, since nothing else reads it.
|
|
377
260
|
const everCommitted = new WeakSet();
|
|
378
|
-
//
|
|
379
|
-
//
|
|
380
|
-
//
|
|
381
|
-
// remove-then-reinsert. Solid does, in `solid-js/universal`: `replaceNode` (universal.cjs:186) is
|
|
382
|
-
// `insertNode` + `removeNode`, and `reconcileArrays` calls it at :157 for a node that IS in the
|
|
383
|
-
// new array and is needed at a later index. Its sibling call at :130 is guarded by
|
|
384
|
-
// `if (!map || !map.has(a[aStart]))` and removes only genuinely absent nodes — one guarded call
|
|
385
|
-
// and one not, which is why a quick read of that file says "removeChild means gone".
|
|
386
|
-
//
|
|
387
|
-
// Tearing down there would kill the machine of a node that returns alive a few operations later,
|
|
388
|
-
// in the same batch: long-press silently stops working after certain list reorders, on device
|
|
389
|
-
// only, with nothing red. So removal only nominates.
|
|
261
|
+
// removeChild is NOT the destroy signal — a framework can spell a move as remove-then-reinsert
|
|
262
|
+
// (Solid's replaceNode does), so tearing down here would kill the machine of a node that comes
|
|
263
|
+
// back alive later in the same batch. Removal only nominates; the commit sweep decides.
|
|
390
264
|
export function markDetachCandidate(node) {
|
|
391
265
|
detachCandidates.add(node);
|
|
392
266
|
}
|
|
393
|
-
// Commit is where a removal is
|
|
394
|
-
//
|
|
395
|
-
//
|
|
396
|
-
//
|
|
397
|
-
// (
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
//
|
|
403
|
-
//
|
|
404
|
-
// mid-gesture in it, and teardown staying unconditional means there is no leak mode.
|
|
405
|
-
//
|
|
406
|
-
// The subtree walk lives here rather than at removal, and is cheaper for it: only the nodes that
|
|
407
|
-
// actually left are walked.
|
|
408
|
-
//
|
|
409
|
-
// `onDetached` runs for EVERY node of a genuinely-removed subtree, whether or not it carries a
|
|
410
|
-
// behavior — it is how the engine's other per-node lifetime state (an Animated subscription, see
|
|
411
|
-
// `animated/host-binding.ts`) gets the same "did it really leave" answer this sweep exists to
|
|
412
|
-
// compute. Passed in for the no-cycle reason `runDeferredAttaches`' predicate is: this module must
|
|
413
|
-
// keep pointing one way, and Metro's `inlineRequires` makes that a live hazard rather than taste.
|
|
414
|
-
/**
|
|
415
|
-
* Whether the sweep has anything to do — asked BEFORE its arguments are built.
|
|
416
|
-
*
|
|
417
|
-
* The sweep's own first line already returns on an empty candidate set, and that was not enough:
|
|
418
|
-
* its caller passes `surface.children`, which is a GETTER that crosses to the host, allocates the
|
|
419
|
-
* whole top-level list and filters it into a second array. On a surface holding four thousand rows
|
|
420
|
-
* that ran on every commit, including the ones with nothing to sweep, because an argument is
|
|
421
|
-
* evaluated before the guard inside the callee can decline. Same shape as the `dlog` arguments that
|
|
422
|
-
* cost Angular 5-10% while emitting nothing.
|
|
423
|
-
*/
|
|
267
|
+
// Commit is where a removal is cheapest to distinguish from a move — reinserted before the commit,
|
|
268
|
+
// a node is back in the tree by now. Not a proof of death either: Svelte parks live nodes offscreen
|
|
269
|
+
// across commits (a pending `{#if}`/snippet) fully intending to bring them back.
|
|
270
|
+
// So a sweep can and does tear down a node that returns, which is why attach is re-runnable
|
|
271
|
+
// (reattachHostBehaviors) rather than the sweep trying to be cleverer. The subtree walk lives here
|
|
272
|
+
// rather than at removal, so only nodes that actually left are walked.
|
|
273
|
+
// onDetached runs for every node of a genuinely-removed subtree, behavior or not — it's how other
|
|
274
|
+
// per-node lifetime state (an Animated subscription) gets the same "did it really leave" answer.
|
|
275
|
+
// Whether the sweep has anything to do, asked before its arguments are built: the sweep's own
|
|
276
|
+
// first line already returns on an empty candidate set, but its caller passes surface.children, a
|
|
277
|
+
// getter that crosses to the host and allocates — evaluated before the guard can decline.
|
|
424
278
|
export function hasDetachCandidates() {
|
|
425
279
|
return detachCandidates.size > 0;
|
|
426
280
|
}
|
|
427
281
|
export function sweepDetachedBehaviors(topLevel, onDetached) {
|
|
428
282
|
if (detachCandidates.size === 0)
|
|
429
283
|
return;
|
|
430
|
-
//
|
|
431
|
-
// subtrees. Asked per node instead, a
|
|
432
|
-
// (`ITreeHost.parentsOf` carries the measurement).
|
|
284
|
+
// Two crossings for the whole sweep, whatever it is sweeping — one for the parents, one for the
|
|
285
|
+
// subtrees. Asked per node instead, a large clear pays one crossing per candidate instead.
|
|
433
286
|
const candidates = [...detachCandidates];
|
|
434
287
|
const parents = parentsOf(candidates);
|
|
435
|
-
// A surface's top-level nodes carry
|
|
436
|
-
//
|
|
437
|
-
// check alone would report a live node as gone.
|
|
288
|
+
// A surface's top-level nodes carry parent === undefined by design, and commitChildren re-lists
|
|
289
|
+
// them without going through appendChild — so the parent check alone would report them as gone.
|
|
438
290
|
const left = candidates.filter((node, at) => parents[at] === undefined && !topLevel.includes(node));
|
|
439
|
-
//
|
|
440
|
-
//
|
|
441
|
-
// with. See `ITreeHost.teardownSubtreesOf` for which nodes come back and why an ancestor must.
|
|
291
|
+
// Narrowed, and it is the sweep's whole cost: what crosses is a handle per node, and most
|
|
292
|
+
// candidates are plain views the sweep marks and does nothing else with.
|
|
442
293
|
for (const node of teardownSubtreesOf(left))
|
|
443
294
|
detachOne(node, onDetached);
|
|
444
295
|
detachCandidates.clear();
|
|
445
296
|
}
|
|
446
|
-
// Tear a subtree down unconditionally — the
|
|
447
|
-
//
|
|
448
|
-
//
|
|
449
|
-
//
|
|
450
|
-
// It exists because the sweep above cannot answer this. The sweep only sees nodes a `removeChild`
|
|
451
|
-
// NOMINATED, and an unmount removes nothing — the adapter drops the whole surface. So before this,
|
|
452
|
-
// `disposeRoot` touched no node at all: `committedOf` reads `node.committed`, a field on the node,
|
|
453
|
-
// so every node of a dead surface still answered `isCommitted` and stayed in `committedEachTime`,
|
|
454
|
-
// drained on every later commit anywhere in the process, with its timers still armed.
|
|
297
|
+
// Tear a subtree down unconditionally — the surface teardown path, where disposeRoot drops the
|
|
298
|
+
// root container and every node under it has left for good. The sweep above can't answer this: it
|
|
299
|
+
// only sees nodes removeChild nominated, and an unmount removes nothing.
|
|
455
300
|
export function teardownSubtree(node, onDetached) {
|
|
456
301
|
// Narrowed for the same reason the sweep is, and with more to gain: this path tears down a whole
|
|
457
|
-
//
|
|
302
|
+
// surface, so the subtree is the screen.
|
|
458
303
|
for (const each of teardownSubtreesOf([node]))
|
|
459
304
|
detachOne(each, onDetached);
|
|
460
305
|
}
|
|
461
|
-
//
|
|
462
|
-
//
|
|
463
|
-
//
|
|
464
|
-
//
|
|
465
|
-
//
|
|
466
|
-
//
|
|
467
|
-
// ordinary shape of an unmount after the framework emptied the tree
|
|
468
|
-
//
|
|
469
|
-
// `seen` was redundant for the first: the mark is raised unconditionally two lines below the
|
|
470
|
-
// guard, in the same call, so a second arrival takes the same early return. The only behaviour it
|
|
471
|
-
// changed was after a THROWING `onDetached`, where the node would be retried — and a sweep that
|
|
472
|
-
// threw half way has already left the tree in a state no retry repairs.
|
|
473
|
-
//
|
|
474
|
-
// It cost a Set allocation and two hash operations per node, against a teardown that visits every
|
|
475
|
-
// removed node: 10 000 of them on a 1 000-row clear. Removing it is a simplification and NOT a
|
|
476
|
-
// speed-up — measured on `build-release`, the sweep stayed at 4.4-4.6 ms either way
|
|
477
|
-
// (`teardown-sweep-cost.itest.ts`). Whatever holds that time is not the bookkeeping per node.
|
|
478
|
-
//
|
|
479
|
-
// The subtree arrives FLAT, in one host read, instead of a `childrenOf` recursion. The recursion
|
|
480
|
-
// stopped descending at an already-torn-down node where this skips it and carries on; the two agree
|
|
481
|
-
// because both marks are whole-subtree — the sweep adds every descendant and `reattachSubtree`
|
|
482
|
-
// removes every descendant — so a marked node has its descendants marked too, and each of them
|
|
483
|
-
// takes the same early return below.
|
|
306
|
+
// isTornDown guards two overlaps: a removed parent and descendant both nominated in one call, and
|
|
307
|
+
// a node the sweep released that disposeRoot then walks again on an ordinary unmount. The mark is
|
|
308
|
+
// raised right below the guard, so a second arrival takes the same early return.
|
|
309
|
+
// The subtree arrives flat, in one host read, instead of a childrenOf recursion — both this walk
|
|
310
|
+
// and reattachSubtree mark or unmark a whole subtree at once, so descendants are marked too and
|
|
311
|
+
// each takes the same early return below.
|
|
484
312
|
function detachOne(node, onDetached) {
|
|
485
313
|
if (node.isTornDown)
|
|
486
314
|
return;
|
|
487
315
|
onDetached(node);
|
|
488
|
-
// Marked whether or not
|
|
489
|
-
//
|
|
490
|
-
// machine. Gating the mark on `behaviors.has` made the row wrapper unmarked and the whole walk
|
|
491
|
-
// skip — the first version of the parked-node test caught exactly that.
|
|
316
|
+
// Marked whether or not this node carries a behavior: the mark tells a later insert to walk, and
|
|
317
|
+
// the node re-inserted is usually a plain container whose descendant holds the machine.
|
|
492
318
|
node.isTornDown = true;
|
|
493
|
-
//
|
|
494
|
-
//
|
|
495
|
-
// and the three collection probes below were being paid for them anyway. All three are written
|
|
496
|
-
// only inside `attachHostBehavior`, so an absent behavior means an absent entry in each.
|
|
319
|
+
// The rest of the body is the behavior's, so a node without one leaves here — nine of every ten
|
|
320
|
+
// nodes in a removed subtree are plain views, and the mark above is the whole of what they owe.
|
|
497
321
|
const behavior = node.hostBehavior;
|
|
498
322
|
if (behavior === undefined)
|
|
499
323
|
return;
|
|
500
|
-
//
|
|
501
|
-
//
|
|
502
|
-
//
|
|
503
|
-
// changes is that a node created and torn down inside one tick stays in the Set forever, holding
|
|
504
|
-
// a strong reference to a dead subtree. A leak, not a wrong result, and this file's break-test
|
|
505
|
-
// discipline correctly reports it as unfalsifiable.
|
|
324
|
+
// Drops a deferral the node never got to run. isCommitted already stops it from firing, so this
|
|
325
|
+
// changes no observable behavior — without it, a node torn down within one tick would stay in
|
|
326
|
+
// the Set forever, holding a strong reference to a dead subtree (a leak, not a wrong result).
|
|
506
327
|
awaitingCommit.delete(node);
|
|
507
|
-
// The recurring hook stops with the node
|
|
508
|
-
//
|
|
509
|
-
// subtree that has left the tree, on every commit, forever.
|
|
328
|
+
// The recurring hook stops with the node — without this a torn-down node would keep being asked
|
|
329
|
+
// to reconcile props against a subtree that has left the tree, on every commit, forever.
|
|
510
330
|
committedEachTime.delete(node);
|
|
511
331
|
behavior.detach(node);
|
|
512
332
|
}
|
|
513
|
-
// Re-arms a node the sweep tore down but that the framework put back.
|
|
514
|
-
//
|
|
515
|
-
// tree, which is the path that runs ~9 000 times per benchmark create.
|
|
333
|
+
// Re-arms a node the sweep tore down but that the framework put back. A WeakSet miss (no walk at
|
|
334
|
+
// all) for every node in a freshly built tree, the common path on every create.
|
|
516
335
|
export function reattachHostBehaviors(node) {
|
|
517
336
|
if (!node.isTornDown)
|
|
518
337
|
return;
|
|
519
338
|
reattachSubtree(node);
|
|
520
339
|
}
|
|
521
|
-
// Flat
|
|
522
|
-
// descended into every child, whatever the node's own mark said.
|
|
340
|
+
// Flat, like the detach walk, and with nothing to reconcile — always descends into every child.
|
|
523
341
|
function reattachSubtree(root) {
|
|
524
|
-
// Narrowed like the teardown that marked them:
|
|
525
|
-
// marked, and the sweep marked exactly what this walk returns.
|
|
342
|
+
// Narrowed like the teardown that marked them: reattachOne acts only on a node the sweep marked.
|
|
526
343
|
for (const node of teardownSubtreesOf([root]))
|
|
527
344
|
reattachOne(node);
|
|
528
345
|
}
|
|
@@ -531,18 +348,15 @@ function reattachOne(node) {
|
|
|
531
348
|
node.isTornDown = false;
|
|
532
349
|
const behavior = node.hostBehavior;
|
|
533
350
|
behavior?.attach(node);
|
|
534
|
-
// Re-
|
|
535
|
-
//
|
|
536
|
-
// PAIR. Restore only one and a behavior that splits its setup across the two comes back
|
|
537
|
-
// half-initialised, which is the failure this seam exists to prevent.
|
|
351
|
+
// Re-arms the deferred half too, keeping attach and attachAfterCommit a pair — restoring only
|
|
352
|
+
// one would leave a split-setup behavior half-initialised on return.
|
|
538
353
|
if (behavior?.attachAfterCommit !== undefined)
|
|
539
354
|
awaitingCommit.add(node);
|
|
540
355
|
if (behavior?.afterCommit !== undefined) {
|
|
541
356
|
committedEachTime.add(node);
|
|
542
357
|
node.hasCommitHook = true;
|
|
543
|
-
// A node
|
|
544
|
-
//
|
|
545
|
-
// the narrowed population must not turn a RETURNING node into a silently skipped one.
|
|
358
|
+
// A returning node may not have had a prop written since, so arm it once to guarantee the
|
|
359
|
+
// next commit still gives it a beat.
|
|
546
360
|
noteCommitHookNodeChanged(node);
|
|
547
361
|
}
|
|
548
362
|
}
|