Plan 033 — Frontend performance: route code-splitting and staleTime
Status: approved Written in plan mode from spec.md and research.md. Approved by the human before any code is written. Status is set by the human, never by the agent: proposed → approved. (Transcribed proposed → approved on the human's explicit instruction, 2026-08-22.)
Produced alone — tasks.md stays untouched until this plan is approved in turn.
Spec: specs/033-frontend-perf/spec.md · Research: specs/033-frontend-perf/research.md
Goal
The web client stops shipping every route's code in one bundle and stops refetching data it just showed. A first visit downloads the shell plus only the route being opened; navigating away and back inside a session serves the cached answer instead of a refetch. Nothing about what an endpoint returns or where data is fetched changes — this is bundle shape and cache configuration only.
Approach
Two independent units of work, one per user story, plus a spike that is already done.
P1 — Route code-splitting. Each feature's routes.tsx today statically imports its page component, so every page is in the entry chunk (main.tsx:6-13, features/*/routes.tsx). Each page becomes a React.lazy wrapper:
const XxxPage = lazy(() =>
import('./XxxPage').then((m) => ({ default: m.XxxPage })),
);The local const keeps the name XxxPage, so the route stays element: <XxxPage /> and the P1 #4 route-naming guard still reads a Page/Layout name (research V3). The dynamic import() string is not a static binding, so there is no name collision with the imported module. React Router's lazy: and Component: route props are not used — they would bypass that guard (research V3, react.md). The lazy page suspends into the single <Suspense> already declared in App.tsx, so no new boundary is added. vite.config.ts gains build.manifest: true so a test can read the emitted chunk graph.
P2 — staleTime per volatility tier. Today new QueryClient() takes no options, so every query is staleTime: 0 (ApiProvider.tsx:4). A new apps/web/src/api/staleTimes.ts exports the three tier constants (research V2). ApiProvider sets a non-zero default floor; each facade hook passes its tier's value into useSuspenseQuery through the existing suspenseOptions facade. Values are finite (research R4), so staleness still fires.
| Tier | Constant | Value | Queries |
|---|---|---|---|
| reference | STALE_TIME.reference | 3_600_000 (1 h) | useLegendaries, useHealth |
| priced structure | STALE_TIME.pricedStructure | 60_000 (60 s) | useRecipeTree |
| per-user | STALE_TIME.perUser | 300_000 (5 min) | useAccount, useMaterials, useWallet, useLegendaryRanking |
| default floor | (QueryClient default) | 60_000 (60 s) | any query with no explicit override |
The SSR/first-paint spike is complete (research V1): SSR is not needed once the cold start is gone. No SSR is built; SC6 traces to research.md plus human review. No task implements it.
Architecture
The change is local to apps/web and touches two seams that already exist:
main.tsx ── assembles ──▶ features/*/routes.tsx ──▶ React.lazy(page)
│ suspends into
▼
App.tsx <Suspense> (existing)
ApiProvider.tsx ── constructs ──▶ QueryClient { default staleTime: 60s }
▲ ▲ per-hook override
└── api/staleTimes.ts (STALE_TIME) ─┴── api/use*.ts (7 facade hooks)No component depends on another feature; no shared boundary moves. The one new module (staleTimes.ts) is a leaf that the seven hooks and their tests read.
Tech stack
React 19.2.8 (lazy + Suspense, already used in App.tsx), react-router 8.3.0, @tanstack/react-query 5.101.4, Vite 8.1.5 (Rolldown/Oxc). No new dependency. The IndexedDB persister that would have added @tanstack/react-query-persist-client + a persister + idb-keyval was cut in brainstorming (spec Out of scope) — this plan adds nothing to package.json.
Global Constraints
Copied verbatim from the architecture docs. Every task inherits these; do not summarise or reword them — a test asserts they appear here unchanged.
From docs/architecture/typescript.md:
- No
any. Not in app code, not in tests. Useunknownplus narrowing, or model the type properly. If a third-party type forces it, isolate it behind one typed adapter and comment why. - No non-null assertions (
!) to silence the compiler. - No
@ts-expect-errorwithout a comment explaining what is expected and when it can be removed. - Validate everything crossing a boundary (GW2 API responses, HTTP input) at runtime, not just at the type level.
- Prefer pure functions for domain logic. The optimizer must be testable without a network or a database.
- Match the style of surrounding code. No new dependency without justification in the spec or plan.
moduleResolution: "node"andbaseUrlare removed in TS 7. Use"nodenext"(or"bundler"for Vite/bundler-resolved code) pluspaths: { "*": ["./*"] }in place ofbaseUrl.- TypeScript's default
libincludes DOM. A base tsconfig shared by non-DOM and DOM profiles must pin a non-DOM floor explicitly ("lib": ["ES2023"]), ordocument/windowresolve everywhere, silently defeating profile isolation. A profile that needs DOM adds"DOM","DOM.Iterable"on top of that floor. experimentalDecoratorsandemitDecoratorMetadataare supported for typecheck only —tsc --noEmitaccepts decorator syntax and resolves metadata types, but--noEmitnever emitsdesign:paramtypesat runtime regardless of these options. A decorator-consuming runtime (e.g. NestJS DI) needs a separate emitting compiler for that metadata; seestack.md's api build model.
From docs/architecture/stack.md:
- Monorepo, pnpm workspaces.
apps/api— NestJS (TypeScript).apps/web— React (TypeScript).packages/*— shared code (domain types, the curated Mystic Forge dataset) when sharing is real, not speculative.- Postgres for persistence. In-memory cache for the MVP — no Redis until the caching story earns it.
- Vitest everywhere, both apps.
- Deploy: managed PaaS (Fly.io / Railway). CI: GitHub Actions — lint + typecheck + test + build.
- Static data (items, station recipes) is immutable: cache hard.
- Prices are volatile: short TTL, recomputed live.
- GW2 API rate limit: per-IP token bucket, 300 burst, refill 5/sec,
429on overflow. Batch up to 200 ids per?ids=call. - All GW2 API access goes through the client that budgets this. Never call the GW2 API directly from a service.
- API keys are user secrets: never logged, never persisted server-side, never returned to the client. In the MVP the key is held client-side — the browser's
localStorage— and sent per request asAuthorization: Bearer; the api forwards it to GW2 and stores nothing at rest. Encryption at rest applies only if/when server-side key storage is introduced; no such storage exists today. Client-sidelocalStorageis plaintext and readable by any script on the origin (XSS) — a deliberate MVP limitation, established by spec 016 (client-custody, revisit before adding write-scoped or higher-value keys).
From CLAUDE.md: typecheck clean, tests pass, every acceptance scenario and success criterion covered by a test whose name traces to it, no unexplained escape hatches, the human reviews the diff.
File Structure
Exact paths, and what each file is responsible for. A path here is a commitment; a task that touches a file not listed is a signal the plan missed something.
| Path | Change | Responsibility |
|---|---|---|
apps/web/src/api/staleTimes.ts | new | The STALE_TIME tier constants (reference, pricedStructure, perUser), the one place the numbers live. |
apps/web/src/api/ApiProvider.tsx | modified | Construct QueryClient with defaultOptions.queries.staleTime = 60 s floor. |
apps/web/src/api/useLegendaries.ts | modified | Pass STALE_TIME.reference. |
apps/web/src/api/useHealth.ts | modified | Pass STALE_TIME.reference. |
apps/web/src/api/useRecipeTree.ts | modified | Pass STALE_TIME.pricedStructure. |
apps/web/src/api/useAccount.ts | modified | Pass STALE_TIME.perUser. |
apps/web/src/api/useMaterials.ts | modified | Pass STALE_TIME.perUser. |
apps/web/src/api/useWallet.ts | modified | Pass STALE_TIME.perUser. |
apps/web/src/api/useLegendaryRanking.ts | modified | Pass STALE_TIME.perUser. |
apps/web/src/features/account/routes.tsx | modified | Lazy-wrap AccountPage. |
apps/web/src/features/assistant/routes.tsx | modified | Lazy-wrap AssistantPage. |
apps/web/src/features/health/routes.tsx | modified | Lazy-wrap HealthPage. |
apps/web/src/features/legendaries/routes.tsx | modified | Lazy-wrap LegendariesLayout, LegendariesPage, RankingPage, LegendaryDetailPage. |
apps/web/src/features/materials/routes.tsx | modified | Lazy-wrap MaterialsPage. |
apps/web/src/features/wallet/routes.tsx | modified | Lazy-wrap WalletPage. |
apps/web/vite.config.ts | modified | Add build.manifest: true so the chunk graph is inspectable. |
apps/web/src/api/__tests__/staleTimes.test.ts | new | SC4 — every tier is a finite number > 0; the QueryClient default is > 0. |
apps/web/src/api/__tests__/staleTimeBehavior.test.tsx | new | SC5 — a remount within staleTime fires 0 requests; a query past staleTime refetches; each hook resolves its tier's value. |
apps/web/src/__tests__/codeSplitting.test.ts | new | SC1/SC2 — build the app and assert each route page is its own chunk, absent from the entry chunk. |
Existing route/page tests under features/*/__tests__/ may need await findBy* where a now-lazy page suspends; those edits belong to the code-split task, not new files.
Data & contracts
None. No endpoint, DTO, OpenAPI document, or generated client changes (spec R6, "HTTP surface: none"). The only new local shape is the STALE_TIME constant object:
export const STALE_TIME = {
reference: 3_600_000,
pricedStructure: 60_000,
perUser: 300_000,
} as const;Test strategy
Every acceptance scenario and success criterion maps to a named test (spec traceability table).
- SC1 / SC2 (code-splitting) —
codeSplitting.test.tsruns a production build programmatically (Vite'sbuild()API to a tempoutDir,manifest: true) and asserts frommanifest.json: (a) each feature page module resolves to its own chunk, and (b) no page module appears in the entry chunk's staticimports. This is the faithful form of the criterion — it fails if a config change ever de-optimises chunking, which a source-only guard would not catch. It is one slower test (~1–2 s, a full build); if it proves flaky under CI resource limits, the fallback is a source-shape guard (eachroutes.tsxuseslazy(() => import(...))with no static page import) — noted here so the decision is visible, not silent. Research V3 already proved the mechanism emits separate chunks. - SC3 (guard not blinded) — the existing
conventions.test.tsP1 #4test keeps its synthetic violation-detection case and still scans the realroutes.tsxfiles. Because the routes keepelement: <XxxPage />, no change to that test is needed; the traceability entry points at it. The code-split task must confirm it stays green. - SC4 (no query at 0; tier values) —
staleTimes.test.tsasserts the three constants are finite and0, and that the
QueryClientdefaultstaleTimeis > 0. - SC5 (repeat-nav = 0 fetch; past-staleTime refetch; per-hook tier) —
staleTimeBehavior.test.tsxusesmswrequest counting with a testQueryClient: render a hook → unmount → remount withinstaleTime→ assert the handler was called once; then, with a tinystaleTime, remount after it elapses → assert a second call. A per-hook check readsqueryClient.getQueryCache().find(...)?.options.staleTimeto confirm each facade hook carries its tier's value. - SC6 (SSR verdict) — no unit test: it is a real-browser metric. Traced to
research.mdV1 plus the human's review of the recorded measurement at step 5, the same standing precedent as spec 030's SC3 ("jsdom has no layout"). - SC7 — CI:
pnpm typecheck,pnpm lint,pnpm test,pnpm build(React Compiler runs here — see Risks),pnpm docs:build. - SC8 — the traceability table itself.
Deliberately not tested: an absolute entry-bundle KB ceiling (research F1: the entry is vendor-dominated, so a KB threshold would be brittle and would not isolate route weight). Before/after entry-chunk sizes are recorded in research.md as evidence, not asserted.
Alternatives considered
- React Router
lazy:/Component:route props — rejected: both bypass theP1 #4route-naming guard (react.md, research V3), silently weakening a live convention.React.lazybehindelement: <XxxPage />keeps the guard reading aPage/Layoutname. - IndexedDB
persistQueryClient— cut in brainstorming (spec Out of scope): gw2.app uses HTTPCache-Control, not IndexedDB; Spec 031 supplies those headers;staleTimecovers in-session nav. It would add three dependencies for a marginal "no flash on hard reload" benefit. - A permanent Playwright/E2E perf gate in CI — rejected: only first-paint/TTI needs a real browser, and that is the one-off spike (research V1); every enforced criterion is vitest-testable, and a CI browser gate would be flaky on the free tier for no added coverage.
manualChunksvendor splitting — rejected (YAGNI): the entry is vendor-heavy (research F1), but vendor chunking is a separate effort the spec does not require.- Source-shape guard as the primary SC1 test — considered; kept only as the fallback because the build-manifest test is the faithful form of the criterion.
Risks
- Lazy pages change render timing — a page that used to render synchronously now suspends, so some existing
features/*/__tests__route/page tests may needawait findBy*instead ofgetBy*. Mitigation: fix those tests inside the code-split task; the App-shell<Suspense>fallback already exists, so behaviour is unchanged for users. - React Compiler build gate — the compiler runs only in
vite build(react.md, memory), so a lazy-wrapper pattern that trips it failspnpm build, neverpnpm test. Mitigation: the exact pattern was built clean in research V3; keeppnpm buildin the verification step and avoid destructured-default + inline-type props. - In-test programmatic build is slow / CI-resource sensitive — one ~1–2 s test. Mitigation: temp
outDir, single build; documented source-shape fallback if it flakes. staleTimemasks genuinely-changed data — e.g. account inventory during play. Mitigation: tiers are finite (spec R4); 5 min for per-user matches the server account cache; a hard refresh always refetches.
Open questions
None blocking. The staleTime values are adopted from research V2; the human may tune any tier at plan approval. The SC1 test mechanism (programmatic build vs source-shape guard) is decided here — build manifest primary, guard as fallback — and is reversible in tasks.md if the build test proves flaky.