effectable 1.0.0-canary.2 → 1.0.0

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 +3 -9
  2. package/build/bootstrap/bootstrap.d.ts.map +1 -1
  3. package/build/bootstrap/bootstrap.js +6 -12
  4. package/build/bootstrap/bootstrap.js.map +1 -1
  5. package/build/bootstrap/types.d.ts +1 -7
  6. package/build/bootstrap/types.d.ts.map +1 -1
  7. package/build/component/GraphRuntime.d.ts +18 -226
  8. package/build/component/GraphRuntime.d.ts.map +1 -1
  9. package/build/component/GraphRuntime.js +211 -944
  10. package/build/component/GraphRuntime.js.map +1 -1
  11. package/build/component/context.d.ts +11 -3
  12. package/build/component/context.d.ts.map +1 -1
  13. package/build/component/context.js +23 -24
  14. package/build/component/context.js.map +1 -1
  15. package/build/component/h.d.ts +6 -14
  16. package/build/component/h.d.ts.map +1 -1
  17. package/build/component/h.js +1 -19
  18. package/build/component/h.js.map +1 -1
  19. package/build/component/refs.d.ts +5 -7
  20. package/build/component/refs.d.ts.map +1 -1
  21. package/build/component/refs.js +22 -45
  22. package/build/component/refs.js.map +1 -1
  23. package/build/component/types.d.ts +5 -16
  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 +12 -3
  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 +0 -6
  32. package/build/runtime/BusDecorators.d.ts.map +1 -1
  33. package/build/runtime/BusDecorators.js +56 -117
  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 +3 -6
  38. package/build/runtime/CommandBus.js.map +1 -1
  39. package/build/runtime/HandleRegistry.d.ts +1 -2
  40. package/build/runtime/HandleRegistry.d.ts.map +1 -1
  41. package/build/runtime/HandleRegistry.js +7 -38
  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 +3 -6
  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 +4 -31
  50. package/build/store/createStore.js.map +1 -1
  51. package/build/store/middleware.d.ts +1 -16
  52. package/build/store/middleware.d.ts.map +1 -1
  53. package/build/store/middleware.js +15 -16
  54. package/build/store/middleware.js.map +1 -1
  55. package/build/store/types.d.ts +4 -11
  56. package/build/store/types.d.ts.map +1 -1
  57. package/build/store/types.js.map +1 -1
  58. package/package.json +3 -10
@@ -5,14 +5,10 @@
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).
9
8
  * - Drive lifecycle via LifecycleEngine: startup in topological order (children before parent),
10
- * shutdown in the same order (children before parent).
9
+ * shutdown in reverse order (parent before children).
11
10
  * - Inject contexts (@UseContext) and bind refs on mount.
12
11
  * - 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.
16
12
  *
17
13
  * Current limitations:
18
14
  * - Work loop is synchronous (no priority lanes — next increment).
@@ -45,20 +41,6 @@ function isThenable(value) {
45
41
  typeof value.then === 'function');
46
42
  }
47
43
  // ---------------------------------------------------------------------------
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
- // ---------------------------------------------------------------------------
62
44
  // GraphRuntime
63
45
  // ---------------------------------------------------------------------------
64
46
  /**
@@ -74,32 +56,13 @@ const RUNTIME_STATE = {
74
56
  class GraphRuntime {
75
57
  /** Current root fiber tree (current tree). */
76
58
  currentRoot = null;
59
+ /** Whether unmount has completed. */
60
+ unmounted = false;
77
61
  /**
78
62
  * Entry counter for {@link continueStableReconcileAsync} (test/debug probe).
79
63
  * Not reset automatically — compare before/after around reconcile.
80
64
  */
81
65
  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;
103
66
  /**
104
67
  * Runtime buses for auto-wiring decorators on nodes (optional, set in {@link GraphRuntime.mount}).
105
68
  */
