Spec 033 — Frontend performance: route code-splitting and staleTime
Status: implemented Branch: 033-frontend-perf
Status is set by the human, never by the agent. It moves draft → approved → implemented. (Transcribed draft → approved, then approved → implemented, on the human's explicit instruction, 2026-08-22.)
Part of the gw2.app alignment epic (Wave 1, "Spec 4 · frontend-perf"). Client-only; independent of the two sibling Wave-1 specs (031 backend-edge-caching, 032 recipe-graph-cacheable).
Problem
The web client is a pure Vite SPA with two performance gaps that are entirely client-side — neither depends on the backend, so both are fixable now, in parallel with the backend caching work:
- No route code-splitting.
main.tsxstatically imports every feature's route table, and each table statically imports its page component (apps/web/src/main.tsx:6-13,features/*/routes.tsx). So the initial JS bundle contains every route's code; the browser downloads and boots all of it before#rootshows anything, on a route the visitor may never open. staleTime: 0everywhere. TheQueryClientis constructed with no options (apps/web/src/api/ApiProvider.tsx:4), so every query is stale the instant it resolves. Navigating away from a route and back, or any remount, fires a background refetch — even for data that does not change from second to second. gw2.app, by contrast, ships per-route chunks and lets cache lifetimes keep repeat views from refetching (docs/gaps/gw2app-caching-comparison.md).
Separately, it is unknown whether the SPA's first paint is acceptable once Spec 031 removes the free-tier cold start, or whether server-side rendering is warranted. That is a large, speculative build to guess at; it should be a measurement, not an assumption.
User stories
Ordered by priority. Each story is independently testable and shippable.
P1 — Route-level code-splitting
As a visitor opening one page of the app, I want the initial download to carry only the shell and the route I am on, so the page becomes interactive without first downloading every other route's code.
Independent test: build the app for production. Each feature route page/layout module is emitted as its own chunk and is absent from the entry chunk; opening a route fetches that route's chunk on demand; the route still renders inside the App-shell <Suspense>; and the P1 #4 route-naming guard still detects violations.
Acceptance scenarios
- Given the production build, when the entry (initial) chunk's module graph is inspected, then no feature route page/layout module (
*Page/*Layout) is part of it — each is a separate, lazily-loaded chunk. - Given the app loaded on one route, when the user navigates to another route, then that route's page chunk is fetched on demand and renders inside the existing App-shell
<Suspense>boundary (no new boundary is introduced). - Given the code-split route tables, when the conventions guard suite runs, then the
P1 #4rule (every route element ends inPageorLayout) still detects a violation — splitting the routes does not blind the guard.
P2 — A staleTime matched to each query's volatility
As someone moving between pages, I want data I just viewed to stay put when I navigate away and back, so the UI does not flash and refetch on every mount — while genuinely volatile data still refreshes.
Independent test: mount a query hook, unmount it, remount it within its staleTime, and assert no second network request fired; assert every hook's staleTime is greater than 0; assert a query past its staleTime still refetches.
Acceptance scenarios
- Given a query whose data is in cache and within its
staleTime, when its component remounts, then no network request is fired (0 fetches). - Given each query hook, when its resolved options are inspected, then its
staleTimeis greater than0and matches its volatility tier (reference / priced-structure / per-user). - Given a query whose
staleTimehas elapsed, when its component mounts, then it refetches — staleness still works; no tier is set toInfinity.
Requirements
R1 — Each feature's
routes.tsxloads its page component lazily (dynamicimport()), so the page's module is not part of the entry chunk. The build must emit one chunk per lazily-imported page. (Confirmed — research V3: Vite 8 / Rolldown emits a separate chunk perReact.lazydynamic import, and the build manifest shows route pages absent from the entry chunk.)R2 — Code-splitting preserves the
P1 #4route-naming guard's coverage. The guard readselement: <Name>only — React Router'slazy:andComponent:props bypass it (docs/architecture/react.md, "route-naming guard"). The intended mechanism is aReact.lazywrapper kept behind the existingelement: <XxxPage />shape (so the guard still reads aPage/Layoutname); the exact wiring is a plan decision, but the guard must still detect a violation afterward.R3 — The one
QueryClient(constructed only inApiProvider,react.mdR11) sets a defaultstaleTimegreater than0, and each query type carries astaleTimematched to its volatility tier. No query is left at0. The tiers and their queries:- reference / static —
useLegendaries(/api/legendaries),useHealth(/api/health); and the recipe-tree structure. - priced structure —
useRecipeTree(/api/recipe-graph/:itemId): user-agnostic structure with trading-post prices folded in server-side (~60 s TTL) today, so its life tracks price volatility until Spec 3 makes the tree priceless. - per-user —
useAccount,useMaterials,useWallet,useLegendaryRanking(each keyed by a hash of the API key), which change across play sessions.
Exact values (set in research V2): reference / static 1 h, priced structure 60 s, per-user 5 min, with a 60 s default floor so nothing sits at
0.- reference / static —
R4 — Staleness still functions: every tier's
staleTimeis a finite value, neverInfinity; a query past itsstaleTimerefetches on mount. This spec tunes refetching, it does not freeze it.R5 — First-paint spike (measurement only, no SSR built). Measure the code-split production build's first paint / time-to-interactive served against a warm backend (a stand-in for post-Spec-031, since Spec 031 removes the cold start), and record in
research.md: the number, the method, a go/no-go threshold, before/after entry-bundle sizes, and a verdict on whether SSR is warranted. No SSR is implemented. (Verdict — research V1: SSR is not needed; against a warm origin the cold-cache first paint is ~0.77 s, well inside the "good" band, so the ~42.6 s cold start — Spec 031's job — was the whole problem.)R6 — Client-only, no HTTP surface. No backend endpoint, controller, DTO, OpenAPI document, or generated API client is added or changed. Under the epic's surface rule, this spec belongs to neither Surface A nor Surface B — it is client bundle + cache-config only, and changes no response body (that is Spec 2 / Spec 3).
HTTP surface
None — client only. This spec adds and changes zero endpoints, so there is no OpenAPI or generated client change. Stated explicitly because endpoints + OpenAPI are first-class spec content in this project: the deliberate answer here is that there are none.
Success criteria
Measurable and technology-agnostic — outcomes, not implementation.
- SC1 — In the production build, no feature route page/layout module is in the entry chunk; each loads as its own chunk. (Enforced by a build-manifest test; before/after entry-bundle sizes recorded in
research.mdas evidence.) - SC2 — Opening a route loads its chunk on demand and renders inside the existing App
<Suspense>boundary; no new boundary is added. - SC3 — After code-splitting, the
P1 #4route-naming guard still detects a violation (the guard is not blinded by moving to a lazy form). - SC4 — No query hook has
staleTime: 0; each has a finite, per-tier value greater than0, set fromresearch.md. - SC5 — A component remounting within its query's
staleTimefires 0 network requests, and a query past itsstaleTimestill refetches. - SC6 —
research.mdrecords the measured first-paint / TTI against a warm origin, the threshold, before/after bundle sizes, and a verdict on SSR — with no SSR code built. - SC7 — Typecheck, lint, the full test suite, the production build (where the React Compiler runs), and
docs:buildare all green. - SC8 — Every acceptance scenario and success criterion maps to a named test in the traceability table.
Out of scope
- Building SSR. Deferred to the R5 spike; only measured here. YAGNI — and research V1 measured it as not needed (see R5).
- A persistent client cache (IndexedDB /
persistQueryClient). Deliberately cut (agreed with the human, 2026-08-22) — a deviation from the epic's Spec-4 sketch, which listed it. Rationale: our measured evidence shows gw2.app uses HTTPCache-Control: max-age(browser HTTP cache), not IndexedDB, for reload-survival; Spec 031 adds those headers, so a reload's refetch is served from the browser HTTP cache; and P2'sstaleTimealready covers in-session repeat-nav. The only residual benefit — no Suspense-fallback flash on a hard reload — is marginal and unproven; revisit with evidence if the R5 spike shows first paint is a problem. Removing it also drops the epic's "IndexedDB persister + gcTime" verification item. - Where data is fetched. Moving prices and account reads to browser-direct ArenaNet calls is Spec 3 (client-direct-anet, Wave 2). This spec changes the bundle and cache config only, not the fetch source.
- Any response-body change. The shape of every query response is untouched (that is Spec 2 / Spec 3).
- Prefetching or preloading route chunks on hover/idle — a later optimisation, not needed to split the bundle.
Assumptions
- App.tsx already owns the one
<Suspense>+ error boundary the layout route wraps every feature route in (react.md;App.tsx). Lazy route components suspend into it, so R1 needs no new boundary — route-level code-splitting is called out there as "the expected next step for this app." - The
QueryClientis constructed in exactly one place (ApiProvider,react.mdR11), so R3's default lives there, with per-tier overrides through the existingsuspenseOptionsfacade (apps/web/src/api/suspenseOptions.ts) — the facade hooks are alluseSuspenseQuery. - Spec 031 is the source of HTTP cache headers, not this spec. Code-splitting and
staleTimedeliver their value whether or not Spec 031 has merged, so this spec has no cross-dependency (Wave 1). - The React Compiler runs only in
vite build(react.md; memory: React-Compiler build gate), so verification runspnpm build, and anyReact.lazywrapper avoids the destructured-default + inline-type props pattern that trips the compiler. - Volatility, for the R3 tiers: trading-post prices are short-lived (~2 min at ArenaNet, ~60 s in the server cache); per-user account/materials/wallet change across play sessions; the legendary catalog and recipe-tree structure are effectively static for hours. Exact
staleTimevalues are verified inresearch.md, not assumed here.
Traceability
Each acceptance scenario and success criterion maps to a named test. Filled in (implementation complete). Web tests: the query-behaviour tests in apps/web/src/api/__tests__/, the code-split test in apps/web/src/__tests__/, the route-naming guard in apps/web/src/__tests__/conventions.test.ts.
| Criterion | Test |
|---|---|
| P1 #1 | codeSplitting.test.ts — SC1: no route page module is in the entry chunk; SC2: each route page is its own on-demand chunk |
| P1 #2 | codeSplitting.test.ts — same case (each page has its own manifest entry/chunk and is absent from the entry's static-import closure — a BFS over static imports) |
| P1 #3 | conventions.test.ts — P1 #4: every component a route renders is named <Name>Page or <Name>Layout (still scans routes.tsx, via the extracted routeElementOffenders) and P1 #4: the rule catches a violation (the synthetic-violation companion case) |
| P2 #1 | staleTimeBehavior.test.tsx — P2 #2: a remount within staleTime serves cache, no second request |
| P2 #2 | staleTimeBehavior.test.tsx — SC5: <hook> registers its tier as the query staleTime (per-hook it.each, all 7); staleTimes.test.tsx — SC4: every tier is a finite number greater than zero |
| P2 #3 | staleTimeBehavior.test.tsx — P2 #3: a remount past staleTime refetches (staleness still fires) |
| SC1 | codeSplitting.test.ts — SC1: no route page module is in the entry chunk; SC2: each route page is its own on-demand chunk (real vite build + manifest; entry 492.89 → 296.75 kB / 151.24 → 88.56 kB gzip, recorded in research.md) |
| SC2 | codeSplitting.test.ts — same case (page has its own manifest entry/chunk, not the entry's static-import closure) |
| SC3 | conventions.test.ts — P1 #4: every component a route renders is named <Name>Page or <Name>Layout and P1 #4: the rule catches a violation (still catches a synthetic violation) |
| SC4 | staleTimes.test.tsx — carries the researched tier values, SC4: every tier is a finite number greater than zero, SC4: the QueryClient default floor is a non-zero staleTime (rendered against the real ApiProvider client via a probe child) |
| SC5 | staleTimeBehavior.test.tsx — P2 #2: a remount within staleTime serves cache, no second request, P2 #3: a remount past staleTime refetches (staleness still fires), SC5: <hook> registers its tier as the query staleTime |
| SC6 | research.md V1 first-paint record (warm-origin cold-cache FCP ~0.77 s) + human review at step 5 — no unit test (real-browser metric) |
| SC7 | CI — pnpm typecheck, pnpm lint, pnpm test (728 pass, 1 pre-existing expected-fail), pnpm build (React Compiler), pnpm docs:build — all green |
| SC8 | this table |