@timber-js/app 0.1.51 → 0.1.52
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/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/state-tree-diff.d.ts +1 -1
- package/dist/server/state-tree-diff.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 +61 -8
- package/src/client/stale-reload.ts +89 -0
- package/src/server/compress.ts +23 -4
- package/src/server/state-tree-diff.ts +19 -18
|
@@ -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"}
|
|
@@ -39,5 +39,5 @@ export declare function parseClientStateTree(req: Request): Set<string> | null;
|
|
|
39
39
|
* @param isLeaf - Whether this is the leaf segment (page segment)
|
|
40
40
|
* @param clientSegments - Set of paths from X-Timber-State-Tree, or null
|
|
41
41
|
*/
|
|
42
|
-
export declare function shouldSkipSegment(
|
|
42
|
+
export declare function shouldSkipSegment(_urlPath: string, _layoutComponent: ((...args: unknown[]) => unknown) | undefined, _isLeaf: boolean, _clientSegments: Set<string> | null): boolean;
|
|
43
43
|
//# sourceMappingURL=state-tree-diff.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"state-tree-diff.d.ts","sourceRoot":"","sources":["../../src/server/state-tree-diff.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,OAAO,GAAG,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,CAarE;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,iBAAiB,CAC/B,
|
|
1
|
+
{"version":3,"file":"state-tree-diff.d.ts","sourceRoot":"","sources":["../../src/server/state-tree-diff.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,OAAO,GAAG,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,CAarE;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,iBAAiB,CAC/B,QAAQ,EAAE,MAAM,EAChB,gBAAgB,EAAE,CAAC,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,OAAO,CAAC,GAAG,SAAS,EAC/D,OAAO,EAAE,OAAO,EAChB,eAAe,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,GAClC,OAAO,CAgBT"}
|
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.
|
|
@@ -614,6 +649,24 @@ function bootstrap(runtimeConfig: typeof config): void {
|
|
|
614
649
|
|
|
615
650
|
bootstrap(config);
|
|
616
651
|
|
|
652
|
+
// Clear the stale reload flag on successful bootstrap. If the page
|
|
653
|
+
// loaded and bootstrapped without hitting a stale reference error,
|
|
654
|
+
// the loop guard should reset so the next stale error gets a fresh
|
|
655
|
+
// reload attempt.
|
|
656
|
+
clearStaleReloadFlag();
|
|
657
|
+
|
|
658
|
+
// Global error handler for stale client reference errors during hydration.
|
|
659
|
+
// The initial RSC payload is decoded lazily by React via createFromReadableStream.
|
|
660
|
+
// If the payload references a module ID from a newer deployment, the error
|
|
661
|
+
// surfaces as an unhandled rejection during React's render/hydration cycle.
|
|
662
|
+
// This handler catches those errors and triggers a full page reload.
|
|
663
|
+
window.addEventListener('unhandledrejection', (event) => {
|
|
664
|
+
if (isStaleClientReference(event.reason)) {
|
|
665
|
+
event.preventDefault();
|
|
666
|
+
triggerStaleReload();
|
|
667
|
+
}
|
|
668
|
+
});
|
|
669
|
+
|
|
617
670
|
// Signal that the client runtime has been initialized.
|
|
618
671
|
// Used by E2E tests to wait for hydration before interacting.
|
|
619
672
|
// We append a <meta name="timber-ready"> tag rather than setting a
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stale Client Reference Reload
|
|
3
|
+
*
|
|
4
|
+
* When a new deployment ships updated bundles, clients running stale
|
|
5
|
+
* JavaScript may encounter "Could not find the module" errors during
|
|
6
|
+
* RSC Flight stream decoding. This happens because the RSC payload
|
|
7
|
+
* references module IDs from the new bundle that don't exist in the
|
|
8
|
+
* old client bundle.
|
|
9
|
+
*
|
|
10
|
+
* This module detects these specific errors and triggers a full page
|
|
11
|
+
* reload so the browser fetches the new bundle. A sessionStorage flag
|
|
12
|
+
* guards against infinite reload loops.
|
|
13
|
+
*
|
|
14
|
+
* See: LOCAL-332
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const RELOAD_FLAG_KEY = '__timber_stale_reload';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Check if an error is a stale client reference error from React's
|
|
21
|
+
* Flight client. These errors have the message pattern:
|
|
22
|
+
* "Could not find the module \"<id>\""
|
|
23
|
+
*
|
|
24
|
+
* This is thrown by react-server-dom-webpack's client when the RSC
|
|
25
|
+
* payload references a module ID that doesn't exist in the client's
|
|
26
|
+
* module map — typically because the server has been redeployed with
|
|
27
|
+
* new bundle hashes while the client is still running old JavaScript.
|
|
28
|
+
*/
|
|
29
|
+
export function isStaleClientReference(error: unknown): boolean {
|
|
30
|
+
if (!(error instanceof Error)) return false;
|
|
31
|
+
const msg = error.message;
|
|
32
|
+
return msg.includes('Could not find the module');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Trigger a full page reload to pick up new bundles.
|
|
37
|
+
*
|
|
38
|
+
* Sets a sessionStorage flag before reloading. If the flag is already
|
|
39
|
+
* set (meaning we already reloaded once for this reason), we don't
|
|
40
|
+
* reload again — this prevents infinite reload loops if the error
|
|
41
|
+
* persists after reload (e.g., a genuine bug rather than a stale bundle).
|
|
42
|
+
*
|
|
43
|
+
* @returns true if a reload was triggered, false if suppressed (loop guard)
|
|
44
|
+
*/
|
|
45
|
+
export function triggerStaleReload(): boolean {
|
|
46
|
+
try {
|
|
47
|
+
// Check if we already reloaded — prevent infinite loop
|
|
48
|
+
if (sessionStorage.getItem(RELOAD_FLAG_KEY)) {
|
|
49
|
+
console.warn(
|
|
50
|
+
'[timber] Stale client reference detected again after reload. ' +
|
|
51
|
+
'Not reloading to prevent infinite loop. ' +
|
|
52
|
+
'This may indicate a deployment issue — try a hard refresh.'
|
|
53
|
+
);
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Set the flag before reloading
|
|
58
|
+
sessionStorage.setItem(RELOAD_FLAG_KEY, '1');
|
|
59
|
+
|
|
60
|
+
console.warn(
|
|
61
|
+
'[timber] Stale client reference detected — the server has been ' +
|
|
62
|
+
'redeployed with new bundles. Reloading to pick up the new version.'
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
window.location.reload();
|
|
66
|
+
return true;
|
|
67
|
+
} catch {
|
|
68
|
+
// sessionStorage may be unavailable (private browsing, storage full, etc.)
|
|
69
|
+
// Fall back to reloading without loop protection
|
|
70
|
+
console.warn(
|
|
71
|
+
'[timber] Stale client reference detected. Reloading page.'
|
|
72
|
+
);
|
|
73
|
+
window.location.reload();
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Clear the stale reload flag. Called on successful bootstrap to reset
|
|
80
|
+
* the loop guard — if the page loaded successfully, the next stale
|
|
81
|
+
* reference error should trigger a fresh reload attempt.
|
|
82
|
+
*/
|
|
83
|
+
export function clearStaleReloadFlag(): void {
|
|
84
|
+
try {
|
|
85
|
+
sessionStorage.removeItem(RELOAD_FLAG_KEY);
|
|
86
|
+
} catch {
|
|
87
|
+
// sessionStorage unavailable — nothing to clear
|
|
88
|
+
}
|
|
89
|
+
}
|
package/src/server/compress.ts
CHANGED
|
@@ -55,11 +55,30 @@ const NO_COMPRESS_STATUSES = new Set([204, 304]);
|
|
|
55
55
|
export function negotiateEncoding(acceptEncoding: string): 'gzip' | null {
|
|
56
56
|
if (!acceptEncoding) return null;
|
|
57
57
|
|
|
58
|
-
// Parse tokens from the Accept-Encoding header
|
|
59
|
-
//
|
|
60
|
-
|
|
58
|
+
// Parse tokens with quality values from the Accept-Encoding header.
|
|
59
|
+
// Per RFC 9110 §12.5.3, q=0 means "not acceptable" — the client explicitly
|
|
60
|
+
// rejects that encoding. Tokens without a q-value default to q=1.
|
|
61
|
+
// e.g. "gzip;q=1.0, br;q=0" → gzip is acceptable, br is rejected.
|
|
62
|
+
const parts = acceptEncoding.split(',');
|
|
63
|
+
for (const part of parts) {
|
|
64
|
+
const [token, ...params] = part.split(';');
|
|
65
|
+
const name = token.trim().toLowerCase();
|
|
66
|
+
if (name !== 'gzip') continue;
|
|
67
|
+
|
|
68
|
+
// Check for q=0 (explicitly disabled)
|
|
69
|
+
let qValue = 1; // default quality is 1
|
|
70
|
+
for (const param of params) {
|
|
71
|
+
const trimmed = param.trim().toLowerCase();
|
|
72
|
+
if (trimmed.startsWith('q=')) {
|
|
73
|
+
qValue = parseFloat(trimmed.slice(2));
|
|
74
|
+
if (Number.isNaN(qValue)) qValue = 1;
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (qValue > 0) return 'gzip';
|
|
80
|
+
}
|
|
61
81
|
|
|
62
|
-
if (tokens.includes('gzip')) return 'gzip';
|
|
63
82
|
return null;
|
|
64
83
|
}
|
|
65
84
|
|
|
@@ -55,23 +55,24 @@ export function parseClientStateTree(req: Request): Set<string> | null {
|
|
|
55
55
|
* @param clientSegments - Set of paths from X-Timber-State-Tree, or null
|
|
56
56
|
*/
|
|
57
57
|
export function shouldSkipSegment(
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
58
|
+
_urlPath: string,
|
|
59
|
+
_layoutComponent: ((...args: unknown[]) => unknown) | undefined,
|
|
60
|
+
_isLeaf: boolean,
|
|
61
|
+
_clientSegments: Set<string> | null
|
|
62
62
|
): boolean {
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
//
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
//
|
|
76
|
-
|
|
63
|
+
// DISABLED: Server-side segment skipping is not safe without client-side
|
|
64
|
+
// tree merging. When the server omits a layout from the RSC payload, the
|
|
65
|
+
// client receives a tree with a different structure (no SegmentProvider,
|
|
66
|
+
// no layout wrapper). React sees a different component tree shape and
|
|
67
|
+
// re-mounts everything — destroying DOM state (input values, focus,
|
|
68
|
+
// scroll position) in layouts that should have been preserved.
|
|
69
|
+
//
|
|
70
|
+
// The correct fix requires client-side tree merging: the router must
|
|
71
|
+
// splice the partial RSC response into the existing cached tree, only
|
|
72
|
+
// replacing changed segments. Until that's implemented, always render
|
|
73
|
+
// the full tree. RSC naturally handles this efficiently — client
|
|
74
|
+
// components are sent as references, not re-serialized.
|
|
75
|
+
//
|
|
76
|
+
// See design/19-client-navigation.md §"X-Timber-State-Tree Header"
|
|
77
|
+
return false;
|
|
77
78
|
}
|