@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.
- package/dist/_chunks/{actions-418ygYKC.js → actions-DX2gkoOZ.js} +2 -2
- package/dist/_chunks/{actions-418ygYKC.js.map → actions-DX2gkoOZ.js.map} +1 -1
- package/dist/_chunks/{build-output-helper-DYOWsPjZ.js → build-output-helper-K7oUCRqU.js} +1 -1
- package/dist/_chunks/{build-output-helper-DYOWsPjZ.js.map → build-output-helper-K7oUCRqU.js.map} +1 -1
- package/dist/_chunks/{codec-registry-G9hD5LOq.js → codec-registry-DqkeAydt.js} +1 -1
- package/dist/_chunks/{codec-registry-G9hD5LOq.js.map → codec-registry-DqkeAydt.js.map} +1 -1
- package/dist/_chunks/{define-BJMwm0c4.js → define-DMq-Yjp7.js} +2 -2
- package/dist/_chunks/{define-BJMwm0c4.js.map → define-DMq-Yjp7.js.map} +1 -1
- package/dist/_chunks/{navigation-root-DaERBEGV.js → navigation-root-B5-RmsPi.js} +35 -28
- package/dist/_chunks/navigation-root-B5-RmsPi.js.map +1 -0
- package/dist/_chunks/{stale-reload-BN2kcE06.js → stale-reload-CCqb62Z3.js} +22 -2
- package/dist/_chunks/{stale-reload-BN2kcE06.js.map → stale-reload-CCqb62Z3.js.map} +1 -1
- package/dist/_chunks/{use-segment-params-ByGqwUuO.js → use-segment-params-CIeinIx2.js} +2 -32
- package/dist/_chunks/{use-segment-params-ByGqwUuO.js.map → use-segment-params-CIeinIx2.js.map} +1 -1
- package/dist/adapters/cloudflare.js +1 -1
- package/dist/adapters/nitro.js +1 -1
- package/dist/client/browser-entry/hydrate.d.ts.map +1 -1
- package/dist/client/browser-entry/post-hydration.d.ts.map +1 -1
- package/dist/client/browser-entry/router-init.d.ts +4 -1
- package/dist/client/browser-entry/router-init.d.ts.map +1 -1
- package/dist/client/index.js +35 -79
- package/dist/client/index.js.map +1 -1
- package/dist/client/internal.js +128 -270
- package/dist/client/internal.js.map +1 -1
- package/dist/client/link-pending-store.d.ts +19 -17
- package/dist/client/link-pending-store.d.ts.map +1 -1
- package/dist/client/link.d.ts.map +1 -1
- package/dist/client/nav-link-store.d.ts +3 -12
- package/dist/client/nav-link-store.d.ts.map +1 -1
- package/dist/client/navigation-root.d.ts +4 -1
- package/dist/client/navigation-root.d.ts.map +1 -1
- package/dist/client/router.d.ts +10 -7
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/rsc-fetch.d.ts.map +1 -1
- package/dist/client/segment-cache.d.ts +2 -0
- package/dist/client/segment-cache.d.ts.map +1 -1
- package/dist/client/segment-outlet.d.ts +23 -37
- package/dist/client/segment-outlet.d.ts.map +1 -1
- package/dist/client/segment-update-context.d.ts +21 -0
- package/dist/client/segment-update-context.d.ts.map +1 -0
- package/dist/client/top-loader.d.ts.map +1 -1
- package/dist/client/use-pending-navigation.d.ts +3 -6
- package/dist/client/use-pending-navigation.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/params/index.js +1 -1
- package/dist/segment-params/index.js +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/internal.js +75 -4
- package/dist/server/internal.js.map +1 -1
- package/dist/server/pipeline-outcome.d.ts.map +1 -1
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/rsc-entry/helpers.d.ts +1 -0
- package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
- package/dist/server/state-tree-diff.d.ts +12 -5
- package/dist/server/state-tree-diff.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/client/browser-entry/hydrate.ts +7 -1
- package/src/client/browser-entry/index.ts +3 -1
- package/src/client/browser-entry/post-hydration.ts +0 -13
- package/src/client/browser-entry/router-init.ts +50 -24
- package/src/client/link-pending-store.ts +22 -17
- package/src/client/link.tsx +30 -79
- package/src/client/nav-link-store.ts +3 -13
- package/src/client/navigation-root.tsx +28 -23
- package/src/client/router.ts +109 -97
- package/src/client/rsc-fetch.ts +85 -6
- package/src/client/segment-cache.ts +5 -2
- package/src/client/segment-outlet.tsx +40 -49
- package/src/client/segment-update-context.ts +42 -0
- package/src/client/top-loader.tsx +5 -15
- package/src/client/use-pending-navigation.ts +18 -16
- package/src/server/pipeline-outcome.ts +23 -2
- package/src/server/route-element-builder.ts +69 -2
- package/src/server/rsc-entry/helpers.ts +18 -14
- package/src/server/state-tree-diff.ts +26 -22
- package/dist/_chunks/navigation-root-DaERBEGV.js.map +0 -1
- package/dist/client/segment-merger.d.ts +0 -119
- package/dist/client/segment-merger.d.ts.map +0 -1
- package/src/client/segment-merger.ts +0 -339
package/src/client/router.ts
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
getNavigationState,
|
|
12
12
|
type NavigationState,
|
|
13
13
|
} from './navigation-context.js';
|
|
14
|
-
|
|
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: (
|
|
104
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
316
|
-
*
|
|
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
|
|
321
|
-
const
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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:
|
|
385
|
+
payload: result.payload,
|
|
388
386
|
headElements: result.headElements,
|
|
389
387
|
params: result.params,
|
|
390
388
|
});
|
|
391
|
-
|
|
392
|
-
|
|
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
|
-
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
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,
|
package/src/client/rsc-fetch.ts
CHANGED
|
@@ -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
|
-
//
|
|
330
|
-
//
|
|
331
|
-
|
|
332
|
-
//
|
|
333
|
-
|
|
334
|
-
|
|
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:
|
|
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(
|
|
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
|
-
*
|
|
5
|
-
*
|
|
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
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
-
*
|
|
19
|
-
*
|
|
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:
|
|
60
|
-
* On partial navigation
|
|
61
|
-
*
|
|
62
|
-
*
|
|
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
|
|
65
|
-
*
|
|
66
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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
|
}
|