effectable 1.0.0 → 1.1.0-canary.10

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 (71) hide show
  1. package/README.md +9 -3
  2. package/build/bootstrap/bootstrap.d.ts.map +1 -1
  3. package/build/bootstrap/bootstrap.js +18 -8
  4. package/build/bootstrap/bootstrap.js.map +1 -1
  5. package/build/bootstrap/types.d.ts +11 -3
  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 +254 -24
  11. package/build/component/GraphRuntime.d.ts.map +1 -1
  12. package/build/component/GraphRuntime.js +1136 -239
  13. package/build/component/GraphRuntime.js.map +1 -1
  14. package/build/component/context.d.ts +3 -11
  15. package/build/component/context.d.ts.map +1 -1
  16. package/build/component/context.js +24 -23
  17. package/build/component/context.js.map +1 -1
  18. package/build/component/h.d.ts +14 -6
  19. package/build/component/h.d.ts.map +1 -1
  20. package/build/component/h.js +19 -1
  21. package/build/component/h.js.map +1 -1
  22. package/build/component/lifecycle.d.ts +11 -2
  23. package/build/component/lifecycle.d.ts.map +1 -1
  24. package/build/component/lifecycle.js +21 -4
  25. package/build/component/lifecycle.js.map +1 -1
  26. package/build/component/refs.d.ts +7 -5
  27. package/build/component/refs.d.ts.map +1 -1
  28. package/build/component/refs.js +45 -22
  29. package/build/component/refs.js.map +1 -1
  30. package/build/component/types.d.ts +16 -5
  31. package/build/component/types.d.ts.map +1 -1
  32. package/build/component/types.js.map +1 -1
  33. package/build/connect/connect.d.ts.map +1 -1
  34. package/build/connect/connect.js +143 -44
  35. package/build/connect/connect.js.map +1 -1
  36. package/build/connect/types.d.ts +2 -2
  37. package/build/connect/types.d.ts.map +1 -1
  38. package/build/runtime/BusDecorators.d.ts +6 -0
  39. package/build/runtime/BusDecorators.d.ts.map +1 -1
  40. package/build/runtime/BusDecorators.js +126 -56
  41. package/build/runtime/BusDecorators.js.map +1 -1
  42. package/build/runtime/CommandBus.d.ts +1 -1
  43. package/build/runtime/CommandBus.d.ts.map +1 -1
  44. package/build/runtime/CommandBus.js +6 -3
  45. package/build/runtime/CommandBus.js.map +1 -1
  46. package/build/runtime/EventBus.d.ts.map +1 -1
  47. package/build/runtime/EventBus.js +11 -3
  48. package/build/runtime/EventBus.js.map +1 -1
  49. package/build/runtime/HandleRegistry.d.ts +2 -1
  50. package/build/runtime/HandleRegistry.d.ts.map +1 -1
  51. package/build/runtime/HandleRegistry.js +38 -7
  52. package/build/runtime/HandleRegistry.js.map +1 -1
  53. package/build/runtime/QueryBus.d.ts +1 -1
  54. package/build/runtime/QueryBus.d.ts.map +1 -1
  55. package/build/runtime/QueryBus.js +6 -3
  56. package/build/runtime/QueryBus.js.map +1 -1
  57. package/build/store/createStore.d.ts +2 -2
  58. package/build/store/createStore.d.ts.map +1 -1
  59. package/build/store/createStore.js +31 -4
  60. package/build/store/createStore.js.map +1 -1
  61. package/build/store/middleware.d.ts +16 -1
  62. package/build/store/middleware.d.ts.map +1 -1
  63. package/build/store/middleware.js +28 -16
  64. package/build/store/middleware.js.map +1 -1
  65. package/build/store/selector.d.ts.map +1 -1
  66. package/build/store/selector.js +6 -2
  67. package/build/store/selector.js.map +1 -1
  68. package/build/store/types.d.ts +11 -4
  69. package/build/store/types.d.ts.map +1 -1
  70. package/build/store/types.js.map +1 -1
  71. package/package.json +10 -3
@@ -5,10 +5,14 @@
5
5
  * Responsibilities:
6
6
  * - Materialize a VirtualServiceNode tree into real component instances (Fiber tree).
7
7
  * - Fiber-like reconcile: diff current vs next trees by key + type, assign effectTags.
8
+ * Reconciliation mutates the live graph (not an isolated work-in-progress tree).
8
9
  * - Drive lifecycle via LifecycleEngine: startup in topological order (children before parent),
9
- * shutdown in reverse order (parent before children).
10
+ * shutdown in the same order (children before parent).
10
11
  * - Inject contexts (@UseContext) and bind refs on mount.
11
12
  * - Pass updated props into existing instances during reconcile.
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.
12
16
  *
13
17
  * Current limitations:
14
18
  * - Work loop is synchronous (no priority lanes — next increment).
@@ -41,6 +45,20 @@ function isThenable(value) {
41
45
  typeof value.then === 'function');
42
46
  }
43
47
  // ---------------------------------------------------------------------------
48
+ // Runtime state machine
49
+ // ---------------------------------------------------------------------------
50
+ /**
51
+ * Runtime state literals.
52
+ * Private to GraphRuntime; reduced set without mounting/reconciling.
53
+ */
54
+ const RUNTIME_STATE = {
55
+ IDLE: 'idle',
56
+ ACTIVE: 'active',
57
+ FAILED: 'failed',
58
+ UNMOUNTING: 'unmounting',
59
+ UNMOUNTED: 'unmounted',
60
+ };
61
+ // ---------------------------------------------------------------------------
44
62
  // GraphRuntime
45
63
  // ---------------------------------------------------------------------------
46
64
  /**
@@ -56,13 +74,32 @@ function isThenable(value) {
56
74
  class GraphRuntime {
57
75
  /** Current root fiber tree (current tree). */
58
76
  currentRoot = null;
59
- /** Whether unmount has completed. */
60
- unmounted = false;
61
77
  /**
62
78
  * Entry counter for {@link continueStableReconcileAsync} (test/debug probe).
63
79
  * Not reset automatically — compare before/after around reconcile.
64
80
  */
65
81
  stableAsyncContinueCount = 0;
82
+ /**
83
+ * Backing field for {@link GraphRuntime.state}. Writes and most reads go
84
+ * through the accessor; post-await re-reads use `_state` (TS 6 still narrows getters).
85
+ */
86
+ _state = RUNTIME_STATE.IDLE;
87
+ /**
88
+ * Runtime state machine.
89
+ * IDLE → ACTIVE (on mount) → FAILED | UNMOUNTING → UNMOUNTED.
90
+ * FAILED is terminal: subsequent reconcile rejects, unmount is safe.
91
+ */
92
+ get state() {
93
+ return this._state;
94
+ }
95
+ set state(next) {
96
+ this._state = next;
97
+ }
98
+ /**
99
+ * Terminal error captured by failStop().
100
+ * Stored to reject later reconcile calls with the same error.
101
+ */
102
+ terminalError = null;
66
103
  /**
67
104
  * Runtime buses for auto-wiring decorators on nodes (optional, set in {@link GraphRuntime.mount}).
68
105
  */
