effectable 1.2.0-canary.4 → 1.2.0-canary.6

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.
@@ -37,6 +37,7 @@ exports.makeFiberEffectTag = makeFiberEffectTag;
37
37
  const types_1 = require("./types");
38
38
  const lifecycle_1 = require("./lifecycle");
39
39
  const context_1 = require("./context");
40
+ const refs_1 = require("./refs");
40
41
  const BusDecorators_1 = require("../runtime/BusDecorators");
41
42
  const graphRuntime_constants_1 = require("./graphRuntime.constants");
42
43
  /**
@@ -170,6 +171,12 @@ class GraphRuntime {
170
171
  * unmount() must await this before concluding teardown is finished.
171
172
  */
172
173
  pendingTeardown = null;
174
+ /**
175
+ * Imperative ref values bound for owners that expose `@UseImperativeHandle` methods.
176
+ * `commitRef` assigns a limited handle (not the Component instance) into `ref.current`;
177
+ * identity-safe clear must still recognize that handle as owned by the instance.
178
+ */
179
+ imperativeRefByOwner = new WeakMap();
173
180
  /**
174
181
  * Instances are created only via {@link GraphRuntime.mount}; direct `new GraphRuntime()` is unavailable externally.
175
182
  */
@@ -257,18 +264,47 @@ class GraphRuntime {
257
264
  delete fiber.effectableRuntimeBusDisposer;
258
265
  }
259
266
  /**
260
- * Identity-safe ref clearing: clears ref.current only if it still points to the expected owner.
267
+ * Builds the value assigned to `ref.current` for a mounted instance.
268
+ *
269
+ * When the constructor declares `@UseImperativeHandle` methods, only those methods
270
+ * are exposed (bound to the instance) — matching the refs.ts / CONCEPT contract.
271
+ * Without an allowlist, the full instance is assigned (legacy escape hatch).
272
+ *
273
+ * @param {Component<unknown, unknown>} instance - mounted component instance
274
+ * @returns {unknown} instance or limited imperative handle object
275
+ */
276
+ resolveRefCurrentValue(instance) {
277
+ const methods = (0, refs_1.getImperativeHandleMethods)(instance.constructor);
278
+ if (methods.length === 0) {
279
+ this.imperativeRefByOwner.delete(instance);
280
+ return instance;
281
+ }
282
+ const handle = Object.create(null);
283
+ for (const { methodKey } of methods) {
284
+ const fn = instance[methodKey];
285
+ if (typeof fn !== 'function') {
286
+ throw new Error(`[Effectable.GraphRuntime] @UseImperativeHandle method is not a function: ${String(methodKey)}`);
287
+ }
288
+ handle[methodKey] = fn.bind(instance);
289
+ }
290
+ this.imperativeRefByOwner.set(instance, handle);
291
+ return handle;
292
+ }
293
+ /**
294
+ * Identity-safe ref clearing: clears ref.current only if it still points to the expected owner
295
+ * (full instance) or the limited imperative handle previously bound for that owner.
261
296
  * Prevents an old rollback from clearing a ref that a newer materialization already reused.
262
- * No cast required (Component | null → unknown | null is assignable).
263
297
  *
264
298
  * @param {RefObject<unknown>} ref - ref object
265
299
  * @param {Component<unknown, unknown>} expectedOwner - expected current owner
266
300
  * @returns {void}
267
301
  */
268
302
  clearRefSafe(ref, expectedOwner) {
269
- if (ref.current === expectedOwner) {
303
+ const boundHandle = this.imperativeRefByOwner.get(expectedOwner);
304
+ if (ref.current === expectedOwner || (boundHandle !== undefined && ref.current === boundHandle)) {
270
305
  ref.current = null;
271
306
  }
307
+ this.imperativeRefByOwner.delete(expectedOwner);
272
308
  }
273
309
  /**
274
310
  * Centralized ref ownership transition.
@@ -276,12 +312,10 @@ class GraphRuntime {
276
312
  *
277
313
  * Rules:
278
314
  * - Clear previousRef only if it still points to expectedPreviousOwner (identity-safe).
279
- * - Bind nextRef to instance if nextRef is provided.
315
+ * - Bind nextRef to the instance, or to a limited `@UseImperativeHandle` surface when declared.
280
316
  * - previousRef and nextRef can be the same object (ref reuse) or different (ref swap).
281
317
  * - Do not let an old disposer clear a newer owner.
282
318
  *
283
- * No casts: Component | null → unknown | null is assignable (widening).
284
- *
285
319
  * @param {RefObject<unknown> | undefined} previousRef - ref to clear (can be undefined if no previous ref)
286
320
  * @param {Component<unknown, unknown> | null} expectedPreviousOwner - expected owner of previousRef (null if unknown)
287
321
  * @param {RefObject<unknown> | undefined} nextRef - ref to bind to instance (can be undefined if removing ref)
@@ -293,9 +327,27 @@ class GraphRuntime {
293
327
  if (previousRef !== undefined && previousRef !== nextRef && expectedPreviousOwner !== null) {
294
328
  this.clearRefSafe(previousRef, expectedPreviousOwner);
295
329
  }
296
- // Bind next ref to instance (Component | null → unknown | null, no cast)
330
+ // Bind next ref: limited handle when @UseImperativeHandle is present, else full instance.
331
+ // Custom setters may assign `current` then throw. Without a catch, UPDATE ref-swap
332
+ // leaves the new ref holding the instance/handle while `fiber.vnode.ref` still points at
333
+ // the previous ref object — fail-stop finalize clears only the old ref (zombie nextRef).
334
+ // Materialize assign-then-throw is covered by journal.refBound (#96/#98); this path covers
335
+ // the UPDATE swap hole and is a safe no-op when the setter never assigned.
297
336
  if (nextRef !== undefined) {
298
- nextRef.current = instance;
337
+ try {
338
+ nextRef.current = instance === null ? null : this.resolveRefCurrentValue(instance);
339
+ }
340
+ catch (error) {
341
+ if (instance !== null) {
342
+ try {
343
+ this.clearRefSafe(nextRef, instance);
344
+ }
345
+ catch {
346
+ // Best-effort: do not mask the original setter error.
347
+ }
348
+ }
349
+ throw error;
350
+ }
299
351
  }
300
352
  }
301
353
  /**
@@ -345,7 +397,7 @@ class GraphRuntime {
345
397
  * Transactional rollback for failed fiber materialization.
346
398
  * Releases acquired resources in reverse acquisition order:
347
399
  * 1. disable scheduler hook
348
- * 2. destroy mounted children in reverse order
400
+ * 2. destroy mounted children in compose order (same as {@link destroyFiber})
349
401
  * 3. run failed-startup cleanup (parent onUnmount when startup ran)
350
402
  * 4. dispose runtime bus registrations
351
403
  * 5. clear bound ref (identity-safe)
@@ -382,16 +434,18 @@ class GraphRuntime {
382
434
  cleanupErrors.push(err instanceof Error ? err : new Error(String(err)));
383
435
  }
384
436
  }
385
- // 2. Destroy mounted children in reverse order BEFORE parent onUnmount / bus / ref.
386
- // Documented teardown contract is children → parent; onUnmount was already after
387
- // children, but bus dispose + ref clear still ran ahead of child destroy — child
388
- // onUnmount could observe a nulled parent ref / dead parent bus subscriptions.
437
+ // 2. Destroy mounted children in compose order BEFORE parent onUnmount / bus / ref.
438
+ // Documented teardown contract is children → parent with siblings in compose
439
+ // order (matches destroyFiber / README shutdown). Reverse sibling order made
440
+ // failure-path onUnmount observe a different live-sibling set than clean unmount.
441
+ // Children must also run before bus dispose + ref clear so child onUnmount still
442
+ // sees a live parent ref / parent @On* subscriptions.
389
443
  // Pass cleanupErrors so nested destroy is best-effort: a throwing
390
444
  // ref-clear/disposer on one grandchild must not skip remaining siblings.
391
445
  // Those nodes were never attached to currentRoot, so failStop cannot reclaim them.
392
446
  const destroyChildrenThenParentCleanup = () => {
393
447
  const children = journal.mountedChildren;
394
- for (let i = children.length - 1; i >= 0; i -= 1) {
448
+ for (let i = 0; i < children.length; i += 1) {
395
449
  try {
396
450
  const destroyRes = this.destroyFiber(children[i], cleanupErrors);
397
451
  if (isThenable(destroyRes)) {
@@ -410,7 +464,7 @@ class GraphRuntime {
410
464
  * Async continuation of rollback child destruction after one child's destroy returned a Promise.
411
465
  *
412
466
  * @param {RuntimeFiber<unknown>[]} children - mounted children
413
- * @param {number} lastIdx - index of the last processed child
467
+ * @param {number} pendingIdx - index of the child whose destroy is pending
414
468
  * @param {Promise<void>} pending - Promise from destroying the previous child
415
469
  * @param {Error} primaryError - original materialization error
416
470
  * @param {Error[]} cleanupErrors - accumulated cleanup errors
@@ -418,14 +472,15 @@ class GraphRuntime {
418
472
  * @param {Component<unknown, unknown> | null} instance - parent instance (for post-child cleanup)
419
473
  * @returns {Promise<void>}
420
474
  */
421
- async continueRollbackDestroyAsync(children, lastIdx, pending, primaryError, cleanupErrors, fiber, instance) {
475
+ async continueRollbackDestroyAsync(children, pendingIdx, pending, primaryError, cleanupErrors, fiber, instance) {
422
476
  try {
423
477
  await pending;
424
478
  }
425
479
  catch (err) {
426
480
  cleanupErrors.push(err instanceof Error ? err : new Error(String(err)));
427
481
  }
428
- for (let i = lastIdx - 1; i >= 0; i -= 1) {
482
+ // Remaining siblings in compose order (pendingIdx+1 … n-1), matching destroyFiber.
483
+ for (let i = pendingIdx + 1; i < children.length; i += 1) {
429
484
  try {
430
485
  const destroyRes = this.destroyFiber(children[i], cleanupErrors);
431
486
  if (isThenable(destroyRes)) {
@@ -1138,6 +1193,221 @@ class GraphRuntime {
1138
1193
  // ---------------------------------------------------------------------------
1139
1194
  // Materialize
1140
1195
  // ---------------------------------------------------------------------------
1196
+ /**
1197
+ * Runs deferred {@link LifecycleEngine.runStartup} for a fiber subtree in
1198
+ * children→parent order (siblings in compose order).
1199
+ *
1200
+ * Call only after the subtree (and any later siblings that must observe
1201
+ * publishes) have completed bus wiring. Idempotent for fibers that already
1202
+ * started.
1203
+ *
1204
+ * @param {RuntimeFiber<unknown>} fiber - subtree root
1205
+ * @returns {void | Promise<void>}
1206
+ */
1207
+ flushDeferredLifecycleTree(fiber) {
1208
+ const children = fiber.children;
1209
+ for (let i = 0; i < children.length; i++) {
1210
+ const child = children[i];
1211
+ const childFlush = this.flushDeferredLifecycleTree(child);
1212
+ if (isThenable(childFlush)) {
1213
+ return this.continueFlushDeferredLifecycleAsync(fiber, children, i, childFlush);
1214
+ }
1215
+ }
1216
+ return this.runDeferredLifecycleIfNeeded(fiber);
1217
+ }
1218
+ /**
1219
+ * Async continuation of {@link flushDeferredLifecycleTree} after a child flush returns a Promise.
1220
+ *
1221
+ * @param {RuntimeFiber<unknown>} fiber - subtree root
1222
+ * @param {RuntimeFiber<unknown>[]} children - child fibers
1223
+ * @param {number} pendingIdx - index of the pending child
1224
+ * @param {PromiseLike<void>} pending - pending child flush
1225
+ * @returns {Promise<void>}
1226
+ */
1227
+ async continueFlushDeferredLifecycleAsync(fiber, children, pendingIdx, pending) {
1228
+ await pending;
1229
+ for (let i = pendingIdx + 1; i < children.length; i++) {
1230
+ const childFlush = this.flushDeferredLifecycleTree(children[i]);
1231
+ if (isThenable(childFlush)) {
1232
+ await childFlush;
1233
+ }
1234
+ }
1235
+ const selfRes = this.runDeferredLifecycleIfNeeded(fiber);
1236
+ if (isThenable(selfRes)) {
1237
+ await selfRes;
1238
+ }
1239
+ }
1240
+ /**
1241
+ * Runs startup + update-hook injection when {@link FiberConstructionJournal.lifecycleDeferred}
1242
+ * is set; no-op otherwise.
1243
+ *
1244
+ * @param {RuntimeFiber<unknown>} fiber - fiber to start
1245
+ * @returns {void | Promise<void>}
1246
+ */
1247
+ runDeferredLifecycleIfNeeded(fiber) {
1248
+ const journal = fiber.constructionJournal;
1249
+ if (journal === undefined || journal.lifecycleDeferred !== true) {
1250
+ return;
1251
+ }
1252
+ const instance = fiber.instance;
1253
+ if (instance === null) {
1254
+ journal.lifecycleDeferred = false;
1255
+ return;
1256
+ }
1257
+ const startupRes = fiber.engine.runStartup(instance, { deferFailedCleanup: true });
1258
+ if (isThenable(startupRes)) {
1259
+ return this.finalizeDeferredLifecycleAsync(fiber, startupRes);
1260
+ }
1261
+ if (!startupRes.ok) {
1262
+ const error = startupRes.error instanceof Error
1263
+ ? startupRes.error
1264
+ : new Error(String(startupRes.error));
1265
+ const rollbackRes = this.rollbackFailedMaterialization(fiber, error);
1266
+ if (isThenable(rollbackRes)) {
1267
+ return rollbackRes.then(() => {
1268
+ throw error;
1269
+ });
1270
+ }
1271
+ throw error;
1272
+ }
1273
+ journal.lifecycleDeferred = false;
1274
+ fiber.lifecycleStatus = fiber.engine.getStatus();
1275
+ fiber.effectTag = null;
1276
+ journal.schedulerHookAttached = true;
1277
+ this.injectUpdateHook(instance, fiber);
1278
+ }
1279
+ /**
1280
+ * Async path for {@link runDeferredLifecycleIfNeeded} when startup returns a Promise.
1281
+ *
1282
+ * @param {RuntimeFiber<unknown>} fiber - fiber being started
1283
+ * @param {PromiseLike<import('./lifecycle').LifecycleTransitionResult>} pendingStartup - pending startup
1284
+ * @returns {Promise<void>}
1285
+ */
1286
+ async finalizeDeferredLifecycleAsync(fiber, pendingStartup) {
1287
+ const result = await pendingStartup;
1288
+ const journal = fiber.constructionJournal;
1289
+ const instance = fiber.instance;
1290
+ if (!result.ok) {
1291
+ const error = result.error instanceof Error ? result.error : new Error(String(result.error));
1292
+ const rollbackRes = this.rollbackFailedMaterialization(fiber, error);
1293
+ if (isThenable(rollbackRes)) {
1294
+ await rollbackRes;
1295
+ }
1296
+ throw error;
1297
+ }
1298
+ journal.lifecycleDeferred = false;
1299
+ fiber.lifecycleStatus = fiber.engine.getStatus();
1300
+ fiber.effectTag = null;
1301
+ if (instance !== null) {
1302
+ journal.schedulerHookAttached = true;
1303
+ this.injectUpdateHook(instance, fiber);
1304
+ }
1305
+ }
1306
+ /**
1307
+ * After bus wiring: either defer lifecycle (sibling/parent batching) or flush
1308
+ * deferred children and run this fiber's startup.
1309
+ *
1310
+ * @template P node props type
1311
+ * @param {RuntimeFiber<P>} fiber - fiber that just finished wiring
1312
+ * @param {Component<unknown, P>} instance - component instance
1313
+ * @param {LifecycleEngine} engine - lifecycle engine
1314
+ * @param {boolean} deferLifecycle - when true, skip startup until a parent flush
1315
+ * @returns {RuntimeFiber<P> | Promise<RuntimeFiber<P>>}
1316
+ */
1317
+ completeMaterializeAfterWiring(fiber, instance, engine, deferLifecycle) {
1318
+ const journal = fiber.constructionJournal;
1319
+ if (deferLifecycle) {
1320
+ journal.lifecycleDeferred = true;
1321
+ return fiber;
1322
+ }
1323
+ // Children were materialized with deferLifecycle=true — start them (and their
1324
+ // subtrees) before this node's onMount so sibling/parent @On* handlers exist.
1325
+ // If a deferred sibling fails during flush after an earlier sibling started,
1326
+ // roll back this parent (destroys all mounted children) — same contract as
1327
+ // the pre-defer depth-first materialize catch around each child.
1328
+ try {
1329
+ const children = fiber.children;
1330
+ for (let i = 0; i < children.length; i++) {
1331
+ const childFlush = this.flushDeferredLifecycleTree(children[i]);
1332
+ if (isThenable(childFlush)) {
1333
+ return this.continueCompleteMaterializeAfterWiringAsync(fiber, instance, engine, children, i, childFlush);
1334
+ }
1335
+ }
1336
+ return this.runMaterializeStartup(fiber, instance, engine);
1337
+ }
1338
+ catch (err) {
1339
+ const error = err instanceof Error ? err : new Error(String(err));
1340
+ const rollbackRes = this.rollbackFailedMaterialization(fiber, error);
1341
+ if (isThenable(rollbackRes)) {
1342
+ return rollbackRes.then(() => {
1343
+ throw error;
1344
+ });
1345
+ }
1346
+ throw error;
1347
+ }
1348
+ }
1349
+ /**
1350
+ * Async continuation after a deferred child flush during materialize completion.
1351
+ *
1352
+ * @template P node props type
1353
+ * @param {RuntimeFiber<P>} fiber - parent fiber
1354
+ * @param {Component<unknown, P>} instance - parent instance
1355
+ * @param {LifecycleEngine} engine - parent engine
1356
+ * @param {RuntimeFiber<unknown>[]} children - child fibers
1357
+ * @param {number} pendingIdx - pending child index
1358
+ * @param {PromiseLike<void>} pending - pending flush
1359
+ * @returns {Promise<RuntimeFiber<P>>}
1360
+ */
1361
+ async continueCompleteMaterializeAfterWiringAsync(fiber, instance, engine, children, pendingIdx, pending) {
1362
+ try {
1363
+ await pending;
1364
+ for (let i = pendingIdx + 1; i < children.length; i++) {
1365
+ const childFlush = this.flushDeferredLifecycleTree(children[i]);
1366
+ if (isThenable(childFlush)) {
1367
+ await childFlush;
1368
+ }
1369
+ }
1370
+ return this.runMaterializeStartup(fiber, instance, engine);
1371
+ }
1372
+ catch (err) {
1373
+ const error = err instanceof Error ? err : new Error(String(err));
1374
+ const rollbackRes = this.rollbackFailedMaterialization(fiber, error);
1375
+ if (isThenable(rollbackRes)) {
1376
+ await rollbackRes;
1377
+ }
1378
+ throw error;
1379
+ }
1380
+ }
1381
+ /**
1382
+ * Runs startup for a fiber that is completing materialize (not deferred).
1383
+ *
1384
+ * @template P node props type
1385
+ * @param {RuntimeFiber<P>} fiber - fiber
1386
+ * @param {Component<unknown, P>} instance - instance
1387
+ * @param {LifecycleEngine} engine - engine
1388
+ * @returns {RuntimeFiber<P> | Promise<RuntimeFiber<P>>}
1389
+ */
1390
+ runMaterializeStartup(fiber, instance, engine) {
1391
+ const startupRes = engine.runStartup(instance, { deferFailedCleanup: true });
1392
+ if (isThenable(startupRes)) {
1393
+ return this.finalizeMaterializeAsync(fiber, engine, startupRes);
1394
+ }
1395
+ if (!startupRes.ok) {
1396
+ const error = startupRes.error instanceof Error ? startupRes.error : new Error(String(startupRes.error));
1397
+ const rollbackRes = this.rollbackFailedMaterialization(fiber, error);
1398
+ if (isThenable(rollbackRes)) {
1399
+ return rollbackRes.then(() => {
1400
+ throw error;
1401
+ });
1402
+ }
1403
+ throw error;
1404
+ }
1405
+ fiber.lifecycleStatus = engine.getStatus();
1406
+ fiber.effectTag = null;
1407
+ fiber.constructionJournal.schedulerHookAttached = true;
1408
+ this.injectUpdateHook(instance, fiber);
1409
+ return fiber;
1410
+ }
1141
1411
  /**
1142
1412
  * Creates a RuntimeFiber for a virtual node: instantiates the component,
1143
1413
  * injects contexts, builds the child scope (for ContextProvider),
@@ -1150,9 +1420,11 @@ class GraphRuntime {
1150
1420
  * @param {VirtualServiceNode<P>} vnode - virtual node
1151
1421
  * @param {RuntimeFiber | null} parentFiber - parent fiber
1152
1422
  * @param {ContextScope} parentScope - parent scope
1423
+ * @param {boolean} [deferLifecycle=false] - when true, wire buses but defer onMount
1424
+ * until {@link flushDeferredLifecycleTree} (used for sibling batching)
1153
1425
  * @returns {RuntimeFiber<P> | Promise<RuntimeFiber<P>>}
1154
1426
  */
1155
- materialize(vnode, parentFiber, parentScope) {
1427
+ materialize(vnode, parentFiber, parentScope, deferLifecycle = false) {
1156
1428
  const engine = new lifecycle_1.LifecycleEngine();
1157
1429
  // Constructor is stored as ComponentConstructor<unknown> for covariance,
1158
1430
  // but invoked with concrete props P. The cast is safe: P extends unknown.
@@ -1209,11 +1481,32 @@ class GraphRuntime {
1209
1481
  }
1210
1482
  throw error;
1211
1483
  }
1484
+ // Wire parent buses BEFORE children materialize/onMount. Children run onMount while
1485
+ // descending; if the parent is not subscribed yet, child publishes to @OnEvent /
1486
+ // @OnCommand on the parent are silently dropped (mirror of #80 teardown order).
1487
+ try {
1488
+ this.attachEffectableRuntimeBusWiring(instance, fiber);
1489
+ if (fiber.effectableRuntimeBusDisposer !== undefined) {
1490
+ fiber.constructionJournal.busWiringAttached = true;
1491
+ }
1492
+ }
1493
+ catch (err) {
1494
+ const rollbackRes = this.rollbackFailedMaterialization(fiber, err instanceof Error ? err : new Error(String(err)));
1495
+ if (isThenable(rollbackRes)) {
1496
+ return rollbackRes.then(() => {
1497
+ throw err;
1498
+ });
1499
+ }
1500
+ throw err;
1501
+ }
1212
1502
  for (let i = 0; i < childVnodes.length; i++) {
1213
1503
  const childVnode = childVnodes[i];
1214
1504
  let childRes;
1215
1505
  try {
1216
- childRes = this.materialize(childVnode, fiber, childScope);
1506
+ // Defer child lifecycle so all siblings (and this parent) finish bus wiring
1507
+ // before any onMount runs — otherwise earlier-sibling publishes miss later
1508
+ // siblings' @On* handlers (and parent handlers miss child onMount publishes).
1509
+ childRes = this.materialize(childVnode, fiber, childScope, true);
1217
1510
  }
1218
1511
  catch (err) {
1219
1512
  const error = err instanceof Error ? err : new Error(String(err));
@@ -1227,17 +1520,21 @@ class GraphRuntime {
1227
1520
  }
1228
1521
  if (isThenable(childRes)) {
1229
1522
  // Hit an async child — continue the materialization tail in the async continuation.
1230
- return this.continueMaterializeAsync(fiber, instance, engine, vnode, childVnodes, childScope, childRes, i);
1523
+ return this.continueMaterializeAsync(fiber, instance, engine, vnode, childVnodes, childScope, childRes, i, deferLifecycle);
1231
1524
  }
1232
1525
  fiber.constructionJournal.mountedChildren.push(childRes);
1233
1526
  }
1234
1527
  fiber.children = fiber.constructionJournal.mountedChildren;
1235
- // Bind ref to the instance (centralized via commitRef)
1528
+ // Bind ref to the instance (centralized via commitRef).
1529
+ // Mark refBound/refOwner BEFORE commitRef: a setter may assign `current`
1530
+ // then throw. If the flags were set only after a successful return,
1531
+ // rollback would skip the identity-safe clear and leave a zombie ref
1532
+ // (root was never published, so failStop cannot reclaim it).
1236
1533
  if (vnode.ref !== undefined) {
1237
1534
  try {
1238
- this.commitRef(undefined, null, vnode.ref, instance);
1239
1535
  fiber.constructionJournal.refBound = true;
1240
1536
  fiber.constructionJournal.refOwner = instance;
1537
+ this.commitRef(undefined, null, vnode.ref, instance);
1241
1538
  }
1242
1539
  catch (err) {
1243
1540
  const error = err instanceof Error ? err : new Error(String(err));
@@ -1250,47 +1547,12 @@ class GraphRuntime {
1250
1547
  throw error;
1251
1548
  }
1252
1549
  }
1253
- try {
1254
- this.attachEffectableRuntimeBusWiring(instance, fiber);
1255
- if (fiber.effectableRuntimeBusDisposer !== undefined) {
1256
- fiber.constructionJournal.busWiringAttached = true;
1257
- }
1258
- }
1259
- catch (err) {
1260
- const rollbackRes = this.rollbackFailedMaterialization(fiber, err instanceof Error ? err : new Error(String(err)));
1261
- if (isThenable(rollbackRes)) {
1262
- return rollbackRes.then(() => {
1263
- throw err;
1264
- });
1265
- }
1266
- throw err;
1267
- }
1268
- // Run lifecycle after all children are materialized.
1269
- // Pre-mount hook was injected before children (covers ancestor setState
1270
- // from descendant onMount and setState during this node's onMount).
1271
- const startupRes = engine.runStartup(instance, { deferFailedCleanup: true });
1272
- if (isThenable(startupRes)) {
1273
- return this.finalizeMaterializeAsync(fiber, engine, startupRes);
1274
- }
1275
- if (!startupRes.ok) {
1276
- const error = startupRes.error instanceof Error ? startupRes.error : new Error(String(startupRes.error));
1277
- const rollbackRes = this.rollbackFailedMaterialization(fiber, error);
1278
- if (isThenable(rollbackRes)) {
1279
- return rollbackRes.then(() => {
1280
- throw error;
1281
- });
1282
- }
1283
- throw error;
1284
- }
1285
- fiber.lifecycleStatus = engine.getStatus();
1286
- fiber.effectTag = null;
1287
- fiber.constructionJournal.schedulerHookAttached = true;
1288
- this.injectUpdateHook(instance, fiber);
1289
- return fiber;
1550
+ // Parent buses already wired before children (see above). Do not wire again here.
1551
+ return this.completeMaterializeAfterWiring(fiber, instance, engine, deferLifecycle);
1290
1552
  }
1291
1553
  /**
1292
1554
  * Async continuation of {@link materialize} after a child returned a Promise.
1293
- * Finishes remaining child fibers with `await`, then runs parent startup.
1555
+ * Finishes remaining child fibers with `await`, then completes wiring / lifecycle.
1294
1556
  *
1295
1557
  * @param {RuntimeFiber<P>} fiber - current fiber
1296
1558
  * @param {Component<unknown, P>} instance - component instance
@@ -1300,9 +1562,10 @@ class GraphRuntime {
1300
1562
  * @param {ContextScope} childScope - scope for child nodes
1301
1563
  * @param {Promise<RuntimeFiber<unknown>>} pending - Promise for the current child
1302
1564
  * @param {number} pendingIdx - index of the current child
1565
+ * @param {boolean} deferLifecycle - parent defer flag from {@link materialize}
1303
1566
  * @returns {Promise<RuntimeFiber<P>>}
1304
1567
  */
1305
- async continueMaterializeAsync(fiber, instance, engine, vnode, childVnodes, childScope, pending, pendingIdx) {
1568
+ async continueMaterializeAsync(fiber, instance, engine, vnode, childVnodes, childScope, pending, pendingIdx, deferLifecycle) {
1306
1569
  const journal = fiber.constructionJournal;
1307
1570
  try {
1308
1571
  journal.mountedChildren.push(await pending);
@@ -1318,7 +1581,7 @@ class GraphRuntime {
1318
1581
  for (let i = pendingIdx + 1; i < childVnodes.length; i++) {
1319
1582
  const childVnode = childVnodes[i];
1320
1583
  try {
1321
- const childRes = this.materialize(childVnode, fiber, childScope);
1584
+ const childRes = this.materialize(childVnode, fiber, childScope, true);
1322
1585
  const resolvedChild = isThenable(childRes) ? await childRes : childRes;
1323
1586
  journal.mountedChildren.push(resolvedChild);
1324
1587
  }
@@ -1332,11 +1595,12 @@ class GraphRuntime {
1332
1595
  }
1333
1596
  }
1334
1597
  fiber.children = journal.mountedChildren;
1598
+ // Mark refBound/refOwner BEFORE commitRef (see sync materialize path).
1335
1599
  if (vnode.ref !== undefined) {
1336
1600
  try {
1337
- this.commitRef(undefined, null, vnode.ref, instance);
1338
1601
  journal.refBound = true;
1339
1602
  journal.refOwner = instance;
1603
+ this.commitRef(undefined, null, vnode.ref, instance);
1340
1604
  }
1341
1605
  catch (err) {
1342
1606
  const error = err instanceof Error ? err : new Error(String(err));
@@ -1347,35 +1611,9 @@ class GraphRuntime {
1347
1611
  throw error;
1348
1612
  }
1349
1613
  }
1350
- try {
1351
- this.attachEffectableRuntimeBusWiring(instance, fiber);
1352
- if (fiber.effectableRuntimeBusDisposer !== undefined) {
1353
- journal.busWiringAttached = true;
1354
- }
1355
- }
1356
- catch (err) {
1357
- const rollbackRes = this.rollbackFailedMaterialization(fiber, err instanceof Error ? err : new Error(String(err)));
1358
- if (isThenable(rollbackRes)) {
1359
- await rollbackRes;
1360
- }
1361
- throw err;
1362
- }
1363
- // Pre-mount hook already injected at the start of materialize (before children).
1364
- const startupRes = engine.runStartup(instance, { deferFailedCleanup: true });
1365
- const resolved = isThenable(startupRes) ? await startupRes : startupRes;
1366
- if (!resolved.ok) {
1367
- const error = resolved.error instanceof Error ? resolved.error : new Error(String(resolved.error));
1368
- const rollbackRes = this.rollbackFailedMaterialization(fiber, error);
1369
- if (isThenable(rollbackRes)) {
1370
- await rollbackRes;
1371
- }
1372
- throw error;
1373
- }
1374
- fiber.lifecycleStatus = engine.getStatus();
1375
- fiber.effectTag = null;
1376
- journal.schedulerHookAttached = true;
1377
- this.injectUpdateHook(instance, fiber);
1378
- return fiber;
1614
+ // Parent buses were wired before the child loop in materialize(); async continuation
1615
+ // must not register handlers a second time.
1616
+ return this.completeMaterializeAfterWiring(fiber, instance, engine, deferLifecycle);
1379
1617
  }
1380
1618
  /**
1381
1619
  * Async finalization of {@link materialize} when child fibers were gathered synchronously but {@link LifecycleEngine.runStartup} returned a Promise.
@@ -1417,9 +1655,12 @@ class GraphRuntime {
1417
1655
  * @param {VirtualServiceNode<P>} nextVnode - new virtual node
1418
1656
  * @param {RuntimeFiber | null} parentFiber - parent fiber
1419
1657
  * @param {ContextScope} parentScope - parent scope
1658
+ * @param {boolean} [deferLifecycle=false] - when true, REPLACE materialize wires buses but
1659
+ * defers onMount until the sibling batch flush in {@link reconcileChildren} (same contract
1660
+ * as PLACE). Root reconcile must leave this false — nothing else flushes the root.
1420
1661
  * @returns {Promise<RuntimeFiber<P>>}
1421
1662
  */
1422
- reconcileFiber(current, nextVnode, parentFiber, parentScope) {
1663
+ reconcileFiber(current, nextVnode, parentFiber, parentScope, deferLifecycle = false) {
1423
1664
  const sameType = current.vnode.type === nextVnode.type;
1424
1665
  const sameKey = (current.vnode.key ?? null) === (nextVnode.key ?? null);
1425
1666
  if (sameType && sameKey) {
@@ -1429,11 +1670,15 @@ class GraphRuntime {
1429
1670
  // Sync fast-path if both destroy and materialize completed synchronously.
1430
1671
  // Collect cleanup errors (ref clear / disposer) so a throwing finalize cannot
1431
1672
  // abort REPLACE and fail-stop the surviving tree — same best-effort contract as unmount.
1673
+ //
1674
+ // When deferLifecycle is set (child reconcile batch), REPLACE must not run onMount
1675
+ // before later sibling buses are wired — otherwise a replaced publisher's mount-time
1676
+ // publish is silently dropped by a not-yet-wired PLACE/REPLACE listener beside it.
1432
1677
  const destroyRes = this.destroyFiber(current, []);
1433
1678
  if (isThenable(destroyRes)) {
1434
- return destroyRes.then(() => this.materialize(nextVnode, parentFiber, parentScope));
1679
+ return destroyRes.then(() => this.materialize(nextVnode, parentFiber, parentScope, deferLifecycle));
1435
1680
  }
1436
- return this.materialize(nextVnode, parentFiber, parentScope);
1681
+ return this.materialize(nextVnode, parentFiber, parentScope, deferLifecycle);
1437
1682
  }
1438
1683
  /**
1439
1684
  * Updates an existing fiber: applies new props to the instance,
@@ -1591,12 +1836,13 @@ class GraphRuntime {
1591
1836
  fiber.lifecycleStatus = fiber.engine.getStatus();
1592
1837
  attachCleanupErrors();
1593
1838
  };
1594
- for (let i = children.length - 1; i >= 0; i -= 1) {
1839
+ // Siblings in compose order — same contract as destroyFiber / clean unmount.
1840
+ for (let i = 0; i < children.length; i += 1) {
1595
1841
  try {
1596
1842
  const destroyRes = this.destroyFiber(children[i], cleanupErrors);
1597
1843
  if (isThenable(destroyRes)) {
1598
1844
  return destroyRes.then(async () => {
1599
- for (let j = i - 1; j >= 0; j -= 1) {
1845
+ for (let j = i + 1; j < children.length; j += 1) {
1600
1846
  try {
1601
1847
  const r = this.destroyFiber(children[j], cleanupErrors);
1602
1848
  if (isThenable(r)) {
@@ -1610,7 +1856,7 @@ class GraphRuntime {
1610
1856
  return finishParent();
1611
1857
  }, async (err) => {
1612
1858
  cleanupErrors.push(err instanceof Error ? err : new Error(String(err)));
1613
- for (let j = i - 1; j >= 0; j -= 1) {
1859
+ for (let j = i + 1; j < children.length; j += 1) {
1614
1860
  try {
1615
1861
  const r = this.destroyFiber(children[j], cleanupErrors);
1616
1862
  if (isThenable(r)) {
@@ -1753,7 +1999,7 @@ class GraphRuntime {
1753
1999
  const n = nextVnodes.length;
1754
2000
  const stableResult = [];
1755
2001
  for (let i = 0; i < n; i++) {
1756
- const reconciled = this.reconcileFiber(currentChildren[i], nextVnodes[i], parentFiber, childScope);
2002
+ const reconciled = this.reconcileFiber(currentChildren[i], nextVnodes[i], parentFiber, childScope, true);
1757
2003
  if (isThenable(reconciled)) {
1758
2004
  return this.continueStableReconcileAsync(stableResult, reconciled, i, nextVnodes, currentChildren, parentFiber, childScope);
1759
2005
  }
@@ -1780,7 +2026,7 @@ class GraphRuntime {
1780
2026
  this.stableAsyncContinueCount += 1;
1781
2027
  resultSoFar.push(await pending);
1782
2028
  for (let i = pendingIdx + 1; i < nextVnodes.length; i++) {
1783
- const reconciled = this.reconcileFiber(currentChildren[i], nextVnodes[i], parentFiber, childScope);
2029
+ const reconciled = this.reconcileFiber(currentChildren[i], nextVnodes[i], parentFiber, childScope, true);
1784
2030
  resultSoFar.push(isThenable(reconciled) ? await reconciled : reconciled);
1785
2031
  }
1786
2032
  return resultSoFar;
@@ -1874,18 +2120,18 @@ class GraphRuntime {
1874
2120
  throw new Error(`[Effectable] GraphRuntime: fiber with key "${nextKey}" not found in map.`);
1875
2121
  }
1876
2122
  keyedCurrentMap.delete(nextKey);
1877
- const reconciledRes = this.reconcileFiber(currentFiber, nextVnode, parentFiber, childScope);
2123
+ const reconciledRes = this.reconcileFiber(currentFiber, nextVnode, parentFiber, childScope, true);
1878
2124
  nextChildren.push(isThenable(reconciledRes) ? await reconciledRes : reconciledRes);
1879
2125
  }
1880
2126
  else if (nextKey === undefined && unkeyedIdx < unkeyedCurrent.length) {
1881
2127
  const currentFiber = unkeyedCurrent[unkeyedIdx];
1882
2128
  unkeyedIdx += 1;
1883
- const reconciledRes = this.reconcileFiber(currentFiber, nextVnode, parentFiber, childScope);
2129
+ const reconciledRes = this.reconcileFiber(currentFiber, nextVnode, parentFiber, childScope, true);
1884
2130
  nextChildren.push(isThenable(reconciledRes) ? await reconciledRes : reconciledRes);
1885
2131
  }
1886
2132
  else {
1887
- // New node — PLACE
1888
- const newRes = this.materialize(nextVnode, parentFiber, childScope);
2133
+ // New node — PLACE: defer startup so later sibling buses wire first.
2134
+ const newRes = this.materialize(nextVnode, parentFiber, childScope, true);
1889
2135
  nextChildren.push(isThenable(newRes) ? await newRes : newRes);
1890
2136
  }
1891
2137
  }
@@ -1913,12 +2159,12 @@ class GraphRuntime {
1913
2159
  if (unkeyedIdx < unkeyedCurrent.length) {
1914
2160
  const currentFiber = unkeyedCurrent[unkeyedIdx];
1915
2161
  unkeyedIdx += 1;
1916
- const reconciledRes = this.reconcileFiber(currentFiber, nextVnode, parentFiber, childScope);
2162
+ const reconciledRes = this.reconcileFiber(currentFiber, nextVnode, parentFiber, childScope, true);
1917
2163
  nextChildren.push(isThenable(reconciledRes) ? await reconciledRes : reconciledRes);
1918
2164
  }
1919
2165
  else {
1920
- // New node — PLACE
1921
- const newRes = this.materialize(nextVnode, parentFiber, childScope);
2166
+ // New node — PLACE: defer startup so later sibling buses wire first.
2167
+ const newRes = this.materialize(nextVnode, parentFiber, childScope, true);
1922
2168
  nextChildren.push(isThenable(newRes) ? await newRes : newRes);
1923
2169
  }
1924
2170
  }
@@ -1933,6 +2179,15 @@ class GraphRuntime {
1933
2179
  }
1934
2180
  }
1935
2181
  }
2182
+ // Flush PLACE/REPLACE startups in compose order after every new sibling is wired.
2183
+ for (const child of nextChildren) {
2184
+ if (child.constructionJournal?.lifecycleDeferred === true) {
2185
+ const flush = this.flushDeferredLifecycleTree(child);
2186
+ if (isThenable(flush)) {
2187
+ await flush;
2188
+ }
2189
+ }
2190
+ }
1936
2191
  return nextChildren;
1937
2192
  }
1938
2193
  catch (primaryError) {