effectable 1.1.0 → 1.2.0-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.
Files changed (37) hide show
  1. package/README.md +10 -0
  2. package/build/bootstrap/bootstrap.d.ts.map +1 -1
  3. package/build/bootstrap/bootstrap.js +6 -2
  4. package/build/bootstrap/bootstrap.js.map +1 -1
  5. package/build/bootstrap/types.d.ts +4 -2
  6. package/build/bootstrap/types.d.ts.map +1 -1
  7. package/build/component/Component.d.ts.map +1 -1
  8. package/build/component/Component.js +16 -4
  9. package/build/component/Component.js.map +1 -1
  10. package/build/component/GraphRuntime.d.ts +43 -9
  11. package/build/component/GraphRuntime.d.ts.map +1 -1
  12. package/build/component/GraphRuntime.js +247 -74
  13. package/build/component/GraphRuntime.js.map +1 -1
  14. package/build/component/lifecycle.d.ts +11 -2
  15. package/build/component/lifecycle.d.ts.map +1 -1
  16. package/build/component/lifecycle.js +21 -4
  17. package/build/component/lifecycle.js.map +1 -1
  18. package/build/connect/connect.d.ts +12 -0
  19. package/build/connect/connect.d.ts.map +1 -1
  20. package/build/connect/connect.js +174 -34
  21. package/build/connect/connect.js.map +1 -1
  22. package/build/runtime/BusDecorators.d.ts +3 -0
  23. package/build/runtime/BusDecorators.d.ts.map +1 -1
  24. package/build/runtime/BusDecorators.js +21 -9
  25. package/build/runtime/BusDecorators.js.map +1 -1
  26. package/build/runtime/EventBus.d.ts +12 -0
  27. package/build/runtime/EventBus.d.ts.map +1 -1
  28. package/build/runtime/EventBus.js +23 -3
  29. package/build/runtime/EventBus.js.map +1 -1
  30. package/build/store/middleware.d.ts +4 -0
  31. package/build/store/middleware.d.ts.map +1 -1
  32. package/build/store/middleware.js +15 -4
  33. package/build/store/middleware.js.map +1 -1
  34. package/build/store/selector.d.ts.map +1 -1
  35. package/build/store/selector.js +6 -2
  36. package/build/store/selector.js.map +1 -1
  37. package/package.json +1 -1
@@ -11,8 +11,17 @@
11
11
  * - Inject contexts (@UseContext) and bind refs on mount.
12
12
  * - Pass updated props into existing instances during reconcile.
13
13
  * - Serialize all graph operations through a single operation queue.
14
- * - Fail-stop on unrecoverable errors: mark runtime FAILED, reject later reconcile,
15
- * unmount stays safe.
14
+ *
15
+ * Fail-safe contracts (public behavior):
16
+ * - Unrecoverable reconcile / dirty-flush errors **fail-stop**: mark runtime FAILED, tear the
17
+ * tree down children→parent, reject later `reconcile`. `onAutoReconcileError` is best-effort
18
+ * observability — a throwing observer cannot skip fail-stop.
19
+ * - Dirty flush runs only while ACTIVE and the operation queue is idle; `setState` during child
20
+ * materialization is buffered and applied after the mount/reconcile pass.
21
+ * - UPDATE `commitRef` runs only after successful compose + child reconcile; compose/key
22
+ * validation failure rolls back the fiber (including premount hooks) without leaving phantoms.
23
+ * - Failed cleanup is children-first and does not invoke a phantom parent `onUnmount`.
24
+ * - Orphan DELETE finalize is best-effort so survivors are not fail-stopped for cleanup noise.
16
25
  *
17
26
  * Current limitations:
18
27
  * - Work loop is synchronous (no priority lanes — next increment).
