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
|
@@ -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
|
|
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
|
-
<
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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 {
|
|
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
|
-
{
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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={
|
|
89
|
-
{
|
|
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
|
|
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",
|
|
@@ -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 = () =>
|
|
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,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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
|
|
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:
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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
|
|
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
|
})
|