Skip to content

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:

tsx
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.

TierConstantValueQueries
referenceSTALE_TIME.reference3_600_000 (1 h)useLegendaries, useHealth
priced structureSTALE_TIME.pricedStructure60_000 (60 s)useRecipeTree
per-userSTALE_TIME.perUser300_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. Use unknown plus 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-error without 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" and baseUrl are removed in TS 7. Use "nodenext" (or "bundler" for Vite/bundler-resolved code) plus paths: { "*": ["./*"] } in place of baseUrl.
  • TypeScript's default lib includes DOM. A base tsconfig shared by non-DOM and DOM profiles must pin a non-DOM floor explicitly ("lib": ["ES2023"]), or document/window resolve everywhere, silently defeating profile isolation. A profile that needs DOM adds "DOM", "DOM.Iterable" on top of that floor.
  • experimentalDecorators and emitDecoratorMetadata are supported for typecheck only — tsc --noEmit accepts decorator syntax and resolves metadata types, but --noEmit never emits design:paramtypes at runtime regardless of these options. A decorator-consuming runtime (e.g. NestJS DI) needs a separate emitting compiler for that metadata; see stack.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, 429 on 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 as Authorization: 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-side localStorage is 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.

PathChangeResponsibility
apps/web/src/api/staleTimes.tsnewThe STALE_TIME tier constants (reference, pricedStructure, perUser), the one place the numbers live.
apps/web/src/api/ApiProvider.tsxmodifiedConstruct QueryClient with defaultOptions.queries.staleTime = 60 s floor.
apps/web/src/api/useLegendaries.tsmodifiedPass STALE_TIME.reference.
apps/web/src/api/useHealth.tsmodifiedPass STALE_TIME.reference.
apps/web/src/api/useRecipeTree.tsmodifiedPass STALE_TIME.pricedStructure.
apps/web/src/api/useAccount.tsmodifiedPass STALE_TIME.perUser.
apps/web/src/api/useMaterials.tsmodifiedPass STALE_TIME.perUser.
apps/web/src/api/useWallet.tsmodifiedPass STALE_TIME.perUser.
apps/web/src/api/useLegendaryRanking.tsmodifiedPass STALE_TIME.perUser.
apps/web/src/features/account/routes.tsxmodifiedLazy-wrap AccountPage.
apps/web/src/features/assistant/routes.tsxmodifiedLazy-wrap AssistantPage.
apps/web/src/features/health/routes.tsxmodifiedLazy-wrap HealthPage.
apps/web/src/features/legendaries/routes.tsxmodifiedLazy-wrap LegendariesLayout, LegendariesPage, RankingPage, LegendaryDetailPage.
apps/web/src/features/materials/routes.tsxmodifiedLazy-wrap MaterialsPage.
apps/web/src/features/wallet/routes.tsxmodifiedLazy-wrap WalletPage.
apps/web/vite.config.tsmodifiedAdd build.manifest: true so the chunk graph is inspectable.
apps/web/src/api/__tests__/staleTimes.test.tsnewSC4 — every tier is a finite number > 0; the QueryClient default is > 0.
apps/web/src/api/__tests__/staleTimeBehavior.test.tsxnewSC5 — 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.tsnewSC1/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:

ts
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.ts runs a production build programmatically (Vite's build() API to a temp outDir, manifest: true) and asserts from manifest.json: (a) each feature page module resolves to its own chunk, and (b) no page module appears in the entry chunk's static imports. 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 (each routes.tsx uses lazy(() => 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.ts P1 #4 test keeps its synthetic violation-detection case and still scans the real routes.tsx files. Because the routes keep element: <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.ts asserts the three constants are finite and

    0, and that the QueryClient default staleTime is > 0.

  • SC5 (repeat-nav = 0 fetch; past-staleTime refetch; per-hook tier) — staleTimeBehavior.test.tsx uses msw request counting with a test QueryClient: render a hook → unmount → remount within staleTime → assert the handler was called once; then, with a tiny staleTime, remount after it elapses → assert a second call. A per-hook check reads queryClient.getQueryCache().find(...)?.options.staleTime to confirm each facade hook carries its tier's value.
  • SC6 (SSR verdict) — no unit test: it is a real-browser metric. Traced to research.md V1 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 the P1 #4 route-naming guard (react.md, research V3), silently weakening a live convention. React.lazy behind element: <XxxPage /> keeps the guard reading a Page/Layout name.
  • IndexedDB persistQueryClient — cut in brainstorming (spec Out of scope): gw2.app uses HTTP Cache-Control, not IndexedDB; Spec 031 supplies those headers; staleTime covers 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.
  • manualChunks vendor 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 need await findBy* instead of getBy*. 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 fails pnpm build, never pnpm test. Mitigation: the exact pattern was built clean in research V3; keep pnpm build in 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.
  • staleTime masks 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.