@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 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
- Two halves. In your browser entry:
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
- ```ts
75
- import { installPreloadErrorHandler } from "@gusnips/react";
83
+ Then two detectors, for the two failures:
76
84
 
77
- installPreloadErrorHandler();
85
+ ```ts
86
+ if (await isStaleBuild()) reloadOnce();
78
87
  ```
79
88
 
80
- And in your error boundary:
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) && reloadOnceForChunkError()) return null;
98
+ if (isChunkLoadError(error) && reloadOnce()) return null;
84
99
  ```
85
100
 
86
- `reloadOnceForChunkError` reloads at most once a minute, because reloading on a chunk error the
87
- reload does not fix is an infinite loop with a person inside it. Past that, a genuinely missing
88
- chunk degrades to your error screen, which can at least say something.
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
- The handler is the other half, and it is narrower than it looks: it swallows Vite's CSS _preload
91
- hint_ failure and nothing else. A hint that fails is harmless — the import runs straight after
92
- and usually works. Taking Vite's `preventDefault()` for every preload error instead resolves the
93
- dynamic import with `undefined`, `React.lazy` reads `.default` off nothing, and you get a crash
94
- screen plus a TypeError with only React frames in it, naming no chunk.
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, reloadOnceForChunkError, } from "./chunk-reload.ts";
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";
@@ -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,uBAAuB,GACxB,MAAM,mBAAmB,CAAC;AAK3B,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"}
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, reloadOnceForChunkError, } from "./chunk-reload.js";
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,uBAAuB,GACxB,MAAM,mBAAmB,CAAC;AAE3B,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"}
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.6",
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, chunk-reload recovery, i18n bootstrap.",
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
- reloadOnceForChunkError,
65
- } from "./chunk-reload.ts";
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
@@ -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
- });
@@ -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
- }