@@ -139,81 +102,10 @@ class GraphRuntime {
139
102
  * Set via the fourth argument of {@link GraphRuntime.mount}.
140
103
  */
141
104
  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;
161
105
  /**
162
106
  * Instances are created only via {@link GraphRuntime.mount}; direct `new GraphRuntime()` is unavailable externally.
163
107
  */
164
108
  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
- }
217
109
  /**
218
110
  * Auto-wires runtime-bus decorators onto the instance before {@link LifecycleEngine.runStartup}.
219
111
  *
@@ -244,225 +136,6 @@ class GraphRuntime {
244
136
  }
245
137
  delete fiber.effectableRuntimeBusDisposer;
246
138
  }
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
- }
466
139
  /**
467
140
  * Injects the scheduler hook onto the component instance after successful startup.
468
141
  * The hook is called from {@link Component.setState} and enqueues the fiber for automatic reconcile.
@@ -515,13 +188,11 @@ class GraphRuntime {
515
188
  * - If an ancestor of the fiber is already queued → skip (ancestor covers the subtree).
516
189
  * - If descendants of the fiber are queued → remove them (fiber covers their subtrees).
517
190
  *
518
- * Skip scheduling when runtime is FAILED.
519
- *
520
191
  * @param {RuntimeFiber<unknown>} fiber - fiber whose subtree needs rebuild
521
192
  * @returns {void}
522
193
  */
