@gusnips/react 0.9.23 → 0.9.25

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.
Files changed (110) hide show
  1. package/README.md +36 -36
  2. package/dist/api-client.d.ts +51 -51
  3. package/dist/api-client.d.ts.map +1 -1
  4. package/dist/api-client.js +3 -3
  5. package/dist/api-error.d.ts +11 -11
  6. package/dist/api-error.js +11 -11
  7. package/dist/auth-email.d.ts +40 -0
  8. package/dist/auth-email.d.ts.map +1 -0
  9. package/dist/auth-email.js +106 -0
  10. package/dist/auth-email.js.map +1 -0
  11. package/dist/auth-store.d.ts +23 -23
  12. package/dist/auth-store.js +13 -13
  13. package/dist/copy.d.ts +3 -3
  14. package/dist/copy.js +3 -3
  15. package/dist/deploy-recovery.d.ts +40 -40
  16. package/dist/deploy-recovery.js +40 -40
  17. package/dist/describe-error.d.ts +68 -68
  18. package/dist/describe-error.d.ts.map +1 -1
  19. package/dist/describe-error.js +5 -5
  20. package/dist/error-boundary.d.ts +12 -12
  21. package/dist/guards.d.ts +15 -15
  22. package/dist/guards.js +3 -3
  23. package/dist/hydrate.d.ts +15 -16
  24. package/dist/hydrate.d.ts.map +1 -1
  25. package/dist/hydrate.js +9 -9
  26. package/dist/hydrate.js.map +1 -1
  27. package/dist/i18n.d.ts +15 -16
  28. package/dist/i18n.d.ts.map +1 -1
  29. package/dist/i18n.js +5 -6
  30. package/dist/i18n.js.map +1 -1
  31. package/dist/index.d.ts +1 -0
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +1 -0
  34. package/dist/index.js.map +1 -1
  35. package/dist/internal-path.d.ts +8 -7
  36. package/dist/internal-path.d.ts.map +1 -1
  37. package/dist/internal-path.js +12 -9
  38. package/dist/internal-path.js.map +1 -1
  39. package/dist/prerender-contract.d.ts +7 -7
  40. package/dist/prerender-contract.js +7 -7
  41. package/dist/query.d.ts +16 -17
  42. package/dist/query.d.ts.map +1 -1
  43. package/dist/query.js +2 -2
  44. package/dist/query.js.map +1 -1
  45. package/dist/sse.d.ts +19 -20
  46. package/dist/sse.d.ts.map +1 -1
  47. package/dist/sse.js +18 -19
  48. package/dist/sse.js.map +1 -1
  49. package/dist/states.d.ts +12 -12
  50. package/dist/states.d.ts.map +1 -1
  51. package/dist/supabase-session.d.ts +47 -48
  52. package/dist/supabase-session.d.ts.map +1 -1
  53. package/dist/supabase-session.js +35 -35
  54. package/dist/supabase-session.js.map +1 -1
  55. package/dist/theme.d.ts +4 -4
  56. package/dist/theme.js +4 -4
  57. package/dist/ui/class-name.d.ts +3 -4
  58. package/dist/ui/class-name.d.ts.map +1 -1
  59. package/dist/ui/class-name.js +3 -4
  60. package/dist/ui/class-name.js.map +1 -1
  61. package/dist/ui/combobox.d.ts +11 -11
  62. package/dist/ui/combobox.d.ts.map +1 -1
  63. package/dist/ui/dialog.d.ts +5 -5
  64. package/dist/ui/dialog.js +2 -2
  65. package/dist/ui/drawer.d.ts +10 -10
  66. package/dist/ui/drawer.d.ts.map +1 -1
  67. package/dist/ui/drawer.js +2 -2
  68. package/dist/ui/input.d.ts +2 -2
  69. package/dist/ui/input.js +2 -2
  70. package/dist/ui/menu.d.ts +8 -8
  71. package/dist/ui/menu.d.ts.map +1 -1
  72. package/dist/ui/menu.js +4 -4
  73. package/dist/ui/menu.js.map +1 -1
  74. package/dist/ui/select.d.ts +12 -12
  75. package/dist/ui/select.d.ts.map +1 -1
  76. package/dist/ui/select.js +3 -3
  77. package/dist/ui/tabs.d.ts +14 -15
  78. package/dist/ui/tabs.d.ts.map +1 -1
  79. package/dist/ui/tabs.js +13 -14
  80. package/dist/ui/tabs.js.map +1 -1
  81. package/package.json +1 -1
  82. package/src/api-client.ts +51 -51
  83. package/src/api-error.ts +11 -11
  84. package/src/auth-email.test.ts +145 -0
  85. package/src/auth-email.ts +112 -0
  86. package/src/auth-store.ts +23 -23
  87. package/src/copy.ts +3 -3
  88. package/src/deploy-recovery.ts +40 -40
  89. package/src/describe-error.ts +68 -68
  90. package/src/error-boundary.tsx +12 -12
  91. package/src/guards.tsx +15 -15
  92. package/src/hydrate.ts +15 -16
  93. package/src/i18n.ts +15 -16
  94. package/src/index.ts +2 -0
  95. package/src/internal-path.test.ts +1 -0
  96. package/src/internal-path.ts +12 -9
  97. package/src/prerender-contract.ts +7 -7
  98. package/src/query.ts +16 -17
  99. package/src/sse.ts +19 -20
  100. package/src/states.ts +12 -12
  101. package/src/supabase-session.ts +46 -47
  102. package/src/theme.ts +4 -4
  103. package/src/ui/class-name.ts +3 -4
  104. package/src/ui/combobox.tsx +11 -11
  105. package/src/ui/dialog.tsx +5 -5
  106. package/src/ui/drawer.tsx +10 -10
  107. package/src/ui/input.tsx +2 -2
  108. package/src/ui/menu.tsx +8 -8
  109. package/src/ui/select.tsx +12 -12
  110. package/src/ui/tabs.tsx +14 -15
@@ -0,0 +1,145 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { forgetAuthEmail, recallAuthEmail, rememberAuthEmail } from "./auth-email.ts";
3
+
4
+ const KEY = "frontkit.auth-email";
5
+ const MINUTE = 60 * 1000;
6
+
7
+ /**
8
+ * `localStorage` stubbed rather than run under a DOM, the same call as `theme.test.ts`. What
9
+ * matters is what a browser does at the edges: site data blocked (every method throws), a quota
10
+ * that refuses new writes while old ones stay readable, and a page with no storage at all.
11
+ */
12
+ function storage({ blocked = false }: { blocked?: boolean } = {}) {
13
+ const store = new Map<string, string>();
14
+ const state = { blocked, full: false };
15
+ vi.stubGlobal("localStorage", {
16
+ getItem: (key: string) => {
17
+ if (state.blocked) throw new Error("site data blocked");
18
+ return store.get(key) ?? null;
19
+ },
20
+ setItem: (key: string, value: string) => {
21
+ if (state.blocked || state.full) throw new Error("site data blocked");
22
+ store.set(key, value);
23
+ },
24
+ removeItem: (key: string) => {
25
+ if (state.blocked) throw new Error("site data blocked");
26
+ store.delete(key);
27
+ },
28
+ });
29
+ return { store, state };
30
+ }
31
+
32
+ beforeEach(() => {
33
+ vi.useFakeTimers();
34
+ vi.setSystemTime(new Date("2026-10-08T10:00:00Z"));
35
+ });
36
+
37
+ afterEach(() => {
38
+ forgetAuthEmail();
39
+ vi.unstubAllGlobals();
40
+ vi.useRealTimers();
41
+ });
42
+
43
+ describe("remember and recall", () => {
44
+ it("starts empty", () => {
45
+ storage();
46
+ expect(recallAuthEmail()).toBe("");
47
+ });
48
+
49
+ it("hands back what was typed, as typed", () => {
50
+ storage();
51
+ rememberAuthEmail(" Ana@Example.com ");
52
+ expect(recallAuthEmail()).toBe(" Ana@Example.com ");
53
+ });
54
+
55
+ it("writes to localStorage, so a new tab opened from a mail finds it", () => {
56
+ const { store } = storage();
57
+ rememberAuthEmail("ana@example.com");
58
+ expect(JSON.parse(store.get(KEY) ?? "null")).toMatchObject({ email: "ana@example.com" });
59
+ });
60
+
61
+ it("empties when the field is cleared", () => {
62
+ const { store } = storage();
63
+ rememberAuthEmail("ana@example.com");
64
+ rememberAuthEmail("");
65
+ expect(recallAuthEmail()).toBe("");
66
+ expect(store.has(KEY)).toBe(false);
67
+ });
68
+
69
+ it("keeps the last good value when handed something longer than an address can be", () => {
70
+ storage();
71
+ rememberAuthEmail("ana@example.com");
72
+ rememberAuthEmail("a".repeat(255));
73
+ expect(recallAuthEmail()).toBe("ana@example.com");
74
+ });
75
+
76
+ it("forgets on request", () => {
77
+ const { store } = storage();
78
+ rememberAuthEmail("ana@example.com");
79
+ forgetAuthEmail();
80
+ expect(recallAuthEmail()).toBe("");
81
+ expect(store.has(KEY)).toBe(false);
82
+ });
83
+ });
84
+
85
+ describe("the thirty minutes", () => {
86
+ it("lets go after thirty minutes of nothing, and deletes what it found", () => {
87
+ const { store } = storage();
88
+ rememberAuthEmail("ana@example.com");
89
+ vi.advanceTimersByTime(30 * MINUTE);
90
+ expect(recallAuthEmail()).toBe("ana@example.com");
91
+ vi.advanceTimersByTime(1);
92
+ expect(recallAuthEmail()).toBe("");
93
+ expect(store.has(KEY)).toBe(false);
94
+ });
95
+
96
+ it("renews on every keystroke", () => {
97
+ storage();
98
+ rememberAuthEmail("ana@example.com");
99
+ vi.advanceTimersByTime(25 * MINUTE);
100
+ rememberAuthEmail("ana@example.com.br");
101
+ vi.advanceTimersByTime(25 * MINUTE);
102
+ expect(recallAuthEmail()).toBe("ana@example.com.br");
103
+ });
104
+ });
105
+
106
+ describe("when the browser will not store it", () => {
107
+ it("falls back to memory when site data is blocked", () => {
108
+ storage({ blocked: true });
109
+ rememberAuthEmail("ana@example.com");
110
+ expect(recallAuthEmail()).toBe("ana@example.com");
111
+ forgetAuthEmail();
112
+ expect(recallAuthEmail()).toBe("");
113
+ });
114
+
115
+ it("falls back to memory when there is no localStorage at all", () => {
116
+ vi.stubGlobal("localStorage", undefined);
117
+ rememberAuthEmail("ana@example.com");
118
+ expect(recallAuthEmail()).toBe("ana@example.com");
119
+ });
120
+
121
+ it("prefers the newer address when a full quota left an older one in storage", () => {
122
+ const { state } = storage();
123
+ rememberAuthEmail("old@example.com");
124
+ state.full = true;
125
+ vi.advanceTimersByTime(MINUTE);
126
+ rememberAuthEmail("new@example.com");
127
+ expect(recallAuthEmail()).toBe("new@example.com");
128
+ });
129
+
130
+ it("prefers what another tab wrote after this tab's memory went stale", () => {
131
+ const { store } = storage();
132
+ rememberAuthEmail("old@example.com");
133
+ vi.advanceTimersByTime(MINUTE);
134
+ store.set(KEY, JSON.stringify({ email: "new@example.com", at: Date.now() }));
135
+ expect(recallAuthEmail()).toBe("new@example.com");
136
+ });
137
+
138
+ it("ignores a stored value that is not ours", () => {
139
+ const { store } = storage();
140
+ store.set(KEY, "{not json");
141
+ expect(recallAuthEmail()).toBe("");
142
+ store.set(KEY, JSON.stringify({ email: 42, at: "now" }));
143
+ expect(recallAuthEmail()).toBe("");
144
+ });
145
+ });
@@ -0,0 +1,112 @@
1
+ /**
2
+ * The email a person has typed during sign-in, kept so the next auth screen opens with it filled.
3
+ *
4
+ * Sign-in, "use a code instead", "forgot password" and "create account" are separate screens, and
5
+ * each one used to start with an empty email field. Someone who typed their address, then chose
6
+ * another way in, typed it again. The fix is the same everywhere, so it lives here once.
7
+ *
8
+ * ```tsx
9
+ * <input name="email" type="email" defaultValue={recallAuthEmail()}
10
+ * onChange={(event) => rememberAuthEmail(event.currentTarget.value)} />
11
+ * ```
12
+ *
13
+ * - **Not in the URL.** A query string rides in `Referer`, in access logs, in history and in every
14
+ * analytics tool that records page addresses. An email is personal data and has no business there.
15
+ * - **`localStorage`, not `sessionStorage`.** An emailed link opens in a new tab, and a new tab
16
+ * does not inherit `sessionStorage`. The person who confirms their account from the mail lands
17
+ * on sign-in in that new tab, which is the screen that most needs the address.
18
+ * - **Thirty minutes, sliding.** Long enough to read a code in a mail client and come back; short
19
+ * enough that a shared computer forgets. Every keystroke renews it, and a value found expired is
20
+ * deleted when it is read, so it does not sit on disk afterwards.
21
+ * - **Memory behind it.** A browser that blocks site data throws on `localStorage` itself, and
22
+ * React Native has none. The value then lasts until the page (or the app) is left, which still
23
+ * covers moving between screens.
24
+ *
25
+ * Read it once when a screen opens, as a default value. It is not a live store: nothing re-renders
26
+ * when it changes, and a prerendered page must read it after hydration, never while rendering.
27
+ * Never put a password, a code or a token here. Call `forgetAuthEmail()` once a session exists, so
28
+ * the next person at this browser starts empty.
29
+ */
30
+
31
+ const KEY = "frontkit.auth-email";
32
+ const TTL_MS = 30 * 60 * 1000;
33
+ /** The longest an address can be (RFC 5321). A longer value is not one, and is not kept. */
34
+ const MAX_LENGTH = 254;
35
+
36
+ interface Saved {
37
+ email: string;
38
+ at: number;
39
+ }
40
+
41
+ let memory: Saved | null = null;
42
+
43
+ function isSaved(value: unknown): value is Saved {
44
+ return (
45
+ typeof value === "object" &&
46
+ value !== null &&
47
+ "email" in value &&
48
+ typeof value.email === "string" &&
49
+ "at" in value &&
50
+ typeof value.at === "number"
51
+ );
52
+ }
53
+
54
+ function readStored(): Saved | null {
55
+ try {
56
+ const raw = globalThis.localStorage?.getItem(KEY);
57
+ if (!raw) return null;
58
+ const parsed: unknown = JSON.parse(raw);
59
+ return isSaved(parsed) ? parsed : null;
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Storage and memory are written together, so they only differ when one of them missed a write:
67
+ * storage that is full keeps an older address while memory holds the new one, and a second tab
68
+ * writes storage while this tab's memory goes stale. The newer of the two is the one the person
69
+ * typed last.
70
+ */
71
+ function newest(): Saved | null {
72
+ const stored = readStored();
73
+ if (!stored || !memory) return stored ?? memory;
74
+ return stored.at >= memory.at ? stored : memory;
75
+ }
76
+
77
+ /** The address typed earlier in this sign-in, or an empty string when there is none. */
78
+ export function recallAuthEmail(): string {
79
+ const saved = newest();
80
+ if (!saved) return "";
81
+ if (Date.now() - saved.at > TTL_MS) {
82
+ forgetAuthEmail();
83
+ return "";
84
+ }
85
+ return saved.email;
86
+ }
87
+
88
+ /**
89
+ * Keep what the person has typed. Call it from the email field's change handler, so every screen
90
+ * they reach next can open with it. The value is stored as typed: trim it where you submit it.
91
+ * An empty value forgets.
92
+ */
93
+ export function rememberAuthEmail(value: string): void {
94
+ if (value === "") return forgetAuthEmail();
95
+ if (value.length > MAX_LENGTH) return;
96
+ memory = { email: value, at: Date.now() };
97
+ try {
98
+ globalThis.localStorage?.setItem(KEY, JSON.stringify(memory));
99
+ } catch {
100
+ // Blocked or full: the memory copy above carries it until the page is left.
101
+ }
102
+ }
103
+
104
+ /** Drop it. Call this once a session exists, so the next person at this browser starts empty. */
105
+ export function forgetAuthEmail(): void {
106
+ memory = null;
107
+ try {
108
+ globalThis.localStorage?.removeItem(KEY);
109
+ } catch {
110
+ // Blocked: nothing was stored, so there is nothing to drop.
111
+ }
112
+ }
package/src/auth-store.ts CHANGED
@@ -3,14 +3,14 @@ import { create, type StoreApi, type UseBoundStore } from "zustand";
3
3
  /**
4
4
  * Session FLAGS only.
5
5
  *
6
- * Who the person is — their plan, their staff bit, whether they are suspended — comes from
7
- * `GET /auth/me` through react-query, never from here. Mirroring server state in a client store
8
- * is how two sources of truth start disagreeing, and the one that is wrong is always the one on
9
- * screen. Three repos wrote this same store; two of them wrote that same warning in a comment.
6
+ * Who the person is, including their plan, staff bit and suspension status, comes from `GET
7
+ * /auth/me` through react-query, never from here. Mirroring server state in a client store is how
8
+ * two sources of truth start disagreeing, and the one that is wrong is always the one on screen.
9
+ * Three repos wrote this same store; two of them wrote that same warning in a comment.
10
10
  *
11
11
  * `TUser` stays generic because it is the one part that differs: one product carries an
12
- * anonymous-browsing flag, another does not. Keep it to what a GUARD needs — an id and an
13
- * email is what all three donors had.
12
+ * anonymous-browsing flag, another does not. Keep it to what a GUARD needs. All three donors had an
13
+ * id and an email.
14
14
  */
15
15
  export interface AuthState<TUser> {
16
16
  user: TUser | null;
@@ -21,10 +21,10 @@ export interface AuthState<TUser> {
21
21
  */
22
22
  isLoading: boolean;
23
23
  /**
24
- * Set (or clear) the signed-in user. This ENDS the loading state — knowing who they are is
25
- * what the bootstrap was waiting for. Two of the three donors left `isLoading` alone here and
26
- * relied on a separate `setLoading(false)`; forgetting that call leaves every guard spinning,
27
- * so the safe default is to do it in one write.
24
+ * Set (or clear) the signed-in user. This ENDS the loading state, because the bootstrap was
25
+ * waiting to learn who they are. Two of the three donors left `isLoading` alone here and relied
26
+ * on a separate `setLoading(false)`; forgetting that call leaves every guard spinning, so the
27
+ * safe default is to do it in one write.
28
28
  */
29
29
  setUser: (user: TUser | null) => void;
30
30
  setLoading: (isLoading: boolean) => void;
@@ -38,12 +38,12 @@ export interface AuthState<TUser> {
38
38
  * The whole reason this is a factory and not a store: `isLoading` must start FALSE where there
39
39
  * is no window, and only a factory can decide that at the call site rather than at import.
40
40
  *
41
- * A session bootstrap can only be in flight in a browser. The BUILD renders this app to files
42
- * with no window at all, so `true` there is a wait that never ends — one donor shipped a route
43
- * guard holding its loading screen forever, and prerendered a public page as 1,174 bytes of
44
- * `role="status"`: a spinner as the indexable body of a page whose entire purpose was to be
45
- * found. With no browser there is no session and never will be, which is exactly the state a
46
- * first-time visitor arrives in, so that is what the page should render.
41
+ * A session bootstrap can only be in flight in a browser. The BUILD renders this app to files with
42
+ * no window at all, so `true` there is a wait that never ends. One donor shipped a route guard
43
+ * holding its loading screen forever, and prerendered a public page as 1,174 bytes of
44
+ * `role="status"`: a spinner as the indexable body of a page whose entire purpose was to be found.
45
+ * With no browser there is no session and never will be, which is exactly the state a first-time
46
+ * visitor arrives in, so that is what the page should render.
47
47
  *
48
48
  * Only one of the three donors knew this. It is invariant 7.
49
49
  */
@@ -70,13 +70,13 @@ type AuthSet<TUser, TExtra> = (
70
70
  /**
71
71
  * The session flags as a plain object, for a product whose store needs more than them.
72
72
  *
73
- * `createAuthStore` owns its `create()` call, which means it owns the whole store: a product
74
- * cannot wrap it in `persist`, cannot add an action, and cannot add a field. Both adopters that
75
- * met it had a superset and neither could use it. One holds a remember-me choice, a profile row
76
- * and the sign-in methods themselves, under `persist`. The other adds `isAnonymous` — and that
77
- * one is the reason this takes a `derive` function rather than just letting the caller spread
78
- * extra keys in: `isAnonymous` is read off the user, so it has to be rewritten by `setUser` and
79
- * `clear`, which are exactly the two writes the product does not own.
73
+ * `createAuthStore` owns its `create()` call, which means it owns the whole store: a product cannot
74
+ * wrap it in `persist`, cannot add an action, and cannot add a field. Both adopters that met it had
75
+ * a superset and neither could use it. One holds a remember-me choice, a profile row and the
76
+ * sign-in methods themselves, under `persist`. The other adds `isAnonymous`, which is the reason
77
+ * this takes a `derive` function rather than just letting the caller spread extra keys in:
78
+ * `isAnonymous` is read off the user, so it has to be rewritten by `setUser` and `clear`, which are
79
+ * exactly the two writes the product does not own.
80
80
  *
81
81
  * The caller keeps `create`, so middleware, extra actions and the store's own name stay theirs:
82
82
  *
package/src/copy.ts CHANGED
@@ -29,9 +29,9 @@ export interface CopyOptions {
29
29
  }
30
30
 
31
31
  /**
32
- * A copy with an outcome the reader can see. What it says stays yours — "Copied", or "Select it
33
- * and copy it yourself", in a label or a toast — and so does announcing it: a label that changes
34
- * needs `aria-live="polite"` to be heard.
32
+ * A copy with an outcome the reader can see. You choose what it says, such as "Copied" or "Select
33
+ * it and copy it yourself", in a label or a toast. You also choose how to announce it: a label that
34
+ * changes needs `aria-live="polite"` to be heard.
35
35
  *
36
36
  * The outcome goes back to `"idle"` on its own. A "Copied" that never clears makes the second copy
37
37
  * look like it did not take.
@@ -5,26 +5,26 @@
5
5
  * every hashed asset underneath whatever tabs are already open, and those tabs go on running
6
6
  * yesterday's bundle. It surfaces two ways, and only one of them looks like a missing file:
7
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.
8
+ * 1. The next navigation asks for a chunk whose hashed name is gone. React's lazy boundary throws,
9
+ * and the person gets a white screen on a click that worked a minute ago. 2. The old bundle reads a
10
+ * field the API has since renamed, and throws. That one arrives as a plain `TypeError` with nothing
11
+ * in it about a deploy: no file, no hash, no signal.
12
12
  *
13
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.
14
+ * matches the message, which is all you have for a failure that never reaches a boundary. {@link
15
+ * isStaleBuild} asks the server for evidence rather than a guess. It is the only one that catches
16
+ * case 2 at all.
17
17
  *
18
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.
19
+ * host answers a missing `/assets/x.js` with the SPA fallback, `index.html`, at a 200, as
20
+ * `text/html`. That is what the browser's "MIME type text/html" module-script refusal actually is.
21
+ * Hosts then match cache rules against the REQUEST PATH rather than the outcome, so the `immutable,
22
+ * max-age=31536000` rule written for hashed assets gets applied to that HTML body: one poisoned
23
+ * asset URL, at the edge and in every browser that touched it, for a year. One adopter removed the
24
+ * cause instead of recovering from it: no blanket fallback over `/assets/`, and an edge function
25
+ * that refuses to answer an asset path with `text/html`. That turns a dead chunk into a real,
26
+ * uncacheable 404 and lets everything below be recovery rather than the plan. A package cannot ship
27
+ * that; it can refuse to let it go unwritten.
28
28
  */
29
29
 
30
30
  /**
@@ -48,14 +48,14 @@ const RELOAD_WINDOW_MS = 60_000;
48
48
  const PROBE_TIMEOUT_MS = 4_000;
49
49
 
50
50
  /**
51
- * A lazy-chunk fetch failure — recoverable by reloading.
51
+ * A lazy-chunk fetch failure that a reload can fix.
52
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`.
53
+ * Matched by message because no browser gives it a shared type. Every phrasing in the wild is here:
54
+ * Chrome/Edge "Failed to fetch dynamically imported module", Firefox "error loading dynamically
55
+ * imported module", Safari "Importing a module script failed", webpack's ChunkLoadError and
56
+ * "Loading chunk N failed", and the SPA-fallback "MIME type" refusal. The last one that fires on a
57
+ * static host that has not fixed the cause above, because the missing `.js` is answered with
58
+ * `index.html`.
59
59
  *
60
60
  * A standalone predicate rather than a method on the boundary, so a global `unhandledrejection`
61
61
  * handler can classify the same failure without importing a component.
@@ -93,13 +93,13 @@ function runningEntry(): string {
93
93
  /**
94
94
  * Is the build this tab is running still the one being served?
95
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.
96
+ * Asked of the server, so the answer is evidence rather than a guess. An offline request, a non-200
97
+ * response, a captive portal or a timed-out probe resolves to `false`: dressing a genuine bug up as
98
+ * an update hides it from the user and from us.
99
99
  *
100
100
  * It asks for the PAGE, not for the asset, and that is not the obvious choice. A missing asset
101
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"
102
+ * bundle a deploy retired an hour ago still answers 200 from the edge. It would report "current"
103
103
  * during exactly the window when skew is most likely. The page is the one URL that cannot lie: it
104
104
  * ships `max-age=0, must-revalidate` and never enters the edge cache.
105
105
  */
@@ -124,9 +124,9 @@ export async function isStaleBuild(): Promise<boolean> {
124
124
  * rather than watching the same attempt loop.
125
125
  *
126
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.
127
+ * that the reload does not fix is an infinite loop with the user inside it. At most one reload per
128
+ * minute, so a genuinely broken deploy shows the error screen instead of flickering forever. That
129
+ * screen can at least say something.
130
130
  *
131
131
  * `sessionStorage` and not `localStorage`: the guard is about this tab's current predicament, and
132
132
  * a stale stamp in another tab must not suppress a reload this one needs. Wrapped because storage
@@ -149,11 +149,11 @@ export function reloadOnce(): boolean {
149
149
  /**
150
150
  * Vite's *hint* failure, which is not the same thing and must not be treated as one.
151
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.
152
+ * Vite injects a speculative `<link rel="stylesheet">` for a chunk's CSS before importing it. When
153
+ * that link fails, the import itself still runs right after and usually succeeds, so this one is
154
+ * worth swallowing, where a failure of the import is the real thing {@link isChunkLoadError}
155
+ * matches. Vite builds this message in exactly one place and only ever for a stylesheet link;
156
+ * script hints never reject.
157
157
  */
158
158
  export function isPreloadHintFailure(error: unknown): boolean {
159
159
  return error instanceof Error && error.message.startsWith("Unable to preload CSS for");
@@ -162,12 +162,12 @@ export function isPreloadHintFailure(error: unknown): boolean {
162
162
  /**
163
163
  * Install the `vite:preloadError` listener. Call once, from the browser entry.
164
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
165
+ * Vite offers `preventDefault()` here so an app can own a failed asset itself, but taking that deal
166
+ * unconditionally resolves the dynamic import with `undefined` (Vite's helper ends in
167
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.
168
+ * The user gets the crash screen and the developer gets a TypeError with only React frames in it,
169
+ * naming no chunk. So it is taken ONLY for a failed preload hint, where the import still runs. A
170
+ * real module failure is left to throw, where the error boundary can recognise it.
171
171
  */
172
172
  export function installPreloadErrorHandler(): void {
173
173
  window.addEventListener("vite:preloadError", (event) => {