effectable 0.1.0 → 1.0.0-canary.2

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 (58) hide show
  1. package/README.md +22 -4
  2. package/build/bootstrap/bootstrap.d.ts.map +1 -1
  3. package/build/bootstrap/bootstrap.js +12 -6
  4. package/build/bootstrap/bootstrap.js.map +1 -1
  5. package/build/bootstrap/types.d.ts +7 -1
  6. package/build/bootstrap/types.d.ts.map +1 -1
  7. package/build/component/GraphRuntime.d.ts +226 -18
  8. package/build/component/GraphRuntime.d.ts.map +1 -1
  9. package/build/component/GraphRuntime.js +944 -211
  10. package/build/component/GraphRuntime.js.map +1 -1
  11. package/build/component/context.d.ts +3 -11
  12. package/build/component/context.d.ts.map +1 -1
  13. package/build/component/context.js +24 -23
  14. package/build/component/context.js.map +1 -1
  15. package/build/component/h.d.ts +14 -6
  16. package/build/component/h.d.ts.map +1 -1
  17. package/build/component/h.js +19 -1
  18. package/build/component/h.js.map +1 -1
  19. package/build/component/refs.d.ts +7 -5
  20. package/build/component/refs.d.ts.map +1 -1
  21. package/build/component/refs.js +45 -22
  22. package/build/component/refs.js.map +1 -1
  23. package/build/component/types.d.ts +16 -5
  24. package/build/component/types.d.ts.map +1 -1
  25. package/build/component/types.js.map +1 -1
  26. package/build/connect/connect.d.ts.map +1 -1
  27. package/build/connect/connect.js +3 -12
  28. package/build/connect/connect.js.map +1 -1
  29. package/build/connect/types.d.ts +2 -2
  30. package/build/connect/types.d.ts.map +1 -1
  31. package/build/runtime/BusDecorators.d.ts +6 -0
  32. package/build/runtime/BusDecorators.d.ts.map +1 -1
  33. package/build/runtime/BusDecorators.js +117 -56
  34. package/build/runtime/BusDecorators.js.map +1 -1
  35. package/build/runtime/CommandBus.d.ts +1 -1
  36. package/build/runtime/CommandBus.d.ts.map +1 -1
  37. package/build/runtime/CommandBus.js +6 -3
  38. package/build/runtime/CommandBus.js.map +1 -1
  39. package/build/runtime/HandleRegistry.d.ts +2 -1
  40. package/build/runtime/HandleRegistry.d.ts.map +1 -1
  41. package/build/runtime/HandleRegistry.js +38 -7
  42. package/build/runtime/HandleRegistry.js.map +1 -1
  43. package/build/runtime/QueryBus.d.ts +1 -1
  44. package/build/runtime/QueryBus.d.ts.map +1 -1
  45. package/build/runtime/QueryBus.js +6 -3
  46. package/build/runtime/QueryBus.js.map +1 -1
  47. package/build/store/createStore.d.ts +2 -2
  48. package/build/store/createStore.d.ts.map +1 -1
  49. package/build/store/createStore.js +31 -4
  50. package/build/store/createStore.js.map +1 -1
  51. package/build/store/middleware.d.ts +16 -1
  52. package/build/store/middleware.d.ts.map +1 -1
  53. package/build/store/middleware.js +16 -15
  54. package/build/store/middleware.js.map +1 -1
  55. package/build/store/types.d.ts +11 -4
  56. package/build/store/types.d.ts.map +1 -1
  57. package/build/store/types.js.map +1 -1
  58. package/package.json +20 -2
@@ -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,225 @@ 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
358
+ if (journal.schedulerHookAttached === true && instance !== null) {
359
+ try {
360
+ this.clearUpdateHook(instance);
361
+ this.dirtyFibers.delete(fiber);
362
+ }
363
+ catch (err) {
364
+ cleanupErrors.push(err instanceof Error ? err : new Error(String(err)));
365
+ }
366
+ }
367
+ // 2. Dispose runtime bus registrations
368
+ if (journal.busWiringAttached === true) {
369
+ try {
370
+ this.disposeEffectableRuntimeBusWiring(fiber);
371
+ }
372
+ catch (err) {
373
+ cleanupErrors.push(err instanceof Error ? err : new Error(String(err)));
374
+ }
375
+ }
376
+ // 3. Clear bound ref (identity-safe)
377
+ if (journal.refBound === true && fiber.vnode.ref !== undefined && journal.refOwner !== undefined) {
378
+ try {
379
+ this.commitRef(fiber.vnode.ref, journal.refOwner, undefined, null);
380
+ }
381
+ catch (err) {
382
+ cleanupErrors.push(err instanceof Error ? err : new Error(String(err)));
383
+ }
384
+ }
385
+ // 4. Run failed-startup cleanup (if instance exists)
386
+ let cleanupPromise = null;
387
+ if (instance !== null) {
388
+ try {
389
+ const cleanupRes = fiber.engine.runFailedCleanup(instance, true);
390
+ if (isThenable(cleanupRes)) {
391
+ cleanupPromise = cleanupRes;
392
+ }
393
+ }
394
+ catch (err) {
395
+ cleanupErrors.push(err instanceof Error ? err : new Error(String(err)));
396
+ }
397
+ }
398
+ // 5. Destroy mounted children in reverse order
399
+ const destroyChildrenAndFinalize = () => {
400
+ const children = journal.mountedChildren;
401
+ for (let i = children.length - 1; i >= 0; i -= 1) {
402
+ try {
403
+ const destroyRes = this.destroyFiber(children[i]);
404
+ if (isThenable(destroyRes)) {
405
+ return this.continueRollbackDestroyAsync(children, i, destroyRes, primaryError, cleanupErrors);
406
+ }
407
+ }
408
+ catch (err) {
409
+ cleanupErrors.push(err instanceof Error ? err : new Error(String(err)));
410
+ }
411
+ }
412
+ // 6. Finalize: attach cleanup errors to primary error
413
+ this.finalizeRollback(primaryError, cleanupErrors);
414
+ };
415
+ if (cleanupPromise !== null) {
416
+ return cleanupPromise.then(() => destroyChildrenAndFinalize(), (err) => {
417
+ cleanupErrors.push(err instanceof Error ? err : new Error(String(err)));
418
+ return destroyChildrenAndFinalize();
419
+ });
420
+ }
421
+ return destroyChildrenAndFinalize();
422
+ }
423
+ /**
424
+ * Async continuation of rollback child destruction after one child's destroy returned a Promise.
425
+ *
426
+ * @param {RuntimeFiber<unknown>[]} children - mounted children
427
+ * @param {number} lastIdx - index of the last processed child
428
+ * @param {Promise<void>} pending - Promise from destroying the previous child
429
+ * @param {Error} primaryError - original materialization error
430
+ * @param {Error[]} cleanupErrors - accumulated cleanup errors
431
+ * @returns {Promise<void>}
432
+ */
433
+ async continueRollbackDestroyAsync(children, lastIdx, pending, primaryError, cleanupErrors) {
434
+ try {
435
+ await pending;
436
+ }
437
+ catch (err) {
438
+ cleanupErrors.push(err instanceof Error ? err : new Error(String(err)));
439
+ }
440
+ for (let i = lastIdx - 1; i >= 0; i -= 1) {
441
+ try {
442
+ const destroyRes = this.destroyFiber(children[i]);
443
+ if (isThenable(destroyRes)) {
444
+ await destroyRes;
445
+ }
446
+ }
447
+ catch (err) {
448
+ cleanupErrors.push(err instanceof Error ? err : new Error(String(err)));
449
+ }
450
+ }
451
+ this.finalizeRollback(primaryError, cleanupErrors);
452
+ }
453
+ /**
454
+ * Attaches cleanup errors to the primary error and rethrows.
455
+ *
456
+ * @param {Error} primaryError - original materialization error
457
+ * @param {Error[]} cleanupErrors - cleanup errors
458
+ * @returns {never}
459
+ */
460
+ finalizeRollback(primaryError, cleanupErrors) {
461
+ if (cleanupErrors.length > 0) {
462
+ primaryError.rollbackErrors = cleanupErrors;
463
+ }
464
+ throw primaryError;
465
+ }
139
466
  /**
140
467
  * Injects the scheduler hook onto the component instance after successful startup.
141
468
  * The hook is called from {@link Component.setState} and enqueues the fiber for automatic reconcile.
@@ -188,11 +515,13 @@ class GraphRuntime {
188
515
  * - If an ancestor of the fiber is already queued → skip (ancestor covers the subtree).
189
516
  * - If descendants of the fiber are queued → remove them (fiber covers their subtrees).
190
517
  *
518
+ * Skip scheduling when runtime is FAILED.
519
+ *
191
520
  * @param {RuntimeFiber<unknown>} fiber - fiber whose subtree needs rebuild
192
521
  * @returns {void}
193
522
  */
