From 663b0cbdb08e9b1fd08dc993a900ab3d5f6aab59 Mon Sep 17 00:00:00 2001 From: Dean Stratakos <29683763+dastratakos@users.noreply.github.com> Date: Sun, 3 May 2026 12:48:31 -0700 Subject: [PATCH 1/7] feat: migrate web routes to tanstack router --- biome.json | 3 +- packages/cli/src/show.ts | 2 +- packages/web/package.json | 2 + packages/web/src/App.tsx | 27 -- packages/web/src/app/__root.tsx | 24 ++ packages/web/src/app/index.tsx | 23 ++ packages/web/src/app/runs.$runId.files.tsx | 11 + packages/web/src/app/runs.$runId.index.tsx | 31 ++ packages/web/src/app/runs.$runId.tsx | 17 + .../lib/__tests__/use-hash-run-id.test.tsx | 59 --- packages/web/src/lib/use-hash-run-id.ts | 22 -- packages/web/src/main.tsx | 19 +- packages/web/src/routeTree.gen.ts | 127 +++++++ packages/web/src/router.tsx | 36 ++ .../web/src/routes/pull-request-layout.tsx | 48 ++- packages/web/vite.config.ts | 12 +- pnpm-lock.yaml | 351 ++++++++++++++++++ 17 files changed, 665 insertions(+), 149 deletions(-) delete mode 100644 packages/web/src/App.tsx create mode 100644 packages/web/src/app/__root.tsx create mode 100644 packages/web/src/app/index.tsx create mode 100644 packages/web/src/app/runs.$runId.files.tsx create mode 100644 packages/web/src/app/runs.$runId.index.tsx create mode 100644 packages/web/src/app/runs.$runId.tsx delete mode 100644 packages/web/src/lib/__tests__/use-hash-run-id.test.tsx delete mode 100644 packages/web/src/lib/use-hash-run-id.ts create mode 100644 packages/web/src/routeTree.gen.ts create mode 100644 packages/web/src/router.tsx diff --git a/biome.json b/biome.json index 7a085d1..370ee10 100644 --- a/biome.json +++ b/biome.json @@ -17,7 +17,8 @@ "!**/node_modules", "!**/dist", "!**/web-dist", - "!**/drizzle" + "!**/drizzle", + "!packages/web/src/routeTree.gen.ts" ] }, "linter": { diff --git a/packages/cli/src/show.ts b/packages/cli/src/show.ts index 6e6714b..f173fae 100644 --- a/packages/cli/src/show.ts +++ b/packages/cli/src/show.ts @@ -14,7 +14,7 @@ export async function show(jsonPath: string): Promise { routes: [...runRoutes(db), ...viewStateRoutes(db), ...diffRoutes(db)], }); const { port } = handle; - const url = `http://${LOOPBACK_HOST}:${port}/#/runs/${runId}`; + const url = `http://${LOOPBACK_HOST}:${port}/runs/${encodeURIComponent(runId)}`; process.stdout.write(`Listening on ${url}\n`); process.stdout.write("Press Ctrl+C to exit.\n"); diff --git a/packages/web/package.json b/packages/web/package.json index 32ef9af..45c8f39 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -21,6 +21,7 @@ "@radix-ui/react-tooltip": "^1.2.8", "@stage-cli/types": "workspace:*", "@tanstack/react-query": "^5.100.7", + "@tanstack/react-router": "^1.169.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.562.0", @@ -34,6 +35,7 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.1.18", + "@tanstack/router-plugin": "^1.167.32", "@testing-library/react": "^16.3.2", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx deleted file mode 100644 index 97114e9..0000000 --- a/packages/web/src/App.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { Topbar } from "@/components/layout/topbar"; -import { useHashRunId } from "@/lib/use-hash-run-id"; -import { PullRequestLayout } from "@/routes/pull-request-layout"; - -function NoRunSelected() { - return ( -
-
-

No run selected

-

- The URL is missing a #/runs/<runId> hash. Open the app via{" "} - stage-cli show <path>. -

-
-
- ); -} - -export function App() { - const runId = useHashRunId(); - return ( -
- - {runId ? : } -
- ); -} diff --git a/packages/web/src/app/__root.tsx b/packages/web/src/app/__root.tsx new file mode 100644 index 0000000..337d421 --- /dev/null +++ b/packages/web/src/app/__root.tsx @@ -0,0 +1,24 @@ +import type { QueryClient } from "@tanstack/react-query"; +import { createRootRouteWithContext, Outlet, redirect } from "@tanstack/react-router"; + +export const Route = createRootRouteWithContext<{ + queryClient: QueryClient; +}>()({ + beforeLoad: ({ location }) => { + if (location.hash.startsWith("#/runs/")) { + throw redirect({ + to: location.hash.slice(1), + replace: true, + }); + } + }, + component: RootLayout, +}); + +function RootLayout() { + return ( +
+ +
+ ); +} diff --git a/packages/web/src/app/index.tsx b/packages/web/src/app/index.tsx new file mode 100644 index 0000000..06b76fa --- /dev/null +++ b/packages/web/src/app/index.tsx @@ -0,0 +1,23 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { Topbar } from "@/components/layout/topbar"; + +export const Route = createFileRoute("/")({ + component: NoRunSelected, +}); + +function NoRunSelected() { + return ( + <> + +
+
+

No run selected

+

+ The URL is missing a /runs/<runId> path. Open the app via{" "} + stage-cli show <path>. +

+
+
+ + ); +} diff --git a/packages/web/src/app/runs.$runId.files.tsx b/packages/web/src/app/runs.$runId.files.tsx new file mode 100644 index 0000000..cb99880 --- /dev/null +++ b/packages/web/src/app/runs.$runId.files.tsx @@ -0,0 +1,11 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { FilesPage } from "@/routes/files-page"; + +export const Route = createFileRoute("/runs/$runId/files")({ + component: FilesRoute, +}); + +function FilesRoute() { + const { runId } = Route.useParams(); + return ; +} diff --git a/packages/web/src/app/runs.$runId.index.tsx b/packages/web/src/app/runs.$runId.index.tsx new file mode 100644 index 0000000..8ee7ac0 --- /dev/null +++ b/packages/web/src/app/runs.$runId.index.tsx @@ -0,0 +1,31 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { useMemo } from "react"; +import { useChapters } from "@/lib/use-chapters"; +import { useViewStateData } from "@/lib/use-view-state"; +import { ChaptersIndexPage } from "@/routes/chapters-index-page"; + +export const Route = createFileRoute("/runs/$runId/")({ + component: ChaptersRoute, +}); + +function ChaptersRoute() { + const { runId } = Route.useParams(); + const { data, isLoading } = useChapters(runId); + const { chapterIdSet } = useViewStateData(runId); + const chapters = data?.chapters; + const viewedCount = useMemo(() => { + if (!chapters) return 0; + let n = 0; + for (const chapter of chapters) if (chapterIdSet.has(chapter.externalId)) n++; + return n; + }, [chapters, chapterIdSet]); + + return ( + + ); +} diff --git a/packages/web/src/app/runs.$runId.tsx b/packages/web/src/app/runs.$runId.tsx new file mode 100644 index 0000000..4e3a8e1 --- /dev/null +++ b/packages/web/src/app/runs.$runId.tsx @@ -0,0 +1,17 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { Topbar } from "@/components/layout/topbar"; +import { PullRequestLayout } from "@/routes/pull-request-layout"; + +export const Route = createFileRoute("/runs/$runId")({ + component: RunLayout, +}); + +function RunLayout() { + const { runId } = Route.useParams(); + return ( + <> + + + + ); +} diff --git a/packages/web/src/lib/__tests__/use-hash-run-id.test.tsx b/packages/web/src/lib/__tests__/use-hash-run-id.test.tsx deleted file mode 100644 index 82da796..0000000 --- a/packages/web/src/lib/__tests__/use-hash-run-id.test.tsx +++ /dev/null @@ -1,59 +0,0 @@ -// @vitest-environment happy-dom -import { act, renderHook } from "@testing-library/react"; -import { afterEach, describe, expect, it } from "vitest"; -import { useHashRunId } from "../use-hash-run-id"; - -afterEach(() => { - window.location.hash = ""; -}); - -function setHash(next: string): void { - // Manually fire hashchange after assigning — happy-dom's location setter - // doesn't dispatch the event the way real browsers do across all paths. - window.location.hash = next; - window.dispatchEvent(new Event("hashchange")); -} - -describe("useHashRunId", () => { - it("parses the runId out of `#/runs/{runId}`", () => { - window.location.hash = "#/runs/abc-123"; - const { result } = renderHook(() => useHashRunId()); - expect(result.current).toBe("abc-123"); - }); - - it("re-renders when the hash changes", () => { - window.location.hash = "#/runs/first"; - const { result } = renderHook(() => useHashRunId()); - expect(result.current).toBe("first"); - - act(() => { - setHash("#/runs/second"); - }); - expect(result.current).toBe("second"); - - act(() => { - setHash(""); - }); - expect(result.current).toBeNull(); - }); - - it("returns null when the hash doesn't match the expected shape", () => { - window.location.hash = "#/something-else"; - const { result } = renderHook(() => useHashRunId()); - expect(result.current).toBeNull(); - }); - - it("decodes percent-encoded runIds", () => { - window.location.hash = "#/runs/run%20with%20spaces"; - const { result } = renderHook(() => useHashRunId()); - expect(result.current).toBe("run with spaces"); - }); - - it("ignores nested path segments after the runId", () => { - // Anticipates a future `#/runs/abc/chapters/3` shape — the runId stays - // accessible while the rest is parsed by a dedicated nested-route hook. - window.location.hash = "#/runs/abc/chapters/3"; - const { result } = renderHook(() => useHashRunId()); - expect(result.current).toBe("abc"); - }); -}); diff --git a/packages/web/src/lib/use-hash-run-id.ts b/packages/web/src/lib/use-hash-run-id.ts deleted file mode 100644 index b38913b..0000000 --- a/packages/web/src/lib/use-hash-run-id.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { useSyncExternalStore } from "react"; - -// Parses `#/runs/{runId}` from the URL hash. The CLI's `show` command opens -// the SPA at exactly this URL shape (see src/show.ts). Hash routing is the -// stage-cli analog of TanStack Router's `Route.useParams()`. -// -// Anything after the runId segment is ignored: a future `#/runs/abc/chapters/3` -// route would still resolve runId to `"abc"` here, leaving the rest for a -// dedicated nested-route hook to parse. -function readRunIdFromHash(): string | null { - const match = window.location.hash.match(/^#\/runs\/([^/?#]+)/); - return match?.[1] ? decodeURIComponent(match[1]) : null; -} - -function subscribe(callback: () => void): () => void { - window.addEventListener("hashchange", callback); - return () => window.removeEventListener("hashchange", callback); -} - -export function useHashRunId(): string | null { - return useSyncExternalStore(subscribe, readRunIdFromHash); -} diff --git a/packages/web/src/main.tsx b/packages/web/src/main.tsx index 3b1ac27..6ae2fd5 100644 --- a/packages/web/src/main.tsx +++ b/packages/web/src/main.tsx @@ -1,9 +1,10 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { RouterProvider } from "@tanstack/react-router"; import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; -import { App } from "./App"; import { ThemeProvider } from "./lib/theme"; import { DiffSettingsProvider } from "./lib/use-diff-settings"; +import { getQueryClient, getRouter } from "./router"; import "./styles/globals.css"; const rootElement = document.getElementById("root"); @@ -11,23 +12,15 @@ if (!rootElement) { throw new Error("Root element #root not found"); } -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - // Two browser tabs on the same run see synchronized state via refetch on focus - // (acceptance criterion). 30s staleTime keeps a single tab from re-fetching too eagerly. - staleTime: 30_000, - refetchOnWindowFocus: true, - }, - }, -}); +const router = getRouter(); +const queryClient = getQueryClient(); createRoot(rootElement).render( - + diff --git a/packages/web/src/routeTree.gen.ts b/packages/web/src/routeTree.gen.ts new file mode 100644 index 0000000..b43d0af --- /dev/null +++ b/packages/web/src/routeTree.gen.ts @@ -0,0 +1,127 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from "./app/__root"; +import { Route as IndexRouteImport } from "./app/index"; +import { Route as RunsRunIdRouteImport } from "./app/runs.$runId"; +import { Route as RunsRunIdIndexRouteImport } from "./app/runs.$runId.index"; +import { Route as RunsRunIdFilesRouteImport } from "./app/runs.$runId.files"; + +const IndexRoute = IndexRouteImport.update({ + id: "/", + path: "/", + getParentRoute: () => rootRouteImport, +} as any); +const RunsRunIdRoute = RunsRunIdRouteImport.update({ + id: "/runs/$runId", + path: "/runs/$runId", + getParentRoute: () => rootRouteImport, +} as any); +const RunsRunIdIndexRoute = RunsRunIdIndexRouteImport.update({ + id: "/", + path: "/", + getParentRoute: () => RunsRunIdRoute, +} as any); +const RunsRunIdFilesRoute = RunsRunIdFilesRouteImport.update({ + id: "/files", + path: "/files", + getParentRoute: () => RunsRunIdRoute, +} as any); + +export interface FileRoutesByFullPath { + "/": typeof IndexRoute; + "/runs/$runId": typeof RunsRunIdRouteWithChildren; + "/runs/$runId/files": typeof RunsRunIdFilesRoute; + "/runs/$runId/": typeof RunsRunIdIndexRoute; +} +export interface FileRoutesByTo { + "/": typeof IndexRoute; + "/runs/$runId/files": typeof RunsRunIdFilesRoute; + "/runs/$runId": typeof RunsRunIdIndexRoute; +} +export interface FileRoutesById { + __root__: typeof rootRouteImport; + "/": typeof IndexRoute; + "/runs/$runId": typeof RunsRunIdRouteWithChildren; + "/runs/$runId/files": typeof RunsRunIdFilesRoute; + "/runs/$runId/": typeof RunsRunIdIndexRoute; +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath; + fullPaths: "/" | "/runs/$runId" | "/runs/$runId/files" | "/runs/$runId/"; + fileRoutesByTo: FileRoutesByTo; + to: "/" | "/runs/$runId/files" | "/runs/$runId"; + id: + | "__root__" + | "/" + | "/runs/$runId" + | "/runs/$runId/files" + | "/runs/$runId/"; + fileRoutesById: FileRoutesById; +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute; + RunsRunIdRoute: typeof RunsRunIdRouteWithChildren; +} + +declare module "@tanstack/react-router" { + interface FileRoutesByPath { + "/": { + id: "/"; + path: "/"; + fullPath: "/"; + preLoaderRoute: typeof IndexRouteImport; + parentRoute: typeof rootRouteImport; + }; + "/runs/$runId": { + id: "/runs/$runId"; + path: "/runs/$runId"; + fullPath: "/runs/$runId"; + preLoaderRoute: typeof RunsRunIdRouteImport; + parentRoute: typeof rootRouteImport; + }; + "/runs/$runId/": { + id: "/runs/$runId/"; + path: "/"; + fullPath: "/runs/$runId/"; + preLoaderRoute: typeof RunsRunIdIndexRouteImport; + parentRoute: typeof RunsRunIdRoute; + }; + "/runs/$runId/files": { + id: "/runs/$runId/files"; + path: "/files"; + fullPath: "/runs/$runId/files"; + preLoaderRoute: typeof RunsRunIdFilesRouteImport; + parentRoute: typeof RunsRunIdRoute; + }; + } +} + +interface RunsRunIdRouteChildren { + RunsRunIdFilesRoute: typeof RunsRunIdFilesRoute; + RunsRunIdIndexRoute: typeof RunsRunIdIndexRoute; +} + +const RunsRunIdRouteChildren: RunsRunIdRouteChildren = { + RunsRunIdFilesRoute: RunsRunIdFilesRoute, + RunsRunIdIndexRoute: RunsRunIdIndexRoute, +}; + +const RunsRunIdRouteWithChildren = RunsRunIdRoute._addFileChildren( + RunsRunIdRouteChildren, +); + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + RunsRunIdRoute: RunsRunIdRouteWithChildren, +}; +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes(); diff --git a/packages/web/src/router.tsx b/packages/web/src/router.tsx new file mode 100644 index 0000000..cbf2919 --- /dev/null +++ b/packages/web/src/router.tsx @@ -0,0 +1,36 @@ +import { QueryClient } from "@tanstack/react-query"; +import { createRouter } from "@tanstack/react-router"; +import { routeTree } from "./routeTree.gen"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + // Two browser tabs on the same run see synchronized state via refetch on focus + // (acceptance criterion). 30s staleTime keeps a single tab from re-fetching too eagerly. + staleTime: 30_000, + refetchOnWindowFocus: true, + }, + }, +}); + +export function getQueryClient() { + return queryClient; +} + +export function getRouter() { + return createRouter({ + routeTree, + context: { + queryClient, + }, + scrollRestoration: true, + defaultPreload: "intent", + defaultPreloadStaleTime: 0, + }); +} + +declare module "@tanstack/react-router" { + interface Register { + router: ReturnType; + } +} diff --git a/packages/web/src/routes/pull-request-layout.tsx b/packages/web/src/routes/pull-request-layout.tsx index e059f04..754ee1a 100644 --- a/packages/web/src/routes/pull-request-layout.tsx +++ b/packages/web/src/routes/pull-request-layout.tsx @@ -1,13 +1,12 @@ +import { Link, Outlet, useRouterState } from "@tanstack/react-router"; import { BookOpen, FileText } from "lucide-react"; -import { type CSSProperties, useEffect, useMemo, useRef, useState } from "react"; +import { type CSSProperties, type ElementType, useEffect, useMemo, useRef, useState } from "react"; import { SectionLabel } from "@/components/pull-request/section-label"; import { useFileDiffEntries } from "@/lib/parse-diff"; import { useChapters } from "@/lib/use-chapters"; import { useDiffPatch } from "@/lib/use-diff-patch"; import { useViewStateData } from "@/lib/use-view-state"; import { cn } from "@/lib/utils"; -import { ChaptersIndexPage } from "./chapters-index-page"; -import { FilesPage } from "./files-page"; const PR_TAB = { CHAPTERS: "chapters", @@ -18,27 +17,28 @@ type PrTab = (typeof PR_TAB)[keyof typeof PR_TAB]; interface TabDef { id: PrTab; label: string; - icon: React.ElementType; + icon: ElementType; + to: "/runs/$runId" | "/runs/$runId/files"; } const tabs: TabDef[] = [ - { id: PR_TAB.CHAPTERS, label: "Chapters", icon: BookOpen }, - { id: PR_TAB.FILES, label: "Files changed", icon: FileText }, + { id: PR_TAB.CHAPTERS, label: "Chapters", icon: BookOpen, to: "/runs/$runId" }, + { id: PR_TAB.FILES, label: "Files changed", icon: FileText, to: "/runs/$runId/files" }, ]; interface TabLinkProps { tab: TabDef; + runId: string; isActive: boolean; - onSelect: (tab: PrTab) => void; countLabel?: string; } -function TabLink({ tab, isActive, onSelect, countLabel }: TabLinkProps) { - const { icon: Icon, label } = tab; +function TabLink({ tab, runId, isActive, countLabel }: TabLinkProps) { + const { icon: Icon, label, to } = tab; return ( - + ); } @@ -69,8 +69,14 @@ function ErrorState({ error }: { error: unknown }) { } export function PullRequestLayout({ runId }: { runId: string }) { - const { data, isLoading, error } = useChapters(runId); - const [activeTab, setActiveTab] = useState(PR_TAB.CHAPTERS); + const { data, error } = useChapters(runId); + const activeTab = useRouterState({ + select: (state): PrTab => { + const routeIds = new Set(state.matches.map((match) => match.routeId)); + if (routeIds.has("/runs/$runId/files")) return PR_TAB.FILES; + return PR_TAB.CHAPTERS; + }, + }); const { chapterIdSet, filePathSet } = useViewStateData(runId); const chapters = data?.chapters; @@ -145,8 +151,8 @@ export function PullRequestLayout({ runId }: { runId: string }) {
- {activeTab === PR_TAB.CHAPTERS && ( - - )} - {activeTab === PR_TAB.FILES && } +
); diff --git a/packages/web/vite.config.ts b/packages/web/vite.config.ts index 885a99a..20b480f 100644 --- a/packages/web/vite.config.ts +++ b/packages/web/vite.config.ts @@ -1,6 +1,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import tailwindcss from "@tailwindcss/vite"; +import { tanstackRouter } from "@tanstack/router-plugin/vite"; import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; @@ -13,7 +14,16 @@ export default defineConfig({ "@": path.resolve(__dirname, "src"), }, }, - plugins: [react(), tailwindcss()], + plugins: [ + tanstackRouter({ + routesDirectory: "./src/app", + quoteStyle: "double", + semicolons: true, + routeFileIgnorePattern: "__tests__", + }), + react(), + tailwindcss(), + ], build: { outDir: path.resolve(__dirname, "../cli/web-dist"), emptyOutDir: true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a8f7a33..9565746 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -115,6 +115,9 @@ importers: '@tanstack/react-query': specifier: ^5.100.7 version: 5.100.8(react@19.2.5) + '@tanstack/react-router': + specifier: ^1.169.1 + version: 1.169.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -149,6 +152,9 @@ importers: '@tailwindcss/vite': specifier: ^4.1.18 version: 4.2.4(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.4)) + '@tanstack/router-plugin': + specifier: ^1.167.32 + version: 1.167.32(@tanstack/react-router@1.169.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.4)) '@testing-library/react': specifier: ^16.3.2 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -252,6 +258,18 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + '@babel/plugin-syntax-jsx@7.28.6': + resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.28.6': + resolution: {integrity: sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-react-jsx-self@7.27.1': resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} engines: {node: '>=6.9.0'} @@ -1575,6 +1593,10 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 + '@tanstack/history@1.161.6': + resolution: {integrity: sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg==} + engines: {node: '>=20.19'} + '@tanstack/query-core@5.100.8': resolution: {integrity: sha512-ceYwSFOqjPwET5TA6IOYxzxlGc0ekyH/gfOtWkP0PX43rzX9bxW48Iuw8KAduKCToi4rJAQ6nRy2kAe8gszdmg==} @@ -1583,6 +1605,62 @@ packages: peerDependencies: react: ^18 || ^19 + '@tanstack/react-router@1.169.1': + resolution: {integrity: sha512-MBtQKSvac3OCcsSa6oBpDrrN90IV47I6Gtv05NxhbFVh+gVjtqvs6HSU4XM9+y5sHZPgS+35eArflX4vM8GEnQ==} + engines: {node: '>=20.19'} + peerDependencies: + react: '>=18.0.0 || >=19.0.0' + react-dom: '>=18.0.0 || >=19.0.0' + + '@tanstack/react-store@0.9.3': + resolution: {integrity: sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/router-core@1.169.1': + resolution: {integrity: sha512-x+2gIGKTTE1qAn7tLieGfrB5ciOviDmmi2ox9fAWUubRV+yTU5ruGFXocoCIWF+lB+SOtnHjo2E9BLSWyYoEmA==} + engines: {node: '>=20.19'} + hasBin: true + + '@tanstack/router-generator@1.166.39': + resolution: {integrity: sha512-j2OW/UvpjM/DT9tHVmuhWW1k6UOezTRrPqBPZFFmIth0fY7iTPqK+Erqpo8r5yGTRGCbMvOS4sL3H2MldnIZew==} + engines: {node: '>=20.19'} + + '@tanstack/router-plugin@1.167.32': + resolution: {integrity: sha512-i9BA6GzUCoM20UYZ77orXzHwD5zM0OQTtLuPNbqTTSG38CvR6viRFP/d+QFo2aRNyCvun8PR7zSa49bslSggEQ==} + engines: {node: '>=20.19'} + hasBin: true + peerDependencies: + '@rsbuild/core': '>=1.0.2 || ^2.0.0' + '@tanstack/react-router': ^1.169.1 + vite: '>=5.0.0 || >=6.0.0 || >=7.0.0 || >=8.0.0' + vite-plugin-solid: ^2.11.10 || ^3.0.0-0 + webpack: '>=5.92.0' + peerDependenciesMeta: + '@rsbuild/core': + optional: true + '@tanstack/react-router': + optional: true + vite: + optional: true + vite-plugin-solid: + optional: true + webpack: + optional: true + + '@tanstack/router-utils@1.161.7': + resolution: {integrity: sha512-VkY0u7ax/GD0qU6ZLLnfPC+UMxVzxRbvZp4yV4iUSXjgJZ/siAT5/QlLm9FEDJ9QDoC0VD9W7f00tKKreUI7Ng==} + engines: {node: '>=20.19'} + + '@tanstack/store@0.9.3': + resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==} + + '@tanstack/virtual-file-routes@1.161.7': + resolution: {integrity: sha512-olW33+Cn+bsCsZKPwEGhlkqS6w3M2slFv11JIobdnCFKMLG97oAI2kWKdx5/zsywTL8flpnoIgaZZPlQTFYhdQ==} + engines: {node: '>=20.19'} + hasBin: true + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -1723,6 +1801,10 @@ packages: resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} engines: {node: '>=14'} + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + aria-hidden@1.2.6: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} @@ -1738,6 +1820,9 @@ packages: resolution: {integrity: sha512-trmleAnZ2PxN/loHWVhhx1qeOHSRXq4TDsBBxq3GqeJitfk3+jTQ+v/C1km/KYq9M7wKqCewMh+/NAvVH7m+bw==} engines: {node: '>=20.19.0'} + babel-dead-code-elimination@1.0.12: + resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -1750,6 +1835,10 @@ packages: resolution: {integrity: sha512-wqUv4Gm3toFpHDQmaKD4QhZm3g1DjUBI0yzS4UBl6lElUmXFYdTQmmEDpAFa5o8FiFiymURypEnfVHzILKaxqQ==} engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x} + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + bindings@1.5.0: resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} @@ -1759,6 +1848,10 @@ packages: bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + browserslist@4.28.2: resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -1794,6 +1887,10 @@ packages: character-entities-legacy@3.0.0: resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + chownr@1.1.4: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} @@ -1825,6 +1922,9 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-es@3.1.1: + resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -2059,6 +2159,10 @@ packages: file-uri-to-path@1.0.0: resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} @@ -2085,6 +2189,10 @@ packages: github-from-package@0.0.0: resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -2122,15 +2230,27 @@ packages: ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + is-docker@3.0.0: resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} hasBin: true + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + is-fullwidth-code-point@5.1.0: resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} engines: {node: '>=18'} + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + is-in-ssh@1.0.0: resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==} engines: {node: '>=20'} @@ -2140,10 +2260,18 @@ packages: engines: {node: '>=14.16'} hasBin: true + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + is-wsl@3.1.1: resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} engines: {node: '>=16'} + isbot@5.1.39: + resolution: {integrity: sha512-obH0yYahGXdzNxo+djmHhBYThUKDkz565cxkIlt2L9hXfv1NlaLKoDBHo6KxXsYrIXx2RK3x5vY36CfZcobxEw==} + engines: {node: '>=18'} + jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true @@ -2312,6 +2440,10 @@ packages: node-releases@2.0.38: resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==} + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} @@ -2338,6 +2470,10 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + picomatch@4.0.4: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} @@ -2356,6 +2492,11 @@ packages: deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. hasBin: true + prettier@3.8.3: + resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} + engines: {node: '>=14'} + hasBin: true + pretty-format@27.5.1: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} @@ -2423,6 +2564,10 @@ packages: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + regex-recursion@6.0.2: resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} @@ -2490,6 +2635,16 @@ packages: engines: {node: '>=10'} hasBin: true + seroval-plugins@1.5.2: + resolution: {integrity: sha512-qpY0Cl+fKYFn4GOf3cMiq6l72CpuVaawb6ILjubOQ+diJ54LfOWaSSPsaswN8DRPIPW4Yq+tE1k5aKd7ILyaFg==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + + seroval@1.5.2: + resolution: {integrity: sha512-xcRN39BdsnO9Tf+VzsE7b3JyTJASItIV1FVFewJKCFcW4s4haIKS3e6vj8PGB9qBwC7tnuOywQMdv5N4qkzi7Q==} + engines: {node: '>=10'} + shiki@3.23.0: resolution: {integrity: sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==} @@ -2592,6 +2747,10 @@ packages: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -2667,6 +2826,10 @@ packages: unist-util-visit@5.1.0: resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + unplugin@3.0.0: + resolution: {integrity: sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==} + engines: {node: ^20.19.0 || >=22.12.0} + unrun@0.2.37: resolution: {integrity: sha512-AA7vDuYsgeSYVzJMm16UKA+aXFKhy7nFqW9z5l7q44K4ppFWZAMqYS58ePRZbugMLPH0fwwMzD5A8nP0avxwZQ==} engines: {node: '>=20.19.0'} @@ -2703,6 +2866,11 @@ packages: '@types/react': optional: true + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -2793,6 +2961,9 @@ packages: jsdom: optional: true + webpack-virtual-modules@0.6.2: + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + whatwg-mimetype@3.0.0: resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} engines: {node: '>=12'} @@ -2833,6 +3004,9 @@ packages: engines: {node: '>= 14.6'} hasBin: true + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.4.2: resolution: {integrity: sha512-IynmDyxsEsb9RKzO3J9+4SxXnl2FTFSzNBaKKaMV6tsSk0rw9gYw9gs+JFCq/qk2LCZ78KDwyj+Z289TijSkUw==} @@ -2937,6 +3111,16 @@ snapshots: dependencies: '@babel/types': 8.0.0-rc.3 + '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 @@ -3908,6 +4092,8 @@ snapshots: tailwindcss: 4.2.4 vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.4) + '@tanstack/history@1.161.6': {} + '@tanstack/query-core@5.100.8': {} '@tanstack/react-query@5.100.8(react@19.2.5)': @@ -3915,6 +4101,81 @@ snapshots: '@tanstack/query-core': 5.100.8 react: 19.2.5 + '@tanstack/react-router@1.169.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@tanstack/history': 1.161.6 + '@tanstack/react-store': 0.9.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@tanstack/router-core': 1.169.1 + isbot: 5.1.39 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + + '@tanstack/react-store@0.9.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@tanstack/store': 0.9.3 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + use-sync-external-store: 1.6.0(react@19.2.5) + + '@tanstack/router-core@1.169.1': + dependencies: + '@tanstack/history': 1.161.6 + cookie-es: 3.1.1 + seroval: 1.5.2 + seroval-plugins: 1.5.2(seroval@1.5.2) + + '@tanstack/router-generator@1.166.39': + dependencies: + '@babel/types': 7.29.0 + '@tanstack/router-core': 1.169.1 + '@tanstack/router-utils': 1.161.7 + '@tanstack/virtual-file-routes': 1.161.7 + jiti: 2.6.1 + magic-string: 0.30.21 + prettier: 3.8.3 + zod: 3.25.76 + transitivePeerDependencies: + - supports-color + + '@tanstack/router-plugin@1.167.32(@tanstack/react-router@1.169.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.4))': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@tanstack/router-core': 1.169.1 + '@tanstack/router-generator': 1.166.39 + '@tanstack/router-utils': 1.161.7 + '@tanstack/virtual-file-routes': 1.161.7 + chokidar: 3.6.0 + unplugin: 3.0.0 + zod: 3.25.76 + optionalDependencies: + '@tanstack/react-router': 1.169.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.4) + transitivePeerDependencies: + - supports-color + + '@tanstack/router-utils@1.161.7': + dependencies: + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + ansis: 4.2.0 + babel-dead-code-elimination: 1.0.12 + diff: 8.0.3 + pathe: 2.0.3 + tinyglobby: 0.2.16 + transitivePeerDependencies: + - supports-color + + '@tanstack/store@0.9.3': {} + + '@tanstack/virtual-file-routes@1.161.7': {} + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.0 @@ -4076,6 +4337,11 @@ snapshots: ansis@4.2.0: {} + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + aria-hidden@1.2.6: dependencies: tslib: 2.8.1 @@ -4092,6 +4358,15 @@ snapshots: estree-walker: 3.0.3 pathe: 2.0.3 + babel-dead-code-elimination@1.0.12: + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.3 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + base64-js@1.5.1: {} baseline-browser-mapping@2.10.25: {} @@ -4101,6 +4376,8 @@ snapshots: bindings: 1.5.0 prebuild-install: 7.1.3 + binary-extensions@2.3.0: {} + bindings@1.5.0: dependencies: file-uri-to-path: 1.0.0 @@ -4113,6 +4390,10 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + browserslist@4.28.2: dependencies: baseline-browser-mapping: 2.10.25 @@ -4144,6 +4425,18 @@ snapshots: character-entities-legacy@3.0.0: {} + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + chownr@1.1.4: {} class-variance-authority@0.7.1: @@ -4169,6 +4462,8 @@ snapshots: convert-source-map@2.0.0: {} + cookie-es@3.1.1: {} + csstype@3.2.3: {} debug@4.4.3: @@ -4342,6 +4637,10 @@ snapshots: file-uri-to-path@1.0.0: {} + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + fs-constants@1.0.0: {} fsevents@2.3.3: @@ -4359,6 +4658,10 @@ snapshots: github-from-package@0.0.0: {} + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + graceful-fs@4.2.11: {} happy-dom@20.9.0: @@ -4405,22 +4708,36 @@ snapshots: ini@1.3.8: {} + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + is-docker@3.0.0: {} + is-extglob@2.1.1: {} + is-fullwidth-code-point@5.1.0: dependencies: get-east-asian-width: 1.5.0 + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + is-in-ssh@1.0.0: {} is-inside-container@1.0.0: dependencies: is-docker: 3.0.0 + is-number@7.0.0: {} + is-wsl@3.1.1: dependencies: is-inside-container: 1.0.0 + isbot@5.1.39: {} + jiti@2.6.1: {} js-tokens@4.0.0: {} @@ -4569,6 +4886,8 @@ snapshots: node-releases@2.0.38: {} + normalize-path@3.0.0: {} + obug@2.1.1: {} once@1.4.0: @@ -4600,6 +4919,8 @@ snapshots: picocolors@1.1.1: {} + picomatch@2.3.2: {} + picomatch@4.0.4: {} postcss@8.5.13: @@ -4625,6 +4946,8 @@ snapshots: tar-fs: 2.1.4 tunnel-agent: 0.6.0 + prettier@3.8.3: {} + pretty-format@27.5.1: dependencies: ansi-regex: 5.0.1 @@ -4691,6 +5014,10 @@ snapshots: string_decoder: 1.3.0 util-deprecate: 1.0.2 + readdirp@3.6.0: + dependencies: + picomatch: 2.3.2 + regex-recursion@6.0.2: dependencies: regex-utilities: 2.3.0 @@ -4790,6 +5117,12 @@ snapshots: semver@7.7.4: {} + seroval-plugins@1.5.2(seroval@1.5.2): + dependencies: + seroval: 1.5.2 + + seroval@1.5.2: {} + shiki@3.23.0: dependencies: '@shikijs/core': 3.23.0 @@ -4898,6 +5231,10 @@ snapshots: tinyrainbow@3.1.0: {} + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + tree-kill@1.2.2: {} trim-lines@3.0.1: {} @@ -4976,6 +5313,12 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 + unplugin@3.0.0: + dependencies: + '@jridgewell/remapping': 2.3.5 + picomatch: 4.0.4 + webpack-virtual-modules: 0.6.2 + unrun@0.2.37: dependencies: rolldown: 1.0.0-rc.17 @@ -5001,6 +5344,10 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + use-sync-external-store@1.6.0(react@19.2.5): + dependencies: + react: 19.2.5 + util-deprecate@1.0.2: {} vfile-message@4.0.3: @@ -5057,6 +5404,8 @@ snapshots: transitivePeerDependencies: - msw + webpack-virtual-modules@0.6.2: {} + whatwg-mimetype@3.0.0: {} why-is-node-running@2.3.0: @@ -5083,6 +5432,8 @@ snapshots: yaml@2.8.4: {} + zod@3.25.76: {} + zod@4.4.2: {} zwitch@2.0.4: {} From 041833aeb7465c88d2e157980874142ed0bc2660 Mon Sep 17 00:00:00 2001 From: Dean Stratakos <29683763+dastratakos@users.noreply.github.com> Date: Sun, 3 May 2026 15:04:11 -0700 Subject: [PATCH 2/7] fix(web): make legacy hash redirect actually fire TanStack Router's `ParsedLocation.hash` strips the leading `#` (see router-core/src/router.ts: `hash: decodePath(hash.slice(1)).path`), so the previous `location.hash.startsWith("#/runs/")` check never matched and the redirect was dead. `slice(1)` was also stripping the leading `/` rather than a (non-existent) `#`. Match `/runs/` and pass `location.hash` through unchanged so legacy `/#/runs/abc` URLs redirect to `/runs/abc`. --- packages/web/src/app/__root.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/web/src/app/__root.tsx b/packages/web/src/app/__root.tsx index 337d421..47229b2 100644 --- a/packages/web/src/app/__root.tsx +++ b/packages/web/src/app/__root.tsx @@ -5,9 +5,11 @@ export const Route = createRootRouteWithContext<{ queryClient: QueryClient; }>()({ beforeLoad: ({ location }) => { - if (location.hash.startsWith("#/runs/")) { + // TanStack Router strips the leading `#` from `location.hash`, so a legacy + // URL like `/#/runs/abc` lands here with `hash === "/runs/abc"`. + if (location.hash.startsWith("/runs/")) { throw redirect({ - to: location.hash.slice(1), + to: location.hash, replace: true, }); } From 0aaa0c13d223615a5d2afb61ab4f46ad0e8879cc Mon Sep 17 00:00:00 2001 From: Dean Stratakos <29683763+dastratakos@users.noreply.github.com> Date: Sun, 3 May 2026 16:45:59 -0700 Subject: [PATCH 3/7] refactor(web): extract countViewedChapters helper Both `PullRequestLayout` (for the tab count chip) and `ChaptersRoute` (for prop-drilling into ChaptersIndexPage) ran the same useMemo loop to count viewed chapters. The duplication was introduced by the TanStack Router migration since `Outlet` doesn't pass props from layout to child. Extract the loop into a pure helper next to `useViewStateData` and call it from both sites. --- packages/web/src/app/runs.$runId.index.tsx | 12 +++++------- packages/web/src/lib/use-view-state.ts | 10 ++++++++++ packages/web/src/routes/pull-request-layout.tsx | 12 +++++------- 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/packages/web/src/app/runs.$runId.index.tsx b/packages/web/src/app/runs.$runId.index.tsx index 8ee7ac0..c66e1be 100644 --- a/packages/web/src/app/runs.$runId.index.tsx +++ b/packages/web/src/app/runs.$runId.index.tsx @@ -1,7 +1,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { useMemo } from "react"; import { useChapters } from "@/lib/use-chapters"; -import { useViewStateData } from "@/lib/use-view-state"; +import { countViewedChapters, useViewStateData } from "@/lib/use-view-state"; import { ChaptersIndexPage } from "@/routes/chapters-index-page"; export const Route = createFileRoute("/runs/$runId/")({ @@ -13,12 +13,10 @@ function ChaptersRoute() { const { data, isLoading } = useChapters(runId); const { chapterIdSet } = useViewStateData(runId); const chapters = data?.chapters; - const viewedCount = useMemo(() => { - if (!chapters) return 0; - let n = 0; - for (const chapter of chapters) if (chapterIdSet.has(chapter.externalId)) n++; - return n; - }, [chapters, chapterIdSet]); + const viewedCount = useMemo( + () => countViewedChapters(chapters, chapterIdSet), + [chapters, chapterIdSet], + ); return ( void; } +export function countViewedChapters( + chapters: ReadonlyArray<{ externalId: string }> | undefined, + chapterIdSet: ReadonlySet, +): number { + if (!chapters) return 0; + let count = 0; + for (const c of chapters) if (chapterIdSet.has(c.externalId)) count++; + return count; +} + // Returns stable Sets so callers can use them as effect/memo deps. // Read-only — `useViewState` adds the mutation hooks on top of this. export function useViewStateData(runId: string): UseViewStateDataResult { diff --git a/packages/web/src/routes/pull-request-layout.tsx b/packages/web/src/routes/pull-request-layout.tsx index 754ee1a..5be4196 100644 --- a/packages/web/src/routes/pull-request-layout.tsx +++ b/packages/web/src/routes/pull-request-layout.tsx @@ -5,7 +5,7 @@ import { SectionLabel } from "@/components/pull-request/section-label"; import { useFileDiffEntries } from "@/lib/parse-diff"; import { useChapters } from "@/lib/use-chapters"; import { useDiffPatch } from "@/lib/use-diff-patch"; -import { useViewStateData } from "@/lib/use-view-state"; +import { countViewedChapters, useViewStateData } from "@/lib/use-view-state"; import { cn } from "@/lib/utils"; const PR_TAB = { @@ -80,12 +80,10 @@ export function PullRequestLayout({ runId }: { runId: string }) { const { chapterIdSet, filePathSet } = useViewStateData(runId); const chapters = data?.chapters; - const viewedChapterCount = useMemo(() => { - if (!chapters) return 0; - let n = 0; - for (const c of chapters) if (chapterIdSet.has(c.externalId)) n++; - return n; - }, [chapters, chapterIdSet]); + const viewedChapterCount = useMemo( + () => countViewedChapters(chapters, chapterIdSet), + [chapters, chapterIdSet], + ); // Fetched here so the Files tab's "N/M viewed" label can render before the // user clicks into the tab; react-query dedupes the same fetch from FilesPage. From f91ff230d5739f6847c93a97e3ad08e4cd08213d Mon Sep 17 00:00:00 2001 From: Dean Stratakos <29683763+dastratakos@users.noreply.github.com> Date: Sun, 3 May 2026 16:46:28 -0700 Subject: [PATCH 4/7] refactor(web): make router a module-level singleton `queryClient` was a module-level constant but the router was created fresh on every `getRouter()` call, which is asymmetric and risks multiple routers sharing one queryClient (e.g. under HMR or in tests). Match TanStack Router's canonical setup (https://tanstack.com/router/latest/docs/framework/react/quick-start) by exporting `router` directly. Drop the `getRouter`/`getQueryClient` factory wrappers in favor of named exports. --- packages/web/src/main.tsx | 5 +---- packages/web/src/router.tsx | 28 +++++++++++----------------- 2 files changed, 12 insertions(+), 21 deletions(-) diff --git a/packages/web/src/main.tsx b/packages/web/src/main.tsx index 6ae2fd5..58f2f4e 100644 --- a/packages/web/src/main.tsx +++ b/packages/web/src/main.tsx @@ -4,7 +4,7 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { ThemeProvider } from "./lib/theme"; import { DiffSettingsProvider } from "./lib/use-diff-settings"; -import { getQueryClient, getRouter } from "./router"; +import { queryClient, router } from "./router"; import "./styles/globals.css"; const rootElement = document.getElementById("root"); @@ -12,9 +12,6 @@ if (!rootElement) { throw new Error("Root element #root not found"); } -const router = getRouter(); -const queryClient = getQueryClient(); - createRoot(rootElement).render( diff --git a/packages/web/src/router.tsx b/packages/web/src/router.tsx index cbf2919..3116310 100644 --- a/packages/web/src/router.tsx +++ b/packages/web/src/router.tsx @@ -2,7 +2,7 @@ import { QueryClient } from "@tanstack/react-query"; import { createRouter } from "@tanstack/react-router"; import { routeTree } from "./routeTree.gen"; -const queryClient = new QueryClient({ +export const queryClient = new QueryClient({ defaultOptions: { queries: { // Two browser tabs on the same run see synchronized state via refetch on focus @@ -13,24 +13,18 @@ const queryClient = new QueryClient({ }, }); -export function getQueryClient() { - return queryClient; -} - -export function getRouter() { - return createRouter({ - routeTree, - context: { - queryClient, - }, - scrollRestoration: true, - defaultPreload: "intent", - defaultPreloadStaleTime: 0, - }); -} +export const router = createRouter({ + routeTree, + context: { + queryClient, + }, + scrollRestoration: true, + defaultPreload: "intent", + defaultPreloadStaleTime: 0, +}); declare module "@tanstack/react-router" { interface Register { - router: ReturnType; + router: typeof router; } } From db957b37ed8282750da3baaeb519caa2debb6e99 Mon Sep 17 00:00:00 2001 From: Dean Stratakos <29683763+dastratakos@users.noreply.github.com> Date: Sun, 3 May 2026 17:22:32 -0700 Subject: [PATCH 5/7] refactor(web): drop legacy hash-route redirect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-ship there are no users with bookmarked `/#/runs/...` URLs to preserve. The earlier fix (041833a) made the redirect actually fire, but the simpler answer is to delete it entirely — YAGNI. --- packages/web/src/app/__root.tsx | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/packages/web/src/app/__root.tsx b/packages/web/src/app/__root.tsx index 47229b2..00bb2d1 100644 --- a/packages/web/src/app/__root.tsx +++ b/packages/web/src/app/__root.tsx @@ -1,19 +1,9 @@ import type { QueryClient } from "@tanstack/react-query"; -import { createRootRouteWithContext, Outlet, redirect } from "@tanstack/react-router"; +import { createRootRouteWithContext, Outlet } from "@tanstack/react-router"; export const Route = createRootRouteWithContext<{ queryClient: QueryClient; }>()({ - beforeLoad: ({ location }) => { - // TanStack Router strips the leading `#` from `location.hash`, so a legacy - // URL like `/#/runs/abc` lands here with `hash === "/runs/abc"`. - if (location.hash.startsWith("/runs/")) { - throw redirect({ - to: location.hash, - replace: true, - }); - } - }, component: RootLayout, }); From d4908f76f9c0f2780d29c0a75f7d01dbc1511b7e Mon Sep 17 00:00:00 2001 From: Dean Stratakos <29683763+dastratakos@users.noreply.github.com> Date: Sun, 3 May 2026 17:43:34 -0700 Subject: [PATCH 6/7] refactor(web): infer PR tab type from tabs array --- .../web/src/routes/pull-request-layout.tsx | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/packages/web/src/routes/pull-request-layout.tsx b/packages/web/src/routes/pull-request-layout.tsx index 5be4196..de9bc22 100644 --- a/packages/web/src/routes/pull-request-layout.tsx +++ b/packages/web/src/routes/pull-request-layout.tsx @@ -1,6 +1,6 @@ import { Link, Outlet, useRouterState } from "@tanstack/react-router"; import { BookOpen, FileText } from "lucide-react"; -import { type CSSProperties, type ElementType, useEffect, useMemo, useRef, useState } from "react"; +import { type CSSProperties, useEffect, useMemo, useRef, useState } from "react"; import { SectionLabel } from "@/components/pull-request/section-label"; import { useFileDiffEntries } from "@/lib/parse-diff"; import { useChapters } from "@/lib/use-chapters"; @@ -14,20 +14,18 @@ const PR_TAB = { } as const; type PrTab = (typeof PR_TAB)[keyof typeof PR_TAB]; -interface TabDef { - id: PrTab; - label: string; - icon: ElementType; - to: "/runs/$runId" | "/runs/$runId/files"; -} - -const tabs: TabDef[] = [ - { id: PR_TAB.CHAPTERS, label: "Chapters", icon: BookOpen, to: "/runs/$runId" }, - { id: PR_TAB.FILES, label: "Files changed", icon: FileText, to: "/runs/$runId/files" }, +const tabs = [ + { id: PR_TAB.CHAPTERS, label: "Chapters", icon: BookOpen, to: "/runs/$runId" as const }, + { + id: PR_TAB.FILES, + label: "Files changed", + icon: FileText, + to: "/runs/$runId/files" as const, + }, ]; interface TabLinkProps { - tab: TabDef; + tab: (typeof tabs)[number]; runId: string; isActive: boolean; countLabel?: string; From 3b7d776d10a4abe02aaf465aa9eeef613fe5e84a Mon Sep 17 00:00:00 2001 From: Dean Stratakos <29683763+dastratakos@users.noreply.github.com> Date: Sun, 3 May 2026 17:45:08 -0700 Subject: [PATCH 7/7] refactor: scope query freshness by data type --- packages/web/src/lib/use-chapters.ts | 1 + packages/web/src/lib/use-diff-patch.ts | 1 + packages/web/src/router.tsx | 11 +---------- 3 files changed, 3 insertions(+), 10 deletions(-) diff --git a/packages/web/src/lib/use-chapters.ts b/packages/web/src/lib/use-chapters.ts index 5939d82..8bea36c 100644 --- a/packages/web/src/lib/use-chapters.ts +++ b/packages/web/src/lib/use-chapters.ts @@ -17,5 +17,6 @@ export function useChapters(runId: string | null) { return useQuery({ queryKey: ["chapters", runId], queryFn: runId === null ? skipToken : () => fetchChapters(runId), + staleTime: Number.POSITIVE_INFINITY, }); } diff --git a/packages/web/src/lib/use-diff-patch.ts b/packages/web/src/lib/use-diff-patch.ts index 3d42c56..0afb056 100644 --- a/packages/web/src/lib/use-diff-patch.ts +++ b/packages/web/src/lib/use-diff-patch.ts @@ -17,5 +17,6 @@ export function useDiffPatch(runId: string): UseQueryResult { return res.text(); }, enabled: runId !== "", + staleTime: Number.POSITIVE_INFINITY, }); } diff --git a/packages/web/src/router.tsx b/packages/web/src/router.tsx index 3116310..fbb540a 100644 --- a/packages/web/src/router.tsx +++ b/packages/web/src/router.tsx @@ -2,16 +2,7 @@ import { QueryClient } from "@tanstack/react-query"; import { createRouter } from "@tanstack/react-router"; import { routeTree } from "./routeTree.gen"; -export const queryClient = new QueryClient({ - defaultOptions: { - queries: { - // Two browser tabs on the same run see synchronized state via refetch on focus - // (acceptance criterion). 30s staleTime keeps a single tab from re-fetching too eagerly. - staleTime: 30_000, - refetchOnWindowFocus: true, - }, - }, -}); +export const queryClient = new QueryClient(); export const router = createRouter({ routeTree,