@@ -102,10 +139,81 @@ class GraphRuntime {
102
139
  * Set via the fourth argument of {@link GraphRuntime.mount}.
103
140
  */
104
141
  onAutoReconcileError = null;
142
+ /**
143
+ * Operation queue: serializes reconcile and unmount.
144
+ * Each operation is a Promise-returning function executed sequentially.
145
+ */
146
+ operationQueue = [];
147
+ /**
148
+ * Whether an operation is currently running.
149
+ */
150
+ operationInProgress = false;
151
+ /**
152
+ * Cached unmount promise for concurrent unmount callers.
153
+ */
154
+ cachedUnmountPromise = null;
155
+ /**
156
+ * Pending fail-stop teardown work.
157
+ * When failStop nulls currentRoot but destroy is async, this tracks the in-flight cleanup.
158
+ * unmount() must await this before concluding teardown is finished.
159
+ */
160
+ pendingTeardown = null;
105
161
  /**
106
162
  * Instances are created only via {@link GraphRuntime.mount}; direct `new GraphRuntime()` is unavailable externally.
107
163
  */
108
164
  constructor() { }
165
+ /**
166
+ * Fail-stop: mark the runtime as failed, disable scheduling, tear down the graph best-effort.
167
+ * After fail-stop:
168
+ * - state is FAILED
169
+ * - terminalError is set
170
+ * - currentRoot is null (even if destroyFiber throws)
171
+ * - later reconcile() rejects with the terminal error
172
+ * - unmount() is safe and joinable
173
+ *
174
+ * No failed reconcile leaves the runtime active with a partial graph.
175
+ * Primary-error rules: cleanup errors attached as rollbackErrors, never replace primary.
176
+ *
177
+ * @param {Error} error - unrecoverable error that triggered fail-stop
178
+ * @returns {void | Promise<void>}
179
+ */
180
+ failStop(error) {
181
+ // Idempotent: if already failed, skip
182
+ if (this.state === RUNTIME_STATE.FAILED || this.state === RUNTIME_STATE.UNMOUNTED) {
183
+ return;
184
+ }
185
+ this.state = RUNTIME_STATE.FAILED;
186
+ this.terminalError = error;
187
+ // Disable scheduling immediately
188
+ this.dirtyFibers.clear();
189
+ this.flushScheduled = false;
190
+ // Best-effort teardown of the current/partial graph
191
+ // MUST set currentRoot = null even if destroyFiber throws
192
+ if (this.currentRoot !== null) {
193
+ const root = this.currentRoot;
194
+ this.currentRoot = null;
195
+ // Collect cleanup errors during fail-stop (attach as rollbackErrors)
196
+ const cleanupErrors = [];
197
+ const destroyRes = this.destroyFiber(root, cleanupErrors);
198
+ if (isThenable(destroyRes)) {
199
+ // Async path: track pending teardown so unmount() can join
200
+ this.pendingTeardown = destroyRes
201
+ .then(() => {
202
+ if (cleanupErrors.length > 0) {
203
+ error.rollbackErrors = cleanupErrors;
204
+ }
205
+ })
206
+ .finally(() => {
207
+ this.pendingTeardown = null;
208
+ });
209
+ return this.pendingTeardown;
210
+ }
211
+ // Sync path: attach cleanup errors immediately
212
+ if (cleanupErrors.length > 0) {
213
+ error.rollbackErrors = cleanupErrors;
214
+ }
215
+ }
216
+ }
109
217
  /**
110
218
  * Auto-wires runtime-bus decorators onto the instance before {@link LifecycleEngine.runStartup}.
111
219
  *
@@ -136,6 +244,252 @@ class GraphRuntime {
136
244
  }
137
245
  delete fiber.effectableRuntimeBusDisposer;
138
246
  }
247
+ /**
248
+ * Identity-safe ref clearing: clears ref.current only if it still points to the expected owner.
249
+ * Prevents an old rollback from clearing a ref that a newer materialization already reused.
250
+ * No cast required (Component | null → unknown | null is assignable).
251
+ *
252
+ * @param {RefObject<unknown>} ref - ref object
253
+ * @param {Component<unknown, unknown>} expectedOwner - expected current owner
254
+ * @returns {void}
255
+ */
256
+ clearRefSafe(ref, expectedOwner) {
257
+ if (ref.current === expectedOwner) {
258
+ ref.current = null;
259
+ }
260
+ }
261
+ /**
262
+ * Centralized ref ownership transition.
263
+ * Handles every ref binding/clearing operation: add, remove, replace.
264
+ *
265
+ * Rules:
266
+ * - Clear previousRef only if it still points to expectedPreviousOwner (identity-safe).
267
+ * - Bind nextRef to instance if nextRef is provided.
268
+ * - previousRef and nextRef can be the same object (ref reuse) or different (ref swap).
269
+ * - Do not let an old disposer clear a newer owner.
270
+ *
271
+ * No casts: Component | null → unknown | null is assignable (widening).
272
+ *
273
+ * @param {RefObject<unknown> | undefined} previousRef - ref to clear (can be undefined if no previous ref)
274
+ * @param {Component<unknown, unknown> | null} expectedPreviousOwner - expected owner of previousRef (null if unknown)
275
+ * @param {RefObject<unknown> | undefined} nextRef - ref to bind to instance (can be undefined if removing ref)
276
+ * @param {Component<unknown, unknown> | null} instance - instance to bind nextRef to (null when clearing only)
277
+ * @returns {void}
278
+ */
279
+ commitRef(previousRef, expectedPreviousOwner, nextRef, instance) {
280
+ // Clear previous ref if it's different from next (ref swap) or if next is undefined (ref removal)
281
+ if (previousRef !== undefined && previousRef !== nextRef && expectedPreviousOwner !== null) {
282
+ this.clearRefSafe(previousRef, expectedPreviousOwner);
283
+ }
284
+ // Bind next ref to instance (Component | null → unknown | null, no cast)
285
+ if (nextRef !== undefined) {
286
+ nextRef.current = instance;
287
+ }
288
+ }
289
+ /**
290
+ * Finalize fiber destroy: dispose wiring, clear ref, update status.
291
+ * Collects errors when collectErrors is provided (best-effort cleanup).
292
+ * When collectErrors is null, errors are thrown immediately.
293
+ *
294
+ * Uses commitRef for identity-safe ref clearing.
295
+ *
296
+ * @param {RuntimeFiber<unknown>} fiber - fiber being finalized
297
+ * @param {Error[] | null} collectErrors - array to collect errors (null to throw)
298
+ * @returns {void}
299
+ */
300
+ finalizeFiberDestroy(fiber, collectErrors) {
301
+ // Dispose runtime bus wiring
302
+ if (collectErrors !== null) {
303
+ try {
304
+ this.disposeEffectableRuntimeBusWiring(fiber);
305
+ }
306
+ catch (err) {
307
+ collectErrors.push(err instanceof Error ? err : new Error(String(err)));
308
+ }
309
+ }
310
+ else {
311
+ this.disposeEffectableRuntimeBusWiring(fiber);
312
+ }
313
+ // Clear ref via commitRef (identity-safe clearing)
314
+ const ref = fiber.vnode.ref;
315
+ const instance = fiber.instance;
316
+ if (ref !== undefined && instance !== null) {
317
+ if (collectErrors !== null) {
318
+ try {
319
+ this.commitRef(ref, instance, undefined, null);
320
+ }
321
+ catch (err) {
322
+ collectErrors.push(err instanceof Error ? err : new Error(String(err)));
323
+ }
324
+ }
325
+ else {
326
+ this.commitRef(ref, instance, undefined, null);
327
+ }
328
+ }
329
+ // Update lifecycle status
330
+ fiber.lifecycleStatus = fiber.engine.getStatus();
331
+ }
332
+ /**
333
+ * Transactional rollback for failed fiber materialization.
334
+ * Releases acquired resources in reverse acquisition order:
335
+ * 1. disable scheduler hook
336
+ * 2. dispose runtime bus registrations
337
+ * 3. clear bound ref (identity-safe)
338
+ * 4. run failed-startup cleanup
339
+ * 5. destroy mounted children in reverse order
340
+ * 6. unlink the partial fiber
341
+ * Cleanup is best-effort: one failure does not skip remaining steps.
342
+ * Preserves the original materialization error; cleanup errors are attached.
343
+ * Rollback is idempotent.
344
+ *
345
+ * @param {RuntimeFiber<P>} fiber - fiber being rolled back
346
+ * @param {Error} primaryError - original materialization/startup error
347
+ * @returns {void | Promise<void>}
348
+ */
349
+ rollbackFailedMaterialization(fiber, primaryError) {
350
+ const journal = fiber.constructionJournal;
351
+ if (journal === undefined || journal.rolledBack === true) {
352
+ return;
353
+ }
354
+ journal.rolledBack = true;
355
+ const cleanupErrors = [];
356
+ const instance = fiber.instance;
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) {
360
+ try {
361
+ this.clearUpdateHook(instance);
362
+ this.dirtyFibers.delete(fiber);
363
+ }
364
+ catch (err) {
365
+ cleanupErrors.push(err instanceof Error ? err : new Error(String(err)));
366
+ }
367
+ }
368
+ // 2. Dispose runtime bus registrations
369
+ if (journal.busWiringAttached === true) {
370
+ try {
371
+ this.disposeEffectableRuntimeBusWiring(fiber);
372
+ }
373
+ catch (err) {
374
+ cleanupErrors.push(err instanceof Error ? err : new Error(String(err)));
375
+ }
376
+ }
377
+ // 3. Clear bound ref (identity-safe)
378
+ if (journal.refBound === true && fiber.vnode.ref !== undefined && journal.refOwner !== undefined) {
379
+ try {
380
+ this.commitRef(fiber.vnode.ref, journal.refOwner, undefined, null);
381
+ }
382
+ catch (err) {
383
+ cleanupErrors.push(err instanceof Error ? err : new Error(String(err)));
384
+ }
385
+ }
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).
390
+ // Pass cleanupErrors so nested destroy is best-effort: a throwing
391
+ // ref-clear/disposer on one grandchild must not skip remaining siblings.
392
+ // Those nodes were never attached to currentRoot, so failStop cannot reclaim them.
393
+ const destroyChildrenThenParentCleanup = () => {
394
+ const children = journal.mountedChildren;
395
+ for (let i = children.length - 1; i >= 0; i -= 1) {
396
+ try {
397
+ const destroyRes = this.destroyFiber(children[i], cleanupErrors);
398
+ if (isThenable(destroyRes)) {
399
+ return this.continueRollbackDestroyAsync(children, i, destroyRes, primaryError, cleanupErrors, fiber, instance);
400
+ }
401
+ }
402
+ catch (err) {
403
+ cleanupErrors.push(err instanceof Error ? err : new Error(String(err)));
404
+ }
405
+ }
406
+ return this.finishRollbackParentCleanup(fiber, instance, primaryError, cleanupErrors);
407
+ };
408
+ return destroyChildrenThenParentCleanup();
409
+ }
410
+ /**
411
+ * Async continuation of rollback child destruction after one child's destroy returned a Promise.
412
+ *
413
+ * @param {RuntimeFiber<unknown>[]} children - mounted children
414
+ * @param {number} lastIdx - index of the last processed child
415
+ * @param {Promise<void>} pending - Promise from destroying the previous child
416
+ * @param {Error} primaryError - original materialization error
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)
420
+ * @returns {Promise<void>}
421
+ */
422
+ async continueRollbackDestroyAsync(children, lastIdx, pending, primaryError, cleanupErrors, fiber, instance) {
423
+ try {
424
+ await pending;
425
+ }
426
+ catch (err) {
427
+ cleanupErrors.push(err instanceof Error ? err : new Error(String(err)));
428
+ }
429
+ for (let i = lastIdx - 1; i >= 0; i -= 1) {
430
+ try {
431
+ const destroyRes = this.destroyFiber(children[i], cleanupErrors);
432
+ if (isThenable(destroyRes)) {
433
+ await destroyRes;
434
+ }
435
+ }
436
+ catch (err) {
437
+ cleanupErrors.push(err instanceof Error ? err : new Error(String(err)));
438
+ }
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
+ }
478
+ this.finalizeRollback(primaryError, cleanupErrors);
479
+ }
480
+ /**
481
+ * Attaches cleanup errors to the primary error and rethrows.
482
+ *
483
+ * @param {Error} primaryError - original materialization error
484
+ * @param {Error[]} cleanupErrors - cleanup errors
485
+ * @returns {never}
486
+ */
487
+ finalizeRollback(primaryError, cleanupErrors) {
488
+ if (cleanupErrors.length > 0) {
489
+ primaryError.rollbackErrors = cleanupErrors;
490
+ }
491
+ throw primaryError;
492
+ }
139
493
  /**
140
494
  * Injects the scheduler hook onto the component instance after successful startup.
141
495
  * The hook is called from {@link Component.setState} and enqueues the fiber for automatic reconcile.
@@ -156,10 +510,12 @@ class GraphRuntime {
156
510
  }
157
511
  }
158
512
  /**
159
- * Pre-mount buffer: `setState` during `onMount` cannot yet schedule reconcile
160
- * (the live hook is injected after startup). Marks the fiber; {@link injectUpdateHook}
161
- * after startup will call {@link scheduleUpdate}
162
- * (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.
163
519
  *
164
520
  * @param {Component<unknown, unknown>} instance - instance before/during startup
165
521
  * @param {RuntimeFiber<unknown>} fiber - instance fiber
@@ -188,11 +544,13 @@ class GraphRuntime {
188
544
  * - If an ancestor of the fiber is already queued → skip (ancestor covers the subtree).
189
545
  * - If descendants of the fiber are queued → remove them (fiber covers their subtrees).
190
546
  *
547
+ * Skip scheduling when runtime is FAILED.
548
+ *
191
549
  * @param {RuntimeFiber<unknown>} fiber - fiber whose subtree needs rebuild
192
550
  * @returns {void}
193
551
  */
