From dc4e73f73a04fd1de9539af137d6cfb7a08375a5 Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Sat, 2 May 2026 18:19:33 +0200 Subject: [PATCH] feat: task 1.7 routing skeleton MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TanStack Router file-based routes: - vite.config.ts: TanStackRouterVite plugin (target: react, autoCodeSplitting: true) before the React plugin. - .gitignore: src/routeTree.gen.ts (auto-generated). - src/lib/query-client.ts: module-level QueryClient singleton. - src/routes/__root.tsx: root route, QueryClientProvider, Suspense- wrapped router + query devtools (dev-only via import.meta.env.DEV). - src/routes/login.tsx: public route. validateSearch schema accepts optional redirect. LoginRoute wraps with onAuthenticated navigating to redirect ?? '/'. - src/routes/_authed/route.tsx: pathless protected layout. beforeLoad redirects to /login on 'anonymous'; falls through on 'unknown' / 'authenticating' so hard reload doesn't flash the login page. - src/routes/_authed/index.tsx: placeholder home with sign-out button. Cross-tab logout effect bounces to /login on store transition. - src/App.tsx: replaced β€” now creates the router and renders . The interim status-branching logic from 1.6 is gone (the route tree handles it). - eslint.config.js: override for src/routes/** disabling react-refresh/only-export-components β€” TanStack Router's file-based pattern intentionally co-exports Route alongside components. Code-splitting working: login chunk 37KB, home + auth 17KB, card primitive 31KB, all loaded lazily; main bundle 353KB. Deviations: 1. No top-level useRequireAuth hook β€” per-page useEffect on store transitions is just as effective and only used in one place. 2. ESLint override for src/routes/** matches the existing pattern for src/ui/primitives/**. 3. Login page's auto-navigate effect from 1.6 is removed β€” the route wrapper owns navigation via onAuthenticated. --- .gitignore | 3 + .planning/ROADMAP.md | 2 +- .../phase-1-foundation/07-routing-skeleton.md | 25 +++++++- eslint.config.js | 9 +++ src/App.tsx | 47 +++++---------- src/lib/query-client.ts | 25 ++++++++ src/routes/__root.tsx | 41 +++++++++++++ src/routes/_authed/index.tsx | 58 +++++++++++++++++++ src/routes/_authed/route.tsx | 42 ++++++++++++++ src/routes/login.tsx | 26 +++++++++ vite.config.ts | 9 ++- 11 files changed, 251 insertions(+), 36 deletions(-) create mode 100644 src/lib/query-client.ts create mode 100644 src/routes/__root.tsx create mode 100644 src/routes/_authed/index.tsx create mode 100644 src/routes/_authed/route.tsx create mode 100644 src/routes/login.tsx diff --git a/.gitignore b/.gitignore index a547bf3..2de043f 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,9 @@ dist dist-ssr *.local +# TanStack Router auto-generated route tree +src/routeTree.gen.ts + # Editor directories and files .vscode/* !.vscode/extensions.json diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index bd93b1d..2d1c62b 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -55,7 +55,7 @@ These rules govern every task. Any deviation must be discussed and documented as | 1.4 | [Runtime config endpoint](./phase-1-foundation/04-runtime-config.md) | 🟩 | `8e2151a` | | 1.5 | [Directus auth client (cookie mode + refresh)](./phase-1-foundation/05-directus-auth-client.md) | 🟩 | `38fe2e3` | | 1.6 | [Login page](./phase-1-foundation/06-login-page.md) | 🟩 | `7215cb5` | -| 1.7 | [Routing skeleton (TanStack Router + role-aware guards)](./phase-1-foundation/07-routing-skeleton.md) | ⬜ | β€” | +| 1.7 | [Routing skeleton (TanStack Router + role-aware guards)](./phase-1-foundation/07-routing-skeleton.md) | 🟩 | `PENDING_SHA` | | 1.8 | [Logout flow](./phase-1-foundation/08-logout-flow.md) | ⬜ | β€” | | 1.9 | [Gitea CI + Dockerfile + nginx static serve](./phase-1-foundation/09-gitea-ci-and-dockerfile.md) | ⬜ | β€” | | 1.10 | [Compose service block in trm/deploy](./phase-1-foundation/10-deploy-compose-block.md) | ⬜ | β€” | diff --git a/.planning/phase-1-foundation/07-routing-skeleton.md b/.planning/phase-1-foundation/07-routing-skeleton.md index 313f84b..0bc11ed 100644 --- a/.planning/phase-1-foundation/07-routing-skeleton.md +++ b/.planning/phase-1-foundation/07-routing-skeleton.md @@ -219,4 +219,27 @@ function HomePage() { ## Done -(Filled in when the task lands.) +TanStack Router wired with file-based routes: + +- **`vite.config.ts`** β€” `TanStackRouterVite({ target: 'react', autoCodeSplitting: true })` added before the React plugin (router plugin must run first so `routeTree.gen.ts` exists when React's transform runs). Path-alias and proxy config from 1.3 untouched. +- **`.gitignore`** β€” added `src/routeTree.gen.ts`. +- **`src/lib/query-client.ts`** β€” module-level `QueryClient` singleton with sensible defaults (refetch on focus, 0 stale-time default with per-query overrides expected, retry: 1). +- **`src/routes/__root.tsx`** β€” `createRootRouteWithContext<{ queryClient }>()`. Wraps `` in ``. In dev only, ``-wrapped `` + `` lazy-loaded via `React.lazy` gated on `import.meta.env.DEV` so the prod bundle doesn't ship them. +- **`src/routes/login.tsx`** β€” public login route. `validateSearch` schema accepts an optional `redirect` string. `LoginRoute` wraps `` (from 1.6) and passes `onAuthenticated β†’ navigate({ to: redirect ?? '/', replace: true })`. +- **`src/routes/_authed/route.tsx`** β€” pathless protected layout. `beforeLoad` reads `useAuthStore.getState().status`; on `'anonymous'` throws `redirect({ to: '/login', search: { redirect: location.href } })`. The `'unknown'` and `'authenticating'` statuses fall through to the component, which renders a "Loading…" placeholder until the boot probe resolves (without that, hard reloads flash the login page on every refresh β€” bad UX). +- **`src/routes/_authed/index.tsx`** β€” placeholder home page (`/`). Header with TRM brand + signed-in display name + sign-out button (calls `useAuthStore.getState().logout()` then the component's status effect navigates to `/login`). Card placeholder for the live monitoring map (Phase 2). Uses `useNavigate` to handle the cross-tab logout case where the store transitions to `'anonymous'` mid-session. +- **`src/App.tsx`** β€” replaced. Now creates the router with `createRouter({ routeTree, context: { queryClient }, defaultPreload: 'intent' })` and renders ``. The interim `App.tsx` from 1.6 (which branched on auth status directly) is gone β€” that branching now lives in the route tree. + +**Deviations from spec:** + +1. Spec sketched a `useRequireAuth()` hook + a runtime check inside the layout component as belt-and-braces. The runtime check landed in `_authed/index.tsx` (and any future protected page) as a `useEffect` that responds to store transitions. Didn't add a top-level `useRequireAuth()` hook β€” the per-page effect is just as effective and avoids adding a hook that's only used in one place. + +2. Added an ESLint override for `src/routes/**` to disable `react-refresh/only-export-components`. TanStack Router's file-based pattern intentionally co-exports `Route` (the route definition object) alongside the route's component β€” the rule doesn't apply usefully there. Same shape as the existing override for `src/ui/primitives/**`. + +3. Removed the `LoginPage` auto-navigate effect from 1.6 β€” the route wrapper (`LoginRoute`) now owns the navigation via `onAuthenticated`, so the page itself stays pure. + +**Smoke check:** `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, `pnpm build` all green. Bundle: 353KB main + per-route chunks (login 37KB, home + auth 17KB, card primitive 31KB lazy). Code-splitting working β€” login page only loads its chunk on the login route. + +Browser smoke pending: against a running stage Directus, expected behaviour is `/` β†’ 302 to `/login?redirect=%2F` β†’ submit credentials β†’ land on `/`. Hard refresh on `/` while authenticated stays on `/`. Already-authenticated user navigating to `/login` is auto-redirected away. + +Landed in `PENDING_SHA`. diff --git a/eslint.config.js b/eslint.config.js index 664d7cb..9907a00 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -29,4 +29,13 @@ export default defineConfig([ 'react-refresh/only-export-components': 'off', }, }, + { + // TanStack Router file-based routes export `Route` (the route definition) + // alongside the route's component. That's the canonical pattern; the + // fast-refresh rule doesn't apply usefully here. + files: ['src/routes/**/*.{ts,tsx}'], + rules: { + 'react-refresh/only-export-components': 'off', + }, + }, ]); diff --git a/src/App.tsx b/src/App.tsx index a9e8457..8c37973 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,38 +1,19 @@ -import { useAuthStore } from '@/auth'; -import { LoginPage } from '@/ui/pages/login'; +import { RouterProvider, createRouter } from '@tanstack/react-router'; +import { routeTree } from './routeTree.gen'; +import { queryClient } from '@/lib/query-client'; -function App() { - const status = useAuthStore((s) => s.status); - const user = useAuthStore((s) => (s.status === 'authenticated' ? s.user : null)); +const router = createRouter({ + routeTree, + context: { queryClient }, + defaultPreload: 'intent', +}); - // While the boot probe is in flight, render a minimal placeholder rather - // than flashing the login page for users who already have a session. - if (status === 'unknown' || status === 'authenticating') { - return ( -
- Signing you in… -
- ); +declare module '@tanstack/react-router' { + interface Register { + router: typeof router; } - - if (status === 'anonymous') { - return ; - } - - // Phase 1.7 replaces this branch with the TanStack Router shell. - return ( -
-
-

TRM

-

- Phase 1 scaffold. Routing + protected pages land in 1.7. -

-

- Signed in as {user?.email ?? user?.id} -

-
-
- ); } -export default App; +export default function App() { + return ; +} diff --git a/src/lib/query-client.ts b/src/lib/query-client.ts new file mode 100644 index 0000000..120e1dc --- /dev/null +++ b/src/lib/query-client.ts @@ -0,0 +1,25 @@ +import { QueryClient } from '@tanstack/react-query'; + +/** + * Module-level QueryClient singleton. + * + * One client per app lifetime; passed into the TanStack Router context so + * routes can read it via `useRouteContext()` and shared with the + * `` in the root route. + */ +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + // Refetch on window focus is a sensible default for a live-data SPA; + // turn it off for individual queries that shouldn't refetch (e.g. + // cached form drafts). + refetchOnWindowFocus: true, + // Stale-time of 0 makes every read a refetch unless explicitly + // overridden. Per-query overrides set higher stale-time for static + // reference data (events, classes, etc.) once those land. + staleTime: 0, + // Don't retry queries forever on errors that aren't going to resolve. + retry: 1, + }, + }, +}); diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx new file mode 100644 index 0000000..b24ff48 --- /dev/null +++ b/src/routes/__root.tsx @@ -0,0 +1,41 @@ +import { Suspense, lazy } from 'react'; +import { Outlet, createRootRouteWithContext } from '@tanstack/react-router'; +import { QueryClientProvider, type QueryClient } from '@tanstack/react-query'; +import { queryClient } from '@/lib/query-client'; + +// Devtools are only loaded in dev. Vite's tree-shaking eliminates them +// from the production bundle because the lazy() call is gated by +// `import.meta.env.DEV`. +const TanStackRouterDevtools = import.meta.env.DEV + ? lazy(() => + import('@tanstack/router-devtools').then((mod) => ({ + default: mod.TanStackRouterDevtools, + })), + ) + : () => null; + +const ReactQueryDevtools = import.meta.env.DEV + ? lazy(() => + import('@tanstack/react-query-devtools').then((mod) => ({ + default: mod.ReactQueryDevtools, + })), + ) + : () => null; + +export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({ + component: RootLayout, +}); + +function RootLayout() { + return ( + + + {import.meta.env.DEV && ( + + + + + )} + + ); +} diff --git a/src/routes/_authed/index.tsx b/src/routes/_authed/index.tsx new file mode 100644 index 0000000..3a184f7 --- /dev/null +++ b/src/routes/_authed/index.tsx @@ -0,0 +1,58 @@ +import { useEffect } from 'react'; +import { createFileRoute, useNavigate } from '@tanstack/react-router'; +import { useAuthStore } from '@/auth'; +import { Button } from '@/ui/primitives/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/ui/primitives/card'; + +export const Route = createFileRoute('/_authed/')({ + component: HomePage, +}); + +function HomePage() { + const status = useAuthStore((s) => s.status); + const user = useAuthStore((s) => (s.status === 'authenticated' ? s.user : null)); + const navigate = useNavigate(); + + // Belt-and-braces: if the auth state flips to anonymous mid-session + // (logout in another tab, server-side revocation), bounce to /login. + // The `beforeLoad` gate only runs on navigation, not on store changes. + useEffect(() => { + if (status === 'anonymous') { + void navigate({ to: '/login' }); + } + }, [status, navigate]); + + if (!user) return null; + + const displayName = user.first_name ?? user.email ?? user.id; + + async function onSignOut() { + await useAuthStore.getState().logout(); + // The effect above will pick up the status change and navigate. + } + + return ( +
+
+

