feat(web): Storybook for offline UI design + light theme + brand spinner
Stand up Storybook so the management console can be designed without a running host, plus the design-system work that surfaced along the way. Storybook (@storybook/react-vite): - Slim Start/Nitro-free vite config; the preview imports the app's real src/styles.css directly so the design tokens stay single-sourced (no mirror). - Stories for the @unom/ui primitives (Button/Card/Inputs/Badge), brand marks, the AppShell (throwaway in-memory TanStack router), and every data-driven page (Dashboard/Host/Clients/Library/Settings) rendered offline via a window.fetch stub + typed fixtures. The route page components are exported so stories can render them. Light theme: - styles.css now carries a light :root (lavender, from the docs palette) with the existing violet chrome moved to .dark; the live console still pins html.dark by default, so this only adds the option (Storybook's toolbar toggles it). - Fixes a stray `*/` inside a comment that prematurely closed it and silently broke Tailwind's @theme processing. Spinner: - The punktfunk lens recreated with motion/react: two circles surge through one another in depth (JS perspective scale + z-index — robust where mix-blend-mode flattens CSS preserve-3d) with a screen-blend lens highlight. Replaces the skeleton loading state in QueryState; removes ui/skeleton.tsx. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import {
|
||||
createMemoryHistory,
|
||||
createRootRoute,
|
||||
createRoute,
|
||||
createRouter,
|
||||
RouterProvider,
|
||||
} from '@tanstack/react-router'
|
||||
import { AppShell } from '@/components/app-shell'
|
||||
|
||||
// AppShell is built from TanStack Router <Link>s, so it needs a router context.
|
||||
// We stand up a throwaway in-memory router whose routes mirror the nav targets
|
||||
// (so links resolve + the active highlight works) and render the shell from the
|
||||
// root route. No loaders/data — purely for designing the chrome offline.
|
||||
function ShellHarness({ initialPath }: { initialPath: string }) {
|
||||
const rootRoute = createRootRoute({
|
||||
component: () => (
|
||||
<AppShell>
|
||||
<div className="space-y-3">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Placeholder content — swap routes from the sidebar to preview the active state.
|
||||
</p>
|
||||
</div>
|
||||
</AppShell>
|
||||
),
|
||||
})
|
||||
|
||||
const navPaths = ['/', '/host', '/library', '/clients', '/pairing', '/settings']
|
||||
const navRoutes = navPaths.map((path) =>
|
||||
createRoute({ getParentRoute: () => rootRoute, path, component: () => null }),
|
||||
)
|
||||
// Splat so any other <Link> target still resolves without throwing.
|
||||
const splat = createRoute({ getParentRoute: () => rootRoute, path: '$', component: () => null })
|
||||
|
||||
const router = createRouter({
|
||||
routeTree: rootRoute.addChildren([...navRoutes, splat]),
|
||||
history: createMemoryHistory({ initialEntries: [initialPath] }),
|
||||
})
|
||||
|
||||
return <RouterProvider router={router} />
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Shell/AppShell',
|
||||
component: AppShell,
|
||||
parameters: { layout: 'fullscreen' },
|
||||
// AppShell requires `children`; the harness supplies the real content, so this
|
||||
// placeholder just satisfies the arg type.
|
||||
args: { children: null },
|
||||
} satisfies Meta<typeof AppShell>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Dashboard: Story = {
|
||||
render: () => <ShellHarness initialPath="/" />,
|
||||
}
|
||||
|
||||
export const HostActive: Story = {
|
||||
render: () => <ShellHarness initialPath="/host" />,
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
const VARIANTS = ['default', 'secondary', 'success', 'destructive', 'outline'] as const
|
||||
|
||||
const meta = {
|
||||
title: 'UI/Badge',
|
||||
component: Badge,
|
||||
args: { children: 'badge' },
|
||||
argTypes: {
|
||||
variant: { control: 'select', options: VARIANTS },
|
||||
},
|
||||
} satisfies Meta<typeof Badge>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Playground: Story = {}
|
||||
|
||||
export const All: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{VARIANTS.map((variant) => (
|
||||
<Badge key={variant} variant={variant}>
|
||||
{variant}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { BrandMark } from '@/components/brand-mark'
|
||||
import { Wordmark } from '@/components/wordmark'
|
||||
import { Logo } from '@/components/logo'
|
||||
|
||||
const meta = {
|
||||
title: 'Brand/Marks',
|
||||
component: BrandMark,
|
||||
} satisfies Meta<typeof BrandMark>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Mark: Story = {
|
||||
render: () => (
|
||||
<div className="flex items-end gap-6">
|
||||
<BrandMark className="size-8" />
|
||||
<BrandMark className="size-12" />
|
||||
<BrandMark className="size-20" />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const Word: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-4">
|
||||
<Wordmark className="h-4" />
|
||||
<Wordmark className="h-6 text-foreground" />
|
||||
<Wordmark className="h-8 text-primary" />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const Lockup: Story = {
|
||||
render: () => (
|
||||
<div className="pl-8 pt-6">
|
||||
<Logo className="w-48" />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { Play } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
const VARIANTS = ['default', 'secondary', 'outline', 'ghost', 'link', 'destructive'] as const
|
||||
const SIZES = ['default', 'sm', 'lg', 'icon'] as const
|
||||
|
||||
const meta = {
|
||||
title: 'UI/Button',
|
||||
component: Button,
|
||||
args: { children: 'Stream' },
|
||||
argTypes: {
|
||||
variant: { control: 'select', options: VARIANTS },
|
||||
size: { control: 'select', options: SIZES },
|
||||
disabled: { control: 'boolean' },
|
||||
},
|
||||
} satisfies Meta<typeof Button>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
/** Playground — drive variant/size/disabled from the Controls panel. */
|
||||
export const Playground: Story = {}
|
||||
|
||||
export const Variants: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{VARIANTS.map((variant) => (
|
||||
<Button key={variant} variant={variant}>
|
||||
{variant}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const Sizes: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button size="sm">Small</Button>
|
||||
<Button size="default">Default</Button>
|
||||
<Button size="lg">Large</Button>
|
||||
<Button size="icon" aria-label="Play">
|
||||
<Play className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
const meta = {
|
||||
title: 'UI/Card',
|
||||
component: Card,
|
||||
// Card requires `children`; every story supplies its own via `render`, so this
|
||||
// is just a placeholder to satisfy the arg type.
|
||||
args: { children: null },
|
||||
} satisfies Meta<typeof Card>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const HostCard: Story = {
|
||||
render: () => (
|
||||
<Card className="max-w-sm">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>ENRICOS-DESKTOP</CardTitle>
|
||||
<Badge variant="success">online</Badge>
|
||||
</div>
|
||||
<CardDescription>RTX 5070 Ti · NVENC · 5120×1440 @ 240</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-muted-foreground">
|
||||
Paired 2 days ago. Last session 11 ms p50 capture→present.
|
||||
</CardContent>
|
||||
<CardFooter className="gap-2">
|
||||
<Button size="sm">Connect</Button>
|
||||
<Button size="sm" variant="outline">
|
||||
Details
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { ClientsPage } from '@/routes/clients'
|
||||
import { MockApi } from './lib/mock-api'
|
||||
import { pairedClients } from './lib/fixtures'
|
||||
|
||||
const meta = {
|
||||
title: 'Pages/Clients',
|
||||
component: ClientsPage,
|
||||
} satisfies Meta<typeof ClientsPage>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Paired: Story = {
|
||||
render: () => (
|
||||
<MockApi routes={{ '/api/v1/clients': pairedClients }}>
|
||||
<ClientsPage />
|
||||
</MockApi>
|
||||
),
|
||||
}
|
||||
|
||||
export const Empty: Story = {
|
||||
render: () => (
|
||||
<MockApi routes={{ '/api/v1/clients': [] }}>
|
||||
<ClientsPage />
|
||||
</MockApi>
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { Dashboard } from '@/routes/index'
|
||||
import { MockApi } from './lib/mock-api'
|
||||
import { statusActive, statusIdle } from './lib/fixtures'
|
||||
|
||||
const meta = {
|
||||
title: 'Pages/Dashboard',
|
||||
component: Dashboard,
|
||||
} satisfies Meta<typeof Dashboard>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const ActiveSession: Story = {
|
||||
render: () => (
|
||||
<MockApi routes={{ '/api/v1/status': statusActive }}>
|
||||
<Dashboard />
|
||||
</MockApi>
|
||||
),
|
||||
}
|
||||
|
||||
export const Idle: Story = {
|
||||
render: () => (
|
||||
<MockApi routes={{ '/api/v1/status': statusIdle }}>
|
||||
<Dashboard />
|
||||
</MockApi>
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { HostPage } from '@/routes/host'
|
||||
import { MockApi } from './lib/mock-api'
|
||||
import { compositors, hostInfo } from './lib/fixtures'
|
||||
|
||||
const meta = {
|
||||
title: 'Pages/Host',
|
||||
component: HostPage,
|
||||
} satisfies Meta<typeof HostPage>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<MockApi routes={{ '/api/v1/host': hostInfo, '/api/v1/compositors': compositors }}>
|
||||
<HostPage />
|
||||
</MockApi>
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
const meta = {
|
||||
title: 'UI/Inputs',
|
||||
component: Input,
|
||||
} satisfies Meta<typeof Input>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Form: Story = {
|
||||
render: () => (
|
||||
<div className="max-w-sm space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="host">Host address</Label>
|
||||
<Input id="host" placeholder="192.168.1.173" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="pin">Pairing PIN</Label>
|
||||
<Input id="pin" inputMode="numeric" maxLength={4} placeholder="0000" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="disabled">Disabled</Label>
|
||||
<Input id="disabled" disabled placeholder="unavailable" />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { LibraryPage } from '@/routes/library'
|
||||
import { MockApi } from './lib/mock-api'
|
||||
import { library } from './lib/fixtures'
|
||||
|
||||
const meta = {
|
||||
title: 'Pages/Library',
|
||||
component: LibraryPage,
|
||||
} satisfies Meta<typeof LibraryPage>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Populated: Story = {
|
||||
render: () => (
|
||||
<MockApi routes={{ '/api/v1/library': library }}>
|
||||
<LibraryPage />
|
||||
</MockApi>
|
||||
),
|
||||
}
|
||||
|
||||
export const Empty: Story = {
|
||||
render: () => (
|
||||
<MockApi routes={{ '/api/v1/library': [] }}>
|
||||
<LibraryPage />
|
||||
</MockApi>
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { ApiError } from '@/api/fetcher'
|
||||
|
||||
// QueryState is the uniform loading/error wrapper every data-backed route uses —
|
||||
// the most useful thing to design WITHOUT a running host, since its three states
|
||||
// (loading spinner / error / unauthorized) never appear together live.
|
||||
const Loaded = () => (
|
||||
<div className="rounded-lg border p-4 text-sm">Loaded content renders here.</div>
|
||||
)
|
||||
|
||||
const meta = {
|
||||
title: 'Patterns/QueryState',
|
||||
component: QueryState,
|
||||
args: { children: <Loaded /> },
|
||||
} satisfies Meta<typeof QueryState>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Loading: Story = {
|
||||
args: { isLoading: true, error: null },
|
||||
}
|
||||
|
||||
export const ErrorWithRetry: Story = {
|
||||
args: { isLoading: false, error: new Error('connection refused'), refetch: () => {} },
|
||||
}
|
||||
|
||||
export const Unauthorized: Story = {
|
||||
args: { isLoading: false, error: new ApiError(401, null) },
|
||||
}
|
||||
|
||||
export const Loaded_: Story = {
|
||||
name: 'Success',
|
||||
args: { isLoading: false, error: null },
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { SettingsPage } from '@/routes/settings'
|
||||
|
||||
// Settings reads no API (just the locale + a logout button), so it renders
|
||||
// directly — no mock needed.
|
||||
const meta = {
|
||||
title: 'Pages/Settings',
|
||||
component: SettingsPage,
|
||||
} satisfies Meta<typeof SettingsPage>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
|
||||
const meta = {
|
||||
title: 'UI/Spinner',
|
||||
component: Spinner,
|
||||
} satisfies Meta<typeof Spinner>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {}
|
||||
|
||||
export const Large: Story = {
|
||||
render: () => (
|
||||
<div className="flex min-h-60 items-center justify-center">
|
||||
<Spinner className="size-40" />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const Sizes: Story = {
|
||||
render: () => (
|
||||
<div className="flex items-center gap-4">
|
||||
<Spinner className="size-4" />
|
||||
<Spinner className="size-6" />
|
||||
<Spinner className="size-10" />
|
||||
<Spinner className="size-10 text-primary" />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// Mock API payloads for the page stories — typed against the generated models so
|
||||
// they stay honest if the OpenAPI schema changes.
|
||||
import type { AvailableCompositor } from '@/api/gen/model/availableCompositor'
|
||||
import type { GameEntry } from '@/api/gen/model/gameEntry'
|
||||
import type { HostInfo } from '@/api/gen/model/hostInfo'
|
||||
import type { PairedClient } from '@/api/gen/model/pairedClient'
|
||||
import type { RuntimeStatus } from '@/api/gen/model/runtimeStatus'
|
||||
|
||||
export const hostInfo: HostInfo = {
|
||||
abi_version: 2,
|
||||
app_version: '7.1.450.0',
|
||||
codecs: ['h264', 'h265', 'av1'],
|
||||
gfe_version: '3.23.0.74',
|
||||
hostname: 'ENRICOS-DESKTOP',
|
||||
local_ip: '192.168.1.173',
|
||||
ports: {
|
||||
audio: 48000,
|
||||
control: 47999,
|
||||
http: 47989,
|
||||
https: 47984,
|
||||
mgmt: 47990,
|
||||
rtsp: 48010,
|
||||
video: 47998,
|
||||
},
|
||||
uniqueid: '0f8a1c3e9b7d4a62',
|
||||
version: '0.2.0',
|
||||
}
|
||||
|
||||
export const compositors: AvailableCompositor[] = [
|
||||
{ id: 'kwin', label: 'KWin (Plasma)', available: true, default: true },
|
||||
{ id: 'gamescope', label: 'gamescope', available: true, default: false },
|
||||
{ id: 'mutter', label: 'Mutter (GNOME)', available: false, default: false },
|
||||
{ id: 'wlroots', label: 'Sway / wlroots', available: false, default: false },
|
||||
]
|
||||
|
||||
export const statusActive: RuntimeStatus = {
|
||||
video_streaming: true,
|
||||
audio_streaming: true,
|
||||
paired_clients: 3,
|
||||
pin_pending: false,
|
||||
session: { width: 5120, height: 1440, fps: 240 },
|
||||
stream: {
|
||||
codec: 'h265',
|
||||
width: 5120,
|
||||
height: 1440,
|
||||
fps: 240,
|
||||
bitrate_kbps: 150_000,
|
||||
min_fec: 5,
|
||||
packet_size: 1392,
|
||||
},
|
||||
}
|
||||
|
||||
export const statusIdle: RuntimeStatus = {
|
||||
video_streaming: false,
|
||||
audio_streaming: false,
|
||||
paired_clients: 1,
|
||||
pin_pending: true,
|
||||
session: null,
|
||||
stream: null,
|
||||
}
|
||||
|
||||
export const pairedClients: PairedClient[] = [
|
||||
{
|
||||
fingerprint: 'a1b2c3d4e5f60718293a4b5c6d7e8f90112233445566778899aabbccddeeff00',
|
||||
subject: 'enricos-macbook',
|
||||
not_before_unix: 1_718_000_000,
|
||||
not_after_unix: 2_030_000_000,
|
||||
},
|
||||
{
|
||||
fingerprint: 'ff00eeddccbbaa998877665544332211009f8e7d6c5b4a39281706f5e4d3c2b1',
|
||||
subject: 'living-room-tv',
|
||||
not_before_unix: 1_718_500_000,
|
||||
not_after_unix: 2_030_000_000,
|
||||
},
|
||||
{
|
||||
fingerprint: '0011223344556677889900aabbccddeeff112233445566778899aabbccddeeff',
|
||||
subject: null,
|
||||
},
|
||||
]
|
||||
|
||||
const noArt = { header: null, hero: null, logo: null, portrait: null }
|
||||
export const library: GameEntry[] = [
|
||||
{ id: 'steam:1245620', store: 'steam', title: 'Elden Ring', art: noArt, launch: null },
|
||||
{ id: 'steam:1086940', store: 'steam', title: "Baldur's Gate 3", art: noArt, launch: null },
|
||||
{ id: 'steam:413150', store: 'steam', title: 'Stardew Valley', art: noArt, launch: null },
|
||||
{ id: 'custom:retroarch', store: 'custom', title: 'RetroArch', art: noArt, launch: null },
|
||||
]
|
||||
@@ -0,0 +1,49 @@
|
||||
import { type ReactNode, useEffect, useRef, useState } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
|
||||
/** Map of API pathname (e.g. `/api/v1/host`) → JSON body to return for a GET. */
|
||||
export type MockRoutes = Record<string, unknown>
|
||||
|
||||
/**
|
||||
* Renders a data-backed page WITHOUT a running host by stubbing `window.fetch`
|
||||
* for the lifetime of the story: matched pathnames return their mock JSON (200),
|
||||
* everything else returns `{}` (200) so mutations + polling never error. The
|
||||
* real orval/React-Query hooks run unchanged, so loading/success transitions and
|
||||
* `refetchInterval` behave exactly as in the app. Each story gets a fresh,
|
||||
* isolated QueryClient (retries off).
|
||||
*/
|
||||
export function MockApi({ routes, children }: { routes: MockRoutes; children: ReactNode }) {
|
||||
// Read the latest routes inside the stub without re-installing it.
|
||||
const routesRef = useRef(routes)
|
||||
routesRef.current = routes
|
||||
const [stubbed, setStubbed] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const real = window.fetch
|
||||
const stub = (input: RequestInfo | URL): Promise<Response> => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
|
||||
const path = new URL(url, window.location.origin).pathname
|
||||
const data = path in routesRef.current ? routesRef.current[path] : {}
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify(data ?? null), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
)
|
||||
}
|
||||
window.fetch = stub as typeof window.fetch
|
||||
setStubbed(true)
|
||||
return () => {
|
||||
window.fetch = real
|
||||
}
|
||||
}, [])
|
||||
|
||||
const [queryClient] = useState(
|
||||
() => new QueryClient({ defaultOptions: { queries: { retry: false } } }),
|
||||
)
|
||||
|
||||
// Hold the first render until the stub is installed, so the page's initial
|
||||
// query resolves against the mock rather than racing a real (failing) request.
|
||||
if (!stubbed) return null
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
}
|
||||
Reference in New Issue
Block a user