Files
MealQuest/web/src/AuthBootstrap.tsx
T
dev_agentandClaude Opus 5.5 54b59965ed
CI / ci (push) Successful in 4m58s
CI / ci (pull_request) Successful in 4m59s
Bound a Supply run toggle's session load and CSRF refresh by its deadline (Feature 17)
- loadMe takes an optional abort signal; AuthBootstrap calls it with none.
- Each toggle try loads GET /api/v1/me itself under its deadline when the
  session is not loaded, instead of joining a /me query already in flight
  that may never answer.
- The /me refresh a csrf 403 makes is bounded by the request's signal: a
  request whose signal aborts stops waiting at once and the refresh is no
  longer shared, so the next csrf 403 starts a fresh /me. Requests without
  a signal still share it, and it is aborted only once no request waits.
- Tests: a hung /me and a hung csrf refresh during a toggle exchange, both
  recovered on the next try in order; the client's refresh sharing and abort.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 11:27:18 -04:00

60 lines
2.4 KiB
TypeScript

import { useEffect, useSyncExternalStore, type ReactNode } from 'react'
import { useQuery } from '@tanstack/react-query'
import { ApiError, ME_PATH, useApi } from './api/index.ts'
import { NotInHouseholdPage } from './pages/NotInHouseholdPage.tsx'
import { ErrorBanner } from './ui/ErrorBanner.tsx'
import { Skeleton } from './ui/Skeleton.tsx'
import styles from './pages/FullScreen.module.css'
export const meQueryKey = [ME_PATH] as const
// AuthBootstrap loads GET /api/v1/me before rendering children. A 401 is
// already navigating to sign-in, so the skeleton stays up; an account outside
// the household sees only the "not in this household" screen.
//
// When /me cannot reach the server and it last answered with a member on this
// device, the saved pages open read-only: the service worker answers their
// reads from its cache, and every write is refused for want of a CSRF token.
// /me runs even while the browser reports offline, so that case fails fast
// instead of waiting, and it is fetched again when the browser is back online.
export function AuthBootstrap({ children }: { children: ReactNode }) {
const api = useApi()
const forbidden = useSyncExternalStore(api.subscribe, api.forbidden)
const me = useQuery({ queryKey: meQueryKey, queryFn: () => api.loadMe(), staleTime: Infinity, networkMode: 'always' })
// fetch rejects with a TypeError only when the network fails.
const savedPages = me.isError && me.error instanceof TypeError && api.session.verified()
const { refetch } = me
useEffect(() => {
if (!savedPages) return
return api.network.subscribe(() => {
if (!api.network.offline()) void refetch()
})
}, [savedPages, api, refetch])
if (forbidden) return <NotInHouseholdPage />
if (me.isSuccess || savedPages) return children
if (me.isError && !(me.error instanceof ApiError && me.error.status === 401)) {
return (
<main className={styles.screen}>
<ErrorBanner onRetry={() => void me.refetch()}>MealQuest could not load: {me.error.message}</ErrorBanner>
</main>
)
}
return <LoadingScreen />
}
// LoadingScreen is the full-screen skeleton shown while MealQuest loads.
export function LoadingScreen() {
return (
<main className={styles.screen} aria-busy="true" aria-label="Loading MealQuest">
<div className={styles.loading}>
<Skeleton className={styles.title} />
<Skeleton />
<Skeleton />
<Skeleton />
</div>
</main>
)
}