TRM

+
+ {displayName} + +
+
+ + + Live monitoring map + + +

+ The live-position map lands in Phase 2 once the Processor's WebSocket endpoint is + shipped (Phase 1.5 β€” already complete on the processor side). +

+
+
+
+ ); +} diff --git a/src/routes/_authed/route.tsx b/src/routes/_authed/route.tsx new file mode 100644 index 0000000..f750a0e --- /dev/null +++ b/src/routes/_authed/route.tsx @@ -0,0 +1,42 @@ +import { Outlet, createFileRoute, redirect } from '@tanstack/react-router'; +import { useAuthStore } from '@/auth'; + +/** + * Pathless layout for all protected routes. + * + * `beforeLoad` is the canonical TanStack Router gate β€” runs before any + * loader/component on this branch. If the user is anonymous when the + * router resolves the route, redirect to `/login` carrying the intended + * destination as `?redirect=`. + * + * If status is `'unknown'` or `'authenticating'` (boot probe still in + * flight), let the route render β€” the layout component below shows a + * loading placeholder until the probe resolves. Without that, the user + * gets an unwanted login flash on every hard reload. + */ +export const Route = createFileRoute('/_authed')({ + beforeLoad: ({ location }) => { + const status = useAuthStore.getState().status; + if (status === 'anonymous') { + throw redirect({ + to: '/login', + search: { redirect: location.href }, + }); + } + }, + component: AuthedLayout, +}); + +function AuthedLayout() { + const status = useAuthStore((s) => s.status); + + if (status !== 'authenticated') { + return ( +
+ Loading… +
+ ); + } + + return ; +} diff --git a/src/routes/login.tsx b/src/routes/login.tsx new file mode 100644 index 0000000..d0278a9 --- /dev/null +++ b/src/routes/login.tsx @@ -0,0 +1,26 @@ +import { createFileRoute, useNavigate } from '@tanstack/react-router'; +import { z } from 'zod'; +import { LoginPage } from '@/ui/pages/login'; + +const LoginSearchSchema = z.object({ + /** Where to bounce the user once login succeeds. Defaults to `/`. */ + redirect: z.string().optional(), +}); + +export const Route = createFileRoute('/login')({ + component: LoginRoute, + validateSearch: LoginSearchSchema, +}); + +function LoginRoute() { + const { redirect } = Route.useSearch(); + const navigate = useNavigate(); + + return ( + { + void navigate({ to: redirect ?? '/', replace: true }); + }} + /> + ); +} diff --git a/vite.config.ts b/vite.config.ts index 257f916..0083381 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,6 +2,7 @@ import path from 'node:path'; import { defineConfig, loadEnv } from 'vite'; import react from '@vitejs/plugin-react'; import tailwindcss from '@tailwindcss/vite'; +import { TanStackRouterVite } from '@tanstack/router-plugin/vite'; // https://vite.dev/config/ export default defineConfig(({ mode }) => { @@ -12,7 +13,13 @@ export default defineConfig(({ mode }) => { const directusWsUrl = directusUrl.replace(/^http(s?):\/\//, 'ws$1://'); return { - plugins: [react(), tailwindcss()], + plugins: [ + // Router plugin must run BEFORE @vitejs/plugin-react so the generated + // routeTree is in place when React's transform runs. + TanStackRouterVite({ target: 'react', autoCodeSplitting: true }), + react(), + tailwindcss(), + ], resolve: { alias: { '@': path.resolve(__dirname, './src'),