523
194
  scheduleUpdate(fiber) {
524
- if (this.state === RUNTIME_STATE.FAILED || this.state === RUNTIME_STATE.UNMOUNTING || this.state === RUNTIME_STATE.UNMOUNTED) {
195
+ if (this.unmounted) {
525
196
  return;
526
197
  }
527
198
  // If any ancestor is already queued — this fiber will be rebuilt as part of the ancestor
@@ -550,79 +221,23 @@ class GraphRuntime {
550
221
  // Schedule microtask dirty-flush
551
222
  this.scheduleDirtyFlushMicrotask();
552
223
  }
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
- }
609
224
  /**
610
225
  * Queues one dirty-flush microtask and publishes {@link activeFlush} for await from `reconcile`.
611
226
  *
612
- * Skip scheduling when runtime is FAILED.
613
- *
614
227
  * @returns {void}
615
228
  */
616
229
  scheduleDirtyFlushMicrotask() {
617
- if (this.flushScheduled || this.state === RUNTIME_STATE.FAILED || this.state === RUNTIME_STATE.UNMOUNTING || this.state === RUNTIME_STATE.UNMOUNTED) {
230
+ if (this.flushScheduled || this.unmounted) {
618
231
  return;
619
232
  }
620
233
  this.flushScheduled = true;
621
234
  const flushWork = new Promise((resolve) => {
622
235
  queueMicrotask(() => {
623
236
  this.flushDirtyFibers()
624
- .catch(() => {
625
- // Error already handled in flushDirtyFibers (onAutoReconcileError + fail-stop)
237
+ .catch((err) => {
238
+ if (this.onAutoReconcileError !== null) {
239
+ this.onAutoReconcileError(err);
240
+ }
626
241
  })
627
242
  .finally(() => {
628
243
  resolve();
@@ -642,14 +257,11 @@ class GraphRuntime {
642
257
  * Guarded by the `flushing` flag against re-entrancy.
643
258
  * The chain of repeat passes is capped by {@link GRAPH_RUNTIME_MAX_DIRTY_FLUSH_PASSES}.
644
259
  *
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
- *
648
260
  * @returns {Promise<void>}
649
261
  */
650
262
  async flushDirtyFibers() {
651
263
  this.flushScheduled = false;
652
- if (this.state === RUNTIME_STATE.FAILED || this.state === RUNTIME_STATE.UNMOUNTING || this.state === RUNTIME_STATE.UNMOUNTED || this.flushing) {
264
+ if (this.unmounted || this.flushing) {
653
265
  this.dirtyFibers.clear();
654
266
  return;
655
267
  }
@@ -659,7 +271,7 @@ class GraphRuntime {
659
271
  this.dirtyFibers.clear();
660
272
  try {
661
273
  for (const fiber of snapshot) {
662
- if (this._state === RUNTIME_STATE.FAILED || this._state === RUNTIME_STATE.UNMOUNTING || this._state === RUNTIME_STATE.UNMOUNTED) {
274
+ if (this.unmounted) {
663
275
  break;
664
276
  }
665
277
  const res = this.reconcileDirtyFiber(fiber);
@@ -668,39 +280,15 @@ class GraphRuntime {
668
280
  }
669
281
  }
670
282
  }
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
- }
685
283
  finally {
686
284
  this.flushing = false;
687
285
  }
688
286
  // If new dirty fibers appeared during flush — schedule the next pass
689
- if (this.dirtyFibers.size > 0 && this.state === RUNTIME_STATE.ACTIVE) {
287
+ if (this.dirtyFibers.size > 0 && !this.unmounted) {
690
288
  if (this.dirtyFlushPassCount >= graphRuntime_constants_1.GRAPH_RUNTIME_MAX_DIRTY_FLUSH_PASSES) {
691
289
  this.dirtyFibers.clear();
692
290
  this.dirtyFlushPassCount = 0;
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;
291
+ throw new Error(`GraphRuntime: dirty flush exceeded ${String(graphRuntime_constants_1.GRAPH_RUNTIME_MAX_DIRTY_FLUSH_PASSES)} passes (anti-loop)`);
704
292
  }
705
293
  this.scheduleDirtyFlushMicrotask();
706
294
  }
@@ -718,7 +306,7 @@ class GraphRuntime {
718
306
  * @returns {void | Promise<void>}
719
307
  */
720
308
  reconcileDirtyFiber(fiber) {
721
- if (this.state === RUNTIME_STATE.UNMOUNTING || this.state === RUNTIME_STATE.UNMOUNTED) {
309
+ if (this.unmounted) {
722
310
  return;
723
311
  }
724
312
  const instance = fiber.instance;
@@ -771,173 +359,63 @@ class GraphRuntime {
771
359
  const rt = new GraphRuntime();
772
360
  rt.effectableRuntimeBuses = typeof runtimeBuses === 'undefined' ? null : runtimeBuses;
773
361
  rt.onAutoReconcileError = typeof onAutoReconcileError === 'function' ? onAutoReconcileError : null;
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
- }
362
+ const res = rt.materialize(root, null, initialScope);
363
+ rt.currentRoot = isThenable(res) ? await res : res;
364
+ return rt;
789
365
  }
790
366
  /**
791
367
  * Reconciles against a new tree.
792
- * Diffs the current tree against the new one, computes effectTags, applies changes:
368
+ * Builds a work-in-progress tree, computes effectTags, applies changes:
793
369
  * - PLACE: create and mount a new node
794
370
  * - UPDATE: update props on an existing instance, call onUpdate
795
371
  * - DELETE: unmount and destroy a node
796
372
  *
797
- * All reconcile calls are serialized through the operation queue.
798
- * Rejects with terminal error when runtime is FAILED.
799
- *
800
373
  * @param {VirtualServiceNode<P>} nextTree - new virtual tree
801
374
  * @returns {Promise<void>}
802
- * @throws {Error} if the runtime state is UNMOUNTING, UNMOUNTED, or FAILED
375
+ * @throws {Error} if the runtime is already unmounted
803
376
  */
804
377
  async reconcile(nextTree) {
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
- });
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;
856
399
  }
857
400
  /**
858
401
  * Fully unmounts the component tree.
859
- * Calls onUnmount for each node (children before parent) and
402
+ * Calls onUnmount for each node in reverse order (children before parent) and
860
403
  * moves stages to destroyed via LifecycleEngine.
861
404
  *
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
868
405
  * @returns {Promise<void>}
869
406
  */
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) {
407
+ async unmount() {
408
+ if (this.unmounted) {
879
409
  return;
880
410
  }
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;
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;
411
+ this.unmounted = true;
412
+ if (this.currentRoot !== null) {
413
+ const d = this.destroyFiber(this.currentRoot);
414
+ if (isThenable(d)) {
415
+ await d;
938
416
  }
939
- });
940
- return this.cachedUnmountPromise;
417
+ this.currentRoot = null;
418
+ }
941
419
  }
942
420
  /**
943
421
  * Returns the root component instance (for testing and introspection).
@@ -951,13 +429,12 @@ class GraphRuntime {
951
429
  return this.currentRoot.instance;
952
430
  }
953
431
  /**
954
- * Whether the runtime is active (not failed and unmount has not been called).
955
- * Returns false when state is FAILED.
432
+ * Whether the runtime is active (unmount has not been called).
956
433
  *
957
434
  * @returns {boolean}
958
435
  */
959
436
  isActive() {
960
- return this.state === RUNTIME_STATE.ACTIVE;
437
+ return !this.unmounted;
961
438
  }
962
439
  /**
963
440
  * Readonly snapshot of the root fiber tree for test/debug introspection.
@@ -993,15 +470,6 @@ class GraphRuntime {
993
470
  getStableAsyncContinueCount() {
994
471
  return this.stableAsyncContinueCount;
995
472
  }
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
- }
1005
473
  /**
1006
474
  * Builds a deep readonly {@link FiberInspectNode} from a RuntimeFiber.
1007
475
  *
@@ -1062,91 +530,44 @@ class GraphRuntime {
1062
530
  effectTag: types_1.FIBER_EFFECT_TAG.PLACE,
1063
531
  engine,
1064
532
  scope: parentScope,
1065
- constructionJournal: {
1066
- mountedChildren: [],
1067
- },
1068
533
  };
1069
534
  // Recursively materialize children before running the parent's lifecycle
1070
535
  const childVnodes = this.getChildVnodes(instance, vnode.children);
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');
536
+ const childFibers = [];
1074
537
  for (let i = 0; i < childVnodes.length; i++) {
1075
538
  const childVnode = childVnodes[i];
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
- }
539
+ const childRes = this.materialize(childVnode, fiber, childScope);
1090
540
  if (isThenable(childRes)) {
1091
541
  // Hit an async child — continue the materialization tail in the async continuation.
1092
- return this.continueMaterializeAsync(fiber, instance, engine, vnode, childVnodes, childScope, childRes, i);
542
+ return this.continueMaterializeAsync(fiber, instance, engine, vnode, childVnodes, childFibers, childRes, i);
1093
543
  }
1094
- fiber.constructionJournal.mountedChildren.push(childRes);
544
+ childFibers.push(childRes);
1095
545
  }
1096
- fiber.children = fiber.constructionJournal.mountedChildren;
1097
- // Bind ref to the instance (centralized via commitRef)
546
+ fiber.children = childFibers;
547
+ // Bind ref to the instance
1098
548
  if (vnode.ref !== undefined) {
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;
549
+ vnode.ref.current = instance;
1129
550
  }
551
+ this.attachEffectableRuntimeBusWiring(instance, fiber);
1130
552
  // Run lifecycle after all children are materialized.
1131
553
  // Pre-mount hook buffers setState from onMount until injectUpdateHook.
1132
554
  this.injectPreMountUpdateHook(instance, fiber);
1133
555
  const startupRes = engine.runStartup(instance);
1134
556
  if (isThenable(startupRes)) {
1135
- return this.finalizeMaterializeAsync(fiber, engine, startupRes);
557
+ return this.finalizeMaterializeAsync(fiber, engine, childFibers, startupRes);
1136
558
  }
1137
559
  if (!startupRes.ok) {
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;
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;
1143
565
  });
1144
566
  }
1145
- throw error;
567
+ throw startupRes.error;
1146
568
  }
1147
569
  fiber.lifecycleStatus = engine.getStatus();
1148
570
  fiber.effectTag = null;
1149
- fiber.constructionJournal.schedulerHookAttached = true;
1150
571
  this.injectUpdateHook(instance, fiber);
1151
572
  return fiber;
1152
573
  }
@@ -1159,83 +580,38 @@ class GraphRuntime {
1159
580
  * @param {LifecycleEngine} engine - lifecycle engine
1160
581
  * @param {VirtualServiceNode<P>} vnode - virtual node
1161
582
  * @param {VirtualServiceNode[]} childVnodes - all child vnodes
1162
- * @param {ContextScope} childScope - scope for child nodes
583
+ * @param {RuntimeFiber<unknown>[]} childFibers - already materialized child fibers
1163
584
  * @param {Promise<RuntimeFiber<unknown>>} pending - Promise for the current child
1164
585
  * @param {number} pendingIdx - index of the current child
1165
586
  * @returns {Promise<RuntimeFiber<P>>}
1166
587
  */
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
- }
588
+ async continueMaterializeAsync(fiber, instance, engine, vnode, childVnodes, childFibers, pending, pendingIdx) {
589
+ const childScope = this.buildChildScope(instance, fiber.scope);
590
+ childFibers.push(await pending);
1180
591
  for (let i = pendingIdx + 1; i < childVnodes.length; i++) {
1181
592
  const childVnode = childVnodes[i];
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
- }
593
+ const childRes = this.materialize(childVnode, fiber, childScope);
594
+ childFibers.push(isThenable(childRes) ? await childRes : childRes);
1195
595
  }
1196
- fiber.children = journal.mountedChildren;
596
+ fiber.children = childFibers;
1197
597
  if (vnode.ref !== undefined) {
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;
598
+ vnode.ref.current = instance;
1224
599
  }
600
+ this.attachEffectableRuntimeBusWiring(instance, fiber);
1225
601
  this.injectPreMountUpdateHook(instance, fiber);
1226
602
  const startupRes = engine.runStartup(instance);
1227
603
  const resolved = isThenable(startupRes) ? await startupRes : startupRes;
1228
604
  if (!resolved.ok) {
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;
605
+ for (const c of childFibers) {
606
+ const d = this.destroyFiber(c);
607
+ if (isThenable(d)) {
608
+ await d;
609
+ }
1233
610
  }
1234
- throw error;
611
+ throw resolved.error;
1235
612
  }
1236
613
  fiber.lifecycleStatus = engine.getStatus();
1237
614
  fiber.effectTag = null;
1238
- journal.schedulerHookAttached = true;
1239
615
  this.injectUpdateHook(instance, fiber);
1240
616
  return fiber;
1241
617
  }
@@ -1245,28 +621,59 @@ class GraphRuntime {
1245
621
  * @template P node props type
1246
622
  * @param {RuntimeFiber<P>} fiber - fiber of the subtree root node
1247
623
  * @param {LifecycleEngine} engine - lifecycle engine for this node
624
+ * @param {RuntimeFiber<unknown>[]} childFibers - already mounted child fibers (for rollback on error)
1248
625
  * @param {PromiseLike<import('./lifecycle').LifecycleTransitionResult>} pendingStartup - Promise of the `runStartup` result
1249
626
  * @returns {Promise<RuntimeFiber<P>>} ready fiber, or rollback children and rethrow
1250
627
  */
1251
- async finalizeMaterializeAsync(fiber, engine, pendingStartup) {
628
+ async finalizeMaterializeAsync(fiber, engine, childFibers, pendingStartup) {
1252
629
  const result = await pendingStartup;
1253
- const journal = fiber.constructionJournal;
1254
630
  if (!result.ok) {
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;
631
+ for (const c of childFibers) {
632
+ const d = this.destroyFiber(c);
633
+ if (isThenable(d)) {
634
+ await d;
635
+ }
1259
636
  }
1260
- throw error;
637
+ throw result.error;
1261
638
  }
1262
639
  fiber.lifecycleStatus = engine.getStatus();
1263
640
  fiber.effectTag = null;
1264
641
  if (fiber.instance !== null) {
1265
- journal.schedulerHookAttached = true;
1266
642
  this.injectUpdateHook(fiber.instance, fiber);
1267
643
  }
1268
644
  return fiber;
1269
645
  }
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
+ }
1270
677
  // ---------------------------------------------------------------------------
1271
678
  // Reconcile
1272
679
  // ---------------------------------------------------------------------------
@@ -1318,27 +725,10 @@ class GraphRuntime {
1318
725
  else {
1319
726
  instance.props = nextVnode.props;
1320
727
  }
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
- }
1337
728
  // Build scope for child nodes (ContextProvider may have updated values)
1338
729
  const childScope = this.buildChildScope(instance, parentScope);
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()) {
730
+ // Call onUpdate if props changed
731
+ if (prevProps !== instance.props && current.engine.canUpdate()) {
1342
732
  try {
1343
733
  instance.onUpdate(prevProps, instance.props);
1344
734
  }
@@ -1352,8 +742,10 @@ class GraphRuntime {
1352
742
  throw error;
1353
743
  }
1354
744
  }
1355
- // Commit ref transition: clear old ref if changed, bind new ref
1356
- this.commitRef(current.vnode.ref, instance, nextVnode.ref, instance);
745
+ // Update ref
746
+ if (nextVnode.ref !== undefined) {
747
+ nextVnode.ref.current = instance;
748
+ }
1357
749
  // Reconcile child nodes (sync fast-path if all children are sync).
1358
750
  let nextChildVnodes;
1359
751
  try {
@@ -1477,60 +869,17 @@ class GraphRuntime {
1477
869
  }
1478
870
  return resultSoFar;
1479
871
  }
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
- }
1509
872
  /**
1510
873
  * Full-diff reconcile: keyed/unkeyed Map + destroy orphans.
1511
874
  * Always async — internal branching is too complex for an efficient sync path.
1512
875
  *
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
- *
1520
876
  * @param {RuntimeFiber<unknown>[]} currentChildren - current child fibers
1521
877
  * @param {VirtualServiceNode[]} nextVnodes - new vnodes
1522
878
  * @param {RuntimeFiber<unknown>} parentFiber - parent fiber
1523
879
  * @param {ContextScope} childScope - children scope
1524
880
  * @returns {Promise<RuntimeFiber<unknown>[]>}
1525
- * @throws {Error} when duplicate keys are detected in current or next children
1526
881
  */
1527
882
  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);
1534
883
  // Check for keyed children before creating a Map (6.06x speedup for unkeyed-only)
1535
884
  let hasKeyedCurrent = false;
1536
885
  for (const child of currentChildren) {
@@ -1542,65 +891,33 @@ class GraphRuntime {
1542
891
  const unkeyedCurrent = [];
1543
892
  const nextChildren = [];
1544
893
  let unkeyedIdx = 0;
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
- }
1560
- }
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
- }
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);
1583
904
  }
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;
1589
- }
905
+ else {
906
+ unkeyedCurrent.push(child);
1590
907
  }
1591
908
  }
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
909
  for (const nextVnode of nextVnodes) {
1603
- if (unkeyedIdx < unkeyedCurrent.length) {
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.`);
915
+ }
916
+ keyedCurrentMap.delete(nextKey);
917
+ const reconciledRes = this.reconcileFiber(currentFiber, nextVnode, parentFiber, childScope);
918
+ nextChildren.push(isThenable(reconciledRes) ? await reconciledRes : reconciledRes);
919
+ }
920
+ else if (nextKey === undefined && unkeyedIdx < unkeyedCurrent.length) {
1604
921
  const currentFiber = unkeyedCurrent[unkeyedIdx];
1605
922
  unkeyedIdx += 1;
1606
923
  const reconciledRes = this.reconcileFiber(currentFiber, nextVnode, parentFiber, childScope);
@@ -1612,48 +929,49 @@ class GraphRuntime {
1612
929
  nextChildren.push(isThenable(newRes) ? await newRes : newRes);
1613
930
  }
1614
931
  }
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) {
932
+ // Destroy remaining unpaired current children (keyed)
933
+ for (const [, orphan] of keyedCurrentMap) {
1620
934
  const d = this.destroyFiber(orphan);
1621
935
  if (isThenable(d)) {
1622
936
  await d;
1623
937
  }
1624
938
  }
1625
939
  }
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;
1637
- }
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
- }
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);
1644
956
  }
1645
- catch (err) {
1646
- rollbackErrors.push(err instanceof Error ? err : new Error(String(err)));
957
+ else {
958
+ // New node PLACE
959
+ const newRes = this.materialize(nextVnode, parentFiber, childScope);
960
+ nextChildren.push(isThenable(newRes) ? await newRes : newRes);
1647
961
  }
1648
962
  }
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;
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;
971
+ }
1654
972
  }
1655
- throw primaryError;
1656
973
  }
974
+ return nextChildren;
1657
975
  }
1658
976
  /**
1659
977
  * Whether children are stable: same count, same type and key per position.
@@ -1713,31 +1031,17 @@ class GraphRuntime {
1713
1031
  * Returns `void` synchronously if the whole subtree is sync (up to 266x speedup
1714
1032
  * on an 85-node tree); otherwise a Promise. `await` works correctly with either union branch.
1715
1033
  *
1716
- * Collects cleanup errors via `collectErrors` parameter (best-effort cleanup).
1717
- *
1718
1034
  * @param {RuntimeFiber} fiber - fiber to destroy
1719
- * @param {Error[] | null} collectErrors - array to collect cleanup errors (null to throw immediately)
1720
1035
  * @returns {void | Promise<void>}
1721
1036
  */
1722
- destroyFiber(fiber, collectErrors = null) {
1037
+ destroyFiber(fiber) {
1723
1038
  const children = fiber.children;
1724
1039
  const n = children.length;
1725
1040
  // Sync recursion over children until the first async
1726
1041
  for (let i = 0; i < n; i++) {
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
- }
1042
+ const childRes = this.destroyFiber(children[i]);
1043
+ if (isThenable(childRes)) {
1044
+ return this.continueDestroyAsync(fiber, children, i, childRes);
1741
1045
  }
1742
1046
  }
1743
1047
  const instance = fiber.instance;
@@ -1748,17 +1052,15 @@ class GraphRuntime {
1748
1052
  this.dirtyFibers.delete(fiber);
1749
1053
  const shutdownRes = fiber.engine.runShutdown(instance);
1750
1054
  if (isThenable(shutdownRes)) {
1751
- return this.finalizeDestroyAsync(fiber, shutdownRes, collectErrors);
1055
+ return this.finalizeDestroyAsync(fiber, shutdownRes);
1752
1056
  }
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
- }
1057
+ this.disposeEffectableRuntimeBusWiring(fiber);
1058
+ // Clear ref after unmount
1059
+ const ref = fiber.vnode.ref;
1060
+ if (ref !== undefined) {
1061
+ ref.current = null;
1758
1062
  }
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);
1063
+ fiber.lifecycleStatus = fiber.engine.getStatus();
1762
1064
  }
1763
1065
  /**
1764
1066
  * Async continuation of {@link destroyFiber} after one of the children returned a Promise.
@@ -1767,37 +1069,14 @@ class GraphRuntime {
1767
1069
  * @param {Fiber[]} children - children list
1768
1070
  * @param {number} pendingIdx - index of the pending child
1769
1071
  * @param {PromiseLike<void>} pending - Promise from destroying the child
1770
- * @param {Error[] | null} collectErrors - array to collect cleanup errors
1771
1072
  * @returns {Promise<void>}
1772
1073
  */
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
1074
+ async continueDestroyAsync(fiber, children, pendingIdx, pending) {
1075
+ await pending;
1787
1076
  for (let i = pendingIdx + 1; i < children.length; i++) {
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
- }
1077
+ const r = this.destroyFiber(children[i]);
1078
+ if (isThenable(r)) {
1079
+ await r;
1801
1080
  }
1802
1081
  }
1803
1082
  const instance = fiber.instance;
@@ -1808,21 +1087,14 @@ class GraphRuntime {
1808
1087
  this.dirtyFibers.delete(fiber);
1809
1088
  const shutdownRes = fiber.engine.runShutdown(instance);
1810
1089
  if (isThenable(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
- }
1090
+ await shutdownRes;
1817
1091
  }
1818
- else if (!shutdownRes.ok) {
1819
- if (collectErrors !== null) {
1820
- collectErrors.push(shutdownRes.error instanceof Error ? shutdownRes.error : new Error(String(shutdownRes.error)));
1821
- }
1092
+ this.disposeEffectableRuntimeBusWiring(fiber);
1093
+ const ref = fiber.vnode.ref;
1094
+ if (ref !== undefined) {
1095
+ ref.current = null;
1822
1096
  }
1823
- // Always finalize even if shutdown failed
1824
- // finalizeFiberDestroy uses commitRef for identity-safe ref clearing
1825
- this.finalizeFiberDestroy(fiber, collectErrors);
1097
+ fiber.lifecycleStatus = fiber.engine.getStatus();
1826
1098
  }
1827
1099
  /**
1828
1100
  * Async finalization of {@link destroyFiber} when children were destroyed synchronously
@@ -1830,21 +1102,16 @@ class GraphRuntime {
1830
1102
  *
1831
1103
  * @param {RuntimeFiber<unknown>} fiber
1832
1104
  * @param {PromiseLike<unknown>} pendingShutdown
1833
- * @param {Error[] | null} collectErrors - array to collect cleanup errors
1834
1105
  * @returns {Promise<void>}
1835
1106
  */
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
- }
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;
1844
1113
  }
1845
- // Always finalize even if shutdown failed
1846
- // finalizeFiberDestroy uses commitRef for identity-safe ref clearing
1847
- this.finalizeFiberDestroy(fiber, collectErrors);
1114
+ fiber.lifecycleStatus = fiber.engine.getStatus();
1848
1115
  }
1849
1116
  // ---------------------------------------------------------------------------
1850
1117
  // Helpers