@@ -64,6 +73,9 @@ const RUNTIME_STATE = {
64
73
  /**
65
74
  * Runtime engine for a declarative component tree.
66
75
  *
76
+ * After fail-stop the instance is terminal (`FAILED`): further `reconcile` rejects;
77
+ * `unmount` remains safe. See the module overview for the fail-safe contracts.
78
+ *
67
79
  * Usage:
68
80
  * ```typescript
69
81
  * const runtime = await GraphRuntime.mount(h(AppRoot));
@@ -354,8 +366,9 @@ class GraphRuntime {
354
366
  journal.rolledBack = true;
355
367
  const cleanupErrors = [];
356
368
  const instance = fiber.instance;
357
- // 1. Disable scheduler hook
358
- if (journal.schedulerHookAttached === true && instance !== null) {
369
+ // 1. Disable scheduler hook (pre-mount buffer may be attached before children,
370
+ // before journal.schedulerHookAttached is set after successful startup).
371
+ if (instance !== null) {
359
372
  try {
360
373
  this.clearUpdateHook(instance);
361
374
  this.dirtyFibers.delete(fiber);
@@ -382,46 +395,29 @@ class GraphRuntime {
382
395
  cleanupErrors.push(err instanceof Error ? err : new Error(String(err)));
383
396
  }
384
397
  }
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.
398
+ // 4. Destroy mounted children in reverse order BEFORE parent onUnmount.
399
+ // Documented teardown contract is children → parent; running parent
400
+ // runFailedCleanup first left children alive during parent onUnmount and
401
+ // also called parent onUnmount when startup never ran (wasMounted=true).
399
402
  // Pass cleanupErrors so nested destroy is best-effort: a throwing
400
403
  // ref-clear/disposer on one grandchild must not skip remaining siblings.
401
404
  // Those nodes were never attached to currentRoot, so failStop cannot reclaim them.
402
- const destroyChildrenAndFinalize = () => {
405
+ const destroyChildrenThenParentCleanup = () => {
403
406
  const children = journal.mountedChildren;
404
407
  for (let i = children.length - 1; i >= 0; i -= 1) {
405
408
  try {
406
409
  const destroyRes = this.destroyFiber(children[i], cleanupErrors);
407
410
  if (isThenable(destroyRes)) {
408
- return this.continueRollbackDestroyAsync(children, i, destroyRes, primaryError, cleanupErrors);
411
+ return this.continueRollbackDestroyAsync(children, i, destroyRes, primaryError, cleanupErrors, fiber, instance);
409
412
  }
410
413
  }
411
414
  catch (err) {
412
415
  cleanupErrors.push(err instanceof Error ? err : new Error(String(err)));
413
416
  }
414
417
  }
415
- // 6. Finalize: attach cleanup errors to primary error
416
- this.finalizeRollback(primaryError, cleanupErrors);
418
+ return this.finishRollbackParentCleanup(fiber, instance, primaryError, cleanupErrors);
417
419
  };
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();
420
+ return destroyChildrenThenParentCleanup();
425
421
  }
426
422
  /**
427
423
  * Async continuation of rollback child destruction after one child's destroy returned a Promise.
@@ -431,9 +427,11 @@ class GraphRuntime {
431
427
  * @param {Promise<void>} pending - Promise from destroying the previous child
432
428
  * @param {Error} primaryError - original materialization error
433
429
  * @param {Error[]} cleanupErrors - accumulated cleanup errors
430
+ * @param {RuntimeFiber<unknown>} fiber - parent fiber being rolled back
431
+ * @param {Component<unknown, unknown> | null} instance - parent instance (for post-child cleanup)
434
432
  * @returns {Promise<void>}
435
433
  */
436
- async continueRollbackDestroyAsync(children, lastIdx, pending, primaryError, cleanupErrors) {
434
+ async continueRollbackDestroyAsync(children, lastIdx, pending, primaryError, cleanupErrors, fiber, instance) {
437
435
  try {
438
436
  await pending;
439
437
  }
@@ -451,6 +449,44 @@ class GraphRuntime {
451
449
  cleanupErrors.push(err instanceof Error ? err : new Error(String(err)));
452
450
  }
453
451
  }
452
+ const cleanupRes = this.finishRollbackParentCleanup(fiber, instance, primaryError, cleanupErrors);
453
+ if (isThenable(cleanupRes)) {
454
+ await cleanupRes;
455
+ }
456
+ }
457
+ /**
458
+ * After rollback destroyed children: run parent failed-cleanup only when startup
459
+ * actually ran (`status !== 'registered'`), then attach cleanup errors and rethrow.
460
+ *
461
+ * @param {RuntimeFiber<unknown>} fiber - parent fiber being rolled back
462
+ * @param {Component<unknown, unknown> | null} instance - parent instance
463
+ * @param {Error} primaryError - original materialization error
464
+ * @param {Error[]} cleanupErrors - accumulated cleanup errors
465
+ * @returns {void | Promise<void>} always rejects via {@link finalizeRollback}
466
+ */
467
+ finishRollbackParentCleanup(fiber, instance, primaryError, cleanupErrors) {
468
+ if (instance !== null) {
469
+ // `registered` ⇒ runStartup never entered; do not invent an onUnmount.
470
+ // `failed` (deferFailedCleanup) / other post-startup statuses ⇒ wasMounted.
471
+ const wasMounted = fiber.engine.getStatus() !== 'registered';
472
+ if (wasMounted) {
473
+ try {
474
+ const cleanupRes = fiber.engine.runFailedCleanup(instance, true);
475
+ if (isThenable(cleanupRes)) {
476
+ return cleanupRes.then(() => {
477
+ this.finalizeRollback(primaryError, cleanupErrors);
478
+ }, (err) => {
479
+ cleanupErrors.push(err instanceof Error ? err : new Error(String(err)));
480
+ this.finalizeRollback(primaryError, cleanupErrors);
481
+ });
482
+ }
483
+ }
484
+ catch (err) {
485
+ cleanupErrors.push(err instanceof Error ? err : new Error(String(err)));
486
+ }
487
+ }
488
+ fiber.lifecycleStatus = fiber.engine.getStatus();
489
+ }
454
490
  this.finalizeRollback(primaryError, cleanupErrors);
455
491
  }
456
492
  /**
@@ -486,10 +522,12 @@ class GraphRuntime {
486
522
  }
487
523
  }
488
524
  /**
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).
525
+ * Pre-mount buffer: `setState` cannot yet schedule reconcile (the live hook is
526
+ * injected after startup). Marks the fiber; {@link injectUpdateHook} after
527
+ * startup will call {@link scheduleUpdate} (deferred until the mount pass completes).
528
+ *
529
+ * Injected before child materialization so descendant `onMount` callbacks that
530
+ * `setState` an ancestor are buffered instead of silently dropped.
493
531
  *
494
532
  * @param {Component<unknown, unknown>} instance - instance before/during startup
495
533
  * @param {RuntimeFiber<unknown>} fiber - instance fiber
@@ -608,16 +646,30 @@ class GraphRuntime {
608
646
  finally {
609
647
  this.operationInProgress = false;
610
648
  }
649
+ // setState during reconcile/unmount defers the microtask (operationInProgress).
650
+ // Kick once the queue is idle so the flush cannot overlap in-flight graph mutations.
651
+ if (this.dirtyFibers.size > 0 && this._state === RUNTIME_STATE.ACTIVE) {
652
+ this.scheduleDirtyFlushMicrotask();
653
+ }
611
654
  }
612
655
  /**
613
656
  * Queues one dirty-flush microtask and publishes {@link activeFlush} for await from `reconcile`.
614
657
  *
615
- * Skip scheduling when runtime is FAILED.
658
+ * Skip scheduling when runtime is not ACTIVE (IDLE / FAILED / UNMOUNTING / UNMOUNTED),
659
+ * a public graph operation is in flight, or an async flush is already running. Callers that
660
+ * mutate `dirtyFibers` during those windows must kick this method again after the tree is
661
+ * ACTIVE and idle ({@link GraphRuntime.mount} / {@link processOperationQueue}), or after the
662
+ * outer flush finishes (end-of-pass kick).
616
663
  *
617
664
  * @returns {void}
618
665
  */
619
666
  scheduleDirtyFlushMicrotask() {
620
- if (this.flushScheduled || this.state === RUNTIME_STATE.FAILED || this.state === RUNTIME_STATE.UNMOUNTING || this.state === RUNTIME_STATE.UNMOUNTED) {
667
+ // Skip while an async flush is in flight: setState during PLACE/onMount only
668
+ // enqueues dirtyFibers; the outer pass kicks the next microtask when it finishes.
669
+ if (this.flushScheduled ||
670
+ this.flushing ||
671
+ this.operationInProgress ||
672
+ this.state !== RUNTIME_STATE.ACTIVE) {
621
673
  return;
622
674
  }
623
675
  this.flushScheduled = true;
@@ -652,10 +704,20 @@ class GraphRuntime {
652
704
  */
653
705
  async flushDirtyFibers() {
654
706
  this.flushScheduled = false;
655
- if (this.state === RUNTIME_STATE.FAILED || this.state === RUNTIME_STATE.UNMOUNTING || this.state === RUNTIME_STATE.UNMOUNTED || this.flushing) {
707
+ if (this.state === RUNTIME_STATE.FAILED || this.state === RUNTIME_STATE.UNMOUNTING || this.state === RUNTIME_STATE.UNMOUNTED) {
656
708
  this.dirtyFibers.clear();
657
709
  return;
658
710
  }
711
+ // Re-entrant call while an async flush awaits: keep dirtyFibers for the outer
712
+ // pass's end-of-flush kick. Clearing here would silently drop setState updates.
713
+ if (this.flushing) {
714
+ return;
715
+ }
716
+ // Do not mutate the graph until mount has published currentRoot, and never
717
+ // overlap a public reconcile/unmount (those leave dirtyFibers queued).
718
+ if (this.state !== RUNTIME_STATE.ACTIVE || this.operationInProgress) {
719
+ return;
720
+ }
659
721
  this.dirtyFlushPassCount += 1;
660
722
  this.flushing = true;
661
723
  const snapshot = Array.from(this.dirtyFibers);
@@ -673,9 +735,17 @@ class GraphRuntime {
673
735
  }
674
736
  catch (error) {
675
737
  this.flushing = false;
676
- // Notify error handler before fail-stop
738
+ // Notify error handler before fail-stop. The hook must not be allowed to
739
+ // skip fail-stop: a throwing observer would leave the runtime ACTIVE with
740
+ // fibers already run through runFiberFailedCleanup, and the microtask
741
+ // `.catch(() => {})` would swallow the failure silently.
677
742
  if (this.onAutoReconcileError !== null) {
678
- this.onAutoReconcileError(error);
743
+ try {
744
+ this.onAutoReconcileError(error);
745
+ }
746
+ catch {
747
+ // Observer/logging failures are non-fatal relative to fail-stop.
748
+ }
679
749
  }
680
750
  // Fail-stop on unrecoverable dirty-flush error
681
751
  const failError = error instanceof Error ? error : new Error(String(error));
@@ -694,9 +764,14 @@ class GraphRuntime {
694
764
  this.dirtyFibers.clear();
695
765
  this.dirtyFlushPassCount = 0;
696
766
  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
767
+ // Same contract as the catch path: observer throw must not skip fail-stop.
698
768
  if (this.onAutoReconcileError !== null) {
699
- this.onAutoReconcileError(loopError);
769
+ try {
770
+ this.onAutoReconcileError(loopError);
771
+ }
772
+ catch {
773
+ // Observer/logging failures are non-fatal relative to fail-stop.
774
+ }
700
775
  }
701
776
  // Fail-stop on loop limit
702
777
  const failRes = this.failStop(loopError);
@@ -736,7 +811,7 @@ class GraphRuntime {
736
811
  return childrenRes.then((nextChildren) => {
737
812
  fiber.children = nextChildren;
738
813
  }, (error) => {
739
- const cleanupResult = this.runFiberFailedCleanup(fiber);
814
+ const cleanupResult = this.runFiberFailedCleanup(fiber, error);
740
815
  if (isThenable(cleanupResult)) {
741
816
  return cleanupResult.then(() => {
742
817
  throw error;
@@ -748,7 +823,7 @@ class GraphRuntime {
748
823
  fiber.children = childrenRes;
749
824
  }
750
825
  catch (error) {
751
- const cleanupResult = this.runFiberFailedCleanup(fiber);
826
+ const cleanupResult = this.runFiberFailedCleanup(fiber, error);
752
827
  if (isThenable(cleanupResult)) {
753
828
  return cleanupResult.then(() => {
754
829
  throw error;
@@ -777,7 +852,13 @@ class GraphRuntime {
777
852
  try {
778
853
  const res = rt.materialize(root, null, initialScope);
779
854
  rt.currentRoot = isThenable(res) ? await res : res;
855
+ if (rt._state === RUNTIME_STATE.FAILED) {
856
+ throw rt.terminalError ?? new Error('[Effectable] GraphRuntime: terminal failure during mount.');
857
+ }
780
858
  rt.state = RUNTIME_STATE.ACTIVE;
859
+ if (rt.dirtyFibers.size > 0) {
860
+ rt.scheduleDirtyFlushMicrotask();
861
+ }
781
862
  return rt;
782
863
  }
783
864
  catch (error) {
@@ -1069,11 +1150,30 @@ class GraphRuntime {
1069
1150
  mountedChildren: [],
1070
1151
  },
1071
1152
  };
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');
1153
+ // Buffer setState before children mount: a descendant onMount may call
1154
+ // ancestor setState (via props callback). Without this hook, that update
1155
+ // mutates state but never schedules reconcile.
1156
+ this.injectPreMountUpdateHook(instance, fiber);
1157
+ // compose() / duplicate-key validation can throw after the pre-mount hook is
1158
+ // attached. Roll back so the hook is cleared (otherwise the failed fiber is
1159
+ // abandoned with a live SCHEDULE_UPDATE_HOOK and no teardown).
1160
+ let childVnodes;
1161
+ try {
1162
+ childVnodes = this.getChildVnodes(instance, vnode.children);
1163
+ // Validate unique keys BEFORE materialization (Option A: React v16.5 contract)
1164
+ // Prevent partial tree construction when duplicate keys are present
1165
+ this.validateUniqueKeys(childVnodes.map(vnode => ({ vnode, instance: null })), fiber, 'current');
1166
+ }
1167
+ catch (err) {
1168
+ const error = err instanceof Error ? err : new Error(String(err));
1169
+ const rollbackRes = this.rollbackFailedMaterialization(fiber, error);
1170
+ if (isThenable(rollbackRes)) {
1171
+ return rollbackRes.then(() => {
1172
+ throw error;
1173
+ });
1174
+ }
1175
+ throw error;
1176
+ }
1077
1177
  for (let i = 0; i < childVnodes.length; i++) {
1078
1178
  const childVnode = childVnodes[i];
1079
1179
  let childRes;
@@ -1131,9 +1231,9 @@ class GraphRuntime {
1131
1231
  throw err;
1132
1232
  }
1133
1233
  // 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);
1234
+ // Pre-mount hook was injected before children (covers ancestor setState
1235
+ // from descendant onMount and setState during this node's onMount).
1236
+ const startupRes = engine.runStartup(instance, { deferFailedCleanup: true });
1137
1237
  if (isThenable(startupRes)) {
1138
1238
  return this.finalizeMaterializeAsync(fiber, engine, startupRes);
1139
1239
  }
@@ -1225,8 +1325,8 @@ class GraphRuntime {
1225
1325
  }
1226
1326
  throw err;
1227
1327
  }
1228
- this.injectPreMountUpdateHook(instance, fiber);
1229
- const startupRes = engine.runStartup(instance);
1328
+ // Pre-mount hook already injected at the start of materialize (before children).
1329
+ const startupRes = engine.runStartup(instance, { deferFailedCleanup: true });
1230
1330
  const resolved = isThenable(startupRes) ? await startupRes : startupRes;
1231
1331
  if (!resolved.ok) {
1232
1332
  const error = resolved.error instanceof Error ? resolved.error : new Error(String(resolved.error));
@@ -1292,7 +1392,9 @@ class GraphRuntime {
1292
1392
  }
1293
1393
  // Type or key changed — destroy the old node, create a new one.
1294
1394
  // Sync fast-path if both destroy and materialize completed synchronously.
1295
- const destroyRes = this.destroyFiber(current);
1395
+ // Collect cleanup errors (ref clear / disposer) so a throwing finalize cannot
1396
+ // abort REPLACE and fail-stop the surviving tree — same best-effort contract as unmount.
1397
+ const destroyRes = this.destroyFiber(current, []);
1296
1398
  if (isThenable(destroyRes)) {
1297
1399
  return destroyRes.then(() => this.materialize(nextVnode, parentFiber, parentScope));
1298
1400
  }
@@ -1328,7 +1430,7 @@ class GraphRuntime {
1328
1430
  contextChanged = (0, context_1.injectContextFields)(instance, parentScope);
1329
1431
  }
1330
1432
  catch (error) {
1331
- const cleanupResult = this.runFiberFailedCleanup(current);
1433
+ const cleanupResult = this.runFiberFailedCleanup(current, error);
1332
1434
  if (isThenable(cleanupResult)) {
1333
1435
  return cleanupResult.then(() => {
1334
1436
  throw error;
@@ -1346,7 +1448,7 @@ class GraphRuntime {
1346
1448
  instance.onUpdate(prevProps, instance.props);
1347
1449
  }
1348
1450
  catch (error) {
1349
- const cleanupResult = this.runFiberFailedCleanup(current);
1451
+ const cleanupResult = this.runFiberFailedCleanup(current, error);
1350
1452
  if (isThenable(cleanupResult)) {
1351
1453
  return cleanupResult.then(() => {
1352
1454
  throw error;
@@ -1355,15 +1457,16 @@ class GraphRuntime {
1355
1457
  throw error;
1356
1458
  }
1357
1459
  }
1358
- // Commit ref transition: clear old ref if changed, bind new ref
1359
- this.commitRef(current.vnode.ref, instance, nextVnode.ref, instance);
1360
1460
  // Reconcile child nodes (sync fast-path if all children are sync).
1461
+ // Ref commit is deferred to applyFiberUpdate so a compose()/child-reconcile
1462
+ // failure cannot leave nextRef.current pointing at an instance that failStop
1463
+ // will destroy while fiber.vnode.ref still holds the previous ref.
1361
1464
  let nextChildVnodes;
1362
1465
  try {
1363
1466
  nextChildVnodes = this.getChildVnodes(instance, nextVnode.children);
1364
1467
  }
1365
1468
  catch (error) {
1366
- const cleanupResult = this.runFiberFailedCleanup(current);
1469
+ const cleanupResult = this.runFiberFailedCleanup(current, error);
1367
1470
  if (isThenable(cleanupResult)) {
1368
1471
  return cleanupResult.then(() => {
1369
1472
  throw error;
@@ -1382,28 +1485,91 @@ class GraphRuntime {
1382
1485
  return current;
1383
1486
  }
1384
1487
  /**
1385
- * Fiber cleanup after update/compose error: `runFailedCleanup` + bus dispose.
1386
- * Does not leave the node in `ready`.
1488
+ * Fiber cleanup after update/compose error: destroy children first (children → parent),
1489
+ * then `runFailedCleanup` + bus dispose. Does not leave the node in `ready`.
1490
+ * Child destroy / disposer errors are attached to `primaryError.rollbackErrors` when provided
1491
+ * so fail-stop observability still surfaces them (children are no longer destroyed in failStop).
1387
1492
  *
1388
1493
  * @param {RuntimeFiber<unknown>} fiber - fiber that failed
1494
+ * @param {unknown} [primaryError] - originating error to attach cleanup failures onto
1389
1495
  * @returns {void | Promise<void>}
1390
1496
  */
1391
- runFiberFailedCleanup(fiber) {
1497
+ runFiberFailedCleanup(fiber, primaryError) {
1392
1498
  const instance = fiber.instance;
1393
1499
  if (instance === null) {
1394
1500
  return;
1395
1501
  }
1396
1502
  this.clearUpdateHook(instance);
1397
1503
  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
- });
1504
+ const cleanupErrors = [];
1505
+ const children = fiber.children;
1506
+ const attachCleanupErrors = () => {
1507
+ if (cleanupErrors.length > 0 &&
1508
+ primaryError instanceof Error) {
1509
+ const existing = primaryError.rollbackErrors;
1510
+ primaryError.rollbackErrors =
1511
+ existing !== undefined ? existing.concat(cleanupErrors) : cleanupErrors.slice();
1512
+ }
1513
+ };
1514
+ const finishParent = () => {
1515
+ fiber.children = [];
1516
+ const cleanupResult = fiber.engine.runFailedCleanup(instance, true);
1517
+ if (isThenable(cleanupResult)) {
1518
+ return cleanupResult.then(() => {
1519
+ this.disposeEffectableRuntimeBusWiring(fiber);
1520
+ fiber.lifecycleStatus = fiber.engine.getStatus();
1521
+ attachCleanupErrors();
1522
+ }, (err) => {
1523
+ cleanupErrors.push(err instanceof Error ? err : new Error(String(err)));
1524
+ this.disposeEffectableRuntimeBusWiring(fiber);
1525
+ fiber.lifecycleStatus = fiber.engine.getStatus();
1526
+ attachCleanupErrors();
1527
+ throw err;
1528
+ });
1529
+ }
1530
+ this.disposeEffectableRuntimeBusWiring(fiber);
1531
+ fiber.lifecycleStatus = fiber.engine.getStatus();
1532
+ attachCleanupErrors();
1533
+ };
1534
+ for (let i = children.length - 1; i >= 0; i -= 1) {
1535
+ try {
1536
+ const destroyRes = this.destroyFiber(children[i], cleanupErrors);
1537
+ if (isThenable(destroyRes)) {
1538
+ return destroyRes.then(async () => {
1539
+ for (let j = i - 1; j >= 0; j -= 1) {
1540
+ try {
1541
+ const r = this.destroyFiber(children[j], cleanupErrors);
1542
+ if (isThenable(r)) {
1543
+ await r;
1544
+ }
1545
+ }
1546
+ catch (err) {
1547
+ cleanupErrors.push(err instanceof Error ? err : new Error(String(err)));
1548
+ }
1549
+ }
1550
+ return finishParent();
1551
+ }, async (err) => {
1552
+ cleanupErrors.push(err instanceof Error ? err : new Error(String(err)));
1553
+ for (let j = i - 1; j >= 0; j -= 1) {
1554
+ try {
1555
+ const r = this.destroyFiber(children[j], cleanupErrors);
1556
+ if (isThenable(r)) {
1557
+ await r;
1558
+ }
1559
+ }
1560
+ catch (inner) {
1561
+ cleanupErrors.push(inner instanceof Error ? inner : new Error(String(inner)));
1562
+ }
1563
+ }
1564
+ return finishParent();
1565
+ });
1566
+ }
1567
+ }
1568
+ catch (err) {
1569
+ cleanupErrors.push(err instanceof Error ? err : new Error(String(err)));
1570
+ }
1404
1571
  }
1405
- this.disposeEffectableRuntimeBusWiring(fiber);
1406
- fiber.lifecycleStatus = fiber.engine.getStatus();
1572
+ return finishParent();
1407
1573
  }
1408
1574
  /**
1409
1575
  * Applies the reconcile result to the current fiber in-place.
@@ -1418,6 +1584,11 @@ class GraphRuntime {
1418
1584
  * @returns {void}
1419
1585
  */
1420
1586
  applyFiberUpdate(current, nextVnode, parentFiber, parentScope, nextChildren) {
1587
+ const instance = current.instance;
1588
+ if (instance !== null) {
1589
+ // Commit ref only after compose + child reconcile succeeded.
1590
+ this.commitRef(current.vnode.ref, instance, nextVnode.ref, instance);
1591
+ }
1421
1592
  current.vnode = nextVnode;
1422
1593
  current.parentFiber = parentFiber;
1423
1594
  current.children = nextChildren;
@@ -1584,9 +1755,11 @@ class GraphRuntime {
1584
1755
  nextChildren.push(isThenable(newRes) ? await newRes : newRes);
1585
1756
  }
1586
1757
  }
1587
- // Destroy remaining unpaired current children (keyed)
1758
+ // Destroy remaining unpaired current children (keyed).
1759
+ // Best-effort: collect finalize errors so one throwing ref clear cannot
1760
+ // skip remaining orphans and fail-stop the whole runtime.
1588
1761
  for (const [, orphan] of keyedCurrentMap) {
1589
- const d = this.destroyFiber(orphan);
1762
+ const d = this.destroyFiber(orphan, []);
1590
1763
  if (isThenable(d)) {
1591
1764
  await d;
1592
1765
  }
@@ -1616,11 +1789,11 @@ class GraphRuntime {
1616
1789
  }
1617
1790
  }
1618
1791
  }
1619
- // Destroy remaining unpaired unkeyed children
1792
+ // Destroy remaining unpaired unkeyed children (best-effort finalize errors).
1620
1793
  for (let i = unkeyedIdx; i < unkeyedCurrent.length; i += 1) {
1621
1794
  const orphan = unkeyedCurrent[i];
1622
1795
  if (orphan !== undefined) {
1623
- const d = this.destroyFiber(orphan);
1796
+ const d = this.destroyFiber(orphan, []);
1624
1797
  if (isThenable(d)) {
1625
1798
  await d;
1626
1799
  }