@timber-js/app 0.2.0-alpha.165 → 0.2.0-alpha.167
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-CSDD6x7U.js → actions-TSxpXLHJ.js} +3 -3
- package/dist/_chunks/{actions-CSDD6x7U.js.map → actions-TSxpXLHJ.js.map} +1 -1
- package/dist/_chunks/{cache-api-eb1gydM7.js → cache-api-DzpQQOEx.js} +53 -49
- package/dist/_chunks/{cache-api-eb1gydM7.js.map → cache-api-DzpQQOEx.js.map} +1 -1
- package/dist/_chunks/{cli-schema-sync-mGfRbjh2.js → cli-schema-sync-wX-i90Og.js} +6 -4
- package/dist/_chunks/cli-schema-sync-wX-i90Og.js.map +1 -0
- package/dist/_chunks/cloudflare-AHoWYTYr.js +1188 -0
- package/dist/_chunks/cloudflare-AHoWYTYr.js.map +1 -0
- package/dist/_chunks/{define-CFmvb4Bt.js → define-COtkxMRT.js} +16 -28
- package/dist/_chunks/define-COtkxMRT.js.map +1 -0
- package/dist/_chunks/{define-Bssfp6ot.js → define-c4au4I9R.js} +2 -2
- package/dist/_chunks/{define-Bssfp6ot.js.map → define-c4au4I9R.js.map} +1 -1
- package/dist/_chunks/{logger-B_O6-mdJ.js → logger-t3uxAmbX.js} +3 -3
- package/dist/_chunks/logger-t3uxAmbX.js.map +1 -0
- package/dist/_chunks/{plugin-context-BnaiU_cF.js → plugin-context---kTF5v8.js} +2 -2
- package/dist/_chunks/{plugin-context-BnaiU_cF.js.map → plugin-context---kTF5v8.js.map} +1 -1
- package/dist/_chunks/{resolve-schema-3iUvBV5T.js → resolve-schema-Dz3fcFUo.js} +2 -2
- package/dist/_chunks/{resolve-schema-3iUvBV5T.js.map → resolve-schema-Dz3fcFUo.js.map} +1 -1
- package/dist/_chunks/{schema-bridge-BY3QLBL7.js → schema-bridge-DT_Tn0Xf.js} +2 -2
- package/dist/_chunks/{schema-bridge-BY3QLBL7.js.map → schema-bridge-DT_Tn0Xf.js.map} +1 -1
- package/dist/_chunks/{use-query-states-CbeQmext.js → use-query-states-DFvWd-EA.js} +65 -7
- package/dist/_chunks/use-query-states-DFvWd-EA.js.map +1 -0
- package/dist/_chunks/{walkers-BL3MCMgO.js → walkers-Cfwvl-UC.js} +3 -3
- package/dist/_chunks/{walkers-BL3MCMgO.js.map → walkers-Cfwvl-UC.js.map} +1 -1
- package/dist/adapters/cloudflare-dev.js +1 -1
- package/dist/adapters/cloudflare-kv-cache.js +1 -1
- package/dist/adapters/cloudflare.d.ts +12 -1
- package/dist/adapters/cloudflare.d.ts.map +1 -1
- package/dist/adapters/cloudflare.js +2 -461
- package/dist/adapters/nitro.js +1 -1
- package/dist/adapters/types.d.ts +2 -0
- package/dist/adapters/types.d.ts.map +1 -1
- package/dist/cache/cache-api.d.ts +33 -11
- package/dist/cache/cache-api.d.ts.map +1 -1
- package/dist/cache/index.js +1 -1
- package/dist/cli.js +2 -2
- package/dist/client/browser-entry/post-hydration.d.ts.map +1 -1
- package/dist/client/history.d.ts +10 -0
- package/dist/client/history.d.ts.map +1 -1
- package/dist/client/internal.js +139 -115
- package/dist/client/internal.js.map +1 -1
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/use-query-states.d.ts.map +1 -1
- package/dist/codec.js +1 -1
- package/dist/cookies/index.js +1 -1
- package/dist/dev-tools/logs.d.ts +15 -1
- package/dist/dev-tools/logs.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +138 -45
- package/dist/index.js.map +1 -1
- package/dist/params/index.js +1 -1
- package/dist/plugins/adapter-build.d.ts.map +1 -1
- package/dist/plugins/cache.d.ts +8 -8
- package/dist/plugins/cache.d.ts.map +1 -1
- package/dist/routing/index.js +2 -2
- package/dist/schema-bridge.d.ts.map +1 -1
- package/dist/search-params/define.d.ts +35 -4
- package/dist/search-params/define.d.ts.map +1 -1
- package/dist/search-params/index.js +3 -7
- package/dist/search-params/index.js.map +1 -1
- package/dist/search-params/wrappers.d.ts +2 -2
- package/dist/search-params/wrappers.d.ts.map +1 -1
- package/dist/segment-params/index.js +1 -1
- package/dist/server/als-registry.d.ts +7 -0
- package/dist/server/als-registry.d.ts.map +1 -1
- package/dist/server/deny-boundary.d.ts +3 -1
- package/dist/server/deny-boundary.d.ts.map +1 -1
- package/dist/server/index.js +2 -2
- package/dist/server/internal.js +12 -7
- package/dist/server/internal.js.map +1 -1
- package/dist/server/route-element-builder.d.ts +9 -0
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
- package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
- package/dist/server/rsc-entry/rsc-stream.d.ts +8 -0
- package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
- package/dist/server/stream-utils.d.ts.map +1 -1
- package/docs/api/32-api-cache.mdx +4 -4
- package/docs/api/33-api-search-params.mdx +13 -0
- package/docs/learn/00-introduction.mdx +1 -2
- package/docs/learn/09-caching.mdx +5 -5
- package/docs/more/03-coming-from-nextjs.mdx +2 -2
- package/docs/more/50-ai-agent-instructions.mdx +5 -5
- package/package.json +10 -8
- package/src/adapters/cloudflare.ts +63 -25
- package/src/adapters/types.ts +2 -0
- package/src/cache/cache-api.ts +84 -84
- package/src/cli.ts +0 -0
- package/src/client/browser-entry/post-hydration.ts +16 -9
- package/src/client/history.ts +11 -0
- package/src/client/router.ts +212 -206
- package/src/client/use-query-states.ts +99 -7
- package/src/dev-tools/logs.ts +119 -34
- package/src/index.ts +14 -1
- package/src/plugins/adapter-build.ts +1 -0
- package/src/plugins/cache.ts +45 -30
- package/src/routing/scanner.ts +7 -0
- package/src/schema-bridge.ts +4 -3
- package/src/search-params/define.ts +57 -51
- package/src/search-params/wrappers.ts +17 -26
- package/src/server/als-registry.ts +7 -0
- package/src/server/deny-boundary.ts +6 -3
- package/src/server/route-element-builder.ts +49 -8
- package/src/server/rsc-entry/render-route.ts +3 -1
- package/src/server/rsc-entry/rsc-payload.ts +21 -4
- package/src/server/rsc-entry/rsc-stream.ts +8 -0
- package/src/server/stream-utils.ts +8 -6
- package/LICENSE +0 -8
- package/dist/_chunks/cli-schema-sync-mGfRbjh2.js.map +0 -1
- package/dist/_chunks/define-CFmvb4Bt.js.map +0 -1
- package/dist/_chunks/logger-B_O6-mdJ.js.map +0 -1
- package/dist/_chunks/use-query-states-CbeQmext.js.map +0 -1
- package/dist/adapters/cloudflare.js.map +0 -1
|
@@ -38,9 +38,25 @@ export function setupPostHydration({
|
|
|
38
38
|
// runPreHydration() — without them on the entry, the cached replay on
|
|
39
39
|
// back-navigation would normalize params to {} and wipe every
|
|
40
40
|
// useSegmentParams() consumer on the initial page (TIM-1037).
|
|
41
|
+
// Populate the segment cache from server-embedded segment metadata.
|
|
42
|
+
// This enables state tree diffing from the very first client navigation.
|
|
43
|
+
// See design/19-client-navigation.md §"X-Timber-State-Tree Header"
|
|
44
|
+
const timberSegments = (self as unknown as Record<string, unknown>).__timber_segments;
|
|
45
|
+
const initialSegmentInfo = Array.isArray(timberSegments)
|
|
46
|
+
? (timberSegments as import('../segment-cache').SegmentInfo[])
|
|
47
|
+
: null;
|
|
48
|
+
if (initialSegmentInfo) {
|
|
49
|
+
router.initSegmentCache(initialSegmentInfo);
|
|
50
|
+
delete (self as unknown as Record<string, unknown>).__timber_segments;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Store the initial page in the history stack so back-button works
|
|
54
|
+
// after the first navigation. Include segmentInfo so the segment
|
|
55
|
+
// cache is restored correctly on popstate cached replay.
|
|
41
56
|
router.historyStack.push(window.location.pathname + window.location.search, {
|
|
42
57
|
payload: initialElement,
|
|
43
58
|
params: getNavigationState().params,
|
|
59
|
+
segmentInfo: initialSegmentInfo,
|
|
44
60
|
});
|
|
45
61
|
|
|
46
62
|
// Initialize scroll state for the initial entry.
|
|
@@ -52,15 +68,6 @@ export function setupPostHydration({
|
|
|
52
68
|
window.history.replaceState({ timber: true, scrollY: 0 }, '');
|
|
53
69
|
}
|
|
54
70
|
|
|
55
|
-
// Populate the segment cache from server-embedded segment metadata.
|
|
56
|
-
// This enables state tree diffing from the very first client navigation.
|
|
57
|
-
// See design/19-client-navigation.md §"X-Timber-State-Tree Header"
|
|
58
|
-
const timberSegments = (self as unknown as Record<string, unknown>).__timber_segments;
|
|
59
|
-
if (Array.isArray(timberSegments)) {
|
|
60
|
-
router.initSegmentCache(timberSegments);
|
|
61
|
-
delete (self as unknown as Record<string, unknown>).__timber_segments;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
71
|
// Register popstate handler for back/forward navigation.
|
|
65
72
|
// When Navigation API is active, the navigate event covers traversals —
|
|
66
73
|
// popstate is a no-op. When unavailable, popstate handles back/forward.
|
package/src/client/history.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// History Stack — stores RSC payloads by URL for instant back/forward navigation
|
|
2
2
|
// See design/19-client-navigation.md § History Stack
|
|
3
3
|
|
|
4
|
+
import type { SegmentInfo } from './segment-cache';
|
|
5
|
+
|
|
4
6
|
// ─── Types ───────────────────────────────────────────────────────
|
|
5
7
|
|
|
6
8
|
export interface HistoryEntry {
|
|
@@ -13,6 +15,15 @@ export interface HistoryEntry {
|
|
|
13
15
|
* and revalidation overwrites preserve the current state (TIM-1037).
|
|
14
16
|
*/
|
|
15
17
|
params?: Record<string, string | string[]> | null;
|
|
18
|
+
/**
|
|
19
|
+
* Segment metadata for this page's route. Restored into the segment cache
|
|
20
|
+
* on popstate cached replay so the next forward navigation computes a
|
|
21
|
+
* correct state tree. Without this, the segment cache retains the
|
|
22
|
+
* *previous* page's segments after back-button, causing the server to
|
|
23
|
+
* skip segments that aren't mounted — and the partial payload targets
|
|
24
|
+
* a non-existent outlet.
|
|
25
|
+
*/
|
|
26
|
+
segmentInfo?: SegmentInfo[] | null;
|
|
16
27
|
}
|
|
17
28
|
|
|
18
29
|
// ─── History Stack ───────────────────────────────────────────────
|
package/src/client/router.ts
CHANGED
|
@@ -306,6 +306,89 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
306
306
|
}
|
|
307
307
|
}
|
|
308
308
|
|
|
309
|
+
/**
|
|
310
|
+
* Atomically update all navigation-owned state for a new page. Every
|
|
311
|
+
* code path that changes the "current page" must go through this
|
|
312
|
+
* function — making "forgot a field" impossible by construction.
|
|
313
|
+
*
|
|
314
|
+
* The three operations:
|
|
315
|
+
* 1. Segment cache — update from server-provided segment metadata
|
|
316
|
+
* 2. Navigation state — params + pathname for useSegmentParams/usePathname
|
|
317
|
+
* 3. History stack — store the payload for instant back/forward replay
|
|
318
|
+
*/
|
|
319
|
+
function commitNavigation(
|
|
320
|
+
url: string,
|
|
321
|
+
opts: {
|
|
322
|
+
payload: unknown;
|
|
323
|
+
params?: Record<string, string | string[]> | null;
|
|
324
|
+
segmentInfo?: SegmentInfo[] | null;
|
|
325
|
+
/** When true, clear the segment cache if segmentInfo is empty
|
|
326
|
+
* (popstate replay for entries without layout metadata). */
|
|
327
|
+
clearSegmentCacheOnEmpty?: boolean;
|
|
328
|
+
}
|
|
329
|
+
): NavigationState {
|
|
330
|
+
if (opts.segmentInfo && opts.segmentInfo.length > 0) {
|
|
331
|
+
updateSegmentCache(opts.segmentInfo);
|
|
332
|
+
} else if (opts.clearSegmentCacheOnEmpty) {
|
|
333
|
+
segmentCache.clear();
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const navState = updateNavigationState(opts.params, url);
|
|
337
|
+
|
|
338
|
+
historyStack.push(url, {
|
|
339
|
+
payload: opts.payload,
|
|
340
|
+
params: navState.params,
|
|
341
|
+
segmentInfo: opts.segmentInfo,
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
return navState;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Wrap a navigation in the standard abort/pending/cleanup lifecycle.
|
|
349
|
+
* Consolidates the createNavAbort + setPending + staleness-guarded
|
|
350
|
+
* finally that was duplicated across navigate, refresh, and both
|
|
351
|
+
* handlePopState paths. AbortErrors are swallowed (not application
|
|
352
|
+
* errors); all other errors propagate to the caller.
|
|
353
|
+
*/
|
|
354
|
+
async function runNavigation(
|
|
355
|
+
url: string,
|
|
356
|
+
fn: (navAbort: AbortController) => Promise<void>,
|
|
357
|
+
externalSignal?: AbortSignal
|
|
358
|
+
): Promise<void> {
|
|
359
|
+
const navAbort = createNavAbort(externalSignal);
|
|
360
|
+
setPending(true, url);
|
|
361
|
+
try {
|
|
362
|
+
await fn(navAbort);
|
|
363
|
+
} catch (error) {
|
|
364
|
+
if (isAbortError(error)) return;
|
|
365
|
+
throw error;
|
|
366
|
+
} finally {
|
|
367
|
+
if (currentNavAbort === navAbort) {
|
|
368
|
+
currentNavAbort = null;
|
|
369
|
+
setPending(false);
|
|
370
|
+
deps.completeRouterNavigation?.();
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Resolve thenable payloads in the test/fallback path (no navigateTransition).
|
|
377
|
+
* In production, React handles thenables from createFromFetch directly via
|
|
378
|
+
* Suspense. In tests, renderRoot is a plain mock that expects resolved values.
|
|
379
|
+
*/
|
|
380
|
+
async function resolveForFallback(payload: unknown): Promise<unknown> {
|
|
381
|
+
if (
|
|
382
|
+
!deps.navigateTransition &&
|
|
383
|
+
payload != null &&
|
|
384
|
+
typeof payload === 'object' &&
|
|
385
|
+
'then' in payload
|
|
386
|
+
) {
|
|
387
|
+
return await (payload as PromiseLike<unknown>);
|
|
388
|
+
}
|
|
389
|
+
return payload;
|
|
390
|
+
}
|
|
391
|
+
|
|
309
392
|
function isPartialNavigation(skippedSegments: string[] | null | undefined): boolean {
|
|
310
393
|
return skippedSegments != null && skippedSegments.length > 0;
|
|
311
394
|
}
|
|
@@ -357,9 +440,11 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
357
440
|
|
|
358
441
|
/**
|
|
359
442
|
* Render a payload via navigateTransition (production) or renderRoot (tests).
|
|
360
|
-
* The perform callback should fetch data,
|
|
361
|
-
* FetchResult plus the NavigationState
|
|
362
|
-
*
|
|
443
|
+
* The perform callback should fetch data, call commitNavigation, and return
|
|
444
|
+
* the FetchResult plus the NavigationState.
|
|
445
|
+
*
|
|
446
|
+
* State management (segmentCache, navState, historyStack) is handled by
|
|
447
|
+
* commitNavigation inside perform — this function only handles rendering.
|
|
363
448
|
*/
|
|
364
449
|
async function renderViaTransition(
|
|
365
450
|
url: string,
|
|
@@ -372,11 +457,6 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
372
457
|
if (isPartialNavigation(result.skippedSegments)) {
|
|
373
458
|
const segmentUpdates = buildSegmentUpdates(result);
|
|
374
459
|
|
|
375
|
-
historyStack.push(url, {
|
|
376
|
-
payload: null,
|
|
377
|
-
params: result.params,
|
|
378
|
-
});
|
|
379
|
-
|
|
380
460
|
// Re-wrap the CURRENT element with new context values.
|
|
381
461
|
// SegmentOutlets read updates from SegmentUpdateContext;
|
|
382
462
|
// NavigationProvider gets new params/pathname.
|
|
@@ -389,10 +469,6 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
389
469
|
}
|
|
390
470
|
|
|
391
471
|
// Full navigation — empty updates, render the new tree.
|
|
392
|
-
historyStack.push(url, {
|
|
393
|
-
payload: result.payload,
|
|
394
|
-
params: result.params,
|
|
395
|
-
});
|
|
396
472
|
const element = wrapPayload(result.payload, result.navState);
|
|
397
473
|
return { element, decodePromise: result.decodePromise };
|
|
398
474
|
});
|
|
@@ -400,24 +476,8 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
400
476
|
}
|
|
401
477
|
// Fallback: no transition (tests, no React tree)
|
|
402
478
|
const result = await perform();
|
|
403
|
-
if (isPartialNavigation(result.skippedSegments)) {
|
|
404
|
-
|
|
405
|
-
payload: null,
|
|
406
|
-
params: result.params,
|
|
407
|
-
});
|
|
408
|
-
} else {
|
|
409
|
-
// Resolve thenables before storing/rendering in the fallback path
|
|
410
|
-
// (tests without navigateTransition). In production, React handles
|
|
411
|
-
// thenables via Suspense; in tests, renderRoot expects resolved values.
|
|
412
|
-
const payload =
|
|
413
|
-
result.payload != null && typeof result.payload === 'object' && 'then' in result.payload
|
|
414
|
-
? await (result.payload as PromiseLike<unknown>)
|
|
415
|
-
: result.payload;
|
|
416
|
-
historyStack.push(url, {
|
|
417
|
-
payload,
|
|
418
|
-
params: result.params,
|
|
419
|
-
});
|
|
420
|
-
renderPayload(payload, result.navState);
|
|
479
|
+
if (!isPartialNavigation(result.skippedSegments)) {
|
|
480
|
+
renderPayload(result.payload, result.navState);
|
|
421
481
|
}
|
|
422
482
|
}
|
|
423
483
|
|
|
@@ -505,18 +565,21 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
505
565
|
deps.setRouterNavigating?.(false);
|
|
506
566
|
}
|
|
507
567
|
|
|
508
|
-
//
|
|
509
|
-
//
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
//
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
568
|
+
// Resolve thenable payloads in the test path so popstate replay
|
|
569
|
+
// and renderPayload receive plain values (see resolveForFallback).
|
|
570
|
+
const payload = await resolveForFallback(result.payload);
|
|
571
|
+
|
|
572
|
+
// Atomically update all navigation state via commitNavigation.
|
|
573
|
+
// Partial navigations store null payload — the partial RSC tree
|
|
574
|
+
// can't be replayed standalone; popstate will fetch fresh.
|
|
575
|
+
const isPartial = isPartialNavigation(result.skippedSegments);
|
|
576
|
+
const navState = commitNavigation(url, {
|
|
577
|
+
payload: isPartial ? null : payload,
|
|
578
|
+
params: result.params,
|
|
579
|
+
segmentInfo: result.segmentInfo,
|
|
580
|
+
});
|
|
518
581
|
|
|
519
|
-
return { ...result, navState };
|
|
582
|
+
return { ...result, payload, navState };
|
|
520
583
|
}
|
|
521
584
|
|
|
522
585
|
async function navigate(url: string, options: NavigationOptions = {}): Promise<void> {
|
|
@@ -534,10 +597,6 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
534
597
|
const hash = hashIndex === -1 ? '' : url.slice(hashIndex);
|
|
535
598
|
const fetchUrl = hashIndex === -1 ? url : url.slice(0, hashIndex);
|
|
536
599
|
|
|
537
|
-
// Create an abort controller for this navigation. Links to the external
|
|
538
|
-
// signal (Navigation API's event.signal) when provided.
|
|
539
|
-
const navAbort = createNavAbort(externalSignal);
|
|
540
|
-
|
|
541
600
|
// Capture the departing page's scroll position for scroll={false} preservation.
|
|
542
601
|
const currentScrollY = deps.getScrollY();
|
|
543
602
|
|
|
@@ -549,114 +608,69 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
549
608
|
deps.replaceState({ timber: true, scrollY: currentScrollY }, '', deps.getCurrentUrl());
|
|
550
609
|
}
|
|
551
610
|
|
|
552
|
-
// When Navigation API is active, initiate the navigation via
|
|
553
|
-
// navigation.navigate() BEFORE the fetch. Unlike history.pushState()
|
|
554
|
-
// which commits the URL synchronously (so Chrome sees it as "done"),
|
|
555
|
-
// navigation.navigate() fires the navigate event before committing.
|
|
556
|
-
// Our handler intercepts with a deferred promise, and Chrome shows
|
|
557
|
-
// its native loading indicator until completeRouterNavigation()
|
|
558
|
-
// resolves it in the finally block (same time as TopLoader clears).
|
|
559
611
|
let effectiveSkipHistory = skipHistory;
|
|
560
|
-
if (!skipHistory && deps.navigationNavigate) {
|
|
561
|
-
deps.setRouterNavigating?.(true);
|
|
562
|
-
deps.navigationNavigate(url, replace);
|
|
563
|
-
deps.setRouterNavigating?.(false);
|
|
564
|
-
effectiveSkipHistory = true;
|
|
565
|
-
}
|
|
566
612
|
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
613
|
+
await runNavigation(
|
|
614
|
+
url,
|
|
615
|
+
async (navAbort) => {
|
|
616
|
+
// When Navigation API is active, initiate the navigation via
|
|
617
|
+
// navigation.navigate() BEFORE the fetch. Must happen after
|
|
618
|
+
// createNavAbort supersedes the previous navigation (done by
|
|
619
|
+
// runNavigation) so the old deferred is resolved first.
|
|
620
|
+
if (!effectiveSkipHistory && deps.navigationNavigate) {
|
|
621
|
+
deps.setRouterNavigating?.(true);
|
|
622
|
+
deps.navigationNavigate(url, replace);
|
|
623
|
+
deps.setRouterNavigating?.(false);
|
|
624
|
+
effectiveSkipHistory = true;
|
|
625
|
+
}
|
|
578
626
|
|
|
579
|
-
|
|
580
|
-
|
|
627
|
+
try {
|
|
628
|
+
await renderViaTransition(fetchUrl, () =>
|
|
629
|
+
performNavigationFetch(fetchUrl, {
|
|
630
|
+
replace,
|
|
631
|
+
commitUrl: url,
|
|
632
|
+
signal: navAbort.signal,
|
|
633
|
+
skipHistory: effectiveSkipHistory,
|
|
634
|
+
})
|
|
635
|
+
);
|
|
581
636
|
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
// §"Error Page Rendering for Client Navigation".
|
|
614
|
-
//
|
|
615
|
-
// Set hard-navigating flag BEFORE setting window.location.href:
|
|
616
|
-
// 1. Prevents Navigation API from intercepting → infinite loop
|
|
617
|
-
// 2. Causes NavigationRoot to throw unresolvedThenable → prevents
|
|
618
|
-
// React from rendering children during page teardown (avoids
|
|
619
|
-
// "Rendered more hooks" crashes). See TIM-626.
|
|
620
|
-
if (error instanceof ServerErrorResponse) {
|
|
621
|
-
setHardNavigating(true);
|
|
622
|
-
window.location.href = error.url;
|
|
623
|
-
return new Promise(() => {}) as never;
|
|
624
|
-
}
|
|
625
|
-
// Abort errors are not application errors — swallow silently.
|
|
626
|
-
if (isAbortError(error)) return;
|
|
627
|
-
throw error;
|
|
628
|
-
} finally {
|
|
629
|
-
// Staleness guard (TIM-1034): only clean up when this navigation
|
|
630
|
-
// still owns the abort controller. When a newer navigation
|
|
631
|
-
// superseded this one, createNavAbort() already replaced
|
|
632
|
-
// currentNavAbort — running setPending(false) /
|
|
633
|
-
// completeRouterNavigation() here would clear the NEWER
|
|
634
|
-
// navigation's pending state and resolve its Navigation API
|
|
635
|
-
// deferred. The superseding navigation resolved OUR deferred in
|
|
636
|
-
// createNavAbort().
|
|
637
|
-
if (currentNavAbort === navAbort) {
|
|
638
|
-
// Clear the abort controller so we don't abort a completed
|
|
639
|
-
// navigation when the next one starts. In dev mode, the RSC body
|
|
640
|
-
// stream stays open after data arrives (React's Flight client
|
|
641
|
-
// waits for debug rows). Aborting a "completed" navigation kills
|
|
642
|
-
// the open stream reader → "BodyStreamBuffer was aborted".
|
|
643
|
-
currentNavAbort = null;
|
|
644
|
-
setPending(false);
|
|
645
|
-
// Resolve the Navigation API deferred — clears the browser's
|
|
646
|
-
// native loading state (tab spinner) at the same time as the
|
|
647
|
-
// TopLoader.
|
|
648
|
-
deps.completeRouterNavigation?.();
|
|
649
|
-
}
|
|
650
|
-
}
|
|
637
|
+
// Notify nuqs adapter (and any other listeners) that navigation completed.
|
|
638
|
+
window.dispatchEvent(new Event('timber:navigation-end'));
|
|
639
|
+
|
|
640
|
+
// Scroll-to-top on forward navigation, scroll to the #fragment target
|
|
641
|
+
// when the URL has one, or restore captured position for scroll={false}.
|
|
642
|
+
if (scroll && hash) {
|
|
643
|
+
scrollToHashAfterPaint(hash);
|
|
644
|
+
} else {
|
|
645
|
+
restoreScrollAfterPaint(scroll ? 0 : currentScrollY);
|
|
646
|
+
}
|
|
647
|
+
} catch (error) {
|
|
648
|
+
if (error instanceof VersionSkewError) {
|
|
649
|
+
setHardNavigating(true);
|
|
650
|
+
window.location.reload();
|
|
651
|
+
await new Promise(() => {});
|
|
652
|
+
}
|
|
653
|
+
if (error instanceof RedirectError) {
|
|
654
|
+
if (currentNavAbort !== navAbort) return;
|
|
655
|
+
await navigate(error.redirectUrl, { replace: true });
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
if (error instanceof ServerErrorResponse) {
|
|
659
|
+
setHardNavigating(true);
|
|
660
|
+
window.location.href = error.url;
|
|
661
|
+
await new Promise(() => {});
|
|
662
|
+
}
|
|
663
|
+
throw error;
|
|
664
|
+
}
|
|
665
|
+
},
|
|
666
|
+
externalSignal
|
|
667
|
+
);
|
|
651
668
|
}
|
|
652
669
|
|
|
653
670
|
async function refresh(): Promise<void> {
|
|
654
671
|
const currentUrl = deps.getCurrentUrl();
|
|
655
|
-
const navAbort = createNavAbort();
|
|
656
|
-
|
|
657
|
-
setPending(true, currentUrl);
|
|
658
672
|
|
|
659
|
-
|
|
673
|
+
await runNavigation(currentUrl, async (navAbort) => {
|
|
660
674
|
await renderViaTransition(currentUrl, async () => {
|
|
661
675
|
// No state tree sent — server renders the complete RSC payload
|
|
662
676
|
const result = await fetchRscPayload(
|
|
@@ -666,22 +680,15 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
666
680
|
undefined,
|
|
667
681
|
navAbort.signal
|
|
668
682
|
);
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
683
|
+
const payload = await resolveForFallback(result.payload);
|
|
684
|
+
const navState = commitNavigation(currentUrl, {
|
|
685
|
+
payload,
|
|
686
|
+
params: result.params,
|
|
687
|
+
segmentInfo: result.segmentInfo,
|
|
688
|
+
});
|
|
689
|
+
return { ...result, payload, navState };
|
|
673
690
|
});
|
|
674
|
-
}
|
|
675
|
-
if (isAbortError(error)) return;
|
|
676
|
-
throw error;
|
|
677
|
-
} finally {
|
|
678
|
-
// Staleness guard (TIM-1034) — see navigate()'s finally block.
|
|
679
|
-
if (currentNavAbort === navAbort) {
|
|
680
|
-
currentNavAbort = null;
|
|
681
|
-
setPending(false);
|
|
682
|
-
deps.completeRouterNavigation?.();
|
|
683
|
-
}
|
|
684
|
-
}
|
|
691
|
+
});
|
|
685
692
|
}
|
|
686
693
|
|
|
687
694
|
async function handlePopState(
|
|
@@ -697,47 +704,51 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
697
704
|
if (entry && entry.payload !== null) {
|
|
698
705
|
// Replay cached payload — no server roundtrip.
|
|
699
706
|
//
|
|
700
|
-
//
|
|
701
|
-
//
|
|
702
|
-
//
|
|
703
|
-
//
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
707
|
+
// runNavigation supersedes any in-flight forward navigation (TIM-1022):
|
|
708
|
+
// aborts its fetch and invalidates its render transition so the stale
|
|
709
|
+
// forward payload can't commit over this replay. The replay itself is
|
|
710
|
+
// synchronous — the fn resolves immediately.
|
|
711
|
+
await runNavigation(
|
|
712
|
+
url,
|
|
713
|
+
async () => {
|
|
714
|
+
// clearSegmentCacheOnEmpty: popstate to an entry without layout
|
|
715
|
+
// metadata (e.g., initial SSR page) clears the cache so the next
|
|
716
|
+
// forward navigation gets a full render.
|
|
717
|
+
const navState = commitNavigation(url, {
|
|
718
|
+
payload: entry.payload,
|
|
719
|
+
params: entry.params,
|
|
720
|
+
segmentInfo: entry.segmentInfo,
|
|
721
|
+
clearSegmentCacheOnEmpty: true,
|
|
722
|
+
});
|
|
723
|
+
renderPayload(entry.payload, navState);
|
|
724
|
+
restoreScrollAfterPaint(scrollY);
|
|
725
|
+
},
|
|
726
|
+
externalSignal
|
|
727
|
+
);
|
|
713
728
|
} else {
|
|
714
729
|
// No cached payload — fetch from server.
|
|
715
730
|
// This happens when navigating back to the initial SSR'd page
|
|
716
731
|
// (its payload is null since it was rendered via SSR, not RSC fetch)
|
|
717
732
|
// or when the entry doesn't exist at all.
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
733
|
+
await runNavigation(
|
|
734
|
+
url,
|
|
735
|
+
async (navAbort) => {
|
|
736
|
+
await renderViaTransition(url, async () => {
|
|
737
|
+
const stateTree = segmentCache.serializeStateTree();
|
|
738
|
+
const result = await fetchRscPayload(url, deps, stateTree, undefined, navAbort.signal);
|
|
739
|
+
const payload = await resolveForFallback(result.payload);
|
|
740
|
+
const navState = commitNavigation(url, {
|
|
741
|
+
payload,
|
|
742
|
+
params: result.params,
|
|
743
|
+
segmentInfo: result.segmentInfo,
|
|
744
|
+
});
|
|
745
|
+
return { ...result, payload, navState };
|
|
746
|
+
});
|
|
729
747
|
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
} finally {
|
|
735
|
-
// Staleness guard (TIM-1034) — see navigate()'s finally block.
|
|
736
|
-
if (currentNavAbort === navAbort) {
|
|
737
|
-
currentNavAbort = null;
|
|
738
|
-
setPending(false);
|
|
739
|
-
}
|
|
740
|
-
}
|
|
748
|
+
restoreScrollAfterPaint(scrollY);
|
|
749
|
+
},
|
|
750
|
+
externalSignal
|
|
751
|
+
);
|
|
741
752
|
}
|
|
742
753
|
}
|
|
743
754
|
|
|
@@ -783,21 +794,16 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
783
794
|
// Render the piggybacked element tree from a server action response.
|
|
784
795
|
// Updates the current history entry with the fresh payload —
|
|
785
796
|
// same as refresh() but without a server fetch.
|
|
786
|
-
// Revalidation payloads are already resolved (no Flight thenable),
|
|
787
|
-
// so no segment merging is needed — just cache and render.
|
|
788
797
|
const currentUrl = deps.getCurrentUrl();
|
|
789
798
|
|
|
790
|
-
//
|
|
791
|
-
//
|
|
792
|
-
//
|
|
793
|
-
const
|
|
794
|
-
|
|
795
|
-
// Keep params on the overwritten entry — dropping them would make
|
|
796
|
-
// away-and-back navigation replay this page with params normalized
|
|
797
|
-
// to {} (TIM-1037).
|
|
798
|
-
historyStack.push(currentUrl, {
|
|
799
|
+
// Preserve existing segmentInfo so away-and-back navigation replays
|
|
800
|
+
// with a correct segment cache (TIM-1037). Preserve current params
|
|
801
|
+
// so dynamic route params aren't cleared to {}.
|
|
802
|
+
const existingEntry = historyStack.get(currentUrl);
|
|
803
|
+
const navState = commitNavigation(currentUrl, {
|
|
799
804
|
payload: element,
|
|
800
|
-
params:
|
|
805
|
+
params: getNavigationState().params,
|
|
806
|
+
segmentInfo: existingEntry?.segmentInfo,
|
|
801
807
|
});
|
|
802
808
|
renderPayload(element, navState);
|
|
803
809
|
},
|