@symbiote-native/engine 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/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,25 @@
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, isEmptyRawText, markDirty, takePropStats, 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 { runCommittedHooks, runDeferredAttaches, sweepDetachedBehaviors, teardownSubtree, } from './host-behavior.js';
25
+ import { detachAnimatedProps } from './animated/host-binding.js';
23
26
  // Re-exported from ./platform-color so callers don't need to change their import path.
24
27
  export { processColor, setColorProcessor } from './platform-color/index.js';
25
28
  // Per-commit work counters, surfaced via dlog so a device run can prove the
26
29
  // engine is incremental (created=0 with clones after the first mount).
27
30
  const stats = { created: 0, cloneProps: 0, cloneChildren: 0, reused: 0 };
31
+ // One append vs one clone is a wash by call count and a pure loss by allocation, so a parent with a
32
+ // single child never takes the batched route whatever the switch says.
33
+ const CREATE_BATCH_MIN_CHILDREN = 2;
34
+ let batchCreate = false;
28
35
  // Cumulative cost of the reconcile walk. Unlike `stats` (per-commit, zeroed at the top of every
29
36
  // commit), this ACCUMULATES: one scroll frame produces a burst of commits and the frame's real cost
30
37
  // is only visible as their sum. Read-and-zeroed via readCommitProfile().
@@ -32,7 +39,23 @@ const stats = { created: 0, cloneProps: 0, cloneChildren: 0, reused: 0 };
32
39
  // Deliberately NOT gated behind isDebug(): two performance.now() calls per commit are noise next to
33
40
  // the walk they measure, and the number is only meaningful from a RELEASE build - dev-mode JS
34
41
  // drowns the signal. The dlog below stays gated as usual.
35
- const profile = { commits: 0, walkMs: 0, nodesVisited: 0 };
42
+ // `propsBuilt` / `propsReused` price the props half of the walk, and they exist because that half
43
+ // is INVISIBLE in the output: reusing the mirror's payload by reference and rebuilding a
44
+ // byte-identical one emit the same Fabric calls, so only a counter separates a working fast lane
45
+ // from a silently reverted one. `propsBuilt` counts fabricProps() calls made by the update path
46
+ // (a fresh object plus a recursive propsEqual); `propsReused` counts nodes that re-cloned for a
47
+ // child's sake and carried the committed payload through untouched. On an ordinary update the
48
+ // second should dominate - that ratio IS the clone-bubble.
49
+ //
50
+ // The create path is deliberately not counted in either: it has no committed payload to reuse, so
51
+ // its fabricProps() calls are not a cost any flag could remove and would only dilute the ratio.
52
+ const profile = {
53
+ commits: 0,
54
+ walkMs: 0,
55
+ nodesVisited: 0,
56
+ propsBuilt: 0,
57
+ propsReused: 0,
58
+ };
36
59
  // What `renderableChildren` costs, accumulated over the same window as `profile`. Anchors are the
37
60
  // only reason that function is not free, and how many a tree carries is a property of the ADAPTER,
38
61
  // not of the app: a Vue/React/Svelte/Solid component is a function returning children and allocates
@@ -164,7 +187,15 @@ function jsonEqual(a, b) {
164
187
  return false;
165
188
  return keys.every(key => key in b && jsonEqual(a[key], b[key]));
166
189
  }
167
- const mirror = new WeakMap();
190
+ // The committed-state record (IMirror) and its guarded accessor (committedOf) live on the node
191
+ // itself, in node.ts - see the `committed` field there for why the side table was collapsed into a
192
+ // field, and committedOf's doc comment for the node-identity check that replaced the WeakMap miss.
193
+ // Everything below reads it exclusively through committedOf and writes it as `node.committed`.
194
+ // The predicate both behavior drains take. Passed in rather than imported by `host-behavior.ts`,
195
+ // keeping that dependency one-directional — this module already imports from it, and a cycle is a
196
+ // live hazard under Metro's `inlineRequires`. Hoisted to module scope because two call sites need
197
+ // the identical question.
198
+ const isNodeCommitted = (node) => committedOf(node) !== undefined;
168
199
  function isSkippedAtCommit(node) {
169
200
  return isAnchor(node) || isEmptyRawText(node);
170
201
  }
