Skip to content

perf(frontend): add a query cache and stop serializing page loads behind auth - #369

Merged
nourshoreibah merged 1 commit into
mainfrom
perf/frontend-query-cache
Aug 23, 2026
Merged

nourshoreibah merged 1 commit into
mainfrom
perf/frontend-query-cache

Conversation

@nourshoreibah

Copy link
Copy Markdown
Collaborator

The waterfall

AuthGate wraps every route from providers.tsx and returns <FullPageSpinner />
while GET /auth/me is in flight. No page component mounts, so no page effect
runs, 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 /projects
before 1726 → 3248 ms 4108 → 4111 ms
after 290 → 1792 ms 285 → 287 ms

Before, /projects did 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 RoutePrefetcher mounted inside Providers but outside
AuthGate
, so it mounts and runs on the first flush. It reads usePathname(),
looks the route up in a shared routeQueries map, and calls
queryClient.prefetchQuery. It is gated on an access token being present in
local storage, so anonymous visitors fire no request that would only 401.

AuthGate itself is untouched — including the two deliberate render-suppression
checks. Prefetching made relaxing the gate unnecessary.

Why prefetching, not optimistic auth hydration

Hydrating user from a cached /auth/me payload would also remove the wait, but
a 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, hasStoredSession in AuthContext is read in an effect rather than
during render. output: 'export' prerenders this tree at build time, so deciding
from storage during render would put protected page content into the static
documents on S3 instead of the spinner. Verified: out/expenses/index.html
contains the spinner and zero page content.

/projects request count

Five call sites (ProjectListView, Navbar, donations, expenses,
reports), each previously owning its own useEffect + useState. Walking
projects → donations → expenses in a real browser:

/projects requests
before 3
after 1

Also covered by test/components/ProjectsQueryDedupe.test.tsx, which drives the
three real page components through one QueryClient. That test was run against
main's components too and reported 3, so the before/after numbers come from the
same assertion rather than from reading the diff.

The navbar flyout stays lazy — enabled: projectsOpen replaces the old
first-expand latch — but now shares the cache, so expanding it on any of those
pages costs no request at all.

Expenses payload

/expenditures previously returned every expenditure the caller could see
and the page sliced 10 rows client-side. The default view now requests
?page=1&limit=10 and uses the server's pagination block, with
placeholderData: keepPreviousData so page flips don't flash a spinner.

One deviation from the brief. /expenditures accepts only page, limit
and projectId. The page's search box, month/type/status filters and
sort-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=Date needs no fallback because the server already orders by
spent_on desc. The decision lives in one place (expensesFiltered) so the
prefetcher and the page cannot disagree about which query to issue.

Cache defaults

staleTime: 60_000, gcTime: 300_000, refetchOnWindowFocus: false,
retry: 1. staleTime is the point: at the library default of 0 every
navigation refetches, which would leave the bug in place while holding the data.

QueryClientProvider sits above AuthProvider so /auth/me is itself a query
(['auth','me']), with AuthContext reading from the cache instead of owning
its own effect and state. All existing auth behaviour is preserved — anonymous
visitors still make zero calls and settle isLoading on the first flush, the
exp-scheduled refresh timer still works, and authClient's proactive refresh
is untouched. isLoading uses isPending rather than isLoading because for
one render after enabled flips true the fetch hasn't started, and AuthGate
would read that frame as "resolved, signed out" and redirect a good session to
/login.

The change to providers.tsx is additive and leaves the
ChakraProvider value={...} line alone, for the concurrent PR editing it.

Deliberately left out

  • donors / donations pagination — needs new server-side aggregates
    (num_projects, last_donation, joined donor_name/project_name) and
    overlaps the rollup-table effort. Donations' three reads are now cached, but
    its pagination, filtering and sorting are byte-for-byte unchanged.
  • The useMemo / render-cost pass — unmemoized filter+sort in expenses,
    navbar hover re-renders, DropdownSelector, ExpensesTable columns. Sequenced
    after this PR because server-side pagination makes some of it moot. The one
    exception is three stable empty-array constants in donations/page.tsx, added
    only because ?? [] on query data would have handed its existing useMemo a
    new array every render.
  • next/dynamic code splitting, fonts, images, the Chakra system — separate
    PRs, some already open.

Verification

  • npm run typecheck — clean
  • npm run lint — clean, no warnings
  • npm run build — succeeds, all 16 pages statically exported
  • npm test — 36 suites, 353 passed, 2 skipped (355 total), up from 34/351
    on main (+2 suites, +4 tests: the prefetch, waterfall and dedupe assertions)
  • First Load JS: /projects 339 → 353 kB, /expenses 359 → 373 kB (~14 kB for
    react-query), against a payload that no longer ships the full expenditure table

test/utils.tsx gained the query provider so individual tests didn't need
touching; AuthContext.test.tsx uses the exported TestQueryProvider for its
own local wrapper.

🤖 Generated with Claude Code

…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>
@nourshoreibah nourshoreibah added no-review The PR review bot won't run test-environment Creates a temporary (nearly free) test environment. Uses prod DB and cognito labels Aug 23, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🌿 ⏳ Creating preview environment… (logs)

@github-actions

Copy link
Copy Markdown
Contributor

🌿 Preview environment — ready ✅

Open: https://d3nmtjoh6ir9ym.cloudfront.net/pr-369/
API: https://o3a2gpsj5j.execute-api.us-east-2.amazonaws.com/prod

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 test-environment label or close the PR to tear it down.

@nourshoreibah
nourshoreibah merged commit 9a42bec into main Aug 23, 2026
22 of 24 checks passed
@nourshoreibah
nourshoreibah deleted the perf/frontend-query-cache branch August 23, 2026 18:01
@github-actions

Copy link
Copy Markdown
Contributor

🌿 Preview environment torn down 🧹 — the stack for this PR has been destroyed.

This branch was successfully deployed

1 active deployment
preview — 43aa95b2 Deployed Aug 23, 2026 by nourshoreibah via teardown #350
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

no-review The PR review bot won't run test-environment Creates a temporary (nearly free) test environment. Uses prod DB and cognito

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant