@timber-js/app 0.2.0-alpha.124 → 0.2.0-alpha.126

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/dist/_chunks/{actions-418ygYKC.js → actions-DX2gkoOZ.js} +2 -2
  2. package/dist/_chunks/{actions-418ygYKC.js.map → actions-DX2gkoOZ.js.map} +1 -1
  3. package/dist/_chunks/{build-output-helper-DYOWsPjZ.js → build-output-helper-K7oUCRqU.js} +1 -1
  4. package/dist/_chunks/{build-output-helper-DYOWsPjZ.js.map → build-output-helper-K7oUCRqU.js.map} +1 -1
  5. package/dist/_chunks/{codec-registry-G9hD5LOq.js → codec-registry-DqkeAydt.js} +1 -1
  6. package/dist/_chunks/{codec-registry-G9hD5LOq.js.map → codec-registry-DqkeAydt.js.map} +1 -1
  7. package/dist/_chunks/{define-BJMwm0c4.js → define-DMq-Yjp7.js} +2 -2
  8. package/dist/_chunks/{define-BJMwm0c4.js.map → define-DMq-Yjp7.js.map} +1 -1
  9. package/dist/_chunks/{navigation-root-DaERBEGV.js → navigation-root-B5-RmsPi.js} +35 -28
  10. package/dist/_chunks/navigation-root-B5-RmsPi.js.map +1 -0
  11. package/dist/_chunks/{stale-reload-BN2kcE06.js → stale-reload-CCqb62Z3.js} +22 -2
  12. package/dist/_chunks/{stale-reload-BN2kcE06.js.map → stale-reload-CCqb62Z3.js.map} +1 -1
  13. package/dist/_chunks/{use-segment-params-ByGqwUuO.js → use-segment-params-CIeinIx2.js} +2 -32
  14. package/dist/_chunks/{use-segment-params-ByGqwUuO.js.map → use-segment-params-CIeinIx2.js.map} +1 -1
  15. package/dist/adapters/cloudflare.js +1 -1
  16. package/dist/adapters/nitro.js +1 -1
  17. package/dist/client/browser-entry/hydrate.d.ts.map +1 -1
  18. package/dist/client/browser-entry/post-hydration.d.ts.map +1 -1
  19. package/dist/client/browser-entry/router-init.d.ts +4 -1
  20. package/dist/client/browser-entry/router-init.d.ts.map +1 -1
  21. package/dist/client/index.js +35 -79
  22. package/dist/client/index.js.map +1 -1
  23. package/dist/client/internal.js +128 -270
  24. package/dist/client/internal.js.map +1 -1
  25. package/dist/client/link-pending-store.d.ts +19 -17
  26. package/dist/client/link-pending-store.d.ts.map +1 -1
  27. package/dist/client/link.d.ts.map +1 -1
  28. package/dist/client/nav-link-store.d.ts +3 -12
  29. package/dist/client/nav-link-store.d.ts.map +1 -1
  30. package/dist/client/navigation-root.d.ts +4 -1
  31. package/dist/client/navigation-root.d.ts.map +1 -1
  32. package/dist/client/router.d.ts +10 -7
  33. package/dist/client/router.d.ts.map +1 -1
  34. package/dist/client/rsc-fetch.d.ts.map +1 -1
  35. package/dist/client/segment-cache.d.ts +2 -0
  36. package/dist/client/segment-cache.d.ts.map +1 -1
  37. package/dist/client/segment-outlet.d.ts +23 -37
  38. package/dist/client/segment-outlet.d.ts.map +1 -1
  39. package/dist/client/segment-update-context.d.ts +21 -0
  40. package/dist/client/segment-update-context.d.ts.map +1 -0
  41. package/dist/client/top-loader.d.ts.map +1 -1
  42. package/dist/client/use-pending-navigation.d.ts +3 -6
  43. package/dist/client/use-pending-navigation.d.ts.map +1 -1
  44. package/dist/index.js +1 -1
  45. package/dist/params/index.js +1 -1
  46. package/dist/segment-params/index.js +1 -1
  47. package/dist/server/index.js +1 -1
  48. package/dist/server/internal.js +75 -4
  49. package/dist/server/internal.js.map +1 -1
  50. package/dist/server/pipeline-outcome.d.ts.map +1 -1
  51. package/dist/server/route-element-builder.d.ts.map +1 -1
  52. package/dist/server/rsc-entry/helpers.d.ts +1 -0
  53. package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
  54. package/dist/server/state-tree-diff.d.ts +12 -5
  55. package/dist/server/state-tree-diff.d.ts.map +1 -1
  56. package/package.json +1 -1
  57. package/src/client/browser-entry/hydrate.ts +7 -1
  58. package/src/client/browser-entry/index.ts +3 -1
  59. package/src/client/browser-entry/post-hydration.ts +0 -13
  60. package/src/client/browser-entry/router-init.ts +50 -24
  61. package/src/client/link-pending-store.ts +22 -17
  62. package/src/client/link.tsx +30 -79
  63. package/src/client/nav-link-store.ts +3 -13
  64. package/src/client/navigation-root.tsx +28 -23
  65. package/src/client/router.ts +109 -97
  66. package/src/client/rsc-fetch.ts +85 -6
  67. package/src/client/segment-cache.ts +5 -2
  68. package/src/client/segment-outlet.tsx +40 -49
  69. package/src/client/segment-update-context.ts +42 -0
  70. package/src/client/top-loader.tsx +5 -15
  71. package/src/client/use-pending-navigation.ts +18 -16
  72. package/src/server/pipeline-outcome.ts +23 -2
  73. package/src/server/route-element-builder.ts +69 -2
  74. package/src/server/rsc-entry/helpers.ts +18 -14
  75. package/src/server/state-tree-diff.ts +26 -22
  76. package/dist/_chunks/navigation-root-DaERBEGV.js.map +0 -1
  77. package/dist/client/segment-merger.d.ts +0 -119
  78. package/dist/client/segment-merger.d.ts.map +0 -1
  79. package/src/client/segment-merger.ts +0 -339
