@timber-js/app 0.1.29 → 0.1.31
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/client/index.js +138 -86
- package/dist/client/index.js.map +1 -1
- package/dist/client/link-status-provider.d.ts +4 -4
- package/dist/client/link-status-provider.d.ts.map +1 -1
- package/dist/client/pending-navigation-context.d.ts +32 -0
- package/dist/client/pending-navigation-context.d.ts.map +1 -0
- package/dist/client/router.d.ts +12 -0
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/transition-root.d.ts +33 -13
- package/dist/client/transition-root.d.ts.map +1 -1
- package/dist/client/use-navigation-pending.d.ts.map +1 -1
- package/dist/index.js +120 -7
- package/dist/index.js.map +1 -1
- package/dist/plugins/chunks.d.ts +17 -6
- package/dist/plugins/chunks.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/client/browser-entry.ts +26 -16
- package/src/client/link-status-provider.tsx +14 -24
- package/src/client/pending-navigation-context.ts +66 -0
- package/src/client/router.ts +127 -75
- package/src/client/transition-root.tsx +84 -20
- package/src/client/use-navigation-pending.ts +8 -17
- package/src/plugins/chunks.ts +145 -17
package/dist/plugins/chunks.d.ts
CHANGED
|
@@ -3,9 +3,16 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Splits client bundles into cache tiers based on update frequency:
|
|
5
5
|
*
|
|
6
|
-
* Tier 1: vendor-react
|
|
7
|
-
* Tier 2: vendor-timber
|
|
8
|
-
* Tier 3:
|
|
6
|
+
* Tier 1: vendor-react — react, react-dom, scheduler (changes rarely)
|
|
7
|
+
* Tier 2: vendor-timber — timber runtime, RSC runtime (changes per framework update)
|
|
8
|
+
* Tier 3: vendor-app — user node_modules (changes on dependency updates)
|
|
9
|
+
* Tier 4: shared-app — small shared app utilities/components (< 5KB source)
|
|
10
|
+
* Tier 5: [route]-* — per-route page/layout chunks (default Rollup splitting)
|
|
11
|
+
*
|
|
12
|
+
* The shared-app tier prevents tiny utility modules (constants, helpers,
|
|
13
|
+
* small UI components) from becoming individual chunks when shared across
|
|
14
|
+
* routes. Without this, Rolldown creates per-module chunks for any code
|
|
15
|
+
* shared between two or more entry points, producing many sub-1KB chunks.
|
|
9
16
|
*
|
|
10
17
|
* Server environments (RSC, SSR) are left to Vite's default chunking since
|
|
11
18
|
* Cloudflare Workers load all code from a single deployment bundle with no
|
|
@@ -17,8 +24,8 @@ import type { Plugin } from 'vite';
|
|
|
17
24
|
/**
|
|
18
25
|
* Categorize a module ID into a cache tier chunk name.
|
|
19
26
|
*
|
|
20
|
-
* Returns a chunk name for vendor modules
|
|
21
|
-
* Rollup's default splitting handle
|
|
27
|
+
* Returns a chunk name for vendor modules and small shared app code,
|
|
28
|
+
* or undefined to let Rollup's default splitting handle route code.
|
|
22
29
|
*/
|
|
23
30
|
export declare function assignChunk(id: string): string | undefined;
|
|
24
31
|
/**
|
|
@@ -27,7 +34,11 @@ export declare function assignChunk(id: string): string | undefined;
|
|
|
27
34
|
* The RSC plugin creates separate entry points for each 'use client' module,
|
|
28
35
|
* which manualChunks can't merge. This function is passed as the RSC plugin's
|
|
29
36
|
* `clientChunks` callback to group timber internals into a single chunk.
|
|
30
|
-
*
|
|
37
|
+
*
|
|
38
|
+
* User client components that are small (< 5KB) are grouped into shared-client
|
|
39
|
+
* to prevent thin facade wrappers from becoming individual chunks. This handles
|
|
40
|
+
* the RSC client reference facade problem where each 'use client' module gets
|
|
41
|
+
* a ~100-300 byte re-export wrapper chunk.
|
|
31
42
|
*/
|
|
32
43
|
export declare function assignClientChunk(meta: {
|
|
33
44
|
id: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"chunks.d.ts","sourceRoot":"","sources":["../../src/plugins/chunks.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"chunks.d.ts","sourceRoot":"","sources":["../../src/plugins/chunks.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAGH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,MAAM,CAAC;AAmGnC;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CA4B1D;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE;IACtC,EAAE,EAAE,MAAM,CAAC;IACX,YAAY,EAAE,MAAM,CAAC;IACrB,WAAW,EAAE,MAAM,CAAC;CACrB,GAAG,MAAM,GAAG,SAAS,CAarB;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,IAAI,MAAM,CAoBrC"}
|
package/package.json
CHANGED
|
@@ -52,7 +52,7 @@ import { isPageUnloading } from './unload-guard.js';
|
|
|
52
52
|
import { NavigationProvider, getNavigationState, setNavigationState } from './navigation-context.js';
|
|
53
53
|
import { setupServerLogReplay, setupClientErrorForwarding } from './browser-dev.js';
|
|
54
54
|
import { handleLinkClick, handleLinkHover } from './browser-links.js';
|
|
55
|
-
import { TransitionRoot, transitionRender } from './transition-root.js';
|
|
55
|
+
import { TransitionRoot, transitionRender, navigateTransition } from './transition-root.js';
|
|
56
56
|
|
|
57
57
|
// ─── Server Action Dispatch ──────────────────────────────────────
|
|
58
58
|
|
|
@@ -368,22 +368,12 @@ function bootstrap(runtimeConfig: typeof config): void {
|
|
|
368
368
|
},
|
|
369
369
|
|
|
370
370
|
// Render decoded RSC tree via TransitionRoot's state-based mechanism.
|
|
371
|
-
//
|
|
372
|
-
//
|
|
371
|
+
// Used for non-navigation renders (popstate cached replay, applyRevalidation).
|
|
372
|
+
// Wraps with NavigationProvider + TimberNuqsAdapter.
|
|
373
373
|
//
|
|
374
|
-
//
|
|
375
|
-
//
|
|
376
|
-
//
|
|
377
|
-
// RSC tree are passed to startTransition(() => setState()) in the same
|
|
378
|
-
// call — making the update atomic. Preserved layout components that call
|
|
379
|
-
// useParams() or usePathname() re-render in the same pass as the
|
|
380
|
-
// new tree, preventing the dual-active-state flash.
|
|
381
|
-
//
|
|
382
|
-
// Using transitionRender instead of reactRoot.render() enables
|
|
383
|
-
// client-side Suspense deferral: React keeps the old committed tree
|
|
384
|
-
// visible while new Suspense boundaries in the navigation resolve.
|
|
385
|
-
// This is the client-side equivalent of deferSuspenseFor on the server.
|
|
386
|
-
// See design/05-streaming.md.
|
|
374
|
+
// For navigation renders (navigate, refresh, popstate-with-fetch),
|
|
375
|
+
// navigateTransition is used instead — it wraps the entire navigation
|
|
376
|
+
// in a React transition with useOptimistic for the pending URL.
|
|
387
377
|
renderRoot: (element: unknown) => {
|
|
388
378
|
const navState = getNavigationState();
|
|
389
379
|
const withNav = createElement(NavigationProvider, { value: navState }, element as React.ReactNode);
|
|
@@ -391,6 +381,26 @@ function bootstrap(runtimeConfig: typeof config): void {
|
|
|
391
381
|
transitionRender(wrapped);
|
|
392
382
|
},
|
|
393
383
|
|
|
384
|
+
// Run a navigation inside a React transition with optimistic pending URL.
|
|
385
|
+
// The entire fetch + state update runs inside startTransition. useOptimistic
|
|
386
|
+
// shows the pending URL immediately and reverts to null when the transition
|
|
387
|
+
// commits (atomic with the new tree + params).
|
|
388
|
+
//
|
|
389
|
+
// The perform callback receives a wrapPayload function that wraps the
|
|
390
|
+
// decoded RSC payload with NavigationProvider + NuqsAdapter — this must
|
|
391
|
+
// happen inside the transition so the NavigationProvider reads the
|
|
392
|
+
// UPDATED navigation state (set by the router inside perform).
|
|
393
|
+
navigateTransition: (pendingUrl: string, perform) => {
|
|
394
|
+
return navigateTransition(pendingUrl, async () => {
|
|
395
|
+
const payload = await perform((rawPayload: unknown) => {
|
|
396
|
+
const navState = getNavigationState();
|
|
397
|
+
const withNav = createElement(NavigationProvider, { value: navState }, rawPayload as React.ReactNode);
|
|
398
|
+
return createElement(TimberNuqsAdapter, null, withNav);
|
|
399
|
+
});
|
|
400
|
+
return payload as React.ReactNode;
|
|
401
|
+
});
|
|
402
|
+
},
|
|
403
|
+
|
|
394
404
|
// Schedule a callback after the next paint so scroll operations
|
|
395
405
|
// happen after React commits the new content to the DOM.
|
|
396
406
|
// Double-rAF ensures the browser has painted the new frame.
|
|
@@ -2,39 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
// LinkStatusProvider — client component that provides per-link pending status
|
|
4
4
|
// via React context. Used inside <Link> to power useLinkStatus().
|
|
5
|
+
//
|
|
6
|
+
// Reads pendingUrl from PendingNavigationContext (provided by TransitionRoot).
|
|
7
|
+
// The pending URL is set as an URGENT update at navigation start (shows
|
|
8
|
+
// immediately) and cleared inside startTransition when the new tree commits
|
|
9
|
+
// (atomic with params/pathname). This eliminates both:
|
|
10
|
+
// 1. The delay before showing the spinner (urgent update, not deferred)
|
|
11
|
+
// 2. The gap between spinner disappearing and active state updating (same commit)
|
|
5
12
|
|
|
6
|
-
import {
|
|
13
|
+
import type { ReactNode } from 'react';
|
|
7
14
|
import { LinkStatusContext, type LinkStatus } from './use-link-status.js';
|
|
8
|
-
import {
|
|
15
|
+
import { usePendingNavigationUrl } from './pending-navigation-context.js';
|
|
9
16
|
|
|
10
17
|
const NOT_PENDING: LinkStatus = { pending: false };
|
|
11
18
|
const IS_PENDING: LinkStatus = { pending: true };
|
|
12
19
|
|
|
13
20
|
/**
|
|
14
|
-
* Client component that
|
|
15
|
-
* a scoped LinkStatusContext to children. Renders no extra DOM —
|
|
16
|
-
* context provider around children.
|
|
21
|
+
* Client component that reads the pending URL from PendingNavigationContext
|
|
22
|
+
* and provides a scoped LinkStatusContext to children. Renders no extra DOM —
|
|
23
|
+
* just a context provider around children.
|
|
17
24
|
*/
|
|
18
25
|
export function LinkStatusProvider({ href, children }: { href: string; children: ReactNode }) {
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
try {
|
|
22
|
-
return getRouter().onPendingChange(callback);
|
|
23
|
-
} catch {
|
|
24
|
-
return () => {};
|
|
25
|
-
}
|
|
26
|
-
},
|
|
27
|
-
() => {
|
|
28
|
-
try {
|
|
29
|
-
const pendingUrl = getRouter().getPendingUrl();
|
|
30
|
-
if (pendingUrl === href) return IS_PENDING;
|
|
31
|
-
return NOT_PENDING;
|
|
32
|
-
} catch {
|
|
33
|
-
return NOT_PENDING;
|
|
34
|
-
}
|
|
35
|
-
},
|
|
36
|
-
() => NOT_PENDING
|
|
37
|
-
);
|
|
26
|
+
const pendingUrl = usePendingNavigationUrl();
|
|
27
|
+
const status = pendingUrl === href ? IS_PENDING : NOT_PENDING;
|
|
38
28
|
|
|
39
29
|
return <LinkStatusContext.Provider value={status}>{children}</LinkStatusContext.Provider>;
|
|
40
30
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PendingNavigationContext — React context for the in-flight navigation URL.
|
|
3
|
+
*
|
|
4
|
+
* Provided by TransitionRoot. The value is the URL being navigated to,
|
|
5
|
+
* or null when idle. Used by:
|
|
6
|
+
* - LinkStatusProvider to show per-link pending spinners
|
|
7
|
+
* - useNavigationPending to return a global pending boolean
|
|
8
|
+
*
|
|
9
|
+
* The pending URL is set as an URGENT update (shows immediately) and
|
|
10
|
+
* cleared inside startTransition (commits atomically with the new tree).
|
|
11
|
+
* This ensures pending state appears instantly on navigation start and
|
|
12
|
+
* disappears in the same React commit as the new params/tree.
|
|
13
|
+
*
|
|
14
|
+
* Separate from NavigationContext (which holds params + pathname) because
|
|
15
|
+
* the pending URL is managed as React state in TransitionRoot, while
|
|
16
|
+
* params/pathname are set via module-level state read by renderRoot.
|
|
17
|
+
* Both contexts commit together in the same transition.
|
|
18
|
+
*
|
|
19
|
+
* See design/19-client-navigation.md §"NavigationContext"
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import React, { createElement, type ReactNode } from 'react';
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Lazy context initialization (same pattern as NavigationContext)
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
let _context: React.Context<string | null> | undefined;
|
|
29
|
+
|
|
30
|
+
function getOrCreateContext(): React.Context<string | null> | undefined {
|
|
31
|
+
if (_context !== undefined) return _context;
|
|
32
|
+
if (typeof React.createContext === 'function') {
|
|
33
|
+
_context = React.createContext<string | null>(null);
|
|
34
|
+
}
|
|
35
|
+
return _context;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Read the pending navigation URL from context.
|
|
40
|
+
* Returns null during SSR (no provider) or in the RSC environment.
|
|
41
|
+
* Internal — used by LinkStatusProvider and useNavigationPending.
|
|
42
|
+
*/
|
|
43
|
+
export function usePendingNavigationUrl(): string | null {
|
|
44
|
+
const ctx = getOrCreateContext();
|
|
45
|
+
if (!ctx) return null;
|
|
46
|
+
if (typeof React.useContext !== 'function') return null;
|
|
47
|
+
return React.useContext(ctx);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Provider component
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
export function PendingNavigationProvider({
|
|
55
|
+
value,
|
|
56
|
+
children,
|
|
57
|
+
}: {
|
|
58
|
+
value: string | null;
|
|
59
|
+
children?: ReactNode;
|
|
60
|
+
}): React.ReactElement {
|
|
61
|
+
const ctx = getOrCreateContext();
|
|
62
|
+
if (!ctx) {
|
|
63
|
+
return children as React.ReactElement;
|
|
64
|
+
}
|
|
65
|
+
return createElement(ctx.Provider, { value }, children);
|
|
66
|
+
}
|
package/src/client/router.ts
CHANGED
|
@@ -54,6 +54,21 @@ export interface RouterDeps {
|
|
|
54
54
|
afterPaint?: (callback: () => void) => void;
|
|
55
55
|
/** Apply resolved head elements (title, meta tags) to the DOM after navigation. */
|
|
56
56
|
applyHead?: (elements: HeadElement[]) => void;
|
|
57
|
+
/**
|
|
58
|
+
* Run a navigation inside a React transition with optimistic pending URL.
|
|
59
|
+
* The pending URL shows immediately (useOptimistic urgent update) and
|
|
60
|
+
* reverts when the transition commits (atomic with the new tree).
|
|
61
|
+
*
|
|
62
|
+
* The `perform` callback receives a `wrapPayload` function to wrap the
|
|
63
|
+
* decoded RSC payload with NavigationProvider + NuqsAdapter before
|
|
64
|
+
* TransitionRoot sets it as the new element.
|
|
65
|
+
*
|
|
66
|
+
* If not provided (tests), the router falls back to renderRoot.
|
|
67
|
+
*/
|
|
68
|
+
navigateTransition?: (
|
|
69
|
+
pendingUrl: string,
|
|
70
|
+
perform: (wrapPayload: (payload: unknown) => unknown) => Promise<unknown>,
|
|
71
|
+
) => Promise<void>;
|
|
57
72
|
}
|
|
58
73
|
|
|
59
74
|
/** Result of fetching an RSC payload — includes head elements and segment metadata. */
|
|
@@ -304,6 +319,9 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
304
319
|
if (pending === value && pendingUrl === newPendingUrl) return;
|
|
305
320
|
pending = value;
|
|
306
321
|
pendingUrl = newPendingUrl;
|
|
322
|
+
// Notify external store listeners (non-React consumers).
|
|
323
|
+
// React-facing pending state is handled by useOptimistic in
|
|
324
|
+
// TransitionRoot via navigateTransition — not this function.
|
|
307
325
|
for (const listener of pendingListeners) {
|
|
308
326
|
listener(value);
|
|
309
327
|
}
|
|
@@ -347,6 +365,31 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
347
365
|
setNavigationState({ params: resolvedParams, pathname });
|
|
348
366
|
}
|
|
349
367
|
|
|
368
|
+
/**
|
|
369
|
+
* Render a payload via navigateTransition (production) or renderRoot (tests).
|
|
370
|
+
* The perform callback should fetch data, update state, and return the payload.
|
|
371
|
+
* In production, the entire callback runs inside a React transition with
|
|
372
|
+
* useOptimistic for the pending URL. In tests, the payload is rendered directly.
|
|
373
|
+
*/
|
|
374
|
+
async function renderViaTransition(
|
|
375
|
+
pendingUrl: string,
|
|
376
|
+
perform: () => Promise<FetchResult>,
|
|
377
|
+
): Promise<HeadElement[] | null> {
|
|
378
|
+
if (deps.navigateTransition) {
|
|
379
|
+
let headElements: HeadElement[] | null = null;
|
|
380
|
+
await deps.navigateTransition(pendingUrl, async (wrapPayload) => {
|
|
381
|
+
const result = await perform();
|
|
382
|
+
headElements = result.headElements;
|
|
383
|
+
return wrapPayload(result.payload);
|
|
384
|
+
});
|
|
385
|
+
return headElements;
|
|
386
|
+
}
|
|
387
|
+
// Fallback: no transition (tests, no React tree)
|
|
388
|
+
const result = await perform();
|
|
389
|
+
renderPayload(result.payload);
|
|
390
|
+
return result.headElements;
|
|
391
|
+
}
|
|
392
|
+
|
|
350
393
|
/** Apply head elements (title, meta tags) to the DOM if available. */
|
|
351
394
|
function applyHead(elements: HeadElement[] | null | undefined): void {
|
|
352
395
|
if (elements && deps.applyHead) {
|
|
@@ -363,6 +406,60 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
363
406
|
}
|
|
364
407
|
}
|
|
365
408
|
|
|
409
|
+
/**
|
|
410
|
+
* Core navigation logic shared between the transition and fallback paths.
|
|
411
|
+
* Fetches the RSC payload, updates all state, and returns the result.
|
|
412
|
+
*/
|
|
413
|
+
async function performNavigationFetch(
|
|
414
|
+
url: string,
|
|
415
|
+
options: { replace: boolean },
|
|
416
|
+
): Promise<FetchResult> {
|
|
417
|
+
// Check prefetch cache first. PrefetchResult has optional segmentInfo/params
|
|
418
|
+
// fields — normalize to null for FetchResult compatibility.
|
|
419
|
+
const prefetched = prefetchCache.consume(url);
|
|
420
|
+
let result: FetchResult | undefined = prefetched
|
|
421
|
+
? {
|
|
422
|
+
payload: prefetched.payload,
|
|
423
|
+
headElements: prefetched.headElements,
|
|
424
|
+
segmentInfo: prefetched.segmentInfo ?? null,
|
|
425
|
+
params: prefetched.params ?? null,
|
|
426
|
+
}
|
|
427
|
+
: undefined;
|
|
428
|
+
|
|
429
|
+
if (result === undefined) {
|
|
430
|
+
// Fetch RSC payload with state tree for partial rendering.
|
|
431
|
+
// Send current URL for intercepting route resolution (modal pattern).
|
|
432
|
+
const stateTree = segmentCache.serializeStateTree();
|
|
433
|
+
const rawCurrentUrl = deps.getCurrentUrl();
|
|
434
|
+
const currentUrl = rawCurrentUrl.startsWith('http')
|
|
435
|
+
? new URL(rawCurrentUrl).pathname
|
|
436
|
+
: new URL(rawCurrentUrl, 'http://localhost').pathname;
|
|
437
|
+
result = await fetchRscPayload(url, deps, stateTree, currentUrl);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Update the browser history — replace mode overwrites the current entry
|
|
441
|
+
if (options.replace) {
|
|
442
|
+
deps.replaceState({ timber: true, scrollY: 0 }, '', url);
|
|
443
|
+
} else {
|
|
444
|
+
deps.pushState({ timber: true, scrollY: 0 }, '', url);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Store the payload in the history stack
|
|
448
|
+
historyStack.push(url, {
|
|
449
|
+
payload: result.payload,
|
|
450
|
+
headElements: result.headElements,
|
|
451
|
+
params: result.params,
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
// Update the segment cache with the new route's segment tree.
|
|
455
|
+
updateSegmentCache(result.segmentInfo);
|
|
456
|
+
|
|
457
|
+
// Update navigation state (params + pathname) before rendering.
|
|
458
|
+
updateNavigationState(result.params, url);
|
|
459
|
+
|
|
460
|
+
return result;
|
|
461
|
+
}
|
|
462
|
+
|
|
366
463
|
async function navigate(url: string, options: NavigationOptions = {}): Promise<void> {
|
|
367
464
|
const scroll = options.scroll !== false;
|
|
368
465
|
const replace = options.replace === true;
|
|
@@ -378,54 +475,14 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
378
475
|
setPending(true, url);
|
|
379
476
|
|
|
380
477
|
try {
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
if (result === undefined) {
|
|
385
|
-
// Fetch RSC payload with state tree for partial rendering.
|
|
386
|
-
// Send current URL for intercepting route resolution (modal pattern).
|
|
387
|
-
const stateTree = segmentCache.serializeStateTree();
|
|
388
|
-
const rawCurrentUrl = deps.getCurrentUrl();
|
|
389
|
-
const currentUrl = rawCurrentUrl.startsWith('http')
|
|
390
|
-
? new URL(rawCurrentUrl).pathname
|
|
391
|
-
: new URL(rawCurrentUrl, 'http://localhost').pathname;
|
|
392
|
-
result = await fetchRscPayload(url, deps, stateTree, currentUrl);
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
// Update the browser history — replace mode overwrites the current entry
|
|
396
|
-
if (replace) {
|
|
397
|
-
deps.replaceState({ timber: true, scrollY: 0 }, '', url);
|
|
398
|
-
} else {
|
|
399
|
-
deps.pushState({ timber: true, scrollY: 0 }, '', url);
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
// Store the payload in the history stack
|
|
403
|
-
historyStack.push(url, {
|
|
404
|
-
payload: result.payload,
|
|
405
|
-
headElements: result.headElements,
|
|
406
|
-
params: result.params,
|
|
407
|
-
});
|
|
408
|
-
|
|
409
|
-
// Update the segment cache with the new route's segment tree.
|
|
410
|
-
// This must happen before the next navigation so the state tree
|
|
411
|
-
// header reflects the currently mounted segments.
|
|
412
|
-
updateSegmentCache(result.segmentInfo);
|
|
413
|
-
|
|
414
|
-
// Update navigation state (params + pathname) before rendering.
|
|
415
|
-
// The renderRoot callback reads this state and wraps the RSC element
|
|
416
|
-
// in NavigationProvider — so the context value and the element tree
|
|
417
|
-
// are passed to reactRoot.render() in the same call, making the
|
|
418
|
-
// update atomic. Preserved layouts see new params in the same render
|
|
419
|
-
// pass as the new tree, preventing the dual-active-row flash.
|
|
420
|
-
updateNavigationState(result.params, url);
|
|
421
|
-
renderPayload(result.payload);
|
|
478
|
+
const headElements = await renderViaTransition(url, () =>
|
|
479
|
+
performNavigationFetch(url, { replace }),
|
|
480
|
+
);
|
|
422
481
|
|
|
423
482
|
// Update document.title and <meta> tags with the new page's metadata
|
|
424
|
-
applyHead(
|
|
483
|
+
applyHead(headElements);
|
|
425
484
|
|
|
426
485
|
// Notify nuqs adapter (and any other listeners) that navigation completed.
|
|
427
|
-
// The nuqs adapter syncs its searchParams state from window.location.search
|
|
428
|
-
// on this event so URL-bound inputs reflect the new URL after navigation.
|
|
429
486
|
window.dispatchEvent(new Event('timber:navigation-end'));
|
|
430
487
|
|
|
431
488
|
// Scroll-to-top on forward navigation, or restore captured position
|
|
@@ -441,17 +498,12 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
441
498
|
});
|
|
442
499
|
} catch (error) {
|
|
443
500
|
// Server-side redirect during RSC fetch → soft router navigation.
|
|
444
|
-
// access.ts called redirect() — the server returns X-Timber-Redirect
|
|
445
|
-
// header, and fetchRscPayload throws RedirectError. We re-navigate
|
|
446
|
-
// to the redirect target using the router for a seamless SPA transition.
|
|
447
501
|
if (error instanceof RedirectError) {
|
|
448
502
|
setPending(false);
|
|
449
503
|
await navigate(error.redirectUrl, { replace: true });
|
|
450
504
|
return;
|
|
451
505
|
}
|
|
452
|
-
// Abort errors
|
|
453
|
-
// while the RSC payload was loading) are not application errors.
|
|
454
|
-
// Swallow them silently — the page is being replaced.
|
|
506
|
+
// Abort errors are not application errors — swallow silently.
|
|
455
507
|
if (isAbortError(error)) return;
|
|
456
508
|
throw error;
|
|
457
509
|
} finally {
|
|
@@ -465,23 +517,20 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
465
517
|
setPending(true, currentUrl);
|
|
466
518
|
|
|
467
519
|
try {
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
520
|
+
const headElements = await renderViaTransition(currentUrl, async () => {
|
|
521
|
+
// No state tree sent — server renders the complete RSC payload
|
|
522
|
+
const result = await fetchRscPayload(currentUrl, deps);
|
|
523
|
+
historyStack.push(currentUrl, {
|
|
524
|
+
payload: result.payload,
|
|
525
|
+
headElements: result.headElements,
|
|
526
|
+
params: result.params,
|
|
527
|
+
});
|
|
528
|
+
updateSegmentCache(result.segmentInfo);
|
|
529
|
+
updateNavigationState(result.params, currentUrl);
|
|
530
|
+
return result;
|
|
476
531
|
});
|
|
477
532
|
|
|
478
|
-
|
|
479
|
-
updateSegmentCache(result.segmentInfo);
|
|
480
|
-
|
|
481
|
-
// Atomic update — see navigate() for rationale on NavigationProvider.
|
|
482
|
-
updateNavigationState(result.params, currentUrl);
|
|
483
|
-
renderPayload(result.payload);
|
|
484
|
-
applyHead(result.headElements);
|
|
533
|
+
applyHead(headElements);
|
|
485
534
|
} finally {
|
|
486
535
|
setPending(false);
|
|
487
536
|
}
|
|
@@ -509,17 +558,20 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
509
558
|
// or when the entry doesn't exist at all.
|
|
510
559
|
setPending(true, url);
|
|
511
560
|
try {
|
|
512
|
-
const
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
561
|
+
const headElements = await renderViaTransition(url, async () => {
|
|
562
|
+
const stateTree = segmentCache.serializeStateTree();
|
|
563
|
+
const result = await fetchRscPayload(url, deps, stateTree);
|
|
564
|
+
updateSegmentCache(result.segmentInfo);
|
|
565
|
+
updateNavigationState(result.params, url);
|
|
566
|
+
historyStack.push(url, {
|
|
567
|
+
payload: result.payload,
|
|
568
|
+
headElements: result.headElements,
|
|
569
|
+
params: result.params,
|
|
570
|
+
});
|
|
571
|
+
return result;
|
|
520
572
|
});
|
|
521
|
-
|
|
522
|
-
applyHead(
|
|
573
|
+
|
|
574
|
+
applyHead(headElements);
|
|
523
575
|
afterPaint(() => {
|
|
524
576
|
deps.scrollTo(0, scrollY);
|
|
525
577
|
window.dispatchEvent(new Event('timber:scroll-restored'));
|