194
523
  scheduleUpdate(fiber) {
195
- if (this.unmounted) {
524
+ if (this.state === RUNTIME_STATE.FAILED || this.state === RUNTIME_STATE.UNMOUNTING || this.state === RUNTIME_STATE.UNMOUNTED) {
196
525
  return;
197
526
  }
198
527
  // If any ancestor is already queued — this fiber will be rebuilt as part of the ancestor
@@ -221,23 +550,79 @@ class GraphRuntime {
221
550
  // Schedule microtask dirty-flush
222
551
  this.scheduleDirtyFlushMicrotask();
223
552
  }
553
+ /**
554
+ * Enqueues an operation and starts the queue processor if idle.
555
+ * Operations are executed sequentially; concurrent callers await the same in-flight operation.
556
+ * Serialize all graph mutations.
557
+ *
558
+ * @param {() => Promise<void>} operation - operation to enqueue
559
+ * @returns {Promise<void>}
560
+ */
561
+ async enqueueOperation(operation) {
562
+ return new Promise((resolve, reject) => {
563
+ this.operationQueue.push(async () => {
564
+ try {
565
+ await operation();
566
+ resolve();
567
+ }
568
+ catch (error) {
569
+ reject(error);
570
+ }
571
+ });
572
+ if (!this.operationInProgress) {
573
+ // Start processing without awaiting to allow concurrent enqueuing
574
+ void this.processOperationQueue();
575
+ }
576
+ });
577
+ }
578
+ /**
579
+ * Processes the operation queue: runs operations one at a time.
580
+ * Single serialized owner of tree mutations.
581
+ * Errors from individual operations are propagated to their callers but do not stop the queue.
582
+ *
583
+ * @returns {Promise<void>}
584
+ */
585
+ async processOperationQueue() {
586
+ if (this.operationInProgress) {
587
+ return;
588
+ }
589
+ this.operationInProgress = true;
590
+ try {
591
+ while (this.operationQueue.length > 0) {
592
+ const operation = this.operationQueue.shift();
593
+ if (operation === undefined) {
594
+ break;
595
+ }
596
+ try {
597
+ await operation();
598
+ }
599
+ catch {
600
+ // Error is already propagated to the caller via the promise wrapper
601
+ // Continue processing the queue (don't poison it forever)
602
+ }
603
+ }
604
+ }
605
+ finally {
606
+ this.operationInProgress = false;
607
+ }
608
+ }
224
609
  /**
225
610
  * Queues one dirty-flush microtask and publishes {@link activeFlush} for await from `reconcile`.
226
611
  *
612
+ * Skip scheduling when runtime is FAILED.
613
+ *
227
614
  * @returns {void}
228
615
  */
229
616
  scheduleDirtyFlushMicrotask() {
230
- if (this.flushScheduled || this.unmounted) {
617
+ if (this.flushScheduled || this.state === RUNTIME_STATE.FAILED || this.state === RUNTIME_STATE.UNMOUNTING || this.state === RUNTIME_STATE.UNMOUNTED) {
231
618
  return;
232
619
  }
233
620
  this.flushScheduled = true;
234
621
  const flushWork = new Promise((resolve) => {
235
622
  queueMicrotask(() => {
236
623
  this.flushDirtyFibers()
237
- .catch((err) => {
238
- if (this.onAutoReconcileError !== null) {
239
- this.onAutoReconcileError(err);
240
- }
624
+ .catch(() => {
625
+ // Error already handled in flushDirtyFibers (onAutoReconcileError + fail-stop)
241
626
  })
242
627
  .finally(() => {
243
628
  resolve();
@@ -257,11 +642,14 @@ class GraphRuntime {
257
642
  * Guarded by the `flushing` flag against re-entrancy.
258
643
  * The chain of repeat passes is capped by {@link GRAPH_RUNTIME_MAX_DIRTY_FLUSH_PASSES}.
259
644
  *
645
+ * Respects state (UNMOUNTING/UNMOUNTED/FAILED) to cancel flush when unmount begins or failure occurs.
646
+ * On unrecoverable error, invokes onAutoReconcileError then fail-stops.
647
+ *
260
648
  * @returns {Promise<void>}
261
649
  */
262
650
  async flushDirtyFibers() {
263
651
  this.flushScheduled = false;
264
- if (this.unmounted || this.flushing) {
652
+ if (this.state === RUNTIME_STATE.FAILED || this.state === RUNTIME_STATE.UNMOUNTING || this.state === RUNTIME_STATE.UNMOUNTED || this.flushing) {
265
653
  this.dirtyFibers.clear();
266
654
  return;
267
655
  }
@@ -271,7 +659,7 @@ class GraphRuntime {
271
659
  this.dirtyFibers.clear();
272
660
  try {
273
661
  for (const fiber of snapshot) {
274
- if (this.unmounted) {
662
+ if (this._state === RUNTIME_STATE.FAILED || this._state === RUNTIME_STATE.UNMOUNTING || this._state === RUNTIME_STATE.UNMOUNTED) {
275
663
  break;
276
664
  }
277
665
  const res = this.reconcileDirtyFiber(fiber);
@@ -280,15 +668,39 @@ class GraphRuntime {
280
668
  }
281
669
  }
282
670
  }
671
+ catch (error) {
672
+ this.flushing = false;
673
+ // Notify error handler before fail-stop
674
+ if (this.onAutoReconcileError !== null) {
675
+ this.onAutoReconcileError(error);
676
+ }
677
+ // Fail-stop on unrecoverable dirty-flush error
678
+ const failError = error instanceof Error ? error : new Error(String(error));
679
+ const failRes = this.failStop(failError);
680
+ if (isThenable(failRes)) {
681
+ await failRes;
682
+ }
683
+ throw failError;
684
+ }
283
685
  finally {
284
686
  this.flushing = false;
285
687
  }
286
688
  // If new dirty fibers appeared during flush — schedule the next pass
287
- if (this.dirtyFibers.size > 0 && !this.unmounted) {
689
+ if (this.dirtyFibers.size > 0 && this.state === RUNTIME_STATE.ACTIVE) {
288
690
  if (this.dirtyFlushPassCount >= graphRuntime_constants_1.GRAPH_RUNTIME_MAX_DIRTY_FLUSH_PASSES) {
289
691
  this.dirtyFibers.clear();
290
692
  this.dirtyFlushPassCount = 0;
291
- throw new Error(`GraphRuntime: dirty flush exceeded ${String(graphRuntime_constants_1.GRAPH_RUNTIME_MAX_DIRTY_FLUSH_PASSES)} passes (anti-loop)`);
693
+ const loopError = new Error(`GraphRuntime: dirty flush exceeded ${String(graphRuntime_constants_1.GRAPH_RUNTIME_MAX_DIRTY_FLUSH_PASSES)} passes (anti-loop)`);
694
+ // Notify error handler before fail-stop
695
+ if (this.onAutoReconcileError !== null) {
696
+ this.onAutoReconcileError(loopError);
697
+ }
698
+ // Fail-stop on loop limit
699
+ const failRes = this.failStop(loopError);
700
+ if (isThenable(failRes)) {
701
+ await failRes;
702
+ }
703
+ throw loopError;
292
704
  }
293
705
  this.scheduleDirtyFlushMicrotask();
294
706
  }
@@ -306,7 +718,7 @@ class GraphRuntime {
306
718
  * @returns {void | Promise<void>}
307
719
  */
308
720
  reconcileDirtyFiber(fiber) {
309
- if (this.unmounted) {
721
+ if (this.state === RUNTIME_STATE.UNMOUNTING || this.state === RUNTIME_STATE.UNMOUNTED) {
310
722
  return;
311
723
  }
312
724
  const instance = fiber.instance;
@@ -359,63 +771,173 @@ class GraphRuntime {
359
771
  const rt = new GraphRuntime();
360
772
  rt.effectableRuntimeBuses = typeof runtimeBuses === 'undefined' ? null : runtimeBuses;
361
773
  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;
774
+ try {
775
+ const res = rt.materialize(root, null, initialScope);
776
+ rt.currentRoot = isThenable(res) ? await res : res;
777
+ rt.state = RUNTIME_STATE.ACTIVE;
778
+ return rt;
779
+ }
780
+ catch (error) {
781
+ // Fail-stop on unrecoverable mount error
782
+ const failError = error instanceof Error ? error : new Error(String(error));
783
+ const failRes = rt.failStop(failError);
784
+ if (isThenable(failRes)) {
785
+ await failRes;
786
+ }
787
+ throw failError;
788
+ }
365
789
  }
366
790
  /**
367
791
  * Reconciles against a new tree.
368
- * Builds a work-in-progress tree, computes effectTags, applies changes:
792
+ * Diffs the current tree against the new one, computes effectTags, applies changes:
369
793
  * - PLACE: create and mount a new node
370
794
  * - UPDATE: update props on an existing instance, call onUpdate
371
795
  * - DELETE: unmount and destroy a node
372
796
  *
797
+ * All reconcile calls are serialized through the operation queue.
798
+ * Rejects with terminal error when runtime is FAILED.
799
+ *
373
800
  * @param {VirtualServiceNode<P>} nextTree - new virtual tree
374
801
  * @returns {Promise<void>}
375
- * @throws {Error} if the runtime is already unmounted
802
+ * @throws {Error} if the runtime state is UNMOUNTING, UNMOUNTED, or FAILED
376
803
  */
377
804
  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;
805
+ // Reject immediately if unmount has started or completed
806
+ if (this.state === RUNTIME_STATE.UNMOUNTING || this.state === RUNTIME_STATE.UNMOUNTED) {
807
+ throw new Error('[Effectable] GraphRuntime: reconcile attempted after unmount started.');
808
+ }
809
+ // Reject immediately if runtime is in failed state
810
+ if (this.state === RUNTIME_STATE.FAILED) {
811
+ throw this.terminalError || new Error('[Effectable] GraphRuntime: reconcile attempted after terminal failure.');
812
+ }
813
+ // Serialize via operation queue
814
+ await this.enqueueOperation(async () => {
815
+ // Double-check after queue wait
816
+ if (this.state === RUNTIME_STATE.UNMOUNTING || this.state === RUNTIME_STATE.UNMOUNTED) {
817
+ throw new Error('[Effectable] GraphRuntime: reconcile attempted after unmount started.');
818
+ }
819
+ if (this.state === RUNTIME_STATE.FAILED) {
820
+ throw this.terminalError || new Error('[Effectable] GraphRuntime: reconcile attempted after terminal failure.');
821
+ }
822
+ if (this.currentRoot === null) {
823
+ throw new Error('[Effectable] GraphRuntime: currentRoot is not initialized.');
824
+ }
825
+ // Await the full dirty-flush chain (including re-schedule) otherwise manual
826
+ // reconcile overlaps the snapshot auto-flush.
827
+ while (this.activeFlush !== null) {
828
+ await this.activeFlush;
829
+ }
830
+ if (this._state === RUNTIME_STATE.UNMOUNTING || this._state === RUNTIME_STATE.UNMOUNTED) {
831
+ throw new Error('[Effectable] GraphRuntime: reconcile attempted after unmount started.');
832
+ }
833
+ if (this._state === RUNTIME_STATE.FAILED) {
834
+ throw this.terminalError || new Error('[Effectable] GraphRuntime: reconcile attempted after terminal failure.');
835
+ }
836
+ // Manual reconcile covers the whole tree from the root: cancel pending auto-flush
837
+ // to avoid double-mounting components from concurrent reconcile paths.
838
+ this.dirtyFibers.clear();
839
+ this.flushScheduled = false;
840
+ this.dirtyFlushPassCount = 0;
841
+ try {
842
+ const res = this.reconcileFiber(this.currentRoot, nextTree, null, this.currentRoot.scope);
843
+ this.currentRoot = isThenable(res) ? await res : res;
844
+ }
845
+ catch (error) {
846
+ // Fail-stop on unrecoverable reconcile error
847
+ // Let failStop destroy current tree and null currentRoot (single owner)
848
+ const failError = error instanceof Error ? error : new Error(String(error));
849
+ const failRes = this.failStop(failError);
850
+ if (isThenable(failRes)) {
851
+ await failRes;
852
+ }
853
+ throw failError;
854
+ }
855
+ });
399
856
  }
400
857
  /**
401
858
  * Fully unmounts the component tree.
402
- * Calls onUnmount for each node in reverse order (children before parent) and
859
+ * Calls onUnmount for each node (children before parent) and
403
860
  * moves stages to destroyed via LifecycleEngine.
404
861
  *
862
+ * Unmount is serialized, cached promise returned for concurrent callers.
863
+ * Safe and joinable even when runtime is FAILED.
864
+ * Collects cleanup errors when `rejectOnCleanupError: true` is passed.
865
+ *
866
+ * @param {object} [options] - unmount options
867
+ * @param {boolean} [options.rejectOnCleanupError=false] - reject on cleanup errors
405
868
  * @returns {Promise<void>}
406
869
  */
407
- async unmount() {
408
- if (this.unmounted) {
870
+ async unmount(options) {
871
+ const rejectOnCleanupError = options?.rejectOnCleanupError === true;
872
+ // If unmount is in progress, return the cached promise (HOLE 1)
873
+ // Must check BEFORE the UNMOUNTED early-return so concurrent callers join the in-flight unmount
874
+ if (this.cachedUnmountPromise !== null) {
875
+ return this.cachedUnmountPromise;
876
+ }
877
+ // If unmount already completed, return immediately
878
+ if (this.state === RUNTIME_STATE.UNMOUNTED) {
409
879
  return;
410
880
  }
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;
881
+ // Transition to UNMOUNTING state (reject new reconcile calls)
882
+ // If already FAILED, stay FAILED until unmount completes
883
+ if (this.state !== RUNTIME_STATE.FAILED) {
884
+ this.state = RUNTIME_STATE.UNMOUNTING;
418
885
  }
886
+ // Create and cache the unmount promise
887
+ this.cachedUnmountPromise = this.enqueueOperation(async () => {
888
+ // Double-check unmounted state
889
+ if (this.state === RUNTIME_STATE.UNMOUNTED) {
890
+ return;
891
+ }
892
+ // Cancel any pending dirty flush
893
+ this.dirtyFibers.clear();
894
+ this.flushScheduled = false;
895
+ // Wait for in-flight dirty flush to complete
896
+ if (this.activeFlush !== null) {
897
+ try {
898
+ await this.activeFlush;
899
+ }
900
+ catch {
901
+ // Ignore flush errors during unmount
902
+ }
903
+ }
904
+ // HOLE 1: stay UNMOUNTING during destroy (not UNMOUNTED)
905
+ // If already FAILED, keep FAILED state through destroy
906
+ // State transition to UNMOUNTED happens AFTER destroy completes
907
+ // If pendingTeardown is active (fail-stop in progress), await it first
908
+ if (this.pendingTeardown !== null) {
909
+ try {
910
+ await this.pendingTeardown;
911
+ }
912
+ catch {
913
+ // Ignore errors — they are already attached to the fail-stop primary error
914
+ }
915
+ }
916
+ if (this.currentRoot !== null) {
917
+ // Collect cleanup errors during unmount
918
+ const cleanupErrors = [];
919
+ const d = this.destroyFiber(this.currentRoot, cleanupErrors);
920
+ if (isThenable(d)) {
921
+ await d;
922
+ }
923
+ this.currentRoot = null;
924
+ // HOLE 1: set UNMOUNTED only after destroy finishes
925
+ // Transition even if FAILED — unmount is the terminal operation
926
+ this.state = RUNTIME_STATE.UNMOUNTED;
927
+ // Reject with cleanup errors when requested
928
+ if (rejectOnCleanupError && cleanupErrors.length > 0) {
929
+ if (cleanupErrors.length === 1) {
930
+ throw cleanupErrors[0];
931
+ }
932
+ throw new AggregateError(cleanupErrors, 'Cleanup errors during unmount');
933
+ }
934
+ }
935
+ else {
936
+ // No root to destroy — transition to UNMOUNTED
937
+ this.state = RUNTIME_STATE.UNMOUNTED;
938
+ }
939
+ });
940
+ return this.cachedUnmountPromise;
419
941
  }
420
942
  /**
421
943
  * Returns the root component instance (for testing and introspection).
@@ -429,12 +951,13 @@ class GraphRuntime {
429
951
  return this.currentRoot.instance;
430
952
  }
431
953
  /**
432
- * Whether the runtime is active (unmount has not been called).
954
+ * Whether the runtime is active (not failed and unmount has not been called).
955
+ * Returns false when state is FAILED.
433
956
  *
434
957
  * @returns {boolean}
435
958
  */
436
959
  isActive() {
437
- return !this.unmounted;
960
+ return this.state === RUNTIME_STATE.ACTIVE;
438
961
  }
439
962
  /**
440
963
  * Readonly snapshot of the root fiber tree for test/debug introspection.
@@ -470,6 +993,15 @@ class GraphRuntime {
470
993
  getStableAsyncContinueCount() {
471
994
  return this.stableAsyncContinueCount;
472
995
  }
996
+ /**
997
+ * Current runtime state.
998
+ * Test/debug probe; not a production API.
999
+ *
1000
+ * @returns {RuntimeState} current state
1001
+ */
1002
+ getState() {
1003
+ return this.state;
1004
+ }
473
1005
  /**
474
1006
  * Builds a deep readonly {@link FiberInspectNode} from a RuntimeFiber.
475
1007
  *
@@ -530,44 +1062,91 @@ class GraphRuntime {
530
1062
  effectTag: types_1.FIBER_EFFECT_TAG.PLACE,
531
1063
  engine,
532
1064
  scope: parentScope,
1065
+ constructionJournal: {
1066
+ mountedChildren: [],
1067
+ },
533
1068
  };
534
1069
  // Recursively materialize children before running the parent's lifecycle
535
1070
  const childVnodes = this.getChildVnodes(instance, vnode.children);
536
- const childFibers = [];
1071
+ // Validate unique keys BEFORE materialization (Option A: React v16.5 contract)
1072
+ // Prevent partial tree construction when duplicate keys are present
1073
+ this.validateUniqueKeys(childVnodes.map(vnode => ({ vnode, instance: null })), fiber, 'current');
537
1074
  for (let i = 0; i < childVnodes.length; i++) {
538
1075
  const childVnode = childVnodes[i];
539
- const childRes = this.materialize(childVnode, fiber, childScope);
1076
+ let childRes;
1077
+ try {
1078
+ childRes = this.materialize(childVnode, fiber, childScope);
1079
+ }
1080
+ catch (err) {
1081
+ const error = err instanceof Error ? err : new Error(String(err));
1082
+ const rollbackRes = this.rollbackFailedMaterialization(fiber, error);
1083
+ if (isThenable(rollbackRes)) {
1084
+ return rollbackRes.then(() => {
1085
+ throw error;
1086
+ });
1087
+ }
1088
+ throw error;
1089
+ }
540
1090
  if (isThenable(childRes)) {
541
1091
  // Hit an async child — continue the materialization tail in the async continuation.
542
- return this.continueMaterializeAsync(fiber, instance, engine, vnode, childVnodes, childFibers, childRes, i);
1092
+ return this.continueMaterializeAsync(fiber, instance, engine, vnode, childVnodes, childScope, childRes, i);
543
1093
  }
544
- childFibers.push(childRes);
1094
+ fiber.constructionJournal.mountedChildren.push(childRes);
545
1095
  }
546
- fiber.children = childFibers;
547
- // Bind ref to the instance
1096
+ fiber.children = fiber.constructionJournal.mountedChildren;
1097
+ // Bind ref to the instance (centralized via commitRef)
548
1098
  if (vnode.ref !== undefined) {
549
- vnode.ref.current = instance;
1099
+ try {
1100
+ this.commitRef(undefined, null, vnode.ref, instance);
1101
+ fiber.constructionJournal.refBound = true;
1102
+ fiber.constructionJournal.refOwner = instance;
1103
+ }
1104
+ catch (err) {
1105
+ const error = err instanceof Error ? err : new Error(String(err));
1106
+ const rollbackRes = this.rollbackFailedMaterialization(fiber, error);
1107
+ if (isThenable(rollbackRes)) {
1108
+ return rollbackRes.then(() => {
1109
+ throw error;
1110
+ });
1111
+ }
1112
+ throw error;
1113
+ }
1114
+ }
1115
+ try {
1116
+ this.attachEffectableRuntimeBusWiring(instance, fiber);
1117
+ if (fiber.effectableRuntimeBusDisposer !== undefined) {
1118
+ fiber.constructionJournal.busWiringAttached = true;
1119
+ }
1120
+ }
1121
+ catch (err) {
1122
+ const rollbackRes = this.rollbackFailedMaterialization(fiber, err instanceof Error ? err : new Error(String(err)));
1123
+ if (isThenable(rollbackRes)) {
1124
+ return rollbackRes.then(() => {
1125
+ throw err;
1126
+ });
1127
+ }
1128
+ throw err;
550
1129
  }
551
- this.attachEffectableRuntimeBusWiring(instance, fiber);
552
1130
  // Run lifecycle after all children are materialized.
553
1131
  // Pre-mount hook buffers setState from onMount until injectUpdateHook.
554
1132
  this.injectPreMountUpdateHook(instance, fiber);
555
1133
  const startupRes = engine.runStartup(instance);
556
1134
  if (isThenable(startupRes)) {
557
- return this.finalizeMaterializeAsync(fiber, engine, childFibers, startupRes);
1135
+ return this.finalizeMaterializeAsync(fiber, engine, startupRes);
558
1136
  }
559
1137
  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;
1138
+ const error = startupRes.error instanceof Error ? startupRes.error : new Error(String(startupRes.error));
1139
+ const rollbackRes = this.rollbackFailedMaterialization(fiber, error);
1140
+ if (isThenable(rollbackRes)) {
1141
+ return rollbackRes.then(() => {
1142
+ throw error;
565
1143
  });
566
1144
  }
567
- throw startupRes.error;
1145
+ throw error;
568
1146
  }
569
1147
  fiber.lifecycleStatus = engine.getStatus();
570
1148
  fiber.effectTag = null;
1149
+ fiber.constructionJournal.schedulerHookAttached = true;
571
1150
  this.injectUpdateHook(instance, fiber);
572
1151
  return fiber;
573
1152
  }
@@ -580,38 +1159,83 @@ class GraphRuntime {
580
1159
  * @param {LifecycleEngine} engine - lifecycle engine
581
1160
  * @param {VirtualServiceNode<P>} vnode - virtual node
582
1161
  * @param {VirtualServiceNode[]} childVnodes - all child vnodes
583
- * @param {RuntimeFiber<unknown>[]} childFibers - already materialized child fibers
1162
+ * @param {ContextScope} childScope - scope for child nodes
584
1163
  * @param {Promise<RuntimeFiber<unknown>>} pending - Promise for the current child
585
1164
  * @param {number} pendingIdx - index of the current child
586
1165
  * @returns {Promise<RuntimeFiber<P>>}
587
1166
  */
588
- async continueMaterializeAsync(fiber, instance, engine, vnode, childVnodes, childFibers, pending, pendingIdx) {
589
- const childScope = this.buildChildScope(instance, fiber.scope);
590
- childFibers.push(await pending);
1167
+ async continueMaterializeAsync(fiber, instance, engine, vnode, childVnodes, childScope, pending, pendingIdx) {
1168
+ const journal = fiber.constructionJournal;
1169
+ try {
1170
+ journal.mountedChildren.push(await pending);
1171
+ }
1172
+ catch (err) {
1173
+ const error = err instanceof Error ? err : new Error(String(err));
1174
+ const rollbackRes = this.rollbackFailedMaterialization(fiber, error);
1175
+ if (isThenable(rollbackRes)) {
1176
+ await rollbackRes;
1177
+ }
1178
+ throw error;
1179
+ }
591
1180
  for (let i = pendingIdx + 1; i < childVnodes.length; i++) {
592
1181
  const childVnode = childVnodes[i];
593
- const childRes = this.materialize(childVnode, fiber, childScope);
594
- childFibers.push(isThenable(childRes) ? await childRes : childRes);
1182
+ try {
1183
+ const childRes = this.materialize(childVnode, fiber, childScope);
1184
+ const resolvedChild = isThenable(childRes) ? await childRes : childRes;
1185
+ journal.mountedChildren.push(resolvedChild);
1186
+ }
1187
+ catch (err) {
1188
+ const error = err instanceof Error ? err : new Error(String(err));
1189
+ const rollbackRes = this.rollbackFailedMaterialization(fiber, error);
1190
+ if (isThenable(rollbackRes)) {
1191
+ await rollbackRes;
1192
+ }
1193
+ throw error;
1194
+ }
595
1195
  }
596
- fiber.children = childFibers;
1196
+ fiber.children = journal.mountedChildren;
597
1197
  if (vnode.ref !== undefined) {
598
- vnode.ref.current = instance;
1198
+ try {
1199
+ this.commitRef(undefined, null, vnode.ref, instance);
1200
+ journal.refBound = true;
1201
+ journal.refOwner = instance;
1202
+ }
1203
+ catch (err) {
1204
+ const error = err instanceof Error ? err : new Error(String(err));
1205
+ const rollbackRes = this.rollbackFailedMaterialization(fiber, error);
1206
+ if (isThenable(rollbackRes)) {
1207
+ await rollbackRes;
1208
+ }
1209
+ throw error;
1210
+ }
1211
+ }
1212
+ try {
1213
+ this.attachEffectableRuntimeBusWiring(instance, fiber);
1214
+ if (fiber.effectableRuntimeBusDisposer !== undefined) {
1215
+ journal.busWiringAttached = true;
1216
+ }
1217
+ }
1218
+ catch (err) {
1219
+ const rollbackRes = this.rollbackFailedMaterialization(fiber, err instanceof Error ? err : new Error(String(err)));
1220
+ if (isThenable(rollbackRes)) {
1221
+ await rollbackRes;
1222
+ }
1223
+ throw err;
599
1224
  }
600
- this.attachEffectableRuntimeBusWiring(instance, fiber);
601
1225
  this.injectPreMountUpdateHook(instance, fiber);
602
1226
  const startupRes = engine.runStartup(instance);
603
1227
  const resolved = isThenable(startupRes) ? await startupRes : startupRes;
604
1228
  if (!resolved.ok) {
605
- for (const c of childFibers) {
606
- const d = this.destroyFiber(c);
607
- if (isThenable(d)) {
608
- await d;
609
- }
1229
+ const error = resolved.error instanceof Error ? resolved.error : new Error(String(resolved.error));
1230
+ const rollbackRes = this.rollbackFailedMaterialization(fiber, error);
1231
+ if (isThenable(rollbackRes)) {
1232
+ await rollbackRes;
610
1233
  }
611
- throw resolved.error;
1234
+ throw error;
612
1235
  }
613
1236
  fiber.lifecycleStatus = engine.getStatus();
614
1237
  fiber.effectTag = null;
1238
+ journal.schedulerHookAttached = true;
615
1239
  this.injectUpdateHook(instance, fiber);
616
1240
  return fiber;
617
1241
  }
@@ -621,59 +1245,28 @@ class GraphRuntime {
621
1245
  * @template P node props type
622
1246
  * @param {RuntimeFiber<P>} fiber - fiber of the subtree root node
623
1247
  * @param {LifecycleEngine} engine - lifecycle engine for this node
624
- * @param {RuntimeFiber<unknown>[]} childFibers - already mounted child fibers (for rollback on error)
625
1248
  * @param {PromiseLike<import('./lifecycle').LifecycleTransitionResult>} pendingStartup - Promise of the `runStartup` result
626
1249
  * @returns {Promise<RuntimeFiber<P>>} ready fiber, or rollback children and rethrow
627
1250
  */
628
- async finalizeMaterializeAsync(fiber, engine, childFibers, pendingStartup) {
1251
+ async finalizeMaterializeAsync(fiber, engine, pendingStartup) {
629
1252
  const result = await pendingStartup;
1253
+ const journal = fiber.constructionJournal;
630
1254
  if (!result.ok) {
631
- for (const c of childFibers) {
632
- const d = this.destroyFiber(c);
633
- if (isThenable(d)) {
634
- await d;
635
- }
1255
+ const error = result.error instanceof Error ? result.error : new Error(String(result.error));
1256
+ const rollbackRes = this.rollbackFailedMaterialization(fiber, error);
1257
+ if (isThenable(rollbackRes)) {
1258
+ await rollbackRes;
636
1259
  }
637
- throw result.error;
1260
+ throw error;
638
1261
  }
639
1262
  fiber.lifecycleStatus = engine.getStatus();
640
1263
  fiber.effectTag = null;
641
1264
  if (fiber.instance !== null) {
1265
+ journal.schedulerHookAttached = true;
642
1266
  this.injectUpdateHook(fiber.instance, fiber);
643
1267
  }
644
1268
  return fiber;
645
1269
  }
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
1270
  // ---------------------------------------------------------------------------
678
1271
  // Reconcile
679
1272
  // ---------------------------------------------------------------------------
@@ -725,10 +1318,27 @@ class GraphRuntime {
725
1318
  else {
726
1319
  instance.props = nextVnode.props;
727
1320
  }
1321
+ // Re-inject context fields when parent scope changed
1322
+ let contextChanged = false;
1323
+ if (current.scope !== parentScope) {
1324
+ try {
1325
+ contextChanged = (0, context_1.injectContextFields)(instance, parentScope);
1326
+ }
1327
+ catch (error) {
1328
+ const cleanupResult = this.runFiberFailedCleanup(current);
1329
+ if (isThenable(cleanupResult)) {
1330
+ return cleanupResult.then(() => {
1331
+ throw error;
1332
+ });
1333
+ }
1334
+ throw error;
1335
+ }
1336
+ }
728
1337
  // Build scope for child nodes (ContextProvider may have updated values)
729
1338
  const childScope = this.buildChildScope(instance, parentScope);
730
- // Call onUpdate if props changed
731
- if (prevProps !== instance.props && current.engine.canUpdate()) {
1339
+ // Call onUpdate if props or context changed (React 16.5 class-component style: one hook)
1340
+ const propsChanged = prevProps !== instance.props;
1341
+ if ((propsChanged || contextChanged) && current.engine.canUpdate()) {
732
1342
  try {
733
1343
  instance.onUpdate(prevProps, instance.props);
734
1344
  }
@@ -742,10 +1352,8 @@ class GraphRuntime {
742
1352
  throw error;
743
1353
  }
744
1354
  }
745
- // Update ref
746
- if (nextVnode.ref !== undefined) {
747
- nextVnode.ref.current = instance;
748
- }
1355
+ // Commit ref transition: clear old ref if changed, bind new ref
1356
+ this.commitRef(current.vnode.ref, instance, nextVnode.ref, instance);
749
1357
  // Reconcile child nodes (sync fast-path if all children are sync).
750
1358
  let nextChildVnodes;
751
1359
  try {
@@ -869,17 +1477,60 @@ class GraphRuntime {
869
1477
  }
870
1478
  return resultSoFar;
871
1479
  }
1480
+ /**
1481
+ * Validates that sibling keys are unique within a list.
1482
+ * Follows React v16.5 keyed child reconciliation contract: duplicate keys are invalid.
1483
+ * Throws a descriptive error including the duplicate key and parent component identity.
1484
+ *
1485
+ * @param {Array<{ vnode: { key?: string }; instance?: Component<unknown, unknown> | null }>} items - list of fibers or vnodes
1486
+ * @param {RuntimeFiber<unknown>} parentFiber - parent fiber (for error message)
1487
+ * @param {string} listName - "current" or "next" (for error message)
1488
+ * @returns {void}
1489
+ * @throws {Error} when duplicate keys are detected
1490
+ */
1491
+ validateUniqueKeys(items, parentFiber, listName) {
1492
+ const seenKeys = new Set();
1493
+ for (const item of items) {
1494
+ const key = item.vnode.key;
1495
+ if (key !== undefined) {
1496
+ if (seenKeys.has(key)) {
1497
+ const parentInstance = parentFiber.instance;
1498
+ const parentName = parentInstance !== null
1499
+ ? parentInstance.constructor.name
1500
+ : 'unknown';
1501
+ throw new Error(`[Effectable] GraphRuntime: duplicate key "${key}" in ${listName} children of ${parentName}. ` +
1502
+ `Sibling keys must be unique (React v16.5 keyed child reconciliation contract). ` +
1503
+ `Duplicates cause undefined matching behavior and lifecycle leaks.`);
1504
+ }
1505
+ seenKeys.add(key);
1506
+ }
1507
+ }
1508
+ }
872
1509
  /**
873
1510
  * Full-diff reconcile: keyed/unkeyed Map + destroy orphans.
874
1511
  * Always async — internal branching is too complex for an efficient sync path.
875
1512
  *
1513
+ * Contract: Sibling keys must be unique (React v16.5 keyed child reconciliation).
1514
+ * Validates both current and next children BEFORE any side effects.
1515
+ * Throws deterministic error on duplicate keys to prevent lifecycle leaks.
1516
+ *
1517
+ * HOLE 3: On throw during PLACE, cleans up previously placed new nodes
1518
+ * to prevent lifecycle leaks. Uses identity-safe check against currentChildren Set.
1519
+ *
876
1520
  * @param {RuntimeFiber<unknown>[]} currentChildren - current child fibers
877
1521
  * @param {VirtualServiceNode[]} nextVnodes - new vnodes
878
1522
  * @param {RuntimeFiber<unknown>} parentFiber - parent fiber
879
1523
  * @param {ContextScope} childScope - children scope
880
1524
  * @returns {Promise<RuntimeFiber<unknown>[]>}
1525
+ * @throws {Error} when duplicate keys are detected in current or next children
881
1526
  */
882
1527
  async reconcileChildrenFullDiff(currentChildren, nextVnodes, parentFiber, childScope) {
1528
+ // Validate unique keys BEFORE any side effects (Option A: React v16.5 contract)
1529
+ this.validateUniqueKeys(currentChildren, parentFiber, 'current');
1530
+ this.validateUniqueKeys(nextVnodes.map(vnode => ({ vnode, instance: null })), parentFiber, 'next');
1531
+ // HOLE 3: Build identity Set of currentChildren for rollback
1532
+ // Used to distinguish UPDATE (same object) from PLACE/REPLACE (new object)
1533
+ const currentChildrenSet = new Set(currentChildren);
883
1534
  // Check for keyed children before creating a Map (6.06x speedup for unkeyed-only)
884
1535
  let hasKeyedCurrent = false;
885
1536
  for (const child of currentChildren) {
@@ -891,33 +1542,65 @@ class GraphRuntime {
891
1542
  const unkeyedCurrent = [];
892
1543
  const nextChildren = [];
893
1544
  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);
1545
+ try {
1546
+ if (hasKeyedCurrent) {
1547
+ // Acquire Map from the depth-indexed pool (5.1x: Map.clear() vs new Map())
1548
+ const keyedCurrentMap = this.acquireKeyedMap();
1549
+ this.reconcileDepth++;
1550
+ try {
1551
+ // Build map of current children by key (for keyed matching)
1552
+ for (const child of currentChildren) {
1553
+ const key = child.vnode.key;
1554
+ if (key !== undefined) {
1555
+ keyedCurrentMap.set(key, child);
1556
+ }
1557
+ else {
1558
+ unkeyedCurrent.push(child);
1559
+ }
904
1560
  }
905
- else {
906
- unkeyedCurrent.push(child);
1561
+ for (const nextVnode of nextVnodes) {
1562
+ const nextKey = nextVnode.key;
1563
+ if (nextKey !== undefined && keyedCurrentMap.has(nextKey)) {
1564
+ const currentFiber = keyedCurrentMap.get(nextKey);
1565
+ if (currentFiber === undefined) {
1566
+ throw new Error(`[Effectable] GraphRuntime: fiber with key "${nextKey}" not found in map.`);
1567
+ }
1568
+ keyedCurrentMap.delete(nextKey);
1569
+ const reconciledRes = this.reconcileFiber(currentFiber, nextVnode, parentFiber, childScope);
1570
+ nextChildren.push(isThenable(reconciledRes) ? await reconciledRes : reconciledRes);
1571
+ }
1572
+ else if (nextKey === undefined && unkeyedIdx < unkeyedCurrent.length) {
1573
+ const currentFiber = unkeyedCurrent[unkeyedIdx];
1574
+ unkeyedIdx += 1;
1575
+ const reconciledRes = this.reconcileFiber(currentFiber, nextVnode, parentFiber, childScope);
1576
+ nextChildren.push(isThenable(reconciledRes) ? await reconciledRes : reconciledRes);
1577
+ }
1578
+ else {
1579
+ // New node — PLACE
1580
+ const newRes = this.materialize(nextVnode, parentFiber, childScope);
1581
+ nextChildren.push(isThenable(newRes) ? await newRes : newRes);
1582
+ }
907
1583
  }
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.`);
1584
+ // Destroy remaining unpaired current children (keyed)
1585
+ for (const [, orphan] of keyedCurrentMap) {
1586
+ const d = this.destroyFiber(orphan);
1587
+ if (isThenable(d)) {
1588
+ await d;
915
1589
  }
916
- keyedCurrentMap.delete(nextKey);
917
- const reconciledRes = this.reconcileFiber(currentFiber, nextVnode, parentFiber, childScope);
918
- nextChildren.push(isThenable(reconciledRes) ? await reconciledRes : reconciledRes);
919
1590
  }
920
- else if (nextKey === undefined && unkeyedIdx < unkeyedCurrent.length) {
1591
+ }
1592
+ finally {
1593
+ this.reconcileDepth--;
1594
+ this.releaseKeyedMap();
1595
+ }
1596
+ }
1597
+ else {
1598
+ // No keyed children — skip Map, positional reconcile
1599
+ for (const child of currentChildren) {
1600
+ unkeyedCurrent.push(child);
1601
+ }
1602
+ for (const nextVnode of nextVnodes) {
1603
+ if (unkeyedIdx < unkeyedCurrent.length) {
921
1604
  const currentFiber = unkeyedCurrent[unkeyedIdx];
922
1605
  unkeyedIdx += 1;
923
1606
  const reconciledRes = this.reconcileFiber(currentFiber, nextVnode, parentFiber, childScope);
@@ -929,49 +1612,48 @@ class GraphRuntime {
929
1612
  nextChildren.push(isThenable(newRes) ? await newRes : newRes);
930
1613
  }
931
1614
  }
932
- // Destroy remaining unpaired current children (keyed)
933
- for (const [, orphan] of keyedCurrentMap) {
1615
+ }
1616
+ // Destroy remaining unpaired unkeyed children
1617
+ for (let i = unkeyedIdx; i < unkeyedCurrent.length; i += 1) {
1618
+ const orphan = unkeyedCurrent[i];
1619
+ if (orphan !== undefined) {
934
1620
  const d = this.destroyFiber(orphan);
935
1621
  if (isThenable(d)) {
936
1622
  await d;
937
1623
  }
938
1624
  }
939
1625
  }
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);
1626
+ return nextChildren;
1627
+ }
1628
+ catch (primaryError) {
1629
+ // HOLE 3: On throw, clean up new fibers in nextChildren
1630
+ // that are NOT identity-in currentChildren (PLACE/REPLACE results).
1631
+ // Do not destroy UPDATE siblings (same object as current child).
1632
+ const rollbackErrors = [];
1633
+ for (const child of nextChildren) {
1634
+ // Skip if this fiber is identity-in currentChildren (UPDATE, not PLACE/REPLACE)
1635
+ if (currentChildrenSet.has(child)) {
1636
+ continue;
956
1637
  }
957
- else {
958
- // New node — PLACE
959
- const newRes = this.materialize(nextVnode, parentFiber, childScope);
960
- nextChildren.push(isThenable(newRes) ? await newRes : newRes);
1638
+ // New fiber (PLACE or REPLACE result) — destroy it
1639
+ try {
1640
+ const d = this.destroyFiber(child, rollbackErrors);
1641
+ if (isThenable(d)) {
1642
+ await d;
1643
+ }
961
1644
  }
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;
1645
+ catch (err) {
1646
+ rollbackErrors.push(err instanceof Error ? err : new Error(String(err)));
971
1647
  }
972
1648
  }
1649
+ // Attach rollback errors to primary error (pattern)
1650
+ if (rollbackErrors.length > 0) {
1651
+ const error = primaryError instanceof Error ? primaryError : new Error(String(primaryError));
1652
+ error.rollbackErrors = rollbackErrors;
1653
+ throw error;
1654
+ }
1655
+ throw primaryError;
973
1656
  }
974
- return nextChildren;
975
1657
  }
976
1658
  /**
977
1659
  * Whether children are stable: same count, same type and key per position.
@@ -1031,17 +1713,31 @@ class GraphRuntime {
1031
1713
  * Returns `void` synchronously if the whole subtree is sync (up to 266x speedup
1032
1714
  * on an 85-node tree); otherwise a Promise. `await` works correctly with either union branch.
1033
1715
  *
1716
+ * Collects cleanup errors via `collectErrors` parameter (best-effort cleanup).
1717
+ *
1034
1718
  * @param {RuntimeFiber} fiber - fiber to destroy
1719
+ * @param {Error[] | null} collectErrors - array to collect cleanup errors (null to throw immediately)
1035
1720
  * @returns {void | Promise<void>}
1036
1721
  */
1037
- destroyFiber(fiber) {
1722
+ destroyFiber(fiber, collectErrors = null) {
1038
1723
  const children = fiber.children;
1039
1724
  const n = children.length;
1040
1725
  // Sync recursion over children until the first async
1041
1726
  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);
1727
+ try {
1728
+ const childRes = this.destroyFiber(children[i], collectErrors);
1729
+ if (isThenable(childRes)) {
1730
+ return this.continueDestroyAsync(fiber, children, i, childRes, collectErrors);
1731
+ }
1732
+ }
1733
+ catch (err) {
1734
+ // Best-effort cleanup — collect error and continue
1735
+ if (collectErrors !== null) {
1736
+ collectErrors.push(err instanceof Error ? err : new Error(String(err)));
1737
+ }
1738
+ else {
1739
+ throw err;
1740
+ }
1045
1741
  }
1046
1742
  }
1047
1743
  const instance = fiber.instance;
@@ -1052,15 +1748,17 @@ class GraphRuntime {
1052
1748
  this.dirtyFibers.delete(fiber);
1053
1749
  const shutdownRes = fiber.engine.runShutdown(instance);
1054
1750
  if (isThenable(shutdownRes)) {
1055
- return this.finalizeDestroyAsync(fiber, shutdownRes);
1751
+ return this.finalizeDestroyAsync(fiber, shutdownRes, collectErrors);
1056
1752
  }
1057
- this.disposeEffectableRuntimeBusWiring(fiber);
1058
- // Clear ref after unmount
1059
- const ref = fiber.vnode.ref;
1060
- if (ref !== undefined) {
1061
- ref.current = null;
1753
+ // Collect shutdown errors for observability
1754
+ if (!shutdownRes.ok) {
1755
+ if (collectErrors !== null) {
1756
+ collectErrors.push(shutdownRes.error instanceof Error ? shutdownRes.error : new Error(String(shutdownRes.error)));
1757
+ }
1062
1758
  }
1063
- fiber.lifecycleStatus = fiber.engine.getStatus();
1759
+ // Always finalize the fiber even if shutdown failed (best-effort cleanup)
1760
+ // finalizeFiberDestroy uses commitRef for identity-safe ref clearing
1761
+ this.finalizeFiberDestroy(fiber, collectErrors);
1064
1762
  }
1065
1763
  /**
1066
1764
  * Async continuation of {@link destroyFiber} after one of the children returned a Promise.
@@ -1069,14 +1767,37 @@ class GraphRuntime {
1069
1767
  * @param {Fiber[]} children - children list
1070
1768
  * @param {number} pendingIdx - index of the pending child
1071
1769
  * @param {PromiseLike<void>} pending - Promise from destroying the child
1770
+ * @param {Error[] | null} collectErrors - array to collect cleanup errors
1072
1771
  * @returns {Promise<void>}
1073
1772
  */
1074
- async continueDestroyAsync(fiber, children, pendingIdx, pending) {
1075
- await pending;
1773
+ async continueDestroyAsync(fiber, children, pendingIdx, pending, collectErrors = null) {
1774
+ // Best-effort cleanup — await pending child
1775
+ try {
1776
+ await pending;
1777
+ }
1778
+ catch (err) {
1779
+ if (collectErrors !== null) {
1780
+ collectErrors.push(err instanceof Error ? err : new Error(String(err)));
1781
+ }
1782
+ else {
1783
+ throw err;
1784
+ }
1785
+ }
1786
+ // Continue destroying remaining children even if previous failed
1076
1787
  for (let i = pendingIdx + 1; i < children.length; i++) {
1077
- const r = this.destroyFiber(children[i]);
1078
- if (isThenable(r)) {
1079
- await r;
1788
+ try {
1789
+ const r = this.destroyFiber(children[i], collectErrors);
1790
+ if (isThenable(r)) {
1791
+ await r;
1792
+ }
1793
+ }
1794
+ catch (err) {
1795
+ if (collectErrors !== null) {
1796
+ collectErrors.push(err instanceof Error ? err : new Error(String(err)));
1797
+ }
1798
+ else {
1799
+ throw err;
1800
+ }
1080
1801
  }
1081
1802
  }
1082
1803
  const instance = fiber.instance;
@@ -1087,14 +1808,21 @@ class GraphRuntime {
1087
1808
  this.dirtyFibers.delete(fiber);
1088
1809
  const shutdownRes = fiber.engine.runShutdown(instance);
1089
1810
  if (isThenable(shutdownRes)) {
1090
- await shutdownRes;
1811
+ const asyncRes = await shutdownRes;
1812
+ if (typeof asyncRes === 'object' && asyncRes !== null && 'ok' in asyncRes && !asyncRes.ok) {
1813
+ if (collectErrors !== null) {
1814
+ collectErrors.push(asyncRes.error instanceof Error ? asyncRes.error : new Error(String(asyncRes.error)));
1815
+ }
1816
+ }
1091
1817
  }
1092
- this.disposeEffectableRuntimeBusWiring(fiber);
1093
- const ref = fiber.vnode.ref;
1094
- if (ref !== undefined) {
1095
- ref.current = null;
1818
+ else if (!shutdownRes.ok) {
1819
+ if (collectErrors !== null) {
1820
+ collectErrors.push(shutdownRes.error instanceof Error ? shutdownRes.error : new Error(String(shutdownRes.error)));
1821
+ }
1096
1822
  }
1097
- fiber.lifecycleStatus = fiber.engine.getStatus();
1823
+ // Always finalize even if shutdown failed
1824
+ // finalizeFiberDestroy uses commitRef for identity-safe ref clearing
1825
+ this.finalizeFiberDestroy(fiber, collectErrors);
1098
1826
  }
1099
1827
  /**
1100
1828
  * Async finalization of {@link destroyFiber} when children were destroyed synchronously
@@ -1102,16 +1830,21 @@ class GraphRuntime {
1102
1830
  *
1103
1831
  * @param {RuntimeFiber<unknown>} fiber
1104
1832
  * @param {PromiseLike<unknown>} pendingShutdown
1833
+ * @param {Error[] | null} collectErrors - array to collect cleanup errors
1105
1834
  * @returns {Promise<void>}
1106
1835
  */
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;
1836
+ async finalizeDestroyAsync(fiber, pendingShutdown, collectErrors = null) {
1837
+ const shutdownRes = await pendingShutdown;
1838
+ // Collect shutdown errors for observability
1839
+ if (typeof shutdownRes === 'object' && shutdownRes !== null && 'ok' in shutdownRes && !shutdownRes.ok) {
1840
+ if (collectErrors !== null) {
1841
+ const err = shutdownRes.error;
1842
+ collectErrors.push(err instanceof Error ? err : new Error(String(err)));
1843
+ }
1113
1844
  }
1114
- fiber.lifecycleStatus = fiber.engine.getStatus();
1845
+ // Always finalize even if shutdown failed
1846
+ // finalizeFiberDestroy uses commitRef for identity-safe ref clearing
1847
+ this.finalizeFiberDestroy(fiber, collectErrors);
1115
1848
  }
1116
1849
  // ---------------------------------------------------------------------------
1117
1850
  // Helpers