diff --git a/svelte-big-bang.md b/svelte-big-bang.md new file mode 100644 index 000000000..d141c9025 --- /dev/null +++ b/svelte-big-bang.md @@ -0,0 +1,294 @@ +# Svelte Big Bang + +Migration plan for sendou.ink: React Router 8 → Svelte 5 + SvelteKit 3, monorepo, remote functions, one cutover. From the user's point of view, nothing happens. That's the goal. + +> Version note: the target is **Svelte 5 (runes) + SvelteKit 3** (currently release candidate, stable expected shortly — no further breaking changes planned). Starting greenfield on 3 means we never do a Kit 2→3 migration mid-project. Kit 3 details that help us: it requires **Vite 8** (the repo is already on Vite 8), config lives in `vite.config.ts`, `$lib` becomes `#lib` via Node subpath imports (which composes cleanly with monorepo package exports), and error handling is rebuilt on Svelte 5 boundaries. **Remote functions are still experimental behind a flag even in v3** — we pin versions and wrap the API (see Risks). + +--- + +## What we're actually migrating + +Measured from the repo today: + +| Surface | Size | +|---|---| +| Feature folders | 70 | +| Routes in `routes.ts` | 170 | +| TSX (the real work) | ~95k lines | +| Plain TS (mostly ports verbatim) | ~166k lines | +| Files exporting a `loader` | 133 | +| Files exporting an `action` | 81 | +| Shared components | 108 in `app/components` + 53 in `app/components/ui` | +| `react-aria-components` usage | 44 files | +| Playwright e2e specs | 44 | +| `useState` / `useEffect` / `createContext` | 162 / 61 / 9 | +| `` usages (the annoying i18n case) | 12 | +| dnd-kit / flip-toolkit / chart.js files | 12 / 5 / 3 | +| map-planner (stays behind) | ~1k lines TSX + tldraw | + +The headline: **almost two thirds of the codebase is plain TS** — repositories, core logic, schemas, utils, the scanner. That code moves across nearly untouched. The migration is really ~95k lines of TSX plus the router/data wiring, guided by 44 e2e specs that already encode what the site must do. + +Two free wins found while auditing: the 5 `react-flip-toolkit` files map to Svelte's built-in `animate:flip`, and `swr` (used via `swr/immutable` in one hooks file — 3 hooks, 3 call sites, lazily fetching resource-route JSON) is exactly what remote `query()` does natively, per-args caching included. That file even carries a TODO wishing for a better data-fetching primitive; remote functions are it — the dependency disappears. + +--- + +## The three load-bearing ideas + +Everything else in this plan hangs off these. + +### 1. Big bang at deploy time, incremental on `main` + +The monorepo hosts **both apps side by side**: `apps/web-react` (current app, feature-frozen, stays the deployed one) and `apps/web` (SvelteKit, growing). Both consume the same extracted packages. Every migration commit lands on `main` and CI stays green the whole time — no six-month mega-branch to rebase. "Big bang" happens only at cutover: the deploy target flips, and the React app remains deployable for instant rollback. + +This also makes the React app a permanent, in-repo **oracle** — which enables idea 2. + +### 2. Differential testing instead of baseline management + +We don't hand-curate visual regression baselines. We run **both apps against the same seeded database with a frozen clock** and diff them directly: + +- **Pixel diff** — screenshot every route in a generated census (route × theme × viewport), React vs Svelte, strict threshold. +- **Structure diff (advisory)** — Playwright ARIA snapshots on both apps, but as a report to eyeball, **not** a 1:1 gate: the handwritten components are *supposed* to have different internals than react-aria (a react-aria modal becomes a plain ``). The diff exists to catch accidents — a table that became divs, a button that became a span — not to demand parity. +- **SSR HTML diff** — curl every route on both servers, normalize, diff `` (meta, og tags, hreflang, inlined i18n). Protects SEO through the cutover. + +Because the oracle lives in the repo and the seed is deterministic, baselines are *reproduced*, never stored or bit-rotted. A feature is "done" when its e2e specs pass against the Svelte app **and** its differential diffs are clean. The prerequisite is determinism: seed relative to a `SEED_NOW` env var, freeze the client clock via Playwright's clock API, disable animations under test. + +### 3. A migration manifest drives the fleet + +A checked-in `migration-manifest.json` is the single source of truth: every file/route/feature with a status (`pending → scaffolded → migrated → verified`), which codemod produced it, and its verification results. All codemods are idempotent against it. It is simultaneously the Opus work queue, the progress dashboard, and the cutover checklist — cutover is legal only when every row reads `verified`. + +--- + +## Target monorepo shape + +``` +apps/ + web/ SvelteKit 3 + Svelte 5 (adapter-node, custom server for cron) + src/lib/ + features// feature folders, same layout as today (routes glue, components, repositories, *.remote.ts) + db/ kysely dialect, tables.ts, migrations + i18n/ locales/ (translator-facing JSONs) + project.inlang + generated paraglide messages + modules/ search-params, permissions, in-game-lists, … + web-react/ current app, frozen; the oracle; deleted after the final planner split + planner/ map-planner + tldraw as-is (React), → planner.sendou.ink; carved out of web-react in the final phase +packages/ + components/ the UI kit: Button, Dialog, Select, VirtualList, use:sortable, … + tournament-engine/ self-contained bracket/standings/progression logic + … more only when a block earns it +tooling/ + codemods/ ts-morph transforms (below) + differ/ differential test harness +``` + +Notes on the shape: + +- **Workspaces are reserved for things with a real boundary**: the UI kit (developed against the showcase, testable standalone, knows nothing about sendou.ink) and self-contained blocks like `tournament-engine` — pure logic that could ship as its own library: no db imports, no i18n, no app state. Candidates beyond the engine: `map-list-generator`, `in-game-lists`, the scanner's detection core. A block gets extracted only when its boundary is *already* clean — extraction is a promotion, not a project. +- **Features are plain folders inside `apps/web`**, same as today — they keep importing each other freely, repositories stay inside their feature folder, and there is no cycle-breaking project because there are no forced package boundaries between features. +- **Route files are thin shells.** We use SvelteKit's folder routing, but a `+page.svelte` carries no logic of its own: it composes a few components imported from `lib/features//` and wires in the feature's remote functions — **~100 lines max per route component**. Everything real (components, remote functions, repositories, utils) lives in the feature folder, exactly as `routes.ts` + feature folders work today; only the wiring moved into the filesystem. The `route-map` codemod generates route files in this shape, and a lint check keeps them thin so the fleet can't quietly grow logic into `src/routes/`. +- **db and i18n live inside `apps/web`** — single consumer, no workspace overhead. During the migration window `apps/web-react` keeps its own frozen copies; drift is impossible by policy because the schema doesn't change during the migration (a boring migration needs no new tables) and locale JSONs are shared by both apps read-only. + +--- + +## How the stack maps + +### Data layer: loaders/actions → remote functions + +This is the part of the codebase best positioned for the migration: + +- Loaders already live in dedicated `loaders/` files → each becomes a `query()` in `.remote.ts`. Zod 4 implements Standard Schema, so **existing zod schemas plug directly into remote function validation**. +- Actions already use `_action`-discriminated zod unions → the codemod **splits each union branch into its own `form()`/`command()`**. This is the one place the code gets structurally *better* for free, while staying boring externally. +- `` (typed `_action` + hidden inputs) → a button wired to a remote `command` or a remote form's button props — same type-safety guarantee, less machinery. +- `SendouForm` (`app/form/`) → `SendouForm.svelte` wrapping a remote `form`: same schema-driven field API on top, remote-function plumbing underneath. **This wrapper is the churn insulation** — when the experimental API moves, we fix one file, not 81. +- `requireUser`/auth context → `getRequestEvent()` inside remote functions; same repository calls. +- **`prerender()` where the data allows it** — the fourth remote function type bakes query results to static payloads at build time, served without touching the server or the database. Targets identified up front: + - **articles** — repo content, the textbook case. + - **patron list** — today a client-side fetch (one of the three swr hooks); becomes a prerendered query, so the footer costs nothing at runtime. + - **xsearch (top-search) + leaderboards for completed seasons** — immutable once a season ends; enumerate season numbers via `inputs`, keep the current season a live `query()`. "At least the most recent seasons" prerendered; older ones can join the list at zero marginal cost. + - **build stats / popular builds per weapon** — currently cachified with a 1-hour TTL; `inputs` = all weapon slugs, and an hour-stale copy was already the accepted freshness, so build-time baking is strictly better (no cold cache, no server work). + + The tradeoff to respect: prerendered data is frozen until the next deploy, so it fits where data changes slower than the deploy cadence or where TTL-staleness was already accepted. cachified's remaining users (sidebar counts, streams, trophies, tiers) genuinely need runtime freshness — they stay as cached `query()`s, and `@epic-web/cachified` shrinks to those few call sites. + +### Pattern cookbook (the Opus bible) + +`MIGRATION.md` in the repo root: a before/after pair for every recurring pattern. Opus agents follow it and never improvise; any pattern not in the book is an escalation, and the resolution becomes a new entry (the book only grows). Seed entries: + +| React / React Router | Svelte 5 / SvelteKit | +|---|---| +| `useLoaderData()` | `await query()` in component (`` for pending/error) | +| `useState` (162×) | `$state` | +| derived-in-render | `$derived` | +| `useEffect` (61×) | mostly **delete** (loaders/reactivity subsume them); rest `$effect` | +| `createContext`/`useContext` (9×) | shared `$state` in `.svelte.ts` modules, plain imports — no context machinery (per Svelte's own "when to use stores": mostly never). One guardrail in the cookbook: module state is a server-side singleton, so anything per-user/per-request stays in load/query data or gets `$state` initialized client-side only | +| props / children / render props | `$props()` / snippets | +| `react-error-boundary` | `` / `+error.svelte` | +| `react-flip-toolkit` (5×) | built-in `animate:flip` | +| `chart.js` via react-chartjs-2 (3×) | chart.js directly via `{@attach}` | +| `clsx` | **built into Svelte's `class` attribute** (5.16+ runs clsx internally): `className={clsx("a", cond && "b", {c})}` → `class={["a", cond && "b", {c}]}` — the codemod unwraps the call, the dependency goes away | +| CSS modules (232 files) | Svelte scoped `