feat: router + query + dual entry (browser/memory history) + MF expose - #14
Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
… 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.
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 Verified correct: no second React on the exposed path ( It also proved both new ACAO rules are load-bearing by removing them: without One real finding, now fixed in 5e87991
window.addEventListener('load', () => { void navigator.serviceWorker.register('/sw.js'); });
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 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 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
Three comments corrected rather than left overstating reality
Gate: 21 tests (was 9), all 9 check steps green, CI green on 5e87991. Filed as follow-ups, not blocking#15 root Separately, the reviewer surfaced a pre-existing |
Planned PR #4. Frontend infrastructure for every screen that follows: one route tree, two histories, and the remote contract
portfolio-shellwill 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/vite1.20.7. TypeScript stays on 6.0.3.One tree, two histories
web/src/router.tsxbuilds 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.tsxpassescreateBrowserHistoryand is still the only file that may ever register a service worker. It also wirescreateRoot'sonCaughtError/onUncaughtError— without them a failed mount is a blank page and a silent console.remote.tsxpassescreateMemoryHistoryand 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
createLazyFileRouteinside a plainplan.tsxbuilds fine, emits no warning, and is bundled EAGERLY. No separate chunk appears. Only the<route>.lazy.tsxfilename 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.tsis committed, and must stay committed.web/vitest.config.tsreplacesweb/vite.config.tsrather 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 watchingvitestfail 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/federationbuild, emitremoteEntry.jsand split the leaves.tanstackRouteris 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.0plus the scoped'react/'and'react-dom/'shares soreact/jsx-runtimeandreact-dom/clientresolve from the one instance,build.target: 'chrome89'for top-level await.Access-Control-Allow-Origin: *goes on/remoteEntry.jsand/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: trueis deliberately NOT set here. It belongs to Track 0's final step, and it cannot be tested untilportfolio-shellis on React 19.The
.ct-apprule is now mechanical, not a conventionStyles are split by mount:
styles/app.scssis imported fromroutes/__root.tsx(so both mounts get it from the single route tree) and contains **zero:root/bodyrules;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
:rootrule lives inindex-*.css, which is referenced solely bydist/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 needsstyle-srcas well asscript-srcandconnect-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/planwith 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, nopushState/replaceState,window.locationunchanged across a real navigation, and no un-namespacedlocalStoragekey. 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
localStoragepersistence — 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.webDependabot group ispatterns: ["*"], so two more packages mean weekly grouped-PR churn for no current benefit. Add them with the first real loader.queryClientis not in the router context — no route has a loader yet.App.tsxis deleted; its/api/healthprobe was absorbed into the dashboard route viauseQuery, because it is the only code path exercisingapiFetchresolving its base fromimport.meta.url— the exact thing that breaks in the federated mount.Verification
npm run checkgreen: 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 cleannpm ci(exit 0, no ERESOLVE, the jsx-a11y override still holds).npm run previewserves the real zero-unsafe-*CSP. The build was loaded in headless Chrome under those headers:/and a dynamically imported/planboth rendered with zero CSP violations, andCross-Origin-Resource-Policyis correctly absent. Static scan agrees — noeval,new Function,blob:or inline script/style anywhere in the output. No CSP change was needed.Left for the deploy, not claimable from here
curl -sIthe two new ACAO headers on/remoteEntry.jsand an/assets/*file, checking no header appears twice — this also closes CLAUDE.md's open item 9 about whether Vercel overwrites or appends./planand/no-such-page→text/html,/api/nopestill FastAPI JSON.@parcel/watcher, which prints anallow-scriptswarning onnpm ci. Warning only:npm ciexits 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