@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.
- package/README.md +36 -36
- package/dist/api-client.d.ts +51 -51
- package/dist/api-client.d.ts.map +1 -1
- package/dist/api-client.js +3 -3
- package/dist/api-error.d.ts +11 -11
- package/dist/api-error.js +11 -11
- package/dist/auth-email.d.ts +40 -0
- package/dist/auth-email.d.ts.map +1 -0
- package/dist/auth-email.js +106 -0
- package/dist/auth-email.js.map +1 -0
- package/dist/auth-store.d.ts +23 -23
- package/dist/auth-store.js +13 -13
- package/dist/copy.d.ts +3 -3
- package/dist/copy.js +3 -3
- package/dist/deploy-recovery.d.ts +40 -40
- package/dist/deploy-recovery.js +40 -40
- package/dist/describe-error.d.ts +68 -68
- package/dist/describe-error.d.ts.map +1 -1
- package/dist/describe-error.js +5 -5
- package/dist/error-boundary.d.ts +12 -12
- package/dist/guards.d.ts +15 -15
- package/dist/guards.js +3 -3
- package/dist/hydrate.d.ts +15 -16
- package/dist/hydrate.d.ts.map +1 -1
- package/dist/hydrate.js +9 -9
- package/dist/hydrate.js.map +1 -1
- package/dist/i18n.d.ts +15 -16
- package/dist/i18n.d.ts.map +1 -1
- package/dist/i18n.js +5 -6
- package/dist/i18n.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/internal-path.d.ts +8 -7
- package/dist/internal-path.d.ts.map +1 -1
- package/dist/internal-path.js +12 -9
- package/dist/internal-path.js.map +1 -1
- package/dist/prerender-contract.d.ts +7 -7
- package/dist/prerender-contract.js +7 -7
- package/dist/query.d.ts +16 -17
- package/dist/query.d.ts.map +1 -1
- package/dist/query.js +2 -2
- package/dist/query.js.map +1 -1
- package/dist/sse.d.ts +19 -20
- package/dist/sse.d.ts.map +1 -1
- package/dist/sse.js +18 -19
- package/dist/sse.js.map +1 -1
- package/dist/states.d.ts +12 -12
- package/dist/states.d.ts.map +1 -1
- package/dist/supabase-session.d.ts +47 -48
- package/dist/supabase-session.d.ts.map +1 -1
- package/dist/supabase-session.js +35 -35
- package/dist/supabase-session.js.map +1 -1
- package/dist/theme.d.ts +4 -4
- package/dist/theme.js +4 -4
- package/dist/ui/class-name.d.ts +3 -4
- package/dist/ui/class-name.d.ts.map +1 -1
- package/dist/ui/class-name.js +3 -4
- package/dist/ui/class-name.js.map +1 -1
- package/dist/ui/combobox.d.ts +11 -11
- package/dist/ui/combobox.d.ts.map +1 -1
- package/dist/ui/dialog.d.ts +5 -5
- package/dist/ui/dialog.js +2 -2
- package/dist/ui/drawer.d.ts +10 -10
- package/dist/ui/drawer.d.ts.map +1 -1
- package/dist/ui/drawer.js +2 -2
- package/dist/ui/input.d.ts +2 -2
- package/dist/ui/input.js +2 -2
- package/dist/ui/menu.d.ts +8 -8
- package/dist/ui/menu.d.ts.map +1 -1
- package/dist/ui/menu.js +4 -4
- package/dist/ui/menu.js.map +1 -1
- package/dist/ui/select.d.ts +12 -12
- package/dist/ui/select.d.ts.map +1 -1
- package/dist/ui/select.js +3 -3
- package/dist/ui/tabs.d.ts +14 -15
- package/dist/ui/tabs.d.ts.map +1 -1
- package/dist/ui/tabs.js +13 -14
- package/dist/ui/tabs.js.map +1 -1
- package/package.json +1 -1
- package/src/api-client.ts +51 -51
- package/src/api-error.ts +11 -11
- package/src/auth-email.test.ts +145 -0
- package/src/auth-email.ts +112 -0
- package/src/auth-store.ts +23 -23
- package/src/copy.ts +3 -3
- package/src/deploy-recovery.ts +40 -40
- package/src/describe-error.ts +68 -68
- package/src/error-boundary.tsx +12 -12
- package/src/guards.tsx +15 -15
- package/src/hydrate.ts +15 -16
- package/src/i18n.ts +15 -16
- package/src/index.ts +2 -0
- package/src/internal-path.test.ts +1 -0
- package/src/internal-path.ts +12 -9
- package/src/prerender-contract.ts +7 -7
- package/src/query.ts +16 -17
- package/src/sse.ts +19 -20
- package/src/states.ts +12 -12
- package/src/supabase-session.ts +46 -47
- package/src/theme.ts +4 -4
- package/src/ui/class-name.ts +3 -4
- package/src/ui/combobox.tsx +11 -11
- package/src/ui/dialog.tsx +5 -5
- package/src/ui/drawer.tsx +10 -10
- package/src/ui/input.tsx +2 -2
- package/src/ui/menu.tsx +8 -8
- package/src/ui/select.tsx +12 -12
- 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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
|
13
|
-
*
|
|
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
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* `role="status"`: a spinner as the indexable body of a page whose entire purpose was to be
|
|
45
|
-
*
|
|
46
|
-
*
|
|
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
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
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.
|
|
33
|
-
* and copy it yourself", in a label or a toast
|
|
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.
|
package/src/deploy-recovery.ts
CHANGED
|
@@ -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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
*
|
|
16
|
-
*
|
|
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
|
|
20
|
-
* `text/html
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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
|
|
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
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
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.
|
|
97
|
-
*
|
|
98
|
-
*
|
|
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
|
|
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
|
-
*
|
|
129
|
-
*
|
|
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
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
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
|
|
166
|
-
*
|
|
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
|
-
*
|
|
170
|
-
*
|
|
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) => {
|