|
| 1 | +import * as React from 'react' |
| 2 | +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' |
1 | 3 | import { |
| 4 | + ArrowsClockwiseIcon, |
2 | 5 | EyeClosedIcon, |
3 | 6 | KeyIcon, |
4 | 7 | LightningIcon, |
| 8 | + PlusIcon, |
5 | 9 | SkullIcon, |
6 | 10 | } from '@phosphor-icons/react' |
7 | 11 |
|
| 12 | +import { usePrefersReducedMotion } from '~/utils/usePrefersReducedMotion' |
8 | 13 | import { LibraryLanding, type LibraryLandingConfig } from './LibraryLanding' |
9 | 14 |
|
| 15 | +type QueryHeroIssue = { |
| 16 | + id: string |
| 17 | + observers: number |
| 18 | + priority: number |
| 19 | + title: string |
| 20 | +} |
| 21 | + |
| 22 | +type QueryHeroSnapshot = { |
| 23 | + fetchedAt: number |
| 24 | + revision: number |
| 25 | + rows: Array<QueryHeroIssue> |
| 26 | +} |
| 27 | + |
| 28 | +type QueryHeroMutationContext = { |
| 29 | + previous?: QueryHeroSnapshot |
| 30 | +} |
| 31 | + |
| 32 | +const queryHeroKey = ['landing-query-hero'] as const |
| 33 | + |
| 34 | +const queryHeroInitialRows: Array<QueryHeroIssue> = [ |
| 35 | + { id: 'router-cache', observers: 3, priority: 98, title: 'Router dashboard' }, |
| 36 | + { id: 'project-detail', observers: 2, priority: 91, title: 'Project detail' }, |
| 37 | + { |
| 38 | + id: 'offline-queue', |
| 39 | + observers: 1, |
| 40 | + priority: 84, |
| 41 | + title: 'Offline mutation queue', |
| 42 | + }, |
| 43 | +] |
| 44 | + |
| 45 | +const queryHeroInitialSnapshot: QueryHeroSnapshot = { |
| 46 | + // `0` keeps `Date.now()` out of the first render so SSR and hydration agree. |
| 47 | + fetchedAt: 0, |
| 48 | + revision: 0, |
| 49 | + rows: queryHeroInitialRows, |
| 50 | +} |
| 51 | + |
| 52 | +const queryHeroMutationTitles = [ |
| 53 | + 'Optimistic table edit', |
| 54 | + 'Search filter sync', |
| 55 | + 'Background retry lane', |
| 56 | + 'Prefetched route data', |
| 57 | +] |
| 58 | + |
| 59 | +function waitForQueryHero(ms: number) { |
| 60 | + return new Promise<void>((resolve) => { |
| 61 | + setTimeout(resolve, ms) |
| 62 | + }) |
| 63 | +} |
| 64 | + |
10 | 65 | const queryLanding = { |
11 | 66 | libraryId: 'query', |
12 | 67 | headline: 'The server-state standard for modern frontend apps.', |
@@ -115,5 +170,252 @@ const queryLanding = { |
115 | 170 | } satisfies LibraryLandingConfig |
116 | 171 |
|
117 | 172 | export default function QueryLanding() { |
118 | | - return <LibraryLanding config={queryLanding} /> |
| 173 | + return ( |
| 174 | + <LibraryLanding |
| 175 | + config={{ ...queryLanding, heroRender: <QueryCachePanel /> }} |
| 176 | + /> |
| 177 | + ) |
| 178 | +} |
| 179 | + |
| 180 | +/** |
| 181 | + * The hero panel runs a real QueryClient rather than mocking one: the cache |
| 182 | + * badge, revision counter, and row list are all derived from query state, and |
| 183 | + * "Add issue" is an optimistic mutation that rolls back on error. |
| 184 | + */ |
| 185 | +function QueryCachePanel() { |
| 186 | + const prefersReducedMotion = usePrefersReducedMotion() |
| 187 | + const queryClient = useQueryClient() |
| 188 | + const serverRowsRef = React.useRef(queryHeroInitialRows) |
| 189 | + const serverRevisionRef = React.useRef(0) |
| 190 | + const mutationSequenceRef = React.useRef(0) |
| 191 | + // Starts paused so the server render matches the first client render; the |
| 192 | + // effect below turns it on unless the visitor asked for reduced motion. |
| 193 | + const [isLive, setIsLive] = React.useState(false) |
| 194 | + |
| 195 | + const projectsQuery = useQuery({ |
| 196 | + queryKey: queryHeroKey, |
| 197 | + queryFn: async (): Promise<QueryHeroSnapshot> => { |
| 198 | + await waitForQueryHero(620) |
| 199 | + |
| 200 | + return { |
| 201 | + fetchedAt: Date.now(), |
| 202 | + revision: serverRevisionRef.current, |
| 203 | + rows: serverRowsRef.current, |
| 204 | + } |
| 205 | + }, |
| 206 | + initialData: queryHeroInitialSnapshot, |
| 207 | + initialDataUpdatedAt: 0, |
| 208 | + refetchInterval: isLive ? 4200 : false, |
| 209 | + staleTime: 3200, |
| 210 | + }) |
| 211 | + |
| 212 | + const addIssueMutation = useMutation< |
| 213 | + QueryHeroIssue, |
| 214 | + Error, |
| 215 | + QueryHeroIssue, |
| 216 | + QueryHeroMutationContext |
| 217 | + >({ |
| 218 | + mutationFn: async (issue) => { |
| 219 | + await waitForQueryHero(720) |
| 220 | + serverRevisionRef.current += 1 |
| 221 | + serverRowsRef.current = [ |
| 222 | + issue, |
| 223 | + ...serverRowsRef.current.filter((row) => row.id !== issue.id), |
| 224 | + ].slice(0, 5) |
| 225 | + |
| 226 | + return issue |
| 227 | + }, |
| 228 | + onMutate: async (issue) => { |
| 229 | + await queryClient.cancelQueries({ queryKey: queryHeroKey }) |
| 230 | + const previous = queryClient.getQueryData<QueryHeroSnapshot>(queryHeroKey) |
| 231 | + |
| 232 | + queryClient.setQueryData<QueryHeroSnapshot>(queryHeroKey, (current) => ({ |
| 233 | + fetchedAt: current?.fetchedAt ?? 0, |
| 234 | + revision: current?.revision ?? serverRevisionRef.current, |
| 235 | + rows: [ |
| 236 | + issue, |
| 237 | + ...(current?.rows ?? queryHeroInitialRows).filter( |
| 238 | + (row) => row.id !== issue.id, |
| 239 | + ), |
| 240 | + ].slice(0, 5), |
| 241 | + })) |
| 242 | + |
| 243 | + return { previous } |
| 244 | + }, |
| 245 | + onError: (_error, _issue, context) => { |
| 246 | + if (context?.previous) { |
| 247 | + queryClient.setQueryData<QueryHeroSnapshot>( |
| 248 | + queryHeroKey, |
| 249 | + context.previous, |
| 250 | + ) |
| 251 | + } |
| 252 | + }, |
| 253 | + onSettled: () => queryClient.invalidateQueries({ queryKey: queryHeroKey }), |
| 254 | + }) |
| 255 | + |
| 256 | + const cacheState = projectsQuery.isFetching |
| 257 | + ? 'fetching' |
| 258 | + : projectsQuery.isStale |
| 259 | + ? 'stale' |
| 260 | + : 'fresh' |
| 261 | + const fetchedLabel = |
| 262 | + projectsQuery.data.fetchedAt > 0 |
| 263 | + ? `${Math.max(0, Math.round((Date.now() - projectsQuery.data.fetchedAt) / 1000))}s ago` |
| 264 | + : 'primed' |
| 265 | + |
| 266 | + React.useEffect(() => { |
| 267 | + if (prefersReducedMotion === false) { |
| 268 | + setIsLive(true) |
| 269 | + } |
| 270 | + }, [prefersReducedMotion]) |
| 271 | + |
| 272 | + const addIssue = () => { |
| 273 | + const nextSequence = mutationSequenceRef.current + 1 |
| 274 | + const nextTitle = |
| 275 | + queryHeroMutationTitles[ |
| 276 | + (nextSequence - 1) % queryHeroMutationTitles.length |
| 277 | + ] |
| 278 | + |
| 279 | + mutationSequenceRef.current = nextSequence |
| 280 | + addIssueMutation.mutate({ |
| 281 | + id: `optimistic-${nextSequence}`, |
| 282 | + observers: (nextSequence % 3) + 1, |
| 283 | + priority: 72 + ((nextSequence * 7) % 24), |
| 284 | + title: nextTitle ?? 'Optimistic write', |
| 285 | + }) |
| 286 | + } |
| 287 | + |
| 288 | + return ( |
| 289 | + <div className="library-landing-graphic min-w-0 overflow-hidden rounded-xl border border-[color:rgb(var(--landing-glow)/0.45)] bg-background-surface shadow-[0_24px_70px_-28px_rgb(var(--landing-glow)/0.45)] dark:shadow-[inset_-3px_-4px_18px_-7px_var(--landing-accent),0_24px_70px_rgb(0_0_0/0.18)]"> |
| 290 | + <div className="flex items-center justify-between border-b border-border-subtle px-4 py-3"> |
| 291 | + <div aria-hidden="true" className="flex gap-1.5"> |
| 292 | + <span className="size-2.5 rounded-full bg-[#ff5f57]" /> |
| 293 | + <span className="size-2.5 rounded-full bg-[#febc2e]" /> |
| 294 | + <span className="size-2.5 rounded-full bg-[#28c840]" /> |
| 295 | + </div> |
| 296 | + <span className="font-ds-mono text-ds-mono-caps-xs uppercase text-text-primary/65"> |
| 297 | + {queryLanding.hero.label} |
| 298 | + </span> |
| 299 | + </div> |
| 300 | + |
| 301 | + <div className="grid min-h-[22rem] lg:grid-cols-[1.08fr_0.82fr]"> |
| 302 | + <div className="space-y-3 border-border-subtle p-4 lg:border-r"> |
| 303 | + <div className="mb-4 flex flex-wrap items-center gap-2 font-ds-mono text-ds-mono-caps-xs uppercase"> |
| 304 | + <span |
| 305 | + className={ |
| 306 | + cacheState === 'fresh' |
| 307 | + ? 'rounded-sm bg-emerald-500 px-2 py-1 text-emerald-950' |
| 308 | + : cacheState === 'fetching' |
| 309 | + ? 'rounded-sm bg-amber-400 px-2 py-1 text-amber-950' |
| 310 | + : 'rounded-sm bg-[var(--landing-accent)] px-2 py-1 text-[var(--landing-accent-ink)]' |
| 311 | + } |
| 312 | + > |
| 313 | + {cacheState} |
| 314 | + </span> |
| 315 | + <span className="rounded-sm bg-text-primary/5 px-2 py-1 text-text-primary/35"> |
| 316 | + rev {projectsQuery.data.revision} / {fetchedLabel} |
| 317 | + </span> |
| 318 | + </div> |
| 319 | + |
| 320 | + {projectsQuery.data.rows.map((row) => ( |
| 321 | + <div |
| 322 | + key={row.id} |
| 323 | + className="block w-full rounded-lg border border-transparent bg-background-subtle p-4 text-left" |
| 324 | + > |
| 325 | + <span className="flex items-start justify-between gap-4"> |
| 326 | + <span className="min-w-0"> |
| 327 | + <span className="block truncate font-ds-mono text-ds-mono-xs text-text-primary"> |
| 328 | + ['issues', '{row.id}'] |
| 329 | + </span> |
| 330 | + <span className="mt-1 block text-ds-body-xs text-text-primary/45"> |
| 331 | + {row.title} |
| 332 | + </span> |
| 333 | + </span> |
| 334 | + <span className="shrink-0 rounded bg-[var(--landing-accent)] px-2 py-1 font-ds-mono text-ds-mono-2xs text-[var(--landing-accent-ink)]"> |
| 335 | + P{row.priority} |
| 336 | + </span> |
| 337 | + </span> |
| 338 | + <span className="mt-4 flex items-center gap-3"> |
| 339 | + <span className="h-1 flex-1 overflow-hidden rounded-full bg-text-primary/5"> |
| 340 | + <span |
| 341 | + className="block h-full rounded-full bg-[var(--landing-accent)] transition-[width] duration-500 motion-reduce:transition-none" |
| 342 | + style={{ width: `${row.priority}%` }} |
| 343 | + /> |
| 344 | + </span> |
| 345 | + <span className="font-ds-mono text-ds-mono-caps-xs uppercase text-text-primary/35"> |
| 346 | + {row.observers} obs |
| 347 | + </span> |
| 348 | + </span> |
| 349 | + </div> |
| 350 | + ))} |
| 351 | + </div> |
| 352 | + |
| 353 | + <div className="flex flex-col p-5"> |
| 354 | + <div className="flex flex-wrap items-center justify-between gap-2"> |
| 355 | + <button |
| 356 | + type="button" |
| 357 | + aria-pressed={isLive} |
| 358 | + className="rounded-md bg-[#ff5f5f] px-3 py-2 text-ds-label-sm text-white transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white" |
| 359 | + onClick={() => setIsLive((current) => !current)} |
| 360 | + > |
| 361 | + Live {isLive ? 'on' : 'off'} |
| 362 | + </button> |
| 363 | + <div className="flex flex-wrap items-center gap-2"> |
| 364 | + <button |
| 365 | + type="button" |
| 366 | + className="inline-flex items-center gap-1.5 rounded-md border border-border-subtle px-3 py-2 text-ds-label-sm text-text-primary/70 transition-colors hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--landing-accent-bright)]" |
| 367 | + onClick={() => projectsQuery.refetch()} |
| 368 | + > |
| 369 | + <ArrowsClockwiseIcon |
| 370 | + aria-hidden="true" |
| 371 | + size={13} |
| 372 | + weight="bold" |
| 373 | + className={projectsQuery.isFetching ? 'animate-spin' : ''} |
| 374 | + /> |
| 375 | + Refetch |
| 376 | + </button> |
| 377 | + <button |
| 378 | + type="button" |
| 379 | + disabled={addIssueMutation.isPending} |
| 380 | + className="inline-flex items-center gap-1.5 rounded-md bg-[#ff5f5f] px-3 py-2 text-ds-label-sm text-white transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white disabled:cursor-wait disabled:opacity-70" |
| 381 | + onClick={addIssue} |
| 382 | + > |
| 383 | + <PlusIcon aria-hidden="true" size={13} weight="bold" /> |
| 384 | + {queryLanding.hero.actionLabel} |
| 385 | + </button> |
| 386 | + </div> |
| 387 | + </div> |
| 388 | + |
| 389 | + <div className="mt-7" aria-live="polite"> |
| 390 | + <p className="text-ds-heading-4">{queryLanding.hero.detailTitle}</p> |
| 391 | + <p className="mt-2 truncate font-ds-mono text-ds-mono-xs text-[var(--landing-accent-bright)]"> |
| 392 | + ['issues', '{projectsQuery.data.rows[0]?.id ?? 'router-cache'}'] |
| 393 | + </p> |
| 394 | + <p className="mt-4 text-ds-body-sm text-text-primary/55"> |
| 395 | + {queryLanding.hero.detailBody} |
| 396 | + </p> |
| 397 | + </div> |
| 398 | + |
| 399 | + <dl className="mt-auto space-y-2 rounded-lg bg-background-subtle p-4 text-ds-body-xs"> |
| 400 | + {[ |
| 401 | + { label: 'status', value: projectsQuery.status }, |
| 402 | + { |
| 403 | + label: 'isFetching', |
| 404 | + value: String(projectsQuery.isFetching), |
| 405 | + }, |
| 406 | + { label: 'staleTime', value: '3,200' }, |
| 407 | + { label: 'mutation', value: addIssueMutation.status }, |
| 408 | + ].map((fact) => ( |
| 409 | + <div key={fact.label} className="flex justify-between gap-3"> |
| 410 | + <dt className="text-text-primary/45">{fact.label}</dt> |
| 411 | + <dd className="text-right font-ds-mono text-ds-mono-xs text-text-primary/85"> |
| 412 | + {fact.value} |
| 413 | + </dd> |
| 414 | + </div> |
| 415 | + ))} |
| 416 | + </dl> |
| 417 | + </div> |
| 418 | + </div> |
| 419 | + </div> |
| 420 | + ) |
119 | 421 | } |
0 commit comments