@gusnips/react 0.4.6 → 0.5.0
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 +43 -15
- package/dist/deploy-recovery.d.ts +107 -0
- package/dist/deploy-recovery.d.ts.map +1 -0
- package/dist/deploy-recovery.js +174 -0
- package/dist/deploy-recovery.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/deploy-recovery.test.ts +207 -0
- package/src/deploy-recovery.ts +177 -0
- package/src/index.ts +4 -2
- package/src/chunk-reload.test.ts +0 -113
- package/src/chunk-reload.ts +0 -94
package/README.md
CHANGED
|
@@ -67,31 +67,59 @@ those codes and they are never retried.
|
|
|
67
67
|
|
|
68
68
|
An app with `lazy()` routes serves chunks by hashed filename. Deploy while someone has a tab
|
|
69
69
|
open, and their next click asks for a file that no longer exists — a white screen on a button
|
|
70
|
-
that worked a minute ago.
|
|
70
|
+
that worked a minute ago. The same deploy breaks a second way that looks nothing like the first:
|
|
71
|
+
the old bundle reads a field your API just renamed, and throws a plain `TypeError` with nothing
|
|
72
|
+
in it about a deploy.
|
|
71
73
|
|
|
72
|
-
|
|
74
|
+
**Fix the cause in your host config first.** This package cannot do it for you and it is worth
|
|
75
|
+
more than everything below. A static host answers a missing `/assets/x.js` with your SPA
|
|
76
|
+
fallback — `index.html`, at a 200, as `text/html` — and that is what the browser's "MIME type
|
|
77
|
+
text/html" module-script refusal actually is. Hosts then match cache rules against the _request
|
|
78
|
+
path_ rather than the outcome, so the `immutable, max-age=31536000` rule you wrote for hashed
|
|
79
|
+
assets lands on that HTML body: one poisoned asset URL, at the edge and in every browser that
|
|
80
|
+
touched it, for a year. Keep the fallback off `/assets/`, and answer a miss there with a real,
|
|
81
|
+
uncacheable 404.
|
|
73
82
|
|
|
74
|
-
|
|
75
|
-
import { installPreloadErrorHandler } from "@gusnips/react";
|
|
83
|
+
Then two detectors, for the two failures:
|
|
76
84
|
|
|
77
|
-
|
|
85
|
+
```ts
|
|
86
|
+
if (await isStaleBuild()) reloadOnce();
|
|
78
87
|
```
|
|
79
88
|
|
|
80
|
-
|
|
89
|
+
`isStaleBuild` asks the server instead of reading the error — it compares the entry script this
|
|
90
|
+
document loaded against the one the live page names. That is the only thing that catches the
|
|
91
|
+
renamed-field crash, where the message carries no signal at all. It asks for the page, not for
|
|
92
|
+
the asset: a CDN serves assets with a long `s-maxage`, so a bundle retired an hour ago still
|
|
93
|
+
answers 200 from the edge and would report "current" during exactly the window when skew is most
|
|
94
|
+
likely. Anything ambiguous — offline, a non-200, a timeout — answers `false`, because dressing a
|
|
95
|
+
real bug up as an update hides it from you too.
|
|
81
96
|
|
|
82
97
|
```ts
|
|
83
|
-
if (isChunkLoadError(error) &&
|
|
98
|
+
if (isChunkLoadError(error) && reloadOnce()) return null;
|
|
84
99
|
```
|
|
85
100
|
|
|
86
|
-
`
|
|
87
|
-
|
|
88
|
-
|
|
101
|
+
`isChunkLoadError` matches the message, which is all you have for a failure that never reaches a
|
|
102
|
+
boundary — an `unhandledrejection`, or a listener. Every browser's phrasing is in there.
|
|
103
|
+
|
|
104
|
+
`reloadOnce` reloads at most once a minute, and there is **one** stamp for every reason to
|
|
105
|
+
reload. A tab can reload because a chunk 404'd, because the server says it is behind, or from a
|
|
106
|
+
guard inlined in `index.html` that runs before any module does. Three detectors, one predicament
|
|
107
|
+
— give each its own key and a broken deploy gets three reloads a minute to take turns with. That
|
|
108
|
+
inline guard cannot import anything, so the key it has to copy is exported as `RELOAD_GUARD_KEY`.
|
|
109
|
+
|
|
110
|
+
And in your browser entry:
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
import { installPreloadErrorHandler } from "@gusnips/react";
|
|
114
|
+
|
|
115
|
+
installPreloadErrorHandler();
|
|
116
|
+
```
|
|
89
117
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
118
|
+
It is narrower than it looks: it swallows Vite's CSS _preload hint_ failure and nothing else. A
|
|
119
|
+
hint that fails is harmless — the import runs straight after and usually works. Taking Vite's
|
|
120
|
+
`preventDefault()` for every preload error instead resolves the dynamic import with `undefined`,
|
|
121
|
+
`React.lazy` reads `.default` off nothing, and you get a crash screen plus a TypeError with only
|
|
122
|
+
React frames in it, naming no chunk.
|
|
95
123
|
|
|
96
124
|
## Guards that can tell "no" from "I don't know"
|
|
97
125
|
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Surviving a deploy that lands while a tab is open.
|
|
3
|
+
*
|
|
4
|
+
* Every app on this stack is a Vite SPA with `lazy()` routes, deployed on push. A build replaces
|
|
5
|
+
* every hashed asset underneath whatever tabs are already open, and those tabs go on running
|
|
6
|
+
* yesterday's bundle. It surfaces two ways, and only one of them looks like a missing file:
|
|
7
|
+
*
|
|
8
|
+
* 1. The next navigation asks for a chunk whose hashed name is gone. React's lazy boundary
|
|
9
|
+
* throws, and the person gets a white screen on a click that worked a minute ago.
|
|
10
|
+
* 2. The old bundle reads a field the API has since renamed, and throws. That one arrives as a
|
|
11
|
+
* plain `TypeError` with nothing in it about a deploy — no file, no hash, no signal.
|
|
12
|
+
*
|
|
13
|
+
* So there are two detectors here and they are not interchangeable. {@link isChunkLoadError}
|
|
14
|
+
* matches the message, which is all you have for a failure that never reaches a boundary.
|
|
15
|
+
* {@link isStaleBuild} asks the server, which is evidence rather than a guess — and it is the
|
|
16
|
+
* only one that catches case 2 at all.
|
|
17
|
+
*
|
|
18
|
+
* **The deepest fix is not in this file, and it is worth more than everything in it.** A static
|
|
19
|
+
* host answers a missing `/assets/x.js` with the SPA fallback — `index.html`, at a 200, as
|
|
20
|
+
* `text/html` — which is what the browser's "MIME type text/html" module-script refusal actually
|
|
21
|
+
* is. Hosts then match cache rules against the REQUEST PATH rather than the outcome, so the
|
|
22
|
+
* `immutable, max-age=31536000` rule written for hashed assets gets applied to that HTML body:
|
|
23
|
+
* one poisoned asset URL, at the edge and in every browser that touched it, for a year. One
|
|
24
|
+
* adopter removed the cause instead of recovering from it — no blanket fallback over `/assets/`,
|
|
25
|
+
* and an edge function that refuses to answer an asset path with `text/html` — which turns a
|
|
26
|
+
* dead chunk into a real, uncacheable 404 and lets everything below be recovery rather than the
|
|
27
|
+
* plan. A package cannot ship that; it can refuse to let it go unwritten.
|
|
28
|
+
*/
|
|
29
|
+
/**
|
|
30
|
+
* One stamp for every reason to reload, which is the part that is easy to get wrong.
|
|
31
|
+
*
|
|
32
|
+
* A tab can decide to reload because a chunk 404'd, because the server says it is running an old
|
|
33
|
+
* build, or from a guard inlined in `index.html` that runs before any module does. That is three
|
|
34
|
+
* detectors for ONE predicament, and giving each its own key hands a broken deploy three reloads
|
|
35
|
+
* a minute to take turns with. The guard belongs to the tab, not to the reason.
|
|
36
|
+
*
|
|
37
|
+
* Exported because of the third caller: an inline `<script>` in `index.html` has to exist for the
|
|
38
|
+
* case where the bundle holding this file is itself the missing asset, and it cannot import
|
|
39
|
+
* anything. Copying the string is what it has to do; copying it from here is why the two agree.
|
|
40
|
+
*/
|
|
41
|
+
export declare const RELOAD_GUARD_KEY = "frontkit:reload-at";
|
|
42
|
+
/**
|
|
43
|
+
* A lazy-chunk fetch failure — recoverable by reloading.
|
|
44
|
+
*
|
|
45
|
+
* Matched by message because no browser gives it a shared type. Every phrasing in the wild is
|
|
46
|
+
* here: Chrome/Edge "Failed to fetch dynamically imported module", Firefox "error loading
|
|
47
|
+
* dynamically imported module", Safari "Importing a module script failed", webpack's
|
|
48
|
+
* ChunkLoadError and "Loading chunk N failed", and the SPA-fallback "MIME type" refusal —
|
|
49
|
+
* which is the one that fires on a static host that has not fixed the cause above, because the
|
|
50
|
+
* missing `.js` is answered with `index.html`.
|
|
51
|
+
*
|
|
52
|
+
* A standalone predicate rather than a method on the boundary, so a global `unhandledrejection`
|
|
53
|
+
* handler can classify the same failure without importing a component.
|
|
54
|
+
*/
|
|
55
|
+
export declare function isChunkLoadError(error: unknown): boolean;
|
|
56
|
+
/**
|
|
57
|
+
* Is the build this tab is running still the one being served?
|
|
58
|
+
*
|
|
59
|
+
* Asked of the server, so the answer is evidence rather than a guess. Everything ambiguous —
|
|
60
|
+
* offline, a non-200, a captive portal, a probe that times out — resolves to `false`: dressing a
|
|
61
|
+
* genuine bug up as an update hides it from the user and from us.
|
|
62
|
+
*
|
|
63
|
+
* It asks for the PAGE, not for the asset, and that is not the obvious choice. A missing asset
|
|
64
|
+
* looks like the direct question, but a CDN commonly serves assets with a long `s-maxage`, so a
|
|
65
|
+
* bundle a deploy retired an hour ago still answers 200 from the edge — it would report "current"
|
|
66
|
+
* during exactly the window when skew is most likely. The page is the one URL that cannot lie: it
|
|
67
|
+
* ships `max-age=0, must-revalidate` and never enters the edge cache.
|
|
68
|
+
*/
|
|
69
|
+
export declare function isStaleBuild(): Promise<boolean>;
|
|
70
|
+
/**
|
|
71
|
+
* Hard-reload once to pick up the fresh deploy. Returns false when the guard suppressed it,
|
|
72
|
+
* which is the caller's cue that reloading did not fix it and the person needs the real error UI
|
|
73
|
+
* rather than watching the same attempt loop.
|
|
74
|
+
*
|
|
75
|
+
* The guard is the part that matters. Reloading on a failure is obvious; reloading on a failure
|
|
76
|
+
* that the reload does not fix is an infinite loop with the user inside it. At most one reload
|
|
77
|
+
* per minute, so a genuinely broken deploy degrades to the error screen — which can at least say
|
|
78
|
+
* something — instead of flickering forever.
|
|
79
|
+
*
|
|
80
|
+
* `sessionStorage` and not `localStorage`: the guard is about this tab's current predicament, and
|
|
81
|
+
* a stale stamp in another tab must not suppress a reload this one needs. Wrapped because storage
|
|
82
|
+
* throws outright when site data is blocked, and a privacy setting must not be the reason
|
|
83
|
+
* somebody cannot recover.
|
|
84
|
+
*/
|
|
85
|
+
export declare function reloadOnce(): boolean;
|
|
86
|
+
/**
|
|
87
|
+
* Vite's *hint* failure, which is not the same thing and must not be treated as one.
|
|
88
|
+
*
|
|
89
|
+
* Vite injects a speculative `<link rel="stylesheet">` for a chunk's CSS before importing it.
|
|
90
|
+
* When that link fails, the import itself still runs right after and usually succeeds — so this
|
|
91
|
+
* one is worth swallowing, where a failure of the import is the real thing
|
|
92
|
+
* {@link isChunkLoadError} matches. Vite builds this message in exactly one place and only ever
|
|
93
|
+
* for a stylesheet link; script hints never reject.
|
|
94
|
+
*/
|
|
95
|
+
export declare function isPreloadHintFailure(error: unknown): boolean;
|
|
96
|
+
/**
|
|
97
|
+
* Install the `vite:preloadError` listener. Call once, from the browser entry.
|
|
98
|
+
*
|
|
99
|
+
* Vite offers `preventDefault()` here so an app can own a failed asset itself — but taking that
|
|
100
|
+
* deal unconditionally resolves the dynamic import with `undefined` (Vite's helper ends in
|
|
101
|
+
* `baseModule().catch(handlePreloadError)`), and `React.lazy` then reads `.default` off nothing.
|
|
102
|
+
* The user gets the crash screen and the developer gets a TypeError with only React frames in
|
|
103
|
+
* it, naming no chunk. So it is taken ONLY for a failed preload hint, where the import still
|
|
104
|
+
* runs. A real module failure is left to throw, where the error boundary can recognise it.
|
|
105
|
+
*/
|
|
106
|
+
export declare function installPreloadErrorHandler(): void;
|
|
107
|
+
//# sourceMappingURL=deploy-recovery.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"deploy-recovery.d.ts","sourceRoot":"","sources":["../src/deploy-recovery.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAEH;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,gBAAgB,uBAAuB,CAAC;AAQrD;;;;;;;;;;;;GAYG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CASxD;AAqBD;;;;;;;;;;;;GAYG;AACH,wBAAsB,YAAY,IAAI,OAAO,CAAC,OAAO,CAAC,CAarD;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,UAAU,IAAI,OAAO,CAWpC;AAED;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAE5D;AAED;;;;;;;;;GASG;AACH,wBAAgB,0BAA0B,IAAI,IAAI,CAKjD"}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Surviving a deploy that lands while a tab is open.
|
|
3
|
+
*
|
|
4
|
+
* Every app on this stack is a Vite SPA with `lazy()` routes, deployed on push. A build replaces
|
|
5
|
+
* every hashed asset underneath whatever tabs are already open, and those tabs go on running
|
|
6
|
+
* yesterday's bundle. It surfaces two ways, and only one of them looks like a missing file:
|
|
7
|
+
*
|
|
8
|
+
* 1. The next navigation asks for a chunk whose hashed name is gone. React's lazy boundary
|
|
9
|
+
* throws, and the person gets a white screen on a click that worked a minute ago.
|
|
10
|
+
* 2. The old bundle reads a field the API has since renamed, and throws. That one arrives as a
|
|
11
|
+
* plain `TypeError` with nothing in it about a deploy — no file, no hash, no signal.
|
|
12
|
+
*
|
|
13
|
+
* So there are two detectors here and they are not interchangeable. {@link isChunkLoadError}
|
|
14
|
+
* matches the message, which is all you have for a failure that never reaches a boundary.
|
|
15
|
+
* {@link isStaleBuild} asks the server, which is evidence rather than a guess — and it is the
|
|
16
|
+
* only one that catches case 2 at all.
|
|
17
|
+
*
|
|
18
|
+
* **The deepest fix is not in this file, and it is worth more than everything in it.** A static
|
|
19
|
+
* host answers a missing `/assets/x.js` with the SPA fallback — `index.html`, at a 200, as
|
|
20
|
+
* `text/html` — which is what the browser's "MIME type text/html" module-script refusal actually
|
|
21
|
+
* is. Hosts then match cache rules against the REQUEST PATH rather than the outcome, so the
|
|
22
|
+
* `immutable, max-age=31536000` rule written for hashed assets gets applied to that HTML body:
|
|
23
|
+
* one poisoned asset URL, at the edge and in every browser that touched it, for a year. One
|
|
24
|
+
* adopter removed the cause instead of recovering from it — no blanket fallback over `/assets/`,
|
|
25
|
+
* and an edge function that refuses to answer an asset path with `text/html` — which turns a
|
|
26
|
+
* dead chunk into a real, uncacheable 404 and lets everything below be recovery rather than the
|
|
27
|
+
* plan. A package cannot ship that; it can refuse to let it go unwritten.
|
|
28
|
+
*/
|
|
29
|
+
/**
|
|
30
|
+
* One stamp for every reason to reload, which is the part that is easy to get wrong.
|
|
31
|
+
*
|
|
32
|
+
* A tab can decide to reload because a chunk 404'd, because the server says it is running an old
|
|
33
|
+
* build, or from a guard inlined in `index.html` that runs before any module does. That is three
|
|
34
|
+
* detectors for ONE predicament, and giving each its own key hands a broken deploy three reloads
|
|
35
|
+
* a minute to take turns with. The guard belongs to the tab, not to the reason.
|
|
36
|
+
*
|
|
37
|
+
* Exported because of the third caller: an inline `<script>` in `index.html` has to exist for the
|
|
38
|
+
* case where the bundle holding this file is itself the missing asset, and it cannot import
|
|
39
|
+
* anything. Copying the string is what it has to do; copying it from here is why the two agree.
|
|
40
|
+
*/
|
|
41
|
+
export const RELOAD_GUARD_KEY = "frontkit:reload-at";
|
|
42
|
+
const RELOAD_WINDOW_MS = 60_000;
|
|
43
|
+
/** Bounded: a crashed app already shows nothing, and a network that hangs rather than fails must
|
|
44
|
+
* land on the real error screen instead of an endless spinner. */
|
|
45
|
+
const PROBE_TIMEOUT_MS = 4_000;
|
|
46
|
+
/**
|
|
47
|
+
* A lazy-chunk fetch failure — recoverable by reloading.
|
|
48
|
+
*
|
|
49
|
+
* Matched by message because no browser gives it a shared type. Every phrasing in the wild is
|
|
50
|
+
* here: Chrome/Edge "Failed to fetch dynamically imported module", Firefox "error loading
|
|
51
|
+
* dynamically imported module", Safari "Importing a module script failed", webpack's
|
|
52
|
+
* ChunkLoadError and "Loading chunk N failed", and the SPA-fallback "MIME type" refusal —
|
|
53
|
+
* which is the one that fires on a static host that has not fixed the cause above, because the
|
|
54
|
+
* missing `.js` is answered with `index.html`.
|
|
55
|
+
*
|
|
56
|
+
* A standalone predicate rather than a method on the boundary, so a global `unhandledrejection`
|
|
57
|
+
* handler can classify the same failure without importing a component.
|
|
58
|
+
*/
|
|
59
|
+
export function isChunkLoadError(error) {
|
|
60
|
+
if (error instanceof Error && error.name === "ChunkLoadError")
|
|
61
|
+
return true;
|
|
62
|
+
const message = (error instanceof Error ? error.message : String(error) || "").toLowerCase();
|
|
63
|
+
return (message.includes("dynamically imported module") ||
|
|
64
|
+
message.includes("importing a module script failed") ||
|
|
65
|
+
message.includes("mime type") ||
|
|
66
|
+
message.includes("loading chunk"));
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The entry script this document loaded, as a path.
|
|
70
|
+
*
|
|
71
|
+
* Read from the DOM rather than from `import.meta.url`, so it stays the ENTRY even if this module
|
|
72
|
+
* is later code-split into some other chunk — and so `vite dev`, which names `/src/main.tsx`,
|
|
73
|
+
* compares like with like and never reports a real crash as an update.
|
|
74
|
+
*
|
|
75
|
+
* A path, compared as a plain substring, rather than the raw attribute matched as `src="…"`. The
|
|
76
|
+
* two donors split on exactly this and the anchored form is the more precise one — which is also
|
|
77
|
+
* why it is the one that breaks if anything between the build and the browser rewrites the
|
|
78
|
+
* markup, where a minifier changing quote style is enough. A missed match resolves to "not
|
|
79
|
+
* stale", which shows the real error, and that is the direction this whole function is built to
|
|
80
|
+
* fail in.
|
|
81
|
+
*/
|
|
82
|
+
function runningEntry() {
|
|
83
|
+
const el = document.querySelector('script[type="module"][src]');
|
|
84
|
+
return el ? new URL(el.src, window.location.href).pathname : "";
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Is the build this tab is running still the one being served?
|
|
88
|
+
*
|
|
89
|
+
* Asked of the server, so the answer is evidence rather than a guess. Everything ambiguous —
|
|
90
|
+
* offline, a non-200, a captive portal, a probe that times out — resolves to `false`: dressing a
|
|
91
|
+
* genuine bug up as an update hides it from the user and from us.
|
|
92
|
+
*
|
|
93
|
+
* It asks for the PAGE, not for the asset, and that is not the obvious choice. A missing asset
|
|
94
|
+
* looks like the direct question, but a CDN commonly serves assets with a long `s-maxage`, so a
|
|
95
|
+
* bundle a deploy retired an hour ago still answers 200 from the edge — it would report "current"
|
|
96
|
+
* during exactly the window when skew is most likely. The page is the one URL that cannot lie: it
|
|
97
|
+
* ships `max-age=0, must-revalidate` and never enters the edge cache.
|
|
98
|
+
*/
|
|
99
|
+
export async function isStaleBuild() {
|
|
100
|
+
const running = runningEntry();
|
|
101
|
+
if (!running)
|
|
102
|
+
return false;
|
|
103
|
+
try {
|
|
104
|
+
const res = await fetch("/", {
|
|
105
|
+
cache: "no-store",
|
|
106
|
+
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
|
|
107
|
+
});
|
|
108
|
+
if (!res.ok)
|
|
109
|
+
return false;
|
|
110
|
+
return !(await res.text()).includes(running);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Hard-reload once to pick up the fresh deploy. Returns false when the guard suppressed it,
|
|
118
|
+
* which is the caller's cue that reloading did not fix it and the person needs the real error UI
|
|
119
|
+
* rather than watching the same attempt loop.
|
|
120
|
+
*
|
|
121
|
+
* The guard is the part that matters. Reloading on a failure is obvious; reloading on a failure
|
|
122
|
+
* that the reload does not fix is an infinite loop with the user inside it. At most one reload
|
|
123
|
+
* per minute, so a genuinely broken deploy degrades to the error screen — which can at least say
|
|
124
|
+
* something — instead of flickering forever.
|
|
125
|
+
*
|
|
126
|
+
* `sessionStorage` and not `localStorage`: the guard is about this tab's current predicament, and
|
|
127
|
+
* a stale stamp in another tab must not suppress a reload this one needs. Wrapped because storage
|
|
128
|
+
* throws outright when site data is blocked, and a privacy setting must not be the reason
|
|
129
|
+
* somebody cannot recover.
|
|
130
|
+
*/
|
|
131
|
+
export function reloadOnce() {
|
|
132
|
+
const now = Date.now();
|
|
133
|
+
try {
|
|
134
|
+
const last = Number(sessionStorage.getItem(RELOAD_GUARD_KEY) ?? 0);
|
|
135
|
+
if (now - last < RELOAD_WINDOW_MS)
|
|
136
|
+
return false;
|
|
137
|
+
sessionStorage.setItem(RELOAD_GUARD_KEY, String(now));
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
// Storage blocked — proceed with the reload rather than refusing to recover.
|
|
141
|
+
}
|
|
142
|
+
window.location.reload();
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Vite's *hint* failure, which is not the same thing and must not be treated as one.
|
|
147
|
+
*
|
|
148
|
+
* Vite injects a speculative `<link rel="stylesheet">` for a chunk's CSS before importing it.
|
|
149
|
+
* When that link fails, the import itself still runs right after and usually succeeds — so this
|
|
150
|
+
* one is worth swallowing, where a failure of the import is the real thing
|
|
151
|
+
* {@link isChunkLoadError} matches. Vite builds this message in exactly one place and only ever
|
|
152
|
+
* for a stylesheet link; script hints never reject.
|
|
153
|
+
*/
|
|
154
|
+
export function isPreloadHintFailure(error) {
|
|
155
|
+
return error instanceof Error && error.message.startsWith("Unable to preload CSS for");
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Install the `vite:preloadError` listener. Call once, from the browser entry.
|
|
159
|
+
*
|
|
160
|
+
* Vite offers `preventDefault()` here so an app can own a failed asset itself — but taking that
|
|
161
|
+
* deal unconditionally resolves the dynamic import with `undefined` (Vite's helper ends in
|
|
162
|
+
* `baseModule().catch(handlePreloadError)`), and `React.lazy` then reads `.default` off nothing.
|
|
163
|
+
* The user gets the crash screen and the developer gets a TypeError with only React frames in
|
|
164
|
+
* it, naming no chunk. So it is taken ONLY for a failed preload hint, where the import still
|
|
165
|
+
* runs. A real module failure is left to throw, where the error boundary can recognise it.
|
|
166
|
+
*/
|
|
167
|
+
export function installPreloadErrorHandler() {
|
|
168
|
+
window.addEventListener("vite:preloadError", (event) => {
|
|
169
|
+
const payload = event.payload;
|
|
170
|
+
if (isPreloadHintFailure(payload))
|
|
171
|
+
event.preventDefault();
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
//# sourceMappingURL=deploy-recovery.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"deploy-recovery.js","sourceRoot":"","sources":["../src/deploy-recovery.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAEH;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG,oBAAoB,CAAC;AAErD,MAAM,gBAAgB,GAAG,MAAM,CAAC;AAEhC;mEACmE;AACnE,MAAM,gBAAgB,GAAG,KAAK,CAAC;AAE/B;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,gBAAgB,CAAC,KAAc;IAC7C,IAAI,KAAK,YAAY,KAAK,IAAI,KAAK,CAAC,IAAI,KAAK,gBAAgB;QAAE,OAAO,IAAI,CAAC;IAC3E,MAAM,OAAO,GAAG,CAAC,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC,WAAW,EAAE,CAAC;IAC7F,OAAO,CACL,OAAO,CAAC,QAAQ,CAAC,6BAA6B,CAAC;QAC/C,OAAO,CAAC,QAAQ,CAAC,kCAAkC,CAAC;QACpD,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC;QAC7B,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAC,CAClC,CAAC;AACJ,CAAC;AAED;;;;;;;;;;;;;GAaG;AACH,SAAS,YAAY;IACnB,MAAM,EAAE,GAAG,QAAQ,CAAC,aAAa,CAAoB,4BAA4B,CAAC,CAAC;IACnF,OAAO,EAAE,CAAC,CAAC,CAAC,IAAI,GAAG,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,CAAC;AAClE,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,KAAK,UAAU,YAAY;IAChC,MAAM,OAAO,GAAG,YAAY,EAAE,CAAC;IAC/B,IAAI,CAAC,OAAO;QAAE,OAAO,KAAK,CAAC;IAC3B,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,MAAM,KAAK,CAAC,GAAG,EAAE;YAC3B,KAAK,EAAE,UAAU;YACjB,MAAM,EAAE,WAAW,CAAC,OAAO,CAAC,gBAAgB,CAAC;SAC9C,CAAC,CAAC;QACH,IAAI,CAAC,GAAG,CAAC,EAAE;YAAE,OAAO,KAAK,CAAC;QAC1B,OAAO,CAAC,CAAC,MAAM,GAAG,CAAC,IAAI,EAAE,CAAC,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC;IAC/C,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC;AAED;;;;;;;;;;;;;;GAcG;AACH,MAAM,UAAU,UAAU;IACxB,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;IACvB,IAAI,CAAC;QACH,MAAM,IAAI,GAAG,MAAM,CAAC,cAAc,CAAC,OAAO,CAAC,gBAAgB,CAAC,IAAI,CAAC,CAAC,CAAC;QACnE,IAAI,GAAG,GAAG,IAAI,GAAG,gBAAgB;YAAE,OAAO,KAAK,CAAC;QAChD,cAAc,CAAC,OAAO,CAAC,gBAAgB,EAAE,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC;IACxD,CAAC;IAAC,MAAM,CAAC;QACP,6EAA6E;IAC/E,CAAC;IACD,MAAM,CAAC,QAAQ,CAAC,MAAM,EAAE,CAAC;IACzB,OAAO,IAAI,CAAC;AACd,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,oBAAoB,CAAC,KAAc;IACjD,OAAO,KAAK,YAAY,KAAK,IAAI,KAAK,CAAC,OAAO,CAAC,UAAU,CAAC,2BAA2B,CAAC,CAAC;AACzF,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,UAAU,0BAA0B;IACxC,MAAM,CAAC,gBAAgB,CAAC,mBAAmB,EAAE,CAAC,KAAK,EAAE,EAAE;QACrD,MAAM,OAAO,GAAI,KAAuC,CAAC,OAAO,CAAC;QACjE,IAAI,oBAAoB,CAAC,OAAO,CAAC;YAAE,KAAK,CAAC,cAAc,EAAE,CAAC;IAC5D,CAAC,CAAC,CAAC;AACL,CAAC"}
|
package/dist/index.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ export { createSseParser, readSseStream, type SseFrame } from "./sse.ts";
|
|
|
5
5
|
export { queryDefaults, retryDelayMs, shouldRetry, type QueryDefaultsOptions } from "./query.ts";
|
|
6
6
|
export { ErrorBoundary, type ErrorBoundaryFallbackProps, type ErrorBoundaryProps, } from "./error-boundary.tsx";
|
|
7
7
|
export { createErrorDescriber, humanizeWait, type DescribedError, type ErrorArm, type ErrorContext, type ErrorDescriberOptions, type Translate, } from "./describe-error.ts";
|
|
8
|
-
export { installPreloadErrorHandler, isChunkLoadError, isPreloadHintFailure,
|
|
8
|
+
export { installPreloadErrorHandler, isChunkLoadError, isPreloadHintFailure, isStaleBuild, RELOAD_GUARD_KEY, reloadOnce, } from "./deploy-recovery.ts";
|
|
9
9
|
export { PRERENDERED_ROUTE_ATTR, SHELL_ROUTE } from "./prerender-contract.ts";
|
|
10
10
|
export { applyBrandVars, i18nInitOptions, type I18nInitOptions } from "./i18n.ts";
|
|
11
11
|
export type { EmptyStateProps, ErrorStateProps } from "./states.ts";
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AA6BA,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAE7B,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AACxE,OAAO,EACL,eAAe,EACf,KAAK,SAAS,EACd,KAAK,gBAAgB,EACrB,KAAK,aAAa,EAClB,KAAK,cAAc,EACnB,KAAK,cAAc,GACpB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,eAAe,EAAE,aAAa,EAAE,KAAK,QAAQ,EAAE,MAAM,UAAU,CAAC;AAEzE,OAAO,EAAE,aAAa,EAAE,YAAY,EAAE,WAAW,EAAE,KAAK,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAEjG,OAAO,EACL,aAAa,EACb,KAAK,0BAA0B,EAC/B,KAAK,kBAAkB,GACxB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EACL,oBAAoB,EACpB,YAAY,EACZ,KAAK,cAAc,EACnB,KAAK,QAAQ,EACb,KAAK,YAAY,EACjB,KAAK,qBAAqB,EAC1B,KAAK,SAAS,GACf,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EACL,0BAA0B,EAC1B,gBAAgB,EAChB,oBAAoB,EACpB,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AA6BA,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAE7B,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AACxE,OAAO,EACL,eAAe,EACf,KAAK,SAAS,EACd,KAAK,gBAAgB,EACrB,KAAK,aAAa,EAClB,KAAK,cAAc,EACnB,KAAK,cAAc,GACpB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,eAAe,EAAE,aAAa,EAAE,KAAK,QAAQ,EAAE,MAAM,UAAU,CAAC;AAEzE,OAAO,EAAE,aAAa,EAAE,YAAY,EAAE,WAAW,EAAE,KAAK,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAEjG,OAAO,EACL,aAAa,EACb,KAAK,0BAA0B,EAC/B,KAAK,kBAAkB,GACxB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EACL,oBAAoB,EACpB,YAAY,EACZ,KAAK,cAAc,EACnB,KAAK,QAAQ,EACb,KAAK,YAAY,EACjB,KAAK,qBAAqB,EAC1B,KAAK,SAAS,GACf,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EACL,0BAA0B,EAC1B,gBAAgB,EAChB,oBAAoB,EACpB,YAAY,EACZ,gBAAgB,EAChB,UAAU,GACX,MAAM,sBAAsB,CAAC;AAK9B,OAAO,EAAE,sBAAsB,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAE9E,OAAO,EAAE,cAAc,EAAE,eAAe,EAAE,KAAK,eAAe,EAAE,MAAM,WAAW,CAAC;AAElF,YAAY,EAAE,eAAe,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -33,7 +33,7 @@ export { createSseParser, readSseStream } from "./sse.js";
|
|
|
33
33
|
export { queryDefaults, retryDelayMs, shouldRetry } from "./query.js";
|
|
34
34
|
export { ErrorBoundary, } from "./error-boundary.js";
|
|
35
35
|
export { createErrorDescriber, humanizeWait, } from "./describe-error.js";
|
|
36
|
-
export { installPreloadErrorHandler, isChunkLoadError, isPreloadHintFailure,
|
|
36
|
+
export { installPreloadErrorHandler, isChunkLoadError, isPreloadHintFailure, isStaleBuild, RELOAD_GUARD_KEY, reloadOnce, } from "./deploy-recovery.js";
|
|
37
37
|
// The two constants, but NOT `hydrateOrMount` — that one is at `@gusnips/react/hydrate`, because
|
|
38
38
|
// it is the package's only `react-dom` import. These come from the contract module, which imports
|
|
39
39
|
// nothing at all, so they are free to everyone: a build script, a browser entry, or a phone.
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,kEAAkE;AAClE,EAAE;AACF,2CAA2C;AAC3C,EAAE;AACF,+FAA+F;AAC/F,gGAAgG;AAChG,+FAA+F;AAC/F,iGAAiG;AACjG,0BAA0B;AAC1B,EAAE;AACF,oEAAoE;AACpE,6DAA6D;AAC7D,sEAAsE;AACtE,+DAA+D;AAC/D,EAAE;AACF,2FAA2F;AAC3F,iGAAiG;AACjG,+FAA+F;AAC/F,gGAAgG;AAChG,4EAA4E;AAC5E,EAAE;AACF,2FAA2F;AAC3F,iGAAiG;AACjG,qFAAqF;AACrF,6FAA6F;AAC7F,iGAAiG;AACjG,yFAAyF;AACzF,uBAAuB;AAEvB,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAE7B,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AACxE,OAAO,EACL,eAAe,GAMhB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,eAAe,EAAE,aAAa,EAAiB,MAAM,UAAU,CAAC;AAEzE,OAAO,EAAE,aAAa,EAAE,YAAY,EAAE,WAAW,EAA6B,MAAM,YAAY,CAAC;AAEjG,OAAO,EACL,aAAa,GAGd,MAAM,qBAAsB,CAAC;AAC9B,OAAO,EACL,oBAAoB,EACpB,YAAY,GAMb,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EACL,0BAA0B,EAC1B,gBAAgB,EAChB,oBAAoB,EACpB,
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,kEAAkE;AAClE,EAAE;AACF,2CAA2C;AAC3C,EAAE;AACF,+FAA+F;AAC/F,gGAAgG;AAChG,+FAA+F;AAC/F,iGAAiG;AACjG,0BAA0B;AAC1B,EAAE;AACF,oEAAoE;AACpE,6DAA6D;AAC7D,sEAAsE;AACtE,+DAA+D;AAC/D,EAAE;AACF,2FAA2F;AAC3F,iGAAiG;AACjG,+FAA+F;AAC/F,gGAAgG;AAChG,4EAA4E;AAC5E,EAAE;AACF,2FAA2F;AAC3F,iGAAiG;AACjG,qFAAqF;AACrF,6FAA6F;AAC7F,iGAAiG;AACjG,yFAAyF;AACzF,uBAAuB;AAEvB,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAE7B,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AACxE,OAAO,EACL,eAAe,GAMhB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,eAAe,EAAE,aAAa,EAAiB,MAAM,UAAU,CAAC;AAEzE,OAAO,EAAE,aAAa,EAAE,YAAY,EAAE,WAAW,EAA6B,MAAM,YAAY,CAAC;AAEjG,OAAO,EACL,aAAa,GAGd,MAAM,qBAAsB,CAAC;AAC9B,OAAO,EACL,oBAAoB,EACpB,YAAY,GAMb,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EACL,0BAA0B,EAC1B,gBAAgB,EAChB,oBAAoB,EACpB,YAAY,EACZ,gBAAgB,EAChB,UAAU,GACX,MAAM,sBAAsB,CAAC;AAE9B,iGAAiG;AACjG,kGAAkG;AAClG,6FAA6F;AAC7F,OAAO,EAAE,sBAAsB,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAE9E,OAAO,EAAE,cAAc,EAAE,eAAe,EAAwB,MAAM,WAAW,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gusnips/react",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "The headless runtime under a Vite + React SPA: typed fetch client with single-flight refresh, auth store, query defaults, route guards, error boundary,
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "The headless runtime under a Vite + React SPA: typed fetch client with single-flight refresh, auth store, query defaults, route guards, error boundary, stale-tab recovery, i18n bootstrap.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "Gustavo Salomé",
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
isChunkLoadError,
|
|
4
|
+
isPreloadHintFailure,
|
|
5
|
+
isStaleBuild,
|
|
6
|
+
RELOAD_GUARD_KEY,
|
|
7
|
+
reloadOnce,
|
|
8
|
+
} from "./deploy-recovery.ts";
|
|
9
|
+
|
|
10
|
+
const ORIGIN = "https://app.example.com";
|
|
11
|
+
|
|
12
|
+
// No browser gives a failed dynamic import a shared type, so this is matched by message and
|
|
13
|
+
// every phrasing has to be here. A miss is a white screen after a deploy — the exact failure
|
|
14
|
+
// this module exists to catch — and it only reproduces on a real deploy, so the test is the
|
|
15
|
+
// only place these strings get checked.
|
|
16
|
+
describe("isChunkLoadError", () => {
|
|
17
|
+
it.each([
|
|
18
|
+
["Chrome/Edge", "Failed to fetch dynamically imported module: https://x/assets/a.js"],
|
|
19
|
+
["Firefox", "error loading dynamically imported module"],
|
|
20
|
+
["Safari", "Importing a module script failed."],
|
|
21
|
+
["webpack", "Loading chunk 42 failed."],
|
|
22
|
+
// The one that fires on a static host that still answers a missing asset with its SPA
|
|
23
|
+
// fallback: the browser refuses the HTML rather than reporting a 404.
|
|
24
|
+
[
|
|
25
|
+
"SPA fallback",
|
|
26
|
+
"Expected a JavaScript module script but the server responded with a MIME type of text/html",
|
|
27
|
+
],
|
|
28
|
+
])("matches %s", (_browser, message) => {
|
|
29
|
+
expect(isChunkLoadError(new Error(message))).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("matches a named ChunkLoadError whatever its message", () => {
|
|
33
|
+
const error = new Error("nothing recognisable");
|
|
34
|
+
error.name = "ChunkLoadError";
|
|
35
|
+
expect(isChunkLoadError(error)).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("does not match an ordinary render error", () => {
|
|
39
|
+
expect(isChunkLoadError(new TypeError("Cannot read properties of undefined"))).toBe(false);
|
|
40
|
+
expect(isChunkLoadError(null)).toBe(false);
|
|
41
|
+
// A thrown string reaches the `String(error)` branch, which is the one a non-Error throw
|
|
42
|
+
// takes — and the one that would blow up if it were written `error.message`.
|
|
43
|
+
expect(isChunkLoadError("something failed")).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// The distinction that matters: swallowing a real module failure resolves the import with
|
|
48
|
+
// `undefined`, React.lazy reads `.default` off nothing, and the developer gets a TypeError with
|
|
49
|
+
// only React frames in it — naming no chunk. Only the CSS hint is safe to swallow.
|
|
50
|
+
describe("isPreloadHintFailure", () => {
|
|
51
|
+
it("matches Vite's CSS preload hint and nothing else", () => {
|
|
52
|
+
expect(isPreloadHintFailure(new Error("Unable to preload CSS for /assets/a.css"))).toBe(true);
|
|
53
|
+
expect(isPreloadHintFailure(new Error("Failed to fetch dynamically imported module"))).toBe(
|
|
54
|
+
false,
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Two globals, stubbed rather than run under a DOM: `window`, `document`, `sessionStorage` and
|
|
61
|
+
* `fetch` are the only ones this module touches, and adding jsdom to a package whose whole point
|
|
62
|
+
* is a small dependency list would cost more than it explains.
|
|
63
|
+
*/
|
|
64
|
+
const reload = vi.fn();
|
|
65
|
+
let store: Record<string, string> = {};
|
|
66
|
+
/** Throws from both methods, the way a browser does when site data is blocked. */
|
|
67
|
+
let blocked = false;
|
|
68
|
+
|
|
69
|
+
beforeEach(() => {
|
|
70
|
+
vi.useFakeTimers();
|
|
71
|
+
reload.mockClear();
|
|
72
|
+
store = {};
|
|
73
|
+
blocked = false;
|
|
74
|
+
vi.stubGlobal("window", { location: { reload, href: `${ORIGIN}/w/anyone` } });
|
|
75
|
+
vi.stubGlobal("sessionStorage", {
|
|
76
|
+
getItem: (key: string): string | null => {
|
|
77
|
+
if (blocked) throw new Error("site data blocked");
|
|
78
|
+
return store[key] ?? null;
|
|
79
|
+
},
|
|
80
|
+
setItem: (key: string, value: string): void => {
|
|
81
|
+
if (blocked) throw new Error("site data blocked");
|
|
82
|
+
store[key] = value;
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
afterEach(() => {
|
|
88
|
+
vi.unstubAllGlobals();
|
|
89
|
+
vi.useRealTimers();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The detector that reads evidence instead of a message. It is the only one that catches a crash
|
|
94
|
+
* carrying no signal — the old bundle meeting a renamed API field — so every ambiguous answer
|
|
95
|
+
* has to resolve to "not stale", or a real bug gets dressed up as an update and hidden.
|
|
96
|
+
*/
|
|
97
|
+
describe("isStaleBuild", () => {
|
|
98
|
+
// What a static host actually serves for `/`: Vite's built page names the entry with double
|
|
99
|
+
// quotes and a crossorigin attribute, and `vite dev` names the source module.
|
|
100
|
+
const BUILT = (hash: string) =>
|
|
101
|
+
`<!doctype html><html><head><script type="module" crossorigin src="/assets/index-${hash}.js"></script></head><body><div id="root"></div></body></html>`;
|
|
102
|
+
const DEV = `<!doctype html><html><head><script type="module" src="/src/main.tsx"></script></head><body></body></html>`;
|
|
103
|
+
|
|
104
|
+
/** Point this document at one entry script and the server at a page naming another. */
|
|
105
|
+
function serving(page: string, entry: string | null): void {
|
|
106
|
+
vi.stubGlobal("document", {
|
|
107
|
+
querySelector: () => (entry === null ? null : { src: `${ORIGIN}${entry}` }),
|
|
108
|
+
});
|
|
109
|
+
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(page, { status: 200 })));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
it("asks the page, never the cache — a cached copy would hide the deploy", async () => {
|
|
113
|
+
serving(BUILT("AAA"), "/assets/index-AAA.js");
|
|
114
|
+
await isStaleBuild();
|
|
115
|
+
expect(fetch).toHaveBeenCalledWith("/", expect.objectContaining({ cache: "no-store" }));
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("is stale when the live page has moved to a different bundle", async () => {
|
|
119
|
+
serving(BUILT("NEW222"), "/assets/index-OLD111.js");
|
|
120
|
+
await expect(isStaleBuild()).resolves.toBe(true);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("is not stale while the live page still names this bundle", async () => {
|
|
124
|
+
serving(BUILT("SAME33"), "/assets/index-SAME33.js");
|
|
125
|
+
await expect(isStaleBuild()).resolves.toBe(false);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// The reason this compares a PATH as a substring rather than matching `src="…"` exactly. The
|
|
129
|
+
// anchored form is more precise and breaks the moment anything rewrites the markup between the
|
|
130
|
+
// build and the browser — and breaking here would report a healthy tab as stale, which is a
|
|
131
|
+
// reload the user did not need and cannot refuse.
|
|
132
|
+
it("survives a host that rewrites the markup on the way out", async () => {
|
|
133
|
+
serving(
|
|
134
|
+
`<!doctype html><html><head><script type=module src='/assets/index-AAA.js'></script></head></html>`,
|
|
135
|
+
"/assets/index-AAA.js",
|
|
136
|
+
);
|
|
137
|
+
await expect(isStaleBuild()).resolves.toBe(false);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("is never stale under `vite dev`, where a crash is always a real one", async () => {
|
|
141
|
+
serving(DEV, "/src/main.tsx");
|
|
142
|
+
await expect(isStaleBuild()).resolves.toBe(false);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("does not guess when the document has no entry script to compare", async () => {
|
|
146
|
+
serving(BUILT("AAA"), null);
|
|
147
|
+
await expect(isStaleBuild()).resolves.toBe(false);
|
|
148
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("does not call a bad response from the host a deploy", async () => {
|
|
152
|
+
serving(BUILT("AAA"), "/assets/index-AAA.js");
|
|
153
|
+
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("nope", { status: 503 })));
|
|
154
|
+
await expect(isStaleBuild()).resolves.toBe(false);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it.each([
|
|
158
|
+
["being offline", new TypeError("Failed to fetch")],
|
|
159
|
+
["a network that hangs until the probe gives up", new DOMException("Timeout", "TimeoutError")],
|
|
160
|
+
])("does not call %s a deploy", async (_label, thrown) => {
|
|
161
|
+
serving(BUILT("AAA"), "/assets/index-AAA.js");
|
|
162
|
+
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(thrown));
|
|
163
|
+
await expect(isStaleBuild()).resolves.toBe(false);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The guard, which is the whole reason this function exists rather than a bare
|
|
169
|
+
* `location.reload()` at the call site. Reloading on a failure the reload does not fix is an
|
|
170
|
+
* infinite loop with a person inside it, and it only reproduces on a real broken deploy — so
|
|
171
|
+
* this is the only place the rule gets checked.
|
|
172
|
+
*/
|
|
173
|
+
describe("reloadOnce", () => {
|
|
174
|
+
it("reloads on the first attempt and stamps the shared guard", () => {
|
|
175
|
+
expect(reloadOnce()).toBe(true);
|
|
176
|
+
expect(reload).toHaveBeenCalledTimes(1);
|
|
177
|
+
expect(store[RELOAD_GUARD_KEY]).toBe(String(Date.now()));
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("refuses a second reload inside the window, so a broken deploy cannot loop", () => {
|
|
181
|
+
expect(reloadOnce()).toBe(true);
|
|
182
|
+
expect(reloadOnce()).toBe(false);
|
|
183
|
+
expect(reload).toHaveBeenCalledTimes(1);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("allows another one after the window, because the next deploy is a new problem", () => {
|
|
187
|
+
expect(reloadOnce()).toBe(true);
|
|
188
|
+
vi.setSystemTime(Date.now() + 61_000);
|
|
189
|
+
expect(reloadOnce()).toBe(true);
|
|
190
|
+
expect(reload).toHaveBeenCalledTimes(2);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("still reloads when storage is blocked — a privacy setting is not a reason to strand", () => {
|
|
194
|
+
blocked = true;
|
|
195
|
+
expect(reloadOnce()).toBe(true);
|
|
196
|
+
expect(reload).toHaveBeenCalledTimes(1);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// The bug this key was renamed to prevent. An inline guard in index.html cannot import
|
|
200
|
+
// anything, so it copies the string — and the moment the two names drift, a broken deploy gets
|
|
201
|
+
// two reloads a minute instead of one, each path politely resetting the other's clock.
|
|
202
|
+
it("exports the one key an inline guard has to share", () => {
|
|
203
|
+
expect(RELOAD_GUARD_KEY).toBe("frontkit:reload-at");
|
|
204
|
+
reloadOnce();
|
|
205
|
+
expect(Object.keys(store)).toEqual([RELOAD_GUARD_KEY]);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Surviving a deploy that lands while a tab is open.
|
|
3
|
+
*
|
|
4
|
+
* Every app on this stack is a Vite SPA with `lazy()` routes, deployed on push. A build replaces
|
|
5
|
+
* every hashed asset underneath whatever tabs are already open, and those tabs go on running
|
|
6
|
+
* yesterday's bundle. It surfaces two ways, and only one of them looks like a missing file:
|
|
7
|
+
*
|
|
8
|
+
* 1. The next navigation asks for a chunk whose hashed name is gone. React's lazy boundary
|
|
9
|
+
* throws, and the person gets a white screen on a click that worked a minute ago.
|
|
10
|
+
* 2. The old bundle reads a field the API has since renamed, and throws. That one arrives as a
|
|
11
|
+
* plain `TypeError` with nothing in it about a deploy — no file, no hash, no signal.
|
|
12
|
+
*
|
|
13
|
+
* So there are two detectors here and they are not interchangeable. {@link isChunkLoadError}
|
|
14
|
+
* matches the message, which is all you have for a failure that never reaches a boundary.
|
|
15
|
+
* {@link isStaleBuild} asks the server, which is evidence rather than a guess — and it is the
|
|
16
|
+
* only one that catches case 2 at all.
|
|
17
|
+
*
|
|
18
|
+
* **The deepest fix is not in this file, and it is worth more than everything in it.** A static
|
|
19
|
+
* host answers a missing `/assets/x.js` with the SPA fallback — `index.html`, at a 200, as
|
|
20
|
+
* `text/html` — which is what the browser's "MIME type text/html" module-script refusal actually
|
|
21
|
+
* is. Hosts then match cache rules against the REQUEST PATH rather than the outcome, so the
|
|
22
|
+
* `immutable, max-age=31536000` rule written for hashed assets gets applied to that HTML body:
|
|
23
|
+
* one poisoned asset URL, at the edge and in every browser that touched it, for a year. One
|
|
24
|
+
* adopter removed the cause instead of recovering from it — no blanket fallback over `/assets/`,
|
|
25
|
+
* and an edge function that refuses to answer an asset path with `text/html` — which turns a
|
|
26
|
+
* dead chunk into a real, uncacheable 404 and lets everything below be recovery rather than the
|
|
27
|
+
* plan. A package cannot ship that; it can refuse to let it go unwritten.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* One stamp for every reason to reload, which is the part that is easy to get wrong.
|
|
32
|
+
*
|
|
33
|
+
* A tab can decide to reload because a chunk 404'd, because the server says it is running an old
|
|
34
|
+
* build, or from a guard inlined in `index.html` that runs before any module does. That is three
|
|
35
|
+
* detectors for ONE predicament, and giving each its own key hands a broken deploy three reloads
|
|
36
|
+
* a minute to take turns with. The guard belongs to the tab, not to the reason.
|
|
37
|
+
*
|
|
38
|
+
* Exported because of the third caller: an inline `<script>` in `index.html` has to exist for the
|
|
39
|
+
* case where the bundle holding this file is itself the missing asset, and it cannot import
|
|
40
|
+
* anything. Copying the string is what it has to do; copying it from here is why the two agree.
|
|
41
|
+
*/
|
|
42
|
+
export const RELOAD_GUARD_KEY = "frontkit:reload-at";
|
|
43
|
+
|
|
44
|
+
const RELOAD_WINDOW_MS = 60_000;
|
|
45
|
+
|
|
46
|
+
/** Bounded: a crashed app already shows nothing, and a network that hangs rather than fails must
|
|
47
|
+
* land on the real error screen instead of an endless spinner. */
|
|
48
|
+
const PROBE_TIMEOUT_MS = 4_000;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A lazy-chunk fetch failure — recoverable by reloading.
|
|
52
|
+
*
|
|
53
|
+
* Matched by message because no browser gives it a shared type. Every phrasing in the wild is
|
|
54
|
+
* here: Chrome/Edge "Failed to fetch dynamically imported module", Firefox "error loading
|
|
55
|
+
* dynamically imported module", Safari "Importing a module script failed", webpack's
|
|
56
|
+
* ChunkLoadError and "Loading chunk N failed", and the SPA-fallback "MIME type" refusal —
|
|
57
|
+
* which is the one that fires on a static host that has not fixed the cause above, because the
|
|
58
|
+
* missing `.js` is answered with `index.html`.
|
|
59
|
+
*
|
|
60
|
+
* A standalone predicate rather than a method on the boundary, so a global `unhandledrejection`
|
|
61
|
+
* handler can classify the same failure without importing a component.
|
|
62
|
+
*/
|
|
63
|
+
export function isChunkLoadError(error: unknown): boolean {
|
|
64
|
+
if (error instanceof Error && error.name === "ChunkLoadError") return true;
|
|
65
|
+
const message = (error instanceof Error ? error.message : String(error) || "").toLowerCase();
|
|
66
|
+
return (
|
|
67
|
+
message.includes("dynamically imported module") ||
|
|
68
|
+
message.includes("importing a module script failed") ||
|
|
69
|
+
message.includes("mime type") ||
|
|
70
|
+
message.includes("loading chunk")
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The entry script this document loaded, as a path.
|
|
76
|
+
*
|
|
77
|
+
* Read from the DOM rather than from `import.meta.url`, so it stays the ENTRY even if this module
|
|
78
|
+
* is later code-split into some other chunk — and so `vite dev`, which names `/src/main.tsx`,
|
|
79
|
+
* compares like with like and never reports a real crash as an update.
|
|
80
|
+
*
|
|
81
|
+
* A path, compared as a plain substring, rather than the raw attribute matched as `src="…"`. The
|
|
82
|
+
* two donors split on exactly this and the anchored form is the more precise one — which is also
|
|
83
|
+
* why it is the one that breaks if anything between the build and the browser rewrites the
|
|
84
|
+
* markup, where a minifier changing quote style is enough. A missed match resolves to "not
|
|
85
|
+
* stale", which shows the real error, and that is the direction this whole function is built to
|
|
86
|
+
* fail in.
|
|
87
|
+
*/
|
|
88
|
+
function runningEntry(): string {
|
|
89
|
+
const el = document.querySelector<HTMLScriptElement>('script[type="module"][src]');
|
|
90
|
+
return el ? new URL(el.src, window.location.href).pathname : "";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Is the build this tab is running still the one being served?
|
|
95
|
+
*
|
|
96
|
+
* Asked of the server, so the answer is evidence rather than a guess. Everything ambiguous —
|
|
97
|
+
* offline, a non-200, a captive portal, a probe that times out — resolves to `false`: dressing a
|
|
98
|
+
* genuine bug up as an update hides it from the user and from us.
|
|
99
|
+
*
|
|
100
|
+
* It asks for the PAGE, not for the asset, and that is not the obvious choice. A missing asset
|
|
101
|
+
* looks like the direct question, but a CDN commonly serves assets with a long `s-maxage`, so a
|
|
102
|
+
* bundle a deploy retired an hour ago still answers 200 from the edge — it would report "current"
|
|
103
|
+
* during exactly the window when skew is most likely. The page is the one URL that cannot lie: it
|
|
104
|
+
* ships `max-age=0, must-revalidate` and never enters the edge cache.
|
|
105
|
+
*/
|
|
106
|
+
export async function isStaleBuild(): Promise<boolean> {
|
|
107
|
+
const running = runningEntry();
|
|
108
|
+
if (!running) return false;
|
|
109
|
+
try {
|
|
110
|
+
const res = await fetch("/", {
|
|
111
|
+
cache: "no-store",
|
|
112
|
+
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
|
|
113
|
+
});
|
|
114
|
+
if (!res.ok) return false;
|
|
115
|
+
return !(await res.text()).includes(running);
|
|
116
|
+
} catch {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Hard-reload once to pick up the fresh deploy. Returns false when the guard suppressed it,
|
|
123
|
+
* which is the caller's cue that reloading did not fix it and the person needs the real error UI
|
|
124
|
+
* rather than watching the same attempt loop.
|
|
125
|
+
*
|
|
126
|
+
* The guard is the part that matters. Reloading on a failure is obvious; reloading on a failure
|
|
127
|
+
* that the reload does not fix is an infinite loop with the user inside it. At most one reload
|
|
128
|
+
* per minute, so a genuinely broken deploy degrades to the error screen — which can at least say
|
|
129
|
+
* something — instead of flickering forever.
|
|
130
|
+
*
|
|
131
|
+
* `sessionStorage` and not `localStorage`: the guard is about this tab's current predicament, and
|
|
132
|
+
* a stale stamp in another tab must not suppress a reload this one needs. Wrapped because storage
|
|
133
|
+
* throws outright when site data is blocked, and a privacy setting must not be the reason
|
|
134
|
+
* somebody cannot recover.
|
|
135
|
+
*/
|
|
136
|
+
export function reloadOnce(): boolean {
|
|
137
|
+
const now = Date.now();
|
|
138
|
+
try {
|
|
139
|
+
const last = Number(sessionStorage.getItem(RELOAD_GUARD_KEY) ?? 0);
|
|
140
|
+
if (now - last < RELOAD_WINDOW_MS) return false;
|
|
141
|
+
sessionStorage.setItem(RELOAD_GUARD_KEY, String(now));
|
|
142
|
+
} catch {
|
|
143
|
+
// Storage blocked — proceed with the reload rather than refusing to recover.
|
|
144
|
+
}
|
|
145
|
+
window.location.reload();
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Vite's *hint* failure, which is not the same thing and must not be treated as one.
|
|
151
|
+
*
|
|
152
|
+
* Vite injects a speculative `<link rel="stylesheet">` for a chunk's CSS before importing it.
|
|
153
|
+
* When that link fails, the import itself still runs right after and usually succeeds — so this
|
|
154
|
+
* one is worth swallowing, where a failure of the import is the real thing
|
|
155
|
+
* {@link isChunkLoadError} matches. Vite builds this message in exactly one place and only ever
|
|
156
|
+
* for a stylesheet link; script hints never reject.
|
|
157
|
+
*/
|
|
158
|
+
export function isPreloadHintFailure(error: unknown): boolean {
|
|
159
|
+
return error instanceof Error && error.message.startsWith("Unable to preload CSS for");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Install the `vite:preloadError` listener. Call once, from the browser entry.
|
|
164
|
+
*
|
|
165
|
+
* Vite offers `preventDefault()` here so an app can own a failed asset itself — but taking that
|
|
166
|
+
* deal unconditionally resolves the dynamic import with `undefined` (Vite's helper ends in
|
|
167
|
+
* `baseModule().catch(handlePreloadError)`), and `React.lazy` then reads `.default` off nothing.
|
|
168
|
+
* The user gets the crash screen and the developer gets a TypeError with only React frames in
|
|
169
|
+
* it, naming no chunk. So it is taken ONLY for a failed preload hint, where the import still
|
|
170
|
+
* runs. A real module failure is left to throw, where the error boundary can recognise it.
|
|
171
|
+
*/
|
|
172
|
+
export function installPreloadErrorHandler(): void {
|
|
173
|
+
window.addEventListener("vite:preloadError", (event) => {
|
|
174
|
+
const payload = (event as Event & { payload?: unknown }).payload;
|
|
175
|
+
if (isPreloadHintFailure(payload)) event.preventDefault();
|
|
176
|
+
});
|
|
177
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -61,8 +61,10 @@ export {
|
|
|
61
61
|
installPreloadErrorHandler,
|
|
62
62
|
isChunkLoadError,
|
|
63
63
|
isPreloadHintFailure,
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
isStaleBuild,
|
|
65
|
+
RELOAD_GUARD_KEY,
|
|
66
|
+
reloadOnce,
|
|
67
|
+
} from "./deploy-recovery.ts";
|
|
66
68
|
|
|
67
69
|
// The two constants, but NOT `hydrateOrMount` — that one is at `@gusnips/react/hydrate`, because
|
|
68
70
|
// it is the package's only `react-dom` import. These come from the contract module, which imports
|
package/src/chunk-reload.test.ts
DELETED
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
-
import { isChunkLoadError, isPreloadHintFailure, reloadOnceForChunkError } from "./chunk-reload.ts";
|
|
3
|
-
|
|
4
|
-
// No browser gives a failed dynamic import a shared type, so this is matched by message and
|
|
5
|
-
// every phrasing has to be here. A miss is a white screen after a deploy — the exact failure
|
|
6
|
-
// this module exists to catch — and it only reproduces on a real deploy, so the test is the
|
|
7
|
-
// only place these strings get checked.
|
|
8
|
-
describe("isChunkLoadError", () => {
|
|
9
|
-
it.each([
|
|
10
|
-
["Chrome/Edge", "Failed to fetch dynamically imported module: https://x/assets/a.js"],
|
|
11
|
-
["Firefox", "error loading dynamically imported module"],
|
|
12
|
-
["Safari", "Importing a module script failed."],
|
|
13
|
-
["webpack", "Loading chunk 42 failed."],
|
|
14
|
-
// The one that actually fires on a static host: the missing .js is answered with the SPA
|
|
15
|
-
// fallback, so the browser refuses the HTML rather than reporting a 404.
|
|
16
|
-
[
|
|
17
|
-
"SPA fallback",
|
|
18
|
-
"Expected a JavaScript module script but the server responded with a MIME type of text/html",
|
|
19
|
-
],
|
|
20
|
-
])("matches %s", (_browser, message) => {
|
|
21
|
-
expect(isChunkLoadError(new Error(message))).toBe(true);
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
it("matches a named ChunkLoadError whatever its message", () => {
|
|
25
|
-
const error = new Error("nothing recognisable");
|
|
26
|
-
error.name = "ChunkLoadError";
|
|
27
|
-
expect(isChunkLoadError(error)).toBe(true);
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it("does not match an ordinary render error", () => {
|
|
31
|
-
expect(isChunkLoadError(new TypeError("Cannot read properties of undefined"))).toBe(false);
|
|
32
|
-
expect(isChunkLoadError(null)).toBe(false);
|
|
33
|
-
// A thrown string reaches the `String(error)` branch, which is the one a non-Error throw
|
|
34
|
-
// takes — and the one that would blow up if it were written `error.message`.
|
|
35
|
-
expect(isChunkLoadError("something failed")).toBe(false);
|
|
36
|
-
});
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
// The distinction that matters: swallowing a real module failure resolves the import with
|
|
40
|
-
// `undefined`, React.lazy reads `.default` off nothing, and the developer gets a TypeError with
|
|
41
|
-
// only React frames in it — naming no chunk. Only the CSS hint is safe to swallow.
|
|
42
|
-
describe("isPreloadHintFailure", () => {
|
|
43
|
-
it("matches Vite's CSS preload hint and nothing else", () => {
|
|
44
|
-
expect(isPreloadHintFailure(new Error("Unable to preload CSS for /assets/a.css"))).toBe(true);
|
|
45
|
-
expect(isPreloadHintFailure(new Error("Failed to fetch dynamically imported module"))).toBe(
|
|
46
|
-
false,
|
|
47
|
-
);
|
|
48
|
-
});
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* The guard, which is the whole reason this function exists rather than a bare
|
|
53
|
-
* `location.reload()` at the call site. Reloading on a chunk error the reload does not fix is
|
|
54
|
-
* an infinite loop with a person inside it, and it only reproduces on a real broken deploy —
|
|
55
|
-
* so this is the only place the rule gets checked.
|
|
56
|
-
*
|
|
57
|
-
* Two globals, stubbed rather than run under a DOM: `window` and `sessionStorage` are the only
|
|
58
|
-
* ones this module touches, and adding jsdom to a package whose whole point is a small
|
|
59
|
-
* dependency list would cost more than it explains.
|
|
60
|
-
*/
|
|
61
|
-
describe("reloadOnceForChunkError", () => {
|
|
62
|
-
const reload = vi.fn();
|
|
63
|
-
let store: Record<string, string> = {};
|
|
64
|
-
/** Throws from both methods, the way a browser does when site data is blocked. */
|
|
65
|
-
let blocked = false;
|
|
66
|
-
|
|
67
|
-
beforeEach(() => {
|
|
68
|
-
vi.useFakeTimers();
|
|
69
|
-
reload.mockClear();
|
|
70
|
-
store = {};
|
|
71
|
-
blocked = false;
|
|
72
|
-
vi.stubGlobal("window", { location: { reload } });
|
|
73
|
-
vi.stubGlobal("sessionStorage", {
|
|
74
|
-
getItem: (key: string): string | null => {
|
|
75
|
-
if (blocked) throw new Error("site data blocked");
|
|
76
|
-
return store[key] ?? null;
|
|
77
|
-
},
|
|
78
|
-
setItem: (key: string, value: string): void => {
|
|
79
|
-
if (blocked) throw new Error("site data blocked");
|
|
80
|
-
store[key] = value;
|
|
81
|
-
},
|
|
82
|
-
});
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
afterEach(() => {
|
|
86
|
-
vi.unstubAllGlobals();
|
|
87
|
-
vi.useRealTimers();
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
it("reloads on the first chunk failure", () => {
|
|
91
|
-
expect(reloadOnceForChunkError()).toBe(true);
|
|
92
|
-
expect(reload).toHaveBeenCalledTimes(1);
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
it("refuses a second reload inside the window, so a dead chunk cannot loop", () => {
|
|
96
|
-
expect(reloadOnceForChunkError()).toBe(true);
|
|
97
|
-
expect(reloadOnceForChunkError()).toBe(false);
|
|
98
|
-
expect(reload).toHaveBeenCalledTimes(1);
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
it("allows another one after the window, because the next deploy is a new problem", () => {
|
|
102
|
-
expect(reloadOnceForChunkError()).toBe(true);
|
|
103
|
-
vi.setSystemTime(Date.now() + 61_000);
|
|
104
|
-
expect(reloadOnceForChunkError()).toBe(true);
|
|
105
|
-
expect(reload).toHaveBeenCalledTimes(2);
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
it("still reloads when storage is blocked — a privacy setting is not a reason to strand", () => {
|
|
109
|
-
blocked = true;
|
|
110
|
-
expect(reloadOnceForChunkError()).toBe(true);
|
|
111
|
-
expect(reload).toHaveBeenCalledTimes(1);
|
|
112
|
-
});
|
|
113
|
-
});
|
package/src/chunk-reload.ts
DELETED
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Surviving a deploy that lands while a tab is open.
|
|
3
|
-
*
|
|
4
|
-
* Every app on this stack is a Vite SPA with `lazy()` routes, deployed on push. When a build
|
|
5
|
-
* replaces the one a tab is running, the next navigation asks for a chunk whose hashed name no
|
|
6
|
-
* longer exists — and a static host answers a missing asset with the SPA fallback, so the
|
|
7
|
-
* import gets HTML where it expected a module. React's lazy boundary throws, and the person
|
|
8
|
-
* gets a white screen on a click that worked a minute ago.
|
|
9
|
-
*
|
|
10
|
-
* One repo in the fleet had all of this. The others had none of it, and none of them knew.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
const RELOAD_FLAG_KEY = "frontkit:chunk-reload-at";
|
|
14
|
-
const RELOAD_WINDOW_MS = 60_000;
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* A lazy-chunk fetch failure — recoverable by reloading.
|
|
18
|
-
*
|
|
19
|
-
* Matched by message because no browser gives it a shared type. Every phrasing in the wild is
|
|
20
|
-
* here: Chrome/Edge "Failed to fetch dynamically imported module", Firefox "error loading
|
|
21
|
-
* dynamically imported module", Safari "Importing a module script failed", webpack's
|
|
22
|
-
* ChunkLoadError and "Loading chunk N failed", and the SPA-fallback "MIME type" refusal —
|
|
23
|
-
* which is the one that actually fires on a static host, because the missing `.js` is answered
|
|
24
|
-
* with `index.html`.
|
|
25
|
-
*
|
|
26
|
-
* A standalone predicate rather than a method on the boundary, so a global `unhandledrejection`
|
|
27
|
-
* handler can classify the same failure without importing a component.
|
|
28
|
-
*/
|
|
29
|
-
export function isChunkLoadError(error: unknown): boolean {
|
|
30
|
-
if (error instanceof Error && error.name === "ChunkLoadError") return true;
|
|
31
|
-
const message = (error instanceof Error ? error.message : String(error) || "").toLowerCase();
|
|
32
|
-
return (
|
|
33
|
-
message.includes("dynamically imported module") ||
|
|
34
|
-
message.includes("importing a module script failed") ||
|
|
35
|
-
message.includes("mime type") ||
|
|
36
|
-
message.includes("loading chunk")
|
|
37
|
-
);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Hard-reload once to pick up the fresh deploy. Returns false when the guard suppressed it.
|
|
42
|
-
*
|
|
43
|
-
* The guard is the part that matters. Reloading on a chunk error is obvious; reloading on a
|
|
44
|
-
* chunk error that the reload does not fix is an infinite loop with the user inside it. At most
|
|
45
|
-
* one reload per minute, so a genuinely missing chunk degrades to the error UI — which can at
|
|
46
|
-
* least say something — instead of flickering forever.
|
|
47
|
-
*
|
|
48
|
-
* `sessionStorage` and not `localStorage`: the guard is about this tab's current predicament,
|
|
49
|
-
* and a stale flag in another tab must not suppress a reload this one needs. Wrapped because
|
|
50
|
-
* storage throws outright when site data is blocked, and a privacy setting must not be the
|
|
51
|
-
* reason somebody cannot recover.
|
|
52
|
-
*/
|
|
53
|
-
export function reloadOnceForChunkError(): boolean {
|
|
54
|
-
const now = Date.now();
|
|
55
|
-
try {
|
|
56
|
-
const last = Number(sessionStorage.getItem(RELOAD_FLAG_KEY) ?? 0);
|
|
57
|
-
if (now - last < RELOAD_WINDOW_MS) return false;
|
|
58
|
-
sessionStorage.setItem(RELOAD_FLAG_KEY, String(now));
|
|
59
|
-
} catch {
|
|
60
|
-
// Storage blocked — proceed with the reload rather than refusing to recover.
|
|
61
|
-
}
|
|
62
|
-
window.location.reload();
|
|
63
|
-
return true;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Vite's *hint* failure, which is not the same thing and must not be treated as one.
|
|
68
|
-
*
|
|
69
|
-
* Vite injects a speculative `<link rel="stylesheet">` for a chunk's CSS before importing it.
|
|
70
|
-
* When that link fails, the import itself still runs right after and usually succeeds — so this
|
|
71
|
-
* one is worth swallowing, where a failure of the import is the real thing
|
|
72
|
-
* {@link isChunkLoadError} matches. Vite builds this message in exactly one place and only ever
|
|
73
|
-
* for a stylesheet link; script hints never reject.
|
|
74
|
-
*/
|
|
75
|
-
export function isPreloadHintFailure(error: unknown): boolean {
|
|
76
|
-
return error instanceof Error && error.message.startsWith("Unable to preload CSS for");
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Install the `vite:preloadError` listener. Call once, from the browser entry.
|
|
81
|
-
*
|
|
82
|
-
* Vite offers `preventDefault()` here so an app can own a failed asset itself — but taking that
|
|
83
|
-
* deal unconditionally resolves the dynamic import with `undefined` (Vite's helper ends in
|
|
84
|
-
* `baseModule().catch(handlePreloadError)`), and `React.lazy` then reads `.default` off nothing.
|
|
85
|
-
* The user gets the crash screen and the developer gets a TypeError with only React frames in
|
|
86
|
-
* it, naming no chunk. So it is taken ONLY for a failed preload hint, where the import still
|
|
87
|
-
* runs. A real module failure is left to throw, where the error boundary can recognise it.
|
|
88
|
-
*/
|
|
89
|
-
export function installPreloadErrorHandler(): void {
|
|
90
|
-
window.addEventListener("vite:preloadError", (event) => {
|
|
91
|
-
const payload = (event as Event & { payload?: unknown }).payload;
|
|
92
|
-
if (isPreloadHintFailure(payload)) event.preventDefault();
|
|
93
|
-
});
|
|
94
|
-
}
|