@timber-js/app 0.1.51 → 0.1.53
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/adapters/compress-module.d.ts.map +1 -1
- package/dist/adapters/nitro.d.ts.map +1 -1
- package/dist/adapters/nitro.js +22 -8
- package/dist/adapters/nitro.js.map +1 -1
- package/dist/client/index.js +248 -22
- package/dist/client/index.js.map +1 -1
- package/dist/client/router.d.ts +6 -0
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/rsc-fetch.d.ts +80 -0
- package/dist/client/rsc-fetch.d.ts.map +1 -0
- package/dist/client/segment-cache.d.ts +2 -0
- package/dist/client/segment-cache.d.ts.map +1 -1
- package/dist/client/segment-merger.d.ts +96 -0
- package/dist/client/segment-merger.d.ts.map +1 -0
- package/dist/client/stale-reload.d.ts +44 -0
- package/dist/client/stale-reload.d.ts.map +1 -0
- package/dist/index.js +15 -1
- package/dist/index.js.map +1 -1
- package/dist/server/compress.d.ts.map +1 -1
- package/dist/server/route-element-builder.d.ts +7 -0
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/rsc-entry/rsc-payload.d.ts +1 -1
- package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/adapters/compress-module.ts +18 -5
- package/src/adapters/nitro.ts +4 -3
- package/src/client/browser-entry.ts +68 -8
- package/src/client/router.ts +48 -188
- package/src/client/rsc-fetch.ts +234 -0
- package/src/client/segment-cache.ts +2 -0
- package/src/client/segment-merger.ts +297 -0
- package/src/client/stale-reload.ts +89 -0
- package/src/server/compress.ts +23 -4
- package/src/server/route-element-builder.ts +14 -0
- package/src/server/rsc-entry/index.ts +3 -2
- package/src/server/rsc-entry/rsc-payload.ts +8 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"compress.d.ts","sourceRoot":"","sources":["../../src/server/compress.ts"],"names":[],"mappings":"AAYA;;;;GAIG;AACH,eAAO,MAAM,kBAAkB,aAc7B,CAAC;AASH;;;;;;;;;;;;;GAaG;AACH,wBAAgB,iBAAiB,CAAC,cAAc,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,
|
|
1
|
+
{"version":3,"file":"compress.d.ts","sourceRoot":"","sources":["../../src/server/compress.ts"],"names":[],"mappings":"AAYA;;;;GAIG;AACH,eAAO,MAAM,kBAAkB,aAc7B,CAAC;AASH;;;;;;;;;;;;;GAaG;AACH,wBAAgB,iBAAiB,CAAC,cAAc,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CA4BvE;AAID;;;;;;;;GAQG;AACH,wBAAgB,cAAc,CAAC,QAAQ,EAAE,QAAQ,GAAG,OAAO,CAmB1D;AAID;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,QAAQ,GAAG,QAAQ,CAiC/E"}
|
|
@@ -41,6 +41,13 @@ export interface RouteElementResult {
|
|
|
41
41
|
segments: ManifestSegmentNode[];
|
|
42
42
|
/** Max deferSuspenseFor hold window across all segments. */
|
|
43
43
|
deferSuspenseFor: number;
|
|
44
|
+
/**
|
|
45
|
+
* Segment paths that were skipped because the client already has them cached.
|
|
46
|
+
* Ordered outermost to innermost. Empty when no segments were skipped.
|
|
47
|
+
* The client uses this to merge the partial payload with cached segments.
|
|
48
|
+
* See design/19-client-navigation.md §"X-Timber-State-Tree Header"
|
|
49
|
+
*/
|
|
50
|
+
skippedSegments: string[];
|
|
44
51
|
}
|
|
45
52
|
/**
|
|
46
53
|
* Wraps a DenySignal or RedirectSignal with the layout components loaded
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"route-element-builder.d.ts","sourceRoot":"","sources":["../../src/server/route-element-builder.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAKH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAChD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAK9D,OAAO,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAO7D,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,eAAe,CAAC;AAKzD,qDAAqD;AACrD,MAAM,WAAW,WAAW;IAC1B,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC,CAAC;CACvC;AAED,+CAA+C;AAC/C,MAAM,WAAW,oBAAoB;IACnC,SAAS,EAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,OAAO,CAAC;IAC3C,OAAO,EAAE,mBAAmB,CAAC;CAC9B;AAED,+CAA+C;AAC/C,MAAM,WAAW,kBAAkB;IACjC,wFAAwF;IACxF,OAAO,EAAE,KAAK,CAAC,YAAY,CAAC;IAC5B,2CAA2C;IAC3C,YAAY,EAAE,WAAW,EAAE,CAAC;IAC5B,wDAAwD;IACxD,gBAAgB,EAAE,oBAAoB,EAAE,CAAC;IACzC,qCAAqC;IACrC,QAAQ,EAAE,mBAAmB,EAAE,CAAC;IAChC,4DAA4D;IAC5D,gBAAgB,EAAE,MAAM,CAAC;
|
|
1
|
+
{"version":3,"file":"route-element-builder.d.ts","sourceRoot":"","sources":["../../src/server/route-element-builder.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAKH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAChD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAK9D,OAAO,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAO7D,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,eAAe,CAAC;AAKzD,qDAAqD;AACrD,MAAM,WAAW,WAAW;IAC1B,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC,CAAC;CACvC;AAED,+CAA+C;AAC/C,MAAM,WAAW,oBAAoB;IACnC,SAAS,EAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,OAAO,CAAC;IAC3C,OAAO,EAAE,mBAAmB,CAAC;CAC9B;AAED,+CAA+C;AAC/C,MAAM,WAAW,kBAAkB;IACjC,wFAAwF;IACxF,OAAO,EAAE,KAAK,CAAC,YAAY,CAAC;IAC5B,2CAA2C;IAC3C,YAAY,EAAE,WAAW,EAAE,CAAC;IAC5B,wDAAwD;IACxD,gBAAgB,EAAE,oBAAoB,EAAE,CAAC;IACzC,qCAAqC;IACrC,QAAQ,EAAE,mBAAmB,EAAE,CAAC;IAChC,4DAA4D;IAC5D,gBAAgB,EAAE,MAAM,CAAC;IACzB;;;;;OAKG;IACH,eAAe,EAAE,MAAM,EAAE,CAAC;CAC3B;AAED;;;GAGG;AACH,qBAAa,sBAAuB,SAAQ,KAAK;aAE7B,MAAM,EAAE,UAAU,GAAG,cAAc;aACnC,gBAAgB,EAAE,oBAAoB,EAAE;aACxC,QAAQ,EAAE,mBAAmB,EAAE;gBAF/B,MAAM,EAAE,UAAU,GAAG,cAAc,EACnC,gBAAgB,EAAE,oBAAoB,EAAE,EACxC,QAAQ,EAAE,mBAAmB,EAAE;CAIlD;AAID;;;;;;;;;GASG;AACH,wBAAsB,iBAAiB,CACrC,GAAG,EAAE,OAAO,EACZ,KAAK,EAAE,UAAU,EACjB,YAAY,CAAC,EAAE,mBAAmB,EAClC,eAAe,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,GACnC,OAAO,CAAC,kBAAkB,CAAC,CAuU7B"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/server/rsc-entry/index.ts"],"names":[],"mappings":"AA0EA;;;;;GAKG;AACH,wBAAgB,0BAA0B,CAAC,OAAO,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,GAAG,IAAI,CAE/F;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/server/rsc-entry/index.ts"],"names":[],"mappings":"AA0EA;;;;;GAKG;AACH,wBAAgB,0BAA0B,CAAC,OAAO,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,GAAG,IAAI,CAE/F;AAyXD,OAAO,EAAE,uBAAuB,EAAE,MAAM,gCAAgC,CAAC;AAIzE,OAAO,EAAE,gBAAgB,EAAE,MAAM,8BAA8B,CAAC;8BA7P3C,OAAO,KAAG,OAAO,CAAC,QAAQ,CAAC;AA+PhD,wBAAiE"}
|
|
@@ -21,5 +21,5 @@ import type { RenderSignals } from './rsc-stream.js';
|
|
|
21
21
|
* initial component tree, allowing onError to capture DenySignal/
|
|
22
22
|
* RedirectSignal before we commit the response. See TIM-344.
|
|
23
23
|
*/
|
|
24
|
-
export declare function buildRscPayloadResponse(req: Request, rscStream: ReadableStream<Uint8Array>, signals: RenderSignals, segments: ManifestSegmentNode[], layoutComponents: LayoutComponentEntry[], headElements: HeadElement[], match: RouteMatch, responseHeaders: Headers): Promise<Response>;
|
|
24
|
+
export declare function buildRscPayloadResponse(req: Request, rscStream: ReadableStream<Uint8Array>, signals: RenderSignals, segments: ManifestSegmentNode[], layoutComponents: LayoutComponentEntry[], headElements: HeadElement[], match: RouteMatch, responseHeaders: Headers, skippedSegments?: string[]): Promise<Response>;
|
|
25
25
|
//# sourceMappingURL=rsc-payload.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"rsc-payload.d.ts","sourceRoot":"","sources":["../../../src/server/rsc-entry/rsc-payload.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAIH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC;AAEvD,OAAO,KAAK,EAAE,WAAW,EAAE,oBAAoB,EAAE,MAAM,mCAAmC,CAAC;AAC3F,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,2BAA2B,CAAC;AAQrE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAErD;;;;;;;;GAQG;AACH,wBAAsB,uBAAuB,CAC3C,GAAG,EAAE,OAAO,EACZ,SAAS,EAAE,cAAc,CAAC,UAAU,CAAC,EACrC,OAAO,EAAE,aAAa,EACtB,QAAQ,EAAE,mBAAmB,EAAE,EAC/B,gBAAgB,EAAE,oBAAoB,EAAE,EACxC,YAAY,EAAE,WAAW,EAAE,EAC3B,KAAK,EAAE,UAAU,EACjB,eAAe,EAAE,OAAO,
|
|
1
|
+
{"version":3,"file":"rsc-payload.d.ts","sourceRoot":"","sources":["../../../src/server/rsc-entry/rsc-payload.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAIH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC;AAEvD,OAAO,KAAK,EAAE,WAAW,EAAE,oBAAoB,EAAE,MAAM,mCAAmC,CAAC;AAC3F,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,2BAA2B,CAAC;AAQrE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAErD;;;;;;;;GAQG;AACH,wBAAsB,uBAAuB,CAC3C,GAAG,EAAE,OAAO,EACZ,SAAS,EAAE,cAAc,CAAC,UAAU,CAAC,EACrC,OAAO,EAAE,aAAa,EACtB,QAAQ,EAAE,mBAAmB,EAAE,EAC/B,gBAAgB,EAAE,oBAAoB,EAAE,EACxC,YAAY,EAAE,WAAW,EAAE,EAC3B,KAAK,EAAE,UAAU,EACjB,eAAe,EAAE,OAAO,EACxB,eAAe,CAAC,EAAE,MAAM,EAAE,GACzB,OAAO,CAAC,QAAQ,CAAC,CAuFnB"}
|
package/package.json
CHANGED
|
@@ -37,12 +37,25 @@ const NO_COMPRESS_STATUSES = new Set([204, 304]);
|
|
|
37
37
|
|
|
38
38
|
function negotiateEncoding(acceptEncoding) {
|
|
39
39
|
if (!acceptEncoding) return null;
|
|
40
|
-
|
|
40
|
+
// Parse tokens with quality values. Per RFC 9110 §12.5.3, q=0 means
|
|
41
|
+
// "not acceptable" — the client explicitly rejects that encoding.
|
|
41
42
|
// Brotli (br) is intentionally not handled at the application level.
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
const parts = acceptEncoding.split(',');
|
|
44
|
+
for (const part of parts) {
|
|
45
|
+
const [token, ...params] = part.split(';');
|
|
46
|
+
const name = token.trim().toLowerCase();
|
|
47
|
+
if (name !== 'gzip') continue;
|
|
48
|
+
let qValue = 1;
|
|
49
|
+
for (const param of params) {
|
|
50
|
+
const trimmed = param.trim().toLowerCase();
|
|
51
|
+
if (trimmed.startsWith('q=')) {
|
|
52
|
+
qValue = parseFloat(trimmed.slice(2));
|
|
53
|
+
if (Number.isNaN(qValue)) qValue = 1;
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (qValue > 0) return 'gzip';
|
|
58
|
+
}
|
|
46
59
|
return null;
|
|
47
60
|
}
|
|
48
61
|
|
package/src/adapters/nitro.ts
CHANGED
|
@@ -341,19 +341,20 @@ export function generateNitroEntry(
|
|
|
341
341
|
return `// Generated by @timber-js/app/adapters/nitro
|
|
342
342
|
// Do not edit — this file is regenerated on each build.
|
|
343
343
|
|
|
344
|
-
${manifestImport}import
|
|
344
|
+
${manifestImport}import { defineEventHandler } from 'nitro/h3'
|
|
345
|
+
import handler, { runWithEarlyHintsSender${waitUntilImport} } from '${serverEntryRelative}'
|
|
345
346
|
import { compressResponse } from './_compress.mjs'
|
|
346
347
|
|
|
347
348
|
// Set TIMBER_RUNTIME for instrumentation.ts conditional SDK initialization.
|
|
348
349
|
// See design/25-production-deployments.md §"TIMBER_RUNTIME".
|
|
349
350
|
process.env.TIMBER_RUNTIME = '${runtimeName}'
|
|
350
351
|
|
|
351
|
-
export default async (event) => {
|
|
352
|
+
export default defineEventHandler(async (event) => {
|
|
352
353
|
// h3 v2: event.req is the Web Request
|
|
353
354
|
const webRequest = event.req
|
|
354
355
|
${handlerCall}
|
|
355
356
|
return compressResponse(webRequest, webResponse)
|
|
356
|
-
}
|
|
357
|
+
})
|
|
357
358
|
`;
|
|
358
359
|
}
|
|
359
360
|
|
|
@@ -52,6 +52,7 @@ import {
|
|
|
52
52
|
import { setupServerLogReplay, setupClientErrorForwarding } from './browser-dev.js';
|
|
53
53
|
import { handleLinkClick, handleLinkHover } from './browser-links.js';
|
|
54
54
|
import { TransitionRoot, transitionRender, navigateTransition } from './transition-root.js';
|
|
55
|
+
import { isStaleClientReference, triggerStaleReload, clearStaleReloadFlag } from './stale-reload.js';
|
|
55
56
|
|
|
56
57
|
// ─── Server Action Dispatch ──────────────────────────────────────
|
|
57
58
|
|
|
@@ -94,7 +95,17 @@ setServerCallback(async (id: string, args: unknown[]) => {
|
|
|
94
95
|
return res;
|
|
95
96
|
});
|
|
96
97
|
|
|
97
|
-
|
|
98
|
+
let decoded: unknown;
|
|
99
|
+
try {
|
|
100
|
+
decoded = await createFromFetch(response);
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if (isStaleClientReference(error)) {
|
|
103
|
+
triggerStaleReload();
|
|
104
|
+
// Return a never-resolving promise to prevent further processing
|
|
105
|
+
return new Promise(() => {});
|
|
106
|
+
}
|
|
107
|
+
throw error;
|
|
108
|
+
}
|
|
98
109
|
|
|
99
110
|
// Handle redirect — server encoded the redirect location in the RSC stream
|
|
100
111
|
// instead of returning HTTP 302. Perform a client-side SPA navigation.
|
|
@@ -169,25 +180,33 @@ function getScrollY(): number {
|
|
|
169
180
|
* (scrollHeight > clientHeight). Returns the first match, or null.
|
|
170
181
|
*
|
|
171
182
|
* This heuristic covers the common layout patterns:
|
|
172
|
-
* <body> → <
|
|
173
|
-
* <body> → <main
|
|
183
|
+
* <body> → <root-layout> → <div class="overflow-y-auto">
|
|
184
|
+
* <body> → <root-layout> → <main> → <nested-layout overflow-y-auto>
|
|
174
185
|
*
|
|
175
|
-
* We limit depth to avoid expensive full-tree traversals
|
|
186
|
+
* We limit depth to 3 to avoid expensive full-tree traversals while still
|
|
187
|
+
* reaching nested layout scroll containers (e.g., parallel route layouts
|
|
188
|
+
* inside a root layout's <main> element).
|
|
176
189
|
*
|
|
177
190
|
* DIVERGENCE FROM NEXT.JS: Next.js's ScrollAndFocusHandler scrolls only
|
|
178
191
|
* document.documentElement.scrollTop — it does NOT handle overflow containers.
|
|
179
192
|
* Layouts using h-screen + overflow-y-auto have the same scroll bug in Next.js.
|
|
180
|
-
* This heuristic is a deliberate improvement. The tradeoff is fragility: depth-
|
|
193
|
+
* This heuristic is a deliberate improvement. The tradeoff is fragility: depth-3
|
|
181
194
|
* traversal may miss deeply nested containers or match the wrong element.
|
|
182
195
|
* See design/19-client-navigation.md §"Overflow Scroll Containers".
|
|
183
196
|
*/
|
|
184
197
|
function findOverflowContainer(): HTMLElement | null {
|
|
185
198
|
const candidates: HTMLElement[] = [];
|
|
186
|
-
// Check body's
|
|
199
|
+
// Check body's descendants up to depth 3. Depth 3 covers the common case:
|
|
200
|
+
// <body> → <root-layout-div> → <main> → <overflow-container>
|
|
201
|
+
// React context providers (SegmentProvider, NavigationProvider) don't add
|
|
202
|
+
// DOM elements, so depth 3 from body reaches nested layout scroll containers.
|
|
187
203
|
for (const child of document.body.children) {
|
|
188
204
|
candidates.push(child as HTMLElement);
|
|
189
205
|
for (const grandchild of child.children) {
|
|
190
206
|
candidates.push(grandchild as HTMLElement);
|
|
207
|
+
for (const greatGrandchild of grandchild.children) {
|
|
208
|
+
candidates.push(greatGrandchild as HTMLElement);
|
|
209
|
+
}
|
|
191
210
|
}
|
|
192
211
|
}
|
|
193
212
|
for (const el of candidates) {
|
|
@@ -425,8 +444,24 @@ function bootstrap(runtimeConfig: typeof config): void {
|
|
|
425
444
|
// Decode RSC Flight stream using createFromFetch.
|
|
426
445
|
// createFromFetch takes a Promise<Response> and progressively
|
|
427
446
|
// parses the RSC stream as chunks arrive.
|
|
428
|
-
|
|
429
|
-
|
|
447
|
+
//
|
|
448
|
+
// Wrapped with stale client reference detection: if the server
|
|
449
|
+
// has been redeployed with new bundles, the RSC payload may
|
|
450
|
+
// reference module IDs that don't exist in the old client bundle.
|
|
451
|
+
// We catch "Could not find the module" errors and trigger a full
|
|
452
|
+
// page reload so the browser fetches the new bundle.
|
|
453
|
+
decodeRsc: async (fetchPromise: Promise<Response>) => {
|
|
454
|
+
try {
|
|
455
|
+
return await createFromFetch(fetchPromise);
|
|
456
|
+
} catch (error) {
|
|
457
|
+
if (isStaleClientReference(error)) {
|
|
458
|
+
triggerStaleReload();
|
|
459
|
+
// Return a never-resolving promise to prevent further processing
|
|
460
|
+
// while the page is reloading.
|
|
461
|
+
return new Promise(() => {});
|
|
462
|
+
}
|
|
463
|
+
throw error;
|
|
464
|
+
}
|
|
430
465
|
},
|
|
431
466
|
|
|
432
467
|
// Render decoded RSC tree via TransitionRoot's state-based mechanism.
|
|
@@ -510,6 +545,13 @@ function bootstrap(runtimeConfig: typeof config): void {
|
|
|
510
545
|
delete (self as unknown as Record<string, unknown>).__timber_segments;
|
|
511
546
|
}
|
|
512
547
|
|
|
548
|
+
// Cache segment elements from the initial RSC payload for client-side
|
|
549
|
+
// tree merging. This ensures the first client navigation can use partial
|
|
550
|
+
// payloads — the merger needs cached elements for skipped segments.
|
|
551
|
+
if (initialElement) {
|
|
552
|
+
router.cacheElementTree(initialElement);
|
|
553
|
+
}
|
|
554
|
+
|
|
513
555
|
// Note: __timber_params is read before hydrateRoot (see above) so that
|
|
514
556
|
// NavigationProvider has correct values during hydration. If the hydration
|
|
515
557
|
// path was skipped (no RSC payload), populate the fallback here.
|
|
@@ -614,6 +656,24 @@ function bootstrap(runtimeConfig: typeof config): void {
|
|
|
614
656
|
|
|
615
657
|
bootstrap(config);
|
|
616
658
|
|
|
659
|
+
// Clear the stale reload flag on successful bootstrap. If the page
|
|
660
|
+
// loaded and bootstrapped without hitting a stale reference error,
|
|
661
|
+
// the loop guard should reset so the next stale error gets a fresh
|
|
662
|
+
// reload attempt.
|
|
663
|
+
clearStaleReloadFlag();
|
|
664
|
+
|
|
665
|
+
// Global error handler for stale client reference errors during hydration.
|
|
666
|
+
// The initial RSC payload is decoded lazily by React via createFromReadableStream.
|
|
667
|
+
// If the payload references a module ID from a newer deployment, the error
|
|
668
|
+
// surfaces as an unhandled rejection during React's render/hydration cycle.
|
|
669
|
+
// This handler catches those errors and triggers a full page reload.
|
|
670
|
+
window.addEventListener('unhandledrejection', (event) => {
|
|
671
|
+
if (isStaleClientReference(event.reason)) {
|
|
672
|
+
event.preventDefault();
|
|
673
|
+
triggerStaleReload();
|
|
674
|
+
}
|
|
675
|
+
});
|
|
676
|
+
|
|
617
677
|
// Signal that the client runtime has been initialized.
|
|
618
678
|
// Used by E2E tests to wait for hydration before interacting.
|
|
619
679
|
// We append a <meta name="timber-ready"> tag rather than setting a
|
package/src/client/router.ts
CHANGED
|
@@ -7,6 +7,13 @@ import { HistoryStack } from './history';
|
|
|
7
7
|
import type { HeadElement } from './head';
|
|
8
8
|
import { setCurrentParams } from './use-params.js';
|
|
9
9
|
import { setNavigationState } from './navigation-context.js';
|
|
10
|
+
import {
|
|
11
|
+
SegmentElementCache,
|
|
12
|
+
cacheSegmentElements,
|
|
13
|
+
mergeSegmentTree,
|
|
14
|
+
} from './segment-merger.js';
|
|
15
|
+
import { fetchRscPayload, RedirectError } from './rsc-fetch.js';
|
|
16
|
+
import type { FetchResult } from './rsc-fetch.js';
|
|
10
17
|
|
|
11
18
|
// ─── Types ───────────────────────────────────────────────────────
|
|
12
19
|
|
|
@@ -71,16 +78,6 @@ export interface RouterDeps {
|
|
|
71
78
|
) => Promise<void>;
|
|
72
79
|
}
|
|
73
80
|
|
|
74
|
-
/** Result of fetching an RSC payload — includes head elements and segment metadata. */
|
|
75
|
-
interface FetchResult {
|
|
76
|
-
payload: unknown;
|
|
77
|
-
headElements: HeadElement[] | null;
|
|
78
|
-
/** Segment metadata from X-Timber-Segments header for populating the segment cache. */
|
|
79
|
-
segmentInfo: SegmentInfo[] | null;
|
|
80
|
-
/** Route params from X-Timber-Params header for populating useParams(). */
|
|
81
|
-
params: Record<string, string | string[]> | null;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
81
|
export interface RouterInstance {
|
|
85
82
|
/** Navigate to a new URL (forward navigation) */
|
|
86
83
|
navigate(url: string, options?: NavigationOptions): Promise<void>;
|
|
@@ -107,6 +104,12 @@ export interface RouterInstance {
|
|
|
107
104
|
* Called on initial hydration with segment info embedded in the HTML.
|
|
108
105
|
*/
|
|
109
106
|
initSegmentCache(segments: SegmentInfo[]): void;
|
|
107
|
+
/**
|
|
108
|
+
* Cache segment elements from a decoded RSC element tree.
|
|
109
|
+
* Called on initial hydration to populate the element cache so the
|
|
110
|
+
* first client navigation can use partial payloads.
|
|
111
|
+
*/
|
|
112
|
+
cacheElementTree(element: unknown): void;
|
|
110
113
|
/** The segment cache (exposed for tests and <Link> prefetch) */
|
|
111
114
|
segmentCache: SegmentCache;
|
|
112
115
|
/** The prefetch cache (exposed for tests and <Link> prefetch) */
|
|
@@ -115,18 +118,6 @@ export interface RouterInstance {
|
|
|
115
118
|
historyStack: HistoryStack;
|
|
116
119
|
}
|
|
117
120
|
|
|
118
|
-
/**
|
|
119
|
-
* Thrown when an RSC payload response contains X-Timber-Redirect header.
|
|
120
|
-
* Caught in navigate() to trigger a soft router navigation to the redirect target.
|
|
121
|
-
*/
|
|
122
|
-
class RedirectError extends Error {
|
|
123
|
-
readonly redirectUrl: string;
|
|
124
|
-
constructor(url: string) {
|
|
125
|
-
super(`Server redirect to ${url}`);
|
|
126
|
-
this.redirectUrl = url;
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
121
|
/**
|
|
131
122
|
* Check if an error is an abort error (connection closed / fetch aborted).
|
|
132
123
|
* Browsers throw DOMException with name 'AbortError' when a fetch is aborted.
|
|
@@ -137,168 +128,6 @@ function isAbortError(error: unknown): boolean {
|
|
|
137
128
|
return false;
|
|
138
129
|
}
|
|
139
130
|
|
|
140
|
-
// ─── RSC Fetch ───────────────────────────────────────────────────
|
|
141
|
-
|
|
142
|
-
const RSC_CONTENT_TYPE = 'text/x-component';
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* Generate a short random cache-busting ID (5 chars, a-z0-9).
|
|
146
|
-
* Matches the format Next.js uses for _rsc params.
|
|
147
|
-
*/
|
|
148
|
-
function generateCacheBustId(): string {
|
|
149
|
-
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
|
150
|
-
let id = '';
|
|
151
|
-
for (let i = 0; i < 5; i++) {
|
|
152
|
-
id += chars[(Math.random() * 36) | 0];
|
|
153
|
-
}
|
|
154
|
-
return id;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* Append a `_rsc=<id>` query parameter to the URL.
|
|
159
|
-
* Follows Next.js's pattern — prevents CDN/browser from serving cached HTML
|
|
160
|
-
* for RSC navigation requests and signals that this is an RSC fetch.
|
|
161
|
-
*/
|
|
162
|
-
function appendRscParam(url: string): string {
|
|
163
|
-
const separator = url.includes('?') ? '&' : '?';
|
|
164
|
-
return `${url}${separator}_rsc=${generateCacheBustId()}`;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
function buildRscHeaders(
|
|
168
|
-
stateTree: { segments: string[] } | undefined,
|
|
169
|
-
currentUrl?: string
|
|
170
|
-
): Record<string, string> {
|
|
171
|
-
const headers: Record<string, string> = {
|
|
172
|
-
Accept: RSC_CONTENT_TYPE,
|
|
173
|
-
};
|
|
174
|
-
if (stateTree) {
|
|
175
|
-
headers['X-Timber-State-Tree'] = JSON.stringify(stateTree);
|
|
176
|
-
}
|
|
177
|
-
// Send current URL for intercepting route resolution.
|
|
178
|
-
// The server uses this to determine if an intercepting route should
|
|
179
|
-
// render instead of the actual target route (modal pattern).
|
|
180
|
-
// See design/07-routing.md §"Intercepting Routes"
|
|
181
|
-
if (currentUrl) {
|
|
182
|
-
headers['X-Timber-URL'] = currentUrl;
|
|
183
|
-
}
|
|
184
|
-
return headers;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/**
|
|
188
|
-
* Extract head elements from the X-Timber-Head response header.
|
|
189
|
-
* Returns null if the header is missing or malformed.
|
|
190
|
-
*/
|
|
191
|
-
function extractHeadElements(response: Response): HeadElement[] | null {
|
|
192
|
-
const header = response.headers.get('X-Timber-Head');
|
|
193
|
-
if (!header) return null;
|
|
194
|
-
try {
|
|
195
|
-
return JSON.parse(decodeURIComponent(header));
|
|
196
|
-
} catch {
|
|
197
|
-
return null;
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
/**
|
|
202
|
-
* Extract segment metadata from the X-Timber-Segments response header.
|
|
203
|
-
* Returns null if the header is missing or malformed.
|
|
204
|
-
*
|
|
205
|
-
* Format: JSON array of {path, isAsync} objects describing the rendered
|
|
206
|
-
* segment chain from root to leaf. Used to populate the client-side
|
|
207
|
-
* segment cache for state tree diffing on subsequent navigations.
|
|
208
|
-
*/
|
|
209
|
-
function extractSegmentInfo(response: Response): SegmentInfo[] | null {
|
|
210
|
-
const header = response.headers.get('X-Timber-Segments');
|
|
211
|
-
if (!header) return null;
|
|
212
|
-
try {
|
|
213
|
-
return JSON.parse(header);
|
|
214
|
-
} catch {
|
|
215
|
-
return null;
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
/**
|
|
220
|
-
* Extract route params from the X-Timber-Params response header.
|
|
221
|
-
* Returns null if the header is missing or malformed.
|
|
222
|
-
*
|
|
223
|
-
* Used to populate useParams() after client-side navigation.
|
|
224
|
-
*/
|
|
225
|
-
function extractParams(response: Response): Record<string, string | string[]> | null {
|
|
226
|
-
const header = response.headers.get('X-Timber-Params');
|
|
227
|
-
if (!header) return null;
|
|
228
|
-
try {
|
|
229
|
-
return JSON.parse(header);
|
|
230
|
-
} catch {
|
|
231
|
-
return null;
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
/**
|
|
236
|
-
* Fetch an RSC payload from the server. If a decodeRsc function is provided,
|
|
237
|
-
* the response is decoded into a React element tree via createFromFetch.
|
|
238
|
-
* Otherwise, the raw response text is returned (test mode).
|
|
239
|
-
*
|
|
240
|
-
* Also extracts head elements from the X-Timber-Head response header
|
|
241
|
-
* so the client can update document.title and <meta> tags after navigation.
|
|
242
|
-
*/
|
|
243
|
-
async function fetchRscPayload(
|
|
244
|
-
url: string,
|
|
245
|
-
deps: RouterDeps,
|
|
246
|
-
stateTree?: { segments: string[] },
|
|
247
|
-
currentUrl?: string
|
|
248
|
-
): Promise<FetchResult> {
|
|
249
|
-
const rscUrl = appendRscParam(url);
|
|
250
|
-
const headers = buildRscHeaders(stateTree, currentUrl);
|
|
251
|
-
if (deps.decodeRsc) {
|
|
252
|
-
// Production path: use createFromFetch for streaming RSC decoding.
|
|
253
|
-
// createFromFetch takes a Promise<Response> and progressively parses
|
|
254
|
-
// the RSC Flight stream as chunks arrive.
|
|
255
|
-
//
|
|
256
|
-
// Intercept the response to read X-Timber-Head before createFromFetch
|
|
257
|
-
// consumes the body. Reading headers does NOT consume the body stream.
|
|
258
|
-
const fetchPromise = deps.fetch(rscUrl, { headers, redirect: 'manual' });
|
|
259
|
-
let headElements: HeadElement[] | null = null;
|
|
260
|
-
let segmentInfo: SegmentInfo[] | null = null;
|
|
261
|
-
let params: Record<string, string | string[]> | null = null;
|
|
262
|
-
const wrappedPromise = fetchPromise.then((response) => {
|
|
263
|
-
// Detect server-side redirects. The server returns 204 + X-Timber-Redirect
|
|
264
|
-
// for RSC payload requests instead of a raw 302, because fetch with
|
|
265
|
-
// redirect: "manual" turns 302s into opaque redirects (status 0, null body)
|
|
266
|
-
// which crashes createFromFetch when it tries to read the body stream.
|
|
267
|
-
const redirectLocation =
|
|
268
|
-
response.headers.get('X-Timber-Redirect') ||
|
|
269
|
-
(response.status >= 300 && response.status < 400 ? response.headers.get('Location') : null);
|
|
270
|
-
if (redirectLocation) {
|
|
271
|
-
throw new RedirectError(redirectLocation);
|
|
272
|
-
}
|
|
273
|
-
headElements = extractHeadElements(response);
|
|
274
|
-
segmentInfo = extractSegmentInfo(response);
|
|
275
|
-
params = extractParams(response);
|
|
276
|
-
return response;
|
|
277
|
-
});
|
|
278
|
-
// Await so headElements/segmentInfo/params are populated before we return.
|
|
279
|
-
// Also await the decoded payload — createFromFetch returns a thenable
|
|
280
|
-
// that resolves to the React element tree.
|
|
281
|
-
await wrappedPromise;
|
|
282
|
-
const payload = await deps.decodeRsc(wrappedPromise);
|
|
283
|
-
return { payload, headElements, segmentInfo, params };
|
|
284
|
-
}
|
|
285
|
-
// Test/fallback path: return raw text
|
|
286
|
-
const response = await deps.fetch(rscUrl, { headers, redirect: 'manual' });
|
|
287
|
-
// Check for redirect in test path too
|
|
288
|
-
if (response.status >= 300 && response.status < 400) {
|
|
289
|
-
const location = response.headers.get('Location');
|
|
290
|
-
if (location) {
|
|
291
|
-
throw new RedirectError(location);
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
return {
|
|
295
|
-
payload: await response.text(),
|
|
296
|
-
headElements: extractHeadElements(response),
|
|
297
|
-
segmentInfo: extractSegmentInfo(response),
|
|
298
|
-
params: extractParams(response),
|
|
299
|
-
};
|
|
300
|
-
}
|
|
301
|
-
|
|
302
131
|
// ─── Router Factory ──────────────────────────────────────────────
|
|
303
132
|
|
|
304
133
|
/**
|
|
@@ -309,6 +138,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
309
138
|
const segmentCache = new SegmentCache();
|
|
310
139
|
const prefetchCache = new PrefetchCache();
|
|
311
140
|
const historyStack = new HistoryStack();
|
|
141
|
+
const segmentElementCache = new SegmentElementCache();
|
|
312
142
|
|
|
313
143
|
let pending = false;
|
|
314
144
|
let pendingUrl: string | null = null;
|
|
@@ -343,6 +173,28 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
343
173
|
}
|
|
344
174
|
}
|
|
345
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Merge a partial RSC payload with cached segment elements if segments
|
|
178
|
+
* were skipped, then cache segments from the (merged) payload.
|
|
179
|
+
* Returns the merged payload ready for rendering.
|
|
180
|
+
*/
|
|
181
|
+
function mergeAndCachePayload(
|
|
182
|
+
payload: unknown,
|
|
183
|
+
skippedSegments: string[] | null | undefined
|
|
184
|
+
): unknown {
|
|
185
|
+
let merged = payload;
|
|
186
|
+
|
|
187
|
+
// If segments were skipped, merge the partial payload with cached segments
|
|
188
|
+
if (skippedSegments && skippedSegments.length > 0) {
|
|
189
|
+
merged = mergeSegmentTree(payload, skippedSegments, segmentElementCache);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Cache segment elements from the (merged) payload for future merges
|
|
193
|
+
cacheSegmentElements(merged, segmentElementCache);
|
|
194
|
+
|
|
195
|
+
return merged;
|
|
196
|
+
}
|
|
197
|
+
|
|
346
198
|
/**
|
|
347
199
|
* Update navigation state (params + pathname) for the next render.
|
|
348
200
|
*
|
|
@@ -378,13 +230,17 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
378
230
|
await deps.navigateTransition(pendingUrl, async (wrapPayload) => {
|
|
379
231
|
const result = await perform();
|
|
380
232
|
headElements = result.headElements;
|
|
381
|
-
|
|
233
|
+
// Merge partial payload with cached segments before wrapping
|
|
234
|
+
const merged = mergeAndCachePayload(result.payload, result.skippedSegments);
|
|
235
|
+
return wrapPayload(merged);
|
|
382
236
|
});
|
|
383
237
|
return headElements;
|
|
384
238
|
}
|
|
385
239
|
// Fallback: no transition (tests, no React tree)
|
|
386
240
|
const result = await perform();
|
|
387
|
-
|
|
241
|
+
// Merge partial payload with cached segments before rendering
|
|
242
|
+
const merged = mergeAndCachePayload(result.payload, result.skippedSegments);
|
|
243
|
+
renderPayload(merged);
|
|
388
244
|
return result.headElements;
|
|
389
245
|
}
|
|
390
246
|
|
|
@@ -421,6 +277,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
421
277
|
headElements: prefetched.headElements,
|
|
422
278
|
segmentInfo: prefetched.segmentInfo ?? null,
|
|
423
279
|
params: prefetched.params ?? null,
|
|
280
|
+
skippedSegments: prefetched.skippedSegments ?? null,
|
|
424
281
|
}
|
|
425
282
|
: undefined;
|
|
426
283
|
|
|
@@ -616,15 +473,18 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
616
473
|
// Render the piggybacked element tree from a server action response.
|
|
617
474
|
// Updates the current history entry with the fresh payload and applies
|
|
618
475
|
// head elements — same as refresh() but without a server fetch.
|
|
476
|
+
// Cache segment elements for future partial merges.
|
|
619
477
|
const currentUrl = deps.getCurrentUrl();
|
|
478
|
+
const merged = mergeAndCachePayload(element, null);
|
|
620
479
|
historyStack.push(currentUrl, {
|
|
621
|
-
payload:
|
|
480
|
+
payload: merged,
|
|
622
481
|
headElements,
|
|
623
482
|
});
|
|
624
|
-
renderPayload(
|
|
483
|
+
renderPayload(merged);
|
|
625
484
|
applyHead(headElements);
|
|
626
485
|
},
|
|
627
486
|
initSegmentCache: (segments: SegmentInfo[]) => updateSegmentCache(segments),
|
|
487
|
+
cacheElementTree: (element: unknown) => cacheSegmentElements(element, segmentElementCache),
|
|
628
488
|
segmentCache,
|
|
629
489
|
prefetchCache,
|
|
630
490
|
historyStack,
|