cursedbelt 5.113.0 → 5.113.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.map +1 -1
- package/dist/react/components/ErrorBoundary.js +23 -13
- package/dist/react/components/ErrorBoundary.js.map +1 -1
- package/dist/react/lazy/staleChunk.d.ts +19 -0
- package/dist/react/lazy/staleChunk.d.ts.map +1 -1
- package/dist/react/lazy/staleChunk.js +49 -2
- package/dist/react/lazy/staleChunk.js.map +1 -1
- package/package.json +1 -1
- package/src/react/components/ErrorBoundary.tsx +19 -11
- package/src/react/components/StaleDeployHealer.spec.tsx +27 -0
- package/src/react/lazy/staleChunk.spec.tsx +63 -1
- package/src/react/lazy/staleChunk.ts +55 -2
|
@@ -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;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,+BAA+B,sBAAsB,
|
|
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,+BAA+B,sBAAsB,gCA8CtF,CAAC"}
|
|
@@ -2,7 +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
|
+
import { claimStaleChunkReload, leavingFor, staleChunkReloadedRecently } from '../lazy/staleChunk.js';
|
|
6
6
|
import { ConnectionHealer, isUnreachableError } from './ConnectionHealer.js';
|
|
7
7
|
const keysChanged = (a, b) => {
|
|
8
8
|
if (!a || !b)
|
|
@@ -129,18 +129,28 @@ export const StaleDeployHealer = ({ error, onRecover, ...rest }) => {
|
|
|
129
129
|
useEffect(() => {
|
|
130
130
|
if (typeof window === 'undefined')
|
|
131
131
|
return;
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
132
|
+
let timer;
|
|
133
|
+
const heal = () => {
|
|
134
|
+
if (!claimStaleChunkReload()) {
|
|
135
|
+
setExhausted(true);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
// A tick of delay so the banner paints first — an instant reload on a slow
|
|
139
|
+
// connection reads as a flicker with no explanation.
|
|
140
|
+
timer = setTimeout(() => {
|
|
141
|
+
if (onRecover)
|
|
142
|
+
onRecover();
|
|
143
|
+
else
|
|
144
|
+
window.location.reload();
|
|
145
|
+
}, 400);
|
|
146
|
+
};
|
|
147
|
+
// A chunk cut off by the page being left (Safari cancels its loads at `beforeunload`) is
|
|
148
|
+
// not stale, and a reload would pull the tab back from where it was going — `staleChunk.ts`.
|
|
149
|
+
const leaving = leavingFor();
|
|
150
|
+
if (leaving > 0)
|
|
151
|
+
timer = setTimeout(heal, leaving);
|
|
152
|
+
else
|
|
153
|
+
heal();
|
|
144
154
|
return () => clearTimeout(timer);
|
|
145
155
|
}, [onRecover]);
|
|
146
156
|
if (exhausted)
|
|
@@ -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,qBAAqB,EAAE,0BAA0B,EAAE,MAAM,uBAAuB,CAAC;
|
|
1
|
+
{"version":3,"file":"ErrorBoundary.js","sourceRoot":"","sources":["../../../src/react/components/ErrorBoundary.tsx"],"names":[],"mappings":"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"}
|
|
@@ -27,6 +27,17 @@
|
|
|
27
27
|
* because a chunk genuinely missing from the deployed build would otherwise reload
|
|
28
28
|
* forever with nothing able to remember trying.
|
|
29
29
|
*
|
|
30
|
+
* 🔴 A chunk cut off by the page being LEFT is not stale. Safari (WebKit) cancels the
|
|
31
|
+
* old document's in-flight loads the moment a document navigation begins, right after
|
|
32
|
+
* `beforeunload`, and every lazy import still on its way rejects "Importing a module
|
|
33
|
+
* script failed." — the same words a deleted chunk gets. Reloading then sends the tab
|
|
34
|
+
* back to the page it was leaving: measured 2026-11-04 on roms' stage, a `goto
|
|
35
|
+
* /settings` 0–300 ms after the library loaded was "interrupted by another navigation
|
|
36
|
+
* to /" in 9 of 12 WebKit runs (forge task 4425). So a failure inside
|
|
37
|
+
* {@link LEAVING_GRACE_MS} of `beforeunload` waits instead: a page that goes never
|
|
38
|
+
* runs the timer, and one still here after it (the navigation was canceled, or was a
|
|
39
|
+
* download) retries the chunk once before treating it as stale.
|
|
40
|
+
*
|
|
30
41
|
* `guardrailsEnforce`'s `no-raw-lazy` is the WHETHER: a bare `React.lazy` in an app
|
|
31
42
|
* is refused, so the next lazy view cannot quietly reopen this.
|
|
32
43
|
*/
|
|
@@ -34,12 +45,20 @@ import { type ComponentType, type LazyExoticComponent } from 'react';
|
|
|
34
45
|
export declare const STALE_CHUNK_RELOADED_AT_KEY = "cursedbelt.staleChunkReloadAt";
|
|
35
46
|
/** A reload within this long of the last one is a loop, not a stale page. */
|
|
36
47
|
export declare const STALE_CHUNK_RELOAD_WINDOW_MS = 60000;
|
|
48
|
+
/** How long after `beforeunload` a failed chunk is read as the navigation's doing, not a deploy's. */
|
|
49
|
+
export declare const LEAVING_GRACE_MS = 10000;
|
|
37
50
|
export interface StaleChunkEnv {
|
|
38
51
|
storage: Pick<Storage, 'getItem' | 'setItem'> | null;
|
|
39
52
|
reload: () => void;
|
|
40
53
|
now: () => number;
|
|
54
|
+
/** When this page last heard `beforeunload` (0: never, or it came back from the bfcache since). */
|
|
55
|
+
leftAt?: () => number;
|
|
56
|
+
/** A timer — one that never fires on a page that has gone. */
|
|
57
|
+
sleep?: (ms: number) => Promise<void>;
|
|
41
58
|
}
|
|
42
59
|
export declare function browserEnv(): StaleChunkEnv;
|
|
60
|
+
/** How much longer a chunk failure now is the page being left (0: it is not being left). */
|
|
61
|
+
export declare function leavingFor(env?: StaleChunkEnv): number;
|
|
43
62
|
/**
|
|
44
63
|
* Claim this tab's one stale-chunk reload for the window: true when a reload may
|
|
45
64
|
* happen now, and the claim is already recorded. False when one happened inside the
|
|
@@ -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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0CG;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,sGAAsG;AACtG,eAAO,MAAM,gBAAgB,QAAS,CAAC;AAEvC,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;IAClB,mGAAmG;IACnG,MAAM,CAAC,EAAE,MAAM,MAAM,CAAC;IACtB,8DAA8D;IAC9D,KAAK,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACvC;AAqBD,wBAAgB,UAAU,IAAI,aAAa,CAQ1C;AAED,4FAA4F;AAC5F,wBAAgB,UAAU,CAAC,GAAG,GAAE,aAA4B,GAAG,MAAM,CAIpE;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,CAoBlB;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"}
|
|
@@ -27,6 +27,17 @@
|
|
|
27
27
|
* because a chunk genuinely missing from the deployed build would otherwise reload
|
|
28
28
|
* forever with nothing able to remember trying.
|
|
29
29
|
*
|
|
30
|
+
* 🔴 A chunk cut off by the page being LEFT is not stale. Safari (WebKit) cancels the
|
|
31
|
+
* old document's in-flight loads the moment a document navigation begins, right after
|
|
32
|
+
* `beforeunload`, and every lazy import still on its way rejects "Importing a module
|
|
33
|
+
* script failed." — the same words a deleted chunk gets. Reloading then sends the tab
|
|
34
|
+
* back to the page it was leaving: measured 2026-11-04 on roms' stage, a `goto
|
|
35
|
+
* /settings` 0–300 ms after the library loaded was "interrupted by another navigation
|
|
36
|
+
* to /" in 9 of 12 WebKit runs (forge task 4425). So a failure inside
|
|
37
|
+
* {@link LEAVING_GRACE_MS} of `beforeunload` waits instead: a page that goes never
|
|
38
|
+
* runs the timer, and one still here after it (the navigation was canceled, or was a
|
|
39
|
+
* download) retries the chunk once before treating it as stale.
|
|
40
|
+
*
|
|
30
41
|
* `guardrailsEnforce`'s `no-raw-lazy` is the WHETHER: a bare `React.lazy` in an app
|
|
31
42
|
* is refused, so the next lazy view cannot quietly reopen this.
|
|
32
43
|
*/
|
|
@@ -34,6 +45,25 @@ import { lazy } from 'react';
|
|
|
34
45
|
export const STALE_CHUNK_RELOADED_AT_KEY = 'cursedbelt.staleChunkReloadAt';
|
|
35
46
|
/** A reload within this long of the last one is a loop, not a stale page. */
|
|
36
47
|
export const STALE_CHUNK_RELOAD_WINDOW_MS = 60_000;
|
|
48
|
+
/** How long after `beforeunload` a failed chunk is read as the navigation's doing, not a deploy's. */
|
|
49
|
+
export const LEAVING_GRACE_MS = 10_000;
|
|
50
|
+
let leftAt = 0;
|
|
51
|
+
let watching = false;
|
|
52
|
+
/** Note when the page starts being left. Installed on import, so it hears the first navigation. */
|
|
53
|
+
function watchLeaving() {
|
|
54
|
+
if (watching || typeof window === 'undefined' || typeof window.addEventListener !== 'function')
|
|
55
|
+
return;
|
|
56
|
+
watching = true;
|
|
57
|
+
window.addEventListener('beforeunload', () => {
|
|
58
|
+
leftAt = Date.now();
|
|
59
|
+
});
|
|
60
|
+
// Back from the bfcache: the navigation that was leaving it is over.
|
|
61
|
+
window.addEventListener('pageshow', () => {
|
|
62
|
+
leftAt = 0;
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
watchLeaving();
|
|
66
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
37
67
|
export function browserEnv() {
|
|
38
68
|
let storage = null;
|
|
39
69
|
try {
|
|
@@ -42,7 +72,14 @@ export function browserEnv() {
|
|
|
42
72
|
catch {
|
|
43
73
|
// Storage refused (blocked site data) — no memory, so no reload: the guard fails closed.
|
|
44
74
|
}
|
|
45
|
-
return { storage, reload: () => window.location.reload(), now: Date.now };
|
|
75
|
+
return { storage, reload: () => window.location.reload(), now: Date.now, leftAt: () => leftAt, sleep };
|
|
76
|
+
}
|
|
77
|
+
/** How much longer a chunk failure now is the page being left (0: it is not being left). */
|
|
78
|
+
export function leavingFor(env = browserEnv()) {
|
|
79
|
+
const at = env.leftAt?.() ?? 0;
|
|
80
|
+
if (at <= 0)
|
|
81
|
+
return 0;
|
|
82
|
+
return Math.max(0, at + LEAVING_GRACE_MS - env.now());
|
|
46
83
|
}
|
|
47
84
|
/**
|
|
48
85
|
* Claim this tab's one stale-chunk reload for the window: true when a reload may
|
|
@@ -81,7 +118,17 @@ export function reloadOnStaleChunk(load, env = browserEnv) {
|
|
|
81
118
|
return await load();
|
|
82
119
|
}
|
|
83
120
|
catch (error) {
|
|
84
|
-
|
|
121
|
+
let current = env();
|
|
122
|
+
const wait = leavingFor(current);
|
|
123
|
+
if (wait > 0) {
|
|
124
|
+
await (current.sleep ?? sleep)(wait);
|
|
125
|
+
try {
|
|
126
|
+
return await load();
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
current = env();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
85
132
|
if (!claimStaleChunkReload(current))
|
|
86
133
|
throw error;
|
|
87
134
|
current.reload();
|
|
@@ -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":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedbelt",
|
|
3
|
-
"version": "5.113.
|
|
3
|
+
"version": "5.113.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,7 +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
|
+
import { claimStaleChunkReload, leavingFor, staleChunkReloadedRecently } from '../lazy/staleChunk.js';
|
|
5
5
|
import { ConnectionHealer, isUnreachableError, type ServerHealOptions } from './ConnectionHealer.js';
|
|
6
6
|
|
|
7
7
|
/* ───────────────────────────────────────────────────────────────────────────
|
|
@@ -191,16 +191,24 @@ export const StaleDeployHealer = ({ error, onRecover, ...rest }: StaleDeployHeal
|
|
|
191
191
|
|
|
192
192
|
useEffect(() => {
|
|
193
193
|
if (typeof window === 'undefined') return;
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
194
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
195
|
+
const heal = () => {
|
|
196
|
+
if (!claimStaleChunkReload()) {
|
|
197
|
+
setExhausted(true);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
// A tick of delay so the banner paints first — an instant reload on a slow
|
|
201
|
+
// connection reads as a flicker with no explanation.
|
|
202
|
+
timer = setTimeout(() => {
|
|
203
|
+
if (onRecover) onRecover();
|
|
204
|
+
else window.location.reload();
|
|
205
|
+
}, 400);
|
|
206
|
+
};
|
|
207
|
+
// A chunk cut off by the page being left (Safari cancels its loads at `beforeunload`) is
|
|
208
|
+
// not stale, and a reload would pull the tab back from where it was going — `staleChunk.ts`.
|
|
209
|
+
const leaving = leavingFor();
|
|
210
|
+
if (leaving > 0) timer = setTimeout(heal, leaving);
|
|
211
|
+
else heal();
|
|
204
212
|
return () => clearTimeout(timer);
|
|
205
213
|
}, [onRecover]);
|
|
206
214
|
|
|
@@ -136,3 +136,30 @@ describe('StaleDeployHealer shares lazyWithReload\'s memory', () => {
|
|
|
136
136
|
expect(recovered).toBe(0);
|
|
137
137
|
});
|
|
138
138
|
});
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* 🔴 Safari cancels the old page's chunks at `beforeunload`, with a stale chunk's words: a boundary
|
|
142
|
+
* that reloaded for it pulled the tab back from where it was going (forge task 4425).
|
|
143
|
+
*/
|
|
144
|
+
describe('StaleDeployHealer leaves a page that is being left alone', () => {
|
|
145
|
+
afterEach(() => {
|
|
146
|
+
window.dispatchEvent(new Event('pageshow'));
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('no reload and no claim within the grace after beforeunload', async () => {
|
|
150
|
+
window.dispatchEvent(new Event('beforeunload'));
|
|
151
|
+
let recovered = 0;
|
|
152
|
+
render(<StaleDeployHealer error={new TypeError('Importing a module script failed.')} onRecover={() => recovered++} />);
|
|
153
|
+
await new Promise((r) => setTimeout(r, 800));
|
|
154
|
+
expect(recovered).toBe(0);
|
|
155
|
+
expect(window.sessionStorage.getItem(STALE_CHUNK_RELOADED_AT_KEY)).toBeNull();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('back from the bfcache, it heals as before', async () => {
|
|
159
|
+
window.dispatchEvent(new Event('beforeunload'));
|
|
160
|
+
window.dispatchEvent(new Event('pageshow'));
|
|
161
|
+
let recovered = 0;
|
|
162
|
+
render(<StaleDeployHealer error={OWNERS_ERROR} onRecover={() => recovered++} />);
|
|
163
|
+
await waitFor(() => expect(recovered).toBe(1), { timeout: 2000 });
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -15,13 +15,23 @@ import {
|
|
|
15
15
|
|
|
16
16
|
function fakeEnv(at: number) {
|
|
17
17
|
const store = new Map<string, string>();
|
|
18
|
-
const state = { reloads: 0, now: at };
|
|
18
|
+
const state = { reloads: 0, now: at, leftAt: 0, slept: [] as number[], wake: () => {} };
|
|
19
19
|
const env: StaleChunkEnv = {
|
|
20
20
|
storage: { getItem: (k) => store.get(k) ?? null, setItem: (k, v) => void store.set(k, v) },
|
|
21
21
|
reload: () => {
|
|
22
22
|
state.reloads += 1;
|
|
23
23
|
},
|
|
24
24
|
now: () => state.now,
|
|
25
|
+
leftAt: () => state.leftAt,
|
|
26
|
+
// A timer the test fires by hand — or never, as on a page that has gone.
|
|
27
|
+
sleep: (ms) =>
|
|
28
|
+
new Promise<void>((resolve) => {
|
|
29
|
+
state.slept.push(ms);
|
|
30
|
+
state.wake = () => {
|
|
31
|
+
state.now += ms;
|
|
32
|
+
resolve();
|
|
33
|
+
};
|
|
34
|
+
}),
|
|
25
35
|
};
|
|
26
36
|
return { env: () => env, state, store };
|
|
27
37
|
}
|
|
@@ -110,6 +120,58 @@ describe('a chunk a deploy removed', () => {
|
|
|
110
120
|
});
|
|
111
121
|
});
|
|
112
122
|
|
|
123
|
+
/*
|
|
124
|
+
* 🔴 Safari cancels the old page's in-flight chunks the moment a navigation begins (right after
|
|
125
|
+
* `beforeunload`), with the very words a deleted chunk gets. Reloading for it pulled roms' tab back
|
|
126
|
+
* to the library from the /settings it was going to (forge task 4425, 9 of 12 WebKit runs).
|
|
127
|
+
*/
|
|
128
|
+
describe('a chunk cut off by the page being left', () => {
|
|
129
|
+
/** `staleChunk.ts`'s `LEAVING_GRACE_MS`, pinned: a test of the bug must not need the fix to compile. */
|
|
130
|
+
const LEAVING_GRACE_MS = 10_000;
|
|
131
|
+
const cutOff = () => Promise.reject(new TypeError('Importing a module script failed.'));
|
|
132
|
+
|
|
133
|
+
test('never reloads, and claims no reload, while the page goes', async () => {
|
|
134
|
+
const { env, state, store } = fakeEnv(1_000_000);
|
|
135
|
+
state.leftAt = state.now - 50;
|
|
136
|
+
const pending = reloadOnStaleChunk(cutOff, env)();
|
|
137
|
+
expect(await settled(pending)).toBe(false);
|
|
138
|
+
expect(state.reloads).toBe(0);
|
|
139
|
+
expect(store.get(STALE_CHUNK_RELOADED_AT_KEY)).toBeUndefined();
|
|
140
|
+
expect(state.slept).toEqual([LEAVING_GRACE_MS - 50]);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('a page still here after the grace retries the chunk, and a chunk that loads is the view', async () => {
|
|
144
|
+
const { env, state } = fakeEnv(1_000_000);
|
|
145
|
+
state.leftAt = state.now;
|
|
146
|
+
let calls = 0;
|
|
147
|
+
const pending = reloadOnStaleChunk(() => (++calls === 1 ? cutOff() : Promise.resolve('view')), env)();
|
|
148
|
+
await tick();
|
|
149
|
+
state.wake();
|
|
150
|
+
expect(await pending).toBe('view');
|
|
151
|
+
expect(calls).toBe(2);
|
|
152
|
+
expect(state.reloads).toBe(0);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('a page still here whose chunk fails again is stale after all, and reloads once', async () => {
|
|
156
|
+
const { env, state } = fakeEnv(1_000_000);
|
|
157
|
+
state.leftAt = state.now;
|
|
158
|
+
const pending = reloadOnStaleChunk(cutOff, env)();
|
|
159
|
+
await tick();
|
|
160
|
+
state.wake();
|
|
161
|
+
expect(await settled(pending)).toBe(false);
|
|
162
|
+
expect(state.reloads).toBe(1);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('a beforeunload older than the grace is no excuse: a gone chunk reloads at once', async () => {
|
|
166
|
+
const { env, state } = fakeEnv(1_000_000);
|
|
167
|
+
state.leftAt = state.now - LEAVING_GRACE_MS;
|
|
168
|
+
void reloadOnStaleChunk(gone, env)();
|
|
169
|
+
await tick();
|
|
170
|
+
expect(state.slept).toEqual([]);
|
|
171
|
+
expect(state.reloads).toBe(1);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
113
175
|
describe('lazyWithReload', () => {
|
|
114
176
|
test('renders the view when its chunk loads', async () => {
|
|
115
177
|
const { env } = fakeEnv(1_000_000);
|
|
@@ -27,6 +27,17 @@
|
|
|
27
27
|
* because a chunk genuinely missing from the deployed build would otherwise reload
|
|
28
28
|
* forever with nothing able to remember trying.
|
|
29
29
|
*
|
|
30
|
+
* 🔴 A chunk cut off by the page being LEFT is not stale. Safari (WebKit) cancels the
|
|
31
|
+
* old document's in-flight loads the moment a document navigation begins, right after
|
|
32
|
+
* `beforeunload`, and every lazy import still on its way rejects "Importing a module
|
|
33
|
+
* script failed." — the same words a deleted chunk gets. Reloading then sends the tab
|
|
34
|
+
* back to the page it was leaving: measured 2026-11-04 on roms' stage, a `goto
|
|
35
|
+
* /settings` 0–300 ms after the library loaded was "interrupted by another navigation
|
|
36
|
+
* to /" in 9 of 12 WebKit runs (forge task 4425). So a failure inside
|
|
37
|
+
* {@link LEAVING_GRACE_MS} of `beforeunload` waits instead: a page that goes never
|
|
38
|
+
* runs the timer, and one still here after it (the navigation was canceled, or was a
|
|
39
|
+
* download) retries the chunk once before treating it as stale.
|
|
40
|
+
*
|
|
30
41
|
* `guardrailsEnforce`'s `no-raw-lazy` is the WHETHER: a bare `React.lazy` in an app
|
|
31
42
|
* is refused, so the next lazy view cannot quietly reopen this.
|
|
32
43
|
*/
|
|
@@ -37,11 +48,37 @@ export const STALE_CHUNK_RELOADED_AT_KEY = 'cursedbelt.staleChunkReloadAt';
|
|
|
37
48
|
/** A reload within this long of the last one is a loop, not a stale page. */
|
|
38
49
|
export const STALE_CHUNK_RELOAD_WINDOW_MS = 60_000;
|
|
39
50
|
|
|
51
|
+
/** How long after `beforeunload` a failed chunk is read as the navigation's doing, not a deploy's. */
|
|
52
|
+
export const LEAVING_GRACE_MS = 10_000;
|
|
53
|
+
|
|
40
54
|
export interface StaleChunkEnv {
|
|
41
55
|
storage: Pick<Storage, 'getItem' | 'setItem'> | null;
|
|
42
56
|
reload: () => void;
|
|
43
57
|
now: () => number;
|
|
58
|
+
/** When this page last heard `beforeunload` (0: never, or it came back from the bfcache since). */
|
|
59
|
+
leftAt?: () => number;
|
|
60
|
+
/** A timer — one that never fires on a page that has gone. */
|
|
61
|
+
sleep?: (ms: number) => Promise<void>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let leftAt = 0;
|
|
65
|
+
let watching = false;
|
|
66
|
+
|
|
67
|
+
/** Note when the page starts being left. Installed on import, so it hears the first navigation. */
|
|
68
|
+
function watchLeaving(): void {
|
|
69
|
+
if (watching || typeof window === 'undefined' || typeof window.addEventListener !== 'function') return;
|
|
70
|
+
watching = true;
|
|
71
|
+
window.addEventListener('beforeunload', () => {
|
|
72
|
+
leftAt = Date.now();
|
|
73
|
+
});
|
|
74
|
+
// Back from the bfcache: the navigation that was leaving it is over.
|
|
75
|
+
window.addEventListener('pageshow', () => {
|
|
76
|
+
leftAt = 0;
|
|
77
|
+
});
|
|
44
78
|
}
|
|
79
|
+
watchLeaving();
|
|
80
|
+
|
|
81
|
+
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
|
45
82
|
|
|
46
83
|
export function browserEnv(): StaleChunkEnv {
|
|
47
84
|
let storage: StaleChunkEnv['storage'] = null;
|
|
@@ -50,7 +87,14 @@ export function browserEnv(): StaleChunkEnv {
|
|
|
50
87
|
} catch {
|
|
51
88
|
// Storage refused (blocked site data) — no memory, so no reload: the guard fails closed.
|
|
52
89
|
}
|
|
53
|
-
return { storage, reload: () => window.location.reload(), now: Date.now };
|
|
90
|
+
return { storage, reload: () => window.location.reload(), now: Date.now, leftAt: () => leftAt, sleep };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** How much longer a chunk failure now is the page being left (0: it is not being left). */
|
|
94
|
+
export function leavingFor(env: StaleChunkEnv = browserEnv()): number {
|
|
95
|
+
const at = env.leftAt?.() ?? 0;
|
|
96
|
+
if (at <= 0) return 0;
|
|
97
|
+
return Math.max(0, at + LEAVING_GRACE_MS - env.now());
|
|
54
98
|
}
|
|
55
99
|
|
|
56
100
|
/**
|
|
@@ -90,7 +134,16 @@ export function reloadOnStaleChunk<T>(
|
|
|
90
134
|
try {
|
|
91
135
|
return await load();
|
|
92
136
|
} catch (error) {
|
|
93
|
-
|
|
137
|
+
let current = env();
|
|
138
|
+
const wait = leavingFor(current);
|
|
139
|
+
if (wait > 0) {
|
|
140
|
+
await (current.sleep ?? sleep)(wait);
|
|
141
|
+
try {
|
|
142
|
+
return await load();
|
|
143
|
+
} catch {
|
|
144
|
+
current = env();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
94
147
|
if (!claimStaleChunkReload(current)) throw error;
|
|
95
148
|
current.reload();
|
|
96
149
|
return new Promise<T>(() => undefined);
|