@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.
- package/dist/_chunks/{actions-ZlcKx0H9.js → actions-418ygYKC.js} +2 -2
- package/dist/_chunks/{actions-ZlcKx0H9.js.map → actions-418ygYKC.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/{cli-schema-sync-VcCf1TFY.js → cli-schema-sync-DFHBj6yZ.js} +59 -12
- package/dist/_chunks/{cli-schema-sync-VcCf1TFY.js.map → cli-schema-sync-DFHBj6yZ.js.map} +1 -1
- package/dist/_chunks/cli-schema-sync-vdobDWwX.js +2 -0
- package/dist/_chunks/{define-C0V_eKiW.js → define-BJMwm0c4.js} +2 -2
- package/dist/_chunks/{define-C0V_eKiW.js.map → define-BJMwm0c4.js.map} +1 -1
- package/dist/_chunks/{navigation-root-Ds_hDtRW.js → navigation-root-CeNTCxzS.js} +6 -6
- package/dist/_chunks/navigation-root-CeNTCxzS.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-Du7u25cK.js → use-segment-params-ByGqwUuO.js} +3 -2
- package/dist/_chunks/use-segment-params-ByGqwUuO.js.map +1 -0
- package/dist/_chunks/{walkers-tkw7sDlj.js → walkers-DMSGZeCk.js} +2 -2
- package/dist/_chunks/{walkers-tkw7sDlj.js.map → walkers-DMSGZeCk.js.map} +1 -1
- package/dist/adapters/cloudflare.js +1 -1
- package/dist/adapters/nitro.js +1 -1
- package/dist/cli-schema-sync.d.ts +6 -4
- package/dist/cli-schema-sync.d.ts.map +1 -1
- package/dist/cli.d.ts +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +12 -6
- package/dist/cli.js.map +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 +4 -4
- package/dist/client/index.js.map +1 -1
- package/dist/client/internal.js +147 -301
- 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/navigation-context.d.ts +1 -0
- package/dist/client/navigation-context.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/use-search-params.d.ts +7 -5
- package/dist/client/use-search-params.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/routing/index.js +2 -2
- package/dist/segment-params/index.js +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/internal.js +58 -2
- package/dist/server/internal.js.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/cli-schema-sync.ts +80 -13
- package/src/cli.ts +18 -7
- package/src/client/browser-entry/hydrate.ts +9 -1
- package/src/client/browser-entry/index.ts +3 -1
- package/src/client/browser-entry/post-hydration.ts +1 -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 +12 -6
- package/src/client/navigation-context.ts +2 -1
- package/src/client/navigation-root.tsx +40 -18
- package/src/client/router.ts +113 -99
- 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/use-search-params.ts +25 -44
- 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/cli-schema-sync-4HcQIziG.js +0 -2
- package/dist/_chunks/navigation-root-Ds_hDtRW.js.map +0 -1
- package/dist/_chunks/use-segment-params-Du7u25cK.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
|
@@ -132,7 +132,10 @@ let _transitionRender: ((element: ReactNode) => void) | null = null;
|
|
|
132
132
|
* with the pending URL.
|
|
133
133
|
*/
|
|
134
134
|
let _navigateTransition:
|
|
135
|
-
| ((
|
|
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 = (
|
|
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
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
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 (
|
|
339
|
-
|
|
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
|
}
|
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
|
/**
|
|
@@ -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
|
|
350
|
-
const
|
|
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
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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:
|
|
385
|
+
payload: result.payload,
|
|
386
386
|
headElements: result.headElements,
|
|
387
387
|
params: result.params,
|
|
388
388
|
});
|
|
389
|
-
|
|
390
|
-
|
|
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
|
-
|
|
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
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
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,
|
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;
|