diff --git a/src/components/landing/LibraryLanding.tsx b/src/components/landing/LibraryLanding.tsx index 08f6c7570..0c7362022 100644 --- a/src/components/landing/LibraryLanding.tsx +++ b/src/components/landing/LibraryLanding.tsx @@ -66,6 +66,7 @@ export type LibraryLandingConfig = { items: readonly LibraryLandingWorkbenchItem[] label: string } + heroRender?: React.ReactNode libraryId: LibraryLandingId lifecycle: { body: string @@ -189,7 +190,7 @@ export function LibraryLanding({ config }: { config: LibraryLandingConfig }) { } + hero={config.heroRender ?? } libraryId={config.libraryId} prompt={config.prompt} promptLabel={config.promptLabel} diff --git a/src/components/landing/QueryLanding.tsx b/src/components/landing/QueryLanding.tsx index ebf747b52..49f7b37f4 100644 --- a/src/components/landing/QueryLanding.tsx +++ b/src/components/landing/QueryLanding.tsx @@ -1,12 +1,86 @@ +import * as React from 'react' import { + useIsFetching, + useMutation, + useQueries, + useQueryClient, +} from '@tanstack/react-query' +import { + ArrowsClockwiseIcon, EyeClosedIcon, KeyIcon, LightningIcon, + PlusIcon, SkullIcon, } from '@phosphor-icons/react' +import { useToast } from '~/components/ToastProvider' +import { usePrefersReducedMotion } from '~/utils/usePrefersReducedMotion' import { LibraryLanding, type LibraryLandingConfig } from './LibraryLanding' +type QueryHeroIssue = { + priority: number + revision: number + title: string +} + +type QueryHeroMutationContext = { + optimistic?: QueryHeroIssue + previous?: QueryHeroIssue +} + +// Each row is its own cache entry with its own `staleTime`, so the three +// gauges drain at different rates and go stale independently. +const queryHeroRows = [ + { + id: 'router-cache', + staleTime: 2000, + refetchInterval: 3000, + seed: { priority: 0, revision: 0, title: 'Router dashboard' }, + }, + { + id: 'project-detail', + staleTime: 6000, + refetchInterval: 7000, + seed: { priority: 0, revision: 0, title: 'Project detail' }, + }, + { + id: 'offline-queue', + staleTime: 14000, + refetchInterval: 15000, + seed: { priority: 0, revision: 0, title: 'Offline mutation queue' }, + }, +] satisfies ReadonlyArray<{ + id: string + refetchInterval: number + seed: QueryHeroIssue + staleTime: number +}> + +const queryHeroKey = (id: string) => ['issues', id] as const + +type QueryHeroState = 'fetching' | 'fresh' | 'stale' + +const queryHeroStateClass: Record = { + fetching: 'bg-amber-400 text-amber-950', + fresh: 'bg-emerald-500 text-emerald-950', + stale: 'bg-[var(--landing-accent)] text-[var(--landing-accent-ink)]', +} + +function queryHeroState(query: { + isFetching: boolean + isStale: boolean +}): QueryHeroState { + if (query.isFetching) return 'fetching' + return query.isStale ? 'stale' : 'fresh' +} + +function waitForQueryHero(ms: number) { + return new Promise((resolve) => { + setTimeout(resolve, ms) + }) +} + const queryLanding = { libraryId: 'query', headline: 'The server-state standard for modern frontend apps.', @@ -115,5 +189,310 @@ const queryLanding = { } satisfies LibraryLandingConfig export default function QueryLanding() { - return + return ( + }} + /> + ) +} + +function QueryCachePanel() { + const prefersReducedMotion = usePrefersReducedMotion() + const queryClient = useQueryClient() + const { notify } = useToast() + // One server-side record per key, so the three queries return distinct data. + const serverRowsRef = React.useRef>( + Object.fromEntries(queryHeroRows.map((row) => [row.id, { ...row.seed }])), + ) + const bumpAttemptRef = React.useRef(0) + // Starts paused so the server render matches the first client render; the + // effect below turns it on unless the visitor asked for reduced motion. + const [isLive, setIsLive] = React.useState(false) + // `0` until the first tick; re-renders drive the draining freshness gauges. + const [now, setNow] = React.useState(0) + const [selectedId, setSelectedId] = React.useState( + queryHeroRows[0].id, + ) + + const rowQueries = useQueries({ + queries: queryHeroRows.map((row) => ({ + queryKey: queryHeroKey(row.id), + queryFn: async (): Promise => { + await waitForQueryHero(620) + return serverRowsRef.current[row.id]! + }, + initialData: row.seed, + initialDataUpdatedAt: 0, + refetchInterval: isLive ? row.refetchInterval : false, + staleTime: row.staleTime, + })), + }) + + // Query tracks in-flight fetches across the whole client, so the header does + // not have to tally the rows itself. + const fetchingCount = useIsFetching({ queryKey: ['issues'] }) + + const bumpMutation = useMutation< + QueryHeroIssue, + Error, + string, + QueryHeroMutationContext + >({ + mutationFn: async (id) => { + await waitForQueryHero(720) + // Every third write fails on purpose, so the optimistic update visibly + // rolls back instead of the rollback path being unreachable code. + bumpAttemptRef.current += 1 + if (bumpAttemptRef.current % 3 === 0) { + throw new Error('Write rejected by the server') + } + + const current = serverRowsRef.current[id]! + const next = { + ...current, + // Wraps instead of clamping so repeated bumps always visibly move. + priority: current.priority >= 99 ? 0 : current.priority + 1, + revision: current.revision + 1, + } + serverRowsRef.current[id] = next + + return next + }, + onMutate: async (id) => { + await queryClient.cancelQueries({ queryKey: queryHeroKey(id) }) + const previous = queryClient.getQueryData( + queryHeroKey(id), + ) + + const optimistic = previous + ? { + ...previous, + // Wraps instead of clamping so repeated bumps always visibly move. + priority: previous.priority >= 99 ? 0 : previous.priority + 1, + revision: previous.revision + 1, + } + : undefined + + if (optimistic) { + queryClient.setQueryData(queryHeroKey(id), optimistic) + } + + return { optimistic, previous } + }, + onError: (_error, id, context) => { + if (context?.previous) { + queryClient.setQueryData( + queryHeroKey(id), + context.previous, + ) + } + // A fixed id keeps repeated failures from stacking up toasts. + notify( + context?.previous && context.optimistic + ? `Demo: every third write fails. Rolled ['issues', '${id}'] back from P${context.optimistic.priority} to P${context.previous.priority}.` + : `Demo: every third write fails. Rolled ['issues', '${id}'] back.`, + { id: 'query-landing-rollback' }, + ) + }, + // Only the mutated key is invalidated; the other rows keep their own + // freshness windows. + onSettled: (_data, _error, id) => + queryClient.invalidateQueries({ queryKey: queryHeroKey(id) }), + }) + + const rows = queryHeroRows.map((row, index) => { + const query = rowQueries[index]! + const elapsed = query.dataUpdatedAt > 0 ? now - query.dataUpdatedAt : 0 + return { + ...row, + query, + issue: query.data, + state: queryHeroState(query), + // Read from the cache rather than seeded, so it reflects the components + // actually subscribed to this key. + observers: + queryClient + .getQueryCache() + .find({ queryKey: queryHeroKey(row.id) }) + ?.getObserversCount() ?? 0, + // Drains from 100% to 0% across this row's own `staleTime`. + freshness: + query.dataUpdatedAt > 0 + ? Math.max( + 0, + Math.min(100, Math.round((1 - elapsed / row.staleTime) * 100)), + ) + : 100, + } + }) + const selected = rows.find((row) => row.id === selectedId) ?? rows[0]! + // The header summarises all three entries; each row carries its own badge. + const freshCount = rows.filter((row) => row.state === 'fresh').length + const cacheState = + fetchingCount > 0 ? 'fetching' : freshCount > 0 ? 'fresh' : 'stale' + const fetchedLabel = + selected.query.dataUpdatedAt > 0 + ? `${Math.max(0, Math.round((Math.max(now, selected.query.dataUpdatedAt) - selected.query.dataUpdatedAt) / 1000))}s ago` + : 'primed' + + React.useEffect(() => { + if (prefersReducedMotion === null) return + + setIsLive(prefersReducedMotion === false) + }, [prefersReducedMotion]) + + React.useEffect(() => { + const id = setInterval(() => setNow(Date.now()), 1000) + return () => clearInterval(id) + }, []) + + return ( +
+
+ + + {queryLanding.hero.label} + +
+ +
+
+
+ + {cacheState} + + + {freshCount}/{rows.length} fresh + +
+ + {rows.map((row) => ( + + ))} +
+ +
+
+ +
+ + +
+
+ +
+

{queryLanding.hero.detailTitle}

+

+ ['issues', '{selected.id}'] +

+

+ {queryLanding.hero.detailBody} +

+
+ +
+ {[ + { label: 'status', value: selected.query.status }, + { + label: 'isStale', + value: String(selected.query.isStale), + }, + { + label: 'observers', + value: String(selected.observers), + }, + { + label: 'staleTime', + value: selected.staleTime.toLocaleString('en-US'), + }, + { label: 'updated', value: fetchedLabel }, + { label: 'mutation', value: bumpMutation.status }, + ].map((fact) => ( +
+
{fact.label}
+
+ {fact.value} +
+
+ ))} +
+
+
+
+ ) }