Skip to content

feat: router + query + dual entry (browser/memory history) + MF expose - #14

Merged
kilianmc merged 2 commits into
devfrom
feat/router-query-dual-entry
Aug 14, 2026
Merged

kilianmc merged 2 commits into
devfrom
feat/router-query-dual-entry

Conversation

@kilianmc

Copy link
Copy Markdown
Owner

Planned PR #4. Frontend infrastructure for every screen that follows: one route tree, two histories, and the remote contract portfolio-shell will consume in PR #5. Every route body is a deliberate placeholder — what is being reviewed is the wiring, not the UI. Design system is PR #7.

Versions were read from the npm registry in this session, not recalled: router 1.170.27, router-plugin 1.168.30, react-query 5.101.4 (no v6 exists), @module-federation/vite 1.20.7. TypeScript stays on 6.0.3.

One tree, two histories

web/src/router.tsx builds the router from a passed-in history. The history is the only difference between the mounts, so every other default lives in that one factory and cannot drift: defaultPreload: 'intent', defaultPreloadStaleTime: 0 (Query owns staleness — raising it gives the router a second cache with its own expiry, and the two disagree), pendingMs: 300 / pendingMinMs: 500.

main.tsx passes createBrowserHistory and is still the only file that may ever register a service worker. It also wires createRoot's onCaughtError/onUncaughtError — without them a failed mount is a blank page and a silent console.

remote.tsx passes createMemoryHistory and default-exports the component. Router and QueryClient are created per mount instance, not per module, so location and cache do not survive a shell navigation away and back.

Query retries skip 4xx and NotJsonError: both are unwinnable, and every retry is another Neon wake-up.

Two findings worth more than the diff

createLazyFileRoute inside a plain plan.tsx builds fine, emits no warning, and is bundled EAGERLY. No separate chunk appears. Only the <route>.lazy.tsx filename makes the generator emit .lazy(() => import(…)). Renaming one of those four files would silently delete its code-splitting, with a green gate. Verified in a throwaway sandbox before touching this repo; all 4 leaves confirmed as separate chunks in the real build.

routeTree.gen.ts is committed, and must stay committed. web/vitest.config.ts replaces web/vite.config.ts rather than merging it (already documented in CLAUDE.md), so the router plugin never runs under Vitest and cannot regenerate the tree. Proven by deleting it and watching vitest fail with no regeneration — and the file is deterministic, so a rebuild produces it byte-identically. It is excluded from ESLint and Prettier because it ships without semicolons.

Plugin ordering, by contrast, turned out not to be sensitive: all four orderings of tanstackRouter / react / federation build, emit remoteEntry.js and split the leaves. tanstackRouter is listed first as the documented order, not a required one.

Federation contract

name: climbTrainer, filename: 'remoteEntry.js', exposes: { './App': './src/remote.tsx' }, dts: false, react/react-dom singletons at ^19.0.0 plus the scoped 'react/' and 'react-dom/' shares so react/jsx-runtime and react-dom/client resolve from the one instance, build.target: 'chrome89' for top-level await.

