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:
@@ -12,6 +12,9 @@ dist
|
|||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
|
# TanStack Router auto-generated route tree
|
||||||
|
src/routeTree.gen.ts
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
|
|||||||
@@ -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.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.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.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.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.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) | ⬜ | — |
|
| 1.10 | [Compose service block in trm/deploy](./phase-1-foundation/10-deploy-compose-block.md) | ⬜ | — |
|
||||||
|
|||||||
@@ -219,4 +219,27 @@ function HomePage() {
|
|||||||
|
|
||||||
## Done
|
## 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 `<Outlet />` in `<QueryClientProvider>`. In dev only, `<Suspense>`-wrapped `<TanStackRouterDevtools position="bottom-right" />` + `<ReactQueryDevtools buttonPosition="bottom-left" />` 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 `<LoginPage>` (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 `<RouterProvider router={router} />`. 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`.
|
||||||
|
|||||||
@@ -29,4 +29,13 @@ export default defineConfig([
|
|||||||
'react-refresh/only-export-components': 'off',
|
'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',
|
||||||
|
},
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
+14
-33
@@ -1,38 +1,19 @@
|
|||||||
import { useAuthStore } from '@/auth';
|
import { RouterProvider, createRouter } from '@tanstack/react-router';
|
||||||
import { LoginPage } from '@/ui/pages/login';
|
import { routeTree } from './routeTree.gen';
|
||||||
|
import { queryClient } from '@/lib/query-client';
|
||||||
|
|
||||||
function App() {
|
const router = createRouter({
|
||||||
const status = useAuthStore((s) => s.status);
|
routeTree,
|
||||||
const user = useAuthStore((s) => (s.status === 'authenticated' ? s.user : null));
|
context: { queryClient },
|
||||||
|
defaultPreload: 'intent',
|
||||||
|
});
|
||||||
|
|
||||||
// While the boot probe is in flight, render a minimal placeholder rather
|
declare module '@tanstack/react-router' {
|
||||||
// than flashing the login page for users who already have a session.
|
interface Register {
|
||||||
if (status === 'unknown' || status === 'authenticating') {
|
router: typeof router;
|
||||||
return (
|
}
|
||||||
<div className="min-h-screen grid place-items-center bg-muted text-sm text-muted-foreground">
|
|
||||||
Signing you in…
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'anonymous') {
|
export default function App() {
|
||||||
return <LoginPage />;
|
return <RouterProvider router={router} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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;
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 />;
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
+8
-1
@@ -2,6 +2,7 @@ import path from 'node:path';
|
|||||||
import { defineConfig, loadEnv } from 'vite';
|
import { defineConfig, loadEnv } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
import { TanStackRouterVite } from '@tanstack/router-plugin/vite';
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig(({ mode }) => {
|
export default defineConfig(({ mode }) => {
|
||||||
@@ -12,7 +13,13 @@ export default defineConfig(({ mode }) => {
|
|||||||
const directusWsUrl = directusUrl.replace(/^http(s?):\/\//, 'ws$1://');
|
const directusWsUrl = directusUrl.replace(/^http(s?):\/\//, 'ws$1://');
|
||||||
|
|
||||||
return {
|
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: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': path.resolve(__dirname, './src'),
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
|||||||
Reference in New Issue
Block a user