refactor(web): migrate to data router (createBrowserRouter) to enable useBlocker (#46)

Convert app.tsx route tree verbatim to a module-level data router via
createRoutesFromElements + RouterProvider, and the test harness to
createMemoryRouter + RouterProvider. The search NavLink-click test now mounts
its routes as real data-router routes so RouterProvider intercepts the link
(descendant <Routes> under a catch-all let it fall through to a jsdom navigation).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 23:07:03 +02:00
parent f3881e8c7c
commit ed0c13907c
3 changed files with 83 additions and 49 deletions
+46 -44
View File
@@ -1,5 +1,5 @@
import { lazy, Suspense } from "react";
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import { createBrowserRouter, createRoutesFromElements, Navigate, Route, RouterProvider } from "react-router-dom";
import { RequireAuth } from "./auth/require-auth";
import { LoginPage } from "./auth/login-page";
@@ -29,55 +29,57 @@ function FormFallback() {
return <div role="status" className="p-4 text-sm text-muted-foreground">Loading</div>;
}
export function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route element={<RequireAuth />}>
<Route element={<AppShell />}>
const router = createBrowserRouter(
createRoutesFromElements(
<>
<Route path="/login" element={<LoginPage />} />
<Route element={<RequireAuth />}>
<Route element={<AppShell />}>
<Route
path="/objects/new"
element={
<Suspense fallback={<FormFallback />}>
<ObjectNewPage />
</Suspense>
}
/>
<Route path="/objects" element={<ObjectsPage />}>
<Route path=":id" element={<ObjectDetail />} />
<Route
path="/objects/new"
path=":id/edit"
element={
<Suspense fallback={<FormFallback />}>
<ObjectNewPage />
<ObjectEditForm />
</Suspense>
}
/>
<Route path="/objects" element={<ObjectsPage />}>
<Route path=":id" element={<ObjectDetail />} />
<Route
path=":id/edit"
element={
<Suspense fallback={<FormFallback />}>
<ObjectEditForm />
</Suspense>
}
/>
</Route>
<Route path="/vocabularies" element={<VocabulariesPage />}>
<Route index element={<SelectVocabularyPrompt />} />
<Route path=":id" element={<VocabularyTerms />} />
</Route>
<Route path="/search" element={<SearchPage />}>
<Route index element={<SelectSearchPrompt />} />
<Route path=":id" element={<ObjectDetail />} />
</Route>
<Route path="/authorities" element={<Navigate to="/authorities/person" replace />} />
<Route path="/authorities/:kind" element={<AuthoritiesPage />} />
<Route
path="/fields"
element={
<Suspense fallback={<FormFallback />}>
<FieldsPage />
</Suspense>
}
/>
<Route path="/" element={<Navigate to="/objects" replace />} />
</Route>
<Route path="/vocabularies" element={<VocabulariesPage />}>
<Route index element={<SelectVocabularyPrompt />} />
<Route path=":id" element={<VocabularyTerms />} />
</Route>
<Route path="/search" element={<SearchPage />}>
<Route index element={<SelectSearchPrompt />} />
<Route path=":id" element={<ObjectDetail />} />
</Route>
<Route path="/authorities" element={<Navigate to="/authorities/person" replace />} />
<Route path="/authorities/:kind" element={<AuthoritiesPage />} />
<Route
path="/fields"
element={
<Suspense fallback={<FormFallback />}>
<FieldsPage />
</Suspense>
}
/>
<Route path="/" element={<Navigate to="/objects" replace />} />
</Route>
<Route path="*" element={<Navigate to="/objects" replace />} />
</Routes>
</BrowserRouter>
);
</Route>
<Route path="*" element={<Navigate to="/objects" replace />} />
</>,
),
);
export function App() {
return <RouterProvider router={router} />;
}
+34 -3
View File
@@ -1,8 +1,9 @@
import { expect, test } from "vitest";
import { screen, waitFor, within } from "@testing-library/react";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
import { Route, Routes } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createMemoryRouter, Route, RouterProvider, Routes } from "react-router-dom";
import { server } from "../test/server";
import { renderApp } from "../test/render";
@@ -10,6 +11,7 @@ import { amphora } from "../test/fixtures";
import { SearchPage } from "./search-page";
import { SelectSearchPrompt } from "./select-search-prompt";
import { ObjectDetail } from "../objects/object-detail";
import "../i18n";
function tree() {
return (
@@ -22,6 +24,35 @@ function tree() {
);
}
// The search rows are <NavLink>s. Under the shared `renderApp` harness the test
// subtree lives in a descendant <Routes> under a catch-all `*` data route, where
// the data router does not intercept the link click (it falls through to a real
// browser navigation that jsdom rejects). Mounting the search routes as real
// data-router routes lets RouterProvider intercept the NavLink, which is the
// data-router equivalent of the old MemoryRouter behavior.
function renderSearchRouter(route = "/search") {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const router = createMemoryRouter(
[
{
path: "/search",
element: <SearchPage />,
children: [
{ index: true, element: <SelectSearchPrompt /> },
{ path: ":id", element: <ObjectDetail /> },
],
},
],
{ initialEntries: [route] },
);
return render(
<QueryClientProvider client={qc}>
<RouterProvider router={router} />
</QueryClientProvider>,
);
}
test("typing searches and renders highlighted rich rows", async () => {
renderApp(tree(), { route: "/search" });
await userEvent.type(screen.getByLabelText(/search the collection/i), "bronze");
@@ -69,7 +100,7 @@ test("empty query shows the prompt; zero results shows empty", async () => {
});
test("clicking a result shows the object in the detail pane", async () => {
renderApp(tree(), { route: "/search" });
renderSearchRouter();
await userEvent.type(screen.getByLabelText(/search the collection/i), "bronze");
await userEvent.click(await screen.findByText("Bronze figurine"));
+3 -2
View File
@@ -1,16 +1,17 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render } from "@testing-library/react";
import type { ReactElement } from "react";
import { MemoryRouter } from "react-router-dom";
import { createMemoryRouter, RouterProvider } from "react-router-dom";
import "../i18n";
export function renderApp(ui: ReactElement, { route = "/" } = {}) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const router = createMemoryRouter([{ path: "*", element: ui }], { initialEntries: [route] });
return render(
<QueryClientProvider client={qc}>
<MemoryRouter initialEntries={[route]}>{ui}</MemoryRouter>
<RouterProvider router={router} />
</QueryClientProvider>,
);
}