@@ -221,10 +252,14 @@ function childrenIdentical(kids, committed) {
221
252
  // committed tag/view-name is resolved. A `MULTI!!` line names the exact extra
222
253
  // node (tag + view-name) that pushed the scroll view past one child.
223
254
  function logScrollChildren(node, viewName, selfTag) {
255
+ // The whole function is a dlog, and it is called on every reconciled node, so the cheap boolean
256
+ // goes first: with logging off this is one property read instead of two string scans per node.
257
+ if (!isDebug())
258
+ return;
224
259
  if (!viewName.includes('Scroll') || viewName.includes('Content'))
225
260
  return;
226
261
  const kids = node.children.map(child => {
227
- const committed = mirror.get(child);
262
+ const committed = committedOf(child);
228
263
  return `${committed?.viewName ?? child.component}#${committed?.tag ?? 'NEW'}`;
229
264
  });
230
265
  const flag = kids.length === 1 ? 'OK' : 'MULTI!!';
@@ -235,19 +270,25 @@ function logScrollChildren(node, viewName, selfTag) {
235
270
  // showing the old value - no error, no crash, nothing to grep for. So under DEBUG pay back the full
236
271
  // price just saved and verify the skip was honest, naming the node loudly enough to find the
237
272
  // missing mark.
238
- function warnIfStale(node, committed) {
273
+ //
274
+ // Two lanes reach it, and naming which one fired matters when reading a log: `subtree` is the whole
275
+ // node skipped because nothing under it was marked, `props` is the node re-cloned for a child's
276
+ // sake but its own payload reused because no prop write was recorded on it. They point at
277
+ // different missing marks - markDirty vs markPropsDirty - so the line says which.
278
+ function warnIfStale(node, committed, lane) {
239
279
  if (!isDebug())
240
280
  return;
241
281
  const fresh = fabricProps(node);
242
282
  if (propsEqual(committed.props, fresh))
243
283
  return;
244
- dlog(`DIRTY-MISS ${committed.viewName}#${committed.tag} node=${debugNodeId(node)} skipped as clean ` +
245
- `but props differ: committed=${JSON.stringify(committed.props)} desired=${JSON.stringify(fresh)}`);
284
+ dlog(`DIRTY-MISS(${lane}) ${committed.viewName}#${committed.tag} node=${debugNodeId(node)} ` +
285
+ `treated as clean but props differ: committed=${JSON.stringify(committed.props)} ` +
286
+ `desired=${JSON.stringify(fresh)}`);
246
287
  }
247
288
  function reconcile(slot, node, rootTag, hasTextAncestor, renderableParent, forceFreshFamily) {
248
289
  profile.nodesVisited += 1;
249
290
  const viewName = viewNameFor(node, hasTextAncestor);
250
- const committed = mirror.get(node);
291
+ const committed = committedOf(node);
251
292
  // Nothing under here changed: hand back the committed handle without rebuilding this node's
252
293
  // Fabric props or descending into it at all. The walk below costs ~13 us/node on device, and an
253
294
  // untouched sibling subtree would pay it on every commit. `committed.parent` is re-checked rather
@@ -259,11 +300,18 @@ function reconcile(slot, node, rootTag, hasTextAncestor, renderableParent, force
259
300
  committed.parent === renderableParent &&
260
301
  committed.viewName === viewName) {
261
302
  stats.reused += 1;
262
- warnIfStale(node, committed);
303
+ warnIfStale(node, committed, 'subtree');
263
304
  return { handle: committed.handle, changed: false };
264
305
  }
265
306
  node.dirty = false;
266
- const props = fabricProps(node);
307
+ // Read before clearing. The create path below ignores it - a node Fabric has never seen needs its
308
+ // whole payload built regardless - so this only ever gates the update path.
309
+ const ownPropsChanged = node.propsDirty;
310
+ node.propsDirty = false;
311
+ // Cleared here because this call is what re-snapshots `committed.children` below, on both the
312
+ // create and the update path. Anything that reads that snapshot afterwards is reading a current
313
+ // one until the next structural op raises the flag again.
314
+ node.structureDirty = false;
267
315
  const childInText = node.isText || hasTextAncestor;
268
316
  const kids = renderableChildren(node);
269
317
  // First mount, or the view kind flipped (RCTText <-> RCTVirtualText when a
@@ -275,30 +323,56 @@ function reconcile(slot, node, rootTag, hasTextAncestor, renderableParent, force
275
323
  committed.viewName !== viewName ||
276
324
  parentChanged) {
277
325
  stats.created += 1;
326
+ // Full payload: there is no committed props object to reuse, whatever propsDirty said.
327
+ const props = fabricProps(node);
278
328
  const tag = nextTag();
279
- const reason = committed === undefined
280
- ? 'mount'
281
- : forceFreshFamily
282
- ? 'fresh-parent'
283
- : committed.viewName !== viewName
284
- ? 'view-kind'
285
- : 'reparent';
286
- dlog(`commit root=${rootTag} createNode tag=${tag} view=${viewName} reason=${reason}`);
287
- if (viewName === 'RCTView' || viewName === 'RCTText') {
288
- dlog(`commit root=${rootTag} colorProbe tag=${tag} view=${viewName} ` +
289
- `bg=${JSON.stringify(props.backgroundColor)} color=${JSON.stringify(props.color)} ` +
290
- `opacity=${JSON.stringify(props.opacity)}`);
329
+ // One gate for the whole diagnostic block, not three dlog calls. This runs once per CREATED
330
+ // node - 9 000 of them on one benchmark press - and dlog cannot help here: its argument is
331
+ // built at the CALL SITE (see debug.ts), so an eager template pays in full with logging off,
332
+ // and a thunk trades that for a closure allocation per node. A plain `if` costs neither.
333
+ if (isDebug()) {
334
+ const reason = committed === undefined
335
+ ? 'mount'
336
+ : forceFreshFamily
337
+ ? 'fresh-parent'
338
+ : committed.viewName !== viewName
339
+ ? 'view-kind'
340
+ : 'reparent';
341
+ dlog(`commit root=${rootTag} createNode tag=${tag} view=${viewName} reason=${reason}`);
342
+ if (viewName === 'RCTView' || viewName === 'RCTText') {
343
+ dlog(`commit root=${rootTag} colorProbe tag=${tag} view=${viewName} ` +
344
+ `bg=${JSON.stringify(props.backgroundColor)} color=${JSON.stringify(props.color)} ` +
345
+ `opacity=${JSON.stringify(props.opacity)}`);
346
+ }
347
+ if (viewName === 'AndroidSwipeRefreshLayout' ||
348
+ viewName === 'RCTScrollView') {
349
+ dlog(`commit root=${rootTag} layoutProbe tag=${tag} view=${viewName} ` +
350
+ `flex=${JSON.stringify(props.flex)} height=${JSON.stringify(props.height)} ` +
351
+ `width=${JSON.stringify(props.width)} minHeight=${JSON.stringify(props.minHeight)} ` +
352
+ `flexGrow=${JSON.stringify(props.flexGrow)}`);
353
+ }
291
354
  }
292
- if (viewName === 'AndroidSwipeRefreshLayout' ||
293
- viewName === 'RCTScrollView') {
294
- dlog(`commit root=${rootTag} layoutProbe tag=${tag} view=${viewName} ` +
295
- `flex=${JSON.stringify(props.flex)} height=${JSON.stringify(props.height)} ` +
296
- `width=${JSON.stringify(props.width)} minHeight=${JSON.stringify(props.minHeight)} ` +
297
- `flexGrow=${JSON.stringify(props.flexGrow)}`);
355
+ const created = slot.createNode(tag, viewName, rootTag, props, node);
356
+ // `createNode` takes no children (UIManagerBinding.cpp gives it 5 params), so a fresh parent
357
+ // can only receive them one `appendChild` at a time — unless we spend a clone to hand the whole
358
+ // list over at once. That is a TRADE, not a win: it removes N-1 JSI crossings and adds one
359
+ // discarded ShadowNode per parent, and which side wins is a native-side question no headless
360
+ // bench can answer. Hence the switch: off, this is byte-for-byte the append loop.
361
+ //
362
+ // Below the threshold the trade is a wash by call count (1 append vs 1 clone) and pure loss by
363
+ // allocation, so a single-child parent never takes it.
364
+ let handle = created;
365
+ if (batchCreate && kids.length >= CREATE_BATCH_MIN_CHILDREN) {
366
+ const childHandles = [];
367
+ for (const child of kids) {
368
+ childHandles.push(reconcile(slot, child, rootTag, childInText, node, true).handle);
369
+ }
370
+ handle = slot.cloneNodeWithNewChildren(created, childHandles);
298
371
  }
299
- const handle = slot.createNode(tag, viewName, rootTag, props, node);
300
- for (const child of kids) {
301
- slot.appendChild(handle, reconcile(slot, child, rootTag, childInText, node, true).handle);
372
+ else {
373
+ for (const child of kids) {
374
+ slot.appendChild(created, reconcile(slot, child, rootTag, childInText, node, true).handle);
375
+ }
302
376
  }
303
377
  logScrollChildren(node, viewName, tag);
304
378
  // Investigation instrumentation (search-bar-ref "node not committed" bug): scoped to RNS* so
@@ -306,17 +380,23 @@ function reconcile(slot, node, rootTag, hasTextAncestor, renderableParent, force
306
380
  // log below — same debugNodeId on both sides proves/disproves an identity mismatch. Kept
307
381
  // behind DEBUG per <keep_logs_gate_behind_DEBUG>, never removed.
308
382
  if (viewName.startsWith('RNS')) {
309
- dlog(`mirror.set (create) node=${debugNodeId(node)} tag=${tag} view=${viewName}`);
383
+ dlog(`committed (create) node=${debugNodeId(node)} tag=${tag} view=${viewName}`);
310
384
  }
311
- mirror.set(node, {
385
+ // `kids` is stored BY REFERENCE, not copied. With no anchors it IS `node.children`, so the
386
+ // record aliases the live array until the next structural op, which copies it out of the way
387
+ // (markStructureDirty, node.ts). Slicing here instead cost one array per node per commit -
388
+ // 9 002 on a 1 000-row create - and all but the handful of nodes that go on to change threw
389
+ // theirs away unread.
390
+ node.committed = {
312
391
  handle,
313
392
  tag,
314
393
  rootTag,
315
394
  props,
316
- children: kids.slice(),
395
+ children: kids,
317
396
  viewName,
318
397
  parent: renderableParent,
319
- });
398
+ owner: node,
399
+ };
320
400
  return { handle, changed: true };
321
401
  }
322
402
  // Reconcile children first; a child that re-cloned forces this node to re-clone
@@ -331,7 +411,32 @@ function reconcile(slot, node, rootTag, hasTextAncestor, renderableParent, force
331
411
  }
332
412
  logScrollChildren(node, viewName, committed.tag);
333
413
  const childrenChanged = !childrenIdentical(kids, committed.children) || descendantChanged;
334
- const propsChanged = !propsEqual(committed.props, props);
414
+ // The fast lane. No prop write was recorded on this node since its last commit, so its Fabric
415
+ // payload is by construction the one the mirror already holds: reuse that object by reference -
416
+ // no rebuild, no allocation, no deep compare - and carry it into the mirror below untouched.
417
+ //
418
+ // This is what propsDirty (node.ts) exists for. Every node on the clone-bubble path from a
419
+ // changed leaf up to the root takes this branch, as does the synthetic container that
420
+ // commitContainer dirties at every single entry. They still re-clone - a persistent parent must
421
+ // point at the new child handles - they just stop paying `fabricProps` + a recursive `propsEqual`
422
+ // to rediscover that nobody touched them.
423
+ //
424
+ // Under DEBUG the saving is handed straight back to check it was honest: an in-place mutation of
425
+ // a style object or of node.props, which no flag can observe, is the same hazard warnIfStale
426
+ // already guards on the skip path, and this lane is open to it identically.
427
+ let props;
428
+ let propsChanged;
429
+ if (ownPropsChanged) {
430
+ profile.propsBuilt += 1;
431
+ props = fabricProps(node);
432
+ propsChanged = !propsEqual(committed.props, props);
433
+ }
434
+ else {
435
+ profile.propsReused += 1;
436
+ props = committed.props;
437
+ propsChanged = false;
438
+ warnIfStale(node, committed, 'props');
439
+ }
335
440
  if (!childrenChanged && !propsChanged) {
336
441
  stats.reused += 1;
337
442
  return { handle: committed.handle, changed: false };
@@ -339,16 +444,23 @@ function reconcile(slot, node, rootTag, hasTextAncestor, renderableParent, force
339
444
  let handle;
340
445
  if (childrenChanged) {
341
446
  stats.cloneChildren += 1;
447
+ // A clone comes back with an EMPTY child list, so every sibling handle has to be handed back
448
+ // one by one — that loop is why touching one row of a thousand costs a thousand JSI crossings
449
+ // at every level up to the root. Where the host accepts the list in the clone call itself it
450
+ // becomes ONE crossing (see supportsCloneWithChildren in fabric.ts).
451
+ const batched = slot.supportsCloneWithChildren;
342
452
  if (propsChanged) {
343
453
  const propsDiff = diffProps(committed.props, props);
344
454
  guardSerializable(propsDiff, viewName, committed.tag);
345
- handle = slot.cloneNodeWithNewChildrenAndProps(committed.handle, propsDiff);
455
+ handle = slot.cloneNodeWithNewChildrenAndProps(committed.handle, propsDiff, batched ? childHandles : undefined);
346
456
  }
347
457
  else {
348
- handle = slot.cloneNodeWithNewChildren(committed.handle);
458
+ handle = slot.cloneNodeWithNewChildren(committed.handle, batched ? childHandles : undefined);
349
459
  }
350
- for (const childHandle of childHandles) {
351
- slot.appendChild(handle, childHandle);
460
+ if (!batched) {
461
+ for (const childHandle of childHandles) {
462
+ slot.appendChild(handle, childHandle);
463
+ }
352
464
  }
353
465
  }
354
466
  else {
@@ -360,22 +472,22 @@ function reconcile(slot, node, rootTag, hasTextAncestor, renderableParent, force
360
472
  // Investigation instrumentation (search-bar-ref "node not committed" bug): see the create-path
361
473
  // dlog above. Kept behind DEBUG per <keep_logs_gate_behind_DEBUG>, never removed.
362
474
  if (viewName.startsWith('RNS')) {
363
- dlog(`mirror.set (update) node=${debugNodeId(node)} tag=${committed.tag} view=${viewName}`);
364
- }
365
- // The clone keeps the node's family, so its reactTag is unchanged; carry it.
366
- mirror.set(node, {
367
- handle,
368
- tag: committed.tag,
369
- rootTag,
370
- props,
371
- // Store the same flattened child list we diffed against. Anchors are retained-tree
372
- // bookkeeping only; keeping raw node.children here makes every anchored subtree look
373
- // structurally changed on the next commit and can re-append already-parented Fabric
374
- // ShadowNode families under a cloned parent.
375
- children: kids.slice(),
376
- viewName,
377
- parent: renderableParent,
378
- });
475
+ dlog(`committed (update) node=${debugNodeId(node)} tag=${committed.tag} view=${viewName}`);
476
+ }
477
+ // Written IN PLACE rather than as a fresh record. The node is the same node, its tag and owner
478
+ // are unchanged by a clone (the clone keeps the family), so replacing the object bought nothing
479
+ // and cost one allocation per changed node per commit - and on an ordinary update that is the
480
+ // whole clone-bubble from the changed leaf up to the root.
481
+ committed.handle = handle;
482
+ committed.rootTag = rootTag;
483
+ committed.props = props;
484
+ // The same flattened child list we diffed against, by reference (see the create path above for
485
+ // why it is not copied). Anchors are retained-tree bookkeeping only; keeping raw node.children
486
+ // here makes every anchored subtree look structurally changed on the next commit and can
487
+ // re-append already-parented Fabric ShadowNode families under a cloned parent.
488
+ committed.children = kids;
489
+ committed.viewName = viewName;
490
+ committed.parent = renderableParent;
379
491
  return { handle, changed: true };
380
492
  }
381
493
  // One persistent synthetic root container per surface, mirroring RN's AppContainer
@@ -407,15 +519,29 @@ function rootContainerFor(rootTag) {
407
519
  // now-stopped surface. Called from unmount (the bridgeless surface-stop path): the host stops then restarts a
408
520
  // surface (Fast Refresh, focus/lifecycle) reusing the same rootTag, and a stale root
409
521
  // container would re-clone dead handles into the new surface -> a blank screen. The old
410
- // container's descendants fall out of every reference and their mirror entries GC.
522
+ // container's descendants fall out of every reference and are collected with the committed
523
+ // records they carry.
411
524
  export function disposeRoot(rootTag) {
412
- if (rootContainers.delete(rootTag))
413
- dlog(`root container disposed root=${rootTag}`);
525
+ // Drop any setNativeProps writes still queued for this surface: their flush is a microtask away
526
+ // and would otherwise commit into a container that no longer exists, re-creating it from scratch.
527
+ pendingByRoot.delete(rootTag);
528
+ const container = rootContainers.get(rootTag);
529
+ if (container === undefined)
530
+ return;
531
+ // BEFORE the delete, which is the only reason the subtree is still reachable. An unmount removes
532
+ // no child, so `sweepDetachedBehaviors` never hears about these nodes — without this every one of
533
+ // them keeps its `afterCommit` registration and its timers, and a restarted surface's commits
534
+ // drain the dead one's hooks forever (`.claude/rules/unmount-does-not-sweep-host-behaviors.md`).
535
+ teardownSubtree(container, detachAnimatedProps);
536
+ rootContainers.delete(rootTag);
537
+ dlog(`root container disposed root=${rootTag}`);
414
538
  }
415
539
  export function commitChildren(rootTag, children) {
416
540
  // The wrapper holds the surface's top-level children; reconcile walks from it so the
417
541
  // whole tree, synthetic root included, goes through the same clone-on-write path.
418
- rootContainerFor(rootTag).children = children.slice();
542
+ const container = rootContainerFor(rootTag);
543
+ container.children = children.slice();
544
+ markStructureDirty(container);
419
545
  commitContainer(rootTag);
420
546
  }
421
547
  // Re-run the scoped commit for a surface from its synthetic root container, reusing
@@ -423,6 +549,9 @@ export function commitChildren(rootTag, children) {
423
549
  // a full mutation->commit and a single-node Animated frame (setNativeProps) funnel here.
424
550
  function commitContainer(rootTag) {
425
551
  const slot = getSlot();
552
+ batchCreate =
553
+ globalThis.__SYMBIOTE_BATCH_CREATE__ === true &&
554
+ slot.supportsCloneWithChildren;
426
555
  const container = rootContainerFor(rootTag);
427
556
  // The synthetic container is dirtied here, at the one entry point, because markDirty can never
428
557
  // bubble up to it: a surface's top-level nodes carry `parent === undefined` (surface.ts sets it
@@ -431,6 +560,11 @@ function commitContainer(rootTag) {
431
560
  // props rebuild per commit and closes the hole for both callers, mutation commit and
432
561
  // setNativeProps alike.
433
562
  markDirty(container);
563
+ // Before the walk, and before either early return: mutations for this tick are done, so a node
564
+ // that `removeChild` unlinked is now either back under a parent (a framework spelling a move as
565
+ // remove-then-reinsert) or gone for good. Costs one Set-size read until an app registers its
566
+ // first host behavior. See host-behavior.ts for why removal cannot answer this itself.
567
+ sweepDetachedBehaviors(container.children, detachAnimatedProps);
434
568
  stats.created = 0;
435
569
  stats.cloneProps = 0;
436
570
  stats.cloneChildren = 0;
@@ -451,8 +585,24 @@ function commitContainer(rootTag) {
451
585
  dlog(`commit root=${rootTag} reconciled changed=${result.changed}`);
452
586
  // The container's identity is stable, so its un-cloned flag is the no-op signal:
453
587
  // an over-scheduled commit that touched nothing makes zero native calls.
588
+ //
589
+ // TRAP FOR BEHAVIOR AUTHORS, and it cost two iterations to find: this return is the gate on
590
+ // `runPostCommitHooks` and `runDeferredAttaches` below. Both exist to retry once FRESH FABRIC TAGS
591
+ // are assigned, and a commit that made zero native calls assigned none — so gating them here is
592
+ // correct, and hoisting them above this line would run every deferred hook on every
593
+ // over-scheduled commit, which is the common case.
594
+ //
595
+ // `afterCommit` is NOT in that group and was gated with them by accident until 2026-09-10. It asks
596
+ // only "props were published", which a no-op commit satisfies, and gating it made it unreachable
597
+ // for exactly the props a behavior OWNS: a fold that strips a prop (TouchableOpacity's `disabled`,
598
+ // Button's `title`/`color`) makes its own commit byte-identical, so the hook that must react to
599
+ // the flip is the one the flip cannot wake. It is drained on both paths now.
600
+ //
601
+ // A behavior needing a turn of the loop with nothing to write at all may still schedule its own
602
+ // (`queueMicrotask`, as Switch's snap-back and Angular's `snapBackIfNeeded` both do).
454
603
  if (!result.changed) {
455
604
  dlog(`commit root=${rootTag} no-op (skipped completeRoot)`);
605
+ runCommittedHooks(isNodeCommitted);
456
606
  return;
457
607
  }
458
608
  const childSet = slot.createChildSet(rootTag);
@@ -463,11 +613,19 @@ function commitContainer(rootTag) {
463
613
  // and ran too early (the Animated native driver binding a props node to a view under
464
614
  // an async-batched commit) retry now. No-op when nothing is pending.
465
615
  runPostCommitHooks();
616
+ // The same moment, for the half of a host behavior that could not run at `attach`. A behavior
617
+ // whose setup needs a Fabric tag (a view command, a native Animated binding, an event attach)
618
+ // declares `attachAfterCommit` and is drained here.
619
+ runDeferredAttaches(isNodeCommitted);
620
+ // After the setup half, never before it: a node carrying both hooks has `attachAfterCommit` seed
621
+ // the mirrors `afterCommit` then compares against. See `runCommittedHooks`.
622
+ runCommittedHooks(isNodeCommitted);
466
623
  if (isDebug()) {
467
624
  const mode = stats.created > 0 && stats.reused === 0 ? 'full' : 'incremental';
468
625
  dlog(`commit root=${rootTag} ${mode} ` +
469
626
  `created=${stats.created} cloneProps=${stats.cloneProps} ` +
470
627
  `cloneChildren=${stats.cloneChildren} reused=${stats.reused} ` +
628
+ `propsBuilt=${profile.propsBuilt} propsReused=${profile.propsReused} ` +
471
629
  `walk=${walkMs.toFixed(3)}ms`);
472
630
  }
473
631
  }
@@ -477,6 +635,8 @@ export function readCommitProfile() {
477
635
  commits: profile.commits,
478
636
  walkMs: profile.walkMs,
479
637
  nodesVisited: profile.nodesVisited,
638
+ propsBuilt: profile.propsBuilt,
639
+ propsReused: profile.propsReused,
480
640
  propWrites: props.writes,
481
641
  propNoops: props.noops,
482
642
  childScans: childScan.scans,
@@ -488,6 +648,8 @@ export function readCommitProfile() {
488
648
  profile.commits = 0;
489
649
  profile.walkMs = 0;
490
650
  profile.nodesVisited = 0;
651
+ profile.propsBuilt = 0;
652
+ profile.propsReused = 0;
491
653
  childScan.scans = 0;
492
654
  childScan.probed = 0;
493
655
  childScan.flattens = 0;
@@ -495,6 +657,230 @@ export function readCommitProfile() {
495
657
  childScan.widest = 0;
496
658
  return snapshot;
497
659
  }
660
+ function commitTargeted(nodes) {
661
+ // ── PLAN FIRST, MUTATE SECOND ──────────────────────────────────────────────────────────────
662
+ // Every precondition is checked and every sibling handle resolved before a single native call,
663
+ // so a bail costs nothing and leaves the tree exactly as it was.
664
+ //
665
+ // This is not hypothetical tidiness: an earlier version validated the ancestor chain up front but
666
+ // resolved SIBLING handles inside the clone loop, so a bail half-way had already re-pointed a
667
+ // node's committed record at a clone that was never handed to completeRoot, and had already
668
+ // cleared its dirty flags - so the general-path fallback then skipped the node as clean and
669
+ // committed an orphan handle. The fallback test in animated-commit-cost.test.ts is what caught it.
670
+ const writes = [];
671
+ for (const node of nodes) {
672
+ const record = committedOf(node);
673
+ if (record === undefined)
674
+ return false;
675
+ // The node's own children must be the ones Fabric holds: this path never descends, so a
676
+ // pending structural change below would be published as if it had not happened.
677
+ if (node.structureDirty)
678
+ return false;
679
+ // THE TWIN OF THE CHECK ABOVE, and the one that was missing. `dirty` is a SUBTREE flag — it
680
+ // means "this node or something under it needs work" — while this path clears it as if it were
681
+ // a self flag. Clearing it over a dirty descendant strands that descendant permanently: the
682
+ // general commit reconciles from the root, finds a clean chain, and never descends again.
683
+ //
684
+ // Direct children are enough, and that is not an approximation. `markDirty` walks up from the
685
+ // dirtied node and STOPS at the first already-dirty ancestor; in this scenario that ancestor is
686
+ // this node, so the chain from any dirty descendant up to here is fully marked — which makes a
687
+ // dirty direct child a certainty whenever a dirty descendant exists. So the check is O(children)
688
+ // rather than a subtree walk, on a path that runs once per animation frame.
689
+ //
690
+ // Reproduced 2026-08-24 by a peer's flag dump after tier-2 made a press ask for its own commit:
691
+ // press the node, dirty its child in the same tick, and the child never commits again. Vue and
692
+ // Solid did not show it because their schedulers rewrite the prop on the node itself and
693
+ // re-dirty the chain — an accident of those schedulers, not a property of this contract.
694
+ if (node.children.some(child => child.dirty)) {
695
+ dlog('commit targeted: dirty descendant, using the general path');
696
+ return false;
697
+ }
698
+ // Counted like the general path's rebuild, because it is one: the meter must not read as though
699
+ // an animation frame builds no payload just because it took the short route.
700
+ profile.propsBuilt += 1;
701
+ const props = fabricProps(node);
702
+ const diff = diffProps(record.props, props);
703
+ if (Object.keys(diff).length === 0) {
704
+ // Fabric already holds these values. Not an error and not a fallback: the general path would
705
+ // reach the same conclusion, after walking the tree to get here. Dropping it from the batch
706
+ // is safe even if a LATER node bails the whole thing — its props genuinely match Fabric, so
707
+ // the general commit skipping it as clean publishes the same tree.
708
+ node.dirty = false;
709
+ node.propsDirty = false;
710
+ dlog(`commit targeted tag=${record.tag} no-op (props identical)`);
711
+ continue;
712
+ }
713
+ writes.push({ node, record, props, diff });
714
+ }
715
+ if (writes.length === 0)
716
+ return true;
717
+ // Build the union of the ancestor chains. `changed` is the whole union keyed by node, so a shared
718
+ // ancestor is entered ONCE however many of the batch's leaves sit under it — which is the entire
719
+ // saving over committing each leaf separately.
720
+ const changed = new Map();
721
+ let unionRoot;
722
+ for (const write of writes) {
723
+ let child = write.node;
724
+ let ancestor = write.record.parent;
725
+ while (ancestor !== undefined) {
726
+ const record = committedOf(ancestor);
727
+ if (record === undefined)
728
+ return false;
729
+ // THE precondition, and the one an earlier version of this function missed. `record.children`
730
+ // is a SNAPSHOT from the last commit. If this ancestor's real child list has moved on - a row
731
+ // added, removed, or reordered - rebuilding its child set from that snapshot silently
732
+ // publishes the OLD structure, with no error anywhere. Caught by the fallback row in
733
+ // animated-commit-cost.test.ts, which appends a sibling and then animates: without this check
734
+ // the new sibling never reached Fabric and every other assertion still passed.
735
+ if (ancestor.structureDirty) {
736
+ dlog('commit targeted: ancestor child list moved on, using the general path');
737
+ return false;
738
+ }
739
+ const seen = changed.get(ancestor);
740
+ if (seen !== undefined) {
741
+ // Already in the union via another leaf. Everything ABOVE it is therefore already in too,
742
+ // and already names this node as changed, so the walk stops here.
743
+ seen.add(child);
744
+ break;
745
+ }
746
+ changed.set(ancestor, new Set([child]));
747
+ if (record.parent === undefined)
748
+ unionRoot = ancestor;
749
+ child = ancestor;
750
+ ancestor = record.parent;
751
+ }
752
+ }
753
+ // Resolve every branch's child handles now, so the clone pass below cannot fail part-way.
754
+ const branches = new Map();
755
+ for (const [node, changedChildren] of changed) {
756
+ const record = committedOf(node);
757
+ if (record === undefined)
758
+ return false;
759
+ const handles = [];
760
+ const slots = new Map();
761
+ for (const child of record.children) {
762
+ const childRecord = committedOf(child);
763
+ // A sibling with no committed record means a structural change is pending, which this path
764
+ // cannot express. Bail before touching anything.
765
+ if (childRecord === undefined) {
766
+ dlog('commit targeted: uncommitted sibling, using the general path');
767
+ return false;
768
+ }
769
+ if (changedChildren.has(child))
770
+ slots.set(child, handles.length);
771
+ handles.push(childRecord.handle);
772
+ }
773
+ if (slots.size !== changedChildren.size) {
774
+ // A changed child is not in its parent's committed child list — an uncommitted move.
775
+ dlog('commit targeted: child not in committed set, using the general path');
776
+ return false;
777
+ }
778
+ branches.set(node, { record, handles, slots });
779
+ }
780
+ // Every write is on one surface (the caller batches by rootTag), so the chains converge on that
781
+ // surface's synthetic container and nowhere else. Checked HERE, the last statement of the plan,
782
+ // so the clone pass below has no way left to bail after it has started mutating.
783
+ const rootBranch = unionRoot === undefined ? undefined : branches.get(unionRoot);
784
+ if (unionRoot === undefined || rootBranch === undefined)
785
+ return false;
786
+ const rootTag = rootBranch.record.rootTag;
787
+ // ── MUTATE ─────────────────────────────────────────────────────────────────────────────────
788
+ const slot = getSlot();
789
+ const walkStart = performance.now();
790
+ const cloned = new Map();
791
+ for (const write of writes) {
792
+ guardSerializable(write.diff, write.record.viewName, write.record.tag);
793
+ const handle = slot.cloneNodeWithNewProps(write.record.handle, write.diff);
794
+ stats.cloneProps += 1;
795
+ write.record.handle = handle;
796
+ write.record.props = write.props;
797
+ write.node.dirty = false;
798
+ write.node.propsDirty = false;
799
+ cloned.set(write.node, handle);
800
+ }
801
+ // Re-clone each branch exactly once, deepest first. The recursion is bounded by the tree depth,
802
+ // and every branch it touches is in the plan above, so nothing here can bail.
803
+ const cloneBranch = (node) => {
804
+ const done = cloned.get(node);
805
+ if (done !== undefined)
806
+ return done;
807
+ const branch = branches.get(node);
808
+ // Unreachable by construction: cloneBranch is only ever called on a node the plan put in
809
+ // `branches` or the write loop put in `cloned`. Leaving the committed handle in place is the
810
+ // safe direction if that ever stops being true, and the oracle row would report it.
811
+ if (branch === undefined)
812
+ return undefined;
813
+ for (const [child, index] of branch.slots) {
814
+ const childHandle = cloneBranch(child);
815
+ if (childHandle !== undefined)
816
+ branch.handles[index] = childHandle;
817
+ }
818
+ const batched = slot.supportsCloneWithChildren;
819
+ const handle = slot.cloneNodeWithNewChildren(branch.record.handle, batched ? branch.handles : undefined);
820
+ stats.cloneChildren += 1;
821
+ if (!batched)
822
+ for (const childHandle of branch.handles)
823
+ slot.appendChild(handle, childHandle);
824
+ branch.record.handle = handle;
825
+ cloned.set(node, handle);
826
+ return handle;
827
+ };
828
+ const rootHandle = cloneBranch(unionRoot) ?? rootBranch.record.handle;
829
+ const childSet = slot.createChildSet(rootTag);
830
+ slot.appendChildToSet(childSet, rootHandle);
831
+ slot.completeRoot(rootTag, childSet);
832
+ profile.walkMs += performance.now() - walkStart;
833
+ profile.commits += 1;
834
+ profile.nodesVisited += cloned.size;
835
+ runPostCommitHooks();
836
+ // Same reasoning as the container path's drain, reached from the other end: this commit
837
+ // published props, which is all `afterCommit` asks. Missing here it was unreachable for a
838
+ // behavior whose only write is its own bookkeeping — no framework prop changes, so no container
839
+ // commit follows to drain it. `runDeferredAttaches` deliberately stays out: a targeted commit
840
+ // only happens after a container commit has already assigned tags and drained them.
841
+ runCommittedHooks(isNodeCommitted);
842
+ dlog(`commit targeted root=${rootTag} leaves=${writes.length} ` +
843
+ `union=${branches.size}`);
844
+ return true;
845
+ }
846
+ // A JS-driven Animated frame lands in setNativeProps once per animated leaf. Five animations on
847
+ // five rows of one list therefore used to mean FIVE commits per frame: five completeRoots, and
848
+ // every shared ancestor re-cloned - with its whole child list re-appended - five times over. The
849
+ // dirty-set census (`symbiote-perf-measurement` skill) measured that appendChild is the real floor
850
+ // of a commit and that it multiplies almost exactly with the commit count. So the win here is not a
851
+ // faster commit, it is FEWER of them.
852
+ //
853
+ // The batch never drops a value, and that is a rule, not a hope. Merging writes to DIFFERENT nodes
854
+ // is free - each carries its own value and one commit publishes them all. The single case where
855
+ // merging WOULD lose a value is a second write to a node already pending, so that case does not
856
+ // merge: it publishes the pending batch first, synchronously, and opens a new one. Hence:
857
+ //
858
+ // N writes to N different nodes in one task -> one completeRoot, all N values land
859
+ // two writes to the SAME node in one task -> two completeRoots, both values land
860
+ //
861
+ // The second row costs nothing in practice: an Animated.Value ticks its props node exactly once per
862
+ // rAF (animations/timing.ts's onFrame calls onUpdate once, then schedules the next frame), so
863
+ // concurrent animations are always distinct nodes. It exists for the paths that genuinely can write
864
+ // twice - two animations bound to one prop of one node, or Animated.event when the host delivers
865
+ // two scroll events in a single task.
866
+ const pendingByRoot = new Map();
867
+ let flushScheduled = false;
868
+ // Publish every pending write: one commit per surface. A surface with exactly one pending node
869
+ // takes the targeted chain clone (4.5x, §4a); with several it takes the general walk, which already
870
+ // visits only dirty paths and reaches all of them under a single completeRoot - which is the point.
871
+ export function flushNativeProps() {
872
+ flushScheduled = false;
873
+ if (pendingByRoot.size === 0)
874
+ return;
875
+ const batches = [...pendingByRoot];
876
+ pendingByRoot.clear();
877
+ for (const [rootTag, nodes] of batches) {
878
+ if (commitTargeted(nodes))
879
+ continue;
880
+ dlog(`flush native props root=${rootTag} nodes=${nodes.size} (general)`);
881
+ commitContainer(rootTag);
882
+ }
883
+ }
498
884
  // Targeted per-frame prop write for the JS-driven Animated path. RN flushes an
499
885
  // animation frame with an in-place `instance.setNativeProps(...)`; we have no
500
886
  // in-place mutation (Fabric is persistent), so a frame is one scoped commit: mutate
@@ -503,11 +889,17 @@ export function readCommitProfile() {
503
889
  // by reference, and emits a single completeRoot. This is the "slow tier", viable for a
504
890
  // single shallow animation; driving the animation natively is the answer for scale.
505
891
  export function setNativeProps(node, partial) {
506
- const record = mirror.get(node);
892
+ const record = committedOf(node);
507
893
  if (record === undefined) {
508
894
  dlog('setNativeProps skipped: node not committed');
509
895
  return;
510
896
  }
897
+ // BEFORE the prop writes below, deliberately: the pending batch still has to publish the value
898
+ // this node holds right now, and mutating first would overwrite the very thing being preserved.
899
+ if (pendingByRoot.get(record.rootTag)?.has(node) === true) {
900
+ dlog(`setNativeProps tag=${record.tag} written twice in one task, flushing`);
901
+ flushNativeProps();
902
+ }
511
903
  for (const [key, value] of Object.entries(partial)) {
512
904
  if (key === 'style') {
513
905
  // A partial style override MERGES onto the declarative style (RN semantics):
@@ -520,18 +912,57 @@ export function setNativeProps(node, partial) {
520
912
  }
521
913
  else {
522
914
  node.props[key] = value;
915
+ // Writes `node.props` directly, so it owes the aria gate for the same reason it owes the
916
+ // props mark below: `setProp` is where that flag is normally raised and this path bypasses
917
+ // it. An `aria-*` arriving only through setNativeProps would otherwise never be folded.
918
+ if (!node.hasAriaAlias && isAriaAliasKey(key))
919
+ node.hasAriaAlias = true;
523
920
  }
524
921
  }
525
- // Writes node.props directly rather than through setProp, so it owes its own mark.
526
- markDirty(node);
922
+ // Writes node.props directly rather than through setProp, so it owes its own mark - and it owes
923
+ // the PROPS mark specifically: markDirty alone would send the node down the fast lane above,
924
+ // which reuses the mirror's payload by reference and would drop the Animated frame entirely.
925
+ markPropsDirty(node);
527
926
  dlog(`setNativeProps root=${record.rootTag} tag=${record.tag} keys=${Object.keys(partial)}`);
528
- commitContainer(record.rootTag);
927
+ // Queued, not committed: every write made in this task publishes together at the microtask
928
+ // boundary. See the batching note above commitTargeted's caller block for why that loses nothing.
929
+ requestCommitFor(node);
930
+ }
931
+ /**
932
+ * Publish a node whose props were changed OUTSIDE any renderer mutation.
933
+ *
934
+ * Dirtying is not publishing. Every other write reaches Fabric because the framework's own commit
935
+ * follows it; a change driven by a NATIVE EVENT has no such follow-up — `native-events.ts`
936
+ * requests no commit and no adapter does either, so a node marked dirty from an event handler
937
+ * simply stays dirty. The host-behavior press path (`setNodePressed` for `:active`) is the first
938
+ * caller that is not `setNativeProps`, and `setNodeHidden`'s React twin never needed it because
939
+ * the reconciler is already in its commit phase when it calls.
940
+ *
941
+ * Queued rather than committed on the spot, sharing `setNativeProps`' batch: several writes in one
942
+ * task publish together at the microtask boundary, one commit per surface.
943
+ */
944
+ export function requestCommitFor(node) {
945
+ const record = committedOf(node);
946
+ if (record === undefined) {
947
+ dlog('requestCommitFor skipped: node not committed');
948
+ return;
949
+ }
950
+ let pending = pendingByRoot.get(record.rootTag);
951
+ if (pending === undefined) {
952
+ pending = new Set();
953
+ pendingByRoot.set(record.rootTag, pending);
954
+ }
955
+ pending.add(node);
956
+ if (!flushScheduled) {
957
+ flushScheduled = true;
958
+ queueMicrotask(flushNativeProps);
959
+ }
529
960
  }
530
961
  // The committed reactTag of a node (stable across clone-on-write), for binding the
531
962
  // native Animated driver via connectAnimatedNodeToView. Undefined until the node
532
963
  // has been committed at least once.
533
964
  export function getNativeTag(node) {
534
- return mirror.get(node)?.tag;
965
+ return committedOf(node)?.tag;
535
966
  }
536
967
  // Actions waiting for their node's first commit. An adapter that wires an imperative/native call at
537
968
  // lifecycle time (autoFocus, a native Animated.event attach) can run BEFORE completeRoot under an
@@ -550,7 +981,7 @@ registerPostCommit(() => {
550
981
  // silently no-opping. Returns a cancel fn (drop the pending retry, e.g. on unmount).
551
982
  export function whenCommitted(node, action) {
552
983
  const attempt = () => {
553
- if (mirror.get(node) === undefined)
984
+ if (committedOf(node) === undefined)
554
985
  return false;
555
986
  action();
556
987
  return true;
@@ -564,16 +995,16 @@ export function whenCommitted(node, action) {
564
995
  // The node's current Fabric handle (the createNode/clone return value), identical in
565
996
  // kind to React's stateNode.node, for the native driver's ShadowNodeFamily path.
566
997
  export function getNativeNode(node) {
567
- return mirror.get(node)?.handle;
998
+ return committedOf(node)?.handle;
568
999
  }
569
1000
  // Imperative view command (e.g. TextInput's setTextAndSelection / focus / blur),
570
1001
  // aimed at a node's CURRENT Fabric handle. Only valid once the node has been
571
- // committed at least once; its handle is read from the mirror.
1002
+ // committed at least once; its handle is read from the node's committed record.
572
1003
  export function dispatchViewCommand(node, commandName, args) {
573
- const record = mirror.get(node);
1004
+ const record = committedOf(node);
574
1005
  if (record === undefined) {
575
- // node=... compares directly against the mirror.set logs above (same debugNodeId scheme) to
576
- // prove/disprove a node-identity mismatch — see the search-bar-ref investigation note there.
1006
+ // node=... compares directly against the `committed (create|update)` logs above (same
1007
+ // debugNodeId scheme) to prove/disprove an identity mismatch — see the note there.
577
1008
  dlog(`dispatchViewCommand "${commandName}" skipped: node not committed (node=${debugNodeId(node)} component=${node.component})`);
578
1009
  return;
579
1010
  }
@@ -586,7 +1017,7 @@ export function dispatchViewCommand(node, commandName, args) {
586
1017
  // with the STRING eventType; the C++ side maps it to the platform's accessibility-event kind.
587
1018
  // A no-op (logged) until the node is committed; there is no handle yet.
588
1019
  export function sendAccessibilityEvent(node, eventType) {
589
- const record = mirror.get(node);
1020
+ const record = committedOf(node);
590
1021
  if (record === undefined) {
591
1022
  dlog(`sendAccessibilityEvent "${eventType}" skipped: node not committed`);
592
1023
  return;
@@ -598,7 +1029,7 @@ export function sendAccessibilityEvent(node, eventType) {
598
1029
  // measure family that reanimated / gesture-handler / scroll-to reach through). A
599
1030
  // no-op with a dlog until the node is committed; there is no handle to measure yet.
600
1031
  export function measure(node, callback) {
601
- const record = mirror.get(node);
1032
+ const record = committedOf(node);
602
1033
  if (record === undefined) {
603
1034
  dlog('measure skipped: node not committed');
604
1035
  return;
@@ -606,7 +1037,7 @@ export function measure(node, callback) {
606
1037
  getSlot().measure(record.handle, callback);
607
1038
  }
608
1039
  export function measureInWindow(node, callback) {
609
- const record = mirror.get(node);
1040
+ const record = committedOf(node);
610
1041
  if (record === undefined) {
611
1042
  dlog('measureInWindow skipped: node not committed');
612
1043
  return;
@@ -617,8 +1048,8 @@ export function measureInWindow(node, callback) {
617
1048
  // signature is (relative, onSuccess, onFail) but the native slot wants the fail
618
1049
  // callback before success, so the order is swapped here.
619
1050
  export function measureLayout(node, relativeTo, onSuccess, onFail = () => { }) {
620
- const record = mirror.get(node);
621
- const relativeRecord = mirror.get(relativeTo);
1051
+ const record = committedOf(node);
1052
+ const relativeRecord = committedOf(relativeTo);
622
1053
  if (record === undefined || relativeRecord === undefined) {
623
1054
  dlog('measureLayout skipped: a node is not committed');
624
1055
  return;