@symbiote-native/engine 1.0.0 → 1.1.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.
@@ -40,6 +40,14 @@ export declare function childrenOf(node: ISymbioteNode): readonly ISymbioteNode[
40
40
  export declare function parentsOf(nodes: readonly ISymbioteNode[]): readonly (ISymbioteNode | undefined)[];
41
41
  /** Each root and every descendant, pre-order, anchors included — all of it in ONE crossing. */
42
42
  export declare function subtreesOf(roots: readonly ISymbioteNode[]): readonly ISymbioteNode[];
43
+ /**
44
+ * The same walk narrowed to what a teardown must visit — see `ITreeHost.teardownSubtreesOf`.
45
+ *
46
+ * The gate is the ANIMATED one, not a behavior one: a binding is per node and carries no tag, so an
47
+ * app that animates needs every node back and gets the full walk. Nothing else narrows, because
48
+ * nothing else is charged per node of a removed subtree.
49
+ */
50
+ export declare function teardownSubtreesOf(roots: readonly ISymbioteNode[]): readonly ISymbioteNode[];
43
51
  /**
44
52
  * The node and every ancestor above it, deepest first, in ONE crossing.
45
53
  *
@@ -52,7 +60,19 @@ export declare function subtreesOf(roots: readonly ISymbioteNode[]): readonly IS
52
60
  * chain it would have built by walking.
53
61
  */
54
62
  export declare function ancestorsOf(node: ISymbioteNode): readonly ISymbioteNode[];
55
- /** The first child, anchors included, or `undefined` for a leaf. */
63
+ /**
64
+ * The first child, anchors included, or `undefined` for a leaf.
65
+ *
66
+ * ONE HOST CALL, not `childrenOf(node)[0]`, and the difference is a complexity class rather than a
67
+ * constant. `solid-js/universal`'s `cleanChildren` empties a parent with
68
+ * `while (removed = getFirstChild(parent)) removeNode(parent, removed)` — so the old spelling read a
69
+ * list of N, then N-1, then N-2, building and discarding every handle each time. Measured on a
70
+ * 2 000-row Solid `Clear` (`solid-clear-scaling.itest.tsx`): **2 001 001 handles** crossed to remove
71
+ * two thousand children, N(N+1)/2 to the unit, against the ~2 000 the work needs.
72
+ *
73
+ * The `mayHaveChildren` fast path is kept for the same reason `childrenOf` has it: FALSE is a
74
+ * certainty, so a leaf answers without a drain and without a crossing.
75
+ */
56
76
  export declare function firstChildOf(node: ISymbioteNode): ISymbioteNode | undefined;
57
77
  /**
58
78
  * The next sibling, or `undefined` at the end of the list.
@@ -36,6 +36,7 @@
36
36
  // would build three JS trees instead of the one being removed.
37
37
  import { flushOps, settleBeforeFlush, treeHost } from './tree-host.js';
38
38
  import { hasPendingPlacement } from './mutation-buffer.js';
39
+ import { hasAnimatedBindings } from './animated/host-binding.js';
39
40
  import { functionPropOf, functionPropsOf, isSymbioteNode, RAW_TEXT_COMPONENT, SURFACE_COMPONENT, } from './node.js';
40
41
  // One frozen empty list rather than a fresh `[]`, because the fast path in `childrenOf` is the
41
42
  // common answer during a build: solid asks 2 000 times on a 1 000-row create and every answer is
@@ -117,8 +118,26 @@ export function parentsOf(nodes) {
117
118
  /** Each root and every descendant, pre-order, anchors included — all of it in ONE crossing. */
118
119
  export function subtreesOf(roots) {
119
120
  flushOps();
121
+ // The `.filter` is the type NARROWING, not a defensive check, and it costs ~0.6 ms of the 5.5 ms
122
+ // a thousand-row clear spends in the engine — measured on `build-release` by returning the host's
123
+ // array unnarrowed (`teardown-sweep-cost.itest.ts`, rest of the sweep 2.75 -> 2.16 ms). It stays,
124
+ // because removing it means either an `as` or declaring `ITreeHost.subtreesOf` to hand back our
125
+ // own type, and a pluggable host is exactly what that `object` boundary is for.
120
126
  return treeHost()?.subtreesOf(roots).filter(isSymbioteNode) ?? [];
121
127
  }
128
+ /**
129
+ * The same walk narrowed to what a teardown must visit — see `ITreeHost.teardownSubtreesOf`.
130
+ *
131
+ * The gate is the ANIMATED one, not a behavior one: a binding is per node and carries no tag, so an
132
+ * app that animates needs every node back and gets the full walk. Nothing else narrows, because
133
+ * nothing else is charged per node of a removed subtree.
134
+ */
135
+ export function teardownSubtreesOf(roots) {
136
+ if (hasAnimatedBindings())
137
+ return subtreesOf(roots);
138
+ flushOps();
139
+ return treeHost()?.teardownSubtreesOf(roots).filter(isSymbioteNode) ?? [];
140
+ }
122
141
  /**
123
142
  * The node and every ancestor above it, deepest first, in ONE crossing.
124
143
  *
@@ -143,9 +162,25 @@ export function ancestorsOf(node) {
143
162
  }
144
163
  return out;
145
164
  }
146
- /** The first child, anchors included, or `undefined` for a leaf. */
165
+ /**
166
+ * The first child, anchors included, or `undefined` for a leaf.
167
+ *
168
+ * ONE HOST CALL, not `childrenOf(node)[0]`, and the difference is a complexity class rather than a
169
+ * constant. `solid-js/universal`'s `cleanChildren` empties a parent with
170
+ * `while (removed = getFirstChild(parent)) removeNode(parent, removed)` — so the old spelling read a
171
+ * list of N, then N-1, then N-2, building and discarding every handle each time. Measured on a
172
+ * 2 000-row Solid `Clear` (`solid-clear-scaling.itest.tsx`): **2 001 001 handles** crossed to remove
173
+ * two thousand children, N(N+1)/2 to the unit, against the ~2 000 the work needs.
174
+ *
175
+ * The `mayHaveChildren` fast path is kept for the same reason `childrenOf` has it: FALSE is a
176
+ * certainty, so a leaf answers without a drain and without a crossing.
177
+ */
147
178
  export function firstChildOf(node) {
148
- return childrenOf(node)[0];
179
+ if (!node.mayHaveChildren)
180
+ return undefined;
181
+ flushOps();
182
+ const child = treeHost()?.firstChildOf(node);
183
+ return isSymbioteNode(child) ? child : undefined;
149
184
  }
150
185
  /**
151
186
  * The next sibling, or `undefined` at the end of the list.
@@ -82,9 +82,9 @@ export declare function hasHostBehaviors(): boolean;
82
82
  * all of it inside the commit. `Clear` is the one row where stock React Native beats every adapter.
83
83
  *
84
84
  * Nothing the sweep does can matter before the first attach, and the four collections say so:
85
- * `attached` is written only by `attachHostBehavior`; `awaitingCommit` and `committedEachTime` are
85
+ * `node.hostBehavior` is written only by `attachHostBehavior`; `awaitingCommit` and `committedEachTime` are
86
86
  * written only inside a `behavior.` branch; `parked` only by `detachAnimatedProps`, which has its
87
- * own gate. The one remaining effect is marking `tornDown`, which exists so a later re-insert knows
87
+ * own gate. The one remaining effect is marking `isTornDown`, which exists so a later re-insert knows
88
88
  * to re-arm — and there is nothing to re-arm.
89
89
  *
90
90
  * MONOTONE, deliberately: it turns on and never off, so it needs no accounting on a `WeakMap` that
@@ -26,18 +26,25 @@
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';
29
+ import { parentsOf, teardownSubtreesOf } from './host-access.js';
30
30
  import { recordSetTag } from './mutation-buffer.js';
31
31
  import { dlog } from './debug.js';
32
32
  const behaviors = new Map();
33
33
  // Nodes that `removeChild` unlinked and that may or may not be coming back. See
34
34
  // `sweepDetachedBehaviors` for why the answer is not known until commit.
35
35
  const detachCandidates = new Set();
36
- // Nodes the sweep has torn down. A torn-down node can still be re-inserted — see
37
- // `reattachHostBehaviors` — and this is what tells an insert whether it must walk at all, so the
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
38
  // common case (building a fresh tree) never walks anything.
39
- const tornDown = new WeakSet();
40
- // The behavior a node actually got, remembered from its one and only registry lookup.
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".
41
48
  //
42
49
  // THE REGISTRY IS KEYED BY INTRINSIC TAG AND THE NODE IS NOT. `node.component` is the FABRIC view
43
50
  // name: every adapter resolves the tag through `descriptorFor` before calling `createElement`, so
@@ -50,7 +57,6 @@ const tornDown = new WeakSet();
50
57
  // subject with `createElement(PRESSABLE_TAG)`, which passes the tag AS the Fabric name and makes
51
58
  // the key match by accident. No adapter constructs a node that way, so the registration could
52
59
  // never have fired in an app while all six break-tests kept failing correctly on their own axes.
53
- const attached = new WeakMap();
54
60
  // The gate. `createElement` and `removeChild` are the two hottest paths in the engine (9 002 and
55
61
  // ~1 000 calls on one benchmark row set), so neither may pay a Set insert for a feature no app
56
62
  // uses yet. While this is false both paths cost one boolean read, the same discipline as `isDebug`.
@@ -87,9 +93,9 @@ export function hasHostBehaviors() {
87
93
  * all of it inside the commit. `Clear` is the one row where stock React Native beats every adapter.
88
94
  *
89
95
  * 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
96
+ * `node.hostBehavior` is written only by `attachHostBehavior`; `awaitingCommit` and `committedEachTime` are
91
97
  * 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
98
+ * own gate. The one remaining effect is marking `isTornDown`, which exists so a later re-insert knows
93
99
  * to re-arm — and there is nothing to re-arm.
94
100
  *
95
101
  * MONOTONE, deliberately: it turns on and never off, so it needs no accounting on a `WeakMap` that
@@ -104,7 +110,7 @@ export function hasAttachedBehaviors() {
104
110
  // is what keeps a WeakMap probe off the hot path: every other node is turned away by one field
105
111
  // read, the same gate `payloadFold` uses one layer down.
106
112
  export function slotPropNameFor(node, key) {
107
- const behavior = attached.get(node);
113
+ const behavior = node.hostBehavior;
108
114
  if (behavior === undefined)
109
115
  return undefined;
110
116
  const named = behavior.slotProps?.[key];
@@ -119,7 +125,7 @@ export function slotPropNameFor(node, key) {
119
125
  // `slotTakesNoChildren`. Same `node.childHost` gate as every other probe here: the two callers ask
120
126
  // only after the field said there is a slot at all.
121
127
  export function slotTakesChildren(node) {
122
- return attached.get(node)?.slotTakesNoChildren !== true;
128
+ return node.hostBehavior?.slotTakesNoChildren !== true;
123
129
  }
124
130
  // Nodes a behavior built that are NOT the slot, and whose payloads derive from the owner's props.
125
131
  //
@@ -149,16 +155,16 @@ export function derivedNodesOf(owner) {
149
155
  // Called from `setEventListener` on a PRESENCE flip of an owned name, and only there — the caller
150
156
  // has already established that this node owns the name, so the WeakMap probe is one it just paid.
151
157
  export function notifyOwnedListenerChange(node, name, wired) {
152
- attached.get(node)?.onOwnedListenerChange?.(node, name, wired);
158
+ node.hostBehavior?.onOwnedListenerChange?.(node, name, wired);
153
159
  }
154
160
  // Called from the two inserts once the child is in place. See `onChildInserted`.
155
161
  export function notifyChildInserted(node, child) {
156
- attached.get(node)?.onChildInserted?.(node, child);
162
+ node.hostBehavior?.onChildInserted?.(node, child);
157
163
  }
158
164
  // What this owner does with a child of that Fabric component, or undefined when it does not claim
159
165
  // it at all. See `claimedChildren`.
160
166
  export function claimModeFor(node, component) {
161
- return attached.get(node)?.claimedChildren?.[component];
167
+ return node.hostBehavior?.claimedChildren?.[component];
162
168
  }
163
169
  /**
164
170
  * Every owner key feeds the slot. The spelling for a `cloneElement` primitive, whose slot is not
@@ -171,7 +177,7 @@ export const SLOT_DERIVED_ALL = '*';
171
177
  // Does this owner key feed the slot's payload? See `slotDerived`. Same `node.childHost` gate as
172
178
  // above keeps the WeakMap probe off every node that has no slot.
173
179
  export function slotDerivesFrom(node, key) {
174
- const names = attached.get(node)?.slotDerived;
180
+ const names = node.hostBehavior?.slotDerived;
175
181
  if (names === undefined)
176
182
  return false;
177
183
  return names.includes(SLOT_DERIVED_ALL) || names.includes(key);
@@ -183,7 +189,7 @@ const stashed = new WeakMap();
183
189
  // Takes the NODE, not a component string: the caller (`setEventListener`) has only the Fabric name
184
190
  // by then, which is not the registry's alphabet. Reads the same map `attachHostBehavior` wrote.
185
191
  export function ownsListener(node, name) {
186
- return attached.get(node)?.ownedListeners?.includes(name) === true;
192
+ return node.hostBehavior?.ownedListeners?.includes(name) === true;
187
193
  }
188
194
  export function stashAppListener(node, name, listener) {
189
195
  let bag = stashed.get(node);
@@ -209,7 +215,7 @@ export function attachHostBehavior(node, tag) {
209
215
  const behavior = behaviors.get(tag);
210
216
  if (behavior === undefined)
211
217
  return;
212
- attached.set(node, behavior);
218
+ node.hostBehavior = behavior;
213
219
  hasAttached = true;
214
220
  // The tag itself, over the wire, so the host can resolve this tag's PLATFORM props without a trip
215
221
  // back into JS. Here rather than in `createElement` because here is where a tag is known to name
@@ -276,7 +282,7 @@ export function runDeferredAttaches(isCommitted) {
276
282
  // boundary — so a node carrying both hooks paid two crossings for one fact on the commit that
277
283
  // landed it. Two per `<text-input>` on a 1 000-row create, measured at the call site.
278
284
  everCommitted.add(node);
279
- attached.get(node)?.attachAfterCommit?.(node);
285
+ node.hostBehavior?.attachAfterCommit?.(node);
280
286
  }
281
287
  }
282
288
  /**
@@ -347,7 +353,7 @@ export function runCommittedHooks(isCommitted) {
347
353
  }
348
354
  everCommitted.add(node);
349
355
  }
350
- attached.get(node)?.afterCommit?.(node);
356
+ node.hostBehavior?.afterCommit?.(node);
351
357
  }
352
358
  }
353
359
  // Nodes of `committedEachTime` that have reached Fabric at least once.
@@ -430,7 +436,10 @@ export function sweepDetachedBehaviors(topLevel, onDetached) {
430
436
  // `commitChildren` re-lists them without going through appendChild — so for those the parent
431
437
  // check alone would report a live node as gone.
432
438
  const left = candidates.filter((node, at) => parents[at] === undefined && !topLevel.includes(node));
433
- for (const node of subtreesOf(left))
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.
442
+ for (const node of teardownSubtreesOf(left))
434
443
  detachOne(node, onDetached);
435
444
  detachCandidates.clear();
436
445
  }
@@ -444,10 +453,12 @@ export function sweepDetachedBehaviors(topLevel, onDetached) {
444
453
  // so every node of a dead surface still answered `isCommitted` and stayed in `committedEachTime`,
445
454
  // drained on every later commit anywhere in the process, with its timers still armed.
446
455
  export function teardownSubtree(node, onDetached) {
447
- for (const each of subtreesOf([node]))
456
+ // 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.
458
+ for (const each of teardownSubtreesOf([node]))
448
459
  detachOne(each, onDetached);
449
460
  }
450
- // `tornDown` guards BOTH overlaps, and it used to be helped by a per-call `seen` Set that guarded
461
+ // `isTornDown` guards BOTH overlaps, and it used to be helped by a per-call `seen` Set that guarded
451
462
  // only the first of them:
452
463
  //
453
464
  // within one call a removed parent and a removed descendant are both nominated, so the
@@ -455,7 +466,7 @@ export function teardownSubtree(node, onDetached) {
455
466
  // across calls a node the sweep released and that `disposeRoot` then walks again, the
456
467
  // ordinary shape of an unmount after the framework emptied the tree
457
468
  //
458
- // `seen` was redundant for the first: `tornDown.add(node)` runs unconditionally two lines below the
469
+ // `seen` was redundant for the first: the mark is raised unconditionally two lines below the
459
470
  // guard, in the same call, so a second arrival takes the same early return. The only behaviour it
460
471
  // changed was after a THROWING `onDetached`, where the node would be retried — and a sweep that
461
472
  // threw half way has already left the tree in a state no retry repairs.
@@ -468,17 +479,24 @@ export function teardownSubtree(node, onDetached) {
468
479
  // The subtree arrives FLAT, in one host read, instead of a `childrenOf` recursion. The recursion
469
480
  // stopped descending at an already-torn-down node where this skips it and carries on; the two agree
470
481
  // 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
482
+ // removes every descendant — so a marked node has its descendants marked too, and each of them
472
483
  // takes the same early return below.
473
484
  function detachOne(node, onDetached) {
474
- if (tornDown.has(node))
485
+ if (node.isTornDown)
475
486
  return;
476
487
  onDetached(node);
477
488
  // Marked whether or not THIS node carries a behavior: the mark is what tells a later insert to
478
489
  // walk, and the node re-inserted is usually a plain container whose DESCENDANT holds the
479
490
  // machine. Gating the mark on `behaviors.has` made the row wrapper unmarked and the whole walk
480
491
  // skip — the first version of the parked-node test caught exactly that.
481
- tornDown.add(node);
492
+ 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.
497
+ const behavior = node.hostBehavior;
498
+ if (behavior === undefined)
499
+ return;
482
500
  // Drop a deferral the node never got to run. NO TEST CAN SEE THIS, and it is kept anyway —
483
501
  // stated rather than left as apparent coverage. The `isCommitted` predicate in the drain already
484
502
  // stops such a node from firing, so removing this line changes no observable behaviour; what it
@@ -490,27 +508,28 @@ function detachOne(node, onDetached) {
490
508
  // consequence if forgotten: a torn-down node would keep being asked to reconcile props against a
491
509
  // subtree that has left the tree, on every commit, forever.
492
510
  committedEachTime.delete(node);
493
- // The map, not the registry: by here only the Fabric name is left on the node.
494
- attached.get(node)?.detach(node);
511
+ behavior.detach(node);
495
512
  }
496
513
  // Re-arms a node the sweep tore down but that the framework put back. Called from appendChild and
497
514
  // insertBefore, and it is a WeakSet miss — no walk at all — for every node in a freshly built
498
515
  // tree, which is the path that runs ~9 000 times per benchmark create.
499
516
  export function reattachHostBehaviors(node) {
500
- if (!tornDown.has(node))
517
+ if (!node.isTornDown)
501
518
  return;
502
519
  reattachSubtree(node);
503
520
  }
504
521
  // Flat for the same reason the detach walk is, and with nothing to reconcile: this one always
505
522
  // descended into every child, whatever the node's own mark said.
506
523
  function reattachSubtree(root) {
507
- for (const node of subtreesOf([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.
526
+ for (const node of teardownSubtreesOf([root]))
508
527
  reattachOne(node);
509
528
  }
510
529
  function reattachOne(node) {
511
- if (tornDown.has(node)) {
512
- tornDown.delete(node);
513
- const behavior = attached.get(node);
530
+ if (node.isTornDown) {
531
+ node.isTornDown = false;
532
+ const behavior = node.hostBehavior;
514
533
  behavior?.attach(node);
515
534
  // Re-arm the deferred half too. A parked node usually returns with its tag intact, so this
516
535
  // fires on the next drain — but re-arming is what keeps `attach` and `attachAfterCommit` a
@@ -118,6 +118,22 @@ export declare const KIND_ANCHOR = 2;
118
118
  * array. Native converts each to `folly::dynamic` ONCE, when the op is applied — never again per
119
119
  * commit, which is the half that ships today.
120
120
  */
121
+ /**
122
+ * What the buffer needs a handle to BE.
123
+ *
124
+ * Still an identity and nothing else as far as the ops are concerned — no parent, no children, no
125
+ * order. The two fields are the buffer's own scratch space for `slotOf`, written by this file and
126
+ * read by nobody else; `ISymbioteNode` declares them from its constructor so every real handle
127
+ * carries the pair on one hidden class.
128
+ *
129
+ * Spelled as a requirement rather than as optional fields on purpose: a handle that cannot hold its
130
+ * slot would fall back to nothing, and the failure would be a silently re-pushed node rather than a
131
+ * type error.
132
+ */
133
+ export type IMutationHandle = {
134
+ slot: number;
135
+ slotBatch: number;
136
+ };
121
137
  export type IMutationBatch = {
122
138
  readonly ops: Int32Array;
123
139
  readonly strings: readonly string[];
@@ -137,9 +153,9 @@ export type IMutationBatch = {
137
153
  */
138
154
  export type INativeTree = {
139
155
  applyOps: (batch: IMutationBatch) => void;
140
- getProp: (handle: object, key: string) => unknown;
156
+ getProp: (handle: IMutationHandle, key: string) => unknown;
141
157
  /** The resolved Fabric view name, which native may have changed at insert (see `KIND_ELEMENT`). */
142
- getViewName: (handle: object) => string;
158
+ getViewName: (handle: IMutationHandle) => string;
143
159
  };
144
160
  /** Whether a commit would publish anything. See `changedSinceCommit`. */
145
161
  export declare function hasChangedSinceCommit(): boolean;
@@ -156,17 +172,17 @@ export declare function noteCommitDrained(): void;
156
172
  */
157
173
  export declare function noteHostSideChange(): void;
158
174
  /** Does the pending batch hold anything that could change what the host says this node's parent is? */
159
- export declare function hasPendingPlacement(handle: object): boolean;
160
- export declare function recordCreateElement(handle: object, viewName: string, isText: boolean, instanceHandle: unknown): void;
161
- export declare function recordCreateRawText(handle: object, text: string): void;
162
- export declare function recordCreateAnchor(handle: object): void;
163
- export declare function recordCreateVoid(handle: object): void;
164
- export declare function recordAppendChild(parent: object, child: object): void;
165
- export declare function recordInsertBefore(parent: object, child: object, before: object): void;
166
- export declare function recordRemoveChild(parent: object, child: object): void;
175
+ export declare function hasPendingPlacement(handle: IMutationHandle): boolean;
176
+ export declare function recordCreateElement(handle: IMutationHandle, viewName: string, isText: boolean, instanceHandle: unknown): void;
177
+ export declare function recordCreateRawText(handle: IMutationHandle, text: string): void;
178
+ export declare function recordCreateAnchor(handle: IMutationHandle): void;
179
+ export declare function recordCreateVoid(handle: IMutationHandle): void;
180
+ export declare function recordAppendChild(parent: IMutationHandle, child: IMutationHandle): void;
181
+ export declare function recordInsertBefore(parent: IMutationHandle, child: IMutationHandle, before: IMutationHandle): void;
182
+ export declare function recordRemoveChild(parent: IMutationHandle, child: IMutationHandle): void;
167
183
  /** `undefined` DELETES the key — the collapse `setProp` has always performed, spelled on the wire. */
168
- export declare function recordSetProp(handle: object, key: string, value: unknown): void;
169
- export declare function recordSetText(handle: object, text: string): void;
184
+ export declare function recordSetProp(handle: IMutationHandle, key: string, value: unknown): void;
185
+ export declare function recordSetText(handle: IMutationHandle, text: string): void;
170
186
  /**
171
187
  * Change a node's Fabric view name after creation.
172
188
  *
@@ -179,8 +195,8 @@ export declare function recordSetText(handle: object, text: string): void;
179
195
  * A JS-only workaround was the alternative and it is the one thing this design rules out: to know
180
196
  * what to rebuild, JS would have to hold the tree.
181
197
  */
182
- export declare function recordSetComponent(handle: object, viewName: string): void;
183
- export declare function recordSetTag(handle: object, tag: string): void;
198
+ export declare function recordSetComponent(handle: IMutationHandle, viewName: string): void;
199
+ export declare function recordSetTag(handle: IMutationHandle, tag: string): void;
184
200
  /**
185
201
  * Whether an app callback is currently wired to an event name the BEHAVIOR owns. `[slot, name,
186
202
  * present]`, `present` being 1 or 0.
@@ -199,10 +215,10 @@ export declare function recordSetTag(handle: object, tag: string): void;
199
215
  * identity because a framework hands a fresh closure nearly every render. So this is a mount-time
200
216
  * op, not a per-render one — against the per-commit fold it replaces.
201
217
  */
202
- export declare function recordSetOwnedListener(handle: object, name: string, isPresent: boolean): void;
218
+ export declare function recordSetOwnedListener(handle: IMutationHandle, name: string, isPresent: boolean): void;
203
219
  /** See `OP_SET_UNDERLAY_SHOWN`. Emitted on a flip only, from the behavior that owns the timer. */
204
- export declare function recordSetUnderlayShown(handle: object, shown: boolean): void;
205
- export declare function recordCommit(rootTag: number, surface: object): void;
220
+ export declare function recordSetUnderlayShown(handle: IMutationHandle, shown: boolean): void;
221
+ export declare function recordCommit(rootTag: number, surface: IMutationHandle): void;
206
222
  /** Whether anything is pending. The commit path asks before paying for a drain. */
207
223
  export declare function hasPendingOps(): boolean;
208
224
  /**
@@ -187,7 +187,12 @@ let handles = [];
187
187
  // Interning matters more here than it looks: a 1 000-row create emits about a dozen distinct view
188
188
  // names across 10 000 elements, and every prop KEY is drawn from a set of a few hundred.
189
189
  const stringIds = new Map();
190
- const slots = new Map();
190
+ // Which batch the `slot` standing on a handle belongs to. Bumped by `takeBatch`, which is what
191
+ // invalidates every slot at once without walking the handles that hold them.
192
+ //
193
+ // It starts at 1 because a fresh node's `slotBatch` is 0, so an untouched handle can never match a
194
+ // live batch and needs no separate "is it in this batch" flag.
195
+ let batchId = 1;
191
196
  // The same table for prop VALUES, and the reason it pays is the far side rather than this one: the
192
197
  // host turns each entry into a `folly::dynamic` when the op is applied, so a style object reused
193
198
  // across a thousand rows was a thousand conversions of one object. Measured on `build-release`,
@@ -265,6 +270,22 @@ function pushValue(value) {
265
270
  * Assigned on first mention rather than at creation, so a batch carries exactly the nodes it names.
266
271
  * A handle created by an earlier batch arrives already owning its native node, which is what lets a
267
272
  * clone source three commits old resolve with no bookkeeping on either side.
273
+ *
274
+ * THE SLOT LIVES ON THE HANDLE, not in a `Map` keyed by it, and that is a measured decision. This
275
+ * function runs once per handle OPERAND — every `setProp` names its node and every append names
276
+ * two, so a thousand-row create mentions handles about forty thousand times — and a `Map<object,
277
+ * number>` charges a hash for each one, plus a second for the `set` on a miss. Two fields on a shape
278
+ * the node already carries turn that into a compare.
279
+ *
280
+ * Measured on `build-release` by `mutation-api-fill-cost.itest.ts`, both arms in one sitting, three
281
+ * runs each: `createRawText` 0.70 -> 0.51 us, `appendChild` 0.65 -> 0.58, `recordSetProp` 0.34 ->
282
+ * 0.30, and the whole `fill` phase of a 10 001-node create 25.0 -> 23.2 ms. `createElement` barely
283
+ * moved (0.72 -> 0.70), which is the control: it does enough else that one lookup is noise in it.
284
+ *
285
+ * WHY AN EPOCH RATHER THAN CLEARING: a slot is meaningless outside its batch (see `IMutationBatch`),
286
+ * so every slot must die when the batch drains. Walking the handles to reset them would cost exactly
287
+ * what the `Map.clear` cost; bumping one counter invalidates all of them at once, and a handle that
288
+ * is never mentioned again is never touched.
268
289
  */
269
290
  function slotOf(handle) {
270
291
  // The retained tree used to ABSORB a handle that was not a node: `children.indexOf(x)` returned
@@ -283,12 +304,12 @@ function slotOf(handle) {
283
304
  `passing a framework sentinel straight through — an absent insert anchor is spelled by ` +
284
305
  `calling appendChild.`);
285
306
  }
286
- const existing = slots.get(handle);
287
- if (existing !== undefined)
288
- return existing;
307
+ if (handle.slotBatch === batchId)
308
+ return handle.slot;
289
309
  handles.push(handle);
290
- slots.set(handle, handles.length - 1);
291
- return handles.length - 1;
310
+ handle.slot = handles.length - 1;
311
+ handle.slotBatch = batchId;
312
+ return handle.slot;
292
313
  }
293
314
  // Has anything changed the TREE since the last commit drained?
294
315
  //
@@ -479,7 +500,8 @@ export function takeBatch() {
479
500
  // held ten thousand handles on a benchmark create costs more than dropping it.
480
501
  placementPending = new Set();
481
502
  stringIds.clear();
482
- slots.clear();
503
+ // Every slot standing on a handle dies here, without touching one of them — see `slotOf`.
504
+ batchId += 1;
483
505
  valueIds.clear();
484
506
  trueId = NOT_INTERNED;
485
507
  falseId = NOT_INTERNED;
@@ -64,11 +64,14 @@ export type INativeEngineBindings = {
64
64
  getViewName: (handle: object) => string;
65
65
  parentOf: (handle: object) => object | undefined;
66
66
  childrenOf: (handle: object) => readonly object[];
67
- /** One entry, not the whole list — see `ITreeHost` for the quadratic it replaces. */
67
+ /** One entry, not the whole list — see `ITreeHost` for the quadratic each of these replaces. */
68
+ firstChildOf: (handle: object) => object | undefined;
68
69
  nextSiblingOf: (handle: object) => object | undefined;
69
70
  /** The batched twins of `parentOf` / `childrenOf`. See `ITreeHost` for why the sweep needs them. */
70
71
  parentsOf: (handles: readonly object[]) => readonly (object | undefined)[];
71
72
  subtreesOf: (roots: readonly object[]) => readonly object[];
73
+ /** The same walk narrowed to what a teardown visits. See `ITreeHost.teardownSubtreesOf`. */
74
+ teardownSubtreesOf: (roots: readonly object[]) => readonly object[];
72
75
  /** The upward twin, deepest first — one crossing for a chain the event path walks per event. */
73
76
  ancestorsOf: (handle: object) => readonly object[];
74
77
  committedRecordOf: (handle: object) => ICommittedRecord | undefined;
@@ -84,12 +84,16 @@ function isBindings(value) {
84
84
  return false;
85
85
  if (typeof value.childrenOf !== 'function')
86
86
  return false;
87
+ if (typeof value.firstChildOf !== 'function')
88
+ return false;
87
89
  if (typeof value.nextSiblingOf !== 'function')
88
90
  return false;
89
91
  if (typeof value.parentsOf !== 'function')
90
92
  return false;
91
93
  if (typeof value.subtreesOf !== 'function')
92
94
  return false;
95
+ if (typeof value.teardownSubtreesOf !== 'function')
96
+ return false;
93
97
  if (typeof value.ancestorsOf !== 'function')
94
98
  return false;
95
99
  if (typeof value.committedRecordOf !== 'function')
@@ -31,9 +31,11 @@ export function nativeTreeHost(bindings) {
31
31
  committedPayloadOf: bindings.committedPayloadOf,
32
32
  parentOf: bindings.parentOf,
33
33
  childrenOf: bindings.childrenOf,
34
+ firstChildOf: bindings.firstChildOf,
34
35
  nextSiblingOf: bindings.nextSiblingOf,
35
36
  parentsOf: bindings.parentsOf,
36
37
  subtreesOf: bindings.subtreesOf,
38
+ teardownSubtreesOf: bindings.teardownSubtreesOf,
37
39
  ancestorsOf: bindings.ancestorsOf,
38
40
  census: () => EMPTY_CENSUS,
39
41
  // Straight through: native already takes the placeholder, which is what `committedRecordOf`
package/build/node.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { IMeasureOnSuccess, IMeasureInWindowOnSuccess, IMeasureLayoutOnSuccess } from './fabric';
2
2
  import { type IClassNameValue } from './style-registry';
3
- import { type IPayloadFold } from './host-behavior';
3
+ import { type IHostBehavior, type IPayloadFold } from './host-behavior';
4
4
  import { type ITreeCensus } from './tree-host';
5
5
  declare const BRAND: unique symbol;
6
6
  export declare const RAW_TEXT_COMPONENT = "RCTRawText";
@@ -46,10 +46,32 @@ export interface ISymbioteNode {
46
46
  resolvesImageSources: boolean;
47
47
  nativeIdWinsOverId: boolean;
48
48
  payloadFold: IPayloadFold | undefined;
49
+ /**
50
+ * The behavior that attached to this node, or `undefined` for the vast majority that have none.
51
+ *
52
+ * A field for the same reason `payloadFold` above it is one, and set on the same line: every
53
+ * reader is a per-node path at list scale — the teardown sweep touches every node of a removed
54
+ * subtree, and `routeProp`'s slot/owned-listener questions run per prop write. A `WeakMap` probe
55
+ * is the dearest way to ask a question whose answer is almost always "none".
56
+ *
57
+ * Owned by `host-behavior.ts`; `attachHostBehavior` is the only writer.
58
+ */
59
+ hostBehavior: IHostBehavior | undefined;
49
60
  styleParts: IClassStyleParts | undefined;
50
61
  childHost: ISymbioteNode | undefined;
51
62
  wrapper: ISymbioteNode | undefined;
52
63
  mayHaveChildren: boolean;
64
+ /**
65
+ * Whether the teardown sweep has released this node and not seen it come back.
66
+ *
67
+ * Owned by `host-behavior.ts` — see `detachOne` / `reattachHostBehaviors`. A field rather than
68
+ * the `WeakSet` it was, because the sweep reads and writes it for EVERY node of a removed
69
+ * subtree (ten thousand on a thousand-row clear) and every insert reads it to decide whether to
70
+ * walk at all, which is the ~9 000-call path of a create.
71
+ */
72
+ isTornDown: boolean;
73
+ slot: number;
74
+ slotBatch: number;
53
75
  measure(callback: IMeasureOnSuccess): void;
54
76
  measureInWindow(callback: IMeasureInWindowOnSuccess): void;
55
77
  measureLayout(relativeToNativeNode: ISymbioteNode | number, onSuccess: IMeasureLayoutOnSuccess, onFail?: () => void): void;
package/build/node.js CHANGED
@@ -84,6 +84,9 @@ class SymbioteNode {
84
84
  // Assigned here for the same hidden-class reason as `hasAriaAlias` above; `attachHostBehavior`
85
85
  // overwrites it a few lines later for the rare node that has a behavior.
86
86
  this.payloadFold = undefined;
87
+ // Same reason again, and the same writer: `attachHostBehavior` fills it for the rare node that
88
+ // gets a behavior at all.
89
+ this.hostBehavior = undefined;
87
90
  // Same reason again, and here it is load-bearing rather than tidy: the redirect below is read
88
91
  // on every append, so the slot must be a stable slot on one hidden class, not a property added
89
92
  // to a few nodes after the fact.
@@ -93,6 +96,16 @@ class SymbioteNode {
93
96
  // guards is read on every `childrenOf`, so it must be a stable slot rather than a property that
94
97
  // appears on some nodes later.
95
98
  this.mayHaveChildren = false;
99
+ // Same hidden-class reason again, and the same measured one: every insert reads it and the
100
+ // teardown sweep writes it per node.
101
+ this.isTornDown = false;
102
+ // Same hidden-class reason as every field above, and the most load-bearing of them: `slotOf`
103
+ // reads this pair on EVERY handle operand of every op — about two hundred thousand times on a
104
+ // thousand-row create — so it has to be a stable slot on one shape. `slotBatch` starts at a
105
+ // value no batch ever carries, which is what makes an untouched node read as "not in this
106
+ // batch" without a separate flag.
107
+ this.slot = 0;
108
+ this.slotBatch = 0;
96
109
  }
97
110
  measure(callback) {
98
111
  engineMeasure(this, callback);
@@ -1,5 +1,5 @@
1
1
  import type { IMeasureInWindowOnSuccess, IMeasureLayoutOnSuccess, IMeasureOnSuccess, IRootTag } from './fabric';
2
- import { type IMutationBatch } from './mutation-buffer';
2
+ import { type IMutationBatch, type IMutationHandle } from './mutation-buffer';
3
3
  /** What Fabric currently holds for one node — the three fields every imperative call is aimed at. */
4
4
  export type ICommittedRecord = {
5
5
  /**
@@ -67,10 +67,25 @@ export type ITreeHost = {
67
67
  committedPayloadOf: (handle: object) => Readonly<Record<string, unknown>> | undefined;
68
68
  parentOf: (handle: object) => object | undefined;
69
69
  childrenOf: (handle: object) => readonly object[];
70
+ firstChildOf: (handle: object) => object | undefined;
70
71
  nextSiblingOf: (handle: object) => object | undefined;
71
72
  parentsOf: (handles: readonly object[]) => readonly (object | undefined)[];
72
73
  /** Each root and every descendant, PRE-ORDER, concatenated in root order. */
73
74
  subtreesOf: (roots: readonly object[]) => readonly object[];
75
+ /**
76
+ * The same walk, narrowed to the nodes a TEARDOWN has work for — each root, every node carrying
77
+ * an intrinsic tag, and every node between the two.
78
+ *
79
+ * It exists because the sweep's whole cost is how WIDE this walk is: a thousand-row clear crossed
80
+ * ten thousand handles to release a thousand machines, and eight in ten of those nodes were plain
81
+ * views the sweep marked and did nothing else with. An ancestor of a tagged node has to come back
82
+ * too, or a framework that returns an interior node on its own would never re-arm what hangs
83
+ * beneath it (`host-behavior.test.ts` guards exactly that shape).
84
+ *
85
+ * Not a replacement for `subtreesOf`: an animated binding is per node and carries no tag, so
86
+ * `host-access.ts` asks for the full walk whenever one exists.
87
+ */
88
+ teardownSubtreesOf: (roots: readonly object[]) => readonly object[];
74
89
  /**
75
90
  * The node itself and every ancestor above it, DEEPEST FIRST.
76
91
  *
@@ -129,7 +144,7 @@ export declare function flushOps(): void;
129
144
  * A single-surface app — every example, and the overwhelming case — passes an empty list and the
130
145
  * behaviour is byte-identical to naming only itself.
131
146
  */
132
- export declare function commitSurfaceOps(rootTag: IRootTag | undefined, surface: object, others?: readonly (readonly [IRootTag, object])[]): void;
147
+ export declare function commitSurfaceOps(rootTag: IRootTag | undefined, surface: IMutationHandle, others?: readonly (readonly [IRootTag, IMutationHandle])[]): void;
133
148
  export interface ICommitProfile {
134
149
  commits: number;
135
150
  propWrites: number;
@@ -206,9 +206,11 @@ void installBindings(jsi::Runtime &runtime) {
206
206
  install("getViewName", 1, &Tree::getViewName);
207
207
  install("parentOf", 1, &Tree::parentOf);
208
208
  install("childrenOf", 1, &Tree::childrenOf);
209
+ install("firstChildOf", 1, &Tree::firstChildOf);
209
210
  install("nextSiblingOf", 1, &Tree::nextSiblingOf);
210
211
  install("parentsOf", 1, &Tree::parentsOf);
211
212
  install("subtreesOf", 1, &Tree::subtreesOf);
213
+ install("teardownSubtreesOf", 1, &Tree::teardownSubtreesOf);
212
214
  install("ancestorsOf", 1, &Tree::ancestorsOf);
213
215
  install("committedRecordOf", 1, &Tree::committedRecordOf);
214
216
  // A TEST read, and it is on this list rather than behind a build flag because the bag it returns
@@ -395,6 +395,18 @@ void holdHandle(jsi::Runtime &runtime, Node &node) {
395
395
  if (live.isObject()) node.attachedHandle.emplace(live.getObject(runtime));
396
396
  }
397
397
 
398
+ /**
399
+ * The UIManager for this runtime.
400
+ *
401
+ * NOT cached, and the failed attempt is worth recording: keying a cache on `&runtime` treats an
402
+ * ADDRESS as a lifetime, and an allocator reuses addresses. `symbiote_tree_tests` builds and tears
403
+ * down a JSCRuntime per case, so the second one can land where the first was and inherit a dangling
404
+ * binding — a crash, not a wrong number. The same mistake with three interned `PropNameID`s aborted
405
+ * that suite outright (`~JSCRuntime`: "destroyed with a dangling API string").
406
+ *
407
+ * It bought nothing anyway: both caches together moved an empty drain 4.46 -> 4.38 us, against the
408
+ * 4.38 -> 1.54 that dropping the checked JSI casts gave.
409
+ */
398
410
  react::UIManager &uiManagerFor(jsi::Runtime &runtime, const char *what) {
399
411
  auto binding = react::UIManagerBinding::getBinding(runtime);
400
412
  if (binding == nullptr) {
@@ -411,10 +423,14 @@ react::UIManager &uiManagerFor(jsi::Runtime &runtime, const char *what) {
411
423
  * `ArrayBuffer::data` hands back the backing store, so the commands never become JS values — which
412
424
  * is the entire reason the format is flat. `byteOffset` is read rather than assumed: a typed array
413
425
  * need not start at the head of its buffer.
426
+ *
427
+ * The three names are built from UTF-8 on every call and that stands: interning them in a
428
+ * file-scope cache is what aborted `symbiote_tree_tests`, because a `PropNameID` outliving its
429
+ * runtime is a dangling API string. See `uiManagerFor` above for the general form and the price.
414
430
  */
415
431
  const int32_t *int32ArrayData(jsi::Runtime &runtime, const jsi::Value &value, size_t &lengthOut) {
416
432
  auto typedArray = value.asObject(runtime);
417
- auto buffer = typedArray.getPropertyAsObject(runtime, "buffer").getArrayBuffer(runtime);
433
+ auto buffer = typedArray.getProperty(runtime, "buffer").asObject(runtime).getArrayBuffer(runtime);
418
434
  auto byteOffset = static_cast<size_t>(typedArray.getProperty(runtime, "byteOffset").asNumber());
419
435
  lengthOut = static_cast<size_t>(typedArray.getProperty(runtime, "length").asNumber());
420
436
  return reinterpret_cast<const int32_t *>(buffer.data(runtime) + byteOffset);
@@ -1490,10 +1506,27 @@ jsi::Value Tree::applyOps(jsi::Runtime &runtime, const jsi::Value *arguments, si
1490
1506
 
1491
1507
  size_t opsLength = 0;
1492
1508
  const int32_t *ops = int32ArrayData(runtime, arguments[0], opsLength);
1493
- auto strings = arguments[1].asObject(runtime).asArray(runtime);
1494
- auto values = arguments[2].asObject(runtime).asArray(runtime);
1495
- auto instanceHandles = arguments[3].asObject(runtime).asArray(runtime);
1496
- auto handles = arguments[4].asObject(runtime).asArray(runtime);
1509
+ // ── THE PROLOGUE WAS THE COST, AND THIS LINE WAS THE PROLOGUE ──────────────────────────────────
1510
+ //
1511
+ // `getObject`/`getArray` rather than `asObject`/`asArray`. The checking pair runs an `isObject`
1512
+ // and an `isArray` per table — eight JSI round trips for four arguments — and they were 2.8 us of
1513
+ // a 4.4 us fixed entry cost. Measured on an EMPTY batch against a 0.13 us bare host call
1514
+ // (`small-batch-crossing-cost.itest.ts`): prologue 4.38 -> 1.54 us, and a whole small drain
1515
+ // 5.54 -> 2.76 us.
1516
+ //
1517
+ // WHY IT IS SAFE TO DROP THEM, and it is the harness's own split rather than a shrug: `takeBatch`
1518
+ // is the only producer on this wire and always hands over four arrays, and `jsi::Value::getObject`
1519
+ // / `Object::getArray` carry `assert`s that are LIVE in the correctness build — `core/engine/cpp/
1520
+ // tests/build` is Debug with `NDEBUG` off, which is the whole reason it exists. So a fixture that
1521
+ // hand-builds a malformed batch aborts there and the build that ships pays nothing for the check.
1522
+ //
1523
+ // WHAT IT COSTS ANYONE: a framework that navigates between mutations pays this entry per
1524
+ // mutation, not per commit. Solid's `cleanChildren` enters `applyOps` 2 000 times to clear a
1525
+ // thousand rows.
1526
+ auto strings = arguments[1].getObject(runtime).getArray(runtime);
1527
+ auto values = arguments[2].getObject(runtime).getArray(runtime);
1528
+ auto instanceHandles = arguments[3].getObject(runtime).getArray(runtime);
1529
+ auto handles = arguments[4].getObject(runtime).getArray(runtime);
1497
1530
  const size_t slotCount = handles.size(runtime);
1498
1531
 
1499
1532
  // Slot -> node, resolved at most ONCE per batch and usually not at all: a slot this batch creates
@@ -2028,6 +2061,34 @@ jsi::Value Tree::nextSiblingOf(jsi::Runtime &runtime, const jsi::Value *argument
2028
2061
  return handleOf(runtime, **std::next(at));
2029
2062
  }
2030
2063
 
2064
+ /**
2065
+ * The first live child, anchors included.
2066
+ *
2067
+ * WHY IT IS ITS OWN CALL, and it is `nextSiblingOf`'s argument one door along. The JS spelling was
2068
+ * `childrenOf(node)[0]`, and `solid-js/universal`'s `cleanChildren` empties a parent with
2069
+ * `while (removed = getFirstChild(parent)) removeNode(parent, removed)` — so a list of N children
2070
+ * was read N times, each read building and discarding the whole remaining list. Measured on a
2071
+ * 2 000-row `Clear` before this existed: **2 001 001 handles** crossed the boundary to remove two
2072
+ * thousand children, which is N(N+1)/2 to the unit, and the step cost 435 ms against stock's 14.
2073
+ *
2074
+ * SKIPS A DEAD HANDLE rather than answering `undefined` on one, exactly as `childrenOf` does. The
2075
+ * two must agree element for element or a caller that switches between them sees a different tree —
2076
+ * and `cleanChildren`'s loop terminates on `undefined`, so answering it early would orphan every
2077
+ * child behind the dead one.
2078
+ */
2079
+ jsi::Value Tree::firstChildOf(jsi::Runtime &runtime, const jsi::Value *arguments, size_t count) {
2080
+ if (count < 1) {
2081
+ throw jsi::JSError(runtime, "symbiote engine: expected firstChildOf(handle)");
2082
+ }
2083
+ const auto node = nodeFrom(runtime, arguments[0].asObject(runtime), "firstChildOf");
2084
+ compactChildren(*node);
2085
+ for (const auto &child : node->children) {
2086
+ auto handle = handleOf(runtime, *child);
2087
+ if (!handle.isUndefined()) return handle;
2088
+ }
2089
+ return jsi::Value::undefined();
2090
+ }
2091
+
2031
2092
  jsi::Value Tree::childrenOf(jsi::Runtime &runtime, const jsi::Value *arguments, size_t count) {
2032
2093
  if (count < 1) {
2033
2094
  throw jsi::JSError(runtime, "symbiote engine: expected childrenOf(handle)");
@@ -2070,6 +2131,42 @@ void collectSubtree(jsi::Runtime &runtime, const NodePtr &node, std::vector<jsi:
2070
2131
  for (const auto &child : node->children) collectSubtree(runtime, child, into);
2071
2132
  }
2072
2133
 
2134
+ // The same walk, narrowed to the nodes a TEARDOWN has anything to do with — and it is the whole of
2135
+ // what the sweep costs, because it is the walk that decides how many handles cross.
2136
+ //
2137
+ // A node earns its place three ways, and the third is the one that keeps the narrowing honest:
2138
+ //
2139
+ // it is a ROOT the sweep was handed it, and marking it is what makes a re-insert walk
2140
+ // it carries a TAG `kOpSetTag` arrives from `attachHostBehavior` and from nowhere else, so
2141
+ // a non-empty `tagName` is exactly "a behavior attached to this node"
2142
+ // something under it an ANCESTOR of a tagged node, because the framework may bring back an
2143
+ // interior node on its own and its insert has to walk
2144
+ //
2145
+ // What drops out is a node with no behavior and none beneath it, which on the benchmark row is
2146
+ // eight of every ten: the sweep would mark it, call `onDetached` on it, and change nothing.
2147
+ //
2148
+ // Returns whether this node earned its place, which is how its parent learns it has to keep its
2149
+ // own. Pre-order is preserved by reserving the slot BEFORE recursing and dropping it afterwards —
2150
+ // a node that turns out uninteresting resizes away, and by then every uninteresting descendant has
2151
+ // already resized itself away, so nothing interesting is ever discarded with it.
2152
+ bool collectTeardownSubtree(
2153
+ jsi::Runtime &runtime,
2154
+ const NodePtr &node,
2155
+ std::vector<jsi::Value> &into,
2156
+ bool isRoot) {
2157
+ auto handle = handleOf(runtime, *node);
2158
+ if (handle.isUndefined()) return false;
2159
+ const size_t reserved = into.size();
2160
+ into.push_back(std::move(handle));
2161
+ bool isWanted = isRoot || !node->tagName.empty();
2162
+ compactChildren(*node);
2163
+ for (const auto &child : node->children) {
2164
+ if (collectTeardownSubtree(runtime, child, into, false)) isWanted = true;
2165
+ }
2166
+ if (!isWanted) into.resize(reserved);
2167
+ return isWanted;
2168
+ }
2169
+
2073
2170
  jsi::Value Tree::ancestorsOf(jsi::Runtime &runtime, const jsi::Value *arguments, size_t count) {
2074
2171
  if (count < 1) {
2075
2172
  throw jsi::JSError(runtime, "symbiote engine: expected ancestorsOf(handle)");
@@ -2118,9 +2215,15 @@ jsi::Value Tree::parentsOf(jsi::Runtime &runtime, const jsi::Value *arguments, s
2118
2215
  return out;
2119
2216
  }
2120
2217
 
2121
- jsi::Value Tree::subtreesOf(jsi::Runtime &runtime, const jsi::Value *arguments, size_t count) {
2218
+ // Shared by `subtreesOf` and `teardownSubtreesOf`, which differ only in which nodes the walk keeps.
2219
+ jsi::Value Tree::collectRoots(
2220
+ jsi::Runtime &runtime,
2221
+ const jsi::Value *arguments,
2222
+ size_t count,
2223
+ const char *what,
2224
+ bool narrowToTeardown) {
2122
2225
  if (count < 1) {
2123
- throw jsi::JSError(runtime, "symbiote engine: expected subtreesOf(roots)");
2226
+ throw jsi::JSError(runtime, std::string("symbiote engine: expected ") + what + "(roots)");
2124
2227
  }
2125
2228
  const auto readStartedAt = ISteadyClock::now();
2126
2229
  auto roots = arguments[0].asObject(runtime).asArray(runtime);
@@ -2134,8 +2237,9 @@ jsi::Value Tree::subtreesOf(jsi::Runtime &runtime, const jsi::Value *arguments,
2134
2237
  std::vector<jsi::Value> flat;
2135
2238
  for (size_t at = 0; at < length; at += 1) {
2136
2239
  const auto root =
2137
- nodeFrom(runtime, roots.getValueAtIndex(runtime, at).asObject(runtime), "subtreesOf");
2138
- collectSubtree(runtime, root, flat);
2240
+ nodeFrom(runtime, roots.getValueAtIndex(runtime, at).asObject(runtime), what);
2241
+ if (narrowToTeardown) collectTeardownSubtree(runtime, root, flat, true);
2242
+ else collectSubtree(runtime, root, flat);
2139
2243
  }
2140
2244
 
2141
2245
  auto out = jsi::Array(runtime, flat.size());
@@ -2147,6 +2251,17 @@ jsi::Value Tree::subtreesOf(jsi::Runtime &runtime, const jsi::Value *arguments,
2147
2251
  return out;
2148
2252
  }
2149
2253
 
2254
+ jsi::Value Tree::subtreesOf(jsi::Runtime &runtime, const jsi::Value *arguments, size_t count) {
2255
+ return collectRoots(runtime, arguments, count, "subtreesOf", false);
2256
+ }
2257
+
2258
+ jsi::Value Tree::teardownSubtreesOf(
2259
+ jsi::Runtime &runtime,
2260
+ const jsi::Value *arguments,
2261
+ size_t count) {
2262
+ return collectRoots(runtime, arguments, count, "teardownSubtreesOf", true);
2263
+ }
2264
+
2150
2265
  jsi::Value Tree::committedRecordOf(
2151
2266
  jsi::Runtime &runtime,
2152
2267
  const jsi::Value *arguments,
@@ -126,6 +126,18 @@ class Tree {
126
126
  facebook::jsi::Runtime &runtime,
127
127
  const facebook::jsi::Value *arguments,
128
128
  size_t count);
129
+ /**
130
+ * `firstChildOf(handle)` — the first entry of the child list, anchors included.
131
+ *
132
+ * Its own call for the same reason `nextSiblingOf` is, and it is the same bug one door along: the
133
+ * JS spelling was `childrenOf(node)[0]`, so emptying a list one child at a time read the whole
134
+ * remaining list on every step. Measured on a 2 000-row Solid `Clear`: 2 001 001 handles crossed
135
+ * to remove two thousand children, N(N+1)/2 exactly, against the ~2 000 the work needs.
136
+ */
137
+ facebook::jsi::Value firstChildOf(
138
+ facebook::jsi::Runtime &runtime,
139
+ const facebook::jsi::Value *arguments,
140
+ size_t count);
129
141
  /** `childrenOf(handle)` — in order, ANCHORS INCLUDED. */
130
142
  facebook::jsi::Value childrenOf(
131
143
  facebook::jsi::Runtime &runtime,
@@ -151,6 +163,22 @@ class Tree {
151
163
  facebook::jsi::Runtime &runtime,
152
164
  const facebook::jsi::Value *arguments,
153
165
  size_t count);
166
+ /**
167
+ * `teardownSubtreesOf(roots)` — `subtreesOf` narrowed to the nodes a teardown has work for.
168
+ *
169
+ * The sweep's cost IS this walk's width: it crosses a handle per node to release the few that
170
+ * carry a machine, and on the benchmark row that is one node in ten. What comes back is each
171
+ * root, every node carrying an intrinsic TAG (which `kOpSetTag` sets from `attachHostBehavior`
172
+ * and nowhere else), and every node BETWEEN the two — an ancestor has to be marked or the
173
+ * framework bringing it back alone would never re-arm what hangs beneath it.
174
+ *
175
+ * NOT a substitute when an app animates: `detachAnimatedProps` is per node and knows nothing
176
+ * about tags, so `host-access.ts` asks for the full walk whenever a binding exists.
177
+ */
178
+ facebook::jsi::Value teardownSubtreesOf(
179
+ facebook::jsi::Runtime &runtime,
180
+ const facebook::jsi::Value *arguments,
181
+ size_t count);
154
182
  /**
155
183
  * `ancestorsOf(handle)` — the node and every ancestor above it, DEEPEST FIRST, in one crossing.
156
184
  *
@@ -252,6 +280,15 @@ class Tree {
252
280
  facebook::jsi::Runtime &runtime,
253
281
  const facebook::jsi::Value *arguments,
254
282
  size_t count);
283
+
284
+ private:
285
+ /** The body `subtreesOf` and `teardownSubtreesOf` share; `narrowToTeardown` picks the walk. */
286
+ facebook::jsi::Value collectRoots(
287
+ facebook::jsi::Runtime &runtime,
288
+ const facebook::jsi::Value *arguments,
289
+ size_t count,
290
+ const char *what,
291
+ bool narrowToTeardown);
255
292
  };
256
293
 
257
294
  } // namespace symbiote
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@symbiote-native/engine",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "SymbioteNative's retained shadow-tree engine — clone-on-write commit path + event normalization over React Native Fabric, shared by every framework adapter.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -71,7 +71,7 @@
71
71
  },
72
72
  "peerDependenciesMeta": {},
73
73
  "devDependencies": {
74
- "@symbiote-native/test-utils": "0.3.1"
74
+ "@symbiote-native/test-utils": "0.4.0"
75
75
  },
76
76
  "scripts": {
77
77
  "typecheck": "tsc --build",