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