@symbiote-native/engine 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +24 -11
  2. package/build/accessibility-props.d.ts +19 -0
  3. package/build/accessibility-props.js +209 -0
  4. package/build/action-sheet-ios/index.js +4 -1
  5. package/build/animated/animations/composition.js +3 -1
  6. package/build/animated/animations/spring-config.js +4 -1
  7. package/build/animated/animations/spring.js +23 -9
  8. package/build/animated/animations/timing.js +3 -2
  9. package/build/animated/bezier.js +2 -1
  10. package/build/animated/color.js +4 -2
  11. package/build/animated/event.js +7 -3
  12. package/build/animated/graph.js +8 -3
  13. package/build/animated/index.d.ts +2 -2
  14. package/build/animated/index.js +2 -2
  15. package/build/animated/interpolation.js +7 -2
  16. package/build/animated/mock.js +1 -1
  17. package/build/animated/native/native-animated.js +1 -1
  18. package/build/animated/operators.js +27 -6
  19. package/build/animated/props.js +1 -1
  20. package/build/animated/rgba.js +18 -3
  21. package/build/animated/style.js +13 -3
  22. package/build/animated/value-xy.d.ts +8 -5
  23. package/build/animated/value-xy.js +4 -1
  24. package/build/app-registry/index.d.ts +1 -0
  25. package/build/app-registry/index.js +15 -5
  26. package/build/appearance/index.js +4 -1
  27. package/build/commit.d.ts +35 -0
  28. package/build/commit.js +613 -74
  29. package/build/debug.js +10 -4
  30. package/build/events/index.js +142 -88
  31. package/build/fabric-props.js +171 -11
  32. package/build/fabric.d.ts +10 -3
  33. package/build/fabric.js +11 -2
  34. package/build/host-behavior.d.ts +47 -0
  35. package/build/host-behavior.js +262 -0
  36. package/build/host-instance/index.d.ts +2 -10
  37. package/build/host-instance/index.js +13 -46
  38. package/build/image-loader.js +3 -2
  39. package/build/index.d.ts +24 -17
  40. package/build/index.js +27 -13
  41. package/build/interaction-manager/index.js +2 -1
  42. package/build/layout-animation/index.js +12 -4
  43. package/build/native-events.js +2 -1
  44. package/build/native-modules/index.js +3 -1
  45. package/build/node.d.ts +121 -2
  46. package/build/node.js +623 -41
  47. package/build/pan-responder/index.js +20 -9
  48. package/build/permissions-android/index.js +3 -1
  49. package/build/platform/index.android.d.ts +1 -1
  50. package/build/platform/index.android.js +1 -1
  51. package/build/platform/index.ios.d.ts +1 -1
  52. package/build/platform-color/index.js +3 -1
  53. package/build/post-commit.d.ts +1 -0
  54. package/build/post-commit.js +6 -0
  55. package/build/process-background-image/index.js +30 -9
  56. package/build/process-filter.js +11 -4
  57. package/build/registry.js +10 -2
  58. package/build/report-error.js +4 -1
  59. package/build/share/index.android.js +1 -1
  60. package/build/share/index.ios.js +1 -1
  61. package/build/status-bar/index.android.js +3 -4
  62. package/build/status-bar/index.ios.js +1 -1
  63. package/build/style-registry/index.d.ts +11 -2
  64. package/build/style-registry/index.js +223 -169
  65. package/build/style-registry/scope.d.ts +15 -1
  66. package/build/style-registry/scope.js +29 -27
  67. package/build/styles.d.ts +11 -1
  68. package/build/surface.d.ts +1 -1
  69. package/build/surface.js +8 -0
  70. package/build/tags.d.ts +1 -0
  71. package/build/tags.js +31 -1
  72. package/build/touch-history.js +9 -2
  73. package/build/vibration/index.ios.js +1 -1
  74. package/build/view-config.js +20 -2
  75. package/package.json +12 -2
package/build/commit.js CHANGED
@@ -2,8 +2,8 @@
2
2
  // node, you clone it with new props/children and atomically hand a fresh child
3
3
  // set to completeRoot.
4
4
  //
5
- // Incremental strategy: each retained node keeps a "mirror" of what Fabric
6
- // currently holds for it: its handle, the flat props last sent, the child
5
+ // Incremental strategy: each retained node keeps, in its own `committed` field (node.ts), a
6
+ // mirror of what Fabric currently holds for it: its handle, the flat props last sent, the child
7
7
  // identities last committed, and the resolved view name. On commit we walk the
8
8
  // retained tree and only clone the nodes that actually changed; an untouched
9
9
  // sibling subtree is reused by reference. That both skips work and preserves the
@@ -13,18 +13,74 @@
13
13
  // references to specific child handles. That bubble is inherent to a persistent
14
14
  // tree and is exactly what React's own Fabric renderer does.
15
15
  import { getSlot, } from './fabric.js';
16
- import { createElement, debugNodeId, isAnchor, VIRTUAL_TEXT_COMPONENT, } from './node.js';
16
+ import { createElement, debugNodeId, isAnchor, isEmptyRawText, committedOf, markDirty, markPropsDirty, markStructureDirty, takePropStats, VIRTUAL_TEXT_COMPONENT, } from './node.js';
17
17
  import { dlog, isDebug } from './debug.js';
18
18
  import { flattenStyle } from './style/index.js';
19
19
  import { nextTag } from './tags.js';
20
20
  import { registerPostCommit, runPostCommitHooks } from './post-commit.js';
21
21
  import { fabricProps } from './fabric-props.js';
22
22
  import { isRecord } from './type-guards.js';
23
+ import { isAriaAliasKey } from './accessibility-props.js';
24
+ import { runDeferredAttaches, sweepDetachedBehaviors } from './host-behavior.js';
23
25
  // Re-exported from ./platform-color so callers don't need to change their import path.
24
26
  export { processColor, setColorProcessor } from './platform-color/index.js';
25
27
  // Per-commit work counters, surfaced via dlog so a device run can prove the
26
28
  // engine is incremental (created=0 with clones after the first mount).
27
29
  const stats = { created: 0, cloneProps: 0, cloneChildren: 0, reused: 0 };
