@timber-js/app 0.2.0-alpha.141 → 0.2.0-alpha.143
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/LICENSE +8 -0
- package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -1
- package/dist/client/browser-entry/index.d.ts +1 -2
- package/dist/client/browser-entry/index.d.ts.map +1 -1
- package/dist/client/browser-entry/router-init.d.ts.map +1 -1
- package/dist/client/internal.js +2 -99
- package/dist/client/internal.js.map +1 -1
- package/dist/client/nuqs-adapter.d.ts +1 -1
- package/dist/client/nuqs-adapter.d.ts.map +1 -1
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/rsc-fetch.d.ts +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/server/early-hints.d.ts.map +1 -1
- package/dist/server/internal.js.map +1 -1
- package/package.json +10 -10
- package/src/cli.ts +0 -0
- package/src/client/browser-entry/action-dispatch.ts +1 -12
- package/src/client/browser-entry/index.ts +2 -42
- package/src/client/browser-entry/router-init.ts +1 -24
- package/src/client/router.ts +2 -30
- package/src/client/rsc-fetch.ts +2 -2
- package/src/server/build-manifest.ts +6 -6
- package/src/server/early-hints.ts +2 -0
- package/dist/client/stale-reload.d.ts +0 -59
- package/dist/client/stale-reload.d.ts.map +0 -1
- package/src/client/stale-reload.ts +0 -156
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@timber-js/app",
|
|
3
|
-
"version": "0.2.0-alpha.
|
|
3
|
+
"version": "0.2.0-alpha.143",
|
|
4
4
|
"description": "Vite-native React framework built for Servers and Serverless Platforms — correct HTTP semantics, real status codes, pages that work without JavaScript",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cloudflare-workers",
|
|
@@ -114,11 +114,6 @@
|
|
|
114
114
|
"publishConfig": {
|
|
115
115
|
"access": "public"
|
|
116
116
|
},
|
|
117
|
-
"scripts": {
|
|
118
|
-
"build": "vite build --config vite.lib.config.ts && tsc --emitDeclarationOnly --project tsconfig.json --outDir dist",
|
|
119
|
-
"typecheck": "tsgo --noEmit",
|
|
120
|
-
"prepublishOnly": "pnpm run build"
|
|
121
|
-
},
|
|
122
117
|
"dependencies": {
|
|
123
118
|
"@opentelemetry/api": "^1.9.1",
|
|
124
119
|
"@opentelemetry/context-async-hooks": "^2.7.1",
|
|
@@ -132,10 +127,11 @@
|
|
|
132
127
|
"@content-collections/vite": "^0.2.0",
|
|
133
128
|
"@mdx-js/rollup": "^3.0.0",
|
|
134
129
|
"@vitejs/plugin-react": "^6.0.0",
|
|
135
|
-
"@vitejs/plugin-rsc": ">=0.5.
|
|
130
|
+
"@vitejs/plugin-rsc": ">=0.5.27",
|
|
136
131
|
"nuqs": "^2.0.0",
|
|
137
|
-
"react": "19.3.0-canary-
|
|
138
|
-
"react-dom": "19.3.0-canary-
|
|
132
|
+
"react": ">=19.3.0-canary-43bcbf80-20260603",
|
|
133
|
+
"react-dom": ">=19.3.0-canary-43bcbf80-20260603",
|
|
134
|
+
"react-server-dom-webpack": ">=19.3.0-canary-43bcbf80-20260603",
|
|
139
135
|
"vite": "^8.0.13",
|
|
140
136
|
"wrangler": "^4.0.0",
|
|
141
137
|
"zod": "^3.22.0 || ^4.0.0"
|
|
@@ -162,5 +158,9 @@
|
|
|
162
158
|
},
|
|
163
159
|
"engines": {
|
|
164
160
|
"node": ">=22.12.0"
|
|
161
|
+
},
|
|
162
|
+
"scripts": {
|
|
163
|
+
"build": "vite build --config vite.lib.config.ts && tsc --emitDeclarationOnly --project tsconfig.json --outDir dist",
|
|
164
|
+
"typecheck": "tsgo --noEmit"
|
|
165
165
|
}
|
|
166
|
-
}
|
|
166
|
+
}
|
package/src/cli.ts
CHANGED
|
File without changes
|
|
@@ -17,7 +17,6 @@
|
|
|
17
17
|
import { setServerCallback, encodeReply, createFromFetch } from '../../rsc-runtime/browser.js';
|
|
18
18
|
import { getRouter, getRouterOrNull } from '#client-internal';
|
|
19
19
|
import { setHardNavigating } from '../navigation-root.js';
|
|
20
|
-
import { isStaleClientReference, triggerStaleReload } from '../stale-reload.js';
|
|
21
20
|
import { getClientDeploymentId, DEPLOYMENT_ID_HEADER, RELOAD_HEADER } from '../rsc-fetch.js';
|
|
22
21
|
|
|
23
22
|
export function setupServerActions(): void {
|
|
@@ -59,17 +58,7 @@ export function setupServerActions(): void {
|
|
|
59
58
|
return res;
|
|
60
59
|
});
|
|
61
60
|
|
|
62
|
-
|
|
63
|
-
try {
|
|
64
|
-
decoded = await createFromFetch(response);
|
|
65
|
-
} catch (error) {
|
|
66
|
-
if (isStaleClientReference(error)) {
|
|
67
|
-
triggerStaleReload();
|
|
68
|
-
// Return a never-resolving promise to prevent further processing
|
|
69
|
-
return new Promise(() => {});
|
|
70
|
-
}
|
|
71
|
-
throw error;
|
|
72
|
-
}
|
|
61
|
+
const decoded = await createFromFetch(response);
|
|
73
62
|
|
|
74
63
|
// Handle redirect — server encoded the redirect location in the RSC stream
|
|
75
64
|
// instead of returning HTTP 302. Perform a client-side SPA navigation.
|
|
@@ -21,8 +21,7 @@
|
|
|
21
21
|
* 5. hydrateApp() — hydrateRoot or deferred createRoot
|
|
22
22
|
* 6. setupPostHydration() — history stack, popstate, scroll
|
|
23
23
|
* 7. setupHmr() — dev-only HMR wiring
|
|
24
|
-
* 8.
|
|
25
|
-
* 9. timber-ready signal — E2E test readiness indicator
|
|
24
|
+
* 8. timber-ready signal — E2E test readiness indicator
|
|
26
25
|
*
|
|
27
26
|
* Design docs: 18-build-system.md §"Entry Files", 19-client-navigation.md
|
|
28
27
|
*/
|
|
@@ -42,12 +41,6 @@ import { setLinkCodecs } from '../../params/codec-registry.js';
|
|
|
42
41
|
setLinkCodecs(schemaCodecs);
|
|
43
42
|
|
|
44
43
|
import { setClientDeploymentId } from '../rsc-fetch.js';
|
|
45
|
-
import {
|
|
46
|
-
isStaleClientReference,
|
|
47
|
-
isChunkLoadError,
|
|
48
|
-
triggerStaleReload,
|
|
49
|
-
clearStaleReloadFlag,
|
|
50
|
-
} from '../stale-reload.js';
|
|
51
44
|
|
|
52
45
|
import { setupServerActions } from './action-dispatch.js';
|
|
53
46
|
import { createRscPayloadStream } from './rsc-stream.js';
|
|
@@ -104,40 +97,7 @@ function bootstrap(runtimeConfig: typeof config): void {
|
|
|
104
97
|
|
|
105
98
|
bootstrap(config);
|
|
106
99
|
|
|
107
|
-
// ─── 8.
|
|
108
|
-
|
|
109
|
-
// Clear the stale reload flag on successful bootstrap. If the page
|
|
110
|
-
// loaded and bootstrapped without hitting a stale reference error,
|
|
111
|
-
// the loop guard should reset so the next stale error gets a fresh
|
|
112
|
-
// reload attempt.
|
|
113
|
-
clearStaleReloadFlag();
|
|
114
|
-
|
|
115
|
-
// Global error handler for stale client reference errors during hydration.
|
|
116
|
-
// The initial RSC payload is decoded lazily by React via createFromReadableStream.
|
|
117
|
-
// If the payload references a module ID from a newer deployment, the error
|
|
118
|
-
// surfaces as an unhandled rejection during React's render/hydration cycle.
|
|
119
|
-
// This handler catches those errors and triggers a full page reload.
|
|
120
|
-
//
|
|
121
|
-
// Also catches chunk load failures (dynamic import of missing assets after
|
|
122
|
-
// a deployment) — these surface as "Failed to fetch dynamically imported module"
|
|
123
|
-
// or "Loading chunk <name> failed" errors. See TIM-446.
|
|
124
|
-
window.addEventListener('unhandledrejection', (event) => {
|
|
125
|
-
if (isStaleClientReference(event.reason) || isChunkLoadError(event.reason)) {
|
|
126
|
-
event.preventDefault();
|
|
127
|
-
triggerStaleReload();
|
|
128
|
-
}
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
// Also catch synchronous errors from chunk loads (some browsers throw
|
|
132
|
-
// TypeError synchronously instead of via unhandled rejection).
|
|
133
|
-
window.addEventListener('error', (event) => {
|
|
134
|
-
if (isChunkLoadError(event.error)) {
|
|
135
|
-
event.preventDefault();
|
|
136
|
-
triggerStaleReload();
|
|
137
|
-
}
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
// ─── 9. Ready Signal ────────────────────────────────────────────
|
|
100
|
+
// ─── 8. Ready Signal ────────────────────────────────────────────
|
|
141
101
|
|
|
142
102
|
// Signal that the client runtime has been initialized.
|
|
143
103
|
// Used by E2E tests to wait for hydration before interacting.
|
|
@@ -17,7 +17,6 @@ import { TimberNuqsAdapter } from '../nuqs-adapter.js';
|
|
|
17
17
|
import { NavigationProvider } from '../navigation-context.js';
|
|
18
18
|
import { transitionRender, navigateTransition } from '../navigation-root.js';
|
|
19
19
|
import { SegmentUpdateContext, EMPTY_SEGMENT_UPDATES } from '../segment-update-context.js';
|
|
20
|
-
import { isStaleClientReference, triggerStaleReload } from '../stale-reload.js';
|
|
21
20
|
import {
|
|
22
21
|
hasNavigationApi,
|
|
23
22
|
setupNavigationApi,
|
|
@@ -79,30 +78,8 @@ export function createTimberRouter(options?: RouterInitOptions): RouterInitResul
|
|
|
79
78
|
getCurrentUrl: () => window.location.pathname + window.location.search,
|
|
80
79
|
getScrollY,
|
|
81
80
|
|
|
82
|
-
// Decode RSC Flight stream using createFromFetch.
|
|
83
|
-
// createFromFetch takes a Promise<Response> and progressively
|
|
84
|
-
// parses the RSC stream as chunks arrive.
|
|
85
|
-
//
|
|
86
|
-
// Wrapped with stale client reference detection: if the server
|
|
87
|
-
// has been redeployed with new bundles, the RSC payload may
|
|
88
|
-
// reference module IDs that don't exist in the old client bundle.
|
|
89
|
-
// We catch "Could not find the module" errors and trigger a full
|
|
90
|
-
// page reload so the browser fetches the new bundle.
|
|
91
|
-
// Pass the fetch promise directly to createFromFetch for streaming.
|
|
92
|
-
// React progressively renders chunks via Suspense as they arrive.
|
|
93
|
-
// The transition stays open via decodePromise (awaited inside
|
|
94
|
-
// startTransition) so useOptimistic pending state persists for
|
|
95
|
-
// the full stream duration.
|
|
96
81
|
decodeRsc: (fetchPromise: Promise<Response>) => {
|
|
97
|
-
|
|
98
|
-
return createFromFetch(fetchPromise);
|
|
99
|
-
} catch (error) {
|
|
100
|
-
if (isStaleClientReference(error)) {
|
|
101
|
-
triggerStaleReload();
|
|
102
|
-
return new Promise(() => {});
|
|
103
|
-
}
|
|
104
|
-
throw error;
|
|
105
|
-
}
|
|
82
|
+
return createFromFetch(fetchPromise);
|
|
106
83
|
},
|
|
107
84
|
|
|
108
85
|
// Render decoded RSC tree via NavigationRoot's state-based mechanism.
|
package/src/client/router.ts
CHANGED
|
@@ -19,7 +19,6 @@ import {
|
|
|
19
19
|
VersionSkewError,
|
|
20
20
|
} from './rsc-fetch.js';
|
|
21
21
|
import { setHardNavigating } from './navigation-root.js';
|
|
22
|
-
import { isStaleClientReference, triggerStaleReload } from './stale-reload.js';
|
|
23
22
|
import type { FetchResult } from './rsc-fetch.js';
|
|
24
23
|
|
|
25
24
|
// ─── Types ───────────────────────────────────────────────────────
|
|
@@ -565,17 +564,11 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
565
564
|
// scroll during DOM reconciliation, so all scroll must be actively managed.
|
|
566
565
|
restoreScrollAfterPaint(scroll ? 0 : currentScrollY);
|
|
567
566
|
} catch (error) {
|
|
568
|
-
// Version skew — server has been redeployed.
|
|
567
|
+
// Version skew — server has been redeployed. Full page reload
|
|
569
568
|
// so the browser fetches the new bundle. See TIM-446.
|
|
570
|
-
// Set hard-navigating flag to prevent Navigation API interception
|
|
571
|
-
// and React from rendering during page teardown. See TIM-626.
|
|
572
569
|
if (error instanceof VersionSkewError) {
|
|
573
570
|
setHardNavigating(true);
|
|
574
|
-
|
|
575
|
-
// and keep the reload logic centralized with its loop guard.
|
|
576
|
-
const { triggerStaleReload } = await import('./stale-reload.js');
|
|
577
|
-
triggerStaleReload();
|
|
578
|
-
// Return a never-resolving promise — page is reloading.
|
|
571
|
+
window.location.reload();
|
|
579
572
|
return new Promise(() => {}) as never;
|
|
580
573
|
}
|
|
581
574
|
// Server-side redirect during RSC fetch → soft router navigation.
|
|
@@ -600,13 +593,6 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
600
593
|
window.location.href = error.url;
|
|
601
594
|
return new Promise(() => {}) as never;
|
|
602
595
|
}
|
|
603
|
-
// Stale client reference from streaming RSC decode — the server
|
|
604
|
-
// was redeployed with new bundles. Same recovery as VersionSkewError.
|
|
605
|
-
if (isStaleClientReference(error)) {
|
|
606
|
-
setHardNavigating(true);
|
|
607
|
-
triggerStaleReload();
|
|
608
|
-
return new Promise(() => {}) as never;
|
|
609
|
-
}
|
|
610
596
|
// Abort errors are not application errors — swallow silently.
|
|
611
597
|
if (isAbortError(error)) return;
|
|
612
598
|
throw error;
|
|
@@ -651,13 +637,6 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
651
637
|
|
|
652
638
|
applyHead(headElements);
|
|
653
639
|
} catch (error) {
|
|
654
|
-
if (isStaleClientReference(error)) {
|
|
655
|
-
setHardNavigating(true);
|
|
656
|
-
triggerStaleReload();
|
|
657
|
-
return new Promise(() => {}) as never;
|
|
658
|
-
}
|
|
659
|
-
// Stale transition (superseded by a newer navigation) or aborted
|
|
660
|
-
// fetch — silently ignore. See TIM-629.
|
|
661
640
|
if (isAbortError(error)) return;
|
|
662
641
|
throw error;
|
|
663
642
|
} finally {
|
|
@@ -705,13 +684,6 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
705
684
|
applyHead(headElements);
|
|
706
685
|
restoreScrollAfterPaint(scrollY);
|
|
707
686
|
} catch (error) {
|
|
708
|
-
if (isStaleClientReference(error)) {
|
|
709
|
-
setHardNavigating(true);
|
|
710
|
-
triggerStaleReload();
|
|
711
|
-
return new Promise(() => {}) as never;
|
|
712
|
-
}
|
|
713
|
-
// Stale transition (superseded by a newer navigation) or aborted
|
|
714
|
-
// fetch — silently ignore. See TIM-629.
|
|
715
687
|
if (isAbortError(error)) return;
|
|
716
688
|
throw error;
|
|
717
689
|
} finally {
|
package/src/client/rsc-fetch.ts
CHANGED
|
@@ -230,7 +230,7 @@ export class RedirectError extends Error {
|
|
|
230
230
|
|
|
231
231
|
/**
|
|
232
232
|
* Thrown when the server signals a version skew (X-Timber-Reload header).
|
|
233
|
-
* Caught in navigate() to trigger a full page reload
|
|
233
|
+
* Caught in navigate() to trigger a full page reload.
|
|
234
234
|
* See TIM-446.
|
|
235
235
|
*/
|
|
236
236
|
export class VersionSkewError extends Error {
|
|
@@ -345,7 +345,7 @@ export async function fetchRscPayload(
|
|
|
345
345
|
const wrappedPromise = fetchPromise.then((response) => {
|
|
346
346
|
// Version skew detection (TIM-446): if the server signals a reload,
|
|
347
347
|
// throw VersionSkewError so the caller (router navigate) can trigger
|
|
348
|
-
// a full page reload
|
|
348
|
+
// a full page reload.
|
|
349
349
|
if (checkReloadSignal(response)) {
|
|
350
350
|
throw new VersionSkewError();
|
|
351
351
|
}
|
|
@@ -92,12 +92,12 @@ export function collectRouteCss(segments: SegmentWithFiles[], manifest: BuildMan
|
|
|
92
92
|
* via injectHead() before </head>.
|
|
93
93
|
*/
|
|
94
94
|
export function buildCssLinkTags(cssUrls: string[]): string {
|
|
95
|
-
// Emit <link rel="stylesheet">
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
95
|
+
// Emit only <link rel="stylesheet"> — no preload tags. CSS preloading
|
|
96
|
+
// is handled by 103 Early Hints (Link header) which fires before the
|
|
97
|
+
// HTML stream. Float also emits `<link rel="stylesheet" data-precedence>`
|
|
98
|
+
// at the top of <head> during Fizz rendering — the browser discovers
|
|
99
|
+
// CSS from that tag. A <link rel="preload"> here would arrive *after*
|
|
100
|
+
// Float's stylesheet tag and trigger "Preload was ignored" warnings.
|
|
101
101
|
return cssUrls.map((url) => `<link rel="stylesheet" href="${url}">`).join('');
|
|
102
102
|
}
|
|
103
103
|
|
|
@@ -127,6 +127,8 @@ export function collectEarlyHintHeaders(
|
|
|
127
127
|
};
|
|
128
128
|
|
|
129
129
|
// Per-route CSS — as=style; rel=preload
|
|
130
|
+
// The HTML <head> also contains a matching <link rel="preload" as="style">
|
|
131
|
+
// tag so browsers can deduplicate the 103 hint against the HTML tag.
|
|
130
132
|
for (const url of collectRouteCss(segments, manifest)) {
|
|
131
133
|
add(url, formatLinkHeader({ href: url, rel: 'preload', as: 'style' }));
|
|
132
134
|
}
|
|
@@ -1,59 +0,0 @@
|
|
|
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
|
-
* Check if an error is a stale client reference error from React's
|
|
18
|
-
* Flight client. These errors have the message pattern:
|
|
19
|
-
* "Could not find the module \"<id>\""
|
|
20
|
-
*
|
|
21
|
-
* This is thrown by react-server-dom-webpack's client when the RSC
|
|
22
|
-
* payload references a module ID that doesn't exist in the client's
|
|
23
|
-
* module map — typically because the server has been redeployed with
|
|
24
|
-
* new bundle hashes while the client is still running old JavaScript.
|
|
25
|
-
*/
|
|
26
|
-
export declare function isStaleClientReference(error: unknown): boolean;
|
|
27
|
-
/**
|
|
28
|
-
* Check if an error is a chunk load failure from a dynamic import.
|
|
29
|
-
*
|
|
30
|
-
* After a deployment, old chunk filenames no longer exist. When the client
|
|
31
|
-
* tries to dynamically import a chunk that's been replaced, the browser
|
|
32
|
-
* throws one of these errors:
|
|
33
|
-
*
|
|
34
|
-
* - Chromium: "Failed to fetch dynamically imported module: <url>"
|
|
35
|
-
* - Firefox: "error loading dynamically imported module: <url>"
|
|
36
|
-
* - Safari: "Importing a module script failed."
|
|
37
|
-
* - Vite/Rollup: "Unable to preload CSS for <url>"
|
|
38
|
-
*
|
|
39
|
-
* See TIM-446
|
|
40
|
-
*/
|
|
41
|
-
export declare function isChunkLoadError(error: unknown): boolean;
|
|
42
|
-
/**
|
|
43
|
-
* Trigger a full page reload to pick up new bundles.
|
|
44
|
-
*
|
|
45
|
-
* Sets a sessionStorage flag before reloading. If the flag is already
|
|
46
|
-
* set (meaning we already reloaded once for this reason), we don't
|
|
47
|
-
* reload again — this prevents infinite reload loops if the error
|
|
48
|
-
* persists after reload (e.g., a genuine bug rather than a stale bundle).
|
|
49
|
-
*
|
|
50
|
-
* @returns true if a reload was triggered, false if suppressed (loop guard)
|
|
51
|
-
*/
|
|
52
|
-
export declare function triggerStaleReload(): boolean;
|
|
53
|
-
/**
|
|
54
|
-
* Clear the stale reload flag. Called on successful bootstrap to reset
|
|
55
|
-
* the loop guard — if the page loaded successfully, the next stale
|
|
56
|
-
* reference error should trigger a fresh reload attempt.
|
|
57
|
-
*/
|
|
58
|
-
export declare function clearStaleReloadFlag(): void;
|
|
59
|
-
//# sourceMappingURL=stale-reload.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"stale-reload.d.ts","sourceRoot":"","sources":["../../src/client/stale-reload.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAcH;;;;;;;;;GASG;AACH,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAI9D;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAYxD;AAED;;;;;;;;;GASG;AACH,wBAAgB,kBAAkB,IAAI,OAAO,CAqD5C;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,IAAI,IAAI,CAa3C"}
|
|
@@ -1,156 +0,0 @@
|
|
|
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
|
-
* In-memory fallback counter for environments where sessionStorage is
|
|
21
|
-
* unavailable (private browsing, storage full, extension interference).
|
|
22
|
-
* Incremented each time triggerStaleReload() falls into the catch path.
|
|
23
|
-
* If the counter exceeds 0 on a subsequent call, the reload is suppressed
|
|
24
|
-
* to prevent an infinite loop. Resets naturally on page load (module
|
|
25
|
-
* re-evaluates) and can be manually reset via clearStaleReloadFlag().
|
|
26
|
-
*/
|
|
27
|
-
let memoryReloadCount = 0;
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Check if an error is a stale client reference error from React's
|
|
31
|
-
* Flight client. These errors have the message pattern:
|
|
32
|
-
* "Could not find the module \"<id>\""
|
|
33
|
-
*
|
|
34
|
-
* This is thrown by react-server-dom-webpack's client when the RSC
|
|
35
|
-
* payload references a module ID that doesn't exist in the client's
|
|
36
|
-
* module map — typically because the server has been redeployed with
|
|
37
|
-
* new bundle hashes while the client is still running old JavaScript.
|
|
38
|
-
*/
|
|
39
|
-
export function isStaleClientReference(error: unknown): boolean {
|
|
40
|
-
if (!(error instanceof Error)) return false;
|
|
41
|
-
const msg = error.message;
|
|
42
|
-
return msg.includes('Could not find the module') || msg.includes('client reference not found');
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Check if an error is a chunk load failure from a dynamic import.
|
|
47
|
-
*
|
|
48
|
-
* After a deployment, old chunk filenames no longer exist. When the client
|
|
49
|
-
* tries to dynamically import a chunk that's been replaced, the browser
|
|
50
|
-
* throws one of these errors:
|
|
51
|
-
*
|
|
52
|
-
* - Chromium: "Failed to fetch dynamically imported module: <url>"
|
|
53
|
-
* - Firefox: "error loading dynamically imported module: <url>"
|
|
54
|
-
* - Safari: "Importing a module script failed."
|
|
55
|
-
* - Vite/Rollup: "Unable to preload CSS for <url>"
|
|
56
|
-
*
|
|
57
|
-
* See TIM-446
|
|
58
|
-
*/
|
|
59
|
-
export function isChunkLoadError(error: unknown): boolean {
|
|
60
|
-
if (!(error instanceof Error)) return false;
|
|
61
|
-
const msg = error.message.toLowerCase();
|
|
62
|
-
return (
|
|
63
|
-
msg.includes('failed to fetch dynamically imported module') ||
|
|
64
|
-
msg.includes('error loading dynamically imported module') ||
|
|
65
|
-
msg.includes('importing a module script failed') ||
|
|
66
|
-
msg.includes('unable to preload css') ||
|
|
67
|
-
// Webpack-style chunk load errors (unlikely in Vite but defensive)
|
|
68
|
-
msg.includes('loading chunk') ||
|
|
69
|
-
msg.includes('loading css chunk')
|
|
70
|
-
);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Trigger a full page reload to pick up new bundles.
|
|
75
|
-
*
|
|
76
|
-
* Sets a sessionStorage flag before reloading. If the flag is already
|
|
77
|
-
* set (meaning we already reloaded once for this reason), we don't
|
|
78
|
-
* reload again — this prevents infinite reload loops if the error
|
|
79
|
-
* persists after reload (e.g., a genuine bug rather than a stale bundle).
|
|
80
|
-
*
|
|
81
|
-
* @returns true if a reload was triggered, false if suppressed (loop guard)
|
|
82
|
-
*/
|
|
83
|
-
export function triggerStaleReload(): boolean {
|
|
84
|
-
try {
|
|
85
|
-
// Check if we already reloaded — prevent infinite loop
|
|
86
|
-
if (sessionStorage.getItem(RELOAD_FLAG_KEY)) {
|
|
87
|
-
console.warn(
|
|
88
|
-
'[timber] Stale client reference detected again after reload. ' +
|
|
89
|
-
'Not reloading to prevent infinite loop. ' +
|
|
90
|
-
'This may indicate a deployment issue — try a hard refresh.'
|
|
91
|
-
);
|
|
92
|
-
return false;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// Set the flag before reloading
|
|
96
|
-
sessionStorage.setItem(RELOAD_FLAG_KEY, '1');
|
|
97
|
-
|
|
98
|
-
console.warn(
|
|
99
|
-
'[timber] Stale client reference detected — the server has been ' +
|
|
100
|
-
'redeployed with new bundles. Reloading to pick up the new version.'
|
|
101
|
-
);
|
|
102
|
-
|
|
103
|
-
window.location.reload();
|
|
104
|
-
return true;
|
|
105
|
-
} catch {
|
|
106
|
-
// sessionStorage unavailable (private browsing, storage full, etc.)
|
|
107
|
-
// Use document.cookie as a reload-persistent fallback loop guard.
|
|
108
|
-
// Module-level memoryReloadCount resets on every reload, so it can't
|
|
109
|
-
// detect cross-reload loops. Cookies persist across reloads and are
|
|
110
|
-
// available even when sessionStorage is blocked (TIM-576).
|
|
111
|
-
const cookieFlag = document.cookie.includes(RELOAD_FLAG_KEY + '=1');
|
|
112
|
-
if (cookieFlag || memoryReloadCount > 0) {
|
|
113
|
-
console.warn(
|
|
114
|
-
'[timber] Stale client reference detected again after reload. ' +
|
|
115
|
-
'Not reloading to prevent infinite loop. ' +
|
|
116
|
-
'This may indicate a deployment issue — try a hard refresh.'
|
|
117
|
-
);
|
|
118
|
-
return false;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
memoryReloadCount++;
|
|
122
|
-
// Set a short-lived cookie (60s) as the persistent loop guard.
|
|
123
|
-
// It auto-expires so it won't block future legitimate reloads.
|
|
124
|
-
try {
|
|
125
|
-
document.cookie = `${RELOAD_FLAG_KEY}=1; max-age=60; path=/; SameSite=Lax`;
|
|
126
|
-
} catch {
|
|
127
|
-
// Cookie API unavailable — proceed anyway, memoryReloadCount guards same-page loops
|
|
128
|
-
}
|
|
129
|
-
console.warn(
|
|
130
|
-
'[timber] Stale client reference detected — the server has been ' +
|
|
131
|
-
'redeployed with new bundles. Reloading to pick up the new version.'
|
|
132
|
-
);
|
|
133
|
-
window.location.reload();
|
|
134
|
-
return true;
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
* Clear the stale reload flag. Called on successful bootstrap to reset
|
|
140
|
-
* the loop guard — if the page loaded successfully, the next stale
|
|
141
|
-
* reference error should trigger a fresh reload attempt.
|
|
142
|
-
*/
|
|
143
|
-
export function clearStaleReloadFlag(): void {
|
|
144
|
-
memoryReloadCount = 0;
|
|
145
|
-
try {
|
|
146
|
-
sessionStorage.removeItem(RELOAD_FLAG_KEY);
|
|
147
|
-
} catch {
|
|
148
|
-
// sessionStorage unavailable — nothing to clear
|
|
149
|
-
}
|
|
150
|
-
// Also clear the cookie fallback
|
|
151
|
-
try {
|
|
152
|
-
document.cookie = `${RELOAD_FLAG_KEY}=; max-age=0; path=/; SameSite=Lax`;
|
|
153
|
-
} catch {
|
|
154
|
-
// Cookie API unavailable
|
|
155
|
-
}
|
|
156
|
-
}
|