cursedbelt 5.12.0 → 5.12.1
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/react/components/ErrorBoundary.d.ts +10 -1
- package/dist/react/components/ErrorBoundary.d.ts.map +1 -1
- package/dist/react/components/ErrorBoundary.js +16 -22
- package/dist/react/components/ErrorBoundary.js.map +1 -1
- package/dist/react/lazy/staleChunk.d.ts +18 -1
- package/dist/react/lazy/staleChunk.d.ts.map +1 -1
- package/dist/react/lazy/staleChunk.js +44 -19
- package/dist/react/lazy/staleChunk.js.map +1 -1
- package/package.json +1 -1
- package/src/react/components/ErrorBoundary.tsx +16 -24
- package/src/react/components/StaleDeployHealer.spec.tsx +32 -1
- package/src/react/lazy/staleChunk.spec.tsx +23 -4
- package/src/react/lazy/staleChunk.ts +42 -17
|
@@ -43,7 +43,16 @@ export declare class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBo
|
|
|
43
43
|
* rather than "the app is broken" or "the server is unreachable".
|
|
44
44
|
*/
|
|
45
45
|
export declare function isStaleChunkError(error: unknown): boolean;
|
|
46
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* Whether this tab already reloaded for a stale chunk inside the last minute — and
|
|
48
|
+
* true when storage is unavailable, so a reload loop is impossible in the one
|
|
49
|
+
* environment where we cannot remember trying. Never throws.
|
|
50
|
+
*
|
|
51
|
+
* 🔴 The memory is `lazyWithReload`'s (`../lazy/staleChunk.ts`), shared on purpose.
|
|
52
|
+
* Until 2026-09-27 this was its own flag, once per tab per URL FOREVER: a tab left
|
|
53
|
+
* open across a SECOND deploy — a film playing for hours — got "could not be
|
|
54
|
+
* loaded" instead of a reload, and the two guards could add up to two reloads.
|
|
55
|
+
*/
|
|
47
56
|
export declare function staleChunkReloadAttempted(): boolean;
|
|
48
57
|
export interface StaleDeployHealerProps {
|
|
49
58
|
/** The caught error, shown as detail when the reload has already been tried. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ErrorBoundary.d.ts","sourceRoot":"","sources":["../../../src/react/components/ErrorBoundary.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,KAAK,SAAS,EAAE,KAAK,SAAS,EAAuB,MAAM,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"ErrorBoundary.d.ts","sourceRoot":"","sources":["../../../src/react/components/ErrorBoundary.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,KAAK,SAAS,EAAE,KAAK,SAAS,EAAuB,MAAM,OAAO,CAAC;AAIvF,OAAO,EAAwC,KAAK,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAerG;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,kBAAkB;IACjC,QAAQ,EAAE,SAAS,CAAC;IACpB,wFAAwF;IACxF,QAAQ,CAAC,EAAE,SAAS,GAAG,CAAC,CAAC,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,IAAI,KAAK,SAAS,CAAC,CAAC;IACxE,iEAAiE;IACjE,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,SAAS,KAAK,IAAI,CAAC;IAClD,qFAAqF;IACrF,SAAS,CAAC,EAAE,SAAS,OAAO,EAAE,CAAC;IAC/B;;;kFAG8E;IAC9E,QAAQ,CAAC,EAAE,OAAO,GAAG,iBAAiB,CAAC;CACxC;AAED,UAAU,kBAAkB;IAC1B,KAAK,EAAE,KAAK,GAAG,IAAI,CAAC;CACrB;AAQD,qBAAa,aAAc,SAAQ,SAAS,CAAC,kBAAkB,EAAE,kBAAkB,CAAC;IAClF,KAAK,EAAE,kBAAkB,CAAmB;IAE5C,MAAM,CAAC,wBAAwB,CAAC,KAAK,EAAE,KAAK,GAAG,kBAAkB;IAIjE,iBAAiB,CAAC,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,SAAS,GAAG,IAAI;IAItD,kBAAkB,CAAC,IAAI,EAAE,kBAAkB,GAAG,IAAI;IAMlD,KAAK,QAAO,IAAI,CAEd;IAEF,MAAM,IAAI,SAAS;CA0BpB;AA4CD;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAMzD;AAED;;;;;;;;;GASG;AACH,wBAAgB,yBAAyB,IAAI,OAAO,CAGnD;AAED,MAAM,WAAW,sBAAsB;IACrC,gFAAgF;IAChF,KAAK,CAAC,EAAE,KAAK,CAAC;IACd,4EAA4E;IAC5E,SAAS,CAAC,EAAE,MAAM,IAAI,CAAC;CACxB;AAED;;;;;GAKG;AACH,eAAO,MAAM,iBAAiB,GAAI,sBAAsB,sBAAsB,gCAqC7E,CAAC"}
|
|
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { Component, useEffect, useState } from 'react';
|
|
3
3
|
import { Alert } from './Alert.js';
|
|
4
4
|
import { Spinner } from './Spinner.js';
|
|
5
|
+
import { claimStaleChunkReload, staleChunkReloadedRecently } from '../lazy/staleChunk.js';
|
|
5
6
|
import { ConnectionHealer, isUnreachableError } from './ConnectionHealer.js';
|
|
6
7
|
const keysChanged = (a, b) => {
|
|
7
8
|
if (!a || !b)
|
|
@@ -39,7 +40,7 @@ export class ErrorBoundary extends Component {
|
|
|
39
40
|
// visualizer, the owner's report of 2026-08-22), and `isUnreachableError` matches
|
|
40
41
|
// the substring `failed to fetch`, so `autoHeal` would have claimed the server was
|
|
41
42
|
// unreachable while it answered every request. `StaleDeployHealer` reloads exactly
|
|
42
|
-
// once per tab per
|
|
43
|
+
// once per tab per minute and explains itself if that does not help.
|
|
43
44
|
if (isStaleChunkError(error))
|
|
44
45
|
return _jsx(StaleDeployHealer, { error: error });
|
|
45
46
|
if (autoHeal && isUnreachableError(error))
|
|
@@ -78,7 +79,7 @@ export class ErrorBoundary extends Component {
|
|
|
78
79
|
* hashes, and the route loads. If the chunk is genuinely missing from the
|
|
79
80
|
* deployed artifact rather than merely stale, reloading would loop — so the
|
|
80
81
|
* attempt is recorded in `sessionStorage` and only ever made ONCE per tab per
|
|
81
|
-
*
|
|
82
|
+
* minute (the memory `lazyWithReload` shares). The second failure stops and says what is actually wrong, which is a
|
|
82
83
|
* broken deployment and a sentence the owner can act on.
|
|
83
84
|
*
|
|
84
85
|
* Since 2026-08-22 the origin answers a missing build asset with a 404 rather
|
|
@@ -102,26 +103,20 @@ export function isStaleChunkError(error) {
|
|
|
102
103
|
const message = (error instanceof Error ? `${error.name} ${error.message}` : String(error)).toLowerCase();
|
|
103
104
|
return STALE_CHUNK_PATTERNS.some((p) => message.includes(p));
|
|
104
105
|
}
|
|
105
|
-
/**
|
|
106
|
-
|
|
107
|
-
|
|
106
|
+
/**
|
|
107
|
+
* Whether this tab already reloaded for a stale chunk inside the last minute — and
|
|
108
|
+
* true when storage is unavailable, so a reload loop is impossible in the one
|
|
109
|
+
* environment where we cannot remember trying. Never throws.
|
|
110
|
+
*
|
|
111
|
+
* 🔴 The memory is `lazyWithReload`'s (`../lazy/staleChunk.ts`), shared on purpose.
|
|
112
|
+
* Until 2026-09-27 this was its own flag, once per tab per URL FOREVER: a tab left
|
|
113
|
+
* open across a SECOND deploy — a film playing for hours — got "could not be
|
|
114
|
+
* loaded" instead of a reload, and the two guards could add up to two reloads.
|
|
115
|
+
*/
|
|
108
116
|
export function staleChunkReloadAttempted() {
|
|
109
|
-
|
|
110
|
-
return window.sessionStorage.getItem(attemptKey()) !== null;
|
|
111
|
-
}
|
|
112
|
-
catch {
|
|
113
|
-
// Private mode / disabled storage: treat as "already tried" so a reload loop
|
|
114
|
-
// is impossible in the one environment where we cannot remember trying.
|
|
117
|
+
if (typeof window === 'undefined')
|
|
115
118
|
return true;
|
|
116
|
-
|
|
117
|
-
}
|
|
118
|
-
function markStaleChunkReload() {
|
|
119
|
-
try {
|
|
120
|
-
window.sessionStorage.setItem(attemptKey(), '1');
|
|
121
|
-
}
|
|
122
|
-
catch {
|
|
123
|
-
// Nothing to do — `staleChunkReloadAttempted` fails closed for the same reason.
|
|
124
|
-
}
|
|
119
|
+
return staleChunkReloadedRecently();
|
|
125
120
|
}
|
|
126
121
|
/**
|
|
127
122
|
* Drop-in fallback for a stale-chunk error: reloads once, automatically, and if
|
|
@@ -134,11 +129,10 @@ export const StaleDeployHealer = ({ error, onRecover }) => {
|
|
|
134
129
|
useEffect(() => {
|
|
135
130
|
if (typeof window === 'undefined')
|
|
136
131
|
return;
|
|
137
|
-
if (
|
|
132
|
+
if (!claimStaleChunkReload()) {
|
|
138
133
|
setExhausted(true);
|
|
139
134
|
return;
|
|
140
135
|
}
|
|
141
|
-
markStaleChunkReload();
|
|
142
136
|
// A tick of delay so the banner paints first — an instant reload on a slow
|
|
143
137
|
// connection reads as a flicker with no explanation.
|
|
144
138
|
const timer = setTimeout(() => {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ErrorBoundary.js","sourceRoot":"","sources":["../../../src/react/components/ErrorBoundary.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,SAAS,EAAkC,SAAS,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AACvF,OAAO,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AACnC,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AACvC,OAAO,EAAE,gBAAgB,EAAE,kBAAkB,EAA0B,MAAM,uBAAuB,CAAC;AA+CrG,MAAM,WAAW,GAAG,CAAC,CAAsB,EAAE,CAAsB,EAAW,EAAE;IAC9E,IAAI,CAAC,CAAC,IAAI,CAAC,CAAC;QAAE,OAAO,CAAC,KAAK,CAAC,CAAC;IAC7B,IAAI,CAAC,CAAC,MAAM,KAAK,CAAC,CAAC,MAAM;QAAE,OAAO,IAAI,CAAC;IACvC,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,KAAK,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;AACvD,CAAC,CAAC;AAEF,MAAM,OAAO,aAAc,SAAQ,SAAiD;IAClF,KAAK,GAAuB,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC;IAE5C,MAAM,CAAC,wBAAwB,CAAC,KAAY;QAC1C,OAAO,EAAE,KAAK,EAAE,CAAC;IACnB,CAAC;IAED,iBAAiB,CAAC,KAAY,EAAE,IAAe;QAC7C,IAAI,CAAC,KAAK,CAAC,OAAO,EAAE,CAAC,KAAK,EAAE,IAAI,CAAC,CAAC;IACpC,CAAC;IAED,kBAAkB,CAAC,IAAwB;QACzC,IAAI,IAAI,CAAC,KAAK,CAAC,KAAK,IAAI,WAAW,CAAC,IAAI,CAAC,SAAS,EAAE,IAAI,CAAC,KAAK,CAAC,SAAS,CAAC,EAAE,CAAC;YAC1E,IAAI,CAAC,KAAK,EAAE,CAAC;QACf,CAAC;IACH,CAAC;IAED,KAAK,GAAG,GAAS,EAAE;QACjB,IAAI,CAAC,QAAQ,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;IACjC,CAAC,CAAC;IAEF,MAAM;QACJ,MAAM,EAAE,KAAK,EAAE,GAAG,IAAI,CAAC,KAAK,CAAC;QAC7B,IAAI,CAAC,KAAK;YAAE,OAAO,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC;QAEvC,MAAM,EAAE,QAAQ,EAAE,QAAQ,EAAE,GAAG,IAAI,CAAC,KAAK,CAAC;QAC1C,gFAAgF;QAChF,+EAA+E;QAC/E,+EAA+E;QAC/E,8EAA8E;QAC9E,2EAA2E;QAC3E,kFAAkF;QAClF,mFAAmF;QACnF,mFAAmF;QACnF,
|
|
1
|
+
{"version":3,"file":"ErrorBoundary.js","sourceRoot":"","sources":["../../../src/react/components/ErrorBoundary.tsx"],"names":[],"mappings":"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"}
|
|
@@ -17,7 +17,15 @@
|
|
|
17
17
|
* boundary, rather than reloading in a loop.
|
|
18
18
|
*
|
|
19
19
|
* The memory is `sessionStorage`, which is per tab AND per origin, so one generic
|
|
20
|
-
* key serves every app without two of them ever reading each other's.
|
|
20
|
+
* key serves every app without two of them ever reading each other's. It is a TIME,
|
|
21
|
+
* not a flag: a tab left open across two deploys (a film playing for hours) heals
|
|
22
|
+
* both times. `ErrorBoundary`'s `StaleDeployHealer` reads and writes the same
|
|
23
|
+
* memory, so the two never add up to more than one reload a minute.
|
|
24
|
+
*
|
|
25
|
+
* 🔴 It fails CLOSED. Storage this tab cannot read or write (site data blocked)
|
|
26
|
+
* means no reload at all — the error goes to the boundary, which explains it —
|
|
27
|
+
* because a chunk genuinely missing from the deployed build would otherwise reload
|
|
28
|
+
* forever with nothing able to remember trying.
|
|
21
29
|
*
|
|
22
30
|
* `guardrailsEnforce`'s `no-raw-lazy` is the WHETHER: a bare `React.lazy` in an app
|
|
23
31
|
* is refused, so the next lazy view cannot quietly reopen this.
|
|
@@ -31,6 +39,15 @@ export interface StaleChunkEnv {
|
|
|
31
39
|
reload: () => void;
|
|
32
40
|
now: () => number;
|
|
33
41
|
}
|
|
42
|
+
export declare function browserEnv(): StaleChunkEnv;
|
|
43
|
+
/**
|
|
44
|
+
* Claim this tab's one stale-chunk reload for the window: true when a reload may
|
|
45
|
+
* happen now, and the claim is already recorded. False when one happened inside the
|
|
46
|
+
* window, or when the memory cannot be read or written — fail closed, see the header.
|
|
47
|
+
*/
|
|
48
|
+
export declare function claimStaleChunkReload(env?: StaleChunkEnv): boolean;
|
|
49
|
+
/** Whether this tab reloaded for a stale chunk inside the window — true, too, when it cannot tell. */
|
|
50
|
+
export declare function staleChunkReloadedRecently(env?: StaleChunkEnv): boolean;
|
|
34
51
|
/** Wrap a dynamic `import()` so a chunk a deploy removed reloads the page instead of failing. */
|
|
35
52
|
export declare function reloadOnStaleChunk<T>(load: () => Promise<T>, env?: () => StaleChunkEnv): () => Promise<T>;
|
|
36
53
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"staleChunk.d.ts","sourceRoot":"","sources":["../../../src/react/lazy/staleChunk.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"staleChunk.d.ts","sourceRoot":"","sources":["../../../src/react/lazy/staleChunk.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,OAAO,EAAE,KAAK,aAAa,EAAE,KAAK,mBAAmB,EAAQ,MAAM,OAAO,CAAC;AAE3E,eAAO,MAAM,2BAA2B,kCAAkC,CAAC;AAE3E,6EAA6E;AAC7E,eAAO,MAAM,4BAA4B,QAAS,CAAC;AAEnD,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,IAAI,CAAC,OAAO,EAAE,SAAS,GAAG,SAAS,CAAC,GAAG,IAAI,CAAC;IACrD,MAAM,EAAE,MAAM,IAAI,CAAC;IACnB,GAAG,EAAE,MAAM,MAAM,CAAC;CACnB;AAED,wBAAgB,UAAU,IAAI,aAAa,CAQ1C;AAED;;;;GAIG;AACH,wBAAgB,qBAAqB,CAAC,GAAG,GAAE,aAA4B,GAAG,OAAO,CAShF;AAED,sGAAsG;AACtG,wBAAgB,0BAA0B,CAAC,GAAG,GAAE,aAA4B,GAAG,OAAO,CASrF;AAED,iGAAiG;AACjG,wBAAgB,kBAAkB,CAAC,CAAC,EAClC,IAAI,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,EACtB,GAAG,GAAE,MAAM,aAA0B,GACpC,MAAM,OAAO,CAAC,CAAC,CAAC,CAWlB;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,CAAC,SAAS,aAAa,CAAC,GAAG,CAAC,EACzD,IAAI,EAAE,MAAM,OAAO,CAAC;IAAE,OAAO,EAAE,CAAC,CAAA;CAAE,CAAC,EACnC,GAAG,CAAC,EAAE,MAAM,aAAa,GACxB,mBAAmB,CAAC,CAAC,CAAC,CAExB"}
|
|
@@ -17,7 +17,15 @@
|
|
|
17
17
|
* boundary, rather than reloading in a loop.
|
|
18
18
|
*
|
|
19
19
|
* The memory is `sessionStorage`, which is per tab AND per origin, so one generic
|
|
20
|
-
* key serves every app without two of them ever reading each other's.
|
|
20
|
+
* key serves every app without two of them ever reading each other's. It is a TIME,
|
|
21
|
+
* not a flag: a tab left open across two deploys (a film playing for hours) heals
|
|
22
|
+
* both times. `ErrorBoundary`'s `StaleDeployHealer` reads and writes the same
|
|
23
|
+
* memory, so the two never add up to more than one reload a minute.
|
|
24
|
+
*
|
|
25
|
+
* 🔴 It fails CLOSED. Storage this tab cannot read or write (site data blocked)
|
|
26
|
+
* means no reload at all — the error goes to the boundary, which explains it —
|
|
27
|
+
* because a chunk genuinely missing from the deployed build would otherwise reload
|
|
28
|
+
* forever with nothing able to remember trying.
|
|
21
29
|
*
|
|
22
30
|
* `guardrailsEnforce`'s `no-raw-lazy` is the WHETHER: a bare `React.lazy` in an app
|
|
23
31
|
* is refused, so the next lazy view cannot quietly reopen this.
|
|
@@ -26,16 +34,46 @@ import { lazy } from 'react';
|
|
|
26
34
|
export const STALE_CHUNK_RELOADED_AT_KEY = 'cursedbelt.staleChunkReloadAt';
|
|
27
35
|
/** A reload within this long of the last one is a loop, not a stale page. */
|
|
28
36
|
export const STALE_CHUNK_RELOAD_WINDOW_MS = 60_000;
|
|
29
|
-
function browserEnv() {
|
|
37
|
+
export function browserEnv() {
|
|
30
38
|
let storage = null;
|
|
31
39
|
try {
|
|
32
40
|
storage = window.sessionStorage;
|
|
33
41
|
}
|
|
34
42
|
catch {
|
|
35
|
-
// Storage refused (
|
|
43
|
+
// Storage refused (blocked site data) — no memory, so no reload: the guard fails closed.
|
|
36
44
|
}
|
|
37
45
|
return { storage, reload: () => window.location.reload(), now: Date.now };
|
|
38
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* Claim this tab's one stale-chunk reload for the window: true when a reload may
|
|
49
|
+
* happen now, and the claim is already recorded. False when one happened inside the
|
|
50
|
+
* window, or when the memory cannot be read or written — fail closed, see the header.
|
|
51
|
+
*/
|
|
52
|
+
export function claimStaleChunkReload(env = browserEnv()) {
|
|
53
|
+
if (staleChunkReloadedRecently(env))
|
|
54
|
+
return false;
|
|
55
|
+
const stamp = String(env.now());
|
|
56
|
+
try {
|
|
57
|
+
env.storage?.setItem(STALE_CHUNK_RELOADED_AT_KEY, stamp);
|
|
58
|
+
return env.storage?.getItem(STALE_CHUNK_RELOADED_AT_KEY) === stamp;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** Whether this tab reloaded for a stale chunk inside the window — true, too, when it cannot tell. */
|
|
65
|
+
export function staleChunkReloadedRecently(env = browserEnv()) {
|
|
66
|
+
const { storage, now } = env;
|
|
67
|
+
if (!storage)
|
|
68
|
+
return true;
|
|
69
|
+
try {
|
|
70
|
+
const last = Number(storage.getItem(STALE_CHUNK_RELOADED_AT_KEY) ?? 0) || 0;
|
|
71
|
+
return last > 0 && now() - last < STALE_CHUNK_RELOAD_WINDOW_MS;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
39
77
|
/** Wrap a dynamic `import()` so a chunk a deploy removed reloads the page instead of failing. */
|
|
40
78
|
export function reloadOnStaleChunk(load, env = browserEnv) {
|
|
41
79
|
return async () => {
|
|
@@ -43,23 +81,10 @@ export function reloadOnStaleChunk(load, env = browserEnv) {
|
|
|
43
81
|
return await load();
|
|
44
82
|
}
|
|
45
83
|
catch (error) {
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
try {
|
|
49
|
-
last = Number(storage?.getItem(STALE_CHUNK_RELOADED_AT_KEY) ?? 0) || 0;
|
|
50
|
-
}
|
|
51
|
-
catch {
|
|
52
|
-
// Unreadable memory is treated as no reload yet.
|
|
53
|
-
}
|
|
54
|
-
if (last > 0 && now() - last < STALE_CHUNK_RELOAD_WINDOW_MS)
|
|
84
|
+
const current = env();
|
|
85
|
+
if (!claimStaleChunkReload(current))
|
|
55
86
|
throw error;
|
|
56
|
-
|
|
57
|
-
storage?.setItem(STALE_CHUNK_RELOADED_AT_KEY, String(now()));
|
|
58
|
-
}
|
|
59
|
-
catch {
|
|
60
|
-
// Unwritable: the reload still happens; the guard just cannot remember it.
|
|
61
|
-
}
|
|
62
|
-
reload();
|
|
87
|
+
current.reload();
|
|
63
88
|
return new Promise(() => undefined);
|
|
64
89
|
}
|
|
65
90
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"staleChunk.js","sourceRoot":"","sources":["../../../src/react/lazy/staleChunk.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"staleChunk.js","sourceRoot":"","sources":["../../../src/react/lazy/staleChunk.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,OAAO,EAAgD,IAAI,EAAE,MAAM,OAAO,CAAC;AAE3E,MAAM,CAAC,MAAM,2BAA2B,GAAG,+BAA+B,CAAC;AAE3E,6EAA6E;AAC7E,MAAM,CAAC,MAAM,4BAA4B,GAAG,MAAM,CAAC;AAQnD,MAAM,UAAU,UAAU;IACxB,IAAI,OAAO,GAA6B,IAAI,CAAC;IAC7C,IAAI,CAAC;QACH,OAAO,GAAG,MAAM,CAAC,cAAc,CAAC;IAClC,CAAC;IAAC,MAAM,CAAC;QACP,yFAAyF;IAC3F,CAAC;IACD,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,QAAQ,CAAC,MAAM,EAAE,EAAE,GAAG,EAAE,IAAI,CAAC,GAAG,EAAE,CAAC;AAC5E,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,qBAAqB,CAAC,MAAqB,UAAU,EAAE;IACrE,IAAI,0BAA0B,CAAC,GAAG,CAAC;QAAE,OAAO,KAAK,CAAC;IAClD,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,CAAC,CAAC;IAChC,IAAI,CAAC;QACH,GAAG,CAAC,OAAO,EAAE,OAAO,CAAC,2BAA2B,EAAE,KAAK,CAAC,CAAC;QACzD,OAAO,GAAG,CAAC,OAAO,EAAE,OAAO,CAAC,2BAA2B,CAAC,KAAK,KAAK,CAAC;IACrE,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC;AAED,sGAAsG;AACtG,MAAM,UAAU,0BAA0B,CAAC,MAAqB,UAAU,EAAE;IAC1E,MAAM,EAAE,OAAO,EAAE,GAAG,EAAE,GAAG,GAAG,CAAC;IAC7B,IAAI,CAAC,OAAO;QAAE,OAAO,IAAI,CAAC;IAC1B,IAAI,CAAC;QACH,MAAM,IAAI,GAAG,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,2BAA2B,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,CAAC;QAC5E,OAAO,IAAI,GAAG,CAAC,IAAI,GAAG,EAAE,GAAG,IAAI,GAAG,4BAA4B,CAAC;IACjE,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAED,iGAAiG;AACjG,MAAM,UAAU,kBAAkB,CAChC,IAAsB,EACtB,MAA2B,UAAU;IAErC,OAAO,KAAK,IAAI,EAAE;QAChB,IAAI,CAAC;YACH,OAAO,MAAM,IAAI,EAAE,CAAC;QACtB,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,MAAM,OAAO,GAAG,GAAG,EAAE,CAAC;YACtB,IAAI,CAAC,qBAAqB,CAAC,OAAO,CAAC;gBAAE,MAAM,KAAK,CAAC;YACjD,OAAO,CAAC,MAAM,EAAE,CAAC;YACjB,OAAO,IAAI,OAAO,CAAI,GAAG,EAAE,CAAC,SAAS,CAAC,CAAC;QACzC,CAAC;IACH,CAAC,CAAC;AACJ,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,cAAc,CAC5B,IAAmC,EACnC,GAAyB;IAEzB,OAAO,IAAI,CAAC,kBAAkB,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC,CAAC;AAC7C,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedbelt",
|
|
3
|
-
"version": "5.12.
|
|
3
|
+
"version": "5.12.1",
|
|
4
4
|
"license": "ISC",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "The React design system of the cursedbelt split — components, styles, theme. cursedbelt-core below it; server tier in cursedbelt-server.",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Component, type ErrorInfo, type ReactNode, useEffect, useState } from 'react';
|
|
2
2
|
import { Alert } from './Alert.js';
|
|
3
3
|
import { Spinner } from './Spinner.js';
|
|
4
|
+
import { claimStaleChunkReload, staleChunkReloadedRecently } from '../lazy/staleChunk.js';
|
|
4
5
|
import { ConnectionHealer, isUnreachableError, type ServerHealOptions } from './ConnectionHealer.js';
|
|
5
6
|
|
|
6
7
|
/* ───────────────────────────────────────────────────────────────────────────
|
|
@@ -88,7 +89,7 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
|
|
|
88
89
|
// visualizer, the owner's report of 2026-08-22), and `isUnreachableError` matches
|
|
89
90
|
// the substring `failed to fetch`, so `autoHeal` would have claimed the server was
|
|
90
91
|
// unreachable while it answered every request. `StaleDeployHealer` reloads exactly
|
|
91
|
-
// once per tab per
|
|
92
|
+
// once per tab per minute and explains itself if that does not help.
|
|
92
93
|
if (isStaleChunkError(error)) return <StaleDeployHealer error={error} />;
|
|
93
94
|
if (autoHeal && isUnreachableError(error))
|
|
94
95
|
return <ConnectionHealer error={error} {...(autoHeal === true ? {} : autoHeal)} />;
|
|
@@ -129,7 +130,7 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
|
|
|
129
130
|
* hashes, and the route loads. If the chunk is genuinely missing from the
|
|
130
131
|
* deployed artifact rather than merely stale, reloading would loop — so the
|
|
131
132
|
* attempt is recorded in `sessionStorage` and only ever made ONCE per tab per
|
|
132
|
-
*
|
|
133
|
+
* minute (the memory `lazyWithReload` shares). The second failure stops and says what is actually wrong, which is a
|
|
133
134
|
* broken deployment and a sentence the owner can act on.
|
|
134
135
|
*
|
|
135
136
|
* Since 2026-08-22 the origin answers a missing build asset with a 404 rather
|
|
@@ -157,27 +158,19 @@ export function isStaleChunkError(error: unknown): boolean {
|
|
|
157
158
|
return STALE_CHUNK_PATTERNS.some((p) => message.includes(p));
|
|
158
159
|
}
|
|
159
160
|
|
|
160
|
-
/**
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
161
|
+
/**
|
|
162
|
+
* Whether this tab already reloaded for a stale chunk inside the last minute — and
|
|
163
|
+
* true when storage is unavailable, so a reload loop is impossible in the one
|
|
164
|
+
* environment where we cannot remember trying. Never throws.
|
|
165
|
+
*
|
|
166
|
+
* 🔴 The memory is `lazyWithReload`'s (`../lazy/staleChunk.ts`), shared on purpose.
|
|
167
|
+
* Until 2026-09-27 this was its own flag, once per tab per URL FOREVER: a tab left
|
|
168
|
+
* open across a SECOND deploy — a film playing for hours — got "could not be
|
|
169
|
+
* loaded" instead of a reload, and the two guards could add up to two reloads.
|
|
170
|
+
*/
|
|
165
171
|
export function staleChunkReloadAttempted(): boolean {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
} catch {
|
|
169
|
-
// Private mode / disabled storage: treat as "already tried" so a reload loop
|
|
170
|
-
// is impossible in the one environment where we cannot remember trying.
|
|
171
|
-
return true;
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
function markStaleChunkReload(): void {
|
|
176
|
-
try {
|
|
177
|
-
window.sessionStorage.setItem(attemptKey(), '1');
|
|
178
|
-
} catch {
|
|
179
|
-
// Nothing to do — `staleChunkReloadAttempted` fails closed for the same reason.
|
|
180
|
-
}
|
|
172
|
+
if (typeof window === 'undefined') return true;
|
|
173
|
+
return staleChunkReloadedRecently();
|
|
181
174
|
}
|
|
182
175
|
|
|
183
176
|
export interface StaleDeployHealerProps {
|
|
@@ -198,11 +191,10 @@ export const StaleDeployHealer = ({ error, onRecover }: StaleDeployHealerProps)
|
|
|
198
191
|
|
|
199
192
|
useEffect(() => {
|
|
200
193
|
if (typeof window === 'undefined') return;
|
|
201
|
-
if (
|
|
194
|
+
if (!claimStaleChunkReload()) {
|
|
202
195
|
setExhausted(true);
|
|
203
196
|
return;
|
|
204
197
|
}
|
|
205
|
-
markStaleChunkReload();
|
|
206
198
|
// A tick of delay so the banner paints first — an instant reload on a slow
|
|
207
199
|
// connection reads as a flicker with no explanation.
|
|
208
200
|
const timer = setTimeout(() => {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test';
|
|
2
2
|
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
|
3
|
+
import { STALE_CHUNK_RELOADED_AT_KEY } from '../lazy/staleChunk.js';
|
|
3
4
|
import { ErrorBoundary, isStaleChunkError, StaleDeployHealer } from './ErrorBoundary.js';
|
|
4
5
|
|
|
5
6
|
afterEach(() => {
|
|
@@ -105,3 +106,33 @@ describe('StaleDeployHealer recovers exactly once', () => {
|
|
|
105
106
|
expect(screen.getByText(/VisualizerView-9pEZSQqW\.js/)).toBeTruthy();
|
|
106
107
|
});
|
|
107
108
|
});
|
|
109
|
+
|
|
110
|
+
describe('StaleDeployHealer shares lazyWithReload\'s memory', () => {
|
|
111
|
+
/**
|
|
112
|
+
* 🔴 The old flag was once per tab per URL FOREVER, so a tab left open across a SECOND
|
|
113
|
+
* deploy — a film playing for hours — hit the dead end instead of reloading.
|
|
114
|
+
*/
|
|
115
|
+
test('a tab open across two deploys heals both', async () => {
|
|
116
|
+
let recovered = 0;
|
|
117
|
+
const first = render(<StaleDeployHealer error={OWNERS_ERROR} onRecover={() => recovered++} />);
|
|
118
|
+
await waitFor(() => expect(recovered).toBe(1), { timeout: 2000 });
|
|
119
|
+
first.unmount();
|
|
120
|
+
|
|
121
|
+
const twoHoursLater = Date.now() + 2 * 60 * 60_000;
|
|
122
|
+
const clock = spyOn(Date, 'now').mockImplementation(() => twoHoursLater);
|
|
123
|
+
try {
|
|
124
|
+
render(<StaleDeployHealer error={OWNERS_ERROR} onRecover={() => recovered++} />);
|
|
125
|
+
await waitFor(() => expect(recovered).toBe(2), { timeout: 2000 });
|
|
126
|
+
} finally {
|
|
127
|
+
clock.mockRestore();
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('a reload lazyWithReload just made is not repeated by the boundary', async () => {
|
|
132
|
+
window.sessionStorage.setItem(STALE_CHUNK_RELOADED_AT_KEY, String(Date.now() - 1000));
|
|
133
|
+
let recovered = 0;
|
|
134
|
+
render(<StaleDeployHealer error={OWNERS_ERROR} onRecover={() => recovered++} />);
|
|
135
|
+
await waitFor(() => expect(screen.getByText(/could not be loaded/i)).toBeTruthy());
|
|
136
|
+
expect(recovered).toBe(0);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -82,12 +82,31 @@ describe('a chunk a deploy removed', () => {
|
|
|
82
82
|
expect(state.reloads).toBe(2);
|
|
83
83
|
});
|
|
84
84
|
|
|
85
|
-
|
|
85
|
+
/*
|
|
86
|
+
* 🔴 Fail CLOSED. With no memory, a chunk genuinely missing from the deployed build
|
|
87
|
+
* would reload forever; the error goes to the boundary instead, which explains it.
|
|
88
|
+
*/
|
|
89
|
+
test('with no storage it never reloads — the error goes to the boundary', async () => {
|
|
86
90
|
const { state } = fakeEnv(1_000_000);
|
|
87
91
|
const env: StaleChunkEnv = { storage: null, reload: () => void state.reloads++, now: () => 5 };
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
92
|
+
await expect(reloadOnStaleChunk(gone, () => env)()).rejects.toThrow('dynamically imported');
|
|
93
|
+
expect(state.reloads).toBe(0);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('storage that refuses a write is no memory either — no reload', async () => {
|
|
97
|
+
const { state } = fakeEnv(1_000_000);
|
|
98
|
+
const env: StaleChunkEnv = {
|
|
99
|
+
storage: {
|
|
100
|
+
getItem: () => null,
|
|
101
|
+
setItem: () => {
|
|
102
|
+
throw new DOMException('blocked', 'SecurityError');
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
reload: () => void state.reloads++,
|
|
106
|
+
now: () => 5,
|
|
107
|
+
};
|
|
108
|
+
await expect(reloadOnStaleChunk(gone, () => env)()).rejects.toThrow('dynamically imported');
|
|
109
|
+
expect(state.reloads).toBe(0);
|
|
91
110
|
});
|
|
92
111
|
});
|
|
93
112
|
|
|
@@ -17,7 +17,15 @@
|
|
|
17
17
|
* boundary, rather than reloading in a loop.
|
|
18
18
|
*
|
|
19
19
|
* The memory is `sessionStorage`, which is per tab AND per origin, so one generic
|
|
20
|
-
* key serves every app without two of them ever reading each other's.
|
|
20
|
+
* key serves every app without two of them ever reading each other's. It is a TIME,
|
|
21
|
+
* not a flag: a tab left open across two deploys (a film playing for hours) heals
|
|
22
|
+
* both times. `ErrorBoundary`'s `StaleDeployHealer` reads and writes the same
|
|
23
|
+
* memory, so the two never add up to more than one reload a minute.
|
|
24
|
+
*
|
|
25
|
+
* 🔴 It fails CLOSED. Storage this tab cannot read or write (site data blocked)
|
|
26
|
+
* means no reload at all — the error goes to the boundary, which explains it —
|
|
27
|
+
* because a chunk genuinely missing from the deployed build would otherwise reload
|
|
28
|
+
* forever with nothing able to remember trying.
|
|
21
29
|
*
|
|
22
30
|
* `guardrailsEnforce`'s `no-raw-lazy` is the WHETHER: a bare `React.lazy` in an app
|
|
23
31
|
* is refused, so the next lazy view cannot quietly reopen this.
|
|
@@ -35,16 +43,44 @@ export interface StaleChunkEnv {
|
|
|
35
43
|
now: () => number;
|
|
36
44
|
}
|
|
37
45
|
|
|
38
|
-
function browserEnv(): StaleChunkEnv {
|
|
46
|
+
export function browserEnv(): StaleChunkEnv {
|
|
39
47
|
let storage: StaleChunkEnv['storage'] = null;
|
|
40
48
|
try {
|
|
41
49
|
storage = window.sessionStorage;
|
|
42
50
|
} catch {
|
|
43
|
-
// Storage refused (
|
|
51
|
+
// Storage refused (blocked site data) — no memory, so no reload: the guard fails closed.
|
|
44
52
|
}
|
|
45
53
|
return { storage, reload: () => window.location.reload(), now: Date.now };
|
|
46
54
|
}
|
|
47
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Claim this tab's one stale-chunk reload for the window: true when a reload may
|
|
58
|
+
* happen now, and the claim is already recorded. False when one happened inside the
|
|
59
|
+
* window, or when the memory cannot be read or written — fail closed, see the header.
|
|
60
|
+
*/
|
|
61
|
+
export function claimStaleChunkReload(env: StaleChunkEnv = browserEnv()): boolean {
|
|
62
|
+
if (staleChunkReloadedRecently(env)) return false;
|
|
63
|
+
const stamp = String(env.now());
|
|
64
|
+
try {
|
|
65
|
+
env.storage?.setItem(STALE_CHUNK_RELOADED_AT_KEY, stamp);
|
|
66
|
+
return env.storage?.getItem(STALE_CHUNK_RELOADED_AT_KEY) === stamp;
|
|
67
|
+
} catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Whether this tab reloaded for a stale chunk inside the window — true, too, when it cannot tell. */
|
|
73
|
+
export function staleChunkReloadedRecently(env: StaleChunkEnv = browserEnv()): boolean {
|
|
74
|
+
const { storage, now } = env;
|
|
75
|
+
if (!storage) return true;
|
|
76
|
+
try {
|
|
77
|
+
const last = Number(storage.getItem(STALE_CHUNK_RELOADED_AT_KEY) ?? 0) || 0;
|
|
78
|
+
return last > 0 && now() - last < STALE_CHUNK_RELOAD_WINDOW_MS;
|
|
79
|
+
} catch {
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
48
84
|
/** Wrap a dynamic `import()` so a chunk a deploy removed reloads the page instead of failing. */
|
|
49
85
|
export function reloadOnStaleChunk<T>(
|
|
50
86
|
load: () => Promise<T>,
|
|
@@ -54,20 +90,9 @@ export function reloadOnStaleChunk<T>(
|
|
|
54
90
|
try {
|
|
55
91
|
return await load();
|
|
56
92
|
} catch (error) {
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
last = Number(storage?.getItem(STALE_CHUNK_RELOADED_AT_KEY) ?? 0) || 0;
|
|
61
|
-
} catch {
|
|
62
|
-
// Unreadable memory is treated as no reload yet.
|
|
63
|
-
}
|
|
64
|
-
if (last > 0 && now() - last < STALE_CHUNK_RELOAD_WINDOW_MS) throw error;
|
|
65
|
-
try {
|
|
66
|
-
storage?.setItem(STALE_CHUNK_RELOADED_AT_KEY, String(now()));
|
|
67
|
-
} catch {
|
|
68
|
-
// Unwritable: the reload still happens; the guard just cannot remember it.
|
|
69
|
-
}
|
|
70
|
-
reload();
|
|
93
|
+
const current = env();
|
|
94
|
+
if (!claimStaleChunkReload(current)) throw error;
|
|
95
|
+
current.reload();
|
|
71
96
|
return new Promise<T>(() => undefined);
|
|
72
97
|
}
|
|
73
98
|
};
|