create-top-secret-starter 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +4 -2
- package/package.json +1 -1
- package/templates/template-router/.claude/hooks/stop-check.sh +3 -12
- package/templates/template-router/CLAUDE.md +6 -2
- package/templates/template-router/doctor.config.json +16 -0
- package/templates/template-router/package.json +4 -1
- package/templates/template-router/src/api/auth/guards.ts +0 -2
- package/templates/template-router/src/api/auth/index.ts +7 -1
- package/templates/template-router/src/api/auth/router-bridge.ts +2 -2
- package/templates/template-router/src/api/auth/session-store.test.ts +1 -1
- package/templates/template-router/src/api/auth/session-store.ts +20 -42
- package/templates/template-router/src/components/back-home-button.tsx +17 -0
- package/templates/template-router/src/env.ts +2 -3
- package/templates/template-router/src/lib/single-flight.test.ts +0 -36
- package/templates/template-router/src/lib/storage-store.ts +61 -0
- package/templates/template-router/src/providers/theme.test.tsx +39 -30
- package/templates/template-router/src/providers/theme.tsx +13 -27
- package/templates/template-router/src/routes/-error.tsx +39 -23
- package/templates/template-router/src/routes/-not-found.tsx +34 -16
- package/templates/template-router/src/routes/sign-in.tsx +32 -25
- package/templates/template-start/.claude/hooks/stop-check.sh +3 -12
- package/templates/template-start/CLAUDE.md +6 -2
- package/templates/template-start/doctor.config.json +16 -0
- package/templates/template-start/package.json +4 -1
- package/templates/template-start/src/api/auth/guards.ts +0 -2
- package/templates/template-start/src/api/auth/index.ts +7 -1
- package/templates/template-start/src/api/auth/router-bridge.ts +2 -2
- package/templates/template-start/src/api/auth/session-store.test.ts +1 -1
- package/templates/template-start/src/api/auth/session-store.ts +21 -54
- package/templates/template-start/src/components/back-home-button.tsx +17 -0
- package/templates/template-start/src/env.ts +2 -3
- package/templates/template-start/src/lib/single-flight.test.ts +0 -36
- package/templates/template-start/src/lib/storage-store.ts +61 -0
- package/templates/template-start/src/providers/theme.test.tsx +39 -30
- package/templates/template-start/src/providers/theme.tsx +13 -27
- package/templates/template-start/src/routes/-error.tsx +39 -23
- package/templates/template-start/src/routes/-not-found.tsx +34 -16
- package/templates/template-start/src/routes/sign-in.tsx +32 -25
- package/templates/template-router/src/components/status-page.tsx +0 -65
- package/templates/template-start/src/components/status-page.tsx +0 -65
package/index.js
CHANGED
|
@@ -230,8 +230,9 @@ const applyBackendOverlay = () => {
|
|
|
230
230
|
pkg.devDependencies = Object.fromEntries(
|
|
231
231
|
Object.entries(pkg.devDependencies).sort(([a], [b]) => a.localeCompare(b))
|
|
232
232
|
)
|
|
233
|
-
// lint and
|
|
234
|
-
for (const script of ['lint', 'lint:fix', 'format', 'format:check'])
|
|
233
|
+
// lint, format and verify run once, from the workspace root
|
|
234
|
+
for (const script of ['lint', 'lint:fix', 'format', 'format:check', 'verify'])
|
|
235
|
+
delete pkg.scripts[script]
|
|
235
236
|
delete pkg.trustedDependencies
|
|
236
237
|
})
|
|
237
238
|
|
|
@@ -287,6 +288,7 @@ if (backend) {
|
|
|
287
288
|
restoreGitignore(targetDir)
|
|
288
289
|
editJson(path.join(targetDir, 'package.json'), pkg => {
|
|
289
290
|
pkg.name = name
|
|
291
|
+
if (pm !== 'bun') pkg.scripts.verify = pkg.scripts.verify.replaceAll('bun run', `${pm} run`)
|
|
290
292
|
})
|
|
291
293
|
// textual edits: re-serialising would break the oxfmt style the project enforces
|
|
292
294
|
if (pm !== 'bun') {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
# Stop-hook gate: block finishing when the tree is red.
|
|
3
|
-
# Runs the package
|
|
3
|
+
# Runs the package script so the same command works in the flat and the
|
|
4
4
|
# workspace (apps/*) layout. Exit 2 blocks the turn and stderr becomes the
|
|
5
5
|
# reason Claude reads; any other non-zero code is a silent no-op.
|
|
6
6
|
set -uo pipefail
|
|
@@ -10,17 +10,8 @@ run="npm run"
|
|
|
10
10
|
[ -f bun.lock ] && run="bun run"
|
|
11
11
|
[ -f pnpm-lock.yaml ] && run="pnpm run"
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
out
|
|
15
|
-
for script in format:check typecheck lint test; do
|
|
16
|
-
if ! o=$($run "$script" 2>&1); then
|
|
17
|
-
fail=1
|
|
18
|
-
out+=$'\n'"=== $script ==="$'\n'"$o"$'\n'
|
|
19
|
-
fi
|
|
20
|
-
done
|
|
21
|
-
|
|
22
|
-
if [ "$fail" = "1" ]; then
|
|
23
|
-
printf 'checks failed — fix before finishing:\n%s' "$out" >&2
|
|
13
|
+
if ! out=$($run verify 2>&1); then
|
|
14
|
+
printf 'verify failed — fix before finishing:\n%s' "$out" >&2
|
|
24
15
|
exit 2
|
|
25
16
|
fi
|
|
26
17
|
exit 0
|
|
@@ -32,7 +32,9 @@ Don't write tests that merely restate the implementation — zero confidence val
|
|
|
32
32
|
|
|
33
33
|
Package manager is **bun**; scripts are in `package.json`.
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
**`bun run verify` is the single definition of green.** The Stop hook (`.claude/hooks/stop-check.sh`) runs it before a turn can finish — don't re-list the individual checks anywhere new.
|
|
36
|
+
|
|
37
|
+
A PostToolUse hook formats/lints every `.ts(x)` write. Don't hand-format to match.
|
|
36
38
|
|
|
37
39
|
## Architecture
|
|
38
40
|
|
|
@@ -44,7 +46,9 @@ React 19 SPA with React Compiler on — no manual `useMemo`/`useCallback`.
|
|
|
44
46
|
|
|
45
47
|
For how to add an API module, a form, a route or a UI primitive, use the **project-conventions** skill.
|
|
46
48
|
|
|
47
|
-
|
|
49
|
+
Persisted state goes through `createStorageStore` in `src/lib/storage-store.ts` — the only module allowed to touch `localStorage`. It owns decoding, cross-tab sync and the SSR snapshot, so `session-store.ts` and the theme store work unchanged on the TanStack Start template's server runtime.
|
|
50
|
+
|
|
51
|
+
Route guards are the remaining SPA-only code: they redirect on a session the server cannot see. SSR-safe copies live in `create-cli/start-overrides/` (and `backend-overlay/web-start/` for the backend variant) — change them together.
|
|
48
52
|
|
|
49
53
|
Dev-only code must put `import.meta.env.DEV` **first** in the condition so Vite drops the branch at build time. `mocksEnabled` alone is a runtime value and would still ship the mock backend as a lazy chunk.
|
|
50
54
|
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://react.doctor/schema/config.json",
|
|
3
|
+
"ignore": {
|
|
4
|
+
"files": ["src/components/ui/**", "src/routeTree.gen.ts"],
|
|
5
|
+
"overrides": [
|
|
6
|
+
{
|
|
7
|
+
"files": ["src/routes/**"],
|
|
8
|
+
"rules": ["react-doctor/only-export-components"]
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"files": ["src/api/auth/session-store.ts"],
|
|
12
|
+
"rules": ["react-doctor/client-localstorage-no-version"]
|
|
13
|
+
}
|
|
14
|
+
]
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -9,10 +9,12 @@
|
|
|
9
9
|
"lint": "oxlint",
|
|
10
10
|
"lint:fix": "oxlint --fix",
|
|
11
11
|
"typecheck": "tsc -b",
|
|
12
|
+
"doctor": "react-doctor . --no-score --no-supply-chain --blocking error",
|
|
12
13
|
"test": "vitest run",
|
|
13
14
|
"format": "oxfmt",
|
|
14
15
|
"format:check": "oxfmt --check",
|
|
15
|
-
"preview": "vite preview"
|
|
16
|
+
"preview": "vite preview",
|
|
17
|
+
"verify": "bun run format:check && bun run lint && bun run typecheck && bun run doctor && bun run test"
|
|
16
18
|
},
|
|
17
19
|
"dependencies": {
|
|
18
20
|
"@base-ui/react": "^1.7.0",
|
|
@@ -46,6 +48,7 @@
|
|
|
46
48
|
"happy-dom": "^20.13.2",
|
|
47
49
|
"oxfmt": "0.66.0",
|
|
48
50
|
"oxlint": "^1.81.0",
|
|
51
|
+
"react-doctor": "^0.9.13",
|
|
49
52
|
"shadcn": "^4.20.1",
|
|
50
53
|
"tailwindcss": "^4.3.3",
|
|
51
54
|
"typescript": "^7.0.2",
|
|
@@ -2,8 +2,6 @@ import { redirect } from '@tanstack/react-router'
|
|
|
2
2
|
|
|
3
3
|
import { sessionStore } from '@/api/auth/session-store'
|
|
4
4
|
|
|
5
|
-
// `beforeLoad` runs outside React, so guards read the store directly instead of useSession().
|
|
6
|
-
|
|
7
5
|
export const requireSession = (location: { href: string }) => {
|
|
8
6
|
if (!sessionStore.get()) {
|
|
9
7
|
throw redirect({ to: '/sign-in', search: { redirect: location.href } })
|
|
@@ -21,7 +21,13 @@ export const logout = async () => {
|
|
|
21
21
|
|
|
22
22
|
export const fetchMe = () => api.get('auth/me').json(UserSchema)
|
|
23
23
|
|
|
24
|
-
export const useLogin = () =>
|
|
24
|
+
export const useLogin = () => {
|
|
25
|
+
const queryClient = useQueryClient()
|
|
26
|
+
return useMutation({
|
|
27
|
+
mutationFn: login,
|
|
28
|
+
onSuccess: () => queryClient.clear()
|
|
29
|
+
})
|
|
30
|
+
}
|
|
25
31
|
|
|
26
32
|
export const useLogout = () => {
|
|
27
33
|
const queryClient = useQueryClient()
|
|
@@ -4,8 +4,8 @@ import { sessionStore } from '@/api/auth/session-store'
|
|
|
4
4
|
// re-run them. Token rotation also notifies, but must not restart loaders.
|
|
5
5
|
export const invalidateOnAuthChange = (router: { invalidate: () => Promise<void> }) => {
|
|
6
6
|
let signedIn = sessionStore.get() !== null
|
|
7
|
-
sessionStore.subscribe(
|
|
8
|
-
if ((
|
|
7
|
+
sessionStore.subscribe(() => {
|
|
8
|
+
if ((sessionStore.get() !== null) === signedIn) return
|
|
9
9
|
signedIn = !signedIn
|
|
10
10
|
void router.invalidate()
|
|
11
11
|
})
|
|
@@ -42,7 +42,7 @@ describe('cross-tab sync', () => {
|
|
|
42
42
|
)
|
|
43
43
|
|
|
44
44
|
expect(store.get()?.user.id).toBe('u_1')
|
|
45
|
-
expect(listener).
|
|
45
|
+
expect(listener).toHaveBeenCalled()
|
|
46
46
|
})
|
|
47
47
|
|
|
48
48
|
it('drops the session when another tab signs out', async () => {
|
|
@@ -1,51 +1,29 @@
|
|
|
1
|
-
import { useSyncExternalStore } from 'react'
|
|
2
|
-
|
|
3
1
|
import { SessionSchema, type Session, type Tokens } from '@/api/auth/schema'
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
let current = parseRaw(localStorage.getItem(KEY))
|
|
18
|
-
const listeners = new Set<Listener>()
|
|
19
|
-
|
|
20
|
-
const notify = () => listeners.forEach(l => l(current))
|
|
21
|
-
|
|
22
|
-
const commit = (next: Session | null) => {
|
|
23
|
-
current = next
|
|
24
|
-
if (next) localStorage.setItem(KEY, JSON.stringify(next))
|
|
25
|
-
else localStorage.removeItem(KEY)
|
|
26
|
-
notify()
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
window.addEventListener('storage', e => {
|
|
30
|
-
if (e.key !== KEY) return
|
|
31
|
-
current = parseRaw(e.newValue)
|
|
32
|
-
notify()
|
|
2
|
+
import { createStorageStore, useStorageStore } from '@/lib/storage-store'
|
|
3
|
+
|
|
4
|
+
const store = createStorageStore<Session | null>({
|
|
5
|
+
key: 'session',
|
|
6
|
+
fallback: null,
|
|
7
|
+
decode: raw => {
|
|
8
|
+
try {
|
|
9
|
+
return raw ? SessionSchema.parse(JSON.parse(raw)) : null
|
|
10
|
+
} catch {
|
|
11
|
+
return null
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
encode: session => (session ? JSON.stringify(session) : null)
|
|
33
15
|
})
|
|
34
16
|
|
|
35
17
|
export const sessionStore = {
|
|
36
|
-
get:
|
|
37
|
-
|
|
38
|
-
|
|
18
|
+
get: store.get,
|
|
19
|
+
subscribe: store.subscribe,
|
|
20
|
+
set: (next: Session) => store.set(next),
|
|
21
|
+
clear: () => store.set(null),
|
|
39
22
|
updateTokens: (tokens: Tokens) => {
|
|
23
|
+
const current = store.get()
|
|
40
24
|
if (!current) throw new Error('Cannot update tokens: no active session')
|
|
41
|
-
|
|
42
|
-
},
|
|
43
|
-
subscribe: (listener: Listener) => {
|
|
44
|
-
listeners.add(listener)
|
|
45
|
-
return () => {
|
|
46
|
-
listeners.delete(listener)
|
|
47
|
-
}
|
|
25
|
+
store.set({ ...current, ...tokens })
|
|
48
26
|
}
|
|
49
27
|
}
|
|
50
28
|
|
|
51
|
-
export const useSession = () =>
|
|
29
|
+
export const useSession = () => useStorageStore(store)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { VariantProps } from 'class-variance-authority'
|
|
2
|
+
import { Link } from '@tanstack/react-router'
|
|
3
|
+
import { House } from 'lucide-react'
|
|
4
|
+
|
|
5
|
+
import { buttonVariants } from '@/components/ui/button'
|
|
6
|
+
|
|
7
|
+
// not `<Button render={<Link/>}>`: Base UI's role="button" strips link semantics
|
|
8
|
+
export const BackHomeButton = ({
|
|
9
|
+
variant = 'default'
|
|
10
|
+
}: {
|
|
11
|
+
variant?: VariantProps<typeof buttonVariants>['variant']
|
|
12
|
+
}) => (
|
|
13
|
+
<Link to='/' className={buttonVariants({ variant })}>
|
|
14
|
+
<House data-icon='inline-start' />
|
|
15
|
+
Back home
|
|
16
|
+
</Link>
|
|
17
|
+
)
|
|
@@ -2,8 +2,7 @@ import * as z from 'zod/mini'
|
|
|
2
2
|
|
|
3
3
|
const envSchema = z.object({
|
|
4
4
|
// trailing slash required: `new URL(path, baseUrl)` drops the base's last segment without it
|
|
5
|
-
VITE_API_URL: z._default(z.string().check(z.minLength(1), z.endsWith('/')), '/api/')
|
|
6
|
-
VITE_ENABLE_MOCKS: z._default(z.enum(['true', 'false']), 'true')
|
|
5
|
+
VITE_API_URL: z._default(z.string().check(z.minLength(1), z.endsWith('/')), '/api/')
|
|
7
6
|
})
|
|
8
7
|
|
|
9
8
|
const parsed = envSchema.safeParse(import.meta.env)
|
|
@@ -16,4 +15,4 @@ if (!parsed.success) {
|
|
|
16
15
|
|
|
17
16
|
export const env = parsed.data
|
|
18
17
|
|
|
19
|
-
export const mocksEnabled = import.meta.env.DEV && env.VITE_ENABLE_MOCKS
|
|
18
|
+
export const mocksEnabled = import.meta.env.DEV && import.meta.env.VITE_ENABLE_MOCKS !== 'false'
|
|
@@ -1,40 +1,5 @@
|
|
|
1
1
|
import { singleFlight } from '@/lib/single-flight'
|
|
2
2
|
|
|
3
|
-
const deferred = <T>() => {
|
|
4
|
-
let resolve!: (value: T) => void
|
|
5
|
-
let reject!: (reason?: unknown) => void
|
|
6
|
-
const promise = new Promise<T>((res, rej) => {
|
|
7
|
-
resolve = res
|
|
8
|
-
reject = rej
|
|
9
|
-
})
|
|
10
|
-
return { promise, resolve, reject }
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
it('collapses concurrent calls into a single in-flight invocation', async () => {
|
|
14
|
-
const d = deferred<number>()
|
|
15
|
-
const fn = vi.fn(() => d.promise)
|
|
16
|
-
const wrapped = singleFlight(fn)
|
|
17
|
-
|
|
18
|
-
const a = wrapped()
|
|
19
|
-
const b = wrapped()
|
|
20
|
-
|
|
21
|
-
expect(fn).toHaveBeenCalledTimes(1)
|
|
22
|
-
expect(a).toBe(b)
|
|
23
|
-
|
|
24
|
-
d.resolve(42)
|
|
25
|
-
await expect(a).resolves.toBe(42)
|
|
26
|
-
})
|
|
27
|
-
|
|
28
|
-
it('re-invokes after the previous call settles', async () => {
|
|
29
|
-
const fn = vi.fn(() => Promise.resolve('ok'))
|
|
30
|
-
const wrapped = singleFlight(fn)
|
|
31
|
-
|
|
32
|
-
await wrapped()
|
|
33
|
-
await wrapped()
|
|
34
|
-
|
|
35
|
-
expect(fn).toHaveBeenCalledTimes(2)
|
|
36
|
-
})
|
|
37
|
-
|
|
38
3
|
it('clears the pending slot on rejection so the next call retries', async () => {
|
|
39
4
|
const fn = vi
|
|
40
5
|
.fn<() => Promise<string>>()
|
|
@@ -44,5 +9,4 @@ it('clears the pending slot on rejection so the next call retries', async () =>
|
|
|
44
9
|
|
|
45
10
|
await expect(wrapped()).rejects.toThrow('boom')
|
|
46
11
|
await expect(wrapped()).resolves.toBe('recovered')
|
|
47
|
-
expect(fn).toHaveBeenCalledTimes(2)
|
|
48
12
|
})
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { useSyncExternalStore } from 'react'
|
|
2
|
+
|
|
3
|
+
// The TanStack Start template imports these stores on the SSR runtime, where
|
|
4
|
+
// `localStorage` does not exist: there the value is the fallback and writes throw.
|
|
5
|
+
const isBrowser = typeof window !== 'undefined'
|
|
6
|
+
|
|
7
|
+
export type StorageStore<T> = {
|
|
8
|
+
get: () => T
|
|
9
|
+
set: (next: T) => void
|
|
10
|
+
subscribe: (listener: () => void) => () => void
|
|
11
|
+
getServerSnapshot: () => T
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type Options<T> = {
|
|
15
|
+
key: string
|
|
16
|
+
fallback: T
|
|
17
|
+
decode: (raw: string | null) => T
|
|
18
|
+
/** `null` removes the key */
|
|
19
|
+
encode: (value: T) => string | null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const createStorageStore = <T>({
|
|
23
|
+
key,
|
|
24
|
+
fallback,
|
|
25
|
+
decode,
|
|
26
|
+
encode
|
|
27
|
+
}: Options<T>): StorageStore<T> => {
|
|
28
|
+
let current = isBrowser ? decode(localStorage.getItem(key)) : fallback
|
|
29
|
+
const listeners = new Set<() => void>()
|
|
30
|
+
const notify = () => listeners.forEach(listener => listener())
|
|
31
|
+
|
|
32
|
+
if (isBrowser) {
|
|
33
|
+
window.addEventListener('storage', event => {
|
|
34
|
+
if (event.key !== key) return
|
|
35
|
+
current = decode(event.newValue)
|
|
36
|
+
notify()
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
get: () => current,
|
|
42
|
+
set: next => {
|
|
43
|
+
if (!isBrowser) throw new Error(`"${key}" can only change in the browser`)
|
|
44
|
+
current = next
|
|
45
|
+
const raw = encode(next)
|
|
46
|
+
if (raw === null) localStorage.removeItem(key)
|
|
47
|
+
else localStorage.setItem(key, raw)
|
|
48
|
+
notify()
|
|
49
|
+
},
|
|
50
|
+
subscribe: listener => {
|
|
51
|
+
listeners.add(listener)
|
|
52
|
+
return () => {
|
|
53
|
+
listeners.delete(listener)
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
getServerSnapshot: () => fallback
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const useStorageStore = <T>(store: StorageStore<T>) =>
|
|
61
|
+
useSyncExternalStore(store.subscribe, store.get, store.getServerSnapshot)
|
|
@@ -1,13 +1,25 @@
|
|
|
1
|
-
import { render, screen } from '@testing-library/react'
|
|
1
|
+
import { fireEvent, render, screen } from '@testing-library/react'
|
|
2
2
|
import { act } from 'react'
|
|
3
|
-
import { beforeEach, expect, it } from 'vitest'
|
|
3
|
+
import { beforeEach, expect, it, vi } from 'vitest'
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
// the store reads localStorage at load, so each case needs a fresh import
|
|
6
|
+
const load = async () => {
|
|
7
|
+
vi.resetModules()
|
|
8
|
+
return await import('@/providers/theme')
|
|
9
|
+
}
|
|
8
10
|
|
|
9
11
|
const theme = () => screen.getByTestId('theme').textContent
|
|
10
12
|
|
|
13
|
+
const renderProbe = async () => {
|
|
14
|
+
const { ThemeProvider, useTheme } = await load()
|
|
15
|
+
const Probe = () => <span data-testid='theme'>{useTheme().theme}</span>
|
|
16
|
+
render(
|
|
17
|
+
<ThemeProvider>
|
|
18
|
+
<Probe />
|
|
19
|
+
</ThemeProvider>
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
|
|
11
23
|
const writeFromAnotherTab = (value: string | null) =>
|
|
12
24
|
act(() => {
|
|
13
25
|
if (value === null) localStorage.removeItem('ui-theme')
|
|
@@ -20,33 +32,23 @@ beforeEach(() => {
|
|
|
20
32
|
document.documentElement.classList.remove('dark')
|
|
21
33
|
})
|
|
22
34
|
|
|
23
|
-
it('adopts the stored theme on mount', () => {
|
|
35
|
+
it('adopts the stored theme on mount', async () => {
|
|
24
36
|
localStorage.setItem('ui-theme', 'dark')
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
<Probe />
|
|
28
|
-
</ThemeProvider>
|
|
29
|
-
)
|
|
37
|
+
await renderProbe()
|
|
38
|
+
|
|
30
39
|
expect(theme()).toBe('dark')
|
|
31
40
|
expect(document.documentElement.classList.contains('dark')).toBe(true)
|
|
32
41
|
})
|
|
33
42
|
|
|
34
|
-
it('falls back to system for a value it does not recognise', () => {
|
|
43
|
+
it('falls back to system for a value it does not recognise', async () => {
|
|
35
44
|
localStorage.setItem('ui-theme', 'neon')
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
<Probe />
|
|
39
|
-
</ThemeProvider>
|
|
40
|
-
)
|
|
45
|
+
await renderProbe()
|
|
46
|
+
|
|
41
47
|
expect(theme()).toBe('system')
|
|
42
48
|
})
|
|
43
49
|
|
|
44
|
-
it('follows a theme change made in another tab', () => {
|
|
45
|
-
|
|
46
|
-
<ThemeProvider>
|
|
47
|
-
<Probe />
|
|
48
|
-
</ThemeProvider>
|
|
49
|
-
)
|
|
50
|
+
it('follows a theme change made in another tab', async () => {
|
|
51
|
+
await renderProbe()
|
|
50
52
|
expect(theme()).toBe('system')
|
|
51
53
|
|
|
52
54
|
writeFromAnotherTab('dark')
|
|
@@ -55,17 +57,24 @@ it('follows a theme change made in another tab', () => {
|
|
|
55
57
|
expect(document.documentElement.classList.contains('dark')).toBe(true)
|
|
56
58
|
})
|
|
57
59
|
|
|
58
|
-
it('
|
|
59
|
-
|
|
60
|
+
it('applies a theme picked in this tab', async () => {
|
|
61
|
+
const { ThemeProvider, useTheme } = await load()
|
|
62
|
+
const Toggle = () => {
|
|
63
|
+
const { theme, setTheme } = useTheme()
|
|
64
|
+
return (
|
|
65
|
+
<button type='button' onClick={() => setTheme('dark')}>
|
|
66
|
+
{theme}
|
|
67
|
+
</button>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
60
70
|
render(
|
|
61
71
|
<ThemeProvider>
|
|
62
|
-
<
|
|
72
|
+
<Toggle />
|
|
63
73
|
</ThemeProvider>
|
|
64
74
|
)
|
|
65
75
|
|
|
66
|
-
|
|
67
|
-
window.dispatchEvent(new StorageEvent('storage', { key: 'session', newValue: null }))
|
|
68
|
-
})
|
|
76
|
+
fireEvent.click(screen.getByRole('button'))
|
|
69
77
|
|
|
70
|
-
expect(
|
|
78
|
+
expect(screen.getByRole('button')).toHaveTextContent('dark')
|
|
79
|
+
expect(document.documentElement.classList.contains('dark')).toBe(true)
|
|
71
80
|
})
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { PropsWithChildren } from 'react'
|
|
2
|
-
import { createContext, use, useEffect
|
|
2
|
+
import { createContext, use, useEffect } from 'react'
|
|
3
|
+
|
|
4
|
+
import { createStorageStore, useStorageStore } from '@/lib/storage-store'
|
|
3
5
|
|
|
4
6
|
export type Theme = 'dark' | 'light' | 'system'
|
|
5
7
|
|
|
@@ -16,30 +18,12 @@ const isTheme = (value: unknown): value is Theme =>
|
|
|
16
18
|
|
|
17
19
|
const ThemeProviderContext = createContext<ThemeProviderState | null>(null)
|
|
18
20
|
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
// `storage` fires only in other tabs; same-tab writes go through `listeners`.
|
|
27
|
-
const subscribe = (listener: () => void) => {
|
|
28
|
-
listeners.add(listener)
|
|
29
|
-
const onStorage = (e: StorageEvent) => {
|
|
30
|
-
if (e.key === THEME_STORAGE_KEY) listener()
|
|
31
|
-
}
|
|
32
|
-
window.addEventListener('storage', onStorage)
|
|
33
|
-
return () => {
|
|
34
|
-
listeners.delete(listener)
|
|
35
|
-
window.removeEventListener('storage', onStorage)
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const writeTheme = (next: Theme) => {
|
|
40
|
-
localStorage.setItem(THEME_STORAGE_KEY, next)
|
|
41
|
-
listeners.forEach(l => l())
|
|
42
|
-
}
|
|
21
|
+
const themeStore = createStorageStore<Theme>({
|
|
22
|
+
key: THEME_STORAGE_KEY,
|
|
23
|
+
fallback: DEFAULT_THEME,
|
|
24
|
+
decode: raw => (isTheme(raw) ? raw : DEFAULT_THEME),
|
|
25
|
+
encode: theme => theme
|
|
26
|
+
})
|
|
43
27
|
|
|
44
28
|
const resolveTheme = (theme: Theme): 'dark' | 'light' => {
|
|
45
29
|
if (theme !== 'system') return theme
|
|
@@ -60,7 +44,7 @@ const applyTheme = (theme: Theme) => {
|
|
|
60
44
|
}
|
|
61
45
|
|
|
62
46
|
export const ThemeProvider = ({ children }: PropsWithChildren) => {
|
|
63
|
-
const theme =
|
|
47
|
+
const theme = useStorageStore(themeStore)
|
|
64
48
|
|
|
65
49
|
useEffect(() => {
|
|
66
50
|
applyTheme(theme)
|
|
@@ -73,7 +57,9 @@ export const ThemeProvider = ({ children }: PropsWithChildren) => {
|
|
|
73
57
|
}, [theme])
|
|
74
58
|
|
|
75
59
|
return (
|
|
76
|
-
<ThemeProviderContext value={{ theme, setTheme:
|
|
60
|
+
<ThemeProviderContext value={{ theme, setTheme: themeStore.set }}>
|
|
61
|
+
{children}
|
|
62
|
+
</ThemeProviderContext>
|
|
77
63
|
)
|
|
78
64
|
}
|
|
79
65
|
|
|
@@ -1,34 +1,50 @@
|
|
|
1
1
|
import type { ErrorComponentProps } from '@tanstack/react-router'
|
|
2
2
|
import { RotateCcw, TriangleAlert } from 'lucide-react'
|
|
3
3
|
|
|
4
|
-
import { BackHomeButton
|
|
4
|
+
import { BackHomeButton } from '@/components/back-home-button'
|
|
5
5
|
import { Button } from '@/components/ui/button'
|
|
6
|
+
import {
|
|
7
|
+
Empty,
|
|
8
|
+
EmptyContent,
|
|
9
|
+
EmptyDescription,
|
|
10
|
+
EmptyHeader,
|
|
11
|
+
EmptyMedia,
|
|
12
|
+
EmptyTitle
|
|
13
|
+
} from '@/components/ui/empty'
|
|
6
14
|
|
|
7
15
|
export const ErrorPage = ({ error, reset }: ErrorComponentProps) => {
|
|
8
16
|
const message = error instanceof Error ? error.message : String(error)
|
|
9
17
|
|
|
10
18
|
return (
|
|
11
|
-
<
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
<
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
</
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
19
|
+
<main className='flex min-h-svh items-center justify-center p-6'>
|
|
20
|
+
<Empty className='max-w-md border-none'>
|
|
21
|
+
<EmptyHeader>
|
|
22
|
+
<EmptyMedia variant='icon'>
|
|
23
|
+
<TriangleAlert className='text-destructive' />
|
|
24
|
+
</EmptyMedia>
|
|
25
|
+
<span className='text-muted-foreground font-mono text-xs tracking-widest tabular-nums'>
|
|
26
|
+
500
|
|
27
|
+
</span>
|
|
28
|
+
<EmptyTitle>Something went wrong</EmptyTitle>
|
|
29
|
+
<EmptyDescription>
|
|
30
|
+
An unexpected error occurred. You can try again, or head back home.
|
|
31
|
+
</EmptyDescription>
|
|
32
|
+
</EmptyHeader>
|
|
33
|
+
<EmptyContent>
|
|
34
|
+
<div className='grid w-full grid-cols-2 gap-2'>
|
|
35
|
+
<BackHomeButton variant='outline' />
|
|
36
|
+
<Button onClick={reset}>
|
|
37
|
+
<RotateCcw data-icon='inline-start' />
|
|
38
|
+
Try again
|
|
39
|
+
</Button>
|
|
40
|
+
</div>
|
|
41
|
+
{import.meta.env.DEV && message && (
|
|
42
|
+
<pre className='text-muted-foreground bg-muted mt-2 max-h-40 w-full overflow-auto rounded-lg border p-3 text-left font-mono text-xs whitespace-pre-wrap'>
|
|
43
|
+
{message}
|
|
44
|
+
</pre>
|
|
45
|
+
)}
|
|
46
|
+
</EmptyContent>
|
|
47
|
+
</Empty>
|
|
48
|
+
</main>
|
|
33
49
|
)
|
|
34
50
|
}
|