30
+ // One append vs one clone is a wash by call count and a pure loss by allocation, so a parent with a
31
+ // single child never takes the batched route whatever the switch says.
32
+ const CREATE_BATCH_MIN_CHILDREN = 2;
33
+ let batchCreate = false;
34
+ // Cumulative cost of the reconcile walk. Unlike `stats` (per-commit, zeroed at the top of every
35
+ // commit), this ACCUMULATES: one scroll frame produces a burst of commits and the frame's real cost
36
+ // is only visible as their sum. Read-and-zeroed via readCommitProfile().
37
+ //
38
+ // Deliberately NOT gated behind isDebug(): two performance.now() calls per commit are noise next to
39
+ // the walk they measure, and the number is only meaningful from a RELEASE build - dev-mode JS
40
+ // drowns the signal. The dlog below stays gated as usual.
41
+ // `propsBuilt` / `propsReused` price the props half of the walk, and they exist because that half
42
+ // is INVISIBLE in the output: reusing the mirror's payload by reference and rebuilding a
43
+ // byte-identical one emit the same Fabric calls, so only a counter separates a working fast lane
44
+ // from a silently reverted one. `propsBuilt` counts fabricProps() calls made by the update path
45
+ // (a fresh object plus a recursive propsEqual); `propsReused` counts nodes that re-cloned for a
46
+ // child's sake and carried the committed payload through untouched. On an ordinary update the
47
+ // second should dominate - that ratio IS the clone-bubble.
48
+ //
49
+ // The create path is deliberately not counted in either: it has no committed payload to reuse, so
50
+ // its fabricProps() calls are not a cost any flag could remove and would only dilute the ratio.
51
+ const profile = {
52
+ commits: 0,
53
+ walkMs: 0,
54
+ nodesVisited: 0,
55
+ propsBuilt: 0,
56
+ propsReused: 0,
57
+ };
58
+ // What `renderableChildren` costs, accumulated over the same window as `profile`. Anchors are the
59
+ // only reason that function is not free, and how many a tree carries is a property of the ADAPTER,
60
+ // not of the app: a Vue/React/Svelte/Solid component is a function returning children and allocates
61
+ // no node, while an Angular component is bound to a host ELEMENT and therefore always has one
62
+ // (anchor-host-registry.ts keeps it from painting). So the same screen is a no-anchor tree under
63
+ // four adapters and an anchor-per-composed-component tree under the fifth, and only a counter tells
64
+ // them apart at runtime - a source-level grep for "anchor" measures instrumentation density, not
65
+ // trees.
66
+ //
67
+ // `scans` counts invocations; `probed` counts children actually examined by the fast-path probe,
68
+ // which is why that probe is a counted loop rather than `.some()` (a `.some` short-circuits at the
69
+ // first anchor, so children.length would overstate a defeated scan by however much it skipped).
70
+ // `flattens` is the probe defeated: a fresh array, a second pass, and a recursion into every
71
+ // anchor. `widest` is the widest single flatten in the window, because a defeated scan over 3
72
+ // children is noise and one over 1000 is not - a count alone cannot separate them.
73
+ //
74
+ // Not gated behind isDebug(), for the same reason the rest of the profile is not: integer
75
+ // increments are noise next to the array work they price, and the number is only meaningful from a
76
+ // release build.
77
+ const childScan = {
78
+ scans: 0,
79
+ probed: 0,
80
+ flattens: 0,
81
+ flattenProbed: 0,
82
+ widest: 0,
83
+ };
28
84
  // Diagnostic (gated): Fabric serializes props to folly::dynamic, which rejects a JS
29
85
  // Symbol or function with "JS Symbols are not convertible to dynamic". A hard native
30
86
  // throw deep in cloneNode*. Walk a props payload and return the dotted path of the
