@timber-js/app 0.1.0 → 0.1.2
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/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/plugins/entries.d.ts.map +1 -1
- package/package.json +43 -58
- package/src/adapters/cloudflare.ts +325 -0
- package/src/adapters/nitro.ts +366 -0
- package/src/adapters/types.ts +63 -0
- package/src/cache/index.ts +91 -0
- package/src/cache/redis-handler.ts +91 -0
- package/src/cache/register-cached-function.ts +99 -0
- package/src/cache/singleflight.ts +26 -0
- package/src/cache/stable-stringify.ts +21 -0
- package/src/cache/timber-cache.ts +116 -0
- package/src/cli.ts +201 -0
- package/src/client/browser-entry.ts +663 -0
- package/src/client/error-boundary.tsx +209 -0
- package/src/client/form.tsx +200 -0
- package/src/client/head.ts +61 -0
- package/src/client/history.ts +46 -0
- package/src/client/index.ts +60 -0
- package/src/client/link-navigate-interceptor.tsx +62 -0
- package/src/client/link-status-provider.tsx +40 -0
- package/src/client/link.tsx +310 -0
- package/src/client/nuqs-adapter.tsx +117 -0
- package/src/client/router-ref.ts +25 -0
- package/src/client/router.ts +563 -0
- package/src/client/segment-cache.ts +194 -0
- package/src/client/segment-context.ts +57 -0
- package/src/client/ssr-data.ts +95 -0
- package/src/client/types.ts +4 -0
- package/src/client/unload-guard.ts +34 -0
- package/src/client/use-cookie.ts +122 -0
- package/src/client/use-link-status.ts +46 -0
- package/src/client/use-navigation-pending.ts +47 -0
- package/src/client/use-params.ts +71 -0
- package/src/client/use-pathname.ts +43 -0
- package/src/client/use-query-states.ts +133 -0
- package/src/client/use-router.ts +77 -0
- package/src/client/use-search-params.ts +74 -0
- package/src/client/use-selected-layout-segment.ts +110 -0
- package/src/content/index.ts +13 -0
- package/src/cookies/define-cookie.ts +137 -0
- package/src/cookies/index.ts +9 -0
- package/src/fonts/ast.ts +359 -0
- package/src/fonts/css.ts +68 -0
- package/src/fonts/fallbacks.ts +248 -0
- package/src/fonts/google.ts +332 -0
- package/src/fonts/local.ts +177 -0
- package/src/fonts/types.ts +88 -0
- package/src/index.ts +413 -0
- package/src/plugins/adapter-build.ts +118 -0
- package/src/plugins/build-manifest.ts +323 -0
- package/src/plugins/build-report.ts +353 -0
- package/src/plugins/cache-transform.ts +199 -0
- package/src/plugins/chunks.ts +90 -0
- package/src/plugins/content.ts +136 -0
- package/src/plugins/dev-error-overlay.ts +230 -0
- package/src/plugins/dev-logs.ts +280 -0
- package/src/plugins/dev-server.ts +389 -0
- package/src/plugins/dynamic-transform.ts +161 -0
- package/src/plugins/entries.ts +207 -0
- package/src/plugins/fonts.ts +581 -0
- package/src/plugins/mdx.ts +179 -0
- package/src/plugins/react-prod.ts +56 -0
- package/src/plugins/routing.ts +419 -0
- package/src/plugins/server-action-exports.ts +220 -0
- package/src/plugins/server-bundle.ts +113 -0
- package/src/plugins/shims.ts +168 -0
- package/src/plugins/static-build.ts +207 -0
- package/src/routing/codegen.ts +396 -0
- package/src/routing/index.ts +14 -0
- package/src/routing/interception.ts +173 -0
- package/src/routing/scanner.ts +487 -0
- package/src/routing/status-file-lint.ts +114 -0
- package/src/routing/types.ts +100 -0
- package/src/search-params/analyze.ts +192 -0
- package/src/search-params/codecs.ts +153 -0
- package/src/search-params/create.ts +314 -0
- package/src/search-params/index.ts +23 -0
- package/src/search-params/registry.ts +31 -0
- package/src/server/access-gate.tsx +142 -0
- package/src/server/action-client.ts +473 -0
- package/src/server/action-handler.ts +325 -0
- package/src/server/actions.ts +236 -0
- package/src/server/asset-headers.ts +81 -0
- package/src/server/body-limits.ts +102 -0
- package/src/server/build-manifest.ts +234 -0
- package/src/server/canonicalize.ts +90 -0
- package/src/server/client-module-map.ts +58 -0
- package/src/server/csrf.ts +79 -0
- package/src/server/deny-renderer.ts +302 -0
- package/src/server/dev-logger.ts +419 -0
- package/src/server/dev-span-processor.ts +78 -0
- package/src/server/dev-warnings.ts +282 -0
- package/src/server/early-hints-sender.ts +55 -0
- package/src/server/early-hints.ts +142 -0
- package/src/server/error-boundary-wrapper.ts +69 -0
- package/src/server/error-formatter.ts +184 -0
- package/src/server/flush.ts +182 -0
- package/src/server/form-data.ts +176 -0
- package/src/server/form-flash.ts +93 -0
- package/src/server/html-injectors.ts +445 -0
- package/src/server/index.ts +222 -0
- package/src/server/instrumentation.ts +136 -0
- package/src/server/logger.ts +145 -0
- package/src/server/manifest-status-resolver.ts +215 -0
- package/src/server/metadata-render.ts +527 -0
- package/src/server/metadata-routes.ts +189 -0
- package/src/server/metadata.ts +263 -0
- package/src/server/middleware-runner.ts +32 -0
- package/src/server/nuqs-ssr-provider.tsx +63 -0
- package/src/server/pipeline.ts +555 -0
- package/src/server/prerender.ts +139 -0
- package/src/server/primitives.ts +264 -0
- package/src/server/proxy.ts +43 -0
- package/src/server/request-context.ts +554 -0
- package/src/server/route-element-builder.ts +395 -0
- package/src/server/route-handler.ts +153 -0
- package/src/server/route-matcher.ts +316 -0
- package/src/server/rsc-entry/api-handler.ts +112 -0
- package/src/server/rsc-entry/error-renderer.ts +177 -0
- package/src/server/rsc-entry/helpers.ts +147 -0
- package/src/server/rsc-entry/index.ts +688 -0
- package/src/server/rsc-entry/ssr-bridge.ts +18 -0
- package/src/server/slot-resolver.ts +359 -0
- package/src/server/ssr-entry.ts +161 -0
- package/src/server/ssr-render.ts +200 -0
- package/src/server/status-code-resolver.ts +282 -0
- package/src/server/tracing.ts +281 -0
- package/src/server/tree-builder.ts +354 -0
- package/src/server/types.ts +150 -0
- package/src/shims/font-google.ts +67 -0
- package/src/shims/headers.ts +11 -0
- package/src/shims/image.ts +48 -0
- package/src/shims/link.ts +9 -0
- package/src/shims/navigation-client.ts +52 -0
- package/src/shims/navigation.ts +31 -0
- package/src/shims/server-only-noop.js +5 -0
- package/src/utils/directive-parser.ts +529 -0
- package/src/utils/format.ts +10 -0
- package/src/utils/startup-timer.ts +102 -0
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
// Segment Router — manages client-side navigation and RSC payload fetching
|
|
2
|
+
// See design/19-client-navigation.md for the full architecture.
|
|
3
|
+
|
|
4
|
+
import { SegmentCache, PrefetchCache, buildSegmentTree } from './segment-cache';
|
|
5
|
+
import type { SegmentInfo } from './segment-cache';
|
|
6
|
+
import { HistoryStack } from './history';
|
|
7
|
+
import type { HeadElement } from './head';
|
|
8
|
+
import { setCurrentParams } from './use-params.js';
|
|
9
|
+
|
|
10
|
+
// ─── Types ───────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
export interface NavigationOptions {
|
|
13
|
+
/** Set to false to prevent scroll-to-top on forward navigation */
|
|
14
|
+
scroll?: boolean;
|
|
15
|
+
/** Use replaceState instead of pushState (replaces current history entry) */
|
|
16
|
+
replace?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Function that decodes an RSC Flight stream into a React element tree.
|
|
21
|
+
* In production: createFromFetch from @vitejs/plugin-rsc/browser.
|
|
22
|
+
* In tests: a mock that returns the raw payload.
|
|
23
|
+
*/
|
|
24
|
+
export type RscDecoder = (fetchPromise: Promise<Response>) => unknown;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Function that renders a decoded RSC element tree into the DOM.
|
|
28
|
+
* In production: reactRoot.render(element).
|
|
29
|
+
* In tests: a no-op or mock.
|
|
30
|
+
*/
|
|
31
|
+
export type RootRenderer = (element: unknown) => void;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Platform dependencies injected for testability. In production these
|
|
35
|
+
* map to browser APIs; in tests they're replaced with mocks.
|
|
36
|
+
*/
|
|
37
|
+
export interface RouterDeps {
|
|
38
|
+
fetch: (url: string, init: RequestInit) => Promise<Response>;
|
|
39
|
+
pushState: (data: unknown, unused: string, url: string) => void;
|
|
40
|
+
replaceState: (data: unknown, unused: string, url: string) => void;
|
|
41
|
+
scrollTo: (x: number, y: number) => void;
|
|
42
|
+
getCurrentUrl: () => string;
|
|
43
|
+
getScrollY: () => number;
|
|
44
|
+
/** Decode RSC Flight stream into React elements. If not provided, raw response text is stored. */
|
|
45
|
+
decodeRsc?: RscDecoder;
|
|
46
|
+
/** Render decoded RSC tree into the DOM. If not provided, rendering is a no-op. */
|
|
47
|
+
renderRoot?: RootRenderer;
|
|
48
|
+
/**
|
|
49
|
+
* Schedule a callback after the next paint. In the browser, this is
|
|
50
|
+
* requestAnimationFrame + setTimeout(0) to run after React commits.
|
|
51
|
+
* In tests, this runs the callback synchronously.
|
|
52
|
+
*/
|
|
53
|
+
afterPaint?: (callback: () => void) => void;
|
|
54
|
+
/** Apply resolved head elements (title, meta tags) to the DOM after navigation. */
|
|
55
|
+
applyHead?: (elements: HeadElement[]) => void;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Result of fetching an RSC payload — includes head elements and segment metadata. */
|
|
59
|
+
interface FetchResult {
|
|
60
|
+
payload: unknown;
|
|
61
|
+
headElements: HeadElement[] | null;
|
|
62
|
+
/** Segment metadata from X-Timber-Segments header for populating the segment cache. */
|
|
63
|
+
segmentInfo: SegmentInfo[] | null;
|
|
64
|
+
/** Route params from X-Timber-Params header for populating useParams(). */
|
|
65
|
+
params: Record<string, string | string[]> | null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface RouterInstance {
|
|
69
|
+
/** Navigate to a new URL (forward navigation) */
|
|
70
|
+
navigate(url: string, options?: NavigationOptions): Promise<void>;
|
|
71
|
+
/** Full re-render of the current URL — no state tree sent */
|
|
72
|
+
refresh(): Promise<void>;
|
|
73
|
+
/** Handle a popstate event (back/forward button). scrollY is read from history.state. */
|
|
74
|
+
handlePopState(url: string, scrollY?: number): Promise<void>;
|
|
75
|
+
/** Whether a navigation is currently in flight */
|
|
76
|
+
isPending(): boolean;
|
|
77
|
+
/** The URL currently being navigated to, or null if idle */
|
|
78
|
+
getPendingUrl(): string | null;
|
|
79
|
+
/** Subscribe to pending state changes */
|
|
80
|
+
onPendingChange(listener: (pending: boolean) => void): () => void;
|
|
81
|
+
/** Prefetch an RSC payload for a URL (used by Link hover) */
|
|
82
|
+
prefetch(url: string): void;
|
|
83
|
+
/**
|
|
84
|
+
* Apply a piggybacked revalidation payload from a server action response.
|
|
85
|
+
* Renders the element tree and updates head elements without a server fetch.
|
|
86
|
+
* See design/08-forms-and-actions.md §"Single-Roundtrip Revalidation".
|
|
87
|
+
*/
|
|
88
|
+
applyRevalidation(element: unknown, headElements: HeadElement[] | null): void;
|
|
89
|
+
/**
|
|
90
|
+
* Populate the segment cache from server-provided segment metadata.
|
|
91
|
+
* Called on initial hydration with segment info embedded in the HTML.
|
|
92
|
+
*/
|
|
93
|
+
initSegmentCache(segments: SegmentInfo[]): void;
|
|
94
|
+
/** The segment cache (exposed for tests and <Link> prefetch) */
|
|
95
|
+
segmentCache: SegmentCache;
|
|
96
|
+
/** The prefetch cache (exposed for tests and <Link> prefetch) */
|
|
97
|
+
prefetchCache: PrefetchCache;
|
|
98
|
+
/** The history stack (exposed for tests) */
|
|
99
|
+
historyStack: HistoryStack;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Thrown when an RSC payload response contains X-Timber-Redirect header.
|
|
104
|
+
* Caught in navigate() to trigger a soft router navigation to the redirect target.
|
|
105
|
+
*/
|
|
106
|
+
class RedirectError extends Error {
|
|
107
|
+
readonly redirectUrl: string;
|
|
108
|
+
constructor(url: string) {
|
|
109
|
+
super(`Server redirect to ${url}`);
|
|
110
|
+
this.redirectUrl = url;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Check if an error is an abort error (connection closed / fetch aborted).
|
|
116
|
+
* Browsers throw DOMException with name 'AbortError' when a fetch is aborted.
|
|
117
|
+
*/
|
|
118
|
+
function isAbortError(error: unknown): boolean {
|
|
119
|
+
if (error instanceof DOMException && error.name === 'AbortError') return true;
|
|
120
|
+
if (error instanceof Error && error.name === 'AbortError') return true;
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ─── RSC Fetch ───────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
const RSC_CONTENT_TYPE = 'text/x-component';
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Generate a short random cache-busting ID (5 chars, a-z0-9).
|
|
130
|
+
* Matches the format Next.js uses for _rsc params.
|
|
131
|
+
*/
|
|
132
|
+
function generateCacheBustId(): string {
|
|
133
|
+
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
|
134
|
+
let id = '';
|
|
135
|
+
for (let i = 0; i < 5; i++) {
|
|
136
|
+
id += chars[(Math.random() * 36) | 0];
|
|
137
|
+
}
|
|
138
|
+
return id;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Append a `_rsc=<id>` query parameter to the URL.
|
|
143
|
+
* Follows Next.js's pattern — prevents CDN/browser from serving cached HTML
|
|
144
|
+
* for RSC navigation requests and signals that this is an RSC fetch.
|
|
145
|
+
*/
|
|
146
|
+
function appendRscParam(url: string): string {
|
|
147
|
+
const separator = url.includes('?') ? '&' : '?';
|
|
148
|
+
return `${url}${separator}_rsc=${generateCacheBustId()}`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function buildRscHeaders(
|
|
152
|
+
stateTree: { segments: string[] } | undefined,
|
|
153
|
+
currentUrl?: string
|
|
154
|
+
): Record<string, string> {
|
|
155
|
+
const headers: Record<string, string> = {
|
|
156
|
+
Accept: RSC_CONTENT_TYPE,
|
|
157
|
+
};
|
|
158
|
+
if (stateTree) {
|
|
159
|
+
headers['X-Timber-State-Tree'] = JSON.stringify(stateTree);
|
|
160
|
+
}
|
|
161
|
+
// Send current URL for intercepting route resolution.
|
|
162
|
+
// The server uses this to determine if an intercepting route should
|
|
163
|
+
// render instead of the actual target route (modal pattern).
|
|
164
|
+
// See design/07-routing.md §"Intercepting Routes"
|
|
165
|
+
if (currentUrl) {
|
|
166
|
+
headers['X-Timber-URL'] = currentUrl;
|
|
167
|
+
}
|
|
168
|
+
return headers;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Extract head elements from the X-Timber-Head response header.
|
|
173
|
+
* Returns null if the header is missing or malformed.
|
|
174
|
+
*/
|
|
175
|
+
function extractHeadElements(response: Response): HeadElement[] | null {
|
|
176
|
+
const header = response.headers.get('X-Timber-Head');
|
|
177
|
+
if (!header) return null;
|
|
178
|
+
try {
|
|
179
|
+
return JSON.parse(decodeURIComponent(header));
|
|
180
|
+
} catch {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Extract segment metadata from the X-Timber-Segments response header.
|
|
187
|
+
* Returns null if the header is missing or malformed.
|
|
188
|
+
*
|
|
189
|
+
* Format: JSON array of {path, isAsync} objects describing the rendered
|
|
190
|
+
* segment chain from root to leaf. Used to populate the client-side
|
|
191
|
+
* segment cache for state tree diffing on subsequent navigations.
|
|
192
|
+
*/
|
|
193
|
+
function extractSegmentInfo(response: Response): SegmentInfo[] | null {
|
|
194
|
+
const header = response.headers.get('X-Timber-Segments');
|
|
195
|
+
if (!header) return null;
|
|
196
|
+
try {
|
|
197
|
+
return JSON.parse(header);
|
|
198
|
+
} catch {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Extract route params from the X-Timber-Params response header.
|
|
205
|
+
* Returns null if the header is missing or malformed.
|
|
206
|
+
*
|
|
207
|
+
* Used to populate useParams() after client-side navigation.
|
|
208
|
+
*/
|
|
209
|
+
function extractParams(response: Response): Record<string, string | string[]> | null {
|
|
210
|
+
const header = response.headers.get('X-Timber-Params');
|
|
211
|
+
if (!header) return null;
|
|
212
|
+
try {
|
|
213
|
+
return JSON.parse(header);
|
|
214
|
+
} catch {
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Fetch an RSC payload from the server. If a decodeRsc function is provided,
|
|
221
|
+
* the response is decoded into a React element tree via createFromFetch.
|
|
222
|
+
* Otherwise, the raw response text is returned (test mode).
|
|
223
|
+
*
|
|
224
|
+
* Also extracts head elements from the X-Timber-Head response header
|
|
225
|
+
* so the client can update document.title and <meta> tags after navigation.
|
|
226
|
+
*/
|
|
227
|
+
async function fetchRscPayload(
|
|
228
|
+
url: string,
|
|
229
|
+
deps: RouterDeps,
|
|
230
|
+
stateTree?: { segments: string[] },
|
|
231
|
+
currentUrl?: string
|
|
232
|
+
): Promise<FetchResult> {
|
|
233
|
+
const rscUrl = appendRscParam(url);
|
|
234
|
+
const headers = buildRscHeaders(stateTree, currentUrl);
|
|
235
|
+
if (deps.decodeRsc) {
|
|
236
|
+
// Production path: use createFromFetch for streaming RSC decoding.
|
|
237
|
+
// createFromFetch takes a Promise<Response> and progressively parses
|
|
238
|
+
// the RSC Flight stream as chunks arrive.
|
|
239
|
+
//
|
|
240
|
+
// Intercept the response to read X-Timber-Head before createFromFetch
|
|
241
|
+
// consumes the body. Reading headers does NOT consume the body stream.
|
|
242
|
+
const fetchPromise = deps.fetch(rscUrl, { headers, redirect: 'manual' });
|
|
243
|
+
let headElements: HeadElement[] | null = null;
|
|
244
|
+
let segmentInfo: SegmentInfo[] | null = null;
|
|
245
|
+
let params: Record<string, string | string[]> | null = null;
|
|
246
|
+
const wrappedPromise = fetchPromise.then((response) => {
|
|
247
|
+
// Detect server-side redirects. The server returns 204 + X-Timber-Redirect
|
|
248
|
+
// for RSC payload requests instead of a raw 302, because fetch with
|
|
249
|
+
// redirect: "manual" turns 302s into opaque redirects (status 0, null body)
|
|
250
|
+
// which crashes createFromFetch when it tries to read the body stream.
|
|
251
|
+
const redirectLocation =
|
|
252
|
+
response.headers.get('X-Timber-Redirect') ||
|
|
253
|
+
(response.status >= 300 && response.status < 400 ? response.headers.get('Location') : null);
|
|
254
|
+
if (redirectLocation) {
|
|
255
|
+
throw new RedirectError(redirectLocation);
|
|
256
|
+
}
|
|
257
|
+
headElements = extractHeadElements(response);
|
|
258
|
+
segmentInfo = extractSegmentInfo(response);
|
|
259
|
+
params = extractParams(response);
|
|
260
|
+
return response;
|
|
261
|
+
});
|
|
262
|
+
// Await so headElements/segmentInfo/params are populated before we return.
|
|
263
|
+
// Also await the decoded payload — createFromFetch returns a thenable
|
|
264
|
+
// that resolves to the React element tree.
|
|
265
|
+
await wrappedPromise;
|
|
266
|
+
const payload = await deps.decodeRsc(wrappedPromise);
|
|
267
|
+
return { payload, headElements, segmentInfo, params };
|
|
268
|
+
}
|
|
269
|
+
// Test/fallback path: return raw text
|
|
270
|
+
const response = await deps.fetch(rscUrl, { headers, redirect: 'manual' });
|
|
271
|
+
// Check for redirect in test path too
|
|
272
|
+
if (response.status >= 300 && response.status < 400) {
|
|
273
|
+
const location = response.headers.get('Location');
|
|
274
|
+
if (location) {
|
|
275
|
+
throw new RedirectError(location);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return {
|
|
279
|
+
payload: await response.text(),
|
|
280
|
+
headElements: extractHeadElements(response),
|
|
281
|
+
segmentInfo: extractSegmentInfo(response),
|
|
282
|
+
params: extractParams(response),
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ─── Router Factory ──────────────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Create a router instance. In production, called once at app hydration
|
|
290
|
+
* with real browser APIs. In tests, called with mock dependencies.
|
|
291
|
+
*/
|
|
292
|
+
export function createRouter(deps: RouterDeps): RouterInstance {
|
|
293
|
+
const segmentCache = new SegmentCache();
|
|
294
|
+
const prefetchCache = new PrefetchCache();
|
|
295
|
+
const historyStack = new HistoryStack();
|
|
296
|
+
|
|
297
|
+
let pending = false;
|
|
298
|
+
let pendingUrl: string | null = null;
|
|
299
|
+
const pendingListeners = new Set<(pending: boolean) => void>();
|
|
300
|
+
|
|
301
|
+
function setPending(value: boolean, url?: string): void {
|
|
302
|
+
const newPendingUrl = value && url ? url : null;
|
|
303
|
+
if (pending === value && pendingUrl === newPendingUrl) return;
|
|
304
|
+
pending = value;
|
|
305
|
+
pendingUrl = newPendingUrl;
|
|
306
|
+
for (const listener of pendingListeners) {
|
|
307
|
+
listener(value);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Update the segment cache from server-provided segment metadata. */
|
|
312
|
+
function updateSegmentCache(segmentInfo: SegmentInfo[] | null | undefined): void {
|
|
313
|
+
if (!segmentInfo || segmentInfo.length === 0) return;
|
|
314
|
+
const tree = buildSegmentTree(segmentInfo);
|
|
315
|
+
if (tree) {
|
|
316
|
+
segmentCache.set('/', tree);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Render a decoded RSC payload into the DOM if a renderer is available. */
|
|
321
|
+
function renderPayload(payload: unknown): void {
|
|
322
|
+
if (deps.renderRoot) {
|
|
323
|
+
deps.renderRoot(payload);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Update useParams() with route params from the server response. */
|
|
328
|
+
function updateParams(params: Record<string, string | string[]> | null | undefined): void {
|
|
329
|
+
setCurrentParams(params ?? {});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Apply head elements (title, meta tags) to the DOM if available. */
|
|
333
|
+
function applyHead(elements: HeadElement[] | null | undefined): void {
|
|
334
|
+
if (elements && deps.applyHead) {
|
|
335
|
+
deps.applyHead(elements);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Run a callback after the next paint (after React commit). */
|
|
340
|
+
function afterPaint(callback: () => void): void {
|
|
341
|
+
if (deps.afterPaint) {
|
|
342
|
+
deps.afterPaint(callback);
|
|
343
|
+
} else {
|
|
344
|
+
callback();
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function navigate(url: string, options: NavigationOptions = {}): Promise<void> {
|
|
349
|
+
const scroll = options.scroll !== false;
|
|
350
|
+
const replace = options.replace === true;
|
|
351
|
+
|
|
352
|
+
// Capture the departing page's scroll position for scroll={false} preservation.
|
|
353
|
+
const currentScrollY = deps.getScrollY();
|
|
354
|
+
|
|
355
|
+
// Save the departing page's scroll position in history.state before
|
|
356
|
+
// pushing a new entry. This ensures back/forward navigation can restore
|
|
357
|
+
// the correct scroll position from the browser's per-entry state.
|
|
358
|
+
deps.replaceState({ timber: true, scrollY: currentScrollY }, '', deps.getCurrentUrl());
|
|
359
|
+
|
|
360
|
+
setPending(true, url);
|
|
361
|
+
|
|
362
|
+
try {
|
|
363
|
+
// Check prefetch cache first
|
|
364
|
+
let result = prefetchCache.consume(url);
|
|
365
|
+
|
|
366
|
+
if (result === undefined) {
|
|
367
|
+
// Fetch RSC payload with state tree for partial rendering.
|
|
368
|
+
// Send current URL for intercepting route resolution (modal pattern).
|
|
369
|
+
const stateTree = segmentCache.serializeStateTree();
|
|
370
|
+
const rawCurrentUrl = deps.getCurrentUrl();
|
|
371
|
+
const currentUrl = rawCurrentUrl.startsWith('http')
|
|
372
|
+
? new URL(rawCurrentUrl).pathname
|
|
373
|
+
: new URL(rawCurrentUrl, 'http://localhost').pathname;
|
|
374
|
+
result = await fetchRscPayload(url, deps, stateTree, currentUrl);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Update the browser history — replace mode overwrites the current entry
|
|
378
|
+
if (replace) {
|
|
379
|
+
deps.replaceState({ timber: true, scrollY: 0 }, '', url);
|
|
380
|
+
} else {
|
|
381
|
+
deps.pushState({ timber: true, scrollY: 0 }, '', url);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Store the payload in the history stack
|
|
385
|
+
historyStack.push(url, {
|
|
386
|
+
payload: result.payload,
|
|
387
|
+
headElements: result.headElements,
|
|
388
|
+
params: result.params,
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
// Update the segment cache with the new route's segment tree.
|
|
392
|
+
// This must happen before the next navigation so the state tree
|
|
393
|
+
// header reflects the currently mounted segments.
|
|
394
|
+
updateSegmentCache(result.segmentInfo);
|
|
395
|
+
|
|
396
|
+
// Update useParams() with the new route's params before rendering.
|
|
397
|
+
updateParams(result.params);
|
|
398
|
+
|
|
399
|
+
// Render the decoded RSC tree into the DOM.
|
|
400
|
+
renderPayload(result.payload);
|
|
401
|
+
|
|
402
|
+
// Update document.title and <meta> tags with the new page's metadata
|
|
403
|
+
applyHead(result.headElements);
|
|
404
|
+
|
|
405
|
+
// Notify nuqs adapter (and any other listeners) that navigation completed.
|
|
406
|
+
// The nuqs adapter syncs its searchParams state from window.location.search
|
|
407
|
+
// on this event so URL-bound inputs reflect the new URL after navigation.
|
|
408
|
+
window.dispatchEvent(new Event('timber:navigation-end'));
|
|
409
|
+
|
|
410
|
+
// Scroll-to-top on forward navigation, or restore captured position
|
|
411
|
+
// for scroll={false}. React's render() on the document root can reset
|
|
412
|
+
// scroll during DOM reconciliation, so all scroll must be actively managed.
|
|
413
|
+
afterPaint(() => {
|
|
414
|
+
if (scroll) {
|
|
415
|
+
deps.scrollTo(0, 0);
|
|
416
|
+
} else {
|
|
417
|
+
deps.scrollTo(0, currentScrollY);
|
|
418
|
+
}
|
|
419
|
+
window.dispatchEvent(new Event('timber:scroll-restored'));
|
|
420
|
+
});
|
|
421
|
+
} catch (error) {
|
|
422
|
+
// Server-side redirect during RSC fetch → soft router navigation.
|
|
423
|
+
// access.ts called redirect() — the server returns X-Timber-Redirect
|
|
424
|
+
// header, and fetchRscPayload throws RedirectError. We re-navigate
|
|
425
|
+
// to the redirect target using the router for a seamless SPA transition.
|
|
426
|
+
if (error instanceof RedirectError) {
|
|
427
|
+
setPending(false);
|
|
428
|
+
await navigate(error.redirectUrl, { replace: true });
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
// Abort errors from the fetch (user refreshed or navigated away
|
|
432
|
+
// while the RSC payload was loading) are not application errors.
|
|
433
|
+
// Swallow them silently — the page is being replaced.
|
|
434
|
+
if (isAbortError(error)) return;
|
|
435
|
+
throw error;
|
|
436
|
+
} finally {
|
|
437
|
+
setPending(false);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
async function refresh(): Promise<void> {
|
|
442
|
+
const currentUrl = deps.getCurrentUrl();
|
|
443
|
+
|
|
444
|
+
setPending(true, currentUrl);
|
|
445
|
+
|
|
446
|
+
try {
|
|
447
|
+
// No state tree sent — server renders the complete RSC payload
|
|
448
|
+
const result = await fetchRscPayload(currentUrl, deps);
|
|
449
|
+
|
|
450
|
+
// Update the history entry with the fresh payload
|
|
451
|
+
historyStack.push(currentUrl, {
|
|
452
|
+
payload: result.payload,
|
|
453
|
+
headElements: result.headElements,
|
|
454
|
+
params: result.params,
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
// Update segment cache with fresh segment info from full render
|
|
458
|
+
updateSegmentCache(result.segmentInfo);
|
|
459
|
+
|
|
460
|
+
// Update useParams() with refreshed route params
|
|
461
|
+
updateParams(result.params);
|
|
462
|
+
|
|
463
|
+
// Render the fresh RSC tree and update head elements
|
|
464
|
+
renderPayload(result.payload);
|
|
465
|
+
applyHead(result.headElements);
|
|
466
|
+
} finally {
|
|
467
|
+
setPending(false);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
async function handlePopState(url: string, scrollY: number = 0): Promise<void> {
|
|
472
|
+
// Scroll position is read from history.state by the caller (browser-entry.ts)
|
|
473
|
+
// and passed in. This is more reliable than tracking scroll per-URL in memory
|
|
474
|
+
// because the browser maintains per-entry state even with duplicate URLs.
|
|
475
|
+
const entry = historyStack.get(url);
|
|
476
|
+
|
|
477
|
+
if (entry && entry.payload !== null) {
|
|
478
|
+
// Replay cached payload — no server roundtrip
|
|
479
|
+
updateParams(entry.params);
|
|
480
|
+
renderPayload(entry.payload);
|
|
481
|
+
applyHead(entry.headElements);
|
|
482
|
+
afterPaint(() => {
|
|
483
|
+
deps.scrollTo(0, scrollY);
|
|
484
|
+
window.dispatchEvent(new Event('timber:scroll-restored'));
|
|
485
|
+
});
|
|
486
|
+
} else {
|
|
487
|
+
// No cached payload — fetch from server.
|
|
488
|
+
// This happens when navigating back to the initial SSR'd page
|
|
489
|
+
// (its payload is null since it was rendered via SSR, not RSC fetch)
|
|
490
|
+
// or when the entry doesn't exist at all.
|
|
491
|
+
setPending(true, url);
|
|
492
|
+
try {
|
|
493
|
+
const stateTree = segmentCache.serializeStateTree();
|
|
494
|
+
const result = await fetchRscPayload(url, deps, stateTree);
|
|
495
|
+
updateSegmentCache(result.segmentInfo);
|
|
496
|
+
updateParams(result.params);
|
|
497
|
+
historyStack.push(url, {
|
|
498
|
+
payload: result.payload,
|
|
499
|
+
headElements: result.headElements,
|
|
500
|
+
params: result.params,
|
|
501
|
+
});
|
|
502
|
+
renderPayload(result.payload);
|
|
503
|
+
applyHead(result.headElements);
|
|
504
|
+
afterPaint(() => {
|
|
505
|
+
deps.scrollTo(0, scrollY);
|
|
506
|
+
window.dispatchEvent(new Event('timber:scroll-restored'));
|
|
507
|
+
});
|
|
508
|
+
} finally {
|
|
509
|
+
setPending(false);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Prefetch an RSC payload for a URL and store it in the prefetch cache.
|
|
516
|
+
* Called on hover of <Link prefetch> elements.
|
|
517
|
+
*/
|
|
518
|
+
function prefetch(url: string): void {
|
|
519
|
+
// Don't prefetch if already cached
|
|
520
|
+
if (prefetchCache.get(url) !== undefined) return;
|
|
521
|
+
if (historyStack.has(url)) return;
|
|
522
|
+
|
|
523
|
+
// Fire-and-forget fetch
|
|
524
|
+
const stateTree = segmentCache.serializeStateTree();
|
|
525
|
+
void fetchRscPayload(url, deps, stateTree).then(
|
|
526
|
+
(result) => {
|
|
527
|
+
prefetchCache.set(url, result);
|
|
528
|
+
},
|
|
529
|
+
() => {
|
|
530
|
+
// Prefetch failure is non-fatal — navigation will fetch fresh
|
|
531
|
+
}
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
return {
|
|
536
|
+
navigate,
|
|
537
|
+
refresh,
|
|
538
|
+
handlePopState,
|
|
539
|
+
isPending: () => pending,
|
|
540
|
+
getPendingUrl: () => pendingUrl,
|
|
541
|
+
onPendingChange(listener) {
|
|
542
|
+
pendingListeners.add(listener);
|
|
543
|
+
return () => pendingListeners.delete(listener);
|
|
544
|
+
},
|
|
545
|
+
prefetch,
|
|
546
|
+
applyRevalidation(element: unknown, headElements: HeadElement[] | null): void {
|
|
547
|
+
// Render the piggybacked element tree from a server action response.
|
|
548
|
+
// Updates the current history entry with the fresh payload and applies
|
|
549
|
+
// head elements — same as refresh() but without a server fetch.
|
|
550
|
+
const currentUrl = deps.getCurrentUrl();
|
|
551
|
+
historyStack.push(currentUrl, {
|
|
552
|
+
payload: element,
|
|
553
|
+
headElements,
|
|
554
|
+
});
|
|
555
|
+
renderPayload(element);
|
|
556
|
+
applyHead(headElements);
|
|
557
|
+
},
|
|
558
|
+
initSegmentCache: (segments: SegmentInfo[]) => updateSegmentCache(segments),
|
|
559
|
+
segmentCache,
|
|
560
|
+
prefetchCache,
|
|
561
|
+
historyStack,
|
|
562
|
+
};
|
|
563
|
+
}
|