@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.
Files changed (79) hide show
  1. package/build/accessibility-props.d.ts +0 -11
  2. package/build/accessibility-props.js +30 -68
  3. package/build/animated/graph.js +1 -1
  4. package/build/animated/leaf-lifecycle.js +2 -2
  5. package/build/asset-source-resolver.d.ts +2 -0
  6. package/build/asset-source-resolver.js +13 -0
  7. package/build/back-handler/index.d.ts +1 -5
  8. package/build/back-handler/index.js +0 -6
  9. package/build/debug.js +8 -22
  10. package/build/dispatch.js +3 -9
  11. package/build/events/delivery.d.ts +9 -0
  12. package/build/events/delivery.js +143 -0
  13. package/build/events/index.js +199 -660
  14. package/build/events/names.d.ts +24 -0
  15. package/build/events/names.js +80 -0
  16. package/build/events/press.d.ts +20 -0
  17. package/build/events/press.js +89 -0
  18. package/build/events/responder.d.ts +6 -0
  19. package/build/events/responder.js +124 -0
  20. package/build/fabric-props.js +74 -179
  21. package/build/fabric.d.ts +0 -13
  22. package/build/fabric.js +18 -38
  23. package/build/host-access.d.ts +1 -128
  24. package/build/host-access.js +96 -205
  25. package/build/host-behavior.d.ts +0 -100
  26. package/build/host-behavior.js +125 -311
  27. package/build/image-loader.js +10 -23
  28. package/build/image-source-resolver.js +3 -7
  29. package/build/image-source-write.d.ts +0 -11
  30. package/build/image-source-write.js +14 -34
  31. package/build/imperative.d.ts +2 -28
  32. package/build/imperative.js +60 -93
  33. package/build/index.d.ts +6 -2
  34. package/build/index.js +29 -39
  35. package/build/mutation-buffer.d.ts +3 -177
  36. package/build/mutation-buffer.js +162 -316
  37. package/build/native-engine.d.ts +6 -102
  38. package/build/native-engine.js +60 -141
  39. package/build/native-events.js +9 -18
  40. package/build/native-tree-host.d.ts +0 -21
  41. package/build/native-tree-host.js +15 -31
  42. package/build/node-events.d.ts +11 -0
  43. package/build/node-events.js +145 -0
  44. package/build/node-instance.d.ts +8 -0
  45. package/build/node-instance.js +168 -0
  46. package/build/node-props.d.ts +13 -0
  47. package/build/node-props.js +131 -0
  48. package/build/node-route.d.ts +2 -0
  49. package/build/node-route.js +151 -0
  50. package/build/node-style.d.ts +15 -0
  51. package/build/node-style.js +214 -0
  52. package/build/node-tree.d.ts +6 -0
  53. package/build/node-tree.js +159 -0
  54. package/build/node-types.d.ts +70 -0
  55. package/build/node-types.js +36 -0
  56. package/build/node.d.ts +7 -309
  57. package/build/node.js +9 -1564
  58. package/build/post-commit.js +3 -8
  59. package/build/process-aspect-ratio.js +3 -7
  60. package/build/process-background-longhands.js +10 -19
  61. package/build/process-filter.js +11 -19
  62. package/build/process-font-variant.js +3 -7
  63. package/build/registry.d.ts +0 -33
  64. package/build/registry.js +22 -57
  65. package/build/report-error.js +4 -18
  66. package/build/structured-style.d.ts +0 -9
  67. package/build/structured-style.js +16 -31
  68. package/build/styles.js +3 -6
  69. package/build/surface.d.ts +0 -26
  70. package/build/surface.js +29 -76
  71. package/build/text-input-state.js +4 -8
  72. package/build/touch-history.js +5 -11
  73. package/build/tree-host.d.ts +7 -270
  74. package/build/tree-host.js +63 -153
  75. package/build/view-config.js +17 -37
  76. package/cpp/SymbioteEngineBindings.cpp +19 -18
  77. package/cpp/SymbioteTree.cpp +81 -156
  78. package/cpp/SymbioteTree.h +6 -0
  79. package/package.json +2 -2
