@timber-js/app 0.2.0-alpha.123 → 0.2.0-alpha.125

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 (88) hide show
  1. package/dist/_chunks/{actions-ZlcKx0H9.js → actions-418ygYKC.js} +2 -2
  2. package/dist/_chunks/{actions-ZlcKx0H9.js.map → actions-418ygYKC.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/{cli-schema-sync-VcCf1TFY.js → cli-schema-sync-DFHBj6yZ.js} +59 -12
  6. package/dist/_chunks/{cli-schema-sync-VcCf1TFY.js.map → cli-schema-sync-DFHBj6yZ.js.map} +1 -1
  7. package/dist/_chunks/cli-schema-sync-vdobDWwX.js +2 -0
  8. package/dist/_chunks/{define-C0V_eKiW.js → define-BJMwm0c4.js} +2 -2
  9. package/dist/_chunks/{define-C0V_eKiW.js.map → define-BJMwm0c4.js.map} +1 -1
  10. package/dist/_chunks/{navigation-root-Ds_hDtRW.js → navigation-root-CeNTCxzS.js} +6 -6
  11. package/dist/_chunks/navigation-root-CeNTCxzS.js.map +1 -0
  12. package/dist/_chunks/{stale-reload-BN2kcE06.js → stale-reload-CCqb62Z3.js} +22 -2
  13. package/dist/_chunks/{stale-reload-BN2kcE06.js.map → stale-reload-CCqb62Z3.js.map} +1 -1
  14. package/dist/_chunks/{use-segment-params-Du7u25cK.js → use-segment-params-ByGqwUuO.js} +3 -2
  15. package/dist/_chunks/use-segment-params-ByGqwUuO.js.map +1 -0
  16. package/dist/_chunks/{walkers-tkw7sDlj.js → walkers-DMSGZeCk.js} +2 -2
  17. package/dist/_chunks/{walkers-tkw7sDlj.js.map → walkers-DMSGZeCk.js.map} +1 -1
  18. package/dist/adapters/cloudflare.js +1 -1
  19. package/dist/adapters/nitro.js +1 -1
  20. package/dist/cli-schema-sync.d.ts +6 -4
  21. package/dist/cli-schema-sync.d.ts.map +1 -1
  22. package/dist/cli.d.ts +1 -1
  23. package/dist/cli.d.ts.map +1 -1
  24. package/dist/cli.js +12 -6
  25. package/dist/cli.js.map +1 -1
  26. package/dist/client/browser-entry/hydrate.d.ts.map +1 -1
  27. package/dist/client/browser-entry/post-hydration.d.ts.map +1 -1
  28. package/dist/client/browser-entry/router-init.d.ts +4 -1
  29. package/dist/client/browser-entry/router-init.d.ts.map +1 -1
  30. package/dist/client/index.js +4 -4
  31. package/dist/client/index.js.map +1 -1
  32. package/dist/client/internal.js +147 -301
  33. package/dist/client/internal.js.map +1 -1
  34. package/dist/client/link-pending-store.d.ts +19 -17
  35. package/dist/client/link-pending-store.d.ts.map +1 -1
  36. package/dist/client/link.d.ts.map +1 -1
  37. package/dist/client/navigation-context.d.ts +1 -0
  38. package/dist/client/navigation-context.d.ts.map +1 -1
  39. package/dist/client/navigation-root.d.ts +4 -1
  40. package/dist/client/navigation-root.d.ts.map +1 -1
  41. package/dist/client/router.d.ts +10 -7
  42. package/dist/client/router.d.ts.map +1 -1
  43. package/dist/client/rsc-fetch.d.ts.map +1 -1
  44. package/dist/client/segment-cache.d.ts +2 -0
  45. package/dist/client/segment-cache.d.ts.map +1 -1
  46. package/dist/client/segment-outlet.d.ts +23 -37
  47. package/dist/client/segment-outlet.d.ts.map +1 -1
  48. package/dist/client/segment-update-context.d.ts +21 -0
  49. package/dist/client/segment-update-context.d.ts.map +1 -0
  50. package/dist/client/use-search-params.d.ts +7 -5
  51. package/dist/client/use-search-params.d.ts.map +1 -1
  52. package/dist/index.js +3 -3
  53. package/dist/routing/index.js +2 -2
  54. package/dist/segment-params/index.js +1 -1
  55. package/dist/server/index.js +1 -1
  56. package/dist/server/internal.js +58 -2
  57. package/dist/server/internal.js.map +1 -1
  58. package/dist/server/route-element-builder.d.ts.map +1 -1
  59. package/dist/server/rsc-entry/helpers.d.ts +1 -0
  60. package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
  61. package/dist/server/state-tree-diff.d.ts +12 -5
  62. package/dist/server/state-tree-diff.d.ts.map +1 -1
  63. package/package.json +1 -1
  64. package/src/cli-schema-sync.ts +80 -13
  65. package/src/cli.ts +18 -7
  66. package/src/client/browser-entry/hydrate.ts +9 -1
  67. package/src/client/browser-entry/index.ts +3 -1
  68. package/src/client/browser-entry/post-hydration.ts +1 -13
  69. package/src/client/browser-entry/router-init.ts +50 -24
  70. package/src/client/link-pending-store.ts +22 -17
  71. package/src/client/link.tsx +12 -6
  72. package/src/client/navigation-context.ts +2 -1
  73. package/src/client/navigation-root.tsx +40 -18
  74. package/src/client/router.ts +113 -99
  75. package/src/client/rsc-fetch.ts +85 -6
  76. package/src/client/segment-cache.ts +5 -2
  77. package/src/client/segment-outlet.tsx +40 -49
  78. package/src/client/segment-update-context.ts +42 -0
  79. package/src/client/use-search-params.ts +25 -44
  80. package/src/server/route-element-builder.ts +69 -2
  81. package/src/server/rsc-entry/helpers.ts +18 -14
  82. package/src/server/state-tree-diff.ts +26 -22
  83. package/dist/_chunks/cli-schema-sync-4HcQIziG.js +0 -2
  84. package/dist/_chunks/navigation-root-Ds_hDtRW.js.map +0 -1
  85. package/dist/_chunks/use-segment-params-Du7u25cK.js.map +0 -1
  86. package/dist/client/segment-merger.d.ts +0 -119
  87. package/dist/client/segment-merger.d.ts.map +0 -1
  88. package/src/client/segment-merger.ts +0 -339
@@ -132,7 +132,10 @@ let _transitionRender: ((element: ReactNode) => void) | null = null;
132
132
  * with the pending URL.
133
133
  */
134
134
  let _navigateTransition:
135
- | ((pendingUrl: string, perform: () => Promise<ReactNode>) => Promise<void>)
135
+ | ((
136
+ pendingUrl: string,
137
+ perform: () => Promise<{ element: ReactNode; decodePromise: Promise<void> | null }>
138
+ ) => Promise<void>)
136
139
  | null = null;
137
140
 
138
141
  // ─── Component ───────────────────────────────────────────────────
@@ -194,7 +197,10 @@ export function NavigationRoot({
194
197
  // are deferred. When the transition commits, the new tree and pendingUrl=null
195
198
  // both apply in the same React commit — making the pending→active transition
196
199
  // atomic (no frame where pending is false but the old tree is still visible).
197
- _navigateTransition = (url: string, perform: () => Promise<ReactNode>) => {
200
+ _navigateTransition = (
201
+ url: string,
202
+ perform: () => Promise<{ element: ReactNode; decodePromise: Promise<void> | null }>
203
+ ) => {
198
204
  // Urgent: show pending state immediately (for TopLoader / usePendingNavigation)
199
205
  setPendingUrl(url);
200
206
 
@@ -204,29 +210,42 @@ export function NavigationRoot({
204
210
  const counter = getTransitionCounter();
205
211
  const transId = ++counter.id;
206
212
 
213
+ // Activate per-link pending state as an URGENT update (outside
214
+ // startTransition). With useState (not useOptimistic), this commits
215
+ // immediately so the clicked link shows pending before the next paint.
216
+ // The corresponding resetLinkPending() is called INSIDE the transition
217
+ // so it commits atomically with the new tree.
218
+ //
219
+ // CRITICAL: activateLinkPending must NOT be called inside the
220
+ // startTransition that awaits decodePromise. With useOptimistic,
221
+ // that combination triggers "Rendered more hooks" when two
222
+ // navigations overlap under the Navigation API (TIM-964).
223
+ activateLinkPending();
224
+
207
225
  return new Promise<void>((resolve, reject) => {
208
226
  startTransition(async () => {
209
- // Activate per-link pending state inside this async transition.
210
- // useOptimistic persists the isPending=true value for the duration
211
- // of this transition, then auto-reverts when it settles.
212
- activateLinkPending();
213
227
  try {
214
- const newElement = await perform();
215
- // Only commit state if this is still the active navigation.
216
- // A superseded transition's updates must be dropped entirely.
228
+ const { element, decodePromise } = await perform();
229
+ if (counter.id !== transId) {
230
+ decodePromise?.catch(() => {});
231
+ reject(new DOMException('Navigation superseded', 'AbortError'));
232
+ return;
233
+ }
234
+ setElement(element);
235
+ // Await RSC stream completion inside the transition so the
236
+ // link's pending state (set urgently above) persists until
237
+ // the full payload is consumed. resetLinkPending() below
238
+ // commits atomically with the new tree when the transition
239
+ // settles — no frame where pending=false but old content shows.
240
+ if (decodePromise) await decodePromise;
217
241
  if (counter.id === transId) {
218
- setElement(newElement);
242
+ resetLinkPending();
219
243
  setPendingUrl(null);
220
244
  resolve();
221
245
  } else {
222
- // Stale transition — a newer navigation has superseded this one.
223
- // Reject so the caller (navigate/refresh/handlePopState) doesn't
224
- // run post-transition side effects (applyHead, scroll, event
225
- // dispatch) with stale data. All callers catch AbortError.
226
246
  reject(new DOMException('Navigation superseded', 'AbortError'));
227
247
  }
228
248
  } catch (err) {
229
- // Only clear pending if this is still the active navigation.
230
249
  if (counter.id === transId) {
231
250
  setPendingUrl(null);
232
251
  resetLinkPending();
@@ -297,7 +316,7 @@ export function transitionRender(element: ReactNode): void {
297
316
  */
298
317
  export function navigateTransition(
299
318
  pendingUrl: string,
300
- perform: () => Promise<ReactNode>
319
+ perform: () => Promise<{ element: ReactNode; decodePromise: Promise<void> | null }>
301
320
  ): Promise<void> {
302
321
  if (_navigateTransition) {
303
322
  return _navigateTransition(pendingUrl, perform);
@@ -335,8 +354,11 @@ export function installDeferredNavigation(createAndMount: (initial: ReactNode) =
335
354
  _transitionRender = (element: ReactNode) => {
336
355
  mountOnce(element);
337
356
  };
338
- _navigateTransition = async (_pendingUrl: string, perform: () => Promise<ReactNode>) => {
339
- const element = await perform();
357
+ _navigateTransition = async (
358
+ _pendingUrl: string,
359
+ perform: () => Promise<{ element: ReactNode; decodePromise: Promise<void> | null }>
360
+ ) => {
361
+ const { element } = await perform();
340
362
  mountOnce(element);
341
363
  };
342
364
  }
@@ -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
  /**
@@ -346,8 +331,10 @@ export function createRouter(deps: RouterDeps): RouterInstance {
346
331
  // Module-level fallback for tests (no NavigationProvider) and SSR
347
332
  setCurrentParams(resolvedParams);
348
333
  // globalThis bridge — kept for backward compat
349
- const pathname = url.startsWith('http') ? new URL(url).pathname : url.split('?')[0] || '/';
350
- const navState: NavigationState = { params: resolvedParams, pathname };
334
+ const parsed = new URL(url, 'http://localhost');
335
+ const pathname = parsed.pathname || '/';
336
+ const search = parsed.search;
337
+ const navState: NavigationState = { params: resolvedParams, pathname, search };
351
338
  setNavigationState(navState);
352
339
  return navState;
353
340
  }
@@ -369,44 +356,66 @@ export function createRouter(deps: RouterDeps): RouterInstance {
369
356
  ): Promise<TransitionResult> {
370
357
  if (deps.navigateTransition) {
371
358
  let headElements: HeadElement[] | null = null;
372
- let decodePromise: Promise<void> | null = null;
373
359
  await deps.navigateTransition(url, async (wrapPayload) => {
374
360
  const result = await perform();
375
361
  headElements = result.headElements;
376
- decodePromise = result.decodePromise;
377
- // Merge partial payload with cached segments before wrapping.
378
- // When no segments were skipped, mergePayload returns the payload
379
- // as-is (possibly still an unresolved Flight thenable — React
380
- // handles progressive rendering via Suspense).
381
- const merged = await mergePayload(result.payload, result.skippedSegments);
382
- // Store the MERGED payload in history — not the partial pre-merge tree.
383
- // 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.
384
384
  historyStack.push(url, {
385
- payload: merged,
385
+ payload: result.payload,
386
386
  headElements: result.headElements,
387
387
  params: result.params,
388
388
  });
389
- // Defer segment element caching to after paint — it only benefits
390
- // future navigations and doesn't need to block the current render.
391
- deferSegmentCaching(merged);
392
- // Pass navState explicitly — wrapPayload wraps element in
393
- // NavigationProvider with this state, no getNavigationState() needed.
394
- return wrapPayload(merged, result.navState);
389
+ const element = wrapPayload(result.payload, result.navState);
390
+ return { element, decodePromise: result.decodePromise };
395
391
  });
396
- 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 };
397
395
  }
398
396
  // Fallback: no transition (tests, no React tree)
399
397
  const result = await perform();
400
- // Merge partial payload with cached segments before rendering
401
- const merged = await mergePayload(result.payload, result.skippedSegments);
402
- // Store merged payload in history
403
- historyStack.push(url, {
404
- payload: merged,
405
- headElements: result.headElements,
406
- params: result.params,
407
- });
408
- deferSegmentCaching(merged);
409
- 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
+ }
410
419
  return { headElements: result.headElements, decodePromise: result.decodePromise };
411
420
  }
412
421
 
@@ -462,7 +471,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
462
471
  if (result === undefined) {
463
472
  // Fetch RSC payload with state tree for partial rendering.
464
473
  // Send current URL for intercepting route resolution (modal pattern).
465
- const stateTree = segmentCache.serializeStateTree(segmentElementCache.getMergeablePaths());
474
+ const stateTree = segmentCache.serializeStateTree();
466
475
  const rawCurrentUrl = deps.getCurrentUrl();
467
476
  const currentUrl = rawCurrentUrl.startsWith('http')
468
477
  ? new URL(rawCurrentUrl).pathname
@@ -504,8 +513,6 @@ export function createRouter(deps: RouterDeps): RouterInstance {
504
513
  const externalSignal = options._signal as AbortSignal | undefined;
505
514
  const skipHistory = options._skipHistory === true;
506
515
 
507
- navigationId++;
508
-
509
516
  // Create an abort controller for this navigation. Links to the external
510
517
  // signal (Navigation API's event.signal) when provided.
511
518
  const navAbort = createNavAbort(externalSignal);
@@ -539,7 +546,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
539
546
  setPending(true, url);
540
547
 
541
548
  try {
542
- const { headElements, decodePromise } = await renderViaTransition(url, () =>
549
+ const { headElements } = await renderViaTransition(url, () =>
543
550
  performNavigationFetch(url, {
544
551
  replace,
545
552
  signal: navAbort.signal,
@@ -547,10 +554,6 @@ export function createRouter(deps: RouterDeps): RouterInstance {
547
554
  })
548
555
  );
549
556
 
550
- // Await async RSC decode errors (truncated streams, Flight parse failures)
551
- // so they surface through this try/catch rather than becoming unhandled.
552
- if (decodePromise) await decodePromise;
553
-
554
557
  // Update document.title and <meta> tags with the new page's metadata
555
558
  applyHead(headElements);
556
559
 
@@ -597,6 +600,13 @@ export function createRouter(deps: RouterDeps): RouterInstance {
597
600
  window.location.href = error.url;
598
601
  return new Promise(() => {}) as never;
599
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
+ }
600
610
  // Abort errors are not application errors — swallow silently.
601
611
  if (isAbortError(error)) return;
602
612
  throw error;
@@ -619,13 +629,12 @@ export function createRouter(deps: RouterDeps): RouterInstance {
619
629
 
620
630
  async function refresh(): Promise<void> {
621
631
  const currentUrl = deps.getCurrentUrl();
622
- navigationId++;
623
632
  const navAbort = createNavAbort();
624
633
 
625
634
  setPending(true, currentUrl);
626
635
 
627
636
  try {
628
- const { headElements, decodePromise } = await renderViaTransition(currentUrl, async () => {
637
+ const { headElements } = await renderViaTransition(currentUrl, async () => {
629
638
  // No state tree sent — server renders the complete RSC payload
630
639
  const result = await fetchRscPayload(
631
640
  currentUrl,
@@ -640,9 +649,13 @@ export function createRouter(deps: RouterDeps): RouterInstance {
640
649
  return { ...result, navState };
641
650
  });
642
651
 
643
- if (decodePromise) await decodePromise;
644
652
  applyHead(headElements);
645
653
  } catch (error) {
654
+ if (isStaleClientReference(error)) {
655
+ setHardNavigating(true);
656
+ triggerStaleReload();
657
+ return new Promise(() => {}) as never;
658
+ }
646
659
  // Stale transition (superseded by a newer navigation) or aborted
647
660
  // fetch — silently ignore. See TIM-629.
648
661
  if (isAbortError(error)) return;
@@ -661,7 +674,6 @@ export function createRouter(deps: RouterDeps): RouterInstance {
661
674
  scrollY: number = 0,
662
675
  externalSignal?: AbortSignal
663
676
  ): Promise<void> {
664
- navigationId++;
665
677
  // Scroll position is read from history.state by the caller (browser-entry.ts)
666
678
  // and passed in. This is more reliable than tracking scroll per-URL in memory
667
679
  // because the browser maintains per-entry state even with duplicate URLs.
@@ -681,10 +693,8 @@ export function createRouter(deps: RouterDeps): RouterInstance {
681
693
  const navAbort = createNavAbort(externalSignal);
682
694
  setPending(true, url);
683
695
  try {
684
- const { headElements, decodePromise } = await renderViaTransition(url, async () => {
685
- const stateTree = segmentCache.serializeStateTree(
686
- segmentElementCache.getMergeablePaths()
687
- );
696
+ const { headElements } = await renderViaTransition(url, async () => {
697
+ const stateTree = segmentCache.serializeStateTree();
688
698
  const result = await fetchRscPayload(url, deps, stateTree, undefined, navAbort.signal);
689
699
  updateSegmentCache(result.segmentInfo);
690
700
  const navState = updateNavigationState(result.params, url);
@@ -692,10 +702,14 @@ export function createRouter(deps: RouterDeps): RouterInstance {
692
702
  return { ...result, navState };
693
703
  });
694
704
 
695
- if (decodePromise) await decodePromise;
696
705
  applyHead(headElements);
697
706
  restoreScrollAfterPaint(scrollY);
698
707
  } catch (error) {
708
+ if (isStaleClientReference(error)) {
709
+ setHardNavigating(true);
710
+ triggerStaleReload();
711
+ return new Promise(() => {}) as never;
712
+ }
699
713
  // Stale transition (superseded by a newer navigation) or aborted
700
714
  // fetch — silently ignore. See TIM-629.
701
715
  if (isAbortError(error)) return;
@@ -719,9 +733,10 @@ export function createRouter(deps: RouterDeps): RouterInstance {
719
733
  if (historyStack.has(url)) return;
720
734
 
721
735
  // Fire-and-forget fetch
722
- const stateTree = segmentCache.serializeStateTree(segmentElementCache.getMergeablePaths());
736
+ const stateTree = segmentCache.serializeStateTree();
723
737
  void fetchRscPayload(url, deps, stateTree).then(
724
738
  (result) => {
739
+ result.decodePromise?.catch(() => {});
725
740
  prefetchCache.set(url, result);
726
741
  },
727
742
  () => {
@@ -752,7 +767,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
752
767
  payload: element,
753
768
  headElements,
754
769
  });
755
- deferSegmentCaching(element);
770
+
756
771
  // Revalidation doesn't change params/pathname — preserve current state.
757
772
  // DO NOT call updateNavigationState(null, ...) here: that normalizes
758
773
  // params to {}, clearing dynamic route params on every action response.
@@ -761,7 +776,6 @@ export function createRouter(deps: RouterDeps): RouterInstance {
761
776
  applyHead(headElements);
762
777
  },
763
778
  initSegmentCache: (segments: SegmentInfo[]) => updateSegmentCache(segments),
764
- cacheElementTree: (element: unknown) => cacheSegmentElements(element, segmentElementCache),
765
779
  segmentCache,
766
780
  prefetchCache,
767
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;