perf(frontend): add a query cache and stop serializing page loads behind auth - #369
Merged
Merged
Conversation
…ind auth Two linked problems, one cause: there was no caching layer, and AuthGate held every page unmounted until GET /auth/me resolved. The gate returns a spinner while the session is in flight, so no page component mounted and no page effect ran until auth came back. Every cold load therefore paid two strictly sequential round trips -- auth, then the page's own call -- each potentially including a Lambda cold start. Measured against a stub API with a 1.5s /auth/me: /auth/me ran 1726-3248ms and /projects did not start until 4108ms. A RoutePrefetcher mounted inside Providers but outside AuthGate now looks the current pathname up in a shared routeQueries map and prefetches that route's queries immediately, in parallel with auth. Same measurement after: /projects 285-287ms, /auth/me 290-1792ms -- the page's data is cached ~1.5s before the gate unblocks. It is gated on a stored access token so anonymous visitors still make zero data requests. Prefetching rather than hydrating `user` from a cached /auth/me payload: the latter also removes the wait, but a signed-out visitor with stale storage would briefly see admin navigation. Authorization is enforced server-side so nothing leaks, but the appearance bug is not worth it. /projects had five independent call sites, each with its own effect and state. A projects -> donations -> expenses walk issued it three times; it now issues one. Both the prefetcher and the pages read the same query-key factories, so a key can't drift out from under a prefetch. /expenses fetched every visible expenditure and sliced ten rows in the browser; the default view now asks for ?page=&limit=. The search box, month/type/status filters and sort-by-amount have no server-side equivalent on /expenditures, so those views still fetch the full list and filter client-side -- paginating underneath them would silently reduce them to filtering the ten visible rows. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Contributor
|
🌿 ⏳ Creating preview environment… (logs) |
Contributor
🌿 Preview environment — ready ✅Open: https://d3nmtjoh6ir9ym.cloudfront.net/pr-369/ Shared RDS + Cognito (prod data); DB migrations are not applied here — if this PR adds a migration, endpoints using the new columns will fail until it merges. New commits update this environment in place — a note is posted here on each update. Remove the |
Contributor
|
🌿 Preview environment torn down 🧹 — the stack for this PR has been destroyed. |
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The waterfall
AuthGatewraps every route fromproviders.tsxand returns<FullPageSpinner />while
GET /auth/meis in flight. No page component mounts, so no page effectruns, so no page fetch starts until auth resolves. Every cold load paid two
strictly sequential round trips — auth, then the page's own call — each
potentially including a Lambda cold start.
Measured in Chrome against the static export and a stub API with a 1.5s
/auth/me:/auth/me/projectsBefore,
/projectsdid not start until 860 ms after auth had already finished.After, it starts 5 ms before auth is even issued and completes ~1.5 s before
the gate unblocks, so the page renders from a warm cache the moment it mounts.
The fix is a
RoutePrefetchermounted insideProvidersbut outsideAuthGate, so it mounts and runs on the first flush. It readsusePathname(),looks the route up in a shared
routeQueriesmap, and callsqueryClient.prefetchQuery. It is gated on an access token being present inlocal storage, so anonymous visitors fire no request that would only 401.
AuthGateitself is untouched — including the two deliberate render-suppressionchecks. Prefetching made relaxing the gate unnecessary.
Why prefetching, not optimistic auth hydration
Hydrating
userfrom a cached/auth/mepayload would also remove the wait, buta signed-out visitor with stale storage would briefly see admin navigation before
the server corrected them. The server enforces authorization independently so it
is not a data leak, but it is a bad enough appearance bug to rule out.
Prefetching buys the same parallelism with no false UI.
Relatedly,
hasStoredSessioninAuthContextis read in an effect rather thanduring render.
output: 'export'prerenders this tree at build time, so decidingfrom storage during render would put protected page content into the static
documents on S3 instead of the spinner. Verified:
out/expenses/index.htmlcontains the spinner and zero page content.
/projectsrequest countFive call sites (
ProjectListView,Navbar,donations,expenses,reports), each previously owning its ownuseEffect+useState. Walkingprojects → donations → expenses in a real browser:
/projectsrequestsAlso covered by
test/components/ProjectsQueryDedupe.test.tsx, which drives thethree real page components through one
QueryClient. That test was run againstmain's components too and reported 3, so the before/after numbers come from thesame assertion rather than from reading the diff.
The navbar flyout stays lazy —
enabled: projectsOpenreplaces the oldfirst-expand latch — but now shares the cache, so expanding it on any of those
pages costs no request at all.
Expenses payload
/expenditurespreviously returned every expenditure the caller could seeand the page sliced 10 rows client-side. The default view now requests
?page=1&limit=10and uses the server'spaginationblock, withplaceholderData: keepPreviousDataso page flips don't flash a spinner.One deviation from the brief.
/expendituresaccepts onlypage,limitand
projectId. The page's search box, month/type/status filters andsort-by-amount have no server-side equivalent, so 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. Those views therefore still fetch the
full list and filter in the browser, exactly as before; only the default view
paginates.
sort=Dateneeds no fallback because the server already orders byspent_on desc. The decision lives in one place (expensesFiltered) so theprefetcher and the page cannot disagree about which query to issue.
Cache defaults
staleTime: 60_000,gcTime: 300_000,refetchOnWindowFocus: false,retry: 1.staleTimeis the point: at the library default of 0 everynavigation refetches, which would leave the bug in place while holding the data.
QueryClientProvidersits aboveAuthProviderso/auth/meis itself a query(
['auth','me']), withAuthContextreading from the cache instead of owningits own effect and state. All existing auth behaviour is preserved — anonymous
visitors still make zero calls and settle
isLoadingon the first flush, theexp-scheduled refresh timer still works, andauthClient's proactive refreshis untouched.
isLoadingusesisPendingrather thanisLoadingbecause forone render after
enabledflips true the fetch hasn't started, and AuthGatewould read that frame as "resolved, signed out" and redirect a good session to
/login.The change to
providers.tsxis additive and leaves theChakraProvider value={...}line alone, for the concurrent PR editing it.Deliberately left out
donors/donationspagination — needs new server-side aggregates(
num_projects,last_donation, joineddonor_name/project_name) andoverlaps the rollup-table effort. Donations' three reads are now cached, but
its pagination, filtering and sorting are byte-for-byte unchanged.
useMemo/ render-cost pass — unmemoized filter+sort inexpenses,navbar hover re-renders,
DropdownSelector,ExpensesTablecolumns. Sequencedafter this PR because server-side pagination makes some of it moot. The one
exception is three stable empty-array constants in
donations/page.tsx, addedonly because
?? []on query data would have handed its existinguseMemoanew array every render.
next/dynamiccode splitting, fonts, images, the Chakra system — separatePRs, some already open.
Verification
npm run typecheck— cleannpm run lint— clean, no warningsnpm run build— succeeds, all 16 pages statically exportednpm test— 36 suites, 353 passed, 2 skipped (355 total), up from 34/351on
main(+2 suites, +4 tests: the prefetch, waterfall and dedupe assertions)/projects339 → 353 kB,/expenses359 → 373 kB (~14 kB forreact-query), against a payload that no longer ships the full expenditure table
test/utils.tsxgained the query provider so individual tests didn't needtouching;
AuthContext.test.tsxuses the exportedTestQueryProviderfor itsown local wrapper.
🤖 Generated with Claude Code