@gusnips/react 0.9.5 → 0.9.7
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 +118 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/supabase-session.d.ts +72 -0
- package/dist/supabase-session.d.ts.map +1 -1
- package/dist/supabase-session.js +55 -0
- package/dist/supabase-session.js.map +1 -1
- package/dist/theme.d.ts +90 -0
- package/dist/theme.d.ts.map +1 -0
- package/dist/theme.js +160 -0
- package/dist/theme.js.map +1 -0
- package/package.json +5 -1
- package/src/index.ts +2 -0
- package/src/supabase-session.test.ts +68 -0
- package/src/supabase-session.ts +104 -0
- package/src/theme.test.ts +268 -0
- package/src/theme.ts +220 -0
|
@@ -8,6 +8,7 @@ import { describe, expect, it, vi } from "vitest";
|
|
|
8
8
|
import {
|
|
9
9
|
createSupabaseSessionAdapter,
|
|
10
10
|
isAuthOutage,
|
|
11
|
+
parseAuthCallback,
|
|
11
12
|
type SupabaseSessionAuth,
|
|
12
13
|
} from "./supabase-session.ts";
|
|
13
14
|
|
|
@@ -154,3 +155,70 @@ describe("isAuthOutage", () => {
|
|
|
154
155
|
expect(isAuthOutage(unreadable)).toBe(false);
|
|
155
156
|
});
|
|
156
157
|
});
|
|
158
|
+
|
|
159
|
+
describe("parseAuthCallback", () => {
|
|
160
|
+
it("reads an email link, including the types an app's templates may not send yet", () => {
|
|
161
|
+
expect(parseAuthCallback("?token_hash=pkce_abc&type=recovery")).toEqual({
|
|
162
|
+
kind: "email-link",
|
|
163
|
+
tokenHash: "pkce_abc",
|
|
164
|
+
type: "recovery",
|
|
165
|
+
});
|
|
166
|
+
// An operator can invite from Studio whatever the product does; rejecting the type put
|
|
167
|
+
// "invalid link" on a real, branded invitation.
|
|
168
|
+
expect(parseAuthCallback(new URLSearchParams("token_hash=abc&type=invite"))).toMatchObject({
|
|
169
|
+
kind: "email-link",
|
|
170
|
+
type: "invite",
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("refuses an email link whose type is missing or made up", () => {
|
|
175
|
+
expect(parseAuthCallback("?token_hash=abc")).toEqual({ kind: "invalid" });
|
|
176
|
+
expect(parseAuthCallback("?token_hash=abc&type=admin")).toEqual({ kind: "invalid" });
|
|
177
|
+
// A key every object inherits is still not a link type.
|
|
178
|
+
expect(parseAuthCallback("?token_hash=abc&type=toString")).toEqual({ kind: "invalid" });
|
|
179
|
+
expect(parseAuthCallback("", "")).toEqual({ kind: "invalid" });
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("hands back the session a URL carries, in either flow", () => {
|
|
183
|
+
expect(parseAuthCallback("?code=4f1c")).toEqual({
|
|
184
|
+
kind: "session",
|
|
185
|
+
type: null,
|
|
186
|
+
credential: { code: "4f1c" },
|
|
187
|
+
});
|
|
188
|
+
// The implicit flow puts the tokens, and a recovery's type, in the fragment.
|
|
189
|
+
expect(
|
|
190
|
+
parseAuthCallback("", "#access_token=at&expires_in=3600&refresh_token=rt&type=recovery"),
|
|
191
|
+
).toEqual({
|
|
192
|
+
kind: "session",
|
|
193
|
+
type: "recovery",
|
|
194
|
+
credential: { access_token: "at", refresh_token: "rt" },
|
|
195
|
+
});
|
|
196
|
+
expect(parseAuthCallback("", "#access_token=at")).toEqual({ kind: "invalid" });
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("tells a person who pressed Cancel from a link that expired", () => {
|
|
200
|
+
// A refusal relayed from the provider: GoTrue writes it to both halves, with no code.
|
|
201
|
+
const cancel = "error=access_denied&error_description=The+user+denied+the+request";
|
|
202
|
+
expect(parseAuthCallback(`?${cancel}`, `#${cancel}&sb=`)).toEqual({
|
|
203
|
+
kind: "error",
|
|
204
|
+
error: "access_denied",
|
|
205
|
+
code: null,
|
|
206
|
+
cancelled: true,
|
|
207
|
+
});
|
|
208
|
+
// GoTrue's own refusal of an implicit-flow email link: the SAME error, a code beside it,
|
|
209
|
+
// and only in the fragment — a query-only reader sees nothing at all.
|
|
210
|
+
expect(
|
|
211
|
+
parseAuthCallback(
|
|
212
|
+
"",
|
|
213
|
+
"#error=access_denied&error_code=otp_expired&error_description=Email+link+is+invalid+or+has+expired&sb=",
|
|
214
|
+
),
|
|
215
|
+
).toEqual({ kind: "error", error: "access_denied", code: "otp_expired", cancelled: false });
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("reads a failure that carries no `error` at all", () => {
|
|
219
|
+
// Statuses outside GoTrue's OAuth mapping, a 429 among them, write only the code.
|
|
220
|
+
expect(
|
|
221
|
+
parseAuthCallback("", "#error_code=over_email_send_rate_limit&error_description=x&sb="),
|
|
222
|
+
).toEqual({ kind: "error", error: null, code: "over_email_send_rate_limit", cancelled: false });
|
|
223
|
+
});
|
|
224
|
+
});
|
package/src/supabase-session.ts
CHANGED
|
@@ -97,3 +97,107 @@ export function createSupabaseSessionAdapter(auth: SupabaseSessionAuth): Session
|
|
|
97
97
|
},
|
|
98
98
|
};
|
|
99
99
|
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Every link type a GoTrue email can carry — not only the ones this app's templates send today.
|
|
103
|
+
* One donor rejected `invite` because "invites are never sent", which was true of the product and
|
|
104
|
+
* false of the deployment: an operator inviting somebody from Studio sent a branded email whose
|
|
105
|
+
* link then said it was invalid. A type the templates never emit costs nothing to accept: the hash
|
|
106
|
+
* is the proof, and GoTrue opens a session only for the person whose one-time token it is.
|
|
107
|
+
*
|
|
108
|
+
* Closed on purpose: auth-js's own `EmailOtpType` ends in `(string & {})`, so it cannot make a
|
|
109
|
+
* destination map name every case. `Record<EmailLinkType, string>` can, and that is where an app
|
|
110
|
+
* decides that `recovery` and `invite` both continue to the new-password screen — GoTrue creates
|
|
111
|
+
* an invited account with NO password, so landing one "inside" signs them in once and locks them
|
|
112
|
+
* out after.
|
|
113
|
+
*/
|
|
114
|
+
export type EmailLinkType =
|
|
115
|
+
"signup" | "invite" | "magiclink" | "recovery" | "email_change" | "email";
|
|
116
|
+
|
|
117
|
+
const EMAIL_LINK_TYPES: Record<EmailLinkType, true> = {
|
|
118
|
+
signup: true,
|
|
119
|
+
invite: true,
|
|
120
|
+
magiclink: true,
|
|
121
|
+
recovery: true,
|
|
122
|
+
email_change: true,
|
|
123
|
+
email: true,
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
function isEmailLinkType(value: string): value is EmailLinkType {
|
|
127
|
+
// Not `in`: `"toString" in EMAIL_LINK_TYPES` is true, and the type is typed by whoever sends
|
|
128
|
+
// the link.
|
|
129
|
+
return Object.hasOwn(EMAIL_LINK_TYPES, value);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Everything an auth callback address can carry, decided from the address alone. */
|
|
133
|
+
export type AuthCallback =
|
|
134
|
+
/** An email link with a one-use `token_hash`: trade it with `verifyOtp`, exactly once. */
|
|
135
|
+
| { kind: "email-link"; tokenHash: string; type: EmailLinkType }
|
|
136
|
+
/**
|
|
137
|
+
* A session arriving in the address: a PKCE `code`, or the implicit flow's tokens. In a browser
|
|
138
|
+
* with `detectSessionInUrl` on, the client has already taken it — wait for the session, and do
|
|
139
|
+
* not trade it again, which would be a second exchange racing the first. Where nothing reads
|
|
140
|
+
* the address for you (React Native), `credential` is what `exchangeCodeForSession` or
|
|
141
|
+
* `setSession` takes.
|
|
142
|
+
*/
|
|
143
|
+
| {
|
|
144
|
+
kind: "session";
|
|
145
|
+
type: EmailLinkType | null;
|
|
146
|
+
credential: { code: string } | { access_token: string; refresh_token: string };
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* GoTrue, or the provider behind it, reported a failure in the address. `code` is GoTrue's
|
|
150
|
+
* stable `error_code` (`otp_expired`, `bad_oauth_state`, …) and is what to choose words by;
|
|
151
|
+
* `error_description` is left out on purpose — it is English-only and often names the wrong
|
|
152
|
+
* cause.
|
|
153
|
+
*/
|
|
154
|
+
| { kind: "error"; error: string | null; code: string | null; cancelled: boolean }
|
|
155
|
+
/** Nothing usable: cut short between the mail client and here, or edited by hand. */
|
|
156
|
+
| { kind: "invalid" };
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Reads an auth callback address. Pure, so every edge case is testable without a router or a
|
|
160
|
+
* client — pass `location.search` and `location.hash`, or the two halves of a deep link.
|
|
161
|
+
*
|
|
162
|
+
* GoTrue writes a failure into the FRAGMENT on every path and into the query only on some — the
|
|
163
|
+
* query too after a provider round trip, the fragment alone after an implicit-flow email link —
|
|
164
|
+
* so a callback reading one of the two misses a failure the other carries, and the person lands
|
|
165
|
+
* on a screen that says nothing. Both are read here.
|
|
166
|
+
*
|
|
167
|
+
* `cancelled` is the provider refusing on its own, which for Google is the person pressing Cancel
|
|
168
|
+
* — their choice, not a failure to report. It is NOT `error === "access_denied"`: GoTrue writes
|
|
169
|
+
* that same value for an expired email link, a banned user and a disabled signup, each with an
|
|
170
|
+
* `error_code` beside it, while a refusal relayed from the provider arrives with none. The obvious
|
|
171
|
+
* test reads an expired link as a change of mind. And `error` itself is absent for a status
|
|
172
|
+
* outside GoTrue's OAuth mapping — a rate-limited link arrives with `error_code` alone.
|
|
173
|
+
*/
|
|
174
|
+
export function parseAuthCallback(search: string | URLSearchParams, hash = ""): AuthCallback {
|
|
175
|
+
const query = new URLSearchParams(search);
|
|
176
|
+
const fragment = new URLSearchParams(hash.replace(/^#/, ""));
|
|
177
|
+
const read = (name: string) => query.get(name) || fragment.get(name) || null;
|
|
178
|
+
|
|
179
|
+
const error = read("error");
|
|
180
|
+
const code = read("error_code");
|
|
181
|
+
if (error || code || read("error_description"))
|
|
182
|
+
return { kind: "error", error, code, cancelled: error === "access_denied" && code === null };
|
|
183
|
+
|
|
184
|
+
const rawType = read("type") ?? "";
|
|
185
|
+
const type = isEmailLinkType(rawType) ? rawType : null;
|
|
186
|
+
|
|
187
|
+
const tokenHash = query.get("token_hash")?.trim();
|
|
188
|
+
if (tokenHash) return type ? { kind: "email-link", tokenHash, type } : { kind: "invalid" };
|
|
189
|
+
|
|
190
|
+
const pkceCode = query.get("code");
|
|
191
|
+
if (pkceCode) return { kind: "session", type, credential: { code: pkceCode } };
|
|
192
|
+
|
|
193
|
+
const accessToken = fragment.get("access_token");
|
|
194
|
+
const refreshToken = fragment.get("refresh_token");
|
|
195
|
+
if (accessToken && refreshToken)
|
|
196
|
+
return {
|
|
197
|
+
kind: "session",
|
|
198
|
+
type,
|
|
199
|
+
credential: { access_token: accessToken, refresh_token: refreshToken },
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
return { kind: "invalid" };
|
|
203
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { createElement } from "react";
|
|
2
|
+
import { renderToString } from "react-dom/server";
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { createTheme, startTheme, type ThemeOptions } from "./theme.ts";
|
|
5
|
+
|
|
6
|
+
const KEY = "app.theme";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The few globals the controller touches, stubbed rather than run under a DOM — the same call as
|
|
10
|
+
* `deploy-recovery.test.ts`. What matters is what a browser does at the edges: storage that
|
|
11
|
+
* throws from both methods when site data is blocked, an OS preference that changes under the
|
|
12
|
+
* page, and events from other tabs.
|
|
13
|
+
*/
|
|
14
|
+
function browser({
|
|
15
|
+
stored,
|
|
16
|
+
osDark = false,
|
|
17
|
+
blocked = false,
|
|
18
|
+
metas = 0,
|
|
19
|
+
}: { stored?: string; osDark?: boolean; blocked?: boolean; metas?: number } = {}) {
|
|
20
|
+
const store = new Map<string, string>(stored === undefined ? [] : [[KEY, stored]]);
|
|
21
|
+
const state = { blocked };
|
|
22
|
+
const classes = new Set<string>();
|
|
23
|
+
const attributes = new Map<string, string>();
|
|
24
|
+
const media = { matches: osDark, listeners: [] as Array<() => void> };
|
|
25
|
+
const events = new Map<string, Array<(event: { key: string | null }) => void>>();
|
|
26
|
+
const themeColors = Array.from({ length: metas }, () => ({ content: "" }));
|
|
27
|
+
|
|
28
|
+
const fire = (type: string, event: { key: string | null } = { key: null }) =>
|
|
29
|
+
events.get(type)?.forEach((listener) => listener(event));
|
|
30
|
+
|
|
31
|
+
vi.stubGlobal("window", {
|
|
32
|
+
matchMedia: () => ({
|
|
33
|
+
get matches() {
|
|
34
|
+
return media.matches;
|
|
35
|
+
},
|
|
36
|
+
addEventListener: (_type: string, listener: () => void) => media.listeners.push(listener),
|
|
37
|
+
}),
|
|
38
|
+
localStorage: {
|
|
39
|
+
getItem: (key: string) => {
|
|
40
|
+
if (state.blocked) throw new Error("site data blocked");
|
|
41
|
+
return store.get(key) ?? null;
|
|
42
|
+
},
|
|
43
|
+
setItem: (key: string, value: string) => {
|
|
44
|
+
if (state.blocked) throw new Error("site data blocked");
|
|
45
|
+
store.set(key, value);
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
addEventListener: (type: string, listener: (event: { key: string | null }) => void) =>
|
|
49
|
+
events.set(type, [...(events.get(type) ?? []), listener]),
|
|
50
|
+
});
|
|
51
|
+
vi.stubGlobal("document", {
|
|
52
|
+
documentElement: {
|
|
53
|
+
classList: {
|
|
54
|
+
toggle: (name: string, on: boolean) => (on ? classes.add(name) : classes.delete(name)),
|
|
55
|
+
},
|
|
56
|
+
setAttribute: (name: string, value: string) => attributes.set(name, value),
|
|
57
|
+
},
|
|
58
|
+
querySelectorAll: () =>
|
|
59
|
+
themeColors.map((meta) => ({
|
|
60
|
+
setAttribute: (_name: string, value: string) => (meta.content = value),
|
|
61
|
+
})),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
store,
|
|
66
|
+
themeColors,
|
|
67
|
+
dark: () => classes.has("dark"),
|
|
68
|
+
attribute: (name: string) => attributes.get(name),
|
|
69
|
+
osSwitches(dark: boolean) {
|
|
70
|
+
media.matches = dark;
|
|
71
|
+
media.listeners.forEach((listener) => listener());
|
|
72
|
+
},
|
|
73
|
+
/** Another tab wrote the key: `storage` fires here, never in the tab that wrote it. */
|
|
74
|
+
otherTabPicks(value: string) {
|
|
75
|
+
store.set(KEY, value);
|
|
76
|
+
fire("storage", { key: KEY });
|
|
77
|
+
},
|
|
78
|
+
storageEvent: (key: string | null) => fire("storage", { key }),
|
|
79
|
+
backForwardRestore: () => fire("pageshow"),
|
|
80
|
+
block: () => (state.blocked = true),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const options: ThemeOptions = { key: KEY };
|
|
85
|
+
|
|
86
|
+
afterEach(() => {
|
|
87
|
+
vi.unstubAllGlobals();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("startTheme", () => {
|
|
91
|
+
it("paints the stored choice the moment it starts", () => {
|
|
92
|
+
const page = browser({ stored: "dark" });
|
|
93
|
+
expect(startTheme(options).getSnapshot()).toEqual({ mode: "dark", resolved: "dark" });
|
|
94
|
+
expect(page.dark()).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("follows the OS while the choice is system, live — and only then", () => {
|
|
98
|
+
const page = browser({ stored: "system", osDark: true });
|
|
99
|
+
const theme = startTheme(options);
|
|
100
|
+
expect(page.dark()).toBe(true);
|
|
101
|
+
|
|
102
|
+
page.osSwitches(false);
|
|
103
|
+
expect(theme.getSnapshot()).toEqual({ mode: "system", resolved: "light" });
|
|
104
|
+
expect(page.dark()).toBe(false);
|
|
105
|
+
|
|
106
|
+
theme.setMode("dark");
|
|
107
|
+
page.osSwitches(false);
|
|
108
|
+
expect(page.dark()).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// A choice made later in the same page must still follow the OS. A listener attached only
|
|
112
|
+
// when the page started in "system" would miss every switch after that.
|
|
113
|
+
it("follows the OS after switching to system mid-page", () => {
|
|
114
|
+
const page = browser({ stored: "light" });
|
|
115
|
+
const theme = startTheme(options);
|
|
116
|
+
theme.setMode("system");
|
|
117
|
+
page.osSwitches(true);
|
|
118
|
+
expect(page.dark()).toBe(true);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("reads an empty key, and one holding anything else, as the default", () => {
|
|
122
|
+
browser({ stored: "blue" });
|
|
123
|
+
expect(startTheme({ key: KEY, defaultMode: "dark" }).getSnapshot().mode).toBe("dark");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// The white page: storage throws on read, during the first render. The default has to apply,
|
|
127
|
+
// following the OS like any other "system".
|
|
128
|
+
it("starts on the default when storage is blocked, instead of throwing", () => {
|
|
129
|
+
const page = browser({ blocked: true, osDark: true });
|
|
130
|
+
expect(startTheme(options).getSnapshot()).toEqual({ mode: "system", resolved: "dark" });
|
|
131
|
+
expect(page.dark()).toBe(true);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("keeps a choice made under blocked storage when the page resyncs", () => {
|
|
135
|
+
const page = browser({ blocked: true });
|
|
136
|
+
const theme = startTheme(options);
|
|
137
|
+
theme.setMode("dark");
|
|
138
|
+
// A failed read says nothing about what was chosen, so it must not reset to the default.
|
|
139
|
+
page.backForwardRestore();
|
|
140
|
+
page.storageEvent(KEY);
|
|
141
|
+
expect(theme.getSnapshot().mode).toBe("dark");
|
|
142
|
+
expect(page.dark()).toBe(true);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("repaints when another tab picks, and tells subscribers", () => {
|
|
146
|
+
const page = browser({ stored: "light" });
|
|
147
|
+
const theme = startTheme(options);
|
|
148
|
+
const listener = vi.fn();
|
|
149
|
+
theme.subscribe(listener);
|
|
150
|
+
|
|
151
|
+
page.otherTabPicks("dark");
|
|
152
|
+
expect(page.dark()).toBe(true);
|
|
153
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("ignores another key, and resyncs when storage is cleared", () => {
|
|
157
|
+
const page = browser({ stored: "dark" });
|
|
158
|
+
const theme = startTheme(options);
|
|
159
|
+
page.store.set(KEY, "light");
|
|
160
|
+
page.storageEvent("some.other.key");
|
|
161
|
+
expect(theme.getSnapshot().mode).toBe("dark");
|
|
162
|
+
|
|
163
|
+
page.store.clear();
|
|
164
|
+
page.storageEvent(null);
|
|
165
|
+
expect(theme.getSnapshot().mode).toBe("system");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("picks up a choice made while this page sat in the back-forward cache", () => {
|
|
169
|
+
const page = browser({ stored: "light" });
|
|
170
|
+
const theme = startTheme(options);
|
|
171
|
+
page.store.set(KEY, "dark");
|
|
172
|
+
page.backForwardRestore();
|
|
173
|
+
expect(theme.getSnapshot().mode).toBe("dark");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// An empty key means the DEFAULT, and the default is not always "system". Clearing the key
|
|
177
|
+
// for "system" in an app that defaults to dark would come back dark on the next visit.
|
|
178
|
+
it("stores system as the word, so it survives a non-system default", () => {
|
|
179
|
+
const page = browser();
|
|
180
|
+
startTheme({ key: KEY, defaultMode: "dark" }).setMode("system");
|
|
181
|
+
expect(page.store.get(KEY)).toBe("system");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("still applies a choice when storage refuses to keep it", () => {
|
|
185
|
+
const page = browser({ stored: "light" });
|
|
186
|
+
const theme = startTheme(options);
|
|
187
|
+
page.block();
|
|
188
|
+
expect(() => theme.setMode("dark")).not.toThrow();
|
|
189
|
+
expect(page.dark()).toBe(true);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("ignores a mode it does not know, rather than storing it", () => {
|
|
193
|
+
const page = browser({ stored: "light" });
|
|
194
|
+
const theme = startTheme(options);
|
|
195
|
+
const fromASelect: string = "blue";
|
|
196
|
+
theme.setMode(fromASelect as "dark");
|
|
197
|
+
expect(page.store.get(KEY)).toBe("light");
|
|
198
|
+
expect(theme.getSnapshot().mode).toBe("light");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("notifies only on a change, and stops after unsubscribing", () => {
|
|
202
|
+
const page = browser({ stored: "dark" });
|
|
203
|
+
const theme = startTheme(options);
|
|
204
|
+
const listener = vi.fn();
|
|
205
|
+
const unsubscribe = theme.subscribe(listener);
|
|
206
|
+
|
|
207
|
+
theme.setMode("dark");
|
|
208
|
+
page.osSwitches(true);
|
|
209
|
+
expect(listener).not.toHaveBeenCalled();
|
|
210
|
+
|
|
211
|
+
unsubscribe();
|
|
212
|
+
theme.setMode("light");
|
|
213
|
+
expect(listener).not.toHaveBeenCalled();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("writes data-theme both ways when asked to", () => {
|
|
217
|
+
const page = browser({ stored: "dark" });
|
|
218
|
+
const theme = startTheme({ key: KEY, attribute: "data-theme" });
|
|
219
|
+
expect(page.attribute("data-theme")).toBe("dark");
|
|
220
|
+
expect(page.dark()).toBe(false);
|
|
221
|
+
theme.setMode("light");
|
|
222
|
+
expect(page.attribute("data-theme")).toBe("light");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// Both tags, whatever their `media`: a query on the tag follows the OS, and the reader may have
|
|
226
|
+
// picked the opposite of it.
|
|
227
|
+
it("moves every theme-color tag with the theme", () => {
|
|
228
|
+
const page = browser({ stored: "light", metas: 2 });
|
|
229
|
+
const theme = startTheme({ key: KEY, themeColor: { light: "#fff", dark: "#111" } });
|
|
230
|
+
expect(page.themeColors.map((meta) => meta.content)).toEqual(["#fff", "#fff"]);
|
|
231
|
+
theme.setMode("dark");
|
|
232
|
+
expect(page.themeColors.map((meta) => meta.content)).toEqual(["#111", "#111"]);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// Two controllers would both follow the OS, and the one that missed a `setMode` would repaint
|
|
236
|
+
// its stale choice on the next switch.
|
|
237
|
+
it("runs once per page — the pre-paint script's controller is the app's", () => {
|
|
238
|
+
const page = browser({ stored: "light" });
|
|
239
|
+
const prePaint = startTheme(options);
|
|
240
|
+
const { setMode, getSnapshot } = createTheme(options);
|
|
241
|
+
expect(startTheme(options)).toBe(prePaint);
|
|
242
|
+
|
|
243
|
+
setMode("dark");
|
|
244
|
+
page.osSwitches(false);
|
|
245
|
+
expect(prePaint.getSnapshot().mode).toBe("dark");
|
|
246
|
+
expect(getSnapshot().mode).toBe("dark");
|
|
247
|
+
expect(page.dark()).toBe(true);
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
describe("createTheme", () => {
|
|
252
|
+
// A module that calls `createTheme` at its top level is imported by the prerender too, where
|
|
253
|
+
// there is no window. Nothing may start until something asks.
|
|
254
|
+
it("touches no browser global at creation, and prerenders the default", () => {
|
|
255
|
+
const { useTheme, getSnapshot } = createTheme({ key: KEY, defaultMode: "dark" });
|
|
256
|
+
expect(getSnapshot()).toEqual({ mode: "dark", resolved: "dark" });
|
|
257
|
+
|
|
258
|
+
function Toggle() {
|
|
259
|
+
const { mode, resolved } = useTheme();
|
|
260
|
+
return createElement("span", null, `${mode}/${resolved}`);
|
|
261
|
+
}
|
|
262
|
+
expect(renderToString(createElement(Toggle))).toContain("dark/dark");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("prerenders system as light, the same guess color-scheme makes", () => {
|
|
266
|
+
expect(createTheme(options).getSnapshot()).toEqual({ mode: "system", resolved: "light" });
|
|
267
|
+
});
|
|
268
|
+
});
|
package/src/theme.ts
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Light, dark, or whatever the operating system says — chosen once, remembered, and painted
|
|
3
|
+
* before the first frame.
|
|
4
|
+
*
|
|
5
|
+
* Nearly every app in the fleet had written this on its own, and each copy knew something the
|
|
6
|
+
* others did not. What they got wrong is the design brief for this file:
|
|
7
|
+
*
|
|
8
|
+
* - **Storage is read unguarded.** A browser that blocks site data throws on `localStorage`
|
|
9
|
+
* itself, and the read ran during a render, so a privacy setting was a white page. Apps fixed
|
|
10
|
+
* this one at a time, and the last copy still had it.
|
|
11
|
+
* - **The pre-paint script and the runtime disagreed.** Most apps kept two copies of "read the
|
|
12
|
+
* key, resolve system, set the class": an inline script in `index.html`, because it has to run
|
|
13
|
+
* before any module loads, and the store the app renders from. They drifted on exactly the
|
|
14
|
+
* inputs nobody tests. With storage blocked one forced light while the other followed the OS,
|
|
15
|
+
* and with a stale value in the key one read it as light while the other read it as system.
|
|
16
|
+
* Either way the page painted one theme, and React swapped it for the other one frame later.
|
|
17
|
+
* One app had written a build check that the two copies at least named the same key.
|
|
18
|
+
* - **The OS preference started as a guess.** A provider that holds the OS preference in state,
|
|
19
|
+
* starting at `"light"` and correcting it in an effect, commits a light frame first. That
|
|
20
|
+
* frame REMOVES the `.dark` class the pre-paint script had just set, so a reader whose OS is
|
|
21
|
+
* dark sees a flash on every load.
|
|
22
|
+
* - **The pre-paint script was inline, and a strict CSP blocks inline scripts.** Under
|
|
23
|
+
* `script-src 'self'` it never ran in production: the app booted light and turned dark a frame
|
|
24
|
+
* later. A CSP hash works until the first whitespace change silently breaks it again.
|
|
25
|
+
* - **Another tab's choice was ignored.** A tab left open kept the old theme until it reloaded.
|
|
26
|
+
* One app did listen for `storage`, then re-read its own page, which the other tab had not
|
|
27
|
+
* changed.
|
|
28
|
+
*
|
|
29
|
+
* So there is ONE function, {@link startTheme}. The page runs it before first paint and the app
|
|
30
|
+
* uses it after React mounts. `@gusnips/vite/theme` serves it as a same-origin FILE, built from
|
|
31
|
+
* this function's own source, so the two cannot drift and a strict CSP allows it. The runtime
|
|
32
|
+
* the pre-paint script creates is also the one the app talks to: {@link createTheme} finds it
|
|
33
|
+
* rather than starting a second one.
|
|
34
|
+
*
|
|
35
|
+
* What stays in the app: the control (three buttons, a menu, a switch) and its words.
|
|
36
|
+
*/
|
|
37
|
+
import { useSyncExternalStore } from "react";
|
|
38
|
+
|
|
39
|
+
/** What a person picked. `"system"` means "follow the operating system", live. */
|
|
40
|
+
export type ThemeMode = "light" | "dark" | "system";
|
|
41
|
+
|
|
42
|
+
/** What is on screen right now. */
|
|
43
|
+
export type ResolvedTheme = "light" | "dark";
|
|
44
|
+
|
|
45
|
+
export interface ThemeOptions {
|
|
46
|
+
/**
|
|
47
|
+
* The `localStorage` key holding the choice. Keep the key your app already uses: a new key
|
|
48
|
+
* resets everybody's choice once.
|
|
49
|
+
*/
|
|
50
|
+
key: string;
|
|
51
|
+
/**
|
|
52
|
+
* What an empty, unreadable or unrecognised key means. Defaults to `"system"`.
|
|
53
|
+
*/
|
|
54
|
+
defaultMode?: ThemeMode;
|
|
55
|
+
/**
|
|
56
|
+
* Where the resolved theme goes on `<html>`. `"class"` toggles `dark`, which is what
|
|
57
|
+
* Tailwind's `dark:` variant and `@gusnips/tokens` read. `"data-theme"` writes `"light"` or
|
|
58
|
+
* `"dark"` into that attribute, for a stylesheet whose `@custom-variant` reads it. Defaults
|
|
59
|
+
* to `"class"`.
|
|
60
|
+
*/
|
|
61
|
+
attribute?: "class" | "data-theme";
|
|
62
|
+
/**
|
|
63
|
+
* The browser toolbar colour for each theme, written to every `<meta name="theme-color">`.
|
|
64
|
+
* Leave it out and the tags are not touched. It is needed whenever the choice can differ from
|
|
65
|
+
* the OS setting: a `media` query on the tag follows the OS, not your app.
|
|
66
|
+
*/
|
|
67
|
+
themeColor?: Readonly<Record<ResolvedTheme, string>>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface ThemeSnapshot {
|
|
71
|
+
readonly mode: ThemeMode;
|
|
72
|
+
readonly resolved: ResolvedTheme;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface ThemeController {
|
|
76
|
+
getSnapshot(): ThemeSnapshot;
|
|
77
|
+
/** Applies at once, and is remembered when storage allows it. */
|
|
78
|
+
setMode(mode: ThemeMode): void;
|
|
79
|
+
subscribe(listener: () => void): () => void;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
declare global {
|
|
83
|
+
interface Window {
|
|
84
|
+
/** The page's one theme controller. See {@link startTheme}. */
|
|
85
|
+
__frontkitTheme?: ThemeController;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Reads the choice, paints it, and keeps it painted: when the OS switches (a laptop that goes
|
|
91
|
+
* dark at sunset), when another tab picks a theme, and when the page comes back from the
|
|
92
|
+
* back-forward cache.
|
|
93
|
+
*
|
|
94
|
+
* **This function is serialized.** `@gusnips/vite/theme` writes `startTheme.toString()` into the
|
|
95
|
+
* pre-paint file, so it must not reference anything outside its own body: no imports, no helpers
|
|
96
|
+
* or constants from this module. It would still pass here and throw a `ReferenceError` in that
|
|
97
|
+
* file, before first paint. The vite package's test runs the serialized copy in a bare context
|
|
98
|
+
* to catch that. Comments go up here rather than in the body, because the body ships.
|
|
99
|
+
*
|
|
100
|
+
* **One per page.** The first call stores the controller on `window` and later calls return it,
|
|
101
|
+
* whatever options they pass. Two would both follow the OS, and the one that missed a `setMode`
|
|
102
|
+
* would repaint its stale choice on the next change. So the pre-paint script and the app must
|
|
103
|
+
* pass the same options, which is easiest when both import them from one module.
|
|
104
|
+
*
|
|
105
|
+
* **Blocked storage is not an empty key.** When storage throws, the default applies at boot, and
|
|
106
|
+
* a choice made afterwards lasts until the page is left. A resync never replaces that choice
|
|
107
|
+
* with the default, because a read that failed says nothing about what was chosen.
|
|
108
|
+
*
|
|
109
|
+
* "system" is stored as the word, not as an empty key. An empty key means the default, and the
|
|
110
|
+
* default is not always "system".
|
|
111
|
+
*/
|
|
112
|
+
export function startTheme(options: ThemeOptions): ThemeController {
|
|
113
|
+
const running = window.__frontkitTheme;
|
|
114
|
+
if (running) return running;
|
|
115
|
+
|
|
116
|
+
const { key, defaultMode = "system", attribute = "class", themeColor } = options;
|
|
117
|
+
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
|
118
|
+
const listeners = new Set<() => void>();
|
|
119
|
+
const isMode = (value: unknown): value is ThemeMode =>
|
|
120
|
+
value === "light" || value === "dark" || value === "system";
|
|
121
|
+
const read = (): ThemeMode | undefined => {
|
|
122
|
+
try {
|
|
123
|
+
const stored = window.localStorage.getItem(key);
|
|
124
|
+
return isMode(stored) ? stored : defaultMode;
|
|
125
|
+
} catch {
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
const resolve = (mode: ThemeMode): ResolvedTheme =>
|
|
130
|
+
mode === "system" ? (media.matches ? "dark" : "light") : mode;
|
|
131
|
+
const paint = (resolved: ResolvedTheme) => {
|
|
132
|
+
const root = document.documentElement;
|
|
133
|
+
if (attribute === "class") root.classList.toggle("dark", resolved === "dark");
|
|
134
|
+
else root.setAttribute(attribute, resolved);
|
|
135
|
+
if (themeColor)
|
|
136
|
+
document
|
|
137
|
+
.querySelectorAll('meta[name="theme-color"]')
|
|
138
|
+
.forEach((meta) => meta.setAttribute("content", themeColor[resolved]));
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const initial = read() ?? defaultMode;
|
|
142
|
+
let snapshot: ThemeSnapshot = { mode: initial, resolved: resolve(initial) };
|
|
143
|
+
const apply = (mode: ThemeMode) => {
|
|
144
|
+
const resolved = resolve(mode);
|
|
145
|
+
paint(resolved);
|
|
146
|
+
if (mode === snapshot.mode && resolved === snapshot.resolved) return;
|
|
147
|
+
snapshot = { mode, resolved };
|
|
148
|
+
listeners.forEach((listener) => listener());
|
|
149
|
+
};
|
|
150
|
+
const sync = () => {
|
|
151
|
+
const stored = read();
|
|
152
|
+
if (stored) apply(stored);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
paint(snapshot.resolved);
|
|
156
|
+
media.addEventListener("change", () => apply(snapshot.mode));
|
|
157
|
+
window.addEventListener("storage", (event) => {
|
|
158
|
+
if (event.key === key || event.key === null) sync();
|
|
159
|
+
});
|
|
160
|
+
window.addEventListener("pageshow", sync);
|
|
161
|
+
|
|
162
|
+
const controller: ThemeController = {
|
|
163
|
+
getSnapshot: () => snapshot,
|
|
164
|
+
setMode(mode) {
|
|
165
|
+
if (!isMode(mode)) return;
|
|
166
|
+
try {
|
|
167
|
+
window.localStorage.setItem(key, mode);
|
|
168
|
+
} catch {
|
|
169
|
+
// Blocked: the choice lasts until the page is left.
|
|
170
|
+
}
|
|
171
|
+
apply(mode);
|
|
172
|
+
},
|
|
173
|
+
subscribe(listener) {
|
|
174
|
+
listeners.add(listener);
|
|
175
|
+
return () => {
|
|
176
|
+
listeners.delete(listener);
|
|
177
|
+
};
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
window.__frontkitTheme = controller;
|
|
181
|
+
return controller;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export interface Theme {
|
|
185
|
+
/** The choice and what it resolves to, re-rendering when either changes. */
|
|
186
|
+
useTheme(): ThemeSnapshot & { setMode(mode: ThemeMode): void };
|
|
187
|
+
/** For code outside React: a keyboard shortcut, a command palette. Browser only. */
|
|
188
|
+
setMode(mode: ThemeMode): void;
|
|
189
|
+
/** For code outside React. Answers the prerender snapshot where there is no window. */
|
|
190
|
+
getSnapshot(): ThemeSnapshot;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The React side of {@link startTheme}. Nothing starts at import, so a module calling this at
|
|
195
|
+
* its top level is safe to prerender. The controller starts on first use, or is found if the
|
|
196
|
+
* pre-paint script already started it.
|
|
197
|
+
*
|
|
198
|
+
* During a prerender, and while hydrating, `useTheme` answers the default. `"system"` cannot be
|
|
199
|
+
* resolved without a window, so it reads as light, as `color-scheme` does before the pre-paint
|
|
200
|
+
* script runs. React then re-renders with the real snapshot, so a toggle hydrates cleanly and
|
|
201
|
+
* shows the right choice a frame later instead of failing to hydrate.
|
|
202
|
+
*/
|
|
203
|
+
export function createTheme(options: ThemeOptions): Theme {
|
|
204
|
+
const defaultMode = options.defaultMode ?? "system";
|
|
205
|
+
const serverSnapshot: ThemeSnapshot = {
|
|
206
|
+
mode: defaultMode,
|
|
207
|
+
resolved: defaultMode === "dark" ? "dark" : "light",
|
|
208
|
+
};
|
|
209
|
+
const subscribe = (listener: () => void) => startTheme(options).subscribe(listener);
|
|
210
|
+
const getSnapshot = () =>
|
|
211
|
+
typeof window === "undefined" ? serverSnapshot : startTheme(options).getSnapshot();
|
|
212
|
+
const getServerSnapshot = () => serverSnapshot;
|
|
213
|
+
const setMode = (mode: ThemeMode) => startTheme(options).setMode(mode);
|
|
214
|
+
|
|
215
|
+
function useTheme() {
|
|
216
|
+
return { ...useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot), setMode };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return { useTheme, setMode, getSnapshot };
|
|
220
|
+
}
|