diff --git a/apps/frontend/package-lock.json b/apps/frontend/package-lock.json index d8a5e82b..51992755 100644 --- a/apps/frontend/package-lock.json +++ b/apps/frontend/package-lock.json @@ -13,6 +13,7 @@ "@emotion/cache": "^11.14.0", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", + "@tanstack/react-query": "^5.102.1", "next": "15.5.4", "qrcode": "^1.5.4", "react": "19.1.0", @@ -2882,6 +2883,32 @@ "tailwindcss": "4.1.13" } }, + "node_modules/@tanstack/query-core": { + "version": "5.102.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.102.1.tgz", + "integrity": "sha512-bJTsrO2ODWSgg+x1leTsK036GBtmNNjnrF1Vk06J5fg/phYZLQIdXrBe0uf5xTItXYvIW1MtbLhyPw7cPMqUUQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.102.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.102.1.tgz", + "integrity": "sha512-DKJeRvxjsGUWhUVnVVXSseTS0SKBMLSkrz8dm7i08r93IQQ2gAfUkX1e801gVZ18KSwHiG5yng4tQm5+qSGS0w==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.102.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", diff --git a/apps/frontend/package.json b/apps/frontend/package.json index f1c436ad..eaa0c57a 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -16,6 +16,7 @@ "@emotion/cache": "^11.14.0", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", + "@tanstack/react-query": "^5.102.1", "next": "15.5.4", "qrcode": "^1.5.4", "react": "19.1.0", diff --git a/apps/frontend/src/app/components/Navbar.tsx b/apps/frontend/src/app/components/Navbar.tsx index 95ded272..bef5cf65 100644 --- a/apps/frontend/src/app/components/Navbar.tsx +++ b/apps/frontend/src/app/components/Navbar.tsx @@ -1,12 +1,13 @@ "use client"; import Image from "next/image"; -import React, { useCallback, useEffect, useRef, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { PT_Sans } from "next/font/google"; import { LuChevronDown, LuChevronRight } from "react-icons/lu"; import { useAuth } from "@/context/AuthContext"; -import { useApi } from "@/hooks/useApi"; +import { useQuery } from "@tanstack/react-query"; +import { projectsQuery } from "@/lib/queries"; import { usePermissions } from "@/hooks/usePermissions"; import { authorizeAny, type RbacSubject } from "@branch/rbac"; import { assetPath } from "@/lib/asset"; @@ -172,7 +173,6 @@ export const NavBar: React.FC<{ const { logout } = useAuth(); const { subject } = usePermissions(); const effectiveSubject = subjectOverride ?? subject; - const api = useApi(); const [hoveredIndex, setHoveredIndex] = useState(null); const [loggingOut, setLoggingOut] = useState(false); @@ -181,17 +181,15 @@ export const NavBar: React.FC<{ // content beside the rail, so opening it automatically would cover the very // page the user just navigated to. const [projectsOpen, setProjectsOpen] = useState(false); - const [projects, setProjects] = useState([]); - // Starts as loading: the menu only renders once expanded, and expanding - // always triggers a load — defaulting to false made "No projects yet" flash - // before the first response arrived. - const [projectsState, setProjectsState] = useState<{ loading: boolean; error: string | null }>({ - loading: true, - error: null, - }); - const hasLoadedProjects = useRef(false); const submenuRef = useRef(null); + // Still lazy — `enabled` keeps the request off every page load, exactly as the + // old first-expand latch did. What changed is that it now shares `['projects']` + // with the page behind the rail, so on /projects, /donations, /expenses and + // /reports expanding the flyout costs no request at all. + const projectsList = useQuery({ ...projectsQuery(), enabled: projectsOpen }); + const projects = projectsList.data ?? []; + // Same table AuthGate guards with, so a link cannot appear for a page that // would then refuse to render. const visibleItems = NAV_ITEMS.filter((item) => { @@ -209,27 +207,6 @@ export const NavBar: React.FC<{ return target !== "/" && currentPath.startsWith(`${target}/`); }; - // Fetched on first expand rather than on mount: the list is only ever read by - // this menu, and eagerly loading it would add a request to every page. - const loadProjects = useCallback(async () => { - if (hasLoadedProjects.current) return; - hasLoadedProjects.current = true; - setProjectsState({ loading: true, error: null }); - try { - const rows = await api.get("/projects"); - setProjects(Array.isArray(rows) ? rows : []); - setProjectsState({ loading: false, error: null }); - } catch { - // Retryable: clearing the latch lets the next expand try again. - hasLoadedProjects.current = false; - setProjectsState({ loading: false, error: "Could not load projects" }); - } - }, [api]); - - useEffect(() => { - if (projectsOpen) void loadProjects(); - }, [projectsOpen, loadProjects]); - // Dismiss on outside click and Escape, the two things a flyout must honour. useEffect(() => { if (!projectsOpen) return; @@ -385,8 +362,8 @@ export const NavBar: React.FC<{ {projectsOpen && ( setProjectsOpen(false)} /> diff --git a/apps/frontend/src/app/components/RoutePrefetcher.tsx b/apps/frontend/src/app/components/RoutePrefetcher.tsx new file mode 100644 index 00000000..c7cf9eac --- /dev/null +++ b/apps/frontend/src/app/components/RoutePrefetcher.tsx @@ -0,0 +1,54 @@ +'use client'; + +import { useEffect } from 'react'; +import { usePathname } from 'next/navigation'; +import { useQueryClient } from '@tanstack/react-query'; +import { getAccessToken } from '@/lib/authTokens'; +import { queriesForRoute } from '@/lib/queries'; + +/** + * Starts a route's data requests in parallel with `GET /auth/me`. + * + * Mounted in `providers.tsx` *outside* `AuthGate`, which is the entire trick. + * The gate renders a spinner until the session resolves, so no page component + * mounts and no page effect runs until then -- giving every cold load a hard + * two-round-trip floor (`/auth/me`, then the page's own call), each trip + * potentially paying a Lambda cold start. This component is not behind the gate, + * so its effect fires on the first flush and the two trips overlap. When the + * gate does unblock, the page's `useQuery` finds a warm cache. + * + * Why not hydrate `user` from a cached `/auth/me` payload instead? That also + * removes the wait, but a signed-out visitor with stale storage would briefly + * see admin navigation before the server corrected them. Authorization is + * enforced server-side so nothing leaks, but it is a bad enough appearance bug + * to rule the approach out. Prefetching buys the same parallelism with no + * false UI. + * + * Renders nothing. + */ +export default function RoutePrefetcher() { + const queryClient = useQueryClient(); + const pathname = usePathname() ?? '/'; + + useEffect(() => { + // Without a token these requests can only 401, so an anonymous visitor + // makes no more calls than before. Storage is the only session signal + // available this early -- asking `useAuth()` would reintroduce the wait + // this component exists to remove. + if (!getAccessToken()) return; + + // `window.location.search` rather than `useSearchParams()`: the hook forces + // the nearest boundary to suspend under `output: 'export'`, and this + // component must not suspend anything -- it renders no UI and its whole + // value is being early. + const search = new URLSearchParams(window.location.search); + + for (const spec of queriesForRoute(pathname, search)) { + // Honours the default staleTime, so a repeat visit to a warm route is a + // no-op rather than a duplicate request. + void queryClient.prefetchQuery(spec); + } + }, [pathname, queryClient]); + + return null; +} diff --git a/apps/frontend/src/app/donations/page.tsx b/apps/frontend/src/app/donations/page.tsx index 0e729d69..ed34cc55 100644 --- a/apps/frontend/src/app/donations/page.tsx +++ b/apps/frontend/src/app/donations/page.tsx @@ -1,5 +1,5 @@ 'use client'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import NavBar from '../components/Navbar'; import Header from '../components/Header'; import { HStack, Input, Dialog, Portal, CloseButton, Stack } from '@chakra-ui/react'; @@ -14,6 +14,8 @@ import RowDeleteButton from '../components/RowDeleteButton'; import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog'; import Pagination from '../components/Pagination'; import { useApi } from '@/hooks/useApi'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { donationsQuery, donorsQuery, projectsQuery } from '@/lib/queries'; import { usePermissions } from '@/hooks/usePermissions'; import { GatedButton } from '../components/Permission'; import { formatCurrencyPrecise, formatDateNumeric } from '@/lib/format'; @@ -22,6 +24,12 @@ import type { Donation, Donor, Project } from '@/types'; const ROWS_PER_PAGE = 10; const SORT_OPTIONS = ['Date', 'Amount']; +// Stable empty defaults. `?? []` hands the useMemo below a fresh array on every +// render, which recomputes the joined rows for nothing. +const NO_DONATIONS: Donation[] = []; +const NO_DONORS: Donor[] = []; +const NO_PROJECTS: Pick[] = []; + /** `GET /donors/donations` returns bare rows; names are joined in on the client. */ interface DonationRow extends Donation { donor_name: string; @@ -31,12 +39,30 @@ interface DonationRow extends Donation { export default function DonationsPage() { const api = useApi(); const { can } = usePermissions(); + const queryClient = useQueryClient(); + + // Client-side pagination and filtering below are unchanged — this page needs + // server-side aggregates it does not have yet. What changed is only that the + // three reads are cached, so returning here is free, and that `/projects` is + // the same cache entry the navbar and every other page reads. + const donationsList = useQuery(donationsQuery()); + const donorsList = useQuery(donorsQuery()); + const projectsList = useQuery(projectsQuery()); + + const donations = donationsList.data ?? NO_DONATIONS; + const donors = donorsList.data ?? NO_DONORS; + const projects = projectsList.data ?? NO_PROJECTS; - const [donations, setDonations] = useState([]); - const [donors, setDonors] = useState([]); - const [projects, setProjects] = useState[]>([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const loading = + donationsList.isPending || donorsList.isPending || projectsList.isPending; + + const loadError = + donationsList.error ?? donorsList.error ?? projectsList.error; + const error = loadError + ? loadError instanceof Error + ? loadError.message + : 'Failed to load donations' + : null; const [currentPage, setCurrentPage] = useState(1); const [search, setSearch] = useState(''); @@ -59,27 +85,12 @@ export default function DonationsPage() { const [donationToDelete, setDonationToDelete] = useState(null); - const load = useCallback(async () => { - setError(null); - try { - const [donationRes, donorRes, projectRes] = await Promise.all([ - api.get<{ data: Donation[] }>('/donors/donations'), - api.get<{ data: Donor[] }>('/donors'), - api.get('/projects'), - ]); - setDonations(donationRes.data ?? []); - setDonors(donorRes.data ?? []); - setProjects(Array.isArray(projectRes) ? projectRes : []); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load donations'); - } finally { - setLoading(false); - } - }, [api]); - - useEffect(() => { - void load(); - }, [load]); + // Recording or deleting a donation changes the list and nothing else, so only + // that key is dropped — donors and projects stay warm. + const load = useCallback( + () => queryClient.invalidateQueries({ queryKey: donationsQuery().queryKey }), + [queryClient], + ); const rows: DonationRow[] = useMemo(() => { const donorNames = new Map(donors.map((d) => [d.donor_id, d.organization])); diff --git a/apps/frontend/src/app/expenses/page.tsx b/apps/frontend/src/app/expenses/page.tsx index 965525c2..e6d2e9ec 100644 --- a/apps/frontend/src/app/expenses/page.tsx +++ b/apps/frontend/src/app/expenses/page.tsx @@ -1,5 +1,5 @@ 'use client'; -import React, { useEffect, useState, Suspense } from 'react'; +import React, { useState, Suspense } from 'react'; import { useQueryParams } from '@/hooks/useQueryParams'; import NavBar from '../components/Navbar'; import Header from '../components/Header'; @@ -14,6 +14,13 @@ import DropdownSelector from '../components/DropdownSelector'; import ExpenseFilterMenu, { type FilterGroup } from '../components/ExpenseFilterMenu'; import ReviewExpenseModal from '../components/ReviewExpenseModal'; import { useApi } from '@/hooks/useApi'; +import { useQuery, useQueryClient, keepPreviousData } from '@tanstack/react-query'; +import { + expendituresAllQuery, + expendituresPageQuery, + expensesFiltered, + projectsQuery, +} from '@/lib/queries'; import { GatedButton } from '../components/Permission'; import { LuArrowDownUp } from 'react-icons/lu'; import { FaPlus } from 'react-icons/fa'; @@ -55,12 +62,7 @@ export default function ExpensePage() { function ExpensePageContent() { const api = useApi(); - - // Data - const [expenditures, setExpenditures] = useState([]); - const [projects, setProjects] = useState[]>([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const queryClient = useQueryClient(); // Search & Filters (synced to URL query params) const [filters, setFilter] = useQueryParams({ @@ -89,34 +91,60 @@ function ExpensePageContent() { const [reviewExpenditureId, setReviewExpenditureId] = useState(null); const [expenseToDelete, setExpenseToDelete] = useState(null); + /** + * Whether this view can be served one page at a time. + * + * `GET /expenditures` accepts only `page`, `limit` and `projectId`. The search + * box, the month/type/status filters and sort-by-amount have no server-side + * equivalent, and paginating underneath them would silently reduce them to + * "filter the ten rows you happen to be looking at" — a correctness + * regression, not a speed-up. So those views still fetch the full list and + * filter in the browser, exactly as before, while the default view — the one + * every cold load pays for — asks for ten rows. + * + * Sort-by-Date needs no fallback: the server already orders by `spent_on` + * descending, which is what that option and the default both do. + */ + const searchState = new URLSearchParams({ + q: query, + sort: sortOption, + months: selectedMonths.join(','), + types: selectedTypes.join(','), + projects: selectedProjects.join(','), + statuses: selectedStatuses.join(','), + }); + const serverPaged = !expensesFiltered(searchState); + + const expendituresList = useQuery({ + ...(serverPaged + ? expendituresPageQuery(currentPage, ROWS_PER_PAGE) + : expendituresAllQuery()), + // v5's replacement for keepPreviousData: a page flip keeps the old rows on + // screen instead of collapsing the table into a skeleton. + placeholderData: keepPreviousData, + }); - // Fetch expenditures - async function fetchExpenditures() { - try { - const json = await api.get<{ data: Expenditure[] }>('/expenditures'); - setExpenditures(json.data ?? []); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load expenditures'); - } finally { - setLoading(false); - } - } + const expenditures = expendituresList.data?.data ?? []; + const loading = expendituresList.isPending; + const loadError = expendituresList.error; - // Fetch projects - async function fetchProjects() { - try { - const json = await api.get('/projects'); - setProjects(Array.isArray(json) ? json : []); - } catch { - // Projects fetch failure is non-critical - } - } + const projectsList = useQuery(projectsQuery()); + const projects: Pick[] = projectsList.data ?? []; + + // A failed projects fetch stays non-critical — it only costs the filter its + // option labels, so it must not replace the table with an error. + const [actionError, setActionError] = useState(null); + const error = + actionError ?? + (loadError + ? loadError instanceof Error + ? loadError.message + : 'Failed to load expenditures' + : null); - useEffect(() => { - fetchExpenditures(); - fetchProjects(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + const refetchExpenditures = async () => { + await queryClient.invalidateQueries({ queryKey: ['expenditures'] }); + }; const uniqueCategories = EXPENSE_CATEGORIES; @@ -161,12 +189,15 @@ function ExpensePageContent() { const { downloadUrl } = await getReceiptDownloadUrl(expenditure.expenditure_id); window.open(downloadUrl, '_blank', 'noopener,noreferrer'); } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to open receipt'); + setActionError(err instanceof Error ? err.message : 'Failed to open receipt'); } } - // Filtered + sorted data - const filteredData = expenditures + // Filtered + sorted data. Skipped entirely when the server already returned + // exactly the ten rows this page shows, in the order it wants them. + const filteredData = serverPaged + ? expenditures + : expenditures .filter((e) => { if (query) { const q = query.toLowerCase(); @@ -202,20 +233,23 @@ function ExpensePageContent() { return new Date(b.spent_on).getTime() - new Date(a.spent_on).getTime(); }); - // Pagination - const totalPages = Math.max(1, Math.ceil(filteredData.length / ROWS_PER_PAGE)); - const paginatedData = filteredData.slice( - (currentPage - 1) * ROWS_PER_PAGE, - currentPage * ROWS_PER_PAGE, - ); + // Pagination — server-side in the default view, client-side behind a filter. + const totalPages = serverPaged + ? Math.max(1, expendituresList.data?.pagination?.totalPages ?? 1) + : Math.max(1, Math.ceil(filteredData.length / ROWS_PER_PAGE)); + const paginatedData = serverPaged + ? filteredData + : filteredData.slice( + (currentPage - 1) * ROWS_PER_PAGE, + currentPage * ROWS_PER_PAGE, + ); // Modal success handler async function handleExpenseAdded() { setShowNewExpense(false); - setLoading(true); - setError(null); - await fetchExpenditures(); + setActionError(null); + await refetchExpenditures(); } return ( @@ -342,7 +376,7 @@ function ExpensePageContent() { onClose={() => setReviewExpenditureId(null)} onReviewed={async () => { setReviewExpenditureId(null); - await fetchExpenditures(); + await refetchExpenditures(); }} /> @@ -352,7 +386,7 @@ function ExpensePageContent() { onConfirm={async () => { if (!expenseToDelete) return; await api.del(`/expenditures/${expenseToDelete.expenditure_id}`); - await fetchExpenditures(); + await refetchExpenditures(); }} title="Delete Expense" itemName={ diff --git a/apps/frontend/src/app/projects/ProjectListView.tsx b/apps/frontend/src/app/projects/ProjectListView.tsx index 0175857f..0b741b28 100644 --- a/apps/frontend/src/app/projects/ProjectListView.tsx +++ b/apps/frontend/src/app/projects/ProjectListView.tsx @@ -1,6 +1,7 @@ 'use client'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import Link from 'next/link'; import { LuPlus } from 'react-icons/lu'; import NavBar from '../components/Navbar'; @@ -10,7 +11,7 @@ import Button from '../components/Button'; import LoadingState from '../components/LoadingState'; import ProjectFormModal from '../components/ProjectFormModal'; import { usePermissions } from '@/hooks/usePermissions'; -import { useApi } from '@/hooks/useApi'; +import { projectsQuery } from '@/lib/queries'; import { formatDateLong } from '@/lib/format'; import { projectPath } from '@/lib/routes'; import type { ProjectSummary } from '@/types'; @@ -21,29 +22,28 @@ import type { ProjectSummary } from '@/types'; * is computed server-side and arrives as `is_active`. */ export default function ProjectListView() { - const api = useApi(); const { can } = usePermissions(); + const queryClient = useQueryClient(); - const [projects, setProjects] = useState([]); - const [error, setError] = useState(null); - const [isLoading, setIsLoading] = useState(true); const [isFormOpen, setFormOpen] = useState(false); - const load = useCallback(async () => { - try { - setError(null); - const rows = await api.get('/projects'); - setProjects(Array.isArray(rows) ? rows : []); - } catch (err) { - setError(err instanceof Error ? err.message : 'Could not load projects'); - } finally { - setIsLoading(false); - } - }, [api]); + // Shares `['projects']` with the navbar's flyout, the donations, expenses and + // reports pages, and with RoutePrefetcher — which has usually already filled + // this in while /auth/me was still in flight. + const { + data: projects = [], + error: loadError, + isPending: isLoading, + } = useQuery(projectsQuery()); + + const error = loadError + ? loadError instanceof Error + ? loadError.message + : 'Could not load projects' + : null; - useEffect(() => { - void load(); - }, [load]); + const reload = () => + void queryClient.invalidateQueries({ queryKey: projectsQuery().queryKey }); const { active, archived } = useMemo( () => ({ @@ -136,7 +136,7 @@ export default function ProjectListView() { setFormOpen(false)} - onSaved={() => void load()} + onSaved={reload} /> ); diff --git a/apps/frontend/src/app/providers.tsx b/apps/frontend/src/app/providers.tsx index 2542cbbd..2dbc6d9e 100644 --- a/apps/frontend/src/app/providers.tsx +++ b/apps/frontend/src/app/providers.tsx @@ -1,19 +1,34 @@ 'use client'; +import { useState } from 'react'; import { ChakraProvider, defaultSystem } from '@chakra-ui/react'; +import { QueryClientProvider } from '@tanstack/react-query'; import { AuthProvider } from '@/context/AuthContext'; +import { makeQueryClient } from '@/lib/queryClient'; import AuthGate from './components/AuthGate'; +import RoutePrefetcher from './components/RoutePrefetcher'; // AuthGate lives here so every route is guarded by construction. Note that // test/utils.tsx intentionally renders ChakraProvider + AuthProvider WITHOUT // AuthGate, so page tests can exercise page content in isolation; the gate has // its own test file. +// +// QueryClientProvider sits above AuthProvider because GET /auth/me is itself a +// cached query. RoutePrefetcher sits above AuthGate because it has to run while +// that query is still in flight -- see its own file for why. export function Providers({ children }: { children: React.ReactNode }) { + // Per-mount, not module-level: a client shared across renders would leak one + // user's cache into the next in tests and in any future SSR pass. + const [queryClient] = useState(makeQueryClient); + return ( - - - {children} - - + + + + + {children} + + + ); } diff --git a/apps/frontend/src/app/reports/page.tsx b/apps/frontend/src/app/reports/page.tsx index 7be3644c..0187b5f0 100644 --- a/apps/frontend/src/app/reports/page.tsx +++ b/apps/frontend/src/app/reports/page.tsx @@ -15,6 +15,8 @@ import { } from '@chakra-ui/react'; import DataTable, { type DataTableColumn } from '../components/DataTable'; import { useApi } from '@/hooks/useApi'; +import { useQuery, useQueryClient, keepPreviousData } from '@tanstack/react-query'; +import { projectsQuery, reportsPageQuery } from '@/lib/queries'; import { type Project } from '@/lib/reports'; import UploadReportModal from '../components/UploadReportModal'; import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog'; @@ -72,13 +74,8 @@ export default function ReportsPage() { } function ReportsPageContent() { - // Data - const [reports, setReports] = useState([]); - const [projects, setProjects] = useState([]); const api = useApi(); - - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const queryClient = useQueryClient(); // Delete/download failures — non-blocking, so they must not hide the table const [actionError, setActionError] = useState(null); @@ -102,37 +99,32 @@ function ReportsPageContent() { page: '', }); const currentPage = parseInt(filters.page, 10) || 1; - const [totalPages, setTotalPages] = useState(1); + // Already server-paginated before this change; now cached, and prefetched + // for the page the URL asks for while /auth/me is still in flight. + const reportsList = useQuery({ + ...reportsPageQuery(currentPage, ROWS_PER_PAGE), + // Page flips reuse the previous page's rows instead of unmounting the + // table into a skeleton. + placeholderData: keepPreviousData, + }); - // Fetch reports - async function fetchReports() { - try { - const json = await api.get<{ data: Report[]; pagination?: { totalPages: number } }>( - `/reports?page=${currentPage}&limit=${ROWS_PER_PAGE}`, - ); - setReports(json.data ?? []); - setTotalPages(Math.max(1, json.pagination?.totalPages ?? 1)); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load reports'); - } finally { - setLoading(false); - } - } + const reports: Report[] = reportsList.data?.data ?? []; + const totalPages = Math.max(1, reportsList.data?.pagination?.totalPages ?? 1); + const loading = reportsList.isPending; + const error = reportsList.error + ? reportsList.error instanceof Error + ? reportsList.error.message + : 'Failed to load reports' + : null; - // Fetch projects (used to resolve project_id -> name if needed elsewhere) - async function fetchProjects() { - try { - const json = await api.get('/projects'); - const list = Array.isArray(json) ? json : []; - setProjects(list); - if (list.length > 0) { - setGenerateProjectId(String(list[0].project_id)); - } - } catch { - // Projects fetch failure is non-critical - } - } + // Same `['projects']` entry the navbar and every other page reads. + const projectsList = useQuery(projectsQuery()); + const projects: Project[] = projectsList.data ?? []; + + const refetchReports = async () => { + await queryClient.invalidateQueries({ queryKey: ['reports'] }); + }; const { showGenerateModal, @@ -144,21 +136,25 @@ function ReportsPageContent() { generating, error: generateError, handleGenerate, - } = useGenerateReport({ onSuccess: fetchReports }); + } = useGenerateReport({ onSuccess: refetchReports }); useEffect(() => { // Selection is scoped to the visible page, so it must not survive a page // change — bulk delete would otherwise remove rows the user can't see. setSelectedIds([]); - fetchReports(); - // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentPage]); + // Default the generate-report picker to the first project once the shared + // projects query resolves. Was a side effect inside the old fetchProjects. + // Only fills a blank selection, so a later refetch cannot silently move the + // user's choice out from under them. + const firstProjectId = projects[0]?.project_id; useEffect(() => { - fetchProjects(); + if (firstProjectId === undefined) return; + setGenerateProjectId((current) => current || String(firstProjectId)); // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [firstProjectId]); // Selection helpers (scoped to the currently visible page of rows) const allSelected = reports.length > 0 && reports.every((r) => selectedIds.includes(r.report_id)); @@ -195,7 +191,7 @@ function ReportsPageContent() { setSelectedIds( selectedIds.filter((_, i) => results[i].status === 'rejected'), ); - await fetchReports(); + await refetchReports(); if (failed > 0) { throw new Error( `Failed to delete ${failed} of ${selectedIds.length} report${selectedIds.length === 1 ? '' : 's'}`, @@ -398,7 +394,7 @@ function ReportsPageContent() { setIsUploadModalOpen(false)} - onSuccess={() => { setIsUploadModalOpen(false); fetchReports(); }} + onSuccess={() => { setIsUploadModalOpen(false); void refetchReports(); }} projects={projects} /> diff --git a/apps/frontend/src/context/AuthContext.tsx b/apps/frontend/src/context/AuthContext.tsx index 24f2aac6..29ed46ec 100644 --- a/apps/frontend/src/context/AuthContext.tsx +++ b/apps/frontend/src/context/AuthContext.tsx @@ -7,8 +7,10 @@ import { useEffect, useState, } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { ANONYMOUS, type RbacSubject } from '@branch/rbac'; import { ApiError, apiFetch } from '@/lib/api'; +import { AUTH_ME_KEY } from '@/lib/queries'; import { authedFetch, endSession, @@ -173,39 +175,86 @@ function isValidUser(candidate: unknown): candidate is AuthUser { export const AuthContext = createContext(null); export function AuthProvider({ children }: { children: React.ReactNode }) { - const [user, setUser] = useState(null); - const [isLoading, setIsLoading] = useState(true); + const queryClient = useQueryClient(); - const fetchMe = useCallback(() => authedFetch('/auth/me'), []); + /** + * Whether storage holds anything worth asking the server about. + * + * `null` is "not looked yet", and it is load-bearing twice over. It is read in + * an effect rather than during render because `output: 'export'` prerenders + * this tree at build time: deciding from storage during render would make the + * prerendered HTML that of a signed-out visitor, so the static document on S3 + * would contain protected page content instead of the spinner. It also keeps + * `isLoading` true across the first flush, so AuthGate cannot mistake + * "haven't checked" for "signed out" and bounce a returning user to /login. + */ + const [hasStoredSession, setHasStoredSession] = useState(null); - // Session bootstrap. Server-verified rather than "trust the local ID token", - // so a revoked or expired session no longer looks signed in. useEffect(() => { - let cancelled = false; + setHasStoredSession(Boolean(getAccessToken() || getRefreshToken())); + }, []); - (async () => { + const fetchMe = useCallback(() => authedFetch('/auth/me'), []); + + /** Writes the session straight into the cache, the one source of `user`. */ + const setUser = useCallback( + (next: AuthUser | null) => { + queryClient.setQueryData(AUTH_ME_KEY, next); + }, + [queryClient], + ); + + /** + * Session bootstrap. Server-verified rather than "trust the local ID token", + * so a revoked or expired session no longer looks signed in. + * + * A query rather than an effect so that the rest of the app can read the same + * cache entry, and so `reloadUser` and the login path can seed it without a + * second copy of the state living here. + */ + const meQuery = useQuery({ + queryKey: AUTH_ME_KEY, + // Anonymous visitors make zero network calls: `enabled` false leaves the + // query idle rather than merely discarding its result. + enabled: hasStoredSession === true, + // The session is re-read explicitly (`reloadUser`) and rewritten by every + // path that changes it, so a background refetch could only add requests. + staleTime: Infinity, + retry: false, + queryFn: async () => { try { - // Anonymous visitors make zero network calls, so isLoading settles on the - // first effect flush and no protected UI is ever painted. - if (!getAccessToken() && !getRefreshToken()) return; const me = await fetchMe(); - if (!cancelled) setUser(isValidUser(me) ? me : null); + return isValidUser(me) ? me : null; } catch { + // Resolving null rather than rejecting preserves the original contract: + // a failed bootstrap is a signed-out session, not an error to render. clearTokens(); - if (!cancelled) setUser(null); - } finally { - if (!cancelled) setIsLoading(false); + return null; } - })(); + }, + }); - return () => { - cancelled = true; - }; - }, [fetchMe]); + const user = meQuery.data ?? null; + + /** + * `isPending`, not `isLoading`: for one render after `enabled` flips true the + * fetch has not started yet, and `isLoading` is false in that window. AuthGate + * would read that single frame as "resolved, signed out" and redirect a + * perfectly good session to /login. + */ + const isLoading = + hasStoredSession === null || (hasStoredSession && meQuery.isPending); // Any endSession() anywhere — including from a background request — collapses // into user === null, which AuthGate turns into a redirect. - useEffect(() => onSessionExpired(() => setUser(null)), []); + useEffect( + () => + onSessionExpired(() => { + setHasStoredSession(false); + setUser(null); + }), + [setUser], + ); // Proactive refresh, rescheduled from each new token's exp. Without this the // session silently breaks after the access token's 1-hour lifetime. @@ -275,6 +324,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const me = await fetchMe(); if (!isValidUser(me)) throw new Error('Malformed /auth/me response'); setUser(me); + setHasStoredSession(true); } catch { // Never leave a half-session behind: tokens present but no known user. clearTokens(); @@ -286,7 +336,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { return { status: 'authenticated' }; }, - [fetchMe], + [fetchMe, setUser], ); const login = useCallback( @@ -321,7 +371,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { }); endSession(); setUser(null); - }, []); + }, [setUser]); const reloadUser = useCallback(async () => { try { @@ -335,7 +385,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { } throw error; } - }, [fetchMe]); + }, [fetchMe, setUser]); const forgotPassword = useCallback(async (email: string) => { await apiFetch('/auth/forgot-password', { diff --git a/apps/frontend/src/lib/queries.ts b/apps/frontend/src/lib/queries.ts new file mode 100644 index 00000000..d8a5fcca --- /dev/null +++ b/apps/frontend/src/lib/queries.ts @@ -0,0 +1,196 @@ +/** + * Every cached read in the app, in one place. + * + * Two rules make this file worth having: + * + * 1. One key per resource. `/projects` was previously fetched by five + * independent components, so navigating projects -> donations -> expenses + * issued the same request three times. They now share `['projects']` and + * the second and third navigations are served from cache. + * 2. The route prefetcher and the page that renders the data must agree on the + * key *and* the fetcher. `RoutePrefetcher` starts a route's queries before + * `AuthGate` has let the page mount; if its key differed from the page's by + * so much as a number's type, the page would refetch and the prefetch would + * be pure waste. Sharing these factories makes that mismatch unexpressible. + * + * Deliberately React-free: it calls `authedFetch` rather than `useApi()`, so + * `RoutePrefetcher` can use it from an effect and so `queryFn`s stay testable. + */ + +import { authedFetch } from '@/lib/authClient'; +import { normalizePath } from '@/lib/routes'; +import type { Donation, Donor, Expenditure, ProjectSummary } from '@/types'; + +/** A page of rows plus the server's pagination block. */ +export interface Paginated { + data: T[]; + pagination?: { + page: number; + limit: number; + totalItems: number; + totalPages: number; + }; +} + +export interface ReportRow { + report_id: number; + project_id: number; + title: string; + object_url: string; + report_type: string; + date_created: string | null; + emails?: string[]; +} + +/** Rows per page, shared so the prefetcher asks for the page the table renders. */ +export const ROWS_PER_PAGE = 10; + +/** `GET /auth/me`. Owned by `AuthContext`; listed here so the key has one home. */ +export const AUTH_ME_KEY = ['auth', 'me'] as const; + +interface QuerySpec { + queryKey: readonly unknown[]; + queryFn: () => Promise; +} + +/** + * `GET /projects` returns a bare array. Callers want different subsets of the + * row (the navbar needs a name, the cards need the aggregates) but they are all + * the same request, so they all get the same key. + */ +export function projectsQuery(): QuerySpec { + return { + queryKey: ['projects'] as const, + queryFn: async () => { + const rows = await authedFetch('/projects', { method: 'GET' }); + return Array.isArray(rows) ? rows : []; + }, + }; +} + +/** + * One page of `GET /expenditures`. + * + * `page` and `limit` are numbers in the key, not strings: a key of + * `['expenditures', 'paged', { page: '1' }]` from the prefetcher would miss + * `{ page: 1 }` from the page and quietly double every request. + */ +export function expendituresPageQuery( + page: number, + limit: number = ROWS_PER_PAGE, +): QuerySpec> { + return { + queryKey: ['expenditures', 'paged', { page, limit }] as const, + queryFn: () => + authedFetch>( + `/expenditures?page=${page}&limit=${limit}`, + { method: 'GET' }, + ), + }; +} + +/** + * Every expenditure the caller can see. + * + * Still needed, and not a leftover: `/expenditures` accepts only `page`, `limit` + * and `projectId`, so the expenses page's search box, month/type/status filters + * and sort-by-amount cannot be expressed server-side. Those views fall back to + * this query and filter in the browser, exactly as before. The default view -- + * the one every cold load pays for -- uses `expendituresPageQuery` instead. + */ +export function expendituresAllQuery(): QuerySpec> { + return { + queryKey: ['expenditures', 'all'] as const, + queryFn: () => authedFetch>('/expenditures', { method: 'GET' }), + }; +} + +export function reportsPageQuery( + page: number, + limit: number = ROWS_PER_PAGE, +): QuerySpec> { + return { + queryKey: ['reports', 'paged', { page, limit }] as const, + queryFn: () => + authedFetch>(`/reports?page=${page}&limit=${limit}`, { + method: 'GET', + }), + }; +} + +export function donationsQuery(): QuerySpec { + return { + queryKey: ['donations'] as const, + queryFn: async () => { + const res = await authedFetch>('/donors/donations', { + method: 'GET', + }); + return res.data ?? []; + }, + }; +} + +export function donorsQuery(): QuerySpec { + return { + queryKey: ['donors'] as const, + queryFn: async () => { + const res = await authedFetch>('/donors', { method: 'GET' }); + return res.data ?? []; + }, + }; +} + +/** + * The queries a route needs before it can paint, keyed by normalized pathname. + * + * `RoutePrefetcher` walks this; the pages call the very same factories. Adding a + * route here is the only step needed to take it off the auth waterfall. + * + * `search` is passed in so a paginated route prefetches the page the URL asks + * for rather than always page 1 -- a deep link to `?page=3` would otherwise warm + * the cache with rows nobody is about to look at. + */ +export const routeQueries: Record< + string, + (search: URLSearchParams) => QuerySpec[] +> = { + '/projects': () => [projectsQuery()], + '/reports': (search) => [ + reportsPageQuery(pageFrom(search)), + projectsQuery(), + ], + '/donations': () => [donationsQuery(), donorsQuery(), projectsQuery()], + '/expenses': (search) => [ + // Mirrors the page's own choice of query. A filtered deep link cannot be + // served by a page request, so prefetch what the page will actually ask for. + expensesFiltered(search) + ? expendituresAllQuery() + : expendituresPageQuery(pageFrom(search)), + projectsQuery(), + ], +}; + +function pageFrom(search: URLSearchParams): number { + return parseInt(search.get('page') ?? '', 10) || 1; +} + +/** + * Whether the expenses URL carries state the server cannot filter on, which is + * what forces the full-list query. Kept next to `routeQueries` so the + * prefetcher and the page cannot drift apart on the decision. + */ +export function expensesFiltered(search: URLSearchParams): boolean { + if ((search.get('q') ?? '') !== '') return true; + if (search.get('sort') === 'Amount') return true; + return ['months', 'types', 'projects', 'statuses'].some( + (key) => (search.get(key) ?? '') !== '', + ); +} + +/** The queries to warm for a pathname, or an empty list for an unmapped route. */ +export function queriesForRoute( + pathname: string, + search: URLSearchParams, +): QuerySpec[] { + return routeQueries[normalizePath(pathname)]?.(search) ?? []; +} diff --git a/apps/frontend/src/lib/queryClient.ts b/apps/frontend/src/lib/queryClient.ts new file mode 100644 index 00000000..ec87a8ab --- /dev/null +++ b/apps/frontend/src/lib/queryClient.ts @@ -0,0 +1,29 @@ +import { QueryClient } from '@tanstack/react-query'; + +/** + * Defaults chosen for an internal tool whose data changes on the order of + * minutes, not seconds. + * + * `staleTime` is the whole point of the cache and is deliberately not 0. At the + * library default every navigation back to a page refetches, which is the bug + * this replaces -- the cache would hold the data and then throw the round trip + * anyway. A minute of staleness across a projects -> donations -> expenses + * round trip is invisible to the user; three identical `GET /projects` calls + * were not. + * + * `refetchOnWindowFocus` is off because alt-tabbing back to a table is not a + * request for fresh data, and every writing path already invalidates the keys + * it dirtied. + */ +export function makeQueryClient(): QueryClient { + return new QueryClient({ + defaultOptions: { + queries: { + staleTime: 60_000, + gcTime: 300_000, + refetchOnWindowFocus: false, + retry: 1, + }, + }, + }); +} diff --git a/apps/frontend/test/components/ProjectsQueryDedupe.test.tsx b/apps/frontend/test/components/ProjectsQueryDedupe.test.tsx new file mode 100644 index 00000000..63400763 --- /dev/null +++ b/apps/frontend/test/components/ProjectsQueryDedupe.test.tsx @@ -0,0 +1,91 @@ +/** + * The `/projects` request count for a projects -> donations -> expenses walk. + * + * Five components read that endpoint (the projects list, the navbar flyout, and + * the donations, expenses and reports pages). Each used to own its own + * `useEffect` + `useState`, so the walk below issued three identical requests + * and every later navigation issued them again. They now share one query key. + * + * This is the assertion behind the number quoted in the PR description, so it + * deliberately drives the real page components through one QueryClient rather + * than testing the cache in the abstract. + */ +import { render, screen, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { ChakraProvider, defaultSystem } from '@chakra-ui/react'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { authedFetch } from '@/lib/authClient'; +import { makeQueryClient } from '@/lib/queryClient'; +import ProjectListView from '@/app/projects/ProjectListView'; +import DonationsPage from '@/app/donations/page'; +import ExpensePage from '@/app/expenses/page'; +import { AuthContext } from '@/context/AuthContext'; +import { adminSubject, session } from '../rbac'; + +jest.mock('../../src/lib/authClient', () => ({ + ...jest.requireActual('../../src/lib/authClient'), + authedFetch: jest.fn(), +})); + +jest.mock('next/navigation', () => ({ + useRouter: () => ({ replace: jest.fn(), push: jest.fn() }), + usePathname: () => '/projects', + useSearchParams: () => new URLSearchParams(), +})); + +const PROJECTS = [ + { project_id: 1, name: 'Alpha', is_active: true, total_spent: 0, member_count: 2, total_budget: '100' }, +]; + +/** Counts requests by path so the assertion can be about `/projects` alone. */ +function stubApi() { + const counts: Record = {}; + (authedFetch as jest.Mock).mockImplementation((path: string) => { + const key = path.split('?')[0]; + counts[key] = (counts[key] ?? 0) + 1; + if (key === '/projects') return Promise.resolve(PROJECTS); + return Promise.resolve({ data: [], pagination: { page: 1, limit: 10, totalItems: 0, totalPages: 1 } }); + }); + return counts; +} + +const authValue = { + ...session({ subject: adminSubject() }), + login: jest.fn(), + respondToChallenge: jest.fn(), + logout: jest.fn(), + refresh: jest.fn(), + reloadUser: jest.fn(), + forgotPassword: jest.fn(), + resetPassword: jest.fn(), +} as never; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +it('fetches /projects once across a projects -> donations -> expenses walk', async () => { + const counts = stubApi(); + // One client for the whole walk, which is what a real SPA session has. + const client = makeQueryClient(); + + const wrap = (ui: React.ReactElement) => ( + + + {ui} + + + ); + + const view = render(wrap()); + expect(await screen.findByText('Alpha')).toBeInTheDocument(); + + view.rerender(wrap()); + await waitFor(() => expect(counts['/donors/donations']).toBe(1)); + + view.rerender(wrap()); + await waitFor(() => expect(counts['/expenditures']).toBe(1)); + + // Was 3 — one per page. The navbar flyout would have made it 4. + expect(counts['/projects']).toBe(1); +}); diff --git a/apps/frontend/test/components/RoutePrefetcher.test.tsx b/apps/frontend/test/components/RoutePrefetcher.test.tsx new file mode 100644 index 00000000..5b8a54db --- /dev/null +++ b/apps/frontend/test/components/RoutePrefetcher.test.tsx @@ -0,0 +1,154 @@ +/** + * The two performance claims this PR makes, as assertions rather than prose. + * + * 1. A page's data request starts while GET /auth/me is still in flight. + * Before, AuthGate held every page unmounted until the session resolved, so + * the two calls were strictly sequential. + * 2. The five components that read `/projects` issue one request between them. + */ +import { render, screen, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; +import RoutePrefetcher from '@/app/components/RoutePrefetcher'; +import { Providers } from '@/app/providers'; +import { projectsQuery } from '@/lib/queries'; +import { STORAGE_KEYS } from '@/lib/authTokens'; +import { makeQueryClient } from '@/lib/queryClient'; + +const mockRouter = { replace: jest.fn(), push: jest.fn() }; +let currentPath = '/projects'; + +jest.mock('next/navigation', () => ({ + useRouter: () => mockRouter, + usePathname: () => currentPath, + useSearchParams: () => new URLSearchParams(), +})); + +/** A JWT-shaped access token that expires an hour from now. */ +function accessToken() { + const payload = btoa( + JSON.stringify({ sub: 's', exp: Math.floor(Date.now() / 1000) + 3600 }), + ) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); + return `eyJhbGciOiJSUzI1NiJ9.${payload}.sig`; +} + +const ME = { + userId: 1, + cognitoSub: 's', + email: 'a@b.c', + name: 'Ada', + isAdmin: true, + rbac: { userId: 1, isAdmin: true, memberProjectIds: [], directorProjectIds: [] }, +}; + +const PROJECTS = [ + { project_id: 1, name: 'Alpha', is_active: true, total_spent: 0, member_count: 1 }, +]; + +/** Records the order calls arrive in and lets a test hold /auth/me open. */ +function stubFetch({ holdAuthMe = false } = {}) { + const calls: string[] = []; + let releaseAuthMe: () => void = () => {}; + const authMeGate = new Promise((resolve) => { + releaseAuthMe = resolve; + }); + + global.fetch = jest.fn(async (url: string) => { + const path = new URL(url, 'http://localhost').pathname; + calls.push(path); + if (path === '/auth/me' && holdAuthMe) await authMeGate; + const body = path === '/projects' ? PROJECTS : ME; + return { + ok: true, + status: 200, + statusText: 'OK', + json: async () => body, + } as unknown as Response; + }) as unknown as typeof fetch; + + return { calls, releaseAuthMe: () => releaseAuthMe() }; +} + +beforeEach(() => { + jest.clearAllMocks(); + localStorage.clear(); + currentPath = '/projects'; +}); + +describe('RoutePrefetcher', () => { + it('issues no request for a visitor with no stored token', async () => { + const { calls } = stubFetch(); + + render( + + + , + ); + + await waitFor(() => expect(calls).toEqual([])); + }); + + it('warms the route\'s query so a later consumer makes no request of its own', async () => { + localStorage.setItem(STORAGE_KEYS.ACCESS, accessToken()); + const { calls } = stubFetch(); + // The app's real defaults on purpose: `staleTime` is precisely the claim + // under test, and a test-only client with staleTime 0 would refetch and + // report a failure the app does not have. + const client = makeQueryClient(); + + function ProjectsConsumer() { + const { data } = useQuery(projectsQuery()); + return
{data?.length ?? 'pending'}
; + } + + const view = render( + + + , + ); + + await waitFor(() => + expect(calls.filter((c) => c === '/projects')).toHaveLength(1), + ); + + // The consumer mounts after the prefetch landed, exactly as a page does when + // AuthGate finally unblocks it. + view.rerender( + + + + , + ); + + expect(await screen.findByTestId('count')).toHaveTextContent('1'); + expect(calls.filter((c) => c === '/projects')).toHaveLength(1); + }); +}); + +describe('the auth waterfall', () => { + it('starts the page request before GET /auth/me has resolved', async () => { + localStorage.setItem(STORAGE_KEYS.ACCESS, accessToken()); + const { calls, releaseAuthMe } = stubFetch({ holdAuthMe: true }); + + render( + +
projects
+
, + ); + + // The claim: /projects is in flight while /auth/me is still blocked, so the + // two round trips overlap instead of queueing. + await waitFor(() => expect(calls).toContain('/projects')); + expect(calls).toContain('/auth/me'); + expect(screen.queryByTestId('page')).not.toBeInTheDocument(); + + releaseAuthMe(); + + // And the gate still does its job: the page appears only once auth resolves. + expect(await screen.findByTestId('page')).toBeInTheDocument(); + }); +}); diff --git a/apps/frontend/test/context/AuthContext.test.tsx b/apps/frontend/test/context/AuthContext.test.tsx index 913a5165..18feb0e4 100644 --- a/apps/frontend/test/context/AuthContext.test.tsx +++ b/apps/frontend/test/context/AuthContext.test.tsx @@ -2,6 +2,7 @@ import { renderHook, act, waitFor } from '@testing-library/react'; import { AuthProvider, useAuth } from '@/context/AuthContext'; import { STORAGE_KEYS } from '@/lib/authTokens'; import { __resetRefreshStateForTests, onSessionExpired } from '@/lib/authClient'; +import { TestQueryProvider } from '../utils'; // --------------------------------------------------------------------------- // Helpers @@ -66,7 +67,9 @@ function seedTokens(access = TOKENS.AccessToken) { } const wrapper = ({ children }: { children: React.ReactNode }) => ( - {children} + + {children} + ); async function renderAuth() { diff --git a/apps/frontend/test/utils.tsx b/apps/frontend/test/utils.tsx index 726c2ad2..e565d2d8 100644 --- a/apps/frontend/test/utils.tsx +++ b/apps/frontend/test/utils.tsx @@ -1,10 +1,42 @@ import { render, type RenderOptions } from '@testing-library/react'; import { ChakraProvider, defaultSystem } from '@chakra-ui/react'; -import type { ReactElement } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { useState, type ReactElement } from 'react'; import type { RbacSubject } from '@branch/rbac'; import { AuthContext, AuthProvider } from '@/context/AuthContext'; import { adminSubject, session } from './rbac'; +/** + * A cache per render, so one test's rows never satisfy the next test's query. + * + * `retry: false` because the app retries once by default, which would turn every + * assertion about a failed request into two stubbed calls and a wait. `gcTime: 0` + * keeps nothing alive past the render. + */ +export function makeTestQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0, refetchOnWindowFocus: false }, + }, + }); +} + +/** + * Wraps children in a fresh QueryClientProvider. Exported for the suites that + * build their own wrapper (AuthContext's, which needs the real provider without + * Chakra) rather than going through `render` below. + */ +export function TestQueryProvider({ + children, +}: { + children: React.ReactNode; +}) { + // useState, not a bare call: a new client on every render would reset the + // cache mid-test and re-trigger every query forever. + const [client] = useState(makeTestQueryClient); + return {children}; +} + /** * Renders with a signed-in admin by default. * @@ -21,6 +53,7 @@ function makeWrapper(subject: RbacSubject | null) { return function Wrapper({ children }: { children: React.ReactNode }) { return ( + + ); }; } @@ -62,9 +96,11 @@ export const renderWithLiveAuth = ( ) => render(ui, { wrapper: ({ children }) => ( - - {children} - + + + {children} + + ), ...options, });