From 35ffc136bec8ce7663f988b39deb0ff9dee1b1ea Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 16 Aug 2026 11:59:13 +0300 Subject: [PATCH] Phase 2 harness wiring: differ --right-app web, e2e target app, dev-controls, cookbook - differ can serve apps/web on the right side (--right-app web); impersonation no longer follows app-specific redirects - e2e: E2E_TARGET_APP=web spawns apps/web servers per worker against the shared dbs; leaderboards spec + page object added; expectRouterIdle exported - apps/web dev-controls endpoints: /auth/impersonate(+stop), /theme (404-redirect GET parity), /refresh-caches, /end-season, /run-routine, /sidenav - HydrationTestIndicator implements the hydrated/router-idle contract (counts in-flight remote fetches) - codemods: route-map (routes.ts -> SvelteKit route scaffolds, manifest-aware, idempotent) and remote-scaffold (loader->query, _action branches->commands) - MIGRATION.md cookbook seeded with the golden read-path pattern and slice learnings; manifest: leaderboards migrated --- MIGRATION.md | 195 ++++++++++++++++++ apps/web-react/e2e/global-setup.ts | 32 ++- apps/web-react/e2e/helpers/playwright.ts | 2 +- apps/web-react/e2e/leaderboards.spec.ts | 93 +++++++++ .../pages/leaderboards/leaderboards-page.ts | 42 ++++ apps/web/package.json | 2 + .../components/HydrationTestIndicator.svelte | 57 +++++ .../admin/ExternalStreamRepository.server.ts | 31 +++ .../lib/features/admin/dev-controls.server.ts | 4 + .../src/routes/auth/impersonate/+server.ts | 46 +++++ .../routes/auth/impersonate/stop/+server.ts | 21 ++ apps/web/src/routes/end-season/+server.ts | 14 ++ apps/web/src/routes/refresh-caches/+server.ts | 22 ++ apps/web/src/routes/run-routine/+server.ts | 24 +++ apps/web/src/routes/sidenav/+server.ts | 29 +++ apps/web/src/routes/theme/+server.ts | 32 +++ migration-manifest.json | 12 +- pnpm-lock.yaml | 6 + tooling/codemods/package.json | 2 + tooling/codemods/src/remote-scaffold.ts | 174 ++++++++++++++++ tooling/codemods/src/route-map.ts | 172 +++++++++++++++ tooling/differ/README.md | 5 + tooling/differ/src/cli.ts | 9 + tooling/differ/src/differ.ts | 6 +- tooling/differ/src/prepare.ts | 20 +- tooling/differ/src/servers.ts | 22 +- tooling/differ/src/types.ts | 4 + 27 files changed, 1062 insertions(+), 16 deletions(-) create mode 100644 MIGRATION.md create mode 100644 apps/web-react/e2e/leaderboards.spec.ts create mode 100644 apps/web-react/e2e/pages/leaderboards/leaderboards-page.ts create mode 100644 apps/web/src/lib/components/HydrationTestIndicator.svelte create mode 100644 apps/web/src/lib/features/admin/ExternalStreamRepository.server.ts create mode 100644 apps/web/src/lib/features/admin/dev-controls.server.ts create mode 100644 apps/web/src/routes/auth/impersonate/+server.ts create mode 100644 apps/web/src/routes/auth/impersonate/stop/+server.ts create mode 100644 apps/web/src/routes/end-season/+server.ts create mode 100644 apps/web/src/routes/refresh-caches/+server.ts create mode 100644 apps/web/src/routes/run-routine/+server.ts create mode 100644 apps/web/src/routes/sidenav/+server.ts create mode 100644 apps/web/src/routes/theme/+server.ts create mode 100644 tooling/codemods/src/remote-scaffold.ts create mode 100644 tooling/codemods/src/route-map.ts diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 000000000..943d2353c --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,195 @@ +# Migration cookbook + +The pattern book for the React → Svelte migration (`svelte-big-bang.md`). Every +recurring pattern gets a before/after entry here; migrating agents follow the +book and never improvise. A pattern missing from the book is an escalation — +the resolution becomes a new entry. Entries are born in the vertical slices +(`/leaderboards` first) and grow from there. + +Conventions used by every entry: + +- `apps/web` imports its own code via `#lib/...` subpath imports **with explicit + file extensions** (`#lib/utils/urls.ts`, `#lib/components/Main.svelte`) — Kit 3 + subpath imports are unambiguous or they don't resolve. +- Server-only code: `*.server.ts` filename suffix, or anything under + `src/lib/server/`. Remote functions: `.remote.ts`. +- One component per `.svelte` file; feature components live in + `src/lib/features//components/`, generic primitives in + `@sendou/components` (`packages/components`), app-wide pieces in + `src/lib/components/`. +- Route files are thin shells: a `+page.svelte` composes feature components and + wires meta tags; everything real lives in the feature folder. + +--- + +## Read path (loader → remote query) + +The golden pattern, from `/leaderboards`. + +**Before** (React Router): `loaders/leaderboards.server.ts` exports a `loader` +that parses search params from the request URL and returns data; +`useLoaderData()` in the route component. + +**After** (SvelteKit): the loader body becomes a `query()` in +`leaderboards.remote.ts`. Remote queries cannot read the URL — the page parses +the URL client/SSR-side and passes the decoded values as query args, validated +by a valibot schema: + +```ts +// leaderboards.remote.ts +import { query } from "$app/server"; +import { getUser } from "#lib/features/auth/user.server.ts"; +import { leaderboardsQuerySchema } from "./leaderboards-schemas.ts"; + +export const getLeaderboards = query( + leaderboardsQuerySchema, + async ({ type, season }) => { + const user = getUser(); // event.locals via getRequestEvent() + // ...same body as the React loader + }, +); +``` + +```svelte + + +``` + +`$derived(await ...)` needs `compilerOptions.experimental.async` (on). When the +params change the derived re-awaits; identical args hit the query cache, so a +URL write that decodes to the same values refetches nothing — this replaces the +React `shouldRevalidate` machinery entirely. + +Auth: `requireUser()` / `getUser()` / `actorId()` from +`#lib/features/auth/user.server.ts` read `event.locals.user`, resolved once per +request in `hooks.server.ts`. No AsyncLocalStorage. + +## Write path (action → command), first shape + +The full write-path pattern lands with the `/scrims` slice; `/leaderboards` +established the command shape for fixed-field mutations: + +- Each `_action` branch of a React action union becomes its own `command()` + with its own valibot schema (the `_action` discriminator disappears). +- **Server-driven refresh is the default**: the handler calls + `getX(args).refresh()` / `.set(result)` for exactly the queries it + invalidated. +- When the server genuinely can't know which query instance the client holds + (filter/pagination args — the leaderboards case), the **client** rides the + refresh on the mutation round trip instead: + +```ts +skipTeam({ season, identifier }).updates(getLeaderboards(queryArgs)); +``` + +Never leave a mutation without one of the two — the refresh-everything default +must not ship. + +## Search params + +`app/modules/search-params` is ported at +`#lib/modules/search-params/search-params.ts` with valibot schemas +(`SP.param(v.picklist(...))` etc.). Component state comes from +`searchParamsState(definition)` (`search-params-state.svelte.ts`): +`params.current` is reactive to the URL; `params.set(updates)` writes through a +replace navigation (`goto` with `reset: false`), shallow for `loader: false` +params. Every definition still registers an `assertRoundTrips` test. + +`SP.custom` takes an explicit `ParamCodec` (`{ decode, encode }`) instead of a +zod codec. + +## Validation (zod → valibot) + +- `z.enum([...])` → `v.picklist([...])` +- `z.number().int().min(1)` → `v.pipe(v.number(), v.integer(), v.minValue(1))` +- `z.string().regex(...).pipe(z.custom())` → + `v.pipe(v.string(), v.regex(...), v.transform((s) => s as T))` +- `.nullable()` → `v.nullable(...)` +- Schemas with 2+ consumers get promoted to `@sendou/schemas` (none yet). + +## Components + +| React | Svelte | +|---|---| +| `useState` | `$state` | +| derived-in-render | `$derived` / `$derived.by` | +| `useEffect` | usually delete; else `$effect` | +| props / `children` / render props | `$props()` / snippets (`{@render children()}`) | +| `clsx(...)` in `className` | `class={[ ... ]}` array form (built-in clsx) | +| `React.cloneElement(icon, {className})` | wrapper `{@render icon()}` + `:global(svg)` sizing | +| ref callbacks | `{@attach fn}` attachments (return value = cleanup) | +| `useHydrated()` | `browser` from `$app/env`, or just render client-only branches after an `$effect` sets a flag | +| controlled/uncontrolled prop pairs | same pattern; mark the mount-time branch with `svelte-ignore state_referenced_locally` | +| module-level caches keyed by `i18n.language` | usually unnecessary — paraglide messages are plain function calls | +| react-aria `Tabs/TabList/Tab/TabPanel` | `@sendou/components` `Tabs/TabList/Tab/TabPanel` (handrolled ARIA tabs, context-based) | +| react-aria `MenuTrigger/Menu/MenuItem` | `@sendou/components` `Menu/MenuItem` — trigger is a snippet receiving `{ "aria-expanded", "aria-haspopup", onclick }` to spread | +| react-aria `DialogTrigger/Popover/Dialog` | `@sendou/components` `Popover`, same trigger-snippet contract | +| react-aria `Select` + `Autocomplete` + `Virtualizer` | `@sendou/components` `Select/SelectItem/SelectItemSection` — filtering happens at the **data level** in the caller (see `WeaponSelect.svelte`), no virtualization (a few hundred items render fine) | +| `useLoaderData()` | `await query()` (see read path) | +| `` | plain `` | +| `useUser()` / `useHasRole()` | `loggedInUser()` / `hasRole()` from `#lib/features/auth/user-state.ts` (reads `page.data.user`) | +| meta functions | `` (`#lib/components/MetaTags.svelte`) inside the `+page.svelte` | + +## CSS + +- Each component's `.module.css` contents move into its `