@@ -1,31 +1,13 @@
1
- // Per-TAG behavior on an engine node — the seam that lets a primitive's state machine live BELOW
2
- // the framework instead of inside a framework component.
3
- //
4
- // WHY IT EXISTS. Vue, Svelte, Solid and Angular all optimize element subtrees and stop at a
5
- // component boundary, so a primitive shipped as a component is charged a per-instance price in
6
- // each framework's own currency (an instance, a props Proxy, anchor nodes, an LView). A primitive
7
- // whose state the TEMPLATE never reads does not need to be a component at all — its machine only
8
- // needs a per-node home, and the engine node is one. `.claude/rules/host-primitive-tier.md` has
9
- // the tier model; this module is the tier-2 half of it.
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 `node.isTornDown`. It can still be re-inserted — see
37
- // `reattachHostBehaviors` — and the bit is what tells an insert whether it must walk at all, so the
38
- // common case (building a fresh tree) never walks anything.
39
- //
40
- // A FIELD rather than the `WeakSet` it was, for the reason `slotBatch` is one: both of its readers
41
- // are per-node paths at list scale — the sweep touches every node of a removed subtree, and every
42
- // insert asks the question once.
43
- // The behavior a node actually got lives on the NODE, as `node.hostBehavior`, remembered from its
44
- // one and only registry lookup. A field rather than the `WeakMap` it was, for the reason
45
- // `node.payloadFold` — written on the next line of `attachHostBehavior` — already is one: every
46
- // reader here is a per-node path at list scale, and a `WeakMap` probe is the dearest way to ask a
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 DERIVE what a behavior owns instead of restating it.
73
- // The one that matters: a name in `ownedListeners` is only reachable if `routeProp` also treats it
74
- // as a registered event — otherwise the app's callback lands in `node.props` and the machine, which
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
- * Has a behavior ever ATTACHED to a node, as opposed to a behavior TYPE having been registered?
86
- *
87
- * `hasBehaviors` answers the second, and it is on from module load in every app: registering
88
- * `Pressable` arms it whether or not one is ever mounted. It gates `createElement`'s attach probe
89
- * correctly — a node has to be offered to the registry to find out. It gates the TEARDOWN SWEEP
90
- * wrongly, and that is expensive: the sweep crosses every removed node into JS, which on a
91
- * 1 000-row clear is ten thousand handles. Measured on `build-release`
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
- // Called from `routeProp` only for a node that HAS a slot (`node.childHost !== undefined`), which
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
- // `slotDerived` marks `node.childHost` and nothing else, which is one hop — enough for ScrollView,
133
- // whose only derived node IS the slot, and not enough for a primitive whose `buildStructure` builds
134
- // a chain. Button builds view > text > raw text and folds two of them from the same three owner
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
- * Every owner key feeds the slot. The spelling for a `cloneElement` primitive, whose slot is not
171
- * derived from a named set at all — see `IHostBehavior.slotDerived`.
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 INTRINSIC tag the adapter started from, not the resolved Fabric name it put on the
211
- // node. Defaulted to `node.component` so an adapter that has not been taught to pass it keeps
212
- // working for a behavior registered under a Fabric name — no adapter registers one, so in practice
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 this tag's PLATFORM props without a trip
221
- // back into JS. Here rather than in `createElement` because here is where a tag is known to name
222
- // something: an app's own `<div>`-equivalent would otherwise pay an intern and an op to tell the
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. A freshly
247
- // created node has had no prop written through `setProp` yet — `createRawText`'s text is an OP,
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
- // A plain Set rather than a call into `whenCommitted`: `commit.ts` already imports this module, so
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
- * Run the deferred half of every behavior whose node has now been committed. Called from the commit
265
- * path immediately after `completeRoot`, where fresh Fabric tags have just been assigned.
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
- // The answer is worth recording, not just acting on. `runCommittedHooks` runs immediately after
281
- // this and asks the SAME question about the SAME node, and `isCommitted` crosses the host
282
- // boundary — so a node carrying both hooks paid two crossings for one fact on the commit that
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
- * The recurring beat. Split from `runDeferredAttaches` because the two answer different questions:
290
- * `attachAfterCommit` needs a FRESH FABRIC TAG, so it belongs below `completeRoot` and must not run
291
- * on a commit that made no native call; `afterCommit` needs only "props were published", which a
292
- * no-op commit satisfies just as well.
293
- *
294
- * Keeping them together made `afterCommit` unreachable for exactly the props a behavior owns: a fold
295
- * that STRIPS a prop makes its own commit byte-identical, `commitContainer` returns above the drain,
296
- * and the hook never sees the flip. TouchableOpacity's re-settle on `disabled` is the case
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
- * Arm a node's recurring hook for the next commit.
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
- // ARMED BUT NOT YET COMMITTED — put it back. A node is armed when its behavior attaches,
346
- // which is at `createElement`, before it is in anyone's tree; dropping it here would mean the
347
- // commit that finally lands it never gives it a beat. This is the one place the narrowed
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 `committedEachTime` that have reached Fabric at least once.
360
- //
361
- // ASKED ONCE PER NODE, not once per node per commit. `isCommitted` is `getNativeTag`, which is
362
- // `committedRecordOf` — a `flushOps()` and a CROSSING TO THE HOST. Before this, the loop above ran
363
- // over every mounted node whose behavior declared `afterCommit`, on every commit of ANY surface, so
364
- // a thousand-row list with a `<text-input>` per row paid a thousand crossings to select one row,
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
- // `removeChild` is NOT the destroy signal, and reading it as one is the bug this indirection
379
- // exists to avoid. Engine-side it looks like one — a reorder goes through `detach` inside
380
- // appendChild/insertBefore and never lands in removeChild — but a FRAMEWORK can spell a move as
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 CHEAPEST to distinguish from a move — a node unlinked and
394
- // reinserted before the commit is back in the tree by now, which covers Solid's replaceNode. It is
395
- // NOT a proof of death, and the earlier version of this comment claimed it was. Svelte parks LIVE
396
- // nodes offscreen across commits and sometimes across seconds: `detachFromParent`
397
- // (adapters/svelte/src/dom-shim/shim-node.ts) moves a node into a DocumentFragment that has no
398
- // engine node, calls engineRemoveChild AND requestCommit, and Svelte fully intends to bring it
399
- // back — from a parked `{#if}` branch, from `each.js`'s destroy_effects, and worst, from
400
- // boundary.js's move_effect while a pending snippet shows, which returns when async work resolves.
401
- // So a sweep can and does tear down a node that comes back, which is why `attach` is re-runnable
402
- // (reattachHostBehaviors) rather than why the sweep tries to be cleverer. The machine RESTARTS on
403
- // re-insert instead of surviving an arbitrary absence; a parked subtree is offscreen, so nobody is
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
- // TWO crossings for the whole sweep, whatever it is sweeping — one for the parents, one for the
431
- // subtrees. Asked per node instead, a Clear of a thousand rows spent eleven thousand
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 `parent === undefined` by design (surface.ts), and
436
- // `commitChildren` re-lists them without going through appendChild — so for those the parent
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
- // NARROWED, and it is the sweep's whole cost: what crosses is a handle per node, and on the
440
- // benchmark row eight of every ten are plain views the sweep would mark and do nothing else
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 SURFACE teardown path, where there is nothing to
447
- // decide: `disposeRoot` drops the root container, so every node under it has left for good whatever
448
- // any framework intended.
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
- // SURFACE, so the subtree is the screen.
302
+ // surface, so the subtree is the screen.
458
303
  for (const each of teardownSubtreesOf([node]))
