feat: task 1.7 routing skeleton

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 <LoginPage> 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
  <RouterProvider />. 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.
This commit is contained in:
2026-05-02 18:19:33 +02:00
parent 4ac5ed4eca
commit dc4e73f73a
11 changed files with 251 additions and 36 deletions
+14 -33
View File
@@ -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 (
<div className="min-h-screen grid place-items-center bg-muted text-sm text-muted-foreground">
Signing you in
</div>
);
declare module '@tanstack/react-router' {
interface Register {
router: typeof router;
}
if (status === 'anonymous') {
return <LoginPage />;
}
// Phase 1.7 replaces this branch with the TanStack Router shell.
return (
<div className="min-h-screen grid place-items-center bg-muted">
<div className="space-y-4 text-center">
<h1 className="text-3xl font-semibold">TRM</h1>
<p className="text-sm text-muted-foreground">
Phase 1 scaffold. Routing + protected pages land in 1.7.
</p>
<p className="text-xs text-muted-foreground">
Signed in as <code className="font-mono">{user?.email ?? user?.id}</code>
</p>
</div>
</div>
);
}
export default App;
export default function App() {
return <RouterProvider router={router} />;
}
+25
View File
@@ -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
* `<QueryClientProvider>` 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,
},
},
});
+41
View File
@@ -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 (
<QueryClientProvider client={queryClient}>
<Outlet />
{import.meta.env.DEV && (
<Suspense fallback={null}>
<TanStackRouterDevtools position="bottom-right" />
<ReactQueryDevtools buttonPosition="bottom-left" />
</Suspense>
)}
</QueryClientProvider>
);
}
+58
View File
@@ -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 (
<div className="container mx-auto p-6 space-y-4">
<header className="flex items-center justify-between">
<h1 className="text-2xl font-semibold">TRM</h1>
<div className="flex items-center gap-3">
<span className="text-sm text-muted-foreground">{displayName}</span>
<Button variant="outline" onClick={onSignOut}>
Sign out
</Button>
</div>
</header>
<Card>
<CardHeader>
<CardTitle>Live monitoring map</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
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).
</p>
</CardContent>
</Card>
</div>
);
}
+42
View File
@@ -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 (
<div className="min-h-screen grid place-items-center bg-muted text-sm text-muted-foreground">
Loading
</div>
);
}
return <Outlet />;
}
+26
View File
@@ -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 (
<LoginPage
onAuthenticated={() => {
void navigate({ to: redirect ?? '/', replace: true });
}}
/>
);
}