@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.
- package/build/host-access.d.ts +21 -1
- package/build/host-access.js +37 -2
- package/build/host-behavior.d.ts +2 -2
- package/build/host-behavior.js +51 -32
- package/build/mutation-buffer.d.ts +33 -17
- package/build/mutation-buffer.js +29 -7
- package/build/native-engine.d.ts +4 -1
- package/build/native-engine.js +4 -0
- package/build/native-tree-host.js +2 -0
- package/build/node.d.ts +23 -1
- package/build/node.js +13 -0
- package/build/tree-host.d.ts +17 -2
- package/cpp/SymbioteEngineBindings.cpp +2 -0
- package/cpp/SymbioteTree.cpp +124 -9
- package/cpp/SymbioteTree.h +37 -0
- package/package.json +2 -2
package/build/host-access.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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.
|
package/build/host-access.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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.
|
package/build/host-behavior.d.ts
CHANGED
|
@@ -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
|
-
* `
|
|
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 `
|
|
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
|
package/build/host-behavior.js
CHANGED
|
@@ -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,
|
|
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
|
-
//
|
|
37
|
-
// `reattachHostBehaviors` — and
|
|
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
|
-
|
|
40
|
-
//
|
|
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
|
-
* `
|
|
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 `
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
// `
|
|
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:
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
|
|
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 (
|
|
512
|
-
|
|
513
|
-
const behavior =
|
|
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:
|
|
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:
|
|
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:
|
|
160
|
-
export declare function recordCreateElement(handle:
|
|
161
|
-
export declare function recordCreateRawText(handle:
|
|
162
|
-
export declare function recordCreateAnchor(handle:
|
|
163
|
-
export declare function recordCreateVoid(handle:
|
|
164
|
-
export declare function recordAppendChild(parent:
|
|
165
|
-
export declare function recordInsertBefore(parent:
|
|
166
|
-
export declare function recordRemoveChild(parent:
|
|
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:
|
|
169
|
-
export declare function recordSetText(handle:
|
|
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:
|
|
183
|
-
export declare function recordSetTag(handle:
|
|
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:
|
|
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:
|
|
205
|
-
export declare function recordCommit(rootTag: number, surface:
|
|
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
|
/**
|
package/build/mutation-buffer.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
return existing;
|
|
307
|
+
if (handle.slotBatch === batchId)
|
|
308
|
+
return handle.slot;
|
|
289
309
|
handles.push(handle);
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
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;
|
package/build/native-engine.d.ts
CHANGED
|
@@ -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
|
|
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;
|
package/build/native-engine.js
CHANGED
|
@@ -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);
|
package/build/tree-host.d.ts
CHANGED
|
@@ -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:
|
|
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
|
package/cpp/SymbioteTree.cpp
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
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
|
-
|
|
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
|
|
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),
|
|
2138
|
-
|
|
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,
|
package/cpp/SymbioteTree.h
CHANGED
|
@@ -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.
|
|
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.
|
|
74
|
+
"@symbiote-native/test-utils": "0.4.0"
|
|
75
75
|
},
|
|
76
76
|
"scripts": {
|
|
77
77
|
"typecheck": "tsc --build",
|