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.
- package/README.md +10 -0
- package/build/bootstrap/bootstrap.d.ts.map +1 -1
- package/build/bootstrap/bootstrap.js +6 -2
- package/build/bootstrap/bootstrap.js.map +1 -1
- package/build/bootstrap/types.d.ts +4 -2
- package/build/bootstrap/types.d.ts.map +1 -1
- package/build/component/Component.d.ts.map +1 -1
- package/build/component/Component.js +16 -4
- package/build/component/Component.js.map +1 -1
- package/build/component/GraphRuntime.d.ts +43 -9
- package/build/component/GraphRuntime.d.ts.map +1 -1
- package/build/component/GraphRuntime.js +247 -74
- package/build/component/GraphRuntime.js.map +1 -1
- package/build/component/lifecycle.d.ts +11 -2
- package/build/component/lifecycle.d.ts.map +1 -1
- package/build/component/lifecycle.js +21 -4
- package/build/component/lifecycle.js.map +1 -1
- package/build/connect/connect.d.ts +12 -0
- package/build/connect/connect.d.ts.map +1 -1
- package/build/connect/connect.js +174 -34
- package/build/connect/connect.js.map +1 -1
- package/build/runtime/BusDecorators.d.ts +3 -0
- package/build/runtime/BusDecorators.d.ts.map +1 -1
- package/build/runtime/BusDecorators.js +21 -9
- package/build/runtime/BusDecorators.js.map +1 -1
- package/build/runtime/EventBus.d.ts +12 -0
- package/build/runtime/EventBus.d.ts.map +1 -1
- package/build/runtime/EventBus.js +23 -3
- package/build/runtime/EventBus.js.map +1 -1
- package/build/store/middleware.d.ts +4 -0
- package/build/store/middleware.d.ts.map +1 -1
- package/build/store/middleware.js +15 -4
- package/build/store/middleware.js.map +1 -1
- package/build/store/selector.d.ts.map +1 -1
- package/build/store/selector.js +6 -2
- package/build/store/selector.js.map +1 -1
- 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
|
-
*
|
|
15
|
-
*
|
|
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
|
-
|
|
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.
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
|
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
|
-
|
|
416
|
-
this.finalizeRollback(primaryError, cleanupErrors);
|
|
418
|
+
return this.finishRollbackParentCleanup(fiber, instance, primaryError, cleanupErrors);
|
|
417
419
|
};
|
|
418
|
-
|
|
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`
|
|
490
|
-
*
|
|
491
|
-
*
|
|
492
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
767
|
+
// Same contract as the catch path: observer throw must not skip fail-stop.
|
|
698
768
|
if (this.onAutoReconcileError !== null) {
|
|
699
|
-
|
|
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
|
-
//
|
|
1073
|
-
|
|
1074
|
-
//
|
|
1075
|
-
|
|
1076
|
-
|
|
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
|
|
1135
|
-
this
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
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
|
-
|
|
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
|
}
|