cloudflare-next-intl 0.9.52 → 0.9.54
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/README.md +3 -1
- package/dist/src/error_handling/install_global_error_override.js +16 -0
- package/dist/src/error_handling/use_stale_deploy_recovery.d.ts +1 -1
- package/dist/src/error_handling/use_stale_deploy_recovery.js +26 -2
- package/dist/src/locale_params_check/check_locale_params.js +14 -1
- package/dist/src/locale_params_check/insert_locale_params.d.ts +2 -0
- package/dist/src/locale_params_check/insert_locale_params.js +130 -0
- package/dist/src/server/components/helper_script.js +25 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -690,7 +690,9 @@ through `@intl-config`.
|
|
|
690
690
|
|
|
691
691
|
When a new version of your application is deployed to Cloudflare Workers, users on older client sessions may encounter `ChunkLoadError` or failed dynamic imports when requesting outdated chunks.
|
|
692
692
|
|
|
693
|
-
`IntlHelperScript` renders an early-catch `<script>` (production only, id `stale-deploy-early-catch`) that runs before hydration and listens for `window.error`/`unhandledrejection` events matching the same patterns as `isStaleDeployError` (inlined as JSON, so it stays in sync with `staleDeployPatterns` config), then force-reloads
|
|
693
|
+
`IntlHelperScript` renders an early-catch `<script>` (production only, id `stale-deploy-early-catch`) that runs before hydration and listens for `window.error`/`unhandledrejection` events matching the same patterns as `isStaleDeployError` (inlined as JSON, so it stays in sync with `staleDeployPatterns` config), then force-reloads (throttled to once per 15s, capped at 2 attempts per build id — a 3rd stale-deploy error for the same build id falls through instead of reloading again; the count resets once a new build id is seen). It also listens for `error` events during the capture phase to catch resource-load failures (a same-origin chunk `<script>`/`<link>` 404ing or served with a disallowed MIME type), which fire a non-bubbling, message-less `error` event on the element itself rather than surfacing as a catchable message; a failed third-party resource (analytics, reCAPTCHA, etc.) is ignored, since only the app's own build output can break its module graph. This covers the case a React-level recovery (`useStaleDeployRecovery` below) cannot: when the chunk that failed to load is part of your own error boundary/global-error bundle, React never gets a chance to render the recovery UI. Both layers share the same `sessionStorage['stale-deploy-recovery-reloaded']` marker keyed by build id, so they can't double-reload each other. No setup beyond rendering `<IntlHelperScript />` is required.
|
|
694
|
+
|
|
695
|
+
`installGlobalErrorOverride` (used internally by error reporting setup) also reports these same-tree resource-load failures — a failed `script`/`link` load is reported via `reportError` instead of being silently dropped, since the browser gives it no bubbling event or message to catch any other way.
|
|
694
696
|
|
|
695
697
|
For errors that don't crash the module graph itself (a normal thrown error reaching an error boundary), use `isStaleDeployError` and `clearClientCache` in error boundaries or global error handlers to automatically recover:
|
|
696
698
|
|
|
@@ -16,6 +16,22 @@ export default function installGlobalErrorOverride(config) {
|
|
|
16
16
|
isClient: true,
|
|
17
17
|
});
|
|
18
18
|
});
|
|
19
|
+
window.addEventListener('error', (event) => {
|
|
20
|
+
const el = event.target;
|
|
21
|
+
if (!el || el === window)
|
|
22
|
+
return;
|
|
23
|
+
const tag = el.tagName?.toLowerCase();
|
|
24
|
+
if (tag !== 'script' && tag !== 'link')
|
|
25
|
+
return;
|
|
26
|
+
const src = el.src || el.href;
|
|
27
|
+
if (!src)
|
|
28
|
+
return;
|
|
29
|
+
void reportError(config, {
|
|
30
|
+
error: `Failed to load ${tag} resource: ${src}`,
|
|
31
|
+
classOrMethodName: 'Global Resource Error Handler',
|
|
32
|
+
isClient: true,
|
|
33
|
+
});
|
|
34
|
+
}, true);
|
|
19
35
|
window.addEventListener('unhandledrejection', (event) => {
|
|
20
36
|
void reportError(config, {
|
|
21
37
|
error: event.reason,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export declare function isRecentBuild(setAt: number | null, now: number, windowMs?: number): boolean;
|
|
2
|
-
export declare function shouldRecoverFromStaleDeploy(error: unknown, buildId: string, marker: string | null, recentBuild?: boolean, reloadTime?: number | null, now?: number, throttleMs?: number): boolean;
|
|
2
|
+
export declare function shouldRecoverFromStaleDeploy(error: unknown, buildId: string, marker: string | null, recentBuild?: boolean, reloadTime?: number | null, now?: number, throttleMs?: number, attempts?: number, maxAttempts?: number): boolean;
|
|
3
3
|
export declare function performCacheBustReload(): void;
|
|
4
4
|
export default function useStaleDeployRecovery(error: unknown, onRecover?: () => Promise<unknown>, delayMs?: number): boolean;
|
|
@@ -4,6 +4,8 @@ import isStaleDeployError from './is_stale_deploy_error.js';
|
|
|
4
4
|
import clearClientCache from './clear_client_cache.js';
|
|
5
5
|
const RECOVERY_RELOAD_KEY = 'stale-deploy-recovery-reloaded';
|
|
6
6
|
const RECOVERY_TIME_KEY = 'stale-deploy-recovery-time';
|
|
7
|
+
const RECOVERY_COUNT_KEY = 'stale-deploy-recovery-count';
|
|
8
|
+
const MAX_RECOVERY_ATTEMPTS = 2;
|
|
7
9
|
const BUILD_ID_KEY = 'buildId';
|
|
8
10
|
const BUILD_ID_SET_AT_KEY = 'buildIdSetAt';
|
|
9
11
|
const RECENT_BUILD_WINDOW_MS = 60000;
|
|
@@ -28,16 +30,33 @@ function buildIdSetAt() {
|
|
|
28
30
|
export function isRecentBuild(setAt, now, windowMs = RECENT_BUILD_WINDOW_MS) {
|
|
29
31
|
return setAt !== null && now - setAt < windowMs;
|
|
30
32
|
}
|
|
31
|
-
export function shouldRecoverFromStaleDeploy(error, buildId, marker, recentBuild = false, reloadTime = null, now = Date.now(), throttleMs = RELOAD_THROTTLE_MS) {
|
|
33
|
+
export function shouldRecoverFromStaleDeploy(error, buildId, marker, recentBuild = false, reloadTime = null, now = Date.now(), throttleMs = RELOAD_THROTTLE_MS, attempts = 0, maxAttempts = MAX_RECOVERY_ATTEMPTS) {
|
|
32
34
|
if (!isStaleDeployError(error))
|
|
33
35
|
return false;
|
|
34
36
|
const isRecentlyReloaded = reloadTime !== null && now - reloadTime < throttleMs;
|
|
35
37
|
const isSameBuildMarker = marker !== null && marker !== '' && (buildId === 'unknown' || marker === buildId);
|
|
38
|
+
if (isSameBuildMarker && attempts >= maxAttempts) {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
36
41
|
if (isSameBuildMarker && isRecentlyReloaded && !recentBuild) {
|
|
37
42
|
return false;
|
|
38
43
|
}
|
|
39
44
|
return true;
|
|
40
45
|
}
|
|
46
|
+
function currentAttempts(buildId, marker) {
|
|
47
|
+
if (marker === null || marker === '')
|
|
48
|
+
return 0;
|
|
49
|
+
if (buildId !== 'unknown' && marker !== buildId)
|
|
50
|
+
return 0;
|
|
51
|
+
try {
|
|
52
|
+
const raw = sessionStorage.getItem(RECOVERY_COUNT_KEY);
|
|
53
|
+
const parsed = raw ? Number(raw) : 0;
|
|
54
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
41
60
|
function canRecover(error) {
|
|
42
61
|
if (typeof window === 'undefined')
|
|
43
62
|
return false;
|
|
@@ -48,7 +67,8 @@ function canRecover(error) {
|
|
|
48
67
|
const marker = sessionStorage.getItem(RECOVERY_RELOAD_KEY);
|
|
49
68
|
const isRecent = isRecentBuild(buildIdSetAt(), Date.now());
|
|
50
69
|
const isStale = isStaleDeployError(error);
|
|
51
|
-
const
|
|
70
|
+
const attempts = currentAttempts(bId, marker);
|
|
71
|
+
const result = shouldRecoverFromStaleDeploy(error, bId, marker, isRecent, reloadTime, Date.now(), RELOAD_THROTTLE_MS, attempts);
|
|
52
72
|
console.warn('[useStaleDeployRecovery]', {
|
|
53
73
|
error,
|
|
54
74
|
isStale,
|
|
@@ -56,6 +76,7 @@ function canRecover(error) {
|
|
|
56
76
|
marker,
|
|
57
77
|
isRecent,
|
|
58
78
|
reloadTime,
|
|
79
|
+
attempts,
|
|
59
80
|
result,
|
|
60
81
|
});
|
|
61
82
|
return result;
|
|
@@ -89,7 +110,10 @@ export default function useStaleDeployRecovery(error, onRecover, delayMs = 1000)
|
|
|
89
110
|
Promise.all([initialOnRecover?.().catch(() => undefined), clearClientCache().catch(() => undefined)])
|
|
90
111
|
.finally(() => {
|
|
91
112
|
try {
|
|
113
|
+
const marker = sessionStorage.getItem(RECOVERY_RELOAD_KEY);
|
|
114
|
+
const spent = currentAttempts(buildId, marker);
|
|
92
115
|
sessionStorage.setItem(RECOVERY_RELOAD_KEY, buildId);
|
|
116
|
+
sessionStorage.setItem(RECOVERY_COUNT_KEY, String(spent + 1));
|
|
93
117
|
sessionStorage.setItem(RECOVERY_TIME_KEY, String(Date.now()));
|
|
94
118
|
}
|
|
95
119
|
catch { }
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { relative } from 'node:path';
|
|
3
3
|
import { detectLocaleParams } from './detect_locale_params.js';
|
|
4
|
-
import { insertLocaleParamsSignature, insertLocaleParamsBody, ensureLocaleInParamsType, addParamsPropToExistingDestructure, ensureSetLocaleImport } from './insert_locale_params.js';
|
|
4
|
+
import { insertLocaleParamsSignature, insertLocaleParamsBody, ensureLocaleInParamsType, addParamsPropToExistingDestructure, ensureSetLocaleImport, wrapSyncDefaultExportWithParams, extractParamsPromiseType } from './insert_locale_params.js';
|
|
5
5
|
import { findLocaleScopedFiles } from './find_locale_scoped_files.js';
|
|
6
6
|
import { deriveRoute, makePageLabeler } from '../dynamic_pages_check/derive_page_label.js';
|
|
7
7
|
const ZERO_ARG_DEFAULT_EXPORT = /export\s+default\s+(async\s+)?function\s+[A-Za-z_$][\w$]*\s*\(\s*\)/;
|
|
8
|
+
const SYNC_DEFAULT_EXPORT = /export\s+default\s+function\s+[A-Za-z_$][\w$]*\s*\(/;
|
|
8
9
|
const LEGEND = '✓ Set up + Added ? Needs manual edit · Skipped';
|
|
9
10
|
function actionGlyph(action) {
|
|
10
11
|
switch (action) {
|
|
@@ -76,6 +77,18 @@ export async function checkLocaleParams(options, io = {}) {
|
|
|
76
77
|
reports.push({ file, action: 'would-add-locale-params' });
|
|
77
78
|
continue;
|
|
78
79
|
}
|
|
80
|
+
const isSyncDefaultExport = !detection.hasInlineDestructure && SYNC_DEFAULT_EXPORT.test(source);
|
|
81
|
+
if (isSyncDefaultExport && (isZeroArg || canAddParamsKey || canReuseExistingParams)) {
|
|
82
|
+
const existingParamsType = canReuseExistingParams ? extractParamsPromiseType(source) ?? undefined : undefined;
|
|
83
|
+
const wrapped = wrapSyncDefaultExportWithParams(source, localeParam, existingParamsType);
|
|
84
|
+
if (wrapped === source) {
|
|
85
|
+
reports.push({ file, action: 'needs-manual-edit' });
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
writeFile(file, ensureSetLocaleImport(wrapped));
|
|
89
|
+
reports.push({ file, action: 'added-locale-params' });
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
79
92
|
let updated = source;
|
|
80
93
|
if (isZeroArg) {
|
|
81
94
|
updated = insertLocaleParamsSignature(updated, localeParam);
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export declare function insertLocaleParamsSignature(sourceText: string, localeParam: string): string;
|
|
2
|
+
export declare function wrapSyncDefaultExportWithParams(sourceText: string, localeParam: string, existingParamsType?: string): string;
|
|
2
3
|
export declare function addParamsPropToExistingDestructure(sourceText: string, localeParam: string): string;
|
|
3
4
|
export declare function insertLocaleParamsBody(sourceText: string, localeParam: string, hasInlineDestructure: boolean): string;
|
|
5
|
+
export declare function extractParamsPromiseType(sourceText: string): string | null;
|
|
4
6
|
export declare function ensureLocaleInParamsType(sourceText: string, localeParam: string): string;
|
|
5
7
|
export declare function ensureSetLocaleImport(sourceText: string): string;
|
|
@@ -18,7 +18,134 @@ export function insertLocaleParamsSignature(sourceText, localeParam) {
|
|
|
18
18
|
const replacement = `({ params }: {\n params: Promise<{ ${localeParam}: Language }>;\n})`;
|
|
19
19
|
return sourceText.slice(0, parensStart) + replacement + sourceText.slice(parensEnd);
|
|
20
20
|
}
|
|
21
|
+
const SYNC_DEFAULT_EXPORT_FUNCTION = /export\s+default\s+function\s+([A-Za-z_$][\w$]*)\s*\(/;
|
|
22
|
+
function findUnusedContentName(sourceText, baseName) {
|
|
23
|
+
let candidate = `${baseName}ContentCloudflareNextIntl`;
|
|
24
|
+
let suffix = 2;
|
|
25
|
+
while (new RegExp(`\\b${candidate}\\b`).test(sourceText)) {
|
|
26
|
+
candidate = `${baseName}ContentCloudflareNextIntl${suffix}`;
|
|
27
|
+
suffix += 1;
|
|
28
|
+
}
|
|
29
|
+
return candidate;
|
|
30
|
+
}
|
|
21
31
|
const DEFAULT_EXPORT_FUNCTION_OPEN_PAREN = /export\s+default\s+(?:async\s+)?function\s+[A-Za-z_$][\w$]*\s*\(/;
|
|
32
|
+
function findParamListSpan(sourceText) {
|
|
33
|
+
const openParenMatch = DEFAULT_EXPORT_FUNCTION_OPEN_PAREN.exec(sourceText);
|
|
34
|
+
if (openParenMatch === null)
|
|
35
|
+
return null;
|
|
36
|
+
const start = openParenMatch.index + openParenMatch[0].length - 1;
|
|
37
|
+
let depth = 0;
|
|
38
|
+
for (let i = start; i < sourceText.length; i++) {
|
|
39
|
+
if (sourceText[i] === '(')
|
|
40
|
+
depth++;
|
|
41
|
+
else if (sourceText[i] === ')') {
|
|
42
|
+
depth--;
|
|
43
|
+
if (depth === 0)
|
|
44
|
+
return { start, end: i + 1 };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
function destructuredKeyNames(inner) {
|
|
50
|
+
const names = [];
|
|
51
|
+
let depth = 0;
|
|
52
|
+
let start = 0;
|
|
53
|
+
const parts = [];
|
|
54
|
+
for (let i = 0; i <= inner.length; i++) {
|
|
55
|
+
const char = inner[i];
|
|
56
|
+
if (i === inner.length || (char === ',' && depth === 0)) {
|
|
57
|
+
parts.push(inner.slice(start, i));
|
|
58
|
+
start = i + 1;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (char === '{' || char === '(' || char === '[')
|
|
62
|
+
depth++;
|
|
63
|
+
else if (char === '}' || char === ')' || char === ']')
|
|
64
|
+
depth--;
|
|
65
|
+
}
|
|
66
|
+
for (const part of parts) {
|
|
67
|
+
const trimmed = part.trim();
|
|
68
|
+
if (trimmed === '' || trimmed.startsWith('...'))
|
|
69
|
+
continue;
|
|
70
|
+
const key = trimmed.split(':')[0].split('=')[0].trim();
|
|
71
|
+
if (/^[A-Za-z_$][\w$]*$/.test(key))
|
|
72
|
+
names.push(key);
|
|
73
|
+
}
|
|
74
|
+
return names;
|
|
75
|
+
}
|
|
76
|
+
export function wrapSyncDefaultExportWithParams(sourceText, localeParam, existingParamsType) {
|
|
77
|
+
const nameMatch = SYNC_DEFAULT_EXPORT_FUNCTION.exec(sourceText);
|
|
78
|
+
if (nameMatch === null)
|
|
79
|
+
return sourceText;
|
|
80
|
+
const name = nameMatch[1];
|
|
81
|
+
const paramList = findParamListSpan(sourceText);
|
|
82
|
+
if (paramList === null)
|
|
83
|
+
return sourceText;
|
|
84
|
+
const bodyStart = findFunctionBodyStart(sourceText);
|
|
85
|
+
if (bodyStart === null)
|
|
86
|
+
return sourceText;
|
|
87
|
+
const bodyEnd = findMatchingBraceEnd(sourceText, bodyStart - 1);
|
|
88
|
+
if (bodyEnd === null)
|
|
89
|
+
return sourceText;
|
|
90
|
+
const contentName = findUnusedContentName(sourceText, name);
|
|
91
|
+
const originalParams = sourceText.slice(paramList.start, paramList.end);
|
|
92
|
+
const originalBody = sourceText.slice(bodyStart, bodyEnd - 1);
|
|
93
|
+
let forwardKeys = [];
|
|
94
|
+
let forwardKeyTypes = [];
|
|
95
|
+
if (originalParams !== '()') {
|
|
96
|
+
const openBrace = originalParams.indexOf('{');
|
|
97
|
+
if (openBrace === -1)
|
|
98
|
+
return sourceText;
|
|
99
|
+
const keysEnd = findMatchingBraceEnd(originalParams, openBrace);
|
|
100
|
+
if (keysEnd === null)
|
|
101
|
+
return sourceText;
|
|
102
|
+
forwardKeys = destructuredKeyNames(originalParams.slice(openBrace + 1, keysEnd - 1));
|
|
103
|
+
let j = keysEnd;
|
|
104
|
+
while (j < originalParams.length && /\s/.test(originalParams[j]))
|
|
105
|
+
j++;
|
|
106
|
+
if (originalParams[j] === ':') {
|
|
107
|
+
j++;
|
|
108
|
+
while (j < originalParams.length && /\s/.test(originalParams[j]))
|
|
109
|
+
j++;
|
|
110
|
+
if (originalParams[j] === '{') {
|
|
111
|
+
const typeEnd = findMatchingBraceEnd(originalParams, j);
|
|
112
|
+
if (typeEnd !== null) {
|
|
113
|
+
const typeBody = originalParams.slice(j + 1, typeEnd - 1).trim();
|
|
114
|
+
forwardKeyTypes = typeBody === '' ? [] : typeBody.split(';').map((s) => s.trim()).filter(Boolean);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const otherForwardKeys = forwardKeys.filter((key) => key !== 'params');
|
|
120
|
+
const otherForwardKeyTypes = forwardKeyTypes.filter((type) => !/^params\s*:/.test(type));
|
|
121
|
+
const contentFunction = `function ${contentName}${originalParams} {${originalBody}}`;
|
|
122
|
+
const alreadyHasLocaleParam = existingParamsType !== undefined && new RegExp(`\\b${localeParam}\\b`).test(existingParamsType);
|
|
123
|
+
const wrapperParamsType = existingParamsType === undefined
|
|
124
|
+
? `${localeParam}: Language`
|
|
125
|
+
: alreadyHasLocaleParam
|
|
126
|
+
? existingParamsType
|
|
127
|
+
: `${existingParamsType.replace(/;?\s*$/, '')}; ${localeParam}: Language`;
|
|
128
|
+
const wrapperSignature = otherForwardKeys.length === 0 ? `{ params }` : `{ ${otherForwardKeys.join(', ')}, params }`;
|
|
129
|
+
const forwardedJsx = forwardKeys.map((key) => (key === 'params' ? ` params={params}` : ` ${key}={${key}}`)).join('');
|
|
130
|
+
const forwardedTypeLines = otherForwardKeyTypes.map((type) => ` ${type.replace(/;?\s*$/, '')};`);
|
|
131
|
+
const wrapperFunction = [
|
|
132
|
+
`// Auto-inserted by cloudflare-next-intl's checkLocaleParams (mode: "fix") — `
|
|
133
|
+
+ `"${name}" was split into this async wrapper plus the sync "${contentName}" above `
|
|
134
|
+
+ `it (see that function's own body, unchanged) so \`await params\` never lands in a `
|
|
135
|
+
+ `function this scan didn't confirm was safe to make async. To opt this file out on a `
|
|
136
|
+
+ `later run, pass it in checkLocaleParams' \`skip\` list instead of hand-editing here.`,
|
|
137
|
+
`export default async function ${name}(${wrapperSignature}: {`,
|
|
138
|
+
...forwardedTypeLines,
|
|
139
|
+
` params: Promise<{ ${wrapperParamsType} }>;`,
|
|
140
|
+
`}) {`,
|
|
141
|
+
` const { ${localeParam} } = await params;`,
|
|
142
|
+
` setLocale(${localeParam});`,
|
|
143
|
+
``,
|
|
144
|
+
` return <${contentName}${forwardedJsx} />;`,
|
|
145
|
+
`}`,
|
|
146
|
+
].join('\n');
|
|
147
|
+
return sourceText.slice(0, nameMatch.index) + contentFunction + '\n\n' + wrapperFunction + sourceText.slice(bodyEnd);
|
|
148
|
+
}
|
|
22
149
|
function findMatchingBraceEnd(code, openBraceIndex) {
|
|
23
150
|
let depth = 0;
|
|
24
151
|
for (let i = openBraceIndex; i < code.length; i++) {
|
|
@@ -90,6 +217,9 @@ export function insertLocaleParamsBody(sourceText, localeParam, hasInlineDestruc
|
|
|
90
217
|
return sourceText.slice(0, bodyStart) + line + sourceText.slice(bodyStart);
|
|
91
218
|
}
|
|
92
219
|
const PARAMS_PROMISE_TYPE = /params\s*:\s*Promise<\{([^}]*)\}>/;
|
|
220
|
+
export function extractParamsPromiseType(sourceText) {
|
|
221
|
+
return PARAMS_PROMISE_TYPE.exec(sourceText)?.[1]?.trim() ?? null;
|
|
222
|
+
}
|
|
93
223
|
export function ensureLocaleInParamsType(sourceText, localeParam) {
|
|
94
224
|
const match = PARAMS_PROMISE_TYPE.exec(sourceText);
|
|
95
225
|
if (match === null)
|
|
@@ -18,6 +18,8 @@ export default function HelperScript() {
|
|
|
18
18
|
var patterns = ${JSON.stringify(defaultStaleDeployPatterns)};
|
|
19
19
|
var key = 'stale-deploy-recovery-reloaded';
|
|
20
20
|
var timeKey = 'stale-deploy-recovery-time';
|
|
21
|
+
var countKey = 'stale-deploy-recovery-count';
|
|
22
|
+
var maxAttempts = 2;
|
|
21
23
|
var throttleMs = 15000;
|
|
22
24
|
var attemptedThisLoad = false;
|
|
23
25
|
function isStale(msg) {
|
|
@@ -39,12 +41,27 @@ export default function HelperScript() {
|
|
|
39
41
|
var lastRaw = sessionStorage.getItem(timeKey);
|
|
40
42
|
var last = lastRaw ? Number(lastRaw) : null;
|
|
41
43
|
var throttled = last !== null && (Date.now() - last) < throttleMs;
|
|
42
|
-
|
|
43
|
-
|
|
44
|
+
// Attempts are counted per build id: the 1st and 2nd
|
|
45
|
+
// page load may each recover, the 3rd falls through to
|
|
46
|
+
// the error UI. A new deploy resets the count.
|
|
47
|
+
var sameBuild = marker === buildId;
|
|
48
|
+
var attempts = 0;
|
|
49
|
+
if (sameBuild) {
|
|
50
|
+
var rawCount = sessionStorage.getItem(countKey);
|
|
51
|
+
attempts = rawCount ? Number(rawCount) : 0;
|
|
52
|
+
if (!(attempts >= 0)) attempts = 0;
|
|
53
|
+
}
|
|
54
|
+
if (sameBuild && attempts >= maxAttempts) {
|
|
55
|
+
console.warn('[StaleDeploy early-catch] Skipping reload, attempts exhausted for buildId:', buildId, attempts);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (sameBuild && throttled) {
|
|
59
|
+
console.warn('[StaleDeploy early-catch] Skipping reload, throttled for buildId:', buildId);
|
|
44
60
|
return;
|
|
45
61
|
}
|
|
46
62
|
attemptedThisLoad = true;
|
|
47
63
|
sessionStorage.setItem(key, buildId);
|
|
64
|
+
sessionStorage.setItem(countKey, String(attempts + 1));
|
|
48
65
|
sessionStorage.setItem(timeKey, String(Date.now()));
|
|
49
66
|
try {
|
|
50
67
|
if (document.documentElement) {
|
|
@@ -79,6 +96,12 @@ export default function HelperScript() {
|
|
|
79
96
|
if (tag !== 'script' && tag !== 'link') return;
|
|
80
97
|
var src = el.src || el.href || '';
|
|
81
98
|
if (!src) return;
|
|
99
|
+
// Only our own build output can break the React module
|
|
100
|
+
// graph. A failed third-party script (analytics,
|
|
101
|
+
// reCAPTCHA) must never trigger a reload.
|
|
102
|
+
var sameOrigin = false;
|
|
103
|
+
try { sameOrigin = new URL(src, window.location.href).origin === window.location.origin; } catch (err2) { return; }
|
|
104
|
+
if (!sameOrigin) return;
|
|
82
105
|
recover('chunk resource failed to load: ' + src, 'resource-error');
|
|
83
106
|
} catch (err) {}
|
|
84
107
|
}, true);
|