@@ -79,7 +135,9 @@ function guardSerializable(propsDiff, viewName, tag) {
79
135
  function viewNameFor(node, hasTextAncestor) {
80
136
  // The only position-dependent name: a <Text> inside another <Text> becomes a
81
137
  // virtual span. Everything else is the component string the adapter chose.
82
- return node.isText && hasTextAncestor ? VIRTUAL_TEXT_COMPONENT : node.component;
138
+ return node.isText && hasTextAncestor
139
+ ? VIRTUAL_TEXT_COMPONENT
140
+ : node.component;
83
141
  }
84
142
  // Fabric's clone*WithNewProps MERGES the raw payload onto the node's existing props,
85
143
  // so the payload must be a MINIMAL diff: only the keys that actually changed, plus any
@@ -128,19 +186,46 @@ function jsonEqual(a, b) {
128
186
  return false;
129
187
  return keys.every(key => key in b && jsonEqual(a[key], b[key]));
130
188
  }
131
- const mirror = new WeakMap();
189
+ function isSkippedAtCommit(node) {
190
+ return isAnchor(node) || isEmptyRawText(node);
191
+ }
132
192
  function renderableChildren(node) {
133
193
  // Anchor nodes (Vue fragment/v-if/v-for placeholders, Angular component hosts that should
134
194
  // not paint) live in the retained tree for sibling ordering but never become Fabric views.
135
195
  // When an anchor owns children, flatten them into the parent's renderable list: this lets a
136
196
  // DOM-less framework use an anchor as a fragment/component host without adding a native
137
197
  // wrapper node. Fast path: no anchors reuses the array, so the common case allocates nothing.
138
- if (!node.children.some(isAnchor))
198
+ // An empty raw text is skipped for a different reason and with no flattening: it has no
199
+ // children, and committing it aborts the app inside Fabric's text walk (isEmptyRawText).
200
+ //
201
+ // The probe is a counted loop rather than `.some()` only so childScan can price it honestly;
202
+ // see the childScan declaration for what the five counters mean and what they were built to
203
+ // settle.
204
+ childScan.scans += 1;
205
+ const total = node.children.length;
206
+ let index = 0;
207
+ while (index < total && !isSkippedAtCommit(node.children[index]))
208
+ index += 1;
209
+ // The defeating child was examined too, so it counts.
210
+ childScan.probed += index === total ? total : index + 1;
211
+ if (index === total)
139
212
  return node.children;
213
+ childScan.flattens += 1;
214
+ childScan.flattenProbed += total;
215
+ if (total > childScan.widest)
216
+ childScan.widest = total;
140
217
  const children = [];
141
218
  for (const child of node.children) {
142
- if (isAnchor(child))
143
- children.push(...renderableChildren(child));
219
+ if (isSkippedAtCommit(child)) {
220
+ // A skipped child is flattened away here and never reaches reconcile, so this is the only
221
+ // place that can clear its dirty flag. Leaving it set would be a silent stale-UI bug:
222
+ // markDirty stops at the first dirty ancestor, so a permanently-dirty skipped node swallows
223
+ // every later mark - from an anchor's subtree, or from the setText that turns an empty raw
224
+ // text back into real content - and the real parent never learns anything changed.
225
+ child.dirty = false;
226
+ if (isAnchor(child))
227
+ children.push(...renderableChildren(child));
228
+ }
144
229
  else
145
230
  children.push(child);
146
231
  }
@@ -157,20 +242,67 @@ function childrenIdentical(kids, committed) {
157
242
  // committed tag/view-name is resolved. A `MULTI!!` line names the exact extra
158
243
  // node (tag + view-name) that pushed the scroll view past one child.
159
244
  function logScrollChildren(node, viewName, selfTag) {
245
+ // The whole function is a dlog, and it is called on every reconciled node, so the cheap boolean
246
+ // goes first: with logging off this is one property read instead of two string scans per node.
247
+ if (!isDebug())
248
+ return;
160
249
  if (!viewName.includes('Scroll') || viewName.includes('Content'))
161
250
  return;
162
251
  const kids = node.children.map(child => {
163
- const committed = mirror.get(child);
252
+ const committed = committedOf(child);
164
253
  return `${committed?.viewName ?? child.component}#${committed?.tag ?? 'NEW'}`;
165
254
  });
166
255
  const flag = kids.length === 1 ? 'OK' : 'MULTI!!';
167
256
  dlog(`SCROLL-${flag} ${viewName} tag=${selfTag} children(${kids.length})=[${kids.join(',')}]`);
168
257
  }
258
+ // The failure mode dirty-marking introduces: a mutation path that forgets to markDirty leaves a
259
+ // node whose desired props have drifted from what Fabric holds, and the screen silently keeps
260
+ // showing the old value - no error, no crash, nothing to grep for. So under DEBUG pay back the full
261
+ // price just saved and verify the skip was honest, naming the node loudly enough to find the
262
+ // missing mark.
263
+ //
264
+ // Two lanes reach it, and naming which one fired matters when reading a log: `subtree` is the whole
265
+ // node skipped because nothing under it was marked, `props` is the node re-cloned for a child's
266
+ // sake but its own payload reused because no prop write was recorded on it. They point at
267
+ // different missing marks - markDirty vs markPropsDirty - so the line says which.
268
+ function warnIfStale(node, committed, lane) {
269
+ if (!isDebug())
270
+ return;
271
+ const fresh = fabricProps(node);
272
+ if (propsEqual(committed.props, fresh))
273
+ return;
274
+ dlog(`DIRTY-MISS(${lane}) ${committed.viewName}#${committed.tag} node=${debugNodeId(node)} ` +
275
+ `treated as clean but props differ: committed=${JSON.stringify(committed.props)} ` +
276
+ `desired=${JSON.stringify(fresh)}`);
277
+ }
169
278
  function reconcile(slot, node, rootTag, hasTextAncestor, renderableParent, forceFreshFamily) {
279
+ profile.nodesVisited += 1;
170
280
  const viewName = viewNameFor(node, hasTextAncestor);
171
- const props = fabricProps(node);
281
+ const committed = committedOf(node);
282
+ // Nothing under here changed: hand back the committed handle without rebuilding this node's
283
+ // Fabric props or descending into it at all. The walk below costs ~13 us/node on device, and an
284
+ // untouched sibling subtree would pay it on every commit. `committed.parent` is re-checked rather
285
+ // than trusted to the flag because a MOVED node can still be legitimately clean (see node.ts's
286
+ // structural ops), and a reparent must fall through to the fresh-family path below.
287
+ if (!forceFreshFamily &&
288
+ !node.dirty &&
289
+ committed !== undefined &&
290
+ committed.parent === renderableParent &&
291
+ committed.viewName === viewName) {
292
+ stats.reused += 1;
293
+ warnIfStale(node, committed, 'subtree');
294
+ return { handle: committed.handle, changed: false };
295
+ }
296
+ node.dirty = false;
297
+ // Read before clearing. The create path below ignores it - a node Fabric has never seen needs its
298
+ // whole payload built regardless - so this only ever gates the update path.
299
+ const ownPropsChanged = node.propsDirty;
300
+ node.propsDirty = false;
301
+ // Cleared here because this call is what re-snapshots `committed.children` below, on both the
302
+ // create and the update path. Anything that reads that snapshot afterwards is reading a current
303
+ // one until the next structural op raises the flag again.
304
+ node.structureDirty = false;
172
305
  const childInText = node.isText || hasTextAncestor;
173
- const committed = mirror.get(node);
174
306
  const kids = renderableChildren(node);
175
307
  // First mount, or the view kind flipped (RCTText <-> RCTVirtualText when a
176
308
  // <Text> moves in or out of another <Text>): a different native component
@@ -181,29 +313,56 @@ function reconcile(slot, node, rootTag, hasTextAncestor, renderableParent, force
181
313
  committed.viewName !== viewName ||
182
314
  parentChanged) {
183
315
  stats.created += 1;
316
+ // Full payload: there is no committed props object to reuse, whatever propsDirty said.
317
+ const props = fabricProps(node);
184
318
  const tag = nextTag();
185
- const reason = committed === undefined
186
- ? 'mount'
187
- : forceFreshFamily
188
- ? 'fresh-parent'
189
- : committed.viewName !== viewName
190
- ? 'view-kind'
191
- : 'reparent';
192
- dlog(`commit root=${rootTag} createNode tag=${tag} view=${viewName} reason=${reason}`);
193
- if (viewName === 'RCTView' || viewName === 'RCTText') {
194
- dlog(`commit root=${rootTag} colorProbe tag=${tag} view=${viewName} ` +
195
- `bg=${JSON.stringify(props.backgroundColor)} color=${JSON.stringify(props.color)} ` +
196
- `opacity=${JSON.stringify(props.opacity)}`);
319
+ // One gate for the whole diagnostic block, not three dlog calls. This runs once per CREATED
320
+ // node - 9 000 of them on one benchmark press - and dlog cannot help here: its argument is
321
+ // built at the CALL SITE (see debug.ts), so an eager template pays in full with logging off,
322
+ // and a thunk trades that for a closure allocation per node. A plain `if` costs neither.
323
+ if (isDebug()) {
324
+ const reason = committed === undefined
325
+ ? 'mount'
326
+ : forceFreshFamily
327
+ ? 'fresh-parent'
328
+ : committed.viewName !== viewName
329
+ ? 'view-kind'
330
+ : 'reparent';
331
+ dlog(`commit root=${rootTag} createNode tag=${tag} view=${viewName} reason=${reason}`);
332
+ if (viewName === 'RCTView' || viewName === 'RCTText') {
333
+ dlog(`commit root=${rootTag} colorProbe tag=${tag} view=${viewName} ` +
334
+ `bg=${JSON.stringify(props.backgroundColor)} color=${JSON.stringify(props.color)} ` +
335
+ `opacity=${JSON.stringify(props.opacity)}`);
336
+ }
337
+ if (viewName === 'AndroidSwipeRefreshLayout' ||
338
+ viewName === 'RCTScrollView') {
339
+ dlog(`commit root=${rootTag} layoutProbe tag=${tag} view=${viewName} ` +
340
+ `flex=${JSON.stringify(props.flex)} height=${JSON.stringify(props.height)} ` +
341
+ `width=${JSON.stringify(props.width)} minHeight=${JSON.stringify(props.minHeight)} ` +
342
+ `flexGrow=${JSON.stringify(props.flexGrow)}`);
343
+ }
197
344
  }
198
- if (viewName === 'AndroidSwipeRefreshLayout' || viewName === 'RCTScrollView') {
199
- dlog(`commit root=${rootTag} layoutProbe tag=${tag} view=${viewName} ` +
200
- `flex=${JSON.stringify(props.flex)} height=${JSON.stringify(props.height)} ` +
201
- `width=${JSON.stringify(props.width)} minHeight=${JSON.stringify(props.minHeight)} ` +
202
- `flexGrow=${JSON.stringify(props.flexGrow)}`);
345
+ const created = slot.createNode(tag, viewName, rootTag, props, node);
346
+ // `createNode` takes no children (UIManagerBinding.cpp gives it 5 params), so a fresh parent
347
+ // can only receive them one `appendChild` at a time — unless we spend a clone to hand the whole
348
+ // list over at once. That is a TRADE, not a win: it removes N-1 JSI crossings and adds one
349
+ // discarded ShadowNode per parent, and which side wins is a native-side question no headless
350
+ // bench can answer. Hence the switch: off, this is byte-for-byte the append loop.
351
+ //
352
+ // Below the threshold the trade is a wash by call count (1 append vs 1 clone) and pure loss by
353
+ // allocation, so a single-child parent never takes it.
354
+ let handle = created;
355
+ if (batchCreate && kids.length >= CREATE_BATCH_MIN_CHILDREN) {
356
+ const childHandles = [];
357
+ for (const child of kids) {
358
+ childHandles.push(reconcile(slot, child, rootTag, childInText, node, true).handle);
359
+ }
360
+ handle = slot.cloneNodeWithNewChildren(created, childHandles);
203
361
  }
204
- const handle = slot.createNode(tag, viewName, rootTag, props, node);
205
- for (const child of kids) {
206
- slot.appendChild(handle, reconcile(slot, child, rootTag, childInText, node, true).handle);
362
+ else {
363
+ for (const child of kids) {
364
+ slot.appendChild(created, reconcile(slot, child, rootTag, childInText, node, true).handle);
365
+ }
207
366
  }
208
367
  logScrollChildren(node, viewName, tag);
209
368
  // Investigation instrumentation (search-bar-ref "node not committed" bug): scoped to RNS* so
@@ -211,17 +370,23 @@ function reconcile(slot, node, rootTag, hasTextAncestor, renderableParent, force
211
370
  // log below — same debugNodeId on both sides proves/disproves an identity mismatch. Kept
212
371
  // behind DEBUG per <keep_logs_gate_behind_DEBUG>, never removed.
213
372
  if (viewName.startsWith('RNS')) {
214
- dlog(`mirror.set (create) node=${debugNodeId(node)} tag=${tag} view=${viewName}`);
373
+ dlog(`committed (create) node=${debugNodeId(node)} tag=${tag} view=${viewName}`);
215
374
  }
216
- mirror.set(node, {
375
+ // `kids` is stored BY REFERENCE, not copied. With no anchors it IS `node.children`, so the
376
+ // record aliases the live array until the next structural op, which copies it out of the way
377
+ // (markStructureDirty, node.ts). Slicing here instead cost one array per node per commit -
378
+ // 9 002 on a 1 000-row create - and all but the handful of nodes that go on to change threw
379
+ // theirs away unread.
380
+ node.committed = {
217
381
  handle,
218
382
  tag,
219
383
  rootTag,
220
384
  props,
221
- children: kids.slice(),
385
+ children: kids,
222
386
  viewName,
223
387
  parent: renderableParent,
224
- });
388
+ owner: node,
389
+ };
225
390
  return { handle, changed: true };
226
391
  }
227
392
  // Reconcile children first; a child that re-cloned forces this node to re-clone
@@ -236,7 +401,32 @@ function reconcile(slot, node, rootTag, hasTextAncestor, renderableParent, force
236
401
  }
237
402
  logScrollChildren(node, viewName, committed.tag);
238
403
  const childrenChanged = !childrenIdentical(kids, committed.children) || descendantChanged;
239
- const propsChanged = !propsEqual(committed.props, props);
404
+ // The fast lane. No prop write was recorded on this node since its last commit, so its Fabric
405
+ // payload is by construction the one the mirror already holds: reuse that object by reference -
406
+ // no rebuild, no allocation, no deep compare - and carry it into the mirror below untouched.
407
+ //
408
+ // This is what propsDirty (node.ts) exists for. Every node on the clone-bubble path from a
409
+ // changed leaf up to the root takes this branch, as does the synthetic container that
410
+ // commitContainer dirties at every single entry. They still re-clone - a persistent parent must
411
+ // point at the new child handles - they just stop paying `fabricProps` + a recursive `propsEqual`
412
+ // to rediscover that nobody touched them.
413
+ //
414
+ // Under DEBUG the saving is handed straight back to check it was honest: an in-place mutation of
415
+ // a style object or of node.props, which no flag can observe, is the same hazard warnIfStale
416
+ // already guards on the skip path, and this lane is open to it identically.
417
+ let props;
418
+ let propsChanged;
419
+ if (ownPropsChanged) {
420
+ profile.propsBuilt += 1;
421
+ props = fabricProps(node);
422
+ propsChanged = !propsEqual(committed.props, props);
423
+ }
424
+ else {
425
+ profile.propsReused += 1;
426
+ props = committed.props;
427
+ propsChanged = false;
428
+ warnIfStale(node, committed, 'props');
429
+ }
240
430
  if (!childrenChanged && !propsChanged) {
241
431
  stats.reused += 1;
242
432
  return { handle: committed.handle, changed: false };
@@ -244,16 +434,23 @@ function reconcile(slot, node, rootTag, hasTextAncestor, renderableParent, force
244
434
  let handle;
245
435
  if (childrenChanged) {
246
436
  stats.cloneChildren += 1;
437
+ // A clone comes back with an EMPTY child list, so every sibling handle has to be handed back
438
+ // one by one — that loop is why touching one row of a thousand costs a thousand JSI crossings
439
+ // at every level up to the root. Where the host accepts the list in the clone call itself it
440
+ // becomes ONE crossing (see supportsCloneWithChildren in fabric.ts).
441
+ const batched = slot.supportsCloneWithChildren;
247
442
  if (propsChanged) {
248
443
  const propsDiff = diffProps(committed.props, props);
249
444
  guardSerializable(propsDiff, viewName, committed.tag);
250
- handle = slot.cloneNodeWithNewChildrenAndProps(committed.handle, propsDiff);
445
+ handle = slot.cloneNodeWithNewChildrenAndProps(committed.handle, propsDiff, batched ? childHandles : undefined);
251
446
  }
252
447
  else {
253
- handle = slot.cloneNodeWithNewChildren(committed.handle);
448
+ handle = slot.cloneNodeWithNewChildren(committed.handle, batched ? childHandles : undefined);
254
449
  }
255
- for (const childHandle of childHandles) {
256
- slot.appendChild(handle, childHandle);
450
+ if (!batched) {
451
+ for (const childHandle of childHandles) {
452
+ slot.appendChild(handle, childHandle);
453
+ }
257
454
  }
258
455
  }
259
456
  else {
@@ -265,22 +462,22 @@ function reconcile(slot, node, rootTag, hasTextAncestor, renderableParent, force
265
462
  // Investigation instrumentation (search-bar-ref "node not committed" bug): see the create-path
266
463
  // dlog above. Kept behind DEBUG per <keep_logs_gate_behind_DEBUG>, never removed.
267
464
  if (viewName.startsWith('RNS')) {
268
- dlog(`mirror.set (update) node=${debugNodeId(node)} tag=${committed.tag} view=${viewName}`);
269
- }
270
- // The clone keeps the node's family, so its reactTag is unchanged; carry it.
271
- mirror.set(node, {
272
- handle,
273
- tag: committed.tag,
274
- rootTag,
275
- props,
276
- // Store the same flattened child list we diffed against. Anchors are retained-tree
277
- // bookkeeping only; keeping raw node.children here makes every anchored subtree look
278
- // structurally changed on the next commit and can re-append already-parented Fabric
279
- // ShadowNode families under a cloned parent.
280
- children: kids.slice(),
281
- viewName,
282
- parent: renderableParent,
283
- });
465
+ dlog(`committed (update) node=${debugNodeId(node)} tag=${committed.tag} view=${viewName}`);
466
+ }
467
+ // Written IN PLACE rather than as a fresh record. The node is the same node, its tag and owner
468
+ // are unchanged by a clone (the clone keeps the family), so replacing the object bought nothing
469
+ // and cost one allocation per changed node per commit - and on an ordinary update that is the
470
+ // whole clone-bubble from the changed leaf up to the root.
471
+ committed.handle = handle;
472
+ committed.rootTag = rootTag;
473
+ committed.props = props;
474
+ // The same flattened child list we diffed against, by reference (see the create path above for
475
+ // why it is not copied). Anchors are retained-tree bookkeeping only; keeping raw node.children
476
+ // here makes every anchored subtree look structurally changed on the next commit and can
477
+ // re-append already-parented Fabric ShadowNode families under a cloned parent.
478
+ committed.children = kids;
479
+ committed.viewName = viewName;
480
+ committed.parent = renderableParent;
284
481
  return { handle, changed: true };
285
482
  }
286
483
  // One persistent synthetic root container per surface, mirroring RN's AppContainer
@@ -312,15 +509,21 @@ function rootContainerFor(rootTag) {
312
509
  // now-stopped surface. Called from unmount (the bridgeless surface-stop path): the host stops then restarts a
313
510
  // surface (Fast Refresh, focus/lifecycle) reusing the same rootTag, and a stale root
314
511
  // container would re-clone dead handles into the new surface -> a blank screen. The old
315
- // container's descendants fall out of every reference and their mirror entries GC.
512
+ // container's descendants fall out of every reference and are collected with the committed
513
+ // records they carry.
316
514
  export function disposeRoot(rootTag) {
515
+ // Drop any setNativeProps writes still queued for this surface: their flush is a microtask away
516
+ // and would otherwise commit into a container that no longer exists, re-creating it from scratch.
517
+ pendingByRoot.delete(rootTag);
317
518
  if (rootContainers.delete(rootTag))
318
519
  dlog(`root container disposed root=${rootTag}`);
319
520
  }
320
521
  export function commitChildren(rootTag, children) {
321
522
  // The wrapper holds the surface's top-level children; reconcile walks from it so the
322
523
  // whole tree, synthetic root included, goes through the same clone-on-write path.
323
- rootContainerFor(rootTag).children = children.slice();
524
+ const container = rootContainerFor(rootTag);
525
+ container.children = children.slice();
526
+ markStructureDirty(container);
324
527
  commitContainer(rootTag);
325
528
  }
326
529
  // Re-run the scoped commit for a surface from its synthetic root container, reusing
@@ -328,7 +531,22 @@ export function commitChildren(rootTag, children) {
328
531
  // a full mutation->commit and a single-node Animated frame (setNativeProps) funnel here.
329
532
  function commitContainer(rootTag) {
330
533
  const slot = getSlot();
534
+ batchCreate =
535
+ globalThis.__SYMBIOTE_BATCH_CREATE__ === true &&
536
+ slot.supportsCloneWithChildren;
331
537
  const container = rootContainerFor(rootTag);
538
+ // The synthetic container is dirtied here, at the one entry point, because markDirty can never
539
+ // bubble up to it: a surface's top-level nodes carry `parent === undefined` (surface.ts sets it
540
+ // deliberately), so a mark stops at the top-level node and the container above it stays clean -
541
+ // it would then early-exit and swallow the whole commit. Marking unconditionally costs one node's
542
+ // props rebuild per commit and closes the hole for both callers, mutation commit and
543
+ // setNativeProps alike.
544
+ markDirty(container);
545
+ // Before the walk, and before either early return: mutations for this tick are done, so a node
546
+ // that `removeChild` unlinked is now either back under a parent (a framework spelling a move as
547
+ // remove-then-reinsert) or gone for good. Costs one Set-size read until an app registers its
548
+ // first host behavior. See host-behavior.ts for why removal cannot answer this itself.
549
+ sweepDetachedBehaviors(container.children);
332
550
  stats.created = 0;
333
551
  stats.cloneProps = 0;
334
552
  stats.cloneChildren = 0;
@@ -338,13 +556,30 @@ function commitContainer(rootTag) {
338
556
  // tree walk); if `start` itself never prints, the stall is upstream: React's commit
339
557
  // phase or the mutation ops before we are even called.
340
558
  dlog(`commit root=${rootTag} start children=${container.children.length}`);
559
+ const walkStart = performance.now();
341
560
  const result = reconcile(slot, container, rootTag, false, undefined, false);
561
+ const walkMs = performance.now() - walkStart;
562
+ profile.walkMs += walkMs;
563
+ profile.commits += 1;
342
564
  // Boundary seam: prints once reconcile returns. If a commit hangs and this line
343
565
  // never appears, the stall is inside reconcile (JS); if it appears but the
344
566
  // post-completeRoot line below never does, the stall is inside the native commit.
345
567
  dlog(`commit root=${rootTag} reconciled changed=${result.changed}`);
346
568
  // The container's identity is stable, so its un-cloned flag is the no-op signal:
347
569
  // an over-scheduled commit that touched nothing makes zero native calls.
570
+ //
571
+ // TRAP FOR BEHAVIOR AUTHORS, and it cost two iterations to find: this return is ALSO the gate on
572
+ // `runDeferredAttaches` and `runPostCommitHooks` below. A host behavior that calls
573
+ // `requestCommitFor(node)` WITHOUT writing a prop therefore never reaches its `afterCommit` /
574
+ // `attachAfterCommit` half — the commit it asked for is a no-op, and a no-op returns here.
575
+ //
576
+ // That is correct for what the hooks are FOR: they exist to retry once fresh Fabric tags are
577
+ // assigned, and a commit that made zero native calls assigned none. So the fix is not to hoist
578
+ // them above this line — that would run every deferred hook on every over-scheduled commit, which
579
+ // is the common case. A behavior needing a turn of the loop with nothing to write should schedule
580
+ // its own (`queueMicrotask`, as Switch's snap-back and Angular's `snapBackIfNeeded` both do) and
581
+ // keep `afterCommit` registered for the case a microtask cannot reach: a prop change with no
582
+ // preceding native event.
348
583
  if (!result.changed) {
349
584
  dlog(`commit root=${rootTag} no-op (skipped completeRoot)`);
350
585
  return;
@@ -357,11 +592,265 @@ function commitContainer(rootTag) {
357
592
  // and ran too early (the Animated native driver binding a props node to a view under
358
593
  // an async-batched commit) retry now. No-op when nothing is pending.
359
594
  runPostCommitHooks();
595
+ // The same moment, for the half of a host behavior that could not run at `attach`. A behavior
596
+ // whose setup needs a Fabric tag (a view command, a native Animated binding, an event attach)
597
+ // declares `attachAfterCommit` and is drained here. `committedOf` is passed as the predicate
598
+ // rather than imported by `host-behavior.ts`, keeping that dependency one-directional — this
599
+ // module already imports from it, and a cycle is a live hazard under Metro's `inlineRequires`.
600
+ runDeferredAttaches(node => committedOf(node) !== undefined);
360
601
  if (isDebug()) {
361
602
  const mode = stats.created > 0 && stats.reused === 0 ? 'full' : 'incremental';
362
603
  dlog(`commit root=${rootTag} ${mode} ` +
363
604
  `created=${stats.created} cloneProps=${stats.cloneProps} ` +
364
- `cloneChildren=${stats.cloneChildren} reused=${stats.reused}`);
605
+ `cloneChildren=${stats.cloneChildren} reused=${stats.reused} ` +
606
+ `propsBuilt=${profile.propsBuilt} propsReused=${profile.propsReused} ` +
607
+ `walk=${walkMs.toFixed(3)}ms`);
608
+ }
609
+ }
610
+ export function readCommitProfile() {
611
+ const props = takePropStats();
612
+ const snapshot = {
613
+ commits: profile.commits,
614
+ walkMs: profile.walkMs,
615
+ nodesVisited: profile.nodesVisited,
616
+ propsBuilt: profile.propsBuilt,
617
+ propsReused: profile.propsReused,
618
+ propWrites: props.writes,
619
+ propNoops: props.noops,
620
+ childScans: childScan.scans,
621
+ childScanProbed: childScan.probed,
622
+ childFlattens: childScan.flattens,
623
+ childFlattenProbed: childScan.flattenProbed,
624
+ childFlattenWidest: childScan.widest,
625
+ };
626
+ profile.commits = 0;
627
+ profile.walkMs = 0;
628
+ profile.nodesVisited = 0;
629
+ profile.propsBuilt = 0;
630
+ profile.propsReused = 0;
631
+ childScan.scans = 0;
632
+ childScan.probed = 0;
633
+ childScan.flattens = 0;
634
+ childScan.flattenProbed = 0;
635
+ childScan.widest = 0;
636
+ return snapshot;
637
+ }
638
+ function commitTargeted(nodes) {
639
+ // ── PLAN FIRST, MUTATE SECOND ──────────────────────────────────────────────────────────────
640
+ // Every precondition is checked and every sibling handle resolved before a single native call,
641
+ // so a bail costs nothing and leaves the tree exactly as it was.
642
+ //
643
+ // This is not hypothetical tidiness: an earlier version validated the ancestor chain up front but
644
+ // resolved SIBLING handles inside the clone loop, so a bail half-way had already re-pointed a
645
+ // node's committed record at a clone that was never handed to completeRoot, and had already
646
+ // cleared its dirty flags - so the general-path fallback then skipped the node as clean and
647
+ // committed an orphan handle. The fallback test in animated-commit-cost.test.ts is what caught it.
648
+ const writes = [];
649
+ for (const node of nodes) {
650
+ const record = committedOf(node);
651
+ if (record === undefined)
652
+ return false;
653
+ // The node's own children must be the ones Fabric holds: this path never descends, so a
654
+ // pending structural change below would be published as if it had not happened.
655
+ if (node.structureDirty)
656
+ return false;
657
+ // THE TWIN OF THE CHECK ABOVE, and the one that was missing. `dirty` is a SUBTREE flag — it
658
+ // means "this node or something under it needs work" — while this path clears it as if it were
659
+ // a self flag. Clearing it over a dirty descendant strands that descendant permanently: the
660
+ // general commit reconciles from the root, finds a clean chain, and never descends again.
661
+ //
662
+ // Direct children are enough, and that is not an approximation. `markDirty` walks up from the
663
+ // dirtied node and STOPS at the first already-dirty ancestor; in this scenario that ancestor is
664
+ // this node, so the chain from any dirty descendant up to here is fully marked — which makes a
665
+ // dirty direct child a certainty whenever a dirty descendant exists. So the check is O(children)
666
+ // rather than a subtree walk, on a path that runs once per animation frame.
667
+ //
668
+ // Reproduced 2026-08-24 by a peer's flag dump after tier-2 made a press ask for its own commit:
669
+ // press the node, dirty its child in the same tick, and the child never commits again. Vue and
670
+ // Solid did not show it because their schedulers rewrite the prop on the node itself and
671
+ // re-dirty the chain — an accident of those schedulers, not a property of this contract.
672
+ if (node.children.some(child => child.dirty)) {
673
+ dlog('commit targeted: dirty descendant, using the general path');
674
+ return false;
675
+ }
676
+ // Counted like the general path's rebuild, because it is one: the meter must not read as though
677
+ // an animation frame builds no payload just because it took the short route.
678
+ profile.propsBuilt += 1;
679
+ const props = fabricProps(node);
680
+ const diff = diffProps(record.props, props);
681
+ if (Object.keys(diff).length === 0) {
682
+ // Fabric already holds these values. Not an error and not a fallback: the general path would
683
+ // reach the same conclusion, after walking the tree to get here. Dropping it from the batch
684
+ // is safe even if a LATER node bails the whole thing — its props genuinely match Fabric, so
685
+ // the general commit skipping it as clean publishes the same tree.
686
+ node.dirty = false;
687
+ node.propsDirty = false;
688
+ dlog(`commit targeted tag=${record.tag} no-op (props identical)`);
689
+ continue;
690
+ }
691
+ writes.push({ node, record, props, diff });
692
+ }
693
+ if (writes.length === 0)
694
+ return true;
695
+ // Build the union of the ancestor chains. `changed` is the whole union keyed by node, so a shared
696
+ // ancestor is entered ONCE however many of the batch's leaves sit under it — which is the entire
697
+ // saving over committing each leaf separately.
698
+ const changed = new Map();
699
+ let unionRoot;
700
+ for (const write of writes) {
701
+ let child = write.node;
702
+ let ancestor = write.record.parent;
703
+ while (ancestor !== undefined) {
704
+ const record = committedOf(ancestor);
705
+ if (record === undefined)
706
+ return false;
707
+ // THE precondition, and the one an earlier version of this function missed. `record.children`
708
+ // is a SNAPSHOT from the last commit. If this ancestor's real child list has moved on - a row
709
+ // added, removed, or reordered - rebuilding its child set from that snapshot silently
710
+ // publishes the OLD structure, with no error anywhere. Caught by the fallback row in
711
+ // animated-commit-cost.test.ts, which appends a sibling and then animates: without this check
712
+ // the new sibling never reached Fabric and every other assertion still passed.
713
+ if (ancestor.structureDirty) {
714
+ dlog('commit targeted: ancestor child list moved on, using the general path');
715
+ return false;
716
+ }
717
+ const seen = changed.get(ancestor);
718
+ if (seen !== undefined) {
719
+ // Already in the union via another leaf. Everything ABOVE it is therefore already in too,
720
+ // and already names this node as changed, so the walk stops here.
721
+ seen.add(child);
722
+ break;
723
+ }
724
+ changed.set(ancestor, new Set([child]));
725
+ if (record.parent === undefined)
726
+ unionRoot = ancestor;
727
+ child = ancestor;
728
+ ancestor = record.parent;
729
+ }
730
+ }
731
+ // Resolve every branch's child handles now, so the clone pass below cannot fail part-way.
732
+ const branches = new Map();
733
+ for (const [node, changedChildren] of changed) {
734
+ const record = committedOf(node);
735
+ if (record === undefined)
736
+ return false;
737
+ const handles = [];
738
+ const slots = new Map();
739
+ for (const child of record.children) {
740
+ const childRecord = committedOf(child);
741
+ // A sibling with no committed record means a structural change is pending, which this path
742
+ // cannot express. Bail before touching anything.
743
+ if (childRecord === undefined) {
744
+ dlog('commit targeted: uncommitted sibling, using the general path');
745
+ return false;
746
+ }
747
+ if (changedChildren.has(child))
748
+ slots.set(child, handles.length);
749
+ handles.push(childRecord.handle);
750
+ }
751
+ if (slots.size !== changedChildren.size) {
752
+ // A changed child is not in its parent's committed child list — an uncommitted move.
753
+ dlog('commit targeted: child not in committed set, using the general path');
754
+ return false;
755
+ }
756
+ branches.set(node, { record, handles, slots });
757
+ }
758
+ // Every write is on one surface (the caller batches by rootTag), so the chains converge on that
759
+ // surface's synthetic container and nowhere else. Checked HERE, the last statement of the plan,
760
+ // so the clone pass below has no way left to bail after it has started mutating.
761
+ const rootBranch = unionRoot === undefined ? undefined : branches.get(unionRoot);
762
+ if (unionRoot === undefined || rootBranch === undefined)
763
+ return false;
764
+ const rootTag = rootBranch.record.rootTag;
765
+ // ── MUTATE ─────────────────────────────────────────────────────────────────────────────────
766
+ const slot = getSlot();
767
+ const walkStart = performance.now();
768
+ const cloned = new Map();
769
+ for (const write of writes) {
770
+ guardSerializable(write.diff, write.record.viewName, write.record.tag);
771
+ const handle = slot.cloneNodeWithNewProps(write.record.handle, write.diff);
772
+ stats.cloneProps += 1;
773
+ write.record.handle = handle;
774
+ write.record.props = write.props;
775
+ write.node.dirty = false;
776
+ write.node.propsDirty = false;
777
+ cloned.set(write.node, handle);
778
+ }
779
+ // Re-clone each branch exactly once, deepest first. The recursion is bounded by the tree depth,
780
+ // and every branch it touches is in the plan above, so nothing here can bail.
781
+ const cloneBranch = (node) => {
782
+ const done = cloned.get(node);
783
+ if (done !== undefined)
784
+ return done;
785
+ const branch = branches.get(node);
786
+ // Unreachable by construction: cloneBranch is only ever called on a node the plan put in
787
+ // `branches` or the write loop put in `cloned`. Leaving the committed handle in place is the
788
+ // safe direction if that ever stops being true, and the oracle row would report it.
789
+ if (branch === undefined)
790
+ return undefined;
791
+ for (const [child, index] of branch.slots) {
792
+ const childHandle = cloneBranch(child);
793
+ if (childHandle !== undefined)
794
+ branch.handles[index] = childHandle;
795
+ }
796
+ const batched = slot.supportsCloneWithChildren;
797
+ const handle = slot.cloneNodeWithNewChildren(branch.record.handle, batched ? branch.handles : undefined);
798
+ stats.cloneChildren += 1;
799
+ if (!batched)
800
+ for (const childHandle of branch.handles)
801
+ slot.appendChild(handle, childHandle);
802
+ branch.record.handle = handle;
803
+ cloned.set(node, handle);
804
+ return handle;
805
+ };
806
+ const rootHandle = cloneBranch(unionRoot) ?? rootBranch.record.handle;
807
+ const childSet = slot.createChildSet(rootTag);
808
+ slot.appendChildToSet(childSet, rootHandle);
809
+ slot.completeRoot(rootTag, childSet);
810
+ profile.walkMs += performance.now() - walkStart;
811
+ profile.commits += 1;
812
+ profile.nodesVisited += cloned.size;
813
+ runPostCommitHooks();
814
+ dlog(`commit targeted root=${rootTag} leaves=${writes.length} ` +
815
+ `union=${branches.size}`);
816
+ return true;
817
+ }
818
+ // A JS-driven Animated frame lands in setNativeProps once per animated leaf. Five animations on
819
+ // five rows of one list therefore used to mean FIVE commits per frame: five completeRoots, and
820
+ // every shared ancestor re-cloned - with its whole child list re-appended - five times over. The
821
+ // dirty-set census (`symbiote-perf-measurement` skill) measured that appendChild is the real floor
822
+ // of a commit and that it multiplies almost exactly with the commit count. So the win here is not a
823
+ // faster commit, it is FEWER of them.
824
+ //
825
+ // The batch never drops a value, and that is a rule, not a hope. Merging writes to DIFFERENT nodes
826
+ // is free - each carries its own value and one commit publishes them all. The single case where
827
+ // merging WOULD lose a value is a second write to a node already pending, so that case does not
828
+ // merge: it publishes the pending batch first, synchronously, and opens a new one. Hence:
829
+ //
830
+ // N writes to N different nodes in one task -> one completeRoot, all N values land
831
+ // two writes to the SAME node in one task -> two completeRoots, both values land
832
+ //
833
+ // The second row costs nothing in practice: an Animated.Value ticks its props node exactly once per
834
+ // rAF (animations/timing.ts's onFrame calls onUpdate once, then schedules the next frame), so
835
+ // concurrent animations are always distinct nodes. It exists for the paths that genuinely can write
836
+ // twice - two animations bound to one prop of one node, or Animated.event when the host delivers
837
+ // two scroll events in a single task.
838
+ const pendingByRoot = new Map();
839
+ let flushScheduled = false;
840
+ // Publish every pending write: one commit per surface. A surface with exactly one pending node
841
+ // takes the targeted chain clone (4.5x, §4a); with several it takes the general walk, which already
842
+ // visits only dirty paths and reaches all of them under a single completeRoot - which is the point.
843
+ export function flushNativeProps() {
844
+ flushScheduled = false;
845
+ if (pendingByRoot.size === 0)
846
+ return;
847
+ const batches = [...pendingByRoot];
848
+ pendingByRoot.clear();
849
+ for (const [rootTag, nodes] of batches) {
850
+ if (commitTargeted(nodes))
851
+ continue;
852
+ dlog(`flush native props root=${rootTag} nodes=${nodes.size} (general)`);
853
+ commitContainer(rootTag);
365
854
  }
366
855
  }
367
856
  // Targeted per-frame prop write for the JS-driven Animated path. RN flushes an
@@ -372,30 +861,80 @@ function commitContainer(rootTag) {
372
861
  // by reference, and emits a single completeRoot. This is the "slow tier", viable for a
373
862
  // single shallow animation; driving the animation natively is the answer for scale.
374
863
  export function setNativeProps(node, partial) {
375
- const record = mirror.get(node);
864
+ const record = committedOf(node);
376
865
  if (record === undefined) {
377
866
  dlog('setNativeProps skipped: node not committed');
378
867
  return;
379
868
  }
869
+ // BEFORE the prop writes below, deliberately: the pending batch still has to publish the value
870
+ // this node holds right now, and mutating first would overwrite the very thing being preserved.
871
+ if (pendingByRoot.get(record.rootTag)?.has(node) === true) {
872
+ dlog(`setNativeProps tag=${record.tag} written twice in one task, flushing`);
873
+ flushNativeProps();
874
+ }
380
875
  for (const [key, value] of Object.entries(partial)) {
381
876
  if (key === 'style') {
382
877
  // A partial style override MERGES onto the declarative style (RN semantics):
383
878
  // setNativeProps({style:{backgroundColor}}) recolors without dropping height
384
879
  // or radius. Transient: the next React commit re-applies the full style.
385
- node.props.style = { ...flattenStyle(node.props.style), ...flattenStyle(value) };
880
+ node.props.style = {
881
+ ...flattenStyle(node.props.style),
882
+ ...flattenStyle(value),
883
+ };
386
884
  }
387
885
  else {
388
886
  node.props[key] = value;
887
+ // Writes `node.props` directly, so it owes the aria gate for the same reason it owes the
888
+ // props mark below: `setProp` is where that flag is normally raised and this path bypasses
889
+ // it. An `aria-*` arriving only through setNativeProps would otherwise never be folded.
890
+ if (!node.hasAriaAlias && isAriaAliasKey(key))
891
+ node.hasAriaAlias = true;
389
892
  }
390
893
  }
894
+ // Writes node.props directly rather than through setProp, so it owes its own mark - and it owes
895
+ // the PROPS mark specifically: markDirty alone would send the node down the fast lane above,
896
+ // which reuses the mirror's payload by reference and would drop the Animated frame entirely.
897
+ markPropsDirty(node);
391
898
  dlog(`setNativeProps root=${record.rootTag} tag=${record.tag} keys=${Object.keys(partial)}`);
392
- commitContainer(record.rootTag);
899
+ // Queued, not committed: every write made in this task publishes together at the microtask
900
+ // boundary. See the batching note above commitTargeted's caller block for why that loses nothing.
901
+ requestCommitFor(node);
902
+ }
903
+ /**
904
+ * Publish a node whose props were changed OUTSIDE any renderer mutation.
905
+ *
906
+ * Dirtying is not publishing. Every other write reaches Fabric because the framework's own commit
907
+ * follows it; a change driven by a NATIVE EVENT has no such follow-up — `native-events.ts`
908
+ * requests no commit and no adapter does either, so a node marked dirty from an event handler
909
+ * simply stays dirty. The host-behavior press path (`setNodePressed` for `:active`) is the first
910
+ * caller that is not `setNativeProps`, and `setNodeHidden`'s React twin never needed it because
911
+ * the reconciler is already in its commit phase when it calls.
912
+ *
913
+ * Queued rather than committed on the spot, sharing `setNativeProps`' batch: several writes in one
914
+ * task publish together at the microtask boundary, one commit per surface.
915
+ */
916
+ export function requestCommitFor(node) {
917
+ const record = committedOf(node);
918
+ if (record === undefined) {
919
+ dlog('requestCommitFor skipped: node not committed');
920
+ return;
921
+ }
922
+ let pending = pendingByRoot.get(record.rootTag);
923
+ if (pending === undefined) {
924
+ pending = new Set();
925
+ pendingByRoot.set(record.rootTag, pending);
926
+ }
927
+ pending.add(node);
928
+ if (!flushScheduled) {
929
+ flushScheduled = true;
930
+ queueMicrotask(flushNativeProps);
931
+ }
393
932
  }
394
933
  // The committed reactTag of a node (stable across clone-on-write), for binding the
395
934
  // native Animated driver via connectAnimatedNodeToView. Undefined until the node
396
935
  // has been committed at least once.
397
936
  export function getNativeTag(node) {
398
- return mirror.get(node)?.tag;
937
+ return committedOf(node)?.tag;
399
938
  }
400
939
  // Actions waiting for their node's first commit. An adapter that wires an imperative/native call at
401
940
  // lifecycle time (autoFocus, a native Animated.event attach) can run BEFORE completeRoot under an
@@ -414,7 +953,7 @@ registerPostCommit(() => {
414
953
  // silently no-opping. Returns a cancel fn (drop the pending retry, e.g. on unmount).
415
954
  export function whenCommitted(node, action) {
416
955
  const attempt = () => {
417
- if (mirror.get(node) === undefined)
956
+ if (committedOf(node) === undefined)
418
957
  return false;
419
958
  action();
420
959
  return true;
@@ -428,16 +967,16 @@ export function whenCommitted(node, action) {
428
967
  // The node's current Fabric handle (the createNode/clone return value), identical in
429
968
  // kind to React's stateNode.node, for the native driver's ShadowNodeFamily path.
430
969
  export function getNativeNode(node) {
431
- return mirror.get(node)?.handle;
970
+ return committedOf(node)?.handle;
432
971
  }
433
972
  // Imperative view command (e.g. TextInput's setTextAndSelection / focus / blur),
434
973
  // aimed at a node's CURRENT Fabric handle. Only valid once the node has been
435
- // committed at least once; its handle is read from the mirror.
974
+ // committed at least once; its handle is read from the node's committed record.
436
975
  export function dispatchViewCommand(node, commandName, args) {
437
- const record = mirror.get(node);
976
+ const record = committedOf(node);
438
977
  if (record === undefined) {
439
- // node=... compares directly against the mirror.set logs above (same debugNodeId scheme) to
440
- // prove/disprove a node-identity mismatch — see the search-bar-ref investigation note there.
978
+ // node=... compares directly against the `committed (create|update)` logs above (same
979
+ // debugNodeId scheme) to prove/disprove an identity mismatch — see the note there.
441
980
  dlog(`dispatchViewCommand "${commandName}" skipped: node not committed (node=${debugNodeId(node)} component=${node.component})`);
442
981
  return;
443
982
  }
@@ -450,7 +989,7 @@ export function dispatchViewCommand(node, commandName, args) {
450
989
  // with the STRING eventType; the C++ side maps it to the platform's accessibility-event kind.
451
990
  // A no-op (logged) until the node is committed; there is no handle yet.
452
991
  export function sendAccessibilityEvent(node, eventType) {
453
- const record = mirror.get(node);
992
+ const record = committedOf(node);
454
993
  if (record === undefined) {
455
994
  dlog(`sendAccessibilityEvent "${eventType}" skipped: node not committed`);
456
995
  return;
@@ -462,7 +1001,7 @@ export function sendAccessibilityEvent(node, eventType) {
462
1001
  // measure family that reanimated / gesture-handler / scroll-to reach through). A
463
1002
  // no-op with a dlog until the node is committed; there is no handle to measure yet.
464
1003
  export function measure(node, callback) {
465
- const record = mirror.get(node);
1004
+ const record = committedOf(node);
466
1005
  if (record === undefined) {
467
1006
  dlog('measure skipped: node not committed');
468
1007
  return;
@@ -470,7 +1009,7 @@ export function measure(node, callback) {
470
1009
  getSlot().measure(record.handle, callback);
471
1010
  }
472
1011
  export function measureInWindow(node, callback) {
473
- const record = mirror.get(node);
1012
+ const record = committedOf(node);
474
1013
  if (record === undefined) {
475
1014
  dlog('measureInWindow skipped: node not committed');
476
1015
  return;
@@ -481,8 +1020,8 @@ export function measureInWindow(node, callback) {
481
1020
  // signature is (relative, onSuccess, onFail) but the native slot wants the fail
482
1021
  // callback before success, so the order is swapped here.
483
1022
  export function measureLayout(node, relativeTo, onSuccess, onFail = () => { }) {
484
- const record = mirror.get(node);
485
- const relativeRecord = mirror.get(relativeTo);
1023
+ const record = committedOf(node);
1024
+ const relativeRecord = committedOf(relativeTo);
486
1025
  if (record === undefined || relativeRecord === undefined) {
487
1026
  dlog('measureLayout skipped: a node is not committed');
488
1027
  return;