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