@@ -11,7 +11,7 @@ import {
11
11
  getNavigationState,
12
12
  type NavigationState,
13
13
  } from './navigation-context.js';
14
- import { SegmentElementCache, cacheSegmentElements, mergeSegmentTree } from './segment-merger.js';
14
+
15
15
  import {
16
16
  fetchRscPayload,
17
17
  RedirectError,
@@ -19,6 +19,7 @@ import {
19
19
  VersionSkewError,
20
20
  } from './rsc-fetch.js';
21
21
  import { setHardNavigating } from './navigation-root.js';
22
+ import { isStaleClientReference, triggerStaleReload } from './stale-reload.js';
22
23
  import type { FetchResult } from './rsc-fetch.js';
23
24
 
24
25
  // ─── Types ───────────────────────────────────────────────────────
@@ -100,8 +101,12 @@ export interface RouterDeps {
100
101
  navigateTransition?: (
101
102
  pendingUrl: string,
102
103
  perform: (
103
- wrapPayload: (payload: unknown, navState: NavigationState) => unknown
104
- ) => Promise<unknown>
104
+ wrapPayload: (
105
+ payload: unknown,
106
+ navState: NavigationState,
107
+ segmentUpdates?: Map<string, unknown>
108
+ ) => unknown
109
+ ) => Promise<{ element: unknown; decodePromise: Promise<void> | null }>
105
110
  ) => Promise<void>;
106
111
 
107
112
  /**
@@ -132,6 +137,13 @@ export interface RouterDeps {
132
137
  */
133
138
  completeRouterNavigation?: () => void;
134
139
 
140
+ /**
141
+ * Get the current unwrapped RSC payload element. Used for partial
142
+ * navigation: the router re-wraps the same element with new
143
+ * NavigationProvider context so mounted SegmentOutlets are preserved.
144
+ */
145
+ _getCurrentPayload?: () => unknown;
146
+
135
147
  /**
136
148
  * Initiate a navigation via the Navigation API (`navigation.navigate()`).
137
149
  * Fires the navigate event BEFORE committing the URL, allowing Chrome
@@ -167,12 +179,7 @@ export interface RouterInstance {
167
179
  * Called on initial hydration with segment info embedded in the HTML.
168
180
  */
169
181
  initSegmentCache(segments: SegmentInfo[]): void;
170
- /**
171
- * Cache segment elements from a decoded RSC element tree.
172
- * Called on initial hydration to populate the element cache so the
173
- * first client navigation can use partial payloads.
174
- */
175
- cacheElementTree(element: unknown): void;
182
+
176
183
  /** The segment cache (exposed for tests and <Link> prefetch) */
177
184
  segmentCache: SegmentCache;
178
185
  /** The prefetch cache (exposed for tests and <Link> prefetch) */
@@ -212,10 +219,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
212
219
  const segmentCache = new SegmentCache();
213
220
  const prefetchCache = new PrefetchCache();
214
221
  const historyStack = new HistoryStack();
215
- const segmentElementCache = new SegmentElementCache();
216
-
217
222
  let routerPhase: RouterPhase = { phase: 'idle' };
218
- let navigationId = 0;
219
223
  const pendingListeners = new Set<(pending: boolean) => void>();
220
224
 
221
225
  // AbortController for the current in-flight navigation.
@@ -286,48 +290,29 @@ export function createRouter(deps: RouterDeps): RouterInstance {
286
290
  }
287
291
  }