194
552
  scheduleUpdate(fiber) {
195
- if (this.unmounted) {
553
+ if (this.state === RUNTIME_STATE.FAILED || this.state === RUNTIME_STATE.UNMOUNTING || this.state === RUNTIME_STATE.UNMOUNTED) {
196
554
  return;
197
555
  }
198
556
  // If any ancestor is already queued — this fiber will be rebuilt as part of the ancestor
@@ -221,23 +579,93 @@ class GraphRuntime {
221
579
  // Schedule microtask dirty-flush
222
580
  this.scheduleDirtyFlushMicrotask();
223
581
  }
582
+ /**
583
+ * Enqueues an operation and starts the queue processor if idle.
584
+ * Operations are executed sequentially; concurrent callers await the same in-flight operation.
585
+ * Serialize all graph mutations.
586
+ *
587
+ * @param {() => Promise<void>} operation - operation to enqueue
588
+ * @returns {Promise<void>}
589
+ */
590
+ async enqueueOperation(operation) {
591
+ return new Promise((resolve, reject) => {
592
+ this.operationQueue.push(async () => {
593
+ try {
594
+ await operation();
595
+ resolve();
596
+ }
597
+ catch (error) {
598
+ reject(error);
599
+ }
600
+ });
601
+ if (!this.operationInProgress) {
602
+ // Start processing without awaiting to allow concurrent enqueuing
603
+ void this.processOperationQueue();
604
+ }
605
+ });
606
+ }
607
+ /**
608
+ * Processes the operation queue: runs operations one at a time.
609
+ * Single serialized owner of tree mutations.
610
+ * Errors from individual operations are propagated to their callers but do not stop the queue.
611
+ *
612
+ * @returns {Promise<void>}
613
+ */
614
+ async processOperationQueue() {
615
+ if (this.operationInProgress) {
616
+ return;
617
+ }
618
+ this.operationInProgress = true;
619
+ try {
620
+ while (this.operationQueue.length > 0) {
621
+ const operation = this.operationQueue.shift();
622
+ if (operation === undefined) {
623
+ break;
624
+ }
625
+ try {
626
+ await operation();
627
+ }
628
+ catch {
629
+ // Error is already propagated to the caller via the promise wrapper
630
+ // Continue processing the queue (don't poison it forever)
631
+ }
632
+ }
633
+ }
634
+ finally {
635
+ this.operationInProgress = false;
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
+ }
642
+ }
224
643
  /**
225
644
  * Queues one dirty-flush microtask and publishes {@link activeFlush} for await from `reconcile`.
226
645
  *
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).
651
+ *
227
652
  * @returns {void}
228
653
  */
229
654
  scheduleDirtyFlushMicrotask() {
230
- if (this.flushScheduled || this.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) {
231
661
  return;
232
662
  }
233
663
  this.flushScheduled = true;
234
664
  const flushWork = new Promise((resolve) => {
235
665
  queueMicrotask(() => {
236
666
  this.flushDirtyFibers()
237
- .catch((err) => {
238
- if (this.onAutoReconcileError !== null) {
239
- this.onAutoReconcileError(err);
240
- }
667
+ .catch(() => {
668
+ // Error already handled in flushDirtyFibers (onAutoReconcileError + fail-stop)
241
669
  })
242
670
  .finally(() => {
243
671
  resolve();
@@ -257,21 +685,34 @@ class GraphRuntime {
257
685
  * Guarded by the `flushing` flag against re-entrancy.
258
686
  * The chain of repeat passes is capped by {@link GRAPH_RUNTIME_MAX_DIRTY_FLUSH_PASSES}.
259
687
  *
688
+ * Respects state (UNMOUNTING/UNMOUNTED/FAILED) to cancel flush when unmount begins or failure occurs.
689
+ * On unrecoverable error, invokes onAutoReconcileError then fail-stops.
690
+ *
260
691
  * @returns {Promise<void>}
261
692
  */
262
693
  async flushDirtyFibers() {
263
694
  this.flushScheduled = false;
264
- if (this.unmounted || this.flushing) {
695
+ if (this.state === RUNTIME_STATE.FAILED || this.state === RUNTIME_STATE.UNMOUNTING || this.state === RUNTIME_STATE.UNMOUNTED) {
265
696
  this.dirtyFibers.clear();
266
697
  return;
267
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
+ }
268
709
  this.dirtyFlushPassCount += 1;
269
710
  this.flushing = true;
270
711
  const snapshot = Array.from(this.dirtyFibers);
271
712
  this.dirtyFibers.clear();
272
713
  try {
273
714
  for (const fiber of snapshot) {
274
- if (this.unmounted) {
715
+ if (this._state === RUNTIME_STATE.FAILED || this._state === RUNTIME_STATE.UNMOUNTING || this._state === RUNTIME_STATE.UNMOUNTED) {
275
716
  break;
276
717
  }
277
718
  const res = this.reconcileDirtyFiber(fiber);
@@ -280,15 +721,52 @@ class GraphRuntime {
280
721
  }
281
722
  }
282
723
  }
724
+ catch (error) {
725
+ this.flushing = false;
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.
730
+ if (this.onAutoReconcileError !== null) {
731
+ try {
732
+ this.onAutoReconcileError(error);
733
+ }
734
+ catch {
735
+ // Observer/logging failures are non-fatal relative to fail-stop.
736
+ }
737
+ }
738
+ // Fail-stop on unrecoverable dirty-flush error
739
+ const failError = error instanceof Error ? error : new Error(String(error));
740
+ const failRes = this.failStop(failError);
741
+ if (isThenable(failRes)) {
742
+ await failRes;
743
+ }
744
+ throw failError;
745
+ }
283
746
  finally {
284
747
  this.flushing = false;
285
748
  }
286
749
  // If new dirty fibers appeared during flush — schedule the next pass
287
- if (this.dirtyFibers.size > 0 && !this.unmounted) {
750
+ if (this.dirtyFibers.size > 0 && this.state === RUNTIME_STATE.ACTIVE) {
288
751
  if (this.dirtyFlushPassCount >= graphRuntime_constants_1.GRAPH_RUNTIME_MAX_DIRTY_FLUSH_PASSES) {
289
752
  this.dirtyFibers.clear();
290
753
  this.dirtyFlushPassCount = 0;
291
- throw new Error(`GraphRuntime: dirty flush exceeded ${String(graphRuntime_constants_1.GRAPH_RUNTIME_MAX_DIRTY_FLUSH_PASSES)} passes (anti-loop)`);
754
+ const loopError = new Error(`GraphRuntime: dirty flush exceeded ${String(graphRuntime_constants_1.GRAPH_RUNTIME_MAX_DIRTY_FLUSH_PASSES)} passes (anti-loop)`);
755
+ // Same contract as the catch path: observer throw must not skip fail-stop.
756
+ if (this.onAutoReconcileError !== null) {
757
+ try {
758
+ this.onAutoReconcileError(loopError);
759
+ }
760
+ catch {
761
+ // Observer/logging failures are non-fatal relative to fail-stop.
762
+ }
763
+ }
764
+ // Fail-stop on loop limit
765
+ const failRes = this.failStop(loopError);
766
+ if (isThenable(failRes)) {
767
+ await failRes;
768
+ }
769
+ throw loopError;
292
770
  }
293
771
  this.scheduleDirtyFlushMicrotask();
294
772
  }
@@ -306,7 +784,7 @@ class GraphRuntime {
306
784
  * @returns {void | Promise<void>}
307
785
  */
308
786
  reconcileDirtyFiber(fiber) {
309
- if (this.unmounted) {
787
+ if (this.state === RUNTIME_STATE.UNMOUNTING || this.state === RUNTIME_STATE.UNMOUNTED) {
310
788
  return;
311
789
  }
312
790
  const instance = fiber.instance;
@@ -321,7 +799,7 @@ class GraphRuntime {
321
799
  return childrenRes.then((nextChildren) => {
322
800
  fiber.children = nextChildren;
323
801
  }, (error) => {
324
- const cleanupResult = this.runFiberFailedCleanup(fiber);
802
+ const cleanupResult = this.runFiberFailedCleanup(fiber, error);
325
803
  if (isThenable(cleanupResult)) {
326
804
  return cleanupResult.then(() => {
327
805
  throw error;
@@ -333,7 +811,7 @@ class GraphRuntime {
333
811
  fiber.children = childrenRes;
334
812
  }
335
813
  catch (error) {
336
- const cleanupResult = this.runFiberFailedCleanup(fiber);
814
+ const cleanupResult = this.runFiberFailedCleanup(fiber, error);
337
815
  if (isThenable(cleanupResult)) {
338
816
  return cleanupResult.then(() => {
339
817
  throw error;
@@ -359,63 +837,179 @@ class GraphRuntime {
359
837
  const rt = new GraphRuntime();
360
838
  rt.effectableRuntimeBuses = typeof runtimeBuses === 'undefined' ? null : runtimeBuses;
361
839
  rt.onAutoReconcileError = typeof onAutoReconcileError === 'function' ? onAutoReconcileError : null;
362
- const res = rt.materialize(root, null, initialScope);
363
- rt.currentRoot = isThenable(res) ? await res : res;
364
- return rt;
840
+ try {
841
+ const res = rt.materialize(root, null, initialScope);
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
+ }
846
+ rt.state = RUNTIME_STATE.ACTIVE;
847
+ if (rt.dirtyFibers.size > 0) {
848
+ rt.scheduleDirtyFlushMicrotask();
849
+ }
850
+ return rt;
851
+ }
852
+ catch (error) {
853
+ // Fail-stop on unrecoverable mount error
854
+ const failError = error instanceof Error ? error : new Error(String(error));
855
+ const failRes = rt.failStop(failError);
856
+ if (isThenable(failRes)) {
857
+ await failRes;
858
+ }
859
+ throw failError;
860
+ }
365
861
  }
366
862
  /**
367
863
  * Reconciles against a new tree.
368
- * Builds a work-in-progress tree, computes effectTags, applies changes:
864
+ * Diffs the current tree against the new one, computes effectTags, applies changes:
369
865
  * - PLACE: create and mount a new node
370
866
  * - UPDATE: update props on an existing instance, call onUpdate
371
867
  * - DELETE: unmount and destroy a node
372
868
  *
869
+ * All reconcile calls are serialized through the operation queue.
870
+ * Rejects with terminal error when runtime is FAILED.
871
+ *
373
872
  * @param {VirtualServiceNode<P>} nextTree - new virtual tree
374
873
  * @returns {Promise<void>}
375
- * @throws {Error} if the runtime is already unmounted
874
+ * @throws {Error} if the runtime state is UNMOUNTING, UNMOUNTED, or FAILED
376
875
  */
377
876
  async reconcile(nextTree) {
378
- if (this.unmounted) {
379
- throw new Error('[Effectable] GraphRuntime: reconcile attempted after unmount.');
380
- }
381
- if (this.currentRoot === null) {
382
- throw new Error('[Effectable] GraphRuntime: currentRoot is not initialized.');
383
- }
384
- // Await the full dirty-flush chain (including re-schedule) otherwise manual
385
- // reconcile overlaps the snapshot auto-flush.
386
- while (this.activeFlush !== null) {
387
- await this.activeFlush;
388
- }
389
- if (this.unmounted) {
390
- throw new Error('[Effectable] GraphRuntime: reconcile attempted after unmount.');
391
- }
392
- // Manual reconcile covers the whole tree from the root: cancel pending auto-flush
393
- // to avoid double-mounting components from concurrent reconcile paths.
394
- this.dirtyFibers.clear();
395
- this.flushScheduled = false;
396
- this.dirtyFlushPassCount = 0;
397
- const res = this.reconcileFiber(this.currentRoot, nextTree, null, this.currentRoot.scope);
398
- this.currentRoot = isThenable(res) ? await res : res;
877
+ // Reject immediately if unmount has started or completed
878
+ if (this.state === RUNTIME_STATE.UNMOUNTING || this.state === RUNTIME_STATE.UNMOUNTED) {
879
+ throw new Error('[Effectable] GraphRuntime: reconcile attempted after unmount started.');
880
+ }
881
+ // Reject immediately if runtime is in failed state
882
+ if (this.state === RUNTIME_STATE.FAILED) {
883
+ throw this.terminalError || new Error('[Effectable] GraphRuntime: reconcile attempted after terminal failure.');
884
+ }
885
+ // Serialize via operation queue
886
+ await this.enqueueOperation(async () => {
887
+ // Double-check after queue wait
888
+ if (this.state === RUNTIME_STATE.UNMOUNTING || this.state === RUNTIME_STATE.UNMOUNTED) {
889
+ throw new Error('[Effectable] GraphRuntime: reconcile attempted after unmount started.');
890
+ }
891
+ if (this.state === RUNTIME_STATE.FAILED) {
892
+ throw this.terminalError || new Error('[Effectable] GraphRuntime: reconcile attempted after terminal failure.');
893
+ }
894
+ if (this.currentRoot === null) {
895
+ throw new Error('[Effectable] GraphRuntime: currentRoot is not initialized.');
896
+ }
897
+ // Await the full dirty-flush chain (including re-schedule) otherwise manual
898
+ // reconcile overlaps the snapshot auto-flush.
899
+ while (this.activeFlush !== null) {
900
+ await this.activeFlush;
901
+ }
902
+ if (this._state === RUNTIME_STATE.UNMOUNTING || this._state === RUNTIME_STATE.UNMOUNTED) {
903
+ throw new Error('[Effectable] GraphRuntime: reconcile attempted after unmount started.');
904
+ }
905
+ if (this._state === RUNTIME_STATE.FAILED) {
906
+ throw this.terminalError || new Error('[Effectable] GraphRuntime: reconcile attempted after terminal failure.');
907
+ }
908
+ // Manual reconcile covers the whole tree from the root: cancel pending auto-flush
909
+ // to avoid double-mounting components from concurrent reconcile paths.
910
+ this.dirtyFibers.clear();
911
+ this.flushScheduled = false;
912
+ this.dirtyFlushPassCount = 0;
913
+ try {
914
+ const res = this.reconcileFiber(this.currentRoot, nextTree, null, this.currentRoot.scope);
915
+ this.currentRoot = isThenable(res) ? await res : res;
916
+ }
917
+ catch (error) {
918
+ // Fail-stop on unrecoverable reconcile error
919
+ // Let failStop destroy current tree and null currentRoot (single owner)
920
+ const failError = error instanceof Error ? error : new Error(String(error));
921
+ const failRes = this.failStop(failError);
922
+ if (isThenable(failRes)) {
923
+ await failRes;
924
+ }
925
+ throw failError;
926
+ }
927
+ });
399
928
  }
400
929
  /**
401
930
  * Fully unmounts the component tree.
402
- * Calls onUnmount for each node in reverse order (children before parent) and
931
+ * Calls onUnmount for each node (children before parent) and
403
932
  * moves stages to destroyed via LifecycleEngine.
404
933
  *
934
+ * Unmount is serialized, cached promise returned for concurrent callers.
935
+ * Safe and joinable even when runtime is FAILED.
936
+ * Collects cleanup errors when `rejectOnCleanupError: true` is passed.
937
+ *
938
+ * @param {object} [options] - unmount options
939
+ * @param {boolean} [options.rejectOnCleanupError=false] - reject on cleanup errors
405
940
  * @returns {Promise<void>}
406
941
  */
407
- async unmount() {
408
- if (this.unmounted) {
942
+ async unmount(options) {
943
+ const rejectOnCleanupError = options?.rejectOnCleanupError === true;
944
+ // If unmount is in progress, return the cached promise (HOLE 1)
945
+ // Must check BEFORE the UNMOUNTED early-return so concurrent callers join the in-flight unmount
946
+ if (this.cachedUnmountPromise !== null) {
947
+ return this.cachedUnmountPromise;
948
+ }
949
+ // If unmount already completed, return immediately
950
+ if (this.state === RUNTIME_STATE.UNMOUNTED) {
409
951
  return;
410
952
  }
411
- this.unmounted = true;
412
- if (this.currentRoot !== null) {
413
- const d = this.destroyFiber(this.currentRoot);
414
- if (isThenable(d)) {
415
- await d;
416
- }
417
- this.currentRoot = null;
953
+ // Transition to UNMOUNTING state (reject new reconcile calls)
954
+ // If already FAILED, stay FAILED until unmount completes
955
+ if (this.state !== RUNTIME_STATE.FAILED) {
956
+ this.state = RUNTIME_STATE.UNMOUNTING;
418
957
  }
958
+ // Create and cache the unmount promise
959
+ this.cachedUnmountPromise = this.enqueueOperation(async () => {
960
+ // Double-check unmounted state
961
+ if (this.state === RUNTIME_STATE.UNMOUNTED) {
962
+ return;
963
+ }
964
+ // Cancel any pending dirty flush
965
+ this.dirtyFibers.clear();
966
+ this.flushScheduled = false;
967
+ // Wait for in-flight dirty flush to complete
968
+ if (this.activeFlush !== null) {
969
+ try {
970
+ await this.activeFlush;
971
+ }
972
+ catch {
973
+ // Ignore flush errors during unmount
974
+ }
975
+ }
976
+ // HOLE 1: stay UNMOUNTING during destroy (not UNMOUNTED)
977
+ // If already FAILED, keep FAILED state through destroy
978
+ // State transition to UNMOUNTED happens AFTER destroy completes
979
+ // If pendingTeardown is active (fail-stop in progress), await it first
980
+ if (this.pendingTeardown !== null) {
981
+ try {
982
+ await this.pendingTeardown;
983
+ }
984
+ catch {
985
+ // Ignore errors — they are already attached to the fail-stop primary error
986
+ }
987
+ }
988
+ if (this.currentRoot !== null) {
989
+ // Collect cleanup errors during unmount
990
+ const cleanupErrors = [];
991
+ const d = this.destroyFiber(this.currentRoot, cleanupErrors);
992
+ if (isThenable(d)) {
993
+ await d;
994
+ }
995
+ this.currentRoot = null;
996
+ // HOLE 1: set UNMOUNTED only after destroy finishes
997
+ // Transition even if FAILED — unmount is the terminal operation
998
+ this.state = RUNTIME_STATE.UNMOUNTED;
999
+ // Reject with cleanup errors when requested
1000
+ if (rejectOnCleanupError && cleanupErrors.length > 0) {
1001
+ if (cleanupErrors.length === 1) {
1002
+ throw cleanupErrors[0];
1003
+ }
1004
+ throw new AggregateError(cleanupErrors, 'Cleanup errors during unmount');
1005
+ }
1006
+ }
1007
+ else {
1008
+ // No root to destroy — transition to UNMOUNTED
1009
+ this.state = RUNTIME_STATE.UNMOUNTED;
1010
+ }
1011
+ });
1012
+ return this.cachedUnmountPromise;
419
1013
  }
420
1014
  /**
421
1015
  * Returns the root component instance (for testing and introspection).
@@ -429,12 +1023,13 @@ class GraphRuntime {
429
1023
  return this.currentRoot.instance;
430
1024
  }
431
1025
  /**
432
- * Whether the runtime is active (unmount has not been called).
1026
+ * Whether the runtime is active (not failed and unmount has not been called).
1027
+ * Returns false when state is FAILED.
433
1028
  *
434
1029
  * @returns {boolean}
435
1030
  */
436
1031
  isActive() {
437
- return !this.unmounted;
1032
+ return this.state === RUNTIME_STATE.ACTIVE;
438
1033
  }
439
1034
  /**
440
1035
  * Readonly snapshot of the root fiber tree for test/debug introspection.
@@ -470,6 +1065,15 @@ class GraphRuntime {
470
1065
  getStableAsyncContinueCount() {
471
1066
  return this.stableAsyncContinueCount;
472
1067
  }
1068
+ /**
1069
+ * Current runtime state.
1070
+ * Test/debug probe; not a production API.
1071
+ *
1072
+ * @returns {RuntimeState} current state
1073
+ */
1074
+ getState() {
1075
+ return this.state;
1076
+ }
473
1077
  /**
474
1078
  * Builds a deep readonly {@link FiberInspectNode} from a RuntimeFiber.
475
1079
  *
@@ -530,44 +1134,110 @@ class GraphRuntime {
530
1134
  effectTag: types_1.FIBER_EFFECT_TAG.PLACE,
531
1135
  engine,
532
1136
  scope: parentScope,
1137
+ constructionJournal: {
1138
+ mountedChildren: [],
1139
+ },
533
1140
  };
534
- // Recursively materialize children before running the parent's lifecycle
535
- const childVnodes = this.getChildVnodes(instance, vnode.children);
536
- const childFibers = [];
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
+ }
537
1165
  for (let i = 0; i < childVnodes.length; i++) {
538
1166
  const childVnode = childVnodes[i];
539
- const childRes = this.materialize(childVnode, fiber, childScope);
1167
+ let childRes;
1168
+ try {
1169
+ childRes = this.materialize(childVnode, fiber, childScope);
1170
+ }
1171
+ catch (err) {
1172
+ const error = err instanceof Error ? err : new Error(String(err));
1173
+ const rollbackRes = this.rollbackFailedMaterialization(fiber, error);
1174
+ if (isThenable(rollbackRes)) {
1175
+ return rollbackRes.then(() => {
1176
+ throw error;
1177
+ });
1178
+ }
1179
+ throw error;
1180
+ }
540
1181
  if (isThenable(childRes)) {
541
1182
  // Hit an async child — continue the materialization tail in the async continuation.
542
- return this.continueMaterializeAsync(fiber, instance, engine, vnode, childVnodes, childFibers, childRes, i);
1183
+ return this.continueMaterializeAsync(fiber, instance, engine, vnode, childVnodes, childScope, childRes, i);
543
1184
  }
544
- childFibers.push(childRes);
1185
+ fiber.constructionJournal.mountedChildren.push(childRes);
545
1186
  }
546
- fiber.children = childFibers;
547
- // Bind ref to the instance
1187
+ fiber.children = fiber.constructionJournal.mountedChildren;
1188
+ // Bind ref to the instance (centralized via commitRef)
548
1189
  if (vnode.ref !== undefined) {
549
- vnode.ref.current = instance;
1190
+ try {
1191
+ this.commitRef(undefined, null, vnode.ref, instance);
1192
+ fiber.constructionJournal.refBound = true;
1193
+ fiber.constructionJournal.refOwner = instance;
1194
+ }
1195
+ catch (err) {
1196
+ const error = err instanceof Error ? err : new Error(String(err));
1197
+ const rollbackRes = this.rollbackFailedMaterialization(fiber, error);
1198
+ if (isThenable(rollbackRes)) {
1199
+ return rollbackRes.then(() => {
1200
+ throw error;
1201
+ });
1202
+ }
1203
+ throw error;
1204
+ }
1205
+ }
1206
+ try {
1207
+ this.attachEffectableRuntimeBusWiring(instance, fiber);
1208
+ if (fiber.effectableRuntimeBusDisposer !== undefined) {
1209
+ fiber.constructionJournal.busWiringAttached = true;
1210
+ }
1211
+ }
1212
+ catch (err) {
1213
+ const rollbackRes = this.rollbackFailedMaterialization(fiber, err instanceof Error ? err : new Error(String(err)));
1214
+ if (isThenable(rollbackRes)) {
1215
+ return rollbackRes.then(() => {
1216
+ throw err;
1217
+ });
1218
+ }
1219
+ throw err;
550
1220
  }
551
- this.attachEffectableRuntimeBusWiring(instance, fiber);
552
1221
  // Run lifecycle after all children are materialized.
553
- // Pre-mount hook buffers setState from onMount until injectUpdateHook.
554
- this.injectPreMountUpdateHook(instance, fiber);
555
- 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 });
556
1225
  if (isThenable(startupRes)) {
557
- return this.finalizeMaterializeAsync(fiber, engine, childFibers, startupRes);
1226
+ return this.finalizeMaterializeAsync(fiber, engine, startupRes);
558
1227
  }
559
1228
  if (!startupRes.ok) {
560
- // Unmount already-mounted children when parent startup fails
561
- const destroyChain = this.destroyChildrenOnError(childFibers);
562
- if (isThenable(destroyChain)) {
563
- return destroyChain.then(() => {
564
- throw startupRes.error;
1229
+ const error = startupRes.error instanceof Error ? startupRes.error : new Error(String(startupRes.error));
1230
+ const rollbackRes = this.rollbackFailedMaterialization(fiber, error);
1231
+ if (isThenable(rollbackRes)) {
1232
+ return rollbackRes.then(() => {
1233
+ throw error;
565
1234
  });
566
1235
  }
567
- throw startupRes.error;
1236
+ throw error;
568
1237
  }
569
1238
  fiber.lifecycleStatus = engine.getStatus();
570
1239
  fiber.effectTag = null;
1240
+ fiber.constructionJournal.schedulerHookAttached = true;
571
1241
  this.injectUpdateHook(instance, fiber);
572
1242
  return fiber;
573
1243
  }
@@ -580,38 +1250,83 @@ class GraphRuntime {
580
1250
  * @param {LifecycleEngine} engine - lifecycle engine
581
1251
  * @param {VirtualServiceNode<P>} vnode - virtual node
582
1252
  * @param {VirtualServiceNode[]} childVnodes - all child vnodes
583
- * @param {RuntimeFiber<unknown>[]} childFibers - already materialized child fibers
1253
+ * @param {ContextScope} childScope - scope for child nodes
584
1254
  * @param {Promise<RuntimeFiber<unknown>>} pending - Promise for the current child
585
1255
  * @param {number} pendingIdx - index of the current child
586
1256
  * @returns {Promise<RuntimeFiber<P>>}
587
1257
  */
588
- async continueMaterializeAsync(fiber, instance, engine, vnode, childVnodes, childFibers, pending, pendingIdx) {
589
- const childScope = this.buildChildScope(instance, fiber.scope);
590
- childFibers.push(await pending);
1258
+ async continueMaterializeAsync(fiber, instance, engine, vnode, childVnodes, childScope, pending, pendingIdx) {
1259
+ const journal = fiber.constructionJournal;
1260
+ try {
1261
+ journal.mountedChildren.push(await pending);
1262
+ }
1263
+ catch (err) {
1264
+ const error = err instanceof Error ? err : new Error(String(err));
1265
+ const rollbackRes = this.rollbackFailedMaterialization(fiber, error);
1266
+ if (isThenable(rollbackRes)) {
1267
+ await rollbackRes;
1268
+ }
1269
+ throw error;
1270
+ }
591
1271
  for (let i = pendingIdx + 1; i < childVnodes.length; i++) {
592
1272
  const childVnode = childVnodes[i];
593
- const childRes = this.materialize(childVnode, fiber, childScope);
594
- childFibers.push(isThenable(childRes) ? await childRes : childRes);
1273
+ try {
1274
+ const childRes = this.materialize(childVnode, fiber, childScope);
1275
+ const resolvedChild = isThenable(childRes) ? await childRes : childRes;
1276
+ journal.mountedChildren.push(resolvedChild);
1277
+ }
1278
+ catch (err) {
1279
+ const error = err instanceof Error ? err : new Error(String(err));
1280
+ const rollbackRes = this.rollbackFailedMaterialization(fiber, error);
1281
+ if (isThenable(rollbackRes)) {
1282
+ await rollbackRes;
1283
+ }
1284
+ throw error;
1285
+ }
595
1286
  }
596
- fiber.children = childFibers;
1287
+ fiber.children = journal.mountedChildren;
597
1288
  if (vnode.ref !== undefined) {
598
- vnode.ref.current = instance;
1289
+ try {
1290
+ this.commitRef(undefined, null, vnode.ref, instance);
1291
+ journal.refBound = true;
1292
+ journal.refOwner = instance;
1293
+ }
1294
+ catch (err) {
1295
+ const error = err instanceof Error ? err : new Error(String(err));
1296
+ const rollbackRes = this.rollbackFailedMaterialization(fiber, error);
1297
+ if (isThenable(rollbackRes)) {
1298
+ await rollbackRes;
1299
+ }
1300
+ throw error;
1301
+ }
599
1302
  }
600
- this.attachEffectableRuntimeBusWiring(instance, fiber);
601
- this.injectPreMountUpdateHook(instance, fiber);
602
- const startupRes = engine.runStartup(instance);
1303
+ try {
1304
+ this.attachEffectableRuntimeBusWiring(instance, fiber);
1305
+ if (fiber.effectableRuntimeBusDisposer !== undefined) {
1306
+ journal.busWiringAttached = true;
1307
+ }
1308
+ }
1309
+ catch (err) {
1310
+ const rollbackRes = this.rollbackFailedMaterialization(fiber, err instanceof Error ? err : new Error(String(err)));
1311
+ if (isThenable(rollbackRes)) {
1312
+ await rollbackRes;
1313
+ }
1314
+ throw err;
1315
+ }
1316
+ // Pre-mount hook already injected at the start of materialize (before children).
1317
+ const startupRes = engine.runStartup(instance, { deferFailedCleanup: true });
603
1318
  const resolved = isThenable(startupRes) ? await startupRes : startupRes;
604
1319
  if (!resolved.ok) {
605
- for (const c of childFibers) {
606
- const d = this.destroyFiber(c);
607
- if (isThenable(d)) {
608
- await d;
609
- }
1320
+ const error = resolved.error instanceof Error ? resolved.error : new Error(String(resolved.error));
1321
+ const rollbackRes = this.rollbackFailedMaterialization(fiber, error);
1322
+ if (isThenable(rollbackRes)) {
1323
+ await rollbackRes;
610
1324
  }
611
- throw resolved.error;
1325
+ throw error;
612
1326
  }
613
1327
  fiber.lifecycleStatus = engine.getStatus();
614
1328
  fiber.effectTag = null;
1329
+ journal.schedulerHookAttached = true;
615
1330
  this.injectUpdateHook(instance, fiber);
616
1331
  return fiber;
617
1332
  }
@@ -621,59 +1336,28 @@ class GraphRuntime {
621
1336
  * @template P node props type
622
1337
  * @param {RuntimeFiber<P>} fiber - fiber of the subtree root node
623
1338
  * @param {LifecycleEngine} engine - lifecycle engine for this node
624
- * @param {RuntimeFiber<unknown>[]} childFibers - already mounted child fibers (for rollback on error)
625
1339
  * @param {PromiseLike<import('./lifecycle').LifecycleTransitionResult>} pendingStartup - Promise of the `runStartup` result
626
1340
  * @returns {Promise<RuntimeFiber<P>>} ready fiber, or rollback children and rethrow
627
1341
  */
628
- async finalizeMaterializeAsync(fiber, engine, childFibers, pendingStartup) {
1342
+ async finalizeMaterializeAsync(fiber, engine, pendingStartup) {
629
1343
  const result = await pendingStartup;
1344
+ const journal = fiber.constructionJournal;
630
1345
  if (!result.ok) {
631
- for (const c of childFibers) {
632
- const d = this.destroyFiber(c);
633
- if (isThenable(d)) {
634
- await d;
635
- }
1346
+ const error = result.error instanceof Error ? result.error : new Error(String(result.error));
1347
+ const rollbackRes = this.rollbackFailedMaterialization(fiber, error);
1348
+ if (isThenable(rollbackRes)) {
1349
+ await rollbackRes;
636
1350
  }
637
- throw result.error;
1351
+ throw error;
638
1352
  }
639
1353
  fiber.lifecycleStatus = engine.getStatus();
640
1354
  fiber.effectTag = null;
641
1355
  if (fiber.instance !== null) {
1356
+ journal.schedulerHookAttached = true;
642
1357
  this.injectUpdateHook(fiber.instance, fiber);
643
1358
  }
644
1359
  return fiber;
645
1360
  }
646
- /**
647
- * Unmounts already-mounted children when parent startup fails.
648
- * Returns sync void if all demounts are sync, otherwise a Promise.
649
- *
650
- * @param {RuntimeFiber<unknown>[]} childFibers
651
- * @returns {void | Promise<void>}
652
- */
653
- destroyChildrenOnError(childFibers) {
654
- let pending = null;
655
- let startIdx = 0;
656
- for (let i = 0; i < childFibers.length; i++) {
657
- const d = this.destroyFiber(childFibers[i]);
658
- if (isThenable(d)) {
659
- pending = d;
660
- startIdx = i + 1;
661
- break;
662
- }
663
- }
664
- if (pending === null) {
665
- return;
666
- }
667
- return (async () => {
668
- await pending;
669
- for (let i = startIdx; i < childFibers.length; i++) {
670
- const d = this.destroyFiber(childFibers[i]);
671
- if (isThenable(d)) {
672
- await d;
673
- }
674
- }
675
- })();
676
- }
677
1361
  // ---------------------------------------------------------------------------
678
1362
  // Reconcile
679
1363
  // ---------------------------------------------------------------------------
@@ -696,7 +1380,9 @@ class GraphRuntime {
696
1380
  }
697
1381
  // Type or key changed — destroy the old node, create a new one.
698
1382
  // Sync fast-path if both destroy and materialize completed synchronously.
699
- 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, []);
700
1386
  if (isThenable(destroyRes)) {
701
1387
  return destroyRes.then(() => this.materialize(nextVnode, parentFiber, parentScope));
702
1388
  }
@@ -725,15 +1411,32 @@ class GraphRuntime {
725
1411
  else {
726
1412
  instance.props = nextVnode.props;
727
1413
  }
1414
+ // Re-inject context fields when parent scope changed
1415
+ let contextChanged = false;
1416
+ if (current.scope !== parentScope) {
1417
+ try {
1418
+ contextChanged = (0, context_1.injectContextFields)(instance, parentScope);
1419
+ }
1420
+ catch (error) {
1421
+ const cleanupResult = this.runFiberFailedCleanup(current, error);
1422
+ if (isThenable(cleanupResult)) {
1423
+ return cleanupResult.then(() => {
1424
+ throw error;
1425
+ });
1426
+ }
1427
+ throw error;
1428
+ }
1429
+ }
728
1430
  // Build scope for child nodes (ContextProvider may have updated values)
729
1431
  const childScope = this.buildChildScope(instance, parentScope);
730
- // Call onUpdate if props changed
731
- if (prevProps !== instance.props && current.engine.canUpdate()) {
1432
+ // Call onUpdate if props or context changed (React 16.5 class-component style: one hook)
1433
+ const propsChanged = prevProps !== instance.props;
1434
+ if ((propsChanged || contextChanged) && current.engine.canUpdate()) {
732
1435
  try {
733
1436
  instance.onUpdate(prevProps, instance.props);
734
1437
  }
735
1438
  catch (error) {
736
- const cleanupResult = this.runFiberFailedCleanup(current);
1439
+ const cleanupResult = this.runFiberFailedCleanup(current, error);
737
1440
  if (isThenable(cleanupResult)) {
738
1441
  return cleanupResult.then(() => {
739
1442
  throw error;
@@ -742,17 +1445,16 @@ class GraphRuntime {
742
1445
  throw error;
743
1446
  }
744
1447
  }
745
- // Update ref
746
- if (nextVnode.ref !== undefined) {
747
- nextVnode.ref.current = instance;
748
- }
749
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.
750
1452
  let nextChildVnodes;
751
1453
  try {
752
1454
  nextChildVnodes = this.getChildVnodes(instance, nextVnode.children);
753
1455
  }
754
1456
  catch (error) {
755
- const cleanupResult = this.runFiberFailedCleanup(current);
1457
+ const cleanupResult = this.runFiberFailedCleanup(current, error);
756
1458
  if (isThenable(cleanupResult)) {
757
1459
  return cleanupResult.then(() => {
758
1460
  throw error;
@@ -771,28 +1473,91 @@ class GraphRuntime {
771
1473
  return current;
772
1474
  }
773
1475
  /**
774
- * Fiber cleanup after update/compose error: `runFailedCleanup` + bus dispose.
775
- * 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).
776
1480
  *
777
1481
  * @param {RuntimeFiber<unknown>} fiber - fiber that failed
1482
+ * @param {unknown} [primaryError] - originating error to attach cleanup failures onto
778
1483
  * @returns {void | Promise<void>}
779
1484
  */
780
- runFiberFailedCleanup(fiber) {
1485
+ runFiberFailedCleanup(fiber, primaryError) {
781
1486
  const instance = fiber.instance;
782
1487
  if (instance === null) {
783
1488
  return;
784
1489
  }
785
1490
  this.clearUpdateHook(instance);
786
1491
  this.dirtyFibers.delete(fiber);
787
- const cleanupResult = fiber.engine.runFailedCleanup(instance, true);
788
- if (isThenable(cleanupResult)) {
789
- return cleanupResult.then(() => {
790
- this.disposeEffectableRuntimeBusWiring(fiber);
791
- fiber.lifecycleStatus = fiber.engine.getStatus();
792
- });
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
+ }
793
1559
  }
794
- this.disposeEffectableRuntimeBusWiring(fiber);
795
- fiber.lifecycleStatus = fiber.engine.getStatus();
1560
+ return finishParent();
796
1561
  }
797
1562
  /**
798
1563
  * Applies the reconcile result to the current fiber in-place.
@@ -807,6 +1572,11 @@ class GraphRuntime {
807
1572
  * @returns {void}
808
1573
  */
809
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
+ }
810
1580
  current.vnode = nextVnode;
811
1581
  current.parentFiber = parentFiber;
812
1582
  current.children = nextChildren;
@@ -869,17 +1639,60 @@ class GraphRuntime {
869
1639
  }
870
1640
  return resultSoFar;
871
1641
  }
1642
+ /**
1643
+ * Validates that sibling keys are unique within a list.
1644
+ * Follows React v16.5 keyed child reconciliation contract: duplicate keys are invalid.
1645
+ * Throws a descriptive error including the duplicate key and parent component identity.
1646
+ *
1647
+ * @param {Array<{ vnode: { key?: string }; instance?: Component<unknown, unknown> | null }>} items - list of fibers or vnodes
1648
+ * @param {RuntimeFiber<unknown>} parentFiber - parent fiber (for error message)
1649
+ * @param {string} listName - "current" or "next" (for error message)
1650
+ * @returns {void}
1651
+ * @throws {Error} when duplicate keys are detected
1652
+ */
1653
+ validateUniqueKeys(items, parentFiber, listName) {
1654
+ const seenKeys = new Set();
1655
+ for (const item of items) {
1656
+ const key = item.vnode.key;
1657
+ if (key !== undefined) {
1658
+ if (seenKeys.has(key)) {
1659
+ const parentInstance = parentFiber.instance;
1660
+ const parentName = parentInstance !== null
1661
+ ? parentInstance.constructor.name
1662
+ : 'unknown';
1663
+ throw new Error(`[Effectable] GraphRuntime: duplicate key "${key}" in ${listName} children of ${parentName}. ` +
1664
+ `Sibling keys must be unique (React v16.5 keyed child reconciliation contract). ` +
1665
+ `Duplicates cause undefined matching behavior and lifecycle leaks.`);
1666
+ }
1667
+ seenKeys.add(key);
1668
+ }
1669
+ }
1670
+ }
872
1671
  /**
873
1672
  * Full-diff reconcile: keyed/unkeyed Map + destroy orphans.
874
1673
  * Always async — internal branching is too complex for an efficient sync path.
875
1674
  *
1675
+ * Contract: Sibling keys must be unique (React v16.5 keyed child reconciliation).
1676
+ * Validates both current and next children BEFORE any side effects.
1677
+ * Throws deterministic error on duplicate keys to prevent lifecycle leaks.
1678
+ *
1679
+ * HOLE 3: On throw during PLACE, cleans up previously placed new nodes
1680
+ * to prevent lifecycle leaks. Uses identity-safe check against currentChildren Set.
1681
+ *
876
1682
  * @param {RuntimeFiber<unknown>[]} currentChildren - current child fibers
877
1683
  * @param {VirtualServiceNode[]} nextVnodes - new vnodes
878
1684
  * @param {RuntimeFiber<unknown>} parentFiber - parent fiber
879
1685
  * @param {ContextScope} childScope - children scope
880
1686
  * @returns {Promise<RuntimeFiber<unknown>[]>}
1687
+ * @throws {Error} when duplicate keys are detected in current or next children
881
1688
  */
882
1689
  async reconcileChildrenFullDiff(currentChildren, nextVnodes, parentFiber, childScope) {
1690
+ // Validate unique keys BEFORE any side effects (Option A: React v16.5 contract)
1691
+ this.validateUniqueKeys(currentChildren, parentFiber, 'current');
1692
+ this.validateUniqueKeys(nextVnodes.map(vnode => ({ vnode, instance: null })), parentFiber, 'next');
1693
+ // HOLE 3: Build identity Set of currentChildren for rollback
1694
+ // Used to distinguish UPDATE (same object) from PLACE/REPLACE (new object)
1695
+ const currentChildrenSet = new Set(currentChildren);
883
1696
  // Check for keyed children before creating a Map (6.06x speedup for unkeyed-only)
884
1697
  let hasKeyedCurrent = false;
885
1698
  for (const child of currentChildren) {
@@ -891,33 +1704,67 @@ class GraphRuntime {
891
1704
  const unkeyedCurrent = [];
892
1705
  const nextChildren = [];
893
1706
  let unkeyedIdx = 0;
894
- if (hasKeyedCurrent) {
895
- // Acquire Map from the depth-indexed pool (5.1x: Map.clear() vs new Map())
896
- const keyedCurrentMap = this.acquireKeyedMap();
897
- this.reconcileDepth++;
898
- try {
899
- // Build map of current children by key (for keyed matching)
900
- for (const child of currentChildren) {
901
- const key = child.vnode.key;
902
- if (key !== undefined) {
903
- keyedCurrentMap.set(key, child);
1707
+ try {
1708
+ if (hasKeyedCurrent) {
1709
+ // Acquire Map from the depth-indexed pool (5.1x: Map.clear() vs new Map())
1710
+ const keyedCurrentMap = this.acquireKeyedMap();
1711
+ this.reconcileDepth++;
1712
+ try {
1713
+ // Build map of current children by key (for keyed matching)
1714
+ for (const child of currentChildren) {
1715
+ const key = child.vnode.key;
1716
+ if (key !== undefined) {
1717
+ keyedCurrentMap.set(key, child);
1718
+ }
1719
+ else {
1720
+ unkeyedCurrent.push(child);
1721
+ }
904
1722
  }
905
- else {
906
- unkeyedCurrent.push(child);
1723
+ for (const nextVnode of nextVnodes) {
1724
+ const nextKey = nextVnode.key;
1725
+ if (nextKey !== undefined && keyedCurrentMap.has(nextKey)) {
1726
+ const currentFiber = keyedCurrentMap.get(nextKey);
1727
+ if (currentFiber === undefined) {
1728
+ throw new Error(`[Effectable] GraphRuntime: fiber with key "${nextKey}" not found in map.`);
1729
+ }
1730
+ keyedCurrentMap.delete(nextKey);
1731
+ const reconciledRes = this.reconcileFiber(currentFiber, nextVnode, parentFiber, childScope);
1732
+ nextChildren.push(isThenable(reconciledRes) ? await reconciledRes : reconciledRes);
1733
+ }
1734
+ else if (nextKey === undefined && unkeyedIdx < unkeyedCurrent.length) {
1735
+ const currentFiber = unkeyedCurrent[unkeyedIdx];
1736
+ unkeyedIdx += 1;
1737
+ const reconciledRes = this.reconcileFiber(currentFiber, nextVnode, parentFiber, childScope);
1738
+ nextChildren.push(isThenable(reconciledRes) ? await reconciledRes : reconciledRes);
1739
+ }
1740
+ else {
1741
+ // New node — PLACE
1742
+ const newRes = this.materialize(nextVnode, parentFiber, childScope);
1743
+ nextChildren.push(isThenable(newRes) ? await newRes : newRes);
1744
+ }
907
1745
  }
908
- }
909
- for (const nextVnode of nextVnodes) {
910
- const nextKey = nextVnode.key;
911
- if (nextKey !== undefined && keyedCurrentMap.has(nextKey)) {
912
- const currentFiber = keyedCurrentMap.get(nextKey);
913
- if (currentFiber === undefined) {
914
- throw new Error(`[Effectable] GraphRuntime: fiber with key "${nextKey}" not found in map.`);
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.
1749
+ for (const [, orphan] of keyedCurrentMap) {
1750
+ const d = this.destroyFiber(orphan, []);
1751
+ if (isThenable(d)) {
1752
+ await d;
915
1753
  }
916
- keyedCurrentMap.delete(nextKey);
917
- const reconciledRes = this.reconcileFiber(currentFiber, nextVnode, parentFiber, childScope);
918
- nextChildren.push(isThenable(reconciledRes) ? await reconciledRes : reconciledRes);
919
1754
  }
920
- else if (nextKey === undefined && unkeyedIdx < unkeyedCurrent.length) {
1755
+ }
1756
+ finally {
1757
+ this.reconcileDepth--;
1758
+ this.releaseKeyedMap();
1759
+ }
1760
+ }
1761
+ else {
1762
+ // No keyed children — skip Map, positional reconcile
1763
+ for (const child of currentChildren) {
1764
+ unkeyedCurrent.push(child);
1765
+ }
1766
+ for (const nextVnode of nextVnodes) {
1767
+ if (unkeyedIdx < unkeyedCurrent.length) {
921
1768
  const currentFiber = unkeyedCurrent[unkeyedIdx];
922
1769
  unkeyedIdx += 1;
923
1770
  const reconciledRes = this.reconcileFiber(currentFiber, nextVnode, parentFiber, childScope);
@@ -929,49 +1776,48 @@ class GraphRuntime {
929
1776
  nextChildren.push(isThenable(newRes) ? await newRes : newRes);
930
1777
  }
931
1778
  }
932
- // Destroy remaining unpaired current children (keyed)
933
- for (const [, orphan] of keyedCurrentMap) {
934
- const d = this.destroyFiber(orphan);
1779
+ }
1780
+ // Destroy remaining unpaired unkeyed children (best-effort finalize errors).
1781
+ for (let i = unkeyedIdx; i < unkeyedCurrent.length; i += 1) {
1782
+ const orphan = unkeyedCurrent[i];
1783
+ if (orphan !== undefined) {
1784
+ const d = this.destroyFiber(orphan, []);
935
1785
  if (isThenable(d)) {
936
1786
  await d;
937
1787
  }
938
1788
  }
939
1789
  }
940
- finally {
941
- this.reconcileDepth--;
942
- this.releaseKeyedMap();
943
- }
944
- }
945
- else {
946
- // No keyed children — skip Map, positional reconcile
947
- for (const child of currentChildren) {
948
- unkeyedCurrent.push(child);
949
- }
950
- for (const nextVnode of nextVnodes) {
951
- if (unkeyedIdx < unkeyedCurrent.length) {
952
- const currentFiber = unkeyedCurrent[unkeyedIdx];
953
- unkeyedIdx += 1;
954
- const reconciledRes = this.reconcileFiber(currentFiber, nextVnode, parentFiber, childScope);
955
- nextChildren.push(isThenable(reconciledRes) ? await reconciledRes : reconciledRes);
1790
+ return nextChildren;
1791
+ }
1792
+ catch (primaryError) {
1793
+ // HOLE 3: On throw, clean up new fibers in nextChildren
1794
+ // that are NOT identity-in currentChildren (PLACE/REPLACE results).
1795
+ // Do not destroy UPDATE siblings (same object as current child).
1796
+ const rollbackErrors = [];
1797
+ for (const child of nextChildren) {
1798
+ // Skip if this fiber is identity-in currentChildren (UPDATE, not PLACE/REPLACE)
1799
+ if (currentChildrenSet.has(child)) {
1800
+ continue;
956
1801
  }
957
- else {
958
- // New node — PLACE
959
- const newRes = this.materialize(nextVnode, parentFiber, childScope);
960
- nextChildren.push(isThenable(newRes) ? await newRes : newRes);
1802
+ // New fiber (PLACE or REPLACE result) — destroy it
1803
+ try {
1804
+ const d = this.destroyFiber(child, rollbackErrors);
1805
+ if (isThenable(d)) {
1806
+ await d;
1807
+ }
961
1808
  }
962
- }
963
- }
964
- // Destroy remaining unpaired unkeyed children
965
- for (let i = unkeyedIdx; i < unkeyedCurrent.length; i += 1) {
966
- const orphan = unkeyedCurrent[i];
967
- if (orphan !== undefined) {
968
- const d = this.destroyFiber(orphan);
969
- if (isThenable(d)) {
970
- await d;
1809
+ catch (err) {
1810
+ rollbackErrors.push(err instanceof Error ? err : new Error(String(err)));
971
1811
  }
972
1812
  }
1813
+ // Attach rollback errors to primary error (pattern)
1814
+ if (rollbackErrors.length > 0) {
1815
+ const error = primaryError instanceof Error ? primaryError : new Error(String(primaryError));
1816
+ error.rollbackErrors = rollbackErrors;
1817
+ throw error;
1818
+ }
1819
+ throw primaryError;
973
1820
  }
974
- return nextChildren;
975
1821
  }
976
1822
  /**
977
1823
  * Whether children are stable: same count, same type and key per position.
@@ -1031,17 +1877,31 @@ class GraphRuntime {
1031
1877
  * Returns `void` synchronously if the whole subtree is sync (up to 266x speedup
1032
1878
  * on an 85-node tree); otherwise a Promise. `await` works correctly with either union branch.
1033
1879
  *
1880
+ * Collects cleanup errors via `collectErrors` parameter (best-effort cleanup).
1881
+ *
1034
1882
  * @param {RuntimeFiber} fiber - fiber to destroy
1883
+ * @param {Error[] | null} collectErrors - array to collect cleanup errors (null to throw immediately)
1035
1884
  * @returns {void | Promise<void>}
1036
1885
  */
1037
- destroyFiber(fiber) {
1886
+ destroyFiber(fiber, collectErrors = null) {
1038
1887
  const children = fiber.children;
1039
1888
  const n = children.length;
1040
1889
  // Sync recursion over children until the first async
1041
1890
  for (let i = 0; i < n; i++) {
1042
- const childRes = this.destroyFiber(children[i]);
1043
- if (isThenable(childRes)) {
1044
- return this.continueDestroyAsync(fiber, children, i, childRes);
1891
+ try {
1892
+ const childRes = this.destroyFiber(children[i], collectErrors);
1893
+ if (isThenable(childRes)) {
1894
+ return this.continueDestroyAsync(fiber, children, i, childRes, collectErrors);
1895
+ }
1896
+ }
1897
+ catch (err) {
1898
+ // Best-effort cleanup — collect error and continue
1899
+ if (collectErrors !== null) {
1900
+ collectErrors.push(err instanceof Error ? err : new Error(String(err)));
1901
+ }
1902
+ else {
1903
+ throw err;
1904
+ }
1045
1905
  }
1046
1906
  }
1047
1907
  const instance = fiber.instance;
@@ -1052,15 +1912,17 @@ class GraphRuntime {
1052
1912
  this.dirtyFibers.delete(fiber);
1053
1913
  const shutdownRes = fiber.engine.runShutdown(instance);
1054
1914
  if (isThenable(shutdownRes)) {
1055
- return this.finalizeDestroyAsync(fiber, shutdownRes);
1915
+ return this.finalizeDestroyAsync(fiber, shutdownRes, collectErrors);
1056
1916
  }
1057
- this.disposeEffectableRuntimeBusWiring(fiber);
1058
- // Clear ref after unmount
1059
- const ref = fiber.vnode.ref;
1060
- if (ref !== undefined) {
1061
- ref.current = null;
1917
+ // Collect shutdown errors for observability
1918
+ if (!shutdownRes.ok) {
1919
+ if (collectErrors !== null) {
1920
+ collectErrors.push(shutdownRes.error instanceof Error ? shutdownRes.error : new Error(String(shutdownRes.error)));
1921
+ }
1062
1922
  }
1063
- fiber.lifecycleStatus = fiber.engine.getStatus();
1923
+ // Always finalize the fiber even if shutdown failed (best-effort cleanup)
1924
+ // finalizeFiberDestroy uses commitRef for identity-safe ref clearing
1925
+ this.finalizeFiberDestroy(fiber, collectErrors);
1064
1926
  }
1065
1927
  /**
1066
1928
  * Async continuation of {@link destroyFiber} after one of the children returned a Promise.
@@ -1069,14 +1931,37 @@ class GraphRuntime {
1069
1931
  * @param {Fiber[]} children - children list
1070
1932
  * @param {number} pendingIdx - index of the pending child
1071
1933
  * @param {PromiseLike<void>} pending - Promise from destroying the child
1934
+ * @param {Error[] | null} collectErrors - array to collect cleanup errors
1072
1935
  * @returns {Promise<void>}
1073
1936
  */
1074
- async continueDestroyAsync(fiber, children, pendingIdx, pending) {
1075
- await pending;
1937
+ async continueDestroyAsync(fiber, children, pendingIdx, pending, collectErrors = null) {
1938
+ // Best-effort cleanup — await pending child
1939
+ try {
1940
+ await pending;
1941
+ }
1942
+ catch (err) {
1943
+ if (collectErrors !== null) {
1944
+ collectErrors.push(err instanceof Error ? err : new Error(String(err)));
1945
+ }
1946
+ else {
1947
+ throw err;
1948
+ }
1949
+ }
1950
+ // Continue destroying remaining children even if previous failed
1076
1951
  for (let i = pendingIdx + 1; i < children.length; i++) {
1077
- const r = this.destroyFiber(children[i]);
1078
- if (isThenable(r)) {
1079
- await r;
1952
+ try {
1953
+ const r = this.destroyFiber(children[i], collectErrors);
1954
+ if (isThenable(r)) {
1955
+ await r;
1956
+ }
1957
+ }
1958
+ catch (err) {
1959
+ if (collectErrors !== null) {
1960
+ collectErrors.push(err instanceof Error ? err : new Error(String(err)));
1961
+ }
1962
+ else {
1963
+ throw err;
1964
+ }
1080
1965
  }
1081
1966
  }
1082
1967
  const instance = fiber.instance;
@@ -1087,14 +1972,21 @@ class GraphRuntime {
1087
1972
  this.dirtyFibers.delete(fiber);
1088
1973
  const shutdownRes = fiber.engine.runShutdown(instance);
1089
1974
  if (isThenable(shutdownRes)) {
1090
- await shutdownRes;
1975
+ const asyncRes = await shutdownRes;
1976
+ if (typeof asyncRes === 'object' && asyncRes !== null && 'ok' in asyncRes && !asyncRes.ok) {
1977
+ if (collectErrors !== null) {
1978
+ collectErrors.push(asyncRes.error instanceof Error ? asyncRes.error : new Error(String(asyncRes.error)));
1979
+ }
1980
+ }
1091
1981
  }
1092
- this.disposeEffectableRuntimeBusWiring(fiber);
1093
- const ref = fiber.vnode.ref;
1094
- if (ref !== undefined) {
1095
- ref.current = null;
1982
+ else if (!shutdownRes.ok) {
1983
+ if (collectErrors !== null) {
1984
+ collectErrors.push(shutdownRes.error instanceof Error ? shutdownRes.error : new Error(String(shutdownRes.error)));
1985
+ }
1096
1986
  }
1097
- fiber.lifecycleStatus = fiber.engine.getStatus();
1987
+ // Always finalize even if shutdown failed
1988
+ // finalizeFiberDestroy uses commitRef for identity-safe ref clearing
1989
+ this.finalizeFiberDestroy(fiber, collectErrors);
1098
1990
  }
1099
1991
  /**
1100
1992
  * Async finalization of {@link destroyFiber} when children were destroyed synchronously
@@ -1102,16 +1994,21 @@ class GraphRuntime {
1102
1994
  *
1103
1995
  * @param {RuntimeFiber<unknown>} fiber
1104
1996
  * @param {PromiseLike<unknown>} pendingShutdown
1997
+ * @param {Error[] | null} collectErrors - array to collect cleanup errors
1105
1998
  * @returns {Promise<void>}
1106
1999
  */
1107
- async finalizeDestroyAsync(fiber, pendingShutdown) {
1108
- await pendingShutdown;
1109
- this.disposeEffectableRuntimeBusWiring(fiber);
1110
- const ref = fiber.vnode.ref;
1111
- if (ref !== undefined) {
1112
- ref.current = null;
2000
+ async finalizeDestroyAsync(fiber, pendingShutdown, collectErrors = null) {
2001
+ const shutdownRes = await pendingShutdown;
2002
+ // Collect shutdown errors for observability
2003
+ if (typeof shutdownRes === 'object' && shutdownRes !== null && 'ok' in shutdownRes && !shutdownRes.ok) {
2004
+ if (collectErrors !== null) {
2005
+ const err = shutdownRes.error;
2006
+ collectErrors.push(err instanceof Error ? err : new Error(String(err)));
2007
+ }
1113
2008
  }
1114
- fiber.lifecycleStatus = fiber.engine.getStatus();
2009
+ // Always finalize even if shutdown failed
2010
+ // finalizeFiberDestroy uses commitRef for identity-safe ref clearing
2011
+ this.finalizeFiberDestroy(fiber, collectErrors);
1115
2012
  }
1116
2013
  // ---------------------------------------------------------------------------
1117
2014
  // Helpers