Skip to content

Tasks 033 — Frontend performance: route code-splitting and staleTime ​

Execution skill: superpowers:subagent-driven-development — one implementer per task, then a two-stage review (spec compliance, then code quality). superpowers:test-driven-development applies inside every task: no production code before a failing test that demands it. Reach for superpowers:systematic-debugging on any surprise rather than guessing.

Derived from plan.md (approved). Each task is small, independently verifiable, and reviewed as its own diff. Split where a reviewer could reject one task while approving its neighbour — not where the work merely changes subject. A task is done only when it satisfies the definition of done in CLAUDE.md.

Global Constraints in plan.md apply to every task and are not repeated per task. Two are load-bearing here: no new dependency (everything uses already-installed React/react-router/TanStack Query/Vite), and the React Compiler runs only in vite build — so every task's verification includes pnpm build, not just pnpm test, and lazy wrappers avoid destructured-default + inline-type props.


T1 — Each query carries a staleTime matched to its volatility tier ​

Satisfies: R3, R4, P2 #1, P2 #2, P2 #3, SC4, SC5.

  • [ ] RED — write apps/web/src/api/__tests__/staleTimes.test.ts: assert STALE_TIME.reference=== 3_600_000, STALE_TIME.pricedStructure === 60_000, STALE_TIME.perUser === 300_000 (each a finite number > 0), and that a new QueryClient(queryClientConfig)'s resolved default queries.staleTime is > 0. Run it; watch it fail because the module does not exist yet.

  • [ ] GREEN — create apps/web/src/api/staleTimes.ts:

    ```ts
    export const STALE_TIME = {
      reference: 3_600_000,
      pricedStructure: 60_000,
      perUser: 300_000,
    } as const;
    ```
    
    In `apps/web/src/api/ApiProvider.tsx`, construct the client with a non-zero default floor —
    `new QueryClient({ defaultOptions: { queries: { staleTime: 60_000 } } })` — keeping it the one
    place the client is built (R11). Run the test; it passes.
    
  • [ ] RED — write apps/web/src/api/__tests__/staleTimeBehavior.test.tsx (msw + Testing Library): (a) render a facade hook inside a QueryClientProvider, unmount, remount within its staleTime, and assert the msw handler fired once (SC5 no-refetch); (b) with a test client whose staleTime is tiny, remount after it elapses and assert a second call (P2 #3 staleness still fires); (c) for each of the seven hooks, read queryClient.getQueryCache().find({ queryKey })?.options.staleTime and assert it equals the tier from the table below. Run it; watch it fail (hooks pass no staleTime yet).

  • [ ] GREEN — add staleTime to each facade hook's useSuspenseQuery options (through the existing suspenseOptions facade), per tier: useLegendaries + useHealth → STALE_TIME.reference; useRecipeTree → STALE_TIME.pricedStructure; useAccount + useMaterials + useWallet + useLegendaryRanking → STALE_TIME.perUser. Run the test; it passes.

  • [ ] REFACTOR — only with tests green: confirm no magic number leaked (every value comes from STALE_TIME or the QueryClient default), and no hook was left at 0.

  • [ ] Teeth — set one per-user hook's staleTime back to 0; watch the SC5 no-refetch case fail; restore.

  • [ ] Verify + commit — pnpm typecheck && pnpm test && pnpm build; commit (web: staleTime tiers, no query left at 0).

Verified by: staleTimes.test.ts (SC4: every tier is a finite staleTime > 0; the QueryClient default is > 0) and staleTimeBehavior.test.tsx (SC5: a remount within staleTime fires no request, P2 #3: a query past its staleTime refetches, P2 #2: each hook resolves its tier's staleTime).


T2 — Route pages ship as their own chunks, absent from the entry bundle ​

Satisfies: R1, R2, P1 #1, P1 #2, P1 #3, SC1, SC2, SC3.

  • [ ] RED — write apps/web/src/__tests__/codeSplitting.test.ts: run a production build with Vite's programmatic API (build() with root = apps/web, build: { manifest: true, outDir = a temp dir }, VITE_APP_ENV=prod), read .vite/manifest.json, and assert (a) each feature page — AccountPage, AssistantPage, HealthPage, LegendariesLayout, LegendariesPage, RankingPage, LegendaryDetailPage, MaterialsPage, WalletPage — resolves to its own chunk, and (b) the entry chunk's static imports contain none of those page modules. Run it; watch it fail (today everything is one chunk, and the manifest is not emitted). If the in-test build proves flaky under CI limits, fall back to the source-shape guard named in plan.md — do not silently drop the assertion.

  • [ ] GREEN (build config) — add build: { manifest: true } to apps/web/vite.config.ts.

  • [ ] GREEN (lazy routes) — rewrite each apps/web/src/features/*/routes.tsx to load its page(s) via React.lazy, keeping the element: <XxxPage /> shape so the P1 #4 guard still reads a Page/Layout name (research V3). Pattern, per page:

    ```tsx
    const XxxPage = lazy(() =>
      import('./XxxPage').then((m) => ({ default: m.XxxPage })),
    );
    ```
    
    Apply to `account`, `assistant`, `health`, `materials`, `wallet`, and all four
    `legendaries` components (`LegendariesLayout`, `LegendariesPage`, `RankingPage`,
    `LegendaryDetailPage`). No router `lazy:`/`Component:` props (they bypass the guard). No new
    `<Suspense>` — the pages suspend into the one in `App.tsx`. Run `codeSplitting.test.ts`; it passes.
    
  • [ ] GREEN (suspense-aware tests) — a now-lazy page suspends on first render, so update the existing apps/web/src/features/*/__tests__ route/page tests that assumed synchronous render to await findBy* / await screen.findBy*. Run pnpm test; all green.

  • [ ] Teeth — revert one route to a static import { XxxPage } from './XxxPage' + element: <XxxPage />; watch codeSplitting.test.ts fail (that page rejoins the entry chunk); restore the lazy form.

  • [ ] Verify SC3 + build — run conventions.test.ts: its P1 #4 case still passes and still catches a synthetic violation (unchanged — the routes kept element: <XxxPage />). Run pnpm build so the React Compiler sees the lazy wrappers. Commit (web: route-level code-splitting via React.lazy).

Verified by: codeSplitting.test.ts (SC1: no route page module is in the entry chunk, SC2: each route page is its own on-demand chunk) and the unchanged conventions.test.ts (P1 #4: every route element is <Name>Page/<Name>Layout, still catching a violation → SC3).


T3 — Verify the full gate, trace, and hand off ​

Satisfies: SC7, SC8 (and closes SC6 from research).

  • [ ] Run the whole gate: pnpm typecheck, pnpm lint, pnpm test, pnpm build, pnpm docs:build — all green (SC7). Fix anything red before proceeding; do not weaken a test to pass.
  • [ ] Fill the traceability table in spec.md with the actual test names from T1/T2 (transcription, not excavation), and confirm SC6 points at research.md V1 + the human's review of the recorded first-paint measurement (no code test — a real-browser metric).
  • [ ] Update research.md's Graduation candidates as landed (the warm-origin baseline → docs/gaps/; the staleTime tier table → docs/architecture/react.md) at step 6, or note them explicitly as deferred.
  • [ ] Hand off for review (superpowers:requesting-code-review). The human reviews the diff and, before merge, transcribes spec.md status approved → implemented inside the PR diff (the agent never sets it unbidden).

Verified by: the five gate commands all exit green (SC7); the spec.md traceability table names a real test for every acceptance scenario and success criterion (SC8).


Notes ​

Staging area for decisions and surprises found during implementation — including anything that turned out differently from what plan.md assumed. Move each one into spec.md, research.md, or docs/ before closing the feature; this section is not a home.

  • Watch the entry-chunk before/after in codeSplitting's build output and record the delta in research.md (evidence for SC1; no absolute-KB assertion — the entry is vendor-dominated, research F1).
  • If a facade hook's queryKey is not stable enough to find() in staleTimeBehavior.test.tsx (the per-user hooks hash the API key), assert against the key the hook actually builds rather than a guessed one.