288
292
 
289
- /**
290
- * Merge a partial RSC payload with cached segment elements if segments
291
- * were skipped. Returns the merged payload ready for rendering.
292
- *
293
- * Segment element caching is deferred to after paint by the caller
294
- * so it doesn't block the render path.
295
- */
296
- async function mergePayload(
297
- payload: unknown,
298
- skippedSegments: string[] | null | undefined
299
- ): Promise<unknown> {
300
- if (skippedSegments && skippedSegments.length > 0) {
301
- // Segment merging needs a resolved React element tree for cloneElement.
302
- // Await the payload only when merging is actually needed.
303
- const resolved =
304
- payload != null && typeof payload === 'object' && 'then' in payload
305
- ? await (payload as PromiseLike<unknown>)
306
- : payload;
307
- return mergeSegmentTree(resolved, skippedSegments, segmentElementCache);
308
- }
309
- // No merging needed — return the payload as-is (may be an unresolved
310
- // Flight thenable). React handles progressive rendering via Suspense.
311
- return payload;
293
+ function isPartialNavigation(skippedSegments: string[] | null | undefined): boolean {
294
+ return skippedSegments != null && skippedSegments.length > 0;
312
295
  }
313
296
 
314
297
  /**
315
- * Schedule segment element caching after paint. Resolves thenable
316
- * payloads first since tree walking requires resolved React elements.
317
- * Guards against stale writes: if a newer navigation started before
318
- * this callback fires, the cache write is skipped.
298
+ * Build a segment updates map for partial navigation. Identifies the
299
+ * first non-skipped segment and maps it to the payload content.
319
300
  */
320
- function deferSegmentCaching(payload: unknown): void {
321
- const capturedNavId = navigationId;
322
- afterPaint(async () => {
323
- if (capturedNavId !== navigationId) return;
324
- const resolved =
325
- payload != null && typeof payload === 'object' && 'then' in payload
326
- ? await (payload as PromiseLike<unknown>)
327
- : payload;
328
- if (capturedNavId !== navigationId) return;
329
- cacheSegmentElements(resolved, segmentElementCache);
330
- });
301
+ function buildSegmentUpdates(result: FetchResult): Map<string, unknown> {
302
+ const skipped = new Set(result.skippedSegments!);
303
+ const segmentInfo = result.segmentInfo;
304
+ const updates = new Map<string, unknown>();
305
+ if (segmentInfo) {
306
+ for (const info of segmentInfo) {
307
+ if (!skipped.has(info.segmentId ?? info.path)) {
308
+ // Use segmentId (outlet key) when available — route groups
309
+ // use a disambiguated key like "/(marketing)" instead of "/".
310
+ updates.set(info.segmentId ?? info.path, result.payload);
311
+ break;
312
+ }
313
+ }
314
+ }
315
+ return updates;
331
316
  }
332
317
 
333
318
  /**
@@ -371,44 +356,66 @@ export function createRouter(deps: RouterDeps): RouterInstance {
371
356
  ): Promise<TransitionResult> {
372
357
  if (deps.navigateTransition) {
373
358
  let headElements: HeadElement[] | null = null;
374
- let decodePromise: Promise<void> | null = null;
375
359
  await deps.navigateTransition(url, async (wrapPayload) => {
376
360
  const result = await perform();
377
361
  headElements = result.headElements;
378
- decodePromise = result.decodePromise;
379
- // Merge partial payload with cached segments before wrapping.
380
- // When no segments were skipped, mergePayload returns the payload
381
- // as-is (possibly still an unresolved Flight thenable — React
382
- // handles progressive rendering via Suspense).
383
- const merged = await mergePayload(result.payload, result.skippedSegments);
384
- // Store the MERGED payload in history — not the partial pre-merge tree.
385
- // This ensures handlePopState replays the complete tree on back/forward.
362
+
363
+ if (isPartialNavigation(result.skippedSegments)) {
364
+ const segmentUpdates = buildSegmentUpdates(result);
365
+
366
+ historyStack.push(url, {
367
+ payload: null,
368
+ headElements: result.headElements,
369
+ params: result.params,
370
+ });
371
+
372
+ // Re-wrap the CURRENT element with new context values.
373
+ // SegmentOutlets read updates from SegmentUpdateContext;
374
+ // NavigationProvider gets new params/pathname.
375
+ const element = wrapPayload(
376
+ deps._getCurrentPayload?.() ?? result.payload,
377
+ result.navState,
378
+ segmentUpdates
379
+ );
380
+ return { element, decodePromise: result.decodePromise };
381
+ }
382
+
383
+ // Full navigation — empty updates, render the new tree.
386
384
  historyStack.push(url, {
387
- payload: merged,
385
+ payload: result.payload,
388
386
  headElements: result.headElements,
389
387
  params: result.params,
390
388
  });
391
- // Defer segment element caching to after paint — it only benefits
392
- // future navigations and doesn't need to block the current render.
393
- deferSegmentCaching(merged);
394
- // Pass navState explicitly — wrapPayload wraps element in
395
- // NavigationProvider with this state, no getNavigationState() needed.
396
- return wrapPayload(merged, result.navState);
389
+ const element = wrapPayload(result.payload, result.navState);
390
+ return { element, decodePromise: result.decodePromise };
397
391
  });
398
- return { headElements, decodePromise };
392
+ // decodePromise is awaited inside the transition's startTransition
393
+ // callback (NavigationRoot), so we don't need it here.
394
+ return { headElements, decodePromise: null };
399
395
  }
400
396
  // Fallback: no transition (tests, no React tree)
401
397
  const result = await perform();
402
- // Merge partial payload with cached segments before rendering
403
- const merged = await mergePayload(result.payload, result.skippedSegments);
404
- // Store merged payload in history
405
- historyStack.push(url, {
406
- payload: merged,
407
- headElements: result.headElements,
408
- params: result.params,
409
- });
410
- deferSegmentCaching(merged);
411
- renderPayload(merged, result.navState);
398
+ if (isPartialNavigation(result.skippedSegments)) {
399
+ historyStack.push(url, {
400
+ payload: null,
401
+ headElements: result.headElements,
402
+ params: result.params,
403
+ });
404
+ } else {
405
+ // Resolve thenables before storing/rendering in the fallback path
406
+ // (tests without navigateTransition). In production, React handles
407
+ // thenables via Suspense; in tests, renderRoot expects resolved values.
408
+ const payload =
409
+ result.payload != null && typeof result.payload === 'object' && 'then' in result.payload
410
+ ? await (result.payload as PromiseLike<unknown>)
411
+ : result.payload;
412
+ historyStack.push(url, {
413
+ payload,
414
+ headElements: result.headElements,
415
+ params: result.params,
416
+ });
417
+ renderPayload(payload, result.navState);
418
+ }
412
419
  return { headElements: result.headElements, decodePromise: result.decodePromise };
413
420
  }
414
421
 
@@ -464,7 +471,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
464
471
  if (result === undefined) {
465
472
  // Fetch RSC payload with state tree for partial rendering.
466
473
  // Send current URL for intercepting route resolution (modal pattern).
467
- const stateTree = segmentCache.serializeStateTree(segmentElementCache.getMergeablePaths());
474
+ const stateTree = segmentCache.serializeStateTree();
468
475
  const rawCurrentUrl = deps.getCurrentUrl();
469
476
  const currentUrl = rawCurrentUrl.startsWith('http')
470
477
  ? new URL(rawCurrentUrl).pathname
@@ -506,8 +513,6 @@ export function createRouter(deps: RouterDeps): RouterInstance {
506
513
  const externalSignal = options._signal as AbortSignal | undefined;
507
514
  const skipHistory = options._skipHistory === true;
508
515
 
509
- navigationId++;
510
-
511
516
  // Create an abort controller for this navigation. Links to the external
512
517
  // signal (Navigation API's event.signal) when provided.
513
518
  const navAbort = createNavAbort(externalSignal);
@@ -541,7 +546,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
541
546
  setPending(true, url);
542
547
 
543
548
  try {
544
- const { headElements, decodePromise } = await renderViaTransition(url, () =>
549
+ const { headElements } = await renderViaTransition(url, () =>
545
550
  performNavigationFetch(url, {
546
551
  replace,
547
552
  signal: navAbort.signal,
@@ -549,10 +554,6 @@ export function createRouter(deps: RouterDeps): RouterInstance {
549
554
  })
550
555
  );
551
556
 
552
- // Await async RSC decode errors (truncated streams, Flight parse failures)
553
- // so they surface through this try/catch rather than becoming unhandled.
554
- if (decodePromise) await decodePromise;
555
-
556
557
  // Update document.title and <meta> tags with the new page's metadata
557
558
  applyHead(headElements);
558
559
 
@@ -599,6 +600,13 @@ export function createRouter(deps: RouterDeps): RouterInstance {
599
600
  window.location.href = error.url;
600
601
  return new Promise(() => {}) as never;
601
602
  }
603
+ // Stale client reference from streaming RSC decode — the server
604
+ // was redeployed with new bundles. Same recovery as VersionSkewError.
605
+ if (isStaleClientReference(error)) {
606
+ setHardNavigating(true);
607
+ triggerStaleReload();
608
+ return new Promise(() => {}) as never;
609
+ }
602
610
  // Abort errors are not application errors — swallow silently.
603
611
  if (isAbortError(error)) return;
604
612
  throw error;
@@ -621,13 +629,12 @@ export function createRouter(deps: RouterDeps): RouterInstance {
621
629
 
622
630
  async function refresh(): Promise<void> {
623
631
  const currentUrl = deps.getCurrentUrl();
624
- navigationId++;
625
632
  const navAbort = createNavAbort();
626
633
 
627
634
  setPending(true, currentUrl);
628
635
 
629
636
  try {
630
- const { headElements, decodePromise } = await renderViaTransition(currentUrl, async () => {
637
+ const { headElements } = await renderViaTransition(currentUrl, async () => {
631
638
  // No state tree sent — server renders the complete RSC payload
632
639
  const result = await fetchRscPayload(
633
640
  currentUrl,
@@ -642,9 +649,13 @@ export function createRouter(deps: RouterDeps): RouterInstance {
642
649
  return { ...result, navState };
643
650
  });
644
651
 
645
- if (decodePromise) await decodePromise;
646
652
  applyHead(headElements);
647
653
  } catch (error) {
654
+ if (isStaleClientReference(error)) {
655
+ setHardNavigating(true);
656
+ triggerStaleReload();
657
+ return new Promise(() => {}) as never;
658
+ }
648
659
  // Stale transition (superseded by a newer navigation) or aborted
649
660
  // fetch — silently ignore. See TIM-629.
650
661
  if (isAbortError(error)) return;
@@ -663,7 +674,6 @@ export function createRouter(deps: RouterDeps): RouterInstance {
663
674
  scrollY: number = 0,
664
675
  externalSignal?: AbortSignal
665
676
  ): Promise<void> {
666
- navigationId++;
667
677
  // Scroll position is read from history.state by the caller (browser-entry.ts)
668
678
  // and passed in. This is more reliable than tracking scroll per-URL in memory
669
679
  // because the browser maintains per-entry state even with duplicate URLs.
@@ -683,10 +693,8 @@ export function createRouter(deps: RouterDeps): RouterInstance {
683
693
  const navAbort = createNavAbort(externalSignal);
684
694
  setPending(true, url);
685
695
  try {
686
- const { headElements, decodePromise } = await renderViaTransition(url, async () => {
687
- const stateTree = segmentCache.serializeStateTree(
688
- segmentElementCache.getMergeablePaths()
689
- );
696
+ const { headElements } = await renderViaTransition(url, async () => {
697
+ const stateTree = segmentCache.serializeStateTree();
690
698
  const result = await fetchRscPayload(url, deps, stateTree, undefined, navAbort.signal);
691
699
  updateSegmentCache(result.segmentInfo);
692
700
  const navState = updateNavigationState(result.params, url);
@@ -694,10 +702,14 @@ export function createRouter(deps: RouterDeps): RouterInstance {
694
702
  return { ...result, navState };
695
703
  });
696
704
 
697
- if (decodePromise) await decodePromise;
698
705
  applyHead(headElements);
699
706
  restoreScrollAfterPaint(scrollY);
700
707
  } catch (error) {
708
+ if (isStaleClientReference(error)) {
709
+ setHardNavigating(true);
710
+ triggerStaleReload();
711
+ return new Promise(() => {}) as never;
712
+ }
701
713
  // Stale transition (superseded by a newer navigation) or aborted
702
714
  // fetch — silently ignore. See TIM-629.
703
715
  if (isAbortError(error)) return;
@@ -721,9 +733,10 @@ export function createRouter(deps: RouterDeps): RouterInstance {
721
733
  if (historyStack.has(url)) return;
722
734
 
723
735
  // Fire-and-forget fetch
724
- const stateTree = segmentCache.serializeStateTree(segmentElementCache.getMergeablePaths());
736
+ const stateTree = segmentCache.serializeStateTree();
725
737
  void fetchRscPayload(url, deps, stateTree).then(
726
738
  (result) => {
739
+ result.decodePromise?.catch(() => {});
727
740
  prefetchCache.set(url, result);
728
741
  },
729
742
  () => {
@@ -754,7 +767,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
754
767
  payload: element,
755
768
  headElements,
756
769
  });
757
- deferSegmentCaching(element);
770
+
758
771
  // Revalidation doesn't change params/pathname — preserve current state.
759
772
  // DO NOT call updateNavigationState(null, ...) here: that normalizes
760
773
  // params to {}, clearing dynamic route params on every action response.
@@ -763,7 +776,6 @@ export function createRouter(deps: RouterDeps): RouterInstance {
763
776
  applyHead(headElements);
764
777
  },
765
778
  initSegmentCache: (segments: SegmentInfo[]) => updateSegmentCache(segments),
766
- cacheElementTree: (element: unknown) => cacheSegmentElements(element, segmentElementCache),
767
779
  segmentCache,
768
780
  prefetchCache,
769
781
  historyStack,
@@ -258,6 +258,54 @@ export class ServerErrorResponse extends Error {
258
258
  }
259
259
  }
260
260
 
261
+ // ─── Stream Completion Tracking ───────────────────────────────────
262
+
263
+ /**
264
+ * Wrap a response body stream to track when it's fully consumed.
265
+ * Returns a new body that passes all chunks through unchanged, plus
266
+ * a `done` promise that resolves when the last chunk is read (or
267
+ * rejects if the stream errors).
268
+ *
269
+ * Used to keep React transitions open for the full RSC stream
270
+ * duration — createFromFetch's thenable resolves on shell arrival,
271
+ * but we need stream completion for useOptimistic pending state.
272
+ */
273
+ function trackStreamCompletion(body: ReadableStream<Uint8Array>): {
274
+ body: ReadableStream<Uint8Array>;
275
+ done: Promise<void>;
276
+ } {
277
+ let resolveDone!: () => void;
278
+ let rejectDone!: (e: unknown) => void;
279
+ const done = new Promise<void>((res, rej) => {
280
+ resolveDone = res;
281
+ rejectDone = rej;
282
+ });
283
+
284
+ const reader = body.getReader();
285
+ const tracked = new ReadableStream<Uint8Array>({
286
+ async pull(controller) {
287
+ try {
288
+ const result = await reader.read();
289
+ if (result.done) {
290
+ controller.close();
291
+ resolveDone();
292
+ } else {
293
+ controller.enqueue(result.value);
294
+ }
295
+ } catch (error) {
296
+ controller.error(error);
297
+ rejectDone(error);
298
+ }
299
+ },
300
+ cancel(reason) {
301
+ reader.cancel(reason);
302
+ resolveDone();
303
+ },
304
+ });
305
+
306
+ return { body: tracked, done };
307
+ }
308
+
261
309
  // ─── Fetch ───────────────────────────────────────────────────────
262
310
 
263
311
  /**
@@ -289,6 +337,11 @@ export async function fetchRscPayload(
289
337
  let segmentInfo: SegmentInfo[] | null = null;
290
338
  let params: Record<string, string | string[]> | null = null;
291
339
  let skippedSegments: string[] | null = null;
340
+ // Track when the full RSC body stream is consumed (not just shell).
341
+ // Initialized to resolved for bodyless responses; overwritten when
342
+ // the response has a body.
343
+ let streamDone: Promise<void> = Promise.resolve();
344
+
292
345
  const wrappedPromise = fetchPromise.then((response) => {
293
346
  // Version skew detection (TIM-446): if the server signals a reload,
294
347
  // throw VersionSkewError so the caller (router navigate) can trigger
@@ -317,6 +370,20 @@ export async function fetchRscPayload(
317
370
  segmentInfo = extractSegmentInfo(response);
318
371
  params = extractParams(response);
319
372
  skippedSegments = extractSkippedSegments(response);
373
+
374
+ // Wrap the body to track full stream consumption. createFromFetch's
375
+ // thenable resolves when the root model (shell) arrives, but we need
376
+ // to know when ALL chunks are read so the React transition stays
377
+ // open for the full streaming duration (keeps useOptimistic alive).
378
+ if (response.body) {
379
+ const tracked = trackStreamCompletion(response.body);
380
+ streamDone = tracked.done;
381
+ streamDone.catch(() => {}); // prevent unhandled rejection
382
+ return new Response(tracked.body, {
383
+ headers: response.headers,
384
+ status: response.status,
385
+ });
386
+ }
320
387
  return response;
321
388
  });
322
389
  // Await headers so headElements/segmentInfo/params are populated.
@@ -326,12 +393,24 @@ export async function fetchRscPayload(
326
393
  // parts and progressively renders as chunks arrive, spreading work across
327
394
  // frames instead of blocking the main thread in one burst.
328
395
  const payload = deps.decodeRsc(wrappedPromise);
329
- // Track decode completion so callers can catch async errors (truncated
330
- // streams, Flight parse failures) that fire after fetchRscPayload returns.
331
- const decodePromise = Promise.resolve(payload).then(() => undefined);
332
- // Prevent unhandled rejection — the router monitors decodePromise separately.
333
- decodePromise.catch(() => {});
334
- return { payload, decodePromise, headElements, segmentInfo, params, skippedSegments };
396
+ // Combine stream completion with payload error propagation.
397
+ // streamDone keeps the transition open for the full RSC stream
398
+ // duration (useOptimistic pending state). payloadError propagates
399
+ // decode failures (stale client references, Flight parse errors)
400
+ // so the router's catch block can trigger recovery (stale reload).
401
+ const payloadError = new Promise<void>((_, reject) => {
402
+ Promise.resolve(payload).then(() => {}, reject);
403
+ });
404
+ payloadError.catch(() => {});
405
+ const decodePromise = Promise.race([streamDone, payloadError]);
406
+ return {
407
+ payload,
408
+ decodePromise,
409
+ headElements,
410
+ segmentInfo,
411
+ params,
412
+ skippedSegments,
413
+ };
335
414
  }
336
415
  // Test/fallback path: return raw text
337
416
  const response = await deps.fetch(rscUrl, { headers, redirect: 'manual', signal });
@@ -111,6 +111,8 @@ function collectSyncSegments(
111
111
  */
112
112
  export interface SegmentInfo {
113
113
  path: string;
114
+ /** Outlet key — includes route group name when applicable (e.g., "/(marketing)"). */
115
+ segmentId?: string;
114
116
  isAsync: boolean;
115
117
  }
116
118
 
@@ -137,8 +139,9 @@ export function buildSegmentTree(segments: SegmentInfo[]): SegmentNode | undefin
137
139
  let parent: SegmentNode | undefined;
138
140
 
139
141
  for (const info of layouts) {
142
+ const id = info.segmentId ?? info.path;
140
143
  const node: SegmentNode = {
141
- segment: info.path,
144
+ segment: id,
142
145
  payload: null,
143
146
  isAsync: info.isAsync,
144
147
  children: new Map(),
@@ -149,7 +152,7 @@ export function buildSegmentTree(segments: SegmentInfo[]): SegmentNode | undefin
149
152
  }
150
153
 
151
154
  if (parent) {
152
- parent.children.set(info.path, node);
155
+ parent.children.set(id, node);
153
156
  }
154
157
 
155
158
  parent = node;
@@ -1,33 +1,31 @@
1
1
  /**
2
2
  * SegmentOutlet — client component boundary at each layout segment.
3
3
  *
4
- * Replaces the post-hoc tree walking in segment-merger.ts with an explicit
5
- * client component at each segment boundary. Each outlet:
4
+ * Each layout in the segment tree is wrapped with a SegmentOutlet that:
5
+ * 1. Knows its segment path (prop from the server)
6
+ * 2. Reads from SegmentUpdateContext for partial navigation updates
7
+ * 3. Caches its rendered content in a ref across navigations
6
8
  *
7
- * 1. Knows its own segment path (prop from the server)
8
- * 2. Caches its children in a ref across navigations
9
- * 3. When `keepCurrent` is true (partial navigation, this segment skipped),
10
- * returns the previously cached children — layout state is preserved
11
- * 4. When `keepCurrent` is false (full navigation or this segment changed),
12
- * stores and renders the new children
9
+ * On full navigation: receives new children via props, caches and renders them.
10
+ * On partial navigation (this segment skipped): the context map has no entry
11
+ * for this path, so the outlet returns cached content — preserving layout state.
12
+ * On partial navigation (this segment updated): the context map has content
13
+ * for this path, so the outlet renders the update.
13
14
  *
14
- * This eliminates the need for client-side element tree walking, which
15
- * breaks on real RSC trees due to opaque client component lazy refs,
16
- * Suspense thenables, and AccessGate wrappers.
15
+ * Uses React context instead of useSyncExternalStore to stay compatible
16
+ * with concurrent rendering (transitions). All outlets re-render when the
17
+ * context value changes, but each bails out quickly if its segment has
18
+ * no update — same approach as Next.js LayoutRouter.
17
19
  *
18
- * Architecture is similar to Next.js's `<LayoutRouter>` client component —
19
- * each layout boundary is an explicit client component that manages its
20
- * own subtree. See design/19-client-navigation.md.
21
- *
22
- * Security: This is a performance optimization only. The server always
23
- * runs all access.ts files regardless of segment skipping. A fabricated
24
- * keepCurrent prop can only cause stale layouts — never auth bypass.
20
+ * Security: performance optimization only. The server always runs all
21
+ * access.ts files regardless of segment skipping.
25
22
  * See design/13-security.md §"State tree manipulation".
26
23
  */
27
24
 
28
25
  'use client';
29
26
 
30
- import { useRef, type ReactNode } from 'react';
27
+ import { useContext, useRef, type ReactNode } from 'react';
28
+ import { SegmentUpdateContext } from './segment-update-context.js';
31
29
 
32
30
  export interface SegmentOutletProps {
33
31
  /**
@@ -38,17 +36,6 @@ export interface SegmentOutletProps {
38
36
  */
39
37
  segmentPath: string;
40
38
 
41
- /**
42
- * When true, the outlet returns its previously cached children instead
43
- * of rendering the new children prop. Set by the server when this
44
- * segment was skipped (the client already has the layout mounted).
45
- *
46
- * On the first render (SSR/hydration), this is always false — there's
47
- * no cached content yet. On subsequent partial navigations, the server
48
- * sets this to true for segments it skipped rendering.
49
- */
50
- keepCurrent?: boolean;
51
-
52
39
  /** The segment's React subtree (layout + inner content). */
53
40
  children: ReactNode;
54
41
  }
@@ -56,31 +43,35 @@ export interface SegmentOutletProps {
56
43
  /**
57
44
  * Client component boundary at each layout segment in the element tree.
58
45
  *
59
- * On full navigation: receives new children, stores them, renders them.
60
- * On partial navigation (keepCurrent=true): ignores children prop,
61
- * returns previously stored content — React reconciles the same elements,
62
- * preserving all client component state in the layout subtree.
46
+ * On full navigation (context map is empty): renders children, caches in ref.
47
+ * On partial navigation: checks the context map for an update at segmentPath.
48
+ * - Update found → renders update, caches in ref.
49
+ * - No update → returns cached content (layout state preserved).
63
50
  *
64
- * React preserves the ref across `reactRoot.render()` calls because:
65
- * - SegmentOutlet has a stable type (client component module reference)
66
- * - It appears at the same tree position on every navigation
67
- * - React reconciles same-type, same-position → instance preserved
51
+ * React preserves component instances across reactRoot.render() calls
52
+ * when the same type appears at the same tree position. The ref persists
53
+ * across navigations because SegmentOutlet is reconciled, not remounted.
68
54
  */
69
- export function SegmentOutlet({
70
- segmentPath: _segmentPath,
71
- keepCurrent = false,
72
- children,
73
- }: SegmentOutletProps) {
74
- // Store content in a ref to avoid triggering re-renders on cache updates.
75
- // The ref persists across reactRoot.render() calls because React reconciles
76
- // the same component type at the same tree position.
55
+ export function SegmentOutlet({ segmentPath, children }: SegmentOutletProps) {
56
+ const updates = useContext(SegmentUpdateContext);
77
57
  const contentRef = useRef<ReactNode>(null);
78
58
 
79
- if (!keepCurrent) {
80
- // Full render or this segment was re-rendered — store and render new content
59
+ const update = updates.get(segmentPath);
60
+
61
+ if (update !== undefined) {
62
+ contentRef.current = update;
63
+ return update;
64
+ }
65
+
66
+ if (contentRef.current === null) {
67
+ contentRef.current = children;
68
+ return children;
69
+ }
70
+
71
+ if (children !== contentRef.current) {
81
72
  contentRef.current = children;
73
+ return children;
82
74
  }
83
- // else: keepCurrent=true — return previously cached content
84
75
 
85
76
  return contentRef.current;
86
77
  }