459
304
  detachOne(each, onDetached);
460
305
  }
461
- // `isTornDown` guards BOTH overlaps, and it used to be helped by a per-call `seen` Set that guarded
462
- // only the first of them:
463
- //
464
- // within one call a removed parent and a removed descendant are both nominated, so the
465
- // descendant arrives twice
466
- // across calls a node the sweep released and that `disposeRoot` then walks again, the
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 THIS node carries a behavior: the mark is what tells a later insert to
489
- // walk, and the node re-inserted is usually a plain container whose DESCENDANT holds the
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
- // AND THE REST OF THE BODY IS THE BEHAVIOUR'S, so a node without one leaves here. Nine of every
494
- // ten nodes in a removed subtree are plain views: the mark above is the whole of what they owe,
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
- // Drop a deferral the node never got to run. NO TEST CAN SEE THIS, and it is kept anyway —
501
- // stated rather than left as apparent coverage. The `isCommitted` predicate in the drain already
502
- // stops such a node from firing, so removing this line changes no observable behaviour; what it
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, and unlike the deferral above this one has a visible
508
- // consequence if forgotten: a torn-down node would keep being asked to reconcile props against a
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. Called from appendChild and
514
- // insertBefore, and it is a WeakSet miss — no walk at all — for every node in a freshly built
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 for the same reason the detach walk is, and with nothing to reconcile: this one always
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: `reattachOne` acts only on a node the sweep
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-arm the deferred half too. A parked node usually returns with its tag intact, so this
535
- // fires on the next drain — but re-arming is what keeps `attach` and `attachAfterCommit` a
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 coming back out of the park has not necessarily had a prop written since, and its
544
- // mirror may have moved while it was away. Arm it once so the next commit gives it a beat —
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
  }