effectable 1.1.0 → 1.1.1-canary.1

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.
@@ -354,8 +354,9 @@ class GraphRuntime {
354
354
  journal.rolledBack = true;
355
355
  const cleanupErrors = [];
356
356
  const instance = fiber.instance;
357
- // 1. Disable scheduler hook
358
- if (journal.schedulerHookAttached === true && instance !== null) {
357
+ // 1. Disable scheduler hook (pre-mount buffer may be attached before children,
358
+ // before journal.schedulerHookAttached is set after successful startup).
359
+ if (instance !== null) {
359
360
  try {
360
361
  this.clearUpdateHook(instance);
361
362
  this.dirtyFibers.delete(fiber);
@@ -382,46 +383,29 @@ class GraphRuntime {
382
383
  cleanupErrors.push(err instanceof Error ? err : new Error(String(err)));
383
384
  }
384
385
  }
385
- // 4. Run failed-startup cleanup (if instance exists)
386
- let cleanupPromise = null;
387
- if (instance !== null) {
388
- try {
389
- const cleanupRes = fiber.engine.runFailedCleanup(instance, true);
390
- if (isThenable(cleanupRes)) {
391
- cleanupPromise = cleanupRes;
392
- }
393
- }
394
- catch (err) {
395
- cleanupErrors.push(err instanceof Error ? err : new Error(String(err)));
396
- }
397
- }
398
- // 5. Destroy mounted children in reverse order.
386
+ // 4. Destroy mounted children in reverse order BEFORE parent onUnmount.
387
+ // Documented teardown contract is children → parent; running parent
388
+ // runFailedCleanup first left children alive during parent onUnmount and
389
+ // also called parent onUnmount when startup never ran (wasMounted=true).
399
390
  // Pass cleanupErrors so nested destroy is best-effort: a throwing
400
391
  // ref-clear/disposer on one grandchild must not skip remaining siblings.
401
392
  // Those nodes were never attached to currentRoot, so failStop cannot reclaim them.
402
- const destroyChildrenAndFinalize = () => {
393
+ const destroyChildrenThenParentCleanup = () => {
403
394
  const children = journal.mountedChildren;
404
395
  for (let i = children.length - 1; i >= 0; i -= 1) {
405
396
  try {
406
397
  const destroyRes = this.destroyFiber(children[i], cleanupErrors);
407
398
  if (isThenable(destroyRes)) {
408
- return this.continueRollbackDestroyAsync(children, i, destroyRes, primaryError, cleanupErrors);
399
+ return this.continueRollbackDestroyAsync(children, i, destroyRes, primaryError, cleanupErrors, fiber, instance);
409
400
  }
410
401
  }
411
402
  catch (err) {
412
403
  cleanupErrors.push(err instanceof Error ? err : new Error(String(err)));
413
404
  }
414
405
  }
415
- // 6. Finalize: attach cleanup errors to primary error
416
- this.finalizeRollback(primaryError, cleanupErrors);
406
+ return this.finishRollbackParentCleanup(fiber, instance, primaryError, cleanupErrors);
417
407
  };
418
- if (cleanupPromise !== null) {
419
- return cleanupPromise.then(() => destroyChildrenAndFinalize(), (err) => {
420
- cleanupErrors.push(err instanceof Error ? err : new Error(String(err)));
421
- return destroyChildrenAndFinalize();
422
- });
423
- }
424
- return destroyChildrenAndFinalize();
408
+ return destroyChildrenThenParentCleanup();
425
409
  }
426
410
  /**
427
411
  * Async continuation of rollback child destruction after one child's destroy returned a Promise.
@@ -431,9 +415,11 @@ class GraphRuntime {
431
415
  * @param {Promise<void>} pending - Promise from destroying the previous child
432
416
  * @param {Error} primaryError - original materialization error
433
417
  * @param {Error[]} cleanupErrors - accumulated cleanup errors
418
+ * @param {RuntimeFiber<unknown>} fiber - parent fiber being rolled back
419
+ * @param {Component<unknown, unknown> | null} instance - parent instance (for post-child cleanup)
434
420
  * @returns {Promise<void>}
435
421
  */
436
- async continueRollbackDestroyAsync(children, lastIdx, pending, primaryError, cleanupErrors) {
422
+ async continueRollbackDestroyAsync(children, lastIdx, pending, primaryError, cleanupErrors, fiber, instance) {
437
423
  try {
438
424
  await pending;
439
425
  }
@@ -451,6 +437,44 @@ class GraphRuntime {
451
437
  cleanupErrors.push(err instanceof Error ? err : new Error(String(err)));
452
438
  }
453
439
  }
440
+ const cleanupRes = this.finishRollbackParentCleanup(fiber, instance, primaryError, cleanupErrors);
441
+ if (isThenable(cleanupRes)) {
442
+ await cleanupRes;
443
+ }
444
+ }
445
+ /**
446
+ * After rollback destroyed children: run parent failed-cleanup only when startup
447
+ * actually ran (`status !== 'registered'`), then attach cleanup errors and rethrow.
448
+ *
449
+ * @param {RuntimeFiber<unknown>} fiber - parent fiber being rolled back
450
+ * @param {Component<unknown, unknown> | null} instance - parent instance
451
+ * @param {Error} primaryError - original materialization error
452
+ * @param {Error[]} cleanupErrors - accumulated cleanup errors
453
+ * @returns {void | Promise<void>} always rejects via {@link finalizeRollback}
454
+ */
455
+ finishRollbackParentCleanup(fiber, instance, primaryError, cleanupErrors) {
456
+ if (instance !== null) {
457
+ // `registered` ⇒ runStartup never entered; do not invent an onUnmount.
458
+ // `failed` (deferFailedCleanup) / other post-startup statuses ⇒ wasMounted.
459
+ const wasMounted = fiber.engine.getStatus() !== 'registered';
460
+ if (wasMounted) {
461
+ try {
462
+ const cleanupRes = fiber.engine.runFailedCleanup(instance, true);
463
+ if (isThenable(cleanupRes)) {
464
+ return cleanupRes.then(() => {
465
+ this.finalizeRollback(primaryError, cleanupErrors);
466
+ }, (err) => {
467
+ cleanupErrors.push(err instanceof Error ? err : new Error(String(err)));
468
+ this.finalizeRollback(primaryError, cleanupErrors);
469
+ });
470
+ }
471
+ }
472
+ catch (err) {
473
+ cleanupErrors.push(err instanceof Error ? err : new Error(String(err)));
474
+ }
475
+ }
476
+ fiber.lifecycleStatus = fiber.engine.getStatus();
477
+ }
454
478
  this.finalizeRollback(primaryError, cleanupErrors);
455
479
  }
456
480
  /**
@@ -486,10 +510,12 @@ class GraphRuntime {
486
510
  }
487
511
  }
488
512
  /**
489
- * Pre-mount buffer: `setState` during `onMount` cannot yet schedule reconcile
490
- * (the live hook is injected after startup). Marks the fiber; {@link injectUpdateHook}
491
- * after startup will call {@link scheduleUpdate}
492
- * (deferred until the mount pass completes).
513
+ * Pre-mount buffer: `setState` cannot yet schedule reconcile (the live hook is
514
+ * injected after startup). Marks the fiber; {@link injectUpdateHook} after
515
+ * startup will call {@link scheduleUpdate} (deferred until the mount pass completes).
516
+ *
517
+ * Injected before child materialization so descendant `onMount` callbacks that
518
+ * `setState` an ancestor are buffered instead of silently dropped.
493
519
  *
494
520
  * @param {Component<unknown, unknown>} instance - instance before/during startup
495
521
  * @param {RuntimeFiber<unknown>} fiber - instance fiber
@@ -608,16 +634,30 @@ class GraphRuntime {
608
634
  finally {
609
635
  this.operationInProgress = false;
610
636
  }
637
+ // setState during reconcile/unmount defers the microtask (operationInProgress).
638
+ // Kick once the queue is idle so the flush cannot overlap in-flight graph mutations.
639
+ if (this.dirtyFibers.size > 0 && this._state === RUNTIME_STATE.ACTIVE) {
640
+ this.scheduleDirtyFlushMicrotask();
641
+ }
611
642
  }
612
643
  /**
613
644
  * Queues one dirty-flush microtask and publishes {@link activeFlush} for await from `reconcile`.
614
645
  *
615
- * Skip scheduling when runtime is FAILED.
646
+ * Skip scheduling when runtime is not ACTIVE (IDLE / FAILED / UNMOUNTING / UNMOUNTED),
647
+ * a public graph operation is in flight, or an async flush is already running. Callers that
648
+ * mutate `dirtyFibers` during those windows must kick this method again after the tree is
649
+ * ACTIVE and idle ({@link GraphRuntime.mount} / {@link processOperationQueue}), or after the
650
+ * outer flush finishes (end-of-pass kick).
616
651
  *
617
652
  * @returns {void}
618
653
  */
619
654
  scheduleDirtyFlushMicrotask() {
620
- if (this.flushScheduled || this.state === RUNTIME_STATE.FAILED || this.state === RUNTIME_STATE.UNMOUNTING || this.state === RUNTIME_STATE.UNMOUNTED) {
655
+ // Skip while an async flush is in flight: setState during PLACE/onMount only
656
+ // enqueues dirtyFibers; the outer pass kicks the next microtask when it finishes.
657
+ if (this.flushScheduled ||
658
+ this.flushing ||
659
+ this.operationInProgress ||
660
+ this.state !== RUNTIME_STATE.ACTIVE) {
621
661
  return;
622
662
  }
623
663
  this.flushScheduled = true;
@@ -652,10 +692,20 @@ class GraphRuntime {
652
692
  */
653
693
  async flushDirtyFibers() {
654
694
  this.flushScheduled = false;
655
- if (this.state === RUNTIME_STATE.FAILED || this.state === RUNTIME_STATE.UNMOUNTING || this.state === RUNTIME_STATE.UNMOUNTED || this.flushing) {
695
+ if (this.state === RUNTIME_STATE.FAILED || this.state === RUNTIME_STATE.UNMOUNTING || this.state === RUNTIME_STATE.UNMOUNTED) {
656
696
  this.dirtyFibers.clear();
657
697
  return;
658
698
  }
699
+ // Re-entrant call while an async flush awaits: keep dirtyFibers for the outer
700
+ // pass's end-of-flush kick. Clearing here would silently drop setState updates.
701
+ if (this.flushing) {
702
+ return;
703
+ }
704
+ // Do not mutate the graph until mount has published currentRoot, and never
705
+ // overlap a public reconcile/unmount (those leave dirtyFibers queued).
706
+ if (this.state !== RUNTIME_STATE.ACTIVE || this.operationInProgress) {
707
+ return;
708
+ }
659
709
  this.dirtyFlushPassCount += 1;
660
710
  this.flushing = true;
661
711
  const snapshot = Array.from(this.dirtyFibers);
@@ -673,9 +723,17 @@ class GraphRuntime {
673
723
  }
674
724
  catch (error) {
675
725
  this.flushing = false;
676
- // Notify error handler before fail-stop
726
+ // Notify error handler before fail-stop. The hook must not be allowed to
727
+ // skip fail-stop: a throwing observer would leave the runtime ACTIVE with
728
+ // fibers already run through runFiberFailedCleanup, and the microtask
729
+ // `.catch(() => {})` would swallow the failure silently.
677
730
  if (this.onAutoReconcileError !== null) {
678
- this.onAutoReconcileError(error);
731
+ try {
732
+ this.onAutoReconcileError(error);
733
+ }
734
+ catch {
735
+ // Observer/logging failures are non-fatal relative to fail-stop.
736
+ }
679
737
  }
680
738
  // Fail-stop on unrecoverable dirty-flush error
681
739
  const failError = error instanceof Error ? error : new Error(String(error));
@@ -694,9 +752,14 @@ class GraphRuntime {
694
752
  this.dirtyFibers.clear();
695
753
  this.dirtyFlushPassCount = 0;
696
754
  const loopError = new Error(`GraphRuntime: dirty flush exceeded ${String(graphRuntime_constants_1.GRAPH_RUNTIME_MAX_DIRTY_FLUSH_PASSES)} passes (anti-loop)`);
697
- // Notify error handler before fail-stop
755
+ // Same contract as the catch path: observer throw must not skip fail-stop.
698
756
  if (this.onAutoReconcileError !== null) {
699
- this.onAutoReconcileError(loopError);
757
+ try {
758
+ this.onAutoReconcileError(loopError);
759
+ }
760
+ catch {
761
+ // Observer/logging failures are non-fatal relative to fail-stop.
762
+ }
700
763
  }
701
764
  // Fail-stop on loop limit
702
765
  const failRes = this.failStop(loopError);
@@ -736,7 +799,7 @@ class GraphRuntime {
736
799
  return childrenRes.then((nextChildren) => {
737
800
  fiber.children = nextChildren;
738
801
  }, (error) => {
739
- const cleanupResult = this.runFiberFailedCleanup(fiber);
802
+ const cleanupResult = this.runFiberFailedCleanup(fiber, error);
740
803
  if (isThenable(cleanupResult)) {
741
804
  return cleanupResult.then(() => {
742
805
  throw error;
@@ -748,7 +811,7 @@ class GraphRuntime {
748
811
  fiber.children = childrenRes;
749
812
  }
750
813
  catch (error) {
751
- const cleanupResult = this.runFiberFailedCleanup(fiber);
814
+ const cleanupResult = this.runFiberFailedCleanup(fiber, error);
752
815
  if (isThenable(cleanupResult)) {
753
816
  return cleanupResult.then(() => {
754
817
  throw error;
@@ -777,7 +840,13 @@ class GraphRuntime {
777
840
  try {
778
841
  const res = rt.materialize(root, null, initialScope);
779
842
  rt.currentRoot = isThenable(res) ? await res : res;
843
+ if (rt._state === RUNTIME_STATE.FAILED) {
844
+ throw rt.terminalError ?? new Error('[Effectable] GraphRuntime: terminal failure during mount.');
845
+ }
780
846
  rt.state = RUNTIME_STATE.ACTIVE;
847
+ if (rt.dirtyFibers.size > 0) {
848
+ rt.scheduleDirtyFlushMicrotask();
849
+ }
781
850
  return rt;
782
851
  }
783
852
  catch (error) {
@@ -1069,11 +1138,30 @@ class GraphRuntime {
1069
1138
  mountedChildren: [],
1070
1139
  },
1071
1140
  };
1072
- // Recursively materialize children before running the parent's lifecycle
1073
- const childVnodes = this.getChildVnodes(instance, vnode.children);
1074
- // Validate unique keys BEFORE materialization (Option A: React v16.5 contract)
1075
- // Prevent partial tree construction when duplicate keys are present
1076
- this.validateUniqueKeys(childVnodes.map(vnode => ({ vnode, instance: null })), fiber, 'current');
1141
+ // Buffer setState before children mount: a descendant onMount may call
1142
+ // ancestor setState (via props callback). Without this hook, that update
1143
+ // mutates state but never schedules reconcile.
1144
+ this.injectPreMountUpdateHook(instance, fiber);
1145
+ // compose() / duplicate-key validation can throw after the pre-mount hook is
1146
+ // attached. Roll back so the hook is cleared (otherwise the failed fiber is
1147
+ // abandoned with a live SCHEDULE_UPDATE_HOOK and no teardown).
1148
+ let childVnodes;
1149
+ try {
1150
+ childVnodes = this.getChildVnodes(instance, vnode.children);
1151
+ // Validate unique keys BEFORE materialization (Option A: React v16.5 contract)
1152
+ // Prevent partial tree construction when duplicate keys are present
1153
+ this.validateUniqueKeys(childVnodes.map(vnode => ({ vnode, instance: null })), fiber, 'current');
1154
+ }
1155
+ catch (err) {
1156
+ const error = err instanceof Error ? err : new Error(String(err));
1157
+ const rollbackRes = this.rollbackFailedMaterialization(fiber, error);
1158
+ if (isThenable(rollbackRes)) {
1159
+ return rollbackRes.then(() => {
1160
+ throw error;
1161
+ });
1162
+ }
1163
+ throw error;
1164
+ }
1077
1165
  for (let i = 0; i < childVnodes.length; i++) {
1078
1166
  const childVnode = childVnodes[i];
1079
1167
  let childRes;
@@ -1131,9 +1219,9 @@ class GraphRuntime {
1131
1219
  throw err;
1132
1220
  }
1133
1221
  // Run lifecycle after all children are materialized.
1134
- // Pre-mount hook buffers setState from onMount until injectUpdateHook.
1135
- this.injectPreMountUpdateHook(instance, fiber);
1136
- const startupRes = engine.runStartup(instance);
1222
+ // Pre-mount hook was injected before children (covers ancestor setState
1223
+ // from descendant onMount and setState during this node's onMount).
1224
+ const startupRes = engine.runStartup(instance, { deferFailedCleanup: true });
1137
1225
  if (isThenable(startupRes)) {
1138
1226
  return this.finalizeMaterializeAsync(fiber, engine, startupRes);
1139
1227
  }
@@ -1225,8 +1313,8 @@ class GraphRuntime {
1225
1313
  }
1226
1314
  throw err;
1227
1315
  }
1228
- this.injectPreMountUpdateHook(instance, fiber);
1229
- const startupRes = engine.runStartup(instance);
1316
+ // Pre-mount hook already injected at the start of materialize (before children).
1317
+ const startupRes = engine.runStartup(instance, { deferFailedCleanup: true });
1230
1318
  const resolved = isThenable(startupRes) ? await startupRes : startupRes;
1231
1319
  if (!resolved.ok) {
1232
1320
  const error = resolved.error instanceof Error ? resolved.error : new Error(String(resolved.error));
@@ -1292,7 +1380,9 @@ class GraphRuntime {
1292
1380
  }
1293
1381
  // Type or key changed — destroy the old node, create a new one.
1294
1382
  // Sync fast-path if both destroy and materialize completed synchronously.
1295
- const destroyRes = this.destroyFiber(current);
1383
+ // Collect cleanup errors (ref clear / disposer) so a throwing finalize cannot
1384
+ // abort REPLACE and fail-stop the surviving tree — same best-effort contract as unmount.
1385
+ const destroyRes = this.destroyFiber(current, []);
1296
1386
  if (isThenable(destroyRes)) {
1297
1387
  return destroyRes.then(() => this.materialize(nextVnode, parentFiber, parentScope));
1298
1388
  }
@@ -1328,7 +1418,7 @@ class GraphRuntime {
1328
1418
  contextChanged = (0, context_1.injectContextFields)(instance, parentScope);
1329
1419
  }
1330
1420
  catch (error) {
1331
- const cleanupResult = this.runFiberFailedCleanup(current);
1421
+ const cleanupResult = this.runFiberFailedCleanup(current, error);
1332
1422
  if (isThenable(cleanupResult)) {
1333
1423
  return cleanupResult.then(() => {
1334
1424
  throw error;
@@ -1346,7 +1436,7 @@ class GraphRuntime {
1346
1436
  instance.onUpdate(prevProps, instance.props);
1347
1437
  }
1348
1438
  catch (error) {
1349
- const cleanupResult = this.runFiberFailedCleanup(current);
1439
+ const cleanupResult = this.runFiberFailedCleanup(current, error);
1350
1440
  if (isThenable(cleanupResult)) {
1351
1441
  return cleanupResult.then(() => {
1352
1442
  throw error;
@@ -1355,15 +1445,16 @@ class GraphRuntime {
1355
1445
  throw error;
1356
1446
  }
1357
1447
  }
1358
- // Commit ref transition: clear old ref if changed, bind new ref
1359
- this.commitRef(current.vnode.ref, instance, nextVnode.ref, instance);
1360
1448
  // Reconcile child nodes (sync fast-path if all children are sync).
1449
+ // Ref commit is deferred to applyFiberUpdate so a compose()/child-reconcile
1450
+ // failure cannot leave nextRef.current pointing at an instance that failStop
1451
+ // will destroy while fiber.vnode.ref still holds the previous ref.
1361
1452
  let nextChildVnodes;
1362
1453
  try {
1363
1454
  nextChildVnodes = this.getChildVnodes(instance, nextVnode.children);
1364
1455
  }
1365
1456
  catch (error) {
1366
- const cleanupResult = this.runFiberFailedCleanup(current);
1457
+ const cleanupResult = this.runFiberFailedCleanup(current, error);
1367
1458
  if (isThenable(cleanupResult)) {
1368
1459
  return cleanupResult.then(() => {
1369
1460
  throw error;
@@ -1382,28 +1473,91 @@ class GraphRuntime {
1382
1473
  return current;
1383
1474
  }
1384
1475
  /**
1385
- * Fiber cleanup after update/compose error: `runFailedCleanup` + bus dispose.
1386
- * Does not leave the node in `ready`.
1476
+ * Fiber cleanup after update/compose error: destroy children first (children → parent),
1477
+ * then `runFailedCleanup` + bus dispose. Does not leave the node in `ready`.
1478
+ * Child destroy / disposer errors are attached to `primaryError.rollbackErrors` when provided
1479
+ * so fail-stop observability still surfaces them (children are no longer destroyed in failStop).
1387
1480
  *
1388
1481
  * @param {RuntimeFiber<unknown>} fiber - fiber that failed
1482
+ * @param {unknown} [primaryError] - originating error to attach cleanup failures onto
1389
1483
  * @returns {void | Promise<void>}
1390
1484
  */
1391
- runFiberFailedCleanup(fiber) {
1485
+ runFiberFailedCleanup(fiber, primaryError) {
1392
1486
  const instance = fiber.instance;
1393
1487
  if (instance === null) {
1394
1488
  return;
1395
1489
  }
1396
1490
  this.clearUpdateHook(instance);
1397
1491
  this.dirtyFibers.delete(fiber);
1398
- const cleanupResult = fiber.engine.runFailedCleanup(instance, true);
1399
- if (isThenable(cleanupResult)) {
1400
- return cleanupResult.then(() => {
1401
- this.disposeEffectableRuntimeBusWiring(fiber);
1402
- fiber.lifecycleStatus = fiber.engine.getStatus();
1403
- });
1492
+ const cleanupErrors = [];
1493
+ const children = fiber.children;
1494
+ const attachCleanupErrors = () => {
1495
+ if (cleanupErrors.length > 0 &&
1496
+ primaryError instanceof Error) {
1497
+ const existing = primaryError.rollbackErrors;
1498
+ primaryError.rollbackErrors =
1499
+ existing !== undefined ? existing.concat(cleanupErrors) : cleanupErrors.slice();
1500
+ }
1501
+ };
1502
+ const finishParent = () => {
1503
+ fiber.children = [];
1504
+ const cleanupResult = fiber.engine.runFailedCleanup(instance, true);
1505
+ if (isThenable(cleanupResult)) {
1506
+ return cleanupResult.then(() => {
1507
+ this.disposeEffectableRuntimeBusWiring(fiber);
1508
+ fiber.lifecycleStatus = fiber.engine.getStatus();
1509
+ attachCleanupErrors();
1510
+ }, (err) => {
1511
+ cleanupErrors.push(err instanceof Error ? err : new Error(String(err)));
1512
+ this.disposeEffectableRuntimeBusWiring(fiber);
1513
+ fiber.lifecycleStatus = fiber.engine.getStatus();
1514
+ attachCleanupErrors();
1515
+ throw err;
1516
+ });
1517
+ }
1518
+ this.disposeEffectableRuntimeBusWiring(fiber);
1519
+ fiber.lifecycleStatus = fiber.engine.getStatus();
1520
+ attachCleanupErrors();
1521
+ };
1522
+ for (let i = children.length - 1; i >= 0; i -= 1) {
1523
+ try {
1524
+ const destroyRes = this.destroyFiber(children[i], cleanupErrors);
1525
+ if (isThenable(destroyRes)) {
1526
+ return destroyRes.then(async () => {
1527
+ for (let j = i - 1; j >= 0; j -= 1) {
1528
+ try {
1529
+ const r = this.destroyFiber(children[j], cleanupErrors);
1530
+ if (isThenable(r)) {
1531
+ await r;
1532
+ }
1533
+ }
1534
+ catch (err) {
1535
+ cleanupErrors.push(err instanceof Error ? err : new Error(String(err)));
1536
+ }
1537
+ }
1538
+ return finishParent();
1539
+ }, async (err) => {
1540
+ cleanupErrors.push(err instanceof Error ? err : new Error(String(err)));
1541
+ for (let j = i - 1; j >= 0; j -= 1) {
1542
+ try {
1543
+ const r = this.destroyFiber(children[j], cleanupErrors);
1544
+ if (isThenable(r)) {
1545
+ await r;
1546
+ }
1547
+ }
1548
+ catch (inner) {
1549
+ cleanupErrors.push(inner instanceof Error ? inner : new Error(String(inner)));
1550
+ }
1551
+ }
1552
+ return finishParent();
1553
+ });
1554
+ }
1555
+ }
1556
+ catch (err) {
1557
+ cleanupErrors.push(err instanceof Error ? err : new Error(String(err)));
1558
+ }
1404
1559
  }
1405
- this.disposeEffectableRuntimeBusWiring(fiber);
1406
- fiber.lifecycleStatus = fiber.engine.getStatus();
1560
+ return finishParent();
1407
1561
  }
1408
1562
  /**
1409
1563
  * Applies the reconcile result to the current fiber in-place.
@@ -1418,6 +1572,11 @@ class GraphRuntime {
1418
1572
  * @returns {void}
1419
1573
  */
1420
1574
  applyFiberUpdate(current, nextVnode, parentFiber, parentScope, nextChildren) {
1575
+ const instance = current.instance;
1576
+ if (instance !== null) {
1577
+ // Commit ref only after compose + child reconcile succeeded.
1578
+ this.commitRef(current.vnode.ref, instance, nextVnode.ref, instance);
1579
+ }
1421
1580
  current.vnode = nextVnode;
1422
1581
  current.parentFiber = parentFiber;
1423
1582
  current.children = nextChildren;
@@ -1584,9 +1743,11 @@ class GraphRuntime {
1584
1743
  nextChildren.push(isThenable(newRes) ? await newRes : newRes);
1585
1744
  }
1586
1745
  }
1587
- // Destroy remaining unpaired current children (keyed)
1746
+ // Destroy remaining unpaired current children (keyed).
1747
+ // Best-effort: collect finalize errors so one throwing ref clear cannot
1748
+ // skip remaining orphans and fail-stop the whole runtime.
1588
1749
  for (const [, orphan] of keyedCurrentMap) {
1589
- const d = this.destroyFiber(orphan);
1750
+ const d = this.destroyFiber(orphan, []);
1590
1751
  if (isThenable(d)) {
1591
1752
  await d;
1592
1753
  }
@@ -1616,11 +1777,11 @@ class GraphRuntime {
1616
1777
  }
1617
1778
  }
1618
1779
  }
1619
- // Destroy remaining unpaired unkeyed children
1780
+ // Destroy remaining unpaired unkeyed children (best-effort finalize errors).
1620
1781
  for (let i = unkeyedIdx; i < unkeyedCurrent.length; i += 1) {
1621
1782
  const orphan = unkeyedCurrent[i];
1622
1783
  if (orphan !== undefined) {
1623
- const d = this.destroyFiber(orphan);
1784
+ const d = this.destroyFiber(orphan, []);
1624
1785
  if (isThenable(d)) {
1625
1786
  await d;
1626
1787
  }