Access-Control-Allow-Origin: * goes on /remoteEntry.js and /assets/* only — the MF chunks load as ES modules, which are always CORS-mode. /api/* keeps its allowlist; a wildcard there would let any site read authenticated responses.

strictVersion: true is deliberately NOT set here. It belongs to Track 0's final step, and it cannot be tested until portfolio-shell is on React 19.

The .ct-app rule is now mechanical, not a convention

Styles are split by mount: styles/app.scss is imported from routes/__root.tsx (so both mounts get it from the single route tree) and contains **zero :root/body rules; styles/global.scssis imported only frommain.tsx` and holds the document reset that must never reach the shell. Safe-area insets moved across intact.

Verified in the build output: the only :root rule lives in index-*.css, which is referenced solely by dist/index.html. The remote's own stylesheet is clean, so nothing can restyle kilianmc.com through the federated mount.

This also corrected a gap in CLAUDE.md's PR #5 note: the exposed chunk references its own stylesheet, so MF injects a cross-origin <link>. The shell's future CSP therefore needs style-src as well as script-src and connect-src — and that is the reason /assets/* needs ACAO, not just /remoteEntry.js.

Tests

Per the testing policy — the placeholder bodies and nav markup are not tested, because a test would only restate them.

  • router.test.tsx — the factory under memory history: renders /, a lazy hop to /plan with the nav surviving, and an unmatched path landing on the catch-all. Memory history is what makes these nearly free, and it is the same history the remote runs on.
  • remote.guard.test.tsx — the three federated-mount rules asserted at runtime: no service worker registered, no pushState/replaceState, window.location unchanged across a real navigation, and no un-namespaced localStorage key. Spies rather than a source scan, because the realistic regression is PR chore: register the Dependabot config on the default branch #7 putting SW registration in a module both entries import — only a runtime check sees that. Low likelihood, severe blast radius (it would intercept the live portfolio's requests), and nothing in the type system or a lint rule catches it.

Not in scope, on purpose

  • No query-cache localStorage persistence — it needs the demo-scope exclusion, which needs auth state that does not exist until PR chore: upgrade FastAPI/Starlette, add security response headers #6. Lands in PR feat: router + query + dual entry (browser/memory history) + MF expose #14 rather than shipping a knowingly incomplete guard.
  • No devtools — nothing to inspect yet (no loaders, one query), and the web Dependabot group is patterns: ["*"], so two more packages mean weekly grouped-PR churn for no current benefit. Add them with the first real loader.
  • queryClient is not in the router context — no route has a loader yet.
  • App.tsx is deleted; its /api/health probe was absorbed into the dashboard route via useQuery, because it is the only code path exercising apiFetch resolving its base from import.meta.url — the exact thing that breaks in the federated mount.

Verification

  • npm run check green: format, lint, typecheck, 9 web tests (5 new), build, ruff × 2, mypy (33 files), 80 pytest. Run once at the end of the batch, and again after a clean npm ci (exit 0, no ERESOLVE, the jsx-a11y override still holds).
  • npm run preview serves the real zero-unsafe-* CSP. The build was loaded in headless Chrome under those headers: / and a dynamically imported /plan both rendered with zero CSP violations, and Cross-Origin-Resource-Policy is correctly absent. Static scan agrees — no eval, new Function, blob: or inline script/style anywhere in the output. No CSP change was needed.
  • Local dev signed off by @kilianmc in Firefox as well as my Chrome check: navigation, lazy hops, deep links and the 404 all behave.

Left for the deploy, not claimable from here

  • curl -sI the two new ACAO headers on /remoteEntry.js and an /assets/* file, checking no header appears twice — this also closes CLAUDE.md's open item 9 about whether Vercel overwrites or appends.
  • Deep links on the real rewrite: /plan and /no-such-page → text/html, /api/nope still FastAPI JSON.
  • The actual cross-origin mount is untestable from this repo — it needs Track 0 (React 19 in the shell) and then PR chore: stop the migrate job printing the Neon endpoint (main-side twin) #5.
  • The router plugin pulls in @parcel/watcher, which prints an allow-scripts warning on npm ci. Warning only: npm ci exits 0 and the lockfile carries all six Linux prebuilds, so no native compile is needed. This PR's own preview build is the real test.

Opens 1.6.0.

🤖 Generated with Claude Code

Frontend infrastructure for every screen that follows. One route tree, two
histories, and the remote contract the shell will consume in PR #5. Every route
body is a placeholder; what this PR establishes is the wiring.

- web/src/router.tsx — createAppRouter(history) + createQueryClient(). The
  history is the ONLY difference between the two mounts, so every other default
  lives here and cannot drift. defaultPreload: 'intent',
  defaultPreloadStaleTime: 0 (Query owns staleness; a second cache with its own
  expiry would disagree with it), pendingMs 300 / pendingMinMs 500.
- web/src/main.tsx — standalone: browser history, StrictMode, and createRoot's
  onCaughtError/onUncaughtError so a failed mount is not a blank page and a
  silent console. Still the only file that may ever register a service worker.
- web/src/remote.tsx — federated: createMemoryHistory, default-exported
  component, router and QueryClient per mount instance rather than per module so
  state does not survive shell navigations. No StrictMode (the host owns that).
- web/src/routes/ — __root (the one .ct-app element), index, login, four lazy
  leaves, and a catch-all. routeTree.gen.ts is committed deliberately.
- vite.config.ts — @tanstack/router-plugin + @module-federation/vite exposing
  './App' as climbTrainer, react/react-dom singletons at ^19.0.0 plus the scoped
  'react/' and 'react-dom/' shares, build.target chrome89 for top-level await.
- vercel.json — Access-Control-Allow-Origin: * on /remoteEntry.js and /assets/*
  only. The MF chunks load as modules, which are always CORS-mode; /api/* keeps
  its allowlist.
- styles split by mount: app.scss from __root.tsx (both mounts, zero :root
  rules), global.scss from main.tsx (the document reset). This is what makes the
  .ct-app rule mechanical instead of a convention.

Two things learned that would otherwise be re-derived, both in CLAUDE.md:

createLazyFileRoute inside a plain plan.tsx builds, warns nothing and bundles
EAGERLY. Only the <route>.lazy.tsx filename makes the generator emit a dynamic
import, so renaming one of those files silently deletes its code-splitting.

routeTree.gen.ts must stay committed: vitest.config.ts replaces vite.config.ts
rather than merging it, so the router plugin never runs under Vitest and cannot
regenerate the tree. Verified by deleting it and watching vitest fail.

Tests cover the router factory under memory history (render, a lazy hop, the
catch-all) and guard the three federated-mount rules at runtime — no service
worker, no history mutation, no un-namespaced localStorage. The placeholder
bodies are untested on purpose, per the testing policy.

No query-cache persistence: it needs the demo-scope exclusion, which needs auth
state that does not exist until PR #6. It lands in PR #14.

Opens 1.6.0.

Co-authored-by: Kilian Mateo <13885240+kilianmc@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 14, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
climb-trainer Ready Ready Preview Aug 14, 2026 12:19pm

… violated

remote.guard.test.tsx went green against the most likely PR #7 regression.
Three jsdom facts caused it, each confirmed by probe:

- document.readyState is already 'complete' when a test runs, so a listener
  added during render never fires. virtual:pwa-register (vite-plugin-pwa)
  registers from exactly such a window 'load' listener. Now dispatched.
- localStorage.foo = 'x' writes the value while bypassing
  Storage.prototype.setItem, so the spy missed it. Now asserts on
  Object.keys(localStorage).
- clear() and removeItem() were not spied at all, and leave no trace in the
  final state. A remote calling clear() wipes the portfolio's storage.

Module-scope side effects also ran before any spy existed, so the entry is
imported dynamically with resetModules per test. Adds a positive control
asserting each detector can see its own violation, since every storage
assertion otherwise passes on an empty set.

Converts two documented "don't touch this" rules into assertions:

- mf-contract.test.ts: vercel.json must keep ACAO on /remoteEntry.js and
  /assets/*, and nowhere else. Both are load-bearing off-repo — remoteEntry.js
  statically imports /assets/virtual_mf-REMOTE_ENTRY_ID*.js, and the exposed
  chunk preloads its own CSS — so deleting either kills the federated mount
  with a fully green gate.
- routeTree.lazy.test.ts: the four heavy leaves must be code-split. Renaming
  plan.lazy.tsx to plan.tsx passes lint, typecheck, test AND build while
  emitting no separate chunk.

Two comments corrected rather than left to become ground truth:

- build.target 'chrome89' removed. Vite 8's default is already chrome111+ and
  keeps MF's top-level await; the pin only lowered the baseline (it also
  forced lightningcss color-scheme fallbacks).
- the nav's "44px touch targets" claim was false on three of six links
  (31.1/37.7/43.8px wide). min-inline-size added; measured 44x44 in Chrome at
  320px and 390px, row gap 4px -> 8px.
@kilianmc

Copy link
Copy Markdown
Owner Author

Adversarial review outcome (independent reviewer, separate worktree)

Tier-1 review per the standing rule for anything touching the MF contract. The reviewer ran the gate itself, served the built app on one origin, built a fake shell document on another, and loaded the real remoteEntry.js in Chrome rather than reasoning from the config.

Verified correct: no second React on the exposed path (react-dom is reachable only from the standalone entry, and the scoped 'react/' shares are what put react/jsx-runtime in the share map at all); every selector in the exposed stylesheet rooted at .ct-app, checked against live CSSOM in a foreign document with @media recursion; zero CSP violations under the real zero-unsafe-* headers; the four lazy leaves split with nothing pulling them in eagerly; remoteEntry.js landing at the path the new vercel.json rule matches; and every factual claim added to CLAUDE.md true.

It also proved both new ACAO rules are load-bearing by removing them: without /assets/*, import() of the remote entry rejects with TypeError: Failed to fetch dynamically imported module; with ACAO on everything except .css, get('./App') rejects with Unable to preload CSS.

One real finding, now fixed in 5e87991

remote.guard.test.tsx was green while all three rules it claims to enforce were violated. It caught a browser history and a useEffect registration correctly, but went green on:

window.addEventListener('load', () => { void navigator.serviceWorker.register('/sw.js'); });

document.readyState is already complete under jsdom when render() runs, so that listener never fires — and it is the shape vite-plugin-pwa's virtual:pwa-register emits, i.e. the single most likely PR #7 regression. Two further holes: property assignment (localStorage.theme = 'x') bypasses the setItem spy, and clear()/removeItem() weren't spied at all — a remote calling localStorage.clear() wipes the portfolio's storage on kilianmc.com.

Hardening it surfaced a fourth hole neither the reviewer nor the original test isolated: violations at module scope execute when the test file's static imports are hoisted, before beforeEach installs any spy. Fixed with vi.resetModules() + await import('./remote') per test, so module evaluation happens inside the observation window. Storage is now watched on all four mutation paths, since they fail differently — Object.keys for final state, the setItem spy for a write later removed, and clear/removeItem for destruction, which leaves no trace in final state at all.

A positive control was added asserting each detector sees its own violation, because the storage test had been passing on an empty set and would have looked identical with a mis-wired spy. That control was itself checked by sabotaging the harness two ways. Same failure class as the vacuous route-enumeration test in CLAUDE.md's FastAPI 0.137 note: a guard that cannot fail reads as coverage.

All violations re-verified: the four that were GREEN are now RED; the two that were already RED stay RED.

Two rules made self-enforcing

  • web/src/mf-contract.test.ts — asserts both ACAO sources carry the wildcard and that it appears on those two sources and nowhere else, which also catches widening onto /(.*). Placed web-side deliberately: tests/test_security_headers.py documents a decision not to assert vercel.json because it would restate config, and these two rules are the exception that reasoning allows — they are the MF contract, and the only header rules whose removal breaks a consumer outside this repo, silently.
  • web/src/routeTree.lazy.test.ts — asserts router state rather than build output, because vitest runs before build, so a dist-reading test would skip itself on a clean checkout and be vacuous in exactly the way this round is about. Verified end to end against the real trap: renaming plan.lazy.tsx → plan.tsx keeps the build green and emits no separate chunk, and the assertion goes red.

Three comments corrected rather than left overstating reality

  • build.target: 'chrome89' dropped entirely. Its stated reason (needed for top-level await) is false for Vite 8, whose default baseline-widely-available target already supports TLA — the pin only lowered our baseline. Parity with ai-portfolio-project1 isn't meaningful, since that repo predates the Vite 8 default. Side benefit: global CSS 0.18 kB → 0.04 kB, because chrome89 was forcing lightningcss to emit color-scheme fallbacks.
  • Touch targets made true instead of softened — min-inline-size: 44px + row-gap 4px → 8px. Re-measured against built CSS and rendered markup at 320px and 390px: all six links now 44×44 minimum (the three flagged went 31.1 / 37.7 / 43.8 → 44.0).
  • /assets/* ACAO rationale now records the static-import chain out of remoteEntry.js first, and states explicitly that dropping the stylesheet would not make the rule unnecessary.

Gate: 21 tests (was 9), all 9 check steps green, CI green on 5e87991.

Filed as follow-ups, not blocking

#15 root errorComponent renders outside .ct-app · #16 memory-history <Link> hrefs resolve against the shell's origin (a PR #5 decision) · #17 CI check for a stale routeTree.gen.ts.

Separately, the reviewer surfaced a pre-existing dev defect this PR did not touch: POST /api/auth/login returns 500 instead of 422 with no database, because DbSession resolves before body validation — so npm run check is red on a fresh clone while CLAUDE.md promises the opposite. Taking it as its own PR.

@kilianmc
kilianmc merged commit f5ad556 into dev Aug 14, 2026
5 checks passed
@kilianmc
kilianmc deleted the feat/router-query-dual-entry branch August 14, 2026 12:23

This branch was successfully deployed

1 active deployment
Preview — 5e879910 Deployed Aug 14, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant