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.
Files changed (40) hide show
  1. package/index.js +4 -2
  2. package/package.json +1 -1
  3. package/templates/template-router/.claude/hooks/stop-check.sh +3 -12
  4. package/templates/template-router/CLAUDE.md +6 -2
  5. package/templates/template-router/doctor.config.json +16 -0
  6. package/templates/template-router/package.json +4 -1
  7. package/templates/template-router/src/api/auth/guards.ts +0 -2
  8. package/templates/template-router/src/api/auth/index.ts +7 -1
  9. package/templates/template-router/src/api/auth/router-bridge.ts +2 -2
  10. package/templates/template-router/src/api/auth/session-store.test.ts +1 -1
  11. package/templates/template-router/src/api/auth/session-store.ts +20 -42
  12. package/templates/template-router/src/components/back-home-button.tsx +17 -0
  13. package/templates/template-router/src/env.ts +2 -3
  14. package/templates/template-router/src/lib/single-flight.test.ts +0 -36
  15. package/templates/template-router/src/lib/storage-store.ts +61 -0
  16. package/templates/template-router/src/providers/theme.test.tsx +39 -30
  17. package/templates/template-router/src/providers/theme.tsx +13 -27
  18. package/templates/template-router/src/routes/-error.tsx +39 -23
  19. package/templates/template-router/src/routes/-not-found.tsx +34 -16
  20. package/templates/template-router/src/routes/sign-in.tsx +32 -25
  21. package/templates/template-start/.claude/hooks/stop-check.sh +3 -12
  22. package/templates/template-start/CLAUDE.md +6 -2
  23. package/templates/template-start/doctor.config.json +16 -0
  24. package/templates/template-start/package.json +4 -1
  25. package/templates/template-start/src/api/auth/guards.ts +0 -2
  26. package/templates/template-start/src/api/auth/index.ts +7 -1
  27. package/templates/template-start/src/api/auth/router-bridge.ts +2 -2
  28. package/templates/template-start/src/api/auth/session-store.test.ts +1 -1
  29. package/templates/template-start/src/api/auth/session-store.ts +21 -54
  30. package/templates/template-start/src/components/back-home-button.tsx +17 -0
  31. package/templates/template-start/src/env.ts +2 -3
  32. package/templates/template-start/src/lib/single-flight.test.ts +0 -36
  33. package/templates/template-start/src/lib/storage-store.ts +61 -0
  34. package/templates/template-start/src/providers/theme.test.tsx +39 -30
  35. package/templates/template-start/src/providers/theme.tsx +13 -27
  36. package/templates/template-start/src/routes/-error.tsx +39 -23
  37. package/templates/template-start/src/routes/-not-found.tsx +34 -16
  38. package/templates/template-start/src/routes/sign-in.tsx +32 -25
  39. package/templates/template-router/src/components/status-page.tsx +0 -65
  40. package/templates/template-start/src/components/status-page.tsx +0 -65
@@ -1,27 +1,45 @@
1
1
  import { useRouter } from '@tanstack/react-router'
2
2
  import { ArrowLeft, SearchX } from 'lucide-react'
3
3
 
4
- import { BackHomeButton, StatusPage } from '@/components/status-page'
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 NotFoundPage = () => {
8
16
  const router = useRouter()
9
17
 
10
18
  return (
11
- <StatusPage
12
- code='404'
13
- icon={<SearchX />}
14
- title='Page not found'
15
- description='The page you’re looking for doesn’t exist or may have been moved.'
16
- actions={
17
- <>
18
- <Button variant='outline' onClick={() => router.history.back()}>
19
- <ArrowLeft data-icon='inline-start' />
20
- Go back
21
- </Button>
22
- <BackHomeButton />
23
- </>
24
- }
25
- />
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
+ <SearchX />
24
+ </EmptyMedia>
25
+ <span className='text-muted-foreground font-mono text-xs tracking-widest tabular-nums'>
26
+ 404
27
+ </span>
28
+ <EmptyTitle>Page not found</EmptyTitle>
29
+ <EmptyDescription>
30
+ The page you’re looking for doesn’t exist or may have been moved.
31
+ </EmptyDescription>
32
+ </EmptyHeader>
33
+ <EmptyContent>
34
+ <div className='grid w-full grid-cols-2 gap-2'>
35
+ <Button variant='outline' onClick={() => router.history.back()}>
36
+ <ArrowLeft data-icon='inline-start' />
37
+ Go back
38
+ </Button>
39
+ <BackHomeButton />
40
+ </div>
41
+ </EmptyContent>
42
+ </Empty>
43
+ </main>
26
44
  )
27
45
  }
@@ -24,11 +24,6 @@ const DEMO_CREDENTIALS: Credentials = import.meta.env.DEV
24
24
  ? { email: 'demo@example.com', password: 'demo1234' }
25
25
  : { email: '', password: '' }
26
26
 
27
- const FIELDS = [
28
- { name: 'email', label: 'Email', type: 'email', autoComplete: 'email' },
29
- { name: 'password', label: 'Password', type: 'password', autoComplete: 'current-password' }
30
- ] as const
31
-
32
27
  export const Route = createFileRoute('/sign-in')({
33
28
  validateSearch: searchSchema,
34
29
  beforeLoad: ({ search }) => redirectIfAuthenticated(search),
@@ -40,7 +35,11 @@ function SignInPage() {
40
35
  const { redirect: redirectTo } = Route.useSearch()
41
36
  const login = useLogin()
42
37
 
43
- const { register, handleSubmit, formState } = useForm({
38
+ const {
39
+ register,
40
+ handleSubmit,
41
+ formState: { errors, isSubmitting }
42
+ } = useForm({
44
43
  resolver: standardSchemaResolver(formSchema),
45
44
  defaultValues: DEMO_CREDENTIALS
46
45
  })
@@ -67,26 +66,34 @@ function SignInPage() {
67
66
  </div>
68
67
  )}
69
68
 
70
- {FIELDS.map(field => {
71
- const error = formState.errors[field.name]
72
- return (
73
- <Field key={field.name} data-invalid={Boolean(error)}>
74
- <FieldLabel htmlFor={field.name}>{field.label}</FieldLabel>
75
- <Input
76
- id={field.name}
77
- type={field.type}
78
- autoComplete={field.autoComplete}
79
- aria-invalid={Boolean(error)}
80
- aria-describedby={error ? `${field.name}-error` : undefined}
81
- {...register(field.name)}
82
- />
83
- <FieldError id={`${field.name}-error`} errors={[error]} />
84
- </Field>
85
- )
86
- })}
69
+ <Field data-invalid={Boolean(errors.email)}>
70
+ <FieldLabel htmlFor='email'>Email</FieldLabel>
71
+ <Input
72
+ id='email'
73
+ type='email'
74
+ autoComplete='email'
75
+ aria-invalid={Boolean(errors.email)}
76
+ aria-describedby={errors.email ? 'email-error' : undefined}
77
+ {...register('email')}
78
+ />
79
+ <FieldError id='email-error' errors={[errors.email]} />
80
+ </Field>
81
+
82
+ <Field data-invalid={Boolean(errors.password)}>
83
+ <FieldLabel htmlFor='password'>Password</FieldLabel>
84
+ <Input
85
+ id='password'
86
+ type='password'
87
+ autoComplete='current-password'
88
+ aria-invalid={Boolean(errors.password)}
89
+ aria-describedby={errors.password ? 'password-error' : undefined}
90
+ {...register('password')}
91
+ />
92
+ <FieldError id='password-error' errors={[errors.password]} />
93
+ </Field>
87
94
 
88
- <Button type='submit' disabled={formState.isSubmitting}>
89
- {formState.isSubmitting ? 'Signing in…' : 'Sign in'}
95
+ <Button type='submit' disabled={isSubmitting}>
96
+ {isSubmitting ? 'Signing in…' : 'Sign in'}
90
97
  </Button>
91
98
  </FieldGroup>
92
99
  </form>
@@ -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 scripts so the same commands work in the flat and the
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
- fail=0
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
- A **Stop hook** (`.claude/hooks/stop-check.sh`) runs `format:check`, `typecheck`, `lint` and `test` before a turn can finish; a PostToolUse hook formats/lints every `.ts(x)` write. Don't hand-format to match — the hooks do it.
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
- The web app is **SPA-only**: `src/api/auth/session-store.ts` and the route guards touch `localStorage` and `document` at module load with no environment check. The TanStack Start template renders on the server, so SSR-safe copies of those two files live in `create-cli/start-overrides/` (and `backend-overlay/web-start/` for the backend variant) — change them together.
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",
@@ -3,8 +3,6 @@ import { redirect } from '@tanstack/react-router'
3
3
 
4
4
  import { sessionStore } from '@/api/auth/session-store'
5
5
 
6
- // `beforeLoad` runs outside React, so guards read the store directly instead of useSession().
7
-
8
6
  // Under SSR the session is unknowable (it lives in localStorage), so a server-side
9
7
  // redirect would be wrong for signed-in users. Guards defer to the client: they
10
8
  // no-op on the server and run again during hydration, where the session is real.
@@ -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 = () => useMutation({ mutationFn: login })
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(session => {
8
- if ((session !== null) === signedIn) return
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).toHaveBeenCalledWith(expect.objectContaining({ accessToken: 'access' }))
45
+ expect(listener).toHaveBeenCalled()
46
46
  })
47
47
 
48
48
  it('drops the session when another tab signs out', async () => {
@@ -1,62 +1,29 @@
1
- // Start override: this module also loads on the SSR runtime, where `localStorage`
2
- // does not exist. The SPA version reads it at module load and never guards.
3
- import { useSyncExternalStore } from 'react'
4
-
5
1
  import { SessionSchema, type Session, type Tokens } from '@/api/auth/schema'
6
-
7
- const KEY = 'session'
8
-
9
- type Listener = (session: Session | null) => void
10
-
11
- const parseRaw = (raw: string | null): Session | null => {
12
- try {
13
- return raw ? SessionSchema.parse(JSON.parse(raw)) : null
14
- } catch {
15
- return null
16
- }
17
- }
18
-
19
- // The session lives in localStorage, so it exists only in the browser. During SSR
20
- // (TanStack Start) this module still gets imported by route guards: there the
21
- // session is always null and writes are impossible — auth resolves after hydration.
22
- const isBrowser = typeof window !== 'undefined'
23
-
24
- let current = isBrowser ? parseRaw(localStorage.getItem(KEY)) : null
25
- const listeners = new Set<Listener>()
26
-
27
- const notify = () => listeners.forEach(l => l(current))
28
-
29
- const commit = (next: Session | null) => {
30
- if (!isBrowser) throw new Error('Session can only change in the browser')
31
- current = next
32
- if (next) localStorage.setItem(KEY, JSON.stringify(next))
33
- else localStorage.removeItem(KEY)
34
- notify()
35
- }
36
-
37
- if (isBrowser) {
38
- window.addEventListener('storage', e => {
39
- if (e.key !== KEY) return
40
- current = parseRaw(e.newValue)
41
- notify()
42
- })
43
- }
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)
15
+ })
44
16
 
45
17
  export const sessionStore = {
46
- get: () => current,
47
- set: (next: Session) => commit(next),
48
- clear: () => commit(null),
18
+ get: store.get,
19
+ subscribe: store.subscribe,
20
+ set: (next: Session) => store.set(next),
21
+ clear: () => store.set(null),
49
22
  updateTokens: (tokens: Tokens) => {
23
+ const current = store.get()
50
24
  if (!current) throw new Error('Cannot update tokens: no active session')
51
- commit({ ...current, ...tokens })
52
- },
53
- subscribe: (listener: Listener) => {
54
- listeners.add(listener)
55
- return () => {
56
- listeners.delete(listener)
57
- }
25
+ store.set({ ...current, ...tokens })
58
26
  }
59
27
  }
60
28
 
61
- export const useSession = () =>
62
- useSyncExternalStore(sessionStore.subscribe, sessionStore.get, () => null)
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 === 'true'
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
- import { ThemeProvider, useTheme } from '@/providers/theme'
6
-
7
- const Probe = () => <span data-testid='theme'>{useTheme().theme}</span>
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
- render(
26
- <ThemeProvider>
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
- render(
37
- <ThemeProvider>
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
- render(
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('ignores cross-tab writes to unrelated keys', () => {
59
- localStorage.setItem('ui-theme', 'dark')
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
- <Probe />
72
+ <Toggle />
63
73
  </ThemeProvider>
64
74
  )
65
75
 
66
- act(() => {
67
- window.dispatchEvent(new StorageEvent('storage', { key: 'session', newValue: null }))
68
- })
76
+ fireEvent.click(screen.getByRole('button'))
69
77
 
70
- expect(theme()).toBe('dark')
78
+ expect(screen.getByRole('button')).toHaveTextContent('dark')
79
+ expect(document.documentElement.classList.contains('dark')).toBe(true)
71
80
  })