diff --git a/.github/workflows/ci-test.yaml b/.github/workflows/ci-test.yaml
index 9ec5ba275b..385120b02c 100644
--- a/.github/workflows/ci-test.yaml
+++ b/.github/workflows/ci-test.yaml
@@ -256,6 +256,7 @@ jobs:
set: |
*.context=.
*.args.VERSION=${{ github.sha }}
+ frontend.args.VITE_V1_DEV_ROUTES=true
frontend.cache-from=type=gha,scope=frontend
frontend.cache-to=type=gha,mode=max,scope=frontend
backend.cache-from=type=gha,scope=backend
diff --git a/docker/dockerfiles/frontend.Dockerfile b/docker/dockerfiles/frontend.Dockerfile
index 0c3fdcc92a..4c03dfa1f0 100644
--- a/docker/dockerfiles/frontend.Dockerfile
+++ b/docker/dockerfiles/frontend.Dockerfile
@@ -46,8 +46,23 @@ RUN bun install --frozen-lockfile --ignore-scripts
# Copy the rest of the application files
COPY ${BUILD_CONTEXT_PATH}/ .
+# Declared after the install layer so a different value (the E2E image sets
+# VITE_V1_DEV_ROUTES) does not invalidate the cached install it shares with
+# release builds.
+# VITE_V1_DEV_ROUTES: "true" only for the E2E image (ci-test.yaml); registers
+# the v1 demo pages that prove the version switch and honours the v1 preview
+# override. Release builds leave it empty.
+# VITE_DISTRIBUTION: oss (default when empty), cloud or onprem; the
+# v1 route registry ignores pages the distribution lacks. The same name as
+# P0-T07's plugin manifest (UN-4286). The cloud and on-prem builds do not set
+# it yet, so every image builds as oss until P0-T07 wires them.
+ARG VITE_V1_DEV_ROUTES=""
+ARG VITE_DISTRIBUTION=""
+
# Build with Vite
-RUN bun run build
+RUN VITE_V1_DEV_ROUTES="${VITE_V1_DEV_ROUTES}" \
+ VITE_DISTRIBUTION="${VITE_DISTRIBUTION}" \
+ bun run build
# Production stage
FROM nginx:1.29.1-alpine AS production
diff --git a/frontend/src/routes/Router.jsx b/frontend/src/routes/Router.jsx
index 05bcea6b18..3c031e0625 100644
--- a/frontend/src/routes/Router.jsx
+++ b/frontend/src/routes/Router.jsx
@@ -1,5 +1,6 @@
+import { setOldVersion, VersionSwitch, v1DevRoutesEnabled } from "@v1";
import { Suspense } from "react";
-import { Route, Routes, useLocation } from "react-router-dom";
+import { Route, Routes, useLocation, useParams } from "react-router-dom";
import { GenericError } from "../components/error/GenericError/GenericError.jsx";
import {
@@ -17,6 +18,7 @@ import { lazyNamed } from "../helpers/lazyNamed.js";
import { isModuleMissing } from "../helpers/pluginLoader.js";
import { lazyPlugin } from "../helpers/pluginRegistry.js";
import { LandingPage } from "../pages/LandingPage.jsx";
+import { useSessionStore } from "../store/session-store.js";
import { useMainAppRoutes } from "./useMainAppRoutes.jsx";
// Heavy route pages are code-split so the unauthenticated /landing page does
@@ -145,115 +147,155 @@ try {
}
}
+// Stand-ins for the v0 side of the v1 demo pages (UN-4271), which prove the
+// version switch; routed only in a build with VITE_V1_DEV_ROUTES=true. The
+// demo page is always live there, so V0DemoPage renders only if the switch
+// fails to redirect: P0-T09's E2E specs (UN-4345, #2355) check for it, so it
+// is not dead code.
+function V0DemoPage() {
+ return
v0 demo page ;
+}
+
+function V0DemoReviewPage() {
+ const { orgName } = useParams();
+ return (
+
+
v0 demo review
+
{orgName}
+
setOldVersion(false)}>
+ Switch to new version
+
+
+ );
+}
+
function Router() {
const location = useLocation();
const MainAppRoute = useMainAppRoutes();
+ const userId = useSessionStore((state) => state.sessionDetails?.id);
return (
- // App-wide backstop: catches shell-level chunk failures and errors on
- // routes that don't sit inside a LazyOutlet layout. Per-page load errors
- // are handled by the scoped boundaries inside the layouts. resetKeys lets
- // navigation clear the error without a full reload.
- }
- >
- }>
-
- } />
- }>
- {/* public routes */}
-
- {/* public routes accessible only to unauthenticated users */}
- }>
- } />
-
+ // Renders v1 for a URL whose page is live in the v1 route registry,
+ // and these v0 routes for everything else (UN-4271).
+
+ {/* v0-wide backstop (the v1 tree has its own, in VersionSwitch):
+ catches shell-level chunk failures and errors on routes that don't
+ sit inside a LazyOutlet layout. Per-page load
+ errors are handled by the scoped boundaries inside the layouts.
+ resetKeys lets navigation clear the error without a full reload. */}
+ }
+ >
+ }>
+
+ } />
+ }>
+ {/* public routes */}
+
+ {/* public routes accessible only to unauthenticated users */}
+ }>
+ } />
+
- {/* public routes accessible to both authenticated and unauthenticated users */}
- {SimplePromptStudioHelper &&
- SimplePromptStudio &&
- SpsLanding &&
- SpsUpload && (
+ {/* public routes accessible to both authenticated and unauthenticated users */}
+ {SimplePromptStudioHelper &&
+ SimplePromptStudio &&
+ SpsLanding &&
+ SpsUpload && (
+ }
+ >
+ } />
+ } />
+ } />
+
+ )}
+ {PublicPromptStudioHelper && (
}
+ path="/promptStudio/share/:id"
+ element={ }
>
- } />
- } />
- } />
+ } />
+ }
+ />
)}
- {PublicPromptStudioHelper && (
+
+
+ {/* protected routes */}
+ } />
+ {SelectProduct && (
+ } />
+ )}
+ {UnstractSubscriptionEndPage && (
}
- >
- } />
- }
- />
-
+ path="/subscription-expired"
+ element={ }
+ />
)}
-
-
- {/* protected routes */}
- } />
- {SelectProduct && (
- } />
- )}
- {UnstractSubscriptionEndPage && (
- }
- />
- )}
- {PaymentSuccessful && (
- } />
- )}
- {MarketplaceLandingEntry && (
- }
- />
- )}
- {MarketplaceStripeConflictEntry && (
- }
- />
- )}
- {LlmWhispererMarketplaceLandingEntry && (
- }
- />
- )}
- {CustomPlanCheckoutPage && (
- }
- />
- )}
- {LlmWhispererCustomCheckoutPage && (
- }
- />
- )}
- }>
- {MainAppRoute}
- {llmWhispererRouter && (
- {llmWhispererRouter()}
+ {PaymentSuccessful && (
+ }
+ />
+ )}
+ {MarketplaceLandingEntry && (
+ }
+ />
+ )}
+ {MarketplaceStripeConflictEntry && (
+ }
+ />
+ )}
+ {LlmWhispererMarketplaceLandingEntry && (
+ }
+ />
)}
+ {CustomPlanCheckoutPage && (
+ }
+ />
+ )}
+ {LlmWhispererCustomCheckoutPage && (
+ }
+ />
+ )}
+ }>
+ {MainAppRoute}
+ {llmWhispererRouter && (
+ {llmWhispererRouter()}
+ )}
+ {v1DevRoutesEnabled && (
+ <>
+ } />
+ }
+ />
+ >
+ )}
+
+ {verticalsRouter && verticalsRouter()}
- {verticalsRouter && verticalsRouter()}
-
- } />
- } />
-
-
-
+ } />
+ } />
+
+
+
+
);
}
diff --git a/frontend/src/v1/README.md b/frontend/src/v1/README.md
index eafb03f034..405387cbda 100644
--- a/frontend/src/v1/README.md
+++ b/frontend/src/v1/README.md
@@ -108,6 +108,52 @@ is convention.
- `CodeEditor` uses Monaco from its default CDN, as v0 does. Never call
`loader.config()` in v1: the loader is shared with v0.
+## Version switch and route registry
+
+v0's router (`routes/Router.jsx`) wraps its routes in `VersionSwitch`, which
+renders v1 for a URL whose page is live in the registry (`app/registry/`) and
+v0 for everything else. v1 renders as its own tree (`app/V1App.tsx`), loaded
+only when a v1 page first renders.
+
+- **Declare** a page in `app/registry/entries.ts` (`PAGES`) with its v1 routes,
+ the v0 routes it replaces and the mapping both ways, and add its component
+ to `app/pages.ts`. Declared pages stay v0 for users.
+- **Register** it, in its go-live task only (D9), by adding its key to
+ `REGISTERED_PAGES`. Its v0 URLs then redirect to v1; reverting is that line.
+ v1 renders outside v0's `PersistentLogin` and `RequireAuth`, and its own
+ session and guards come with P0-T06: a page registered before that ships
+ with no session load, login redirect or org check.
+- `app/registry/validate.test.ts` fails CI on an invalid entry: overlapping
+ patterns across pages, a v1 route and a different v0 route of one page that
+ match the same URL (a page may keep its v0 URL), a route without a mapping
+ to the other version, a page without a component, or `toggle` on any page
+ but Human Review (UN11, D14) and its demo stand-in.
+- Human Review's old-version preference is per user, per browser, in
+ localStorage (`unstract.v1.oldVersion.`). `setOldVersion(on)` sets
+ it, and the switch then moves the user to the mapped screen without a
+ reload. `unstract.v1.lastUser` lets a reload apply it before the session
+ loads; on a shared browser that is the previous user's choice until the
+ session says otherwise.
+- Until the v1 session lands (P0-T06), the switch learns the user only from
+ v0's session, which v0 loads in `PersistentLogin`, a v0 route. A page load
+ that starts on a v1 URL never loads it, so the switch has only
+ `unstract.v1.lastUser`. With none (a fresh browser, or an E2E context
+ signed in by API), `setOldVersion` returns false and changes nothing; with
+ another person's, a choice made there is saved as theirs. Both last until a
+ v0 page loads in that tab. E2E round trips seed `unstract.v1.lastUser` or
+ open a v0 page first.
+- `VITE_V1_DEV_ROUTES=true` (the E2E image, or `.env` locally) registers the
+ demo pages (`/:orgName/v1-demo`, `/:orgName/v1-demo-review`, and their v0
+ stand-ins `/:orgName/v0-demo…`). It also renders declared pages as v1 before
+ they go live: set `localStorage["unstract.v1.preview"] = '["UN05"]'`, then
+ reload (it is read when the app loads).
+- `VITE_DISTRIBUTION` (`oss` by default, `cloud` or `onprem`) selects
+ the build's distribution; entries whose `distributions` omit it are ignored.
+ An unknown value fails `vite build`. P0-T07's plugin manifest (UN-4286)
+ reads the same variable. The cloud and on-prem image builds do not set it
+ yet, so every image builds as `oss`: until P0-T07 wires them, an entry that
+ lists fewer than all three distributions is not filtered correctly there.
+
## Folders and what each may import
Every folder may import from itself. Test files (`*.test.ts(x)`,
@@ -115,7 +161,7 @@ Every folder may import from itself. Test files (`*.test.ts(x)`,
| Folder | Holds | May import from |
| --- | --- | --- |
-| `index.ts` | Public entry; the version switch (UN-4271) will mount v1 through it | everything |
+| `index.ts` | Public entry: the version switch and its helpers | everything |
| `app/` | Entry, providers, router, route registry | everything |
| `components/ui/` | Core components | `lib/`, `styles/`, `assets/` |
| `components/patterns/` | Composites built from core components | `components/ui/`, `lib/`, `services/`, `stores/`, `hooks/`, `assets/` |
diff --git a/frontend/src/v1/app/.gitkeep b/frontend/src/v1/app/.gitkeep
deleted file mode 100644
index e69de29bb2..0000000000
diff --git a/frontend/src/v1/app/V1App.tsx b/frontend/src/v1/app/V1App.tsx
new file mode 100644
index 0000000000..22ddb35053
--- /dev/null
+++ b/frontend/src/v1/app/V1App.tsx
@@ -0,0 +1,36 @@
+/*
+ * The v1 tree: its own providers and routes, never inside a v0 layout or
+ * guard. Loaded lazily by the version switch, so v1's code reaches the
+ * browser only when a v1 page renders; its styles are in the global
+ * stylesheet (vite.config.js sets cssCodeSplit: false). The Toaster mounts
+ * here, once, so `notify()` reaches the user on every v1 page. Session,
+ * guards (P0-T06) and the shell (P2-T01) mount here.
+ */
+
+import type { RegistryEntry } from "@v1/app/registry/types";
+import { ThemeProvider } from "@v1/app/theme/ThemeProvider";
+import { Toaster } from "@v1/components/ui/feedback";
+import { type JSX, Suspense } from "react";
+import { Route, Routes } from "react-router-dom";
+import { PAGE_COMPONENTS } from "./pages";
+
+export type V1AppProps = Readonly<{ entry: RegistryEntry }>;
+
+export function V1App({ entry }: V1AppProps): JSX.Element {
+ const Page = PAGE_COMPONENTS[entry.page];
+ if (!Page) {
+ throw new Error(`v1 page ${entry.page} is registered but has no component`);
+ }
+ return (
+
+
+
+ {entry.v1Routes.map((path) => (
+ } />
+ ))}
+
+
+
+
+ );
+}
diff --git a/frontend/src/v1/app/VersionSwitch.test.tsx b/frontend/src/v1/app/VersionSwitch.test.tsx
new file mode 100644
index 0000000000..d326254e5a
--- /dev/null
+++ b/frontend/src/v1/app/VersionSwitch.test.tsx
@@ -0,0 +1,298 @@
+import { act, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { setOldVersion } from "@v1/app/api";
+import { DEMO_PAGES } from "@v1/app/registry/entries";
+import { buildLiveRegistry, type LiveRegistry } from "@v1/app/registry/live";
+import { toast } from "@v1/lib/notify";
+import { oldVersionStorageKey } from "@v1/lib/oldVersion";
+import { useOldVersionStore } from "@v1/stores/oldVersion";
+import { useEffect } from "react";
+import {
+ MemoryRouter,
+ type NavigateFunction,
+ Route,
+ Routes,
+ useLocation,
+ useNavigate,
+} from "react-router-dom";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { VersionSwitch } from "./VersionSwitch";
+
+// Two test-only pages beside the real demo ones: one that fails to render on
+// its sub-route, and one whose chunk is gone after a deploy.
+vi.mock("./pages", async (importOriginal) => {
+ const real = await importOriginal();
+ const { lazy } = await import("react");
+ const { useLocation } = await import("react-router-dom");
+ function FlakyPage() {
+ if (useLocation().pathname.endsWith("/details")) {
+ throw new Error("render failed");
+ }
+ return flaky queue ;
+ }
+ return {
+ PAGE_COMPONENTS: {
+ ...real.PAGE_COMPONENTS,
+ "DEMO-FLAKY": lazy(async () => ({ default: FlakyPage })),
+ "DEMO-CHUNK": lazy(() =>
+ Promise.reject(
+ new TypeError(
+ "Failed to fetch dynamically imported module: /assets/gone.js",
+ ),
+ ),
+ ),
+ },
+ };
+});
+
+const demo = buildLiveRegistry({
+ devRoutes: true,
+ distribution: "oss",
+ storage: null,
+});
+const empty: LiveRegistry = { pages: [], entries: [] };
+
+/** Every pathname the router passes through, and a handle to navigate. */
+let visited: string[] = [];
+let navigate: NavigateFunction = () => undefined;
+
+function Where() {
+ const { pathname } = useLocation();
+ navigate = useNavigate();
+ useEffect(() => {
+ visited.push(pathname);
+ }, [pathname]);
+ return {pathname} ;
+}
+
+/** Stands in for v0's routes. */
+const v0 = (
+
+ v0 demo} />
+ v0 review}
+ />
+ v0 other} />
+
+);
+
+function renderAt(
+ path: string | string[],
+ {
+ registry = demo,
+ userId = "7",
+ }: { registry?: LiveRegistry; userId?: string | number | null } = {},
+) {
+ const entries = Array.isArray(path) ? path : [path];
+ const view = (user: string | number | null) => (
+
+
+
+ {v0}
+
+
+ );
+ const result = render(view(userId));
+ return {
+ ...result,
+ setUser: (user: string | number | null) => result.rerender(view(user)),
+ };
+}
+
+const where = () => screen.getByTestId("where").textContent;
+
+beforeEach(() => {
+ visited = [];
+ window.localStorage.clear();
+ window.sessionStorage.clear();
+ useOldVersionStore.setState({ userId: null, oldVersion: null, chosen: {} });
+});
+
+describe("VersionSwitch", () => {
+ it("with an empty registry renders v0 for every URL, including v0 URLs of demo pages", () => {
+ for (const path of ["/acme/v0-demo", "/acme/v1-demo", "/acme/workflows"]) {
+ const { unmount } = renderAt(path, { registry: empty });
+ expect(screen.getByTestId("v0")).toBeTruthy();
+ expect(where()).toBe(path);
+ unmount();
+ }
+ });
+
+ it("renders v0 for an unregistered URL", () => {
+ renderAt("/acme/workflows");
+ expect(screen.getByTestId("v0").textContent).toBe("v0 other");
+ });
+
+ it("renders a registered page as its own v1 tree, and redirects its v0 URL there", async () => {
+ renderAt("/acme/v0-demo?x=1");
+ expect(await screen.findByTestId("v1-demo")).toBeTruthy();
+ expect(where()).toBe("/acme/v1-demo");
+ expect(screen.queryByTestId("v0")).toBeNull();
+ expect(
+ screen.getByTestId("v1-demo").closest("[data-ui-version='v1']"),
+ ).not.toBeNull();
+ });
+
+ it("a redirect replaces the history entry, so Back skips the v0 URL", async () => {
+ renderAt(["/acme/start", "/acme/v0-demo"]);
+ await screen.findByTestId("v1-demo");
+ act(() => {
+ navigate(-1);
+ });
+ expect(where()).toBe("/acme/start");
+ });
+
+ it("Human Review round trip: old version maps to v0 and back, with no reload", async () => {
+ const user = userEvent.setup();
+ // v0's session gives the id as a number.
+ renderAt("/acme/v1-demo-review/details", { userId: 7 });
+ await user.click(
+ await screen.findByRole("button", { name: "Switch to old version" }),
+ );
+
+ // The v1-only sub-route lands on its nearest v0 route.
+ expect(where()).toBe("/acme/v0-demo-review");
+ expect(screen.getByTestId("v0").textContent).toBe("v0 review");
+ expect(window.localStorage.getItem(oldVersionStorageKey("7"))).toBe("true");
+
+ // v0's "Switch to new version" control calls the public entry.
+ act(() => {
+ expect(setOldVersion(false)).toBe(true);
+ });
+ expect(where()).toBe("/acme/v1-demo-review");
+ expect(await screen.findByTestId("v1-demo-review")).toBeTruthy();
+ expect(window.localStorage.getItem(oldVersionStorageKey("7"))).toBeNull();
+ });
+
+ it("demo links keep an encoded org name in one segment", async () => {
+ const user = userEvent.setup();
+ renderAt("/a%2Fb/v1-demo-review");
+ await user.click(await screen.findByRole("link", { name: "Details" }));
+ expect(where()).toBe("/a%2Fb/v1-demo-review/details");
+ expect(screen.getByTestId("v1-demo-review")).toBeTruthy();
+ });
+
+ it("a stored old-version choice applies on load (re-login)", () => {
+ window.localStorage.setItem(oldVersionStorageKey("7"), "true");
+ renderAt("/acme/v1-demo-review");
+ expect(where()).toBe("/acme/v0-demo-review");
+ expect(screen.getByTestId("v0")).toBeTruthy();
+ });
+
+ it("on reload, applies the last user's choice before the session loads", () => {
+ useOldVersionStore.setState({ userId: "7", oldVersion: true });
+ renderAt("/acme/v1-demo-review", { userId: null });
+ expect(where()).toBe("/acme/v0-demo-review");
+ expect(screen.queryByTestId("v1-demo-review")).toBeNull();
+ });
+
+ it("the preference does nothing on a page without the toggle", async () => {
+ window.localStorage.setItem(oldVersionStorageKey("7"), "true");
+ renderAt("/acme/v0-demo");
+ expect(await screen.findByTestId("v1-demo")).toBeTruthy();
+ });
+
+ it("keeps Human Review's v0 URL on v0 until the user is known, then applies their choice", async () => {
+ const { setUser } = renderAt("/acme/v0-demo-review", { userId: null });
+ expect(screen.getByTestId("v0")).toBeTruthy();
+ expect(where()).toBe("/acme/v0-demo-review");
+
+ setUser("7");
+ expect(await screen.findByTestId("v1-demo-review")).toBeTruthy();
+ expect(where()).toBe("/acme/v1-demo-review");
+ });
+
+ it("on a load that starts on a v1 URL with no known user, the toggle changes nothing (until P0-T06)", async () => {
+ // v0 loads the session in PersistentLogin, a v0 route, so a v1 URL never
+ // gets a userId; with no unstract.v1.lastUser there is no user at all.
+ const user = userEvent.setup();
+ renderAt("/acme/v1-demo-review", { userId: null });
+ await user.click(
+ await screen.findByRole("button", { name: "Switch to old version" }),
+ );
+ expect(where()).toBe("/acme/v1-demo-review");
+ expect(screen.getByTestId("v1-demo-review")).toBeTruthy();
+ expect(setOldVersion(true)).toBe(false);
+ expect(window.localStorage).toHaveLength(0);
+ });
+
+ it("treats an empty user id as signed out, keeping the last user's choice", () => {
+ window.localStorage.setItem(oldVersionStorageKey("7"), "true");
+ useOldVersionStore.setState({ userId: "7", oldVersion: true });
+ renderAt("/acme/v1-demo-review", { userId: "" });
+ expect(where()).toBe("/acme/v0-demo-review");
+ });
+
+ it("is per user: another user signing in never passes through the previous user's old version", async () => {
+ window.localStorage.setItem(oldVersionStorageKey("7"), "true");
+ useOldVersionStore.setState({ userId: "7", oldVersion: true });
+ renderAt("/acme/v1-demo-review", { userId: "8" });
+ expect(await screen.findByTestId("v1-demo-review")).toBeTruthy();
+ expect(visited).not.toContain("/acme/v0-demo-review");
+ });
+
+ it("mounts the Toaster with the v1 tree, so notify() shows a toast only while v1 renders", async () => {
+ renderAt("/acme/v1-demo");
+ await screen.findByTestId("v1-demo");
+ act(() => {
+ toast.success("Saved", { persistent: true });
+ });
+ expect(screen.getByText("Saved")).toBeTruthy();
+
+ // Moving to a v0 screen unmounts the v1 tree and its Toaster: notify()
+ // falls back to the console.
+ act(() => {
+ navigate("/acme/workflows");
+ });
+ expect(screen.getByTestId("v0").textContent).toBe("v0 other");
+ expect(screen.queryByText("Saved")).toBeNull();
+ const log = vi.spyOn(console, "info").mockImplementation(() => undefined);
+ expect(toast.success("After v1")).toBeUndefined();
+ expect(log).toHaveBeenCalledWith("[v1 notify] After v1");
+ });
+
+ it("contains a v1 failure in its own boundary instead of unmounting the app", async () => {
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
+ const broken = { ...DEMO_PAGES[0], page: "DEMO-BROKEN" } as const;
+ renderAt("/acme/v1-demo", {
+ registry: { pages: [broken], entries: [broken] },
+ });
+ expect(await screen.findByRole("alert")).toBeTruthy();
+ expect(screen.getByRole("button", { name: "Reload" })).toBeTruthy();
+ });
+
+ it("clears a v1 failure when the user moves to another URL of the same page", async () => {
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
+ const flaky = { ...DEMO_PAGES[1], page: "DEMO-FLAKY" } as const;
+ renderAt("/acme/v1-demo-review/details", {
+ registry: { pages: [flaky], entries: [flaky] },
+ });
+ expect(await screen.findByRole("alert")).toBeTruthy();
+
+ act(() => {
+ navigate("/acme/v1-demo-review");
+ });
+ expect(await screen.findByTestId("v1-flaky")).toBeTruthy();
+ expect(screen.queryByRole("alert")).toBeNull();
+ });
+
+ it("reloads once on a stale v1 chunk, and shows the fallback if it fails again", async () => {
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
+ const reload = vi
+ .spyOn(window.location, "reload")
+ .mockImplementation(() => undefined);
+ const gone = { ...DEMO_PAGES[0], page: "DEMO-CHUNK" } as const;
+ const registry = { pages: [gone], entries: [gone] };
+
+ const first = renderAt("/acme/v1-demo", { registry });
+ await screen.findByRole("alert");
+ expect(reload).toHaveBeenCalledTimes(1);
+
+ // The reloaded page fails again within ten seconds: no second reload.
+ first.unmount();
+ renderAt("/acme/v1-demo", { registry });
+ expect(await screen.findByRole("alert")).toBeTruthy();
+ expect(reload).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/frontend/src/v1/app/VersionSwitch.tsx b/frontend/src/v1/app/VersionSwitch.tsx
new file mode 100644
index 0000000000..b06d78d022
--- /dev/null
+++ b/frontend/src/v1/app/VersionSwitch.tsx
@@ -0,0 +1,138 @@
+/*
+ * The version switch (UN-4271): renders v0 or v1 per URL. v0's router mounts
+ * it around its own routes, as `children`, which render untouched whenever
+ * the URL resolves to v0.
+ */
+
+import { LIVE_REGISTRY, type LiveRegistry } from "@v1/app/registry/live";
+import { resolveRoute } from "@v1/app/registry/resolve";
+import type { RegistryEntry } from "@v1/app/registry/types";
+import { isChunkLoadError, reloadOnce } from "@v1/lib/chunkReload";
+import { useOldVersionStore } from "@v1/stores/oldVersion";
+import {
+ Component,
+ type JSX,
+ lazy,
+ type ReactNode,
+ Suspense,
+ useLayoutEffect,
+} from "react";
+import { Navigate, useLocation } from "react-router-dom";
+
+const V1App = lazy(() => import("./V1App").then((m) => ({ default: m.V1App })));
+
+export type VersionSwitchProps = Readonly<{
+ /** The signed-in user's id from the v0 session; null or absent while signed out. */
+ userId?: string | number | null;
+ /** The v0 app, rendered when the URL resolves to v0. */
+ children: ReactNode;
+ /** For tests; defaults to this build's registry. */
+ registry?: LiveRegistry;
+}>;
+
+type BoundaryState = { failed: boolean };
+
+function sessionStore(): Storage | null {
+ try {
+ return window.sessionStorage;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Keeps a v1 failure from unmounting the whole app. A stale chunk after a
+ * deploy reloads the page once to fetch the new one (React caches a failed
+ * lazy import, so nothing short of a reload retries it); anything else, or a
+ * repeat failure, shows the fallback. Navigating to another URL clears it, as
+ * v0's backstop does with `resetKeys`.
+ */
+class V1ErrorBoundary extends Component<
+ { children: ReactNode; resetKey: string },
+ BoundaryState
+> {
+ override state: BoundaryState = { failed: false };
+
+ static getDerivedStateFromError(): BoundaryState {
+ return { failed: true };
+ }
+
+ override componentDidUpdate(prev: Readonly<{ resetKey: string }>): void {
+ if (this.state.failed && prev.resetKey !== this.props.resetKey) {
+ this.setState({ failed: false });
+ }
+ }
+
+ override componentDidCatch(error: unknown): void {
+ if (isChunkLoadError(error)) {
+ reloadOnce(sessionStore(), () => window.location.reload());
+ }
+ }
+
+ override render(): ReactNode {
+ if (this.state.failed) {
+ return (
+
+
This page could not be loaded.
+
window.location.reload()}>
+ Reload
+
+
+ );
+ }
+ return this.props.children;
+ }
+}
+
+function V1Root({
+ entry,
+ pathname,
+}: Readonly<{ entry: RegistryEntry; pathname: string }>): JSX.Element {
+ // Keyed by page so another page starts clean; within a page, a new
+ // pathname clears a failure without remounting the tree.
+ return (
+
+
+
+
+
+ );
+}
+
+function toUserId(userId: string | number | null | undefined): string | null {
+ return userId === undefined || userId === null || userId === ""
+ ? null
+ : String(userId);
+}
+
+export function VersionSwitch({
+ userId,
+ children,
+ registry = LIVE_REGISTRY,
+}: VersionSwitchProps): JSX.Element {
+ const location = useLocation();
+ const user = toUserId(userId);
+ const storeUser = useOldVersionStore((s) => s.userId);
+ const storedChoice = useOldVersionStore((s) => s.oldVersion);
+
+ useLayoutEffect(() => {
+ useOldVersionStore.getState().setUser(user);
+ }, [user]);
+
+ // Until the store has caught up with a newly signed-in user, their choice
+ // is unknown, so the previous user's never redirects them.
+ const oldVersion = user !== null && user !== storeUser ? null : storedChoice;
+ const resolution = resolveRoute(location, {
+ entries: registry.entries,
+ oldVersion,
+ });
+
+ switch (resolution.kind) {
+ case "v0":
+ return <>{children}>;
+ case "redirect":
+ return ;
+ case "v1":
+ return ;
+ }
+}
diff --git a/frontend/src/v1/app/api.test.ts b/frontend/src/v1/app/api.test.ts
new file mode 100644
index 0000000000..da479faf84
--- /dev/null
+++ b/frontend/src/v1/app/api.test.ts
@@ -0,0 +1,84 @@
+import {
+ LAST_USER_STORAGE_KEY,
+ OLD_VERSION_ON,
+ oldVersionStorageKey,
+} from "@v1/lib/oldVersion";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+// A declared page that is not live yet, beside the real demo pages.
+vi.mock("@v1/app/registry/entries", async (importOriginal) => {
+ const real =
+ await importOriginal();
+ return {
+ ...real,
+ PAGES: [
+ {
+ page: "UN05",
+ v1Routes: ["/:orgName/flows/:id"],
+ replaces: ["/:orgName/workflows/:id"],
+ toV0: { "/:orgName/flows/:id": "/:orgName/workflows/:id" },
+ toV1: { "/:orgName/workflows/:id": "/:orgName/flows/:id" },
+ toggle: false,
+ distributions: ["oss", "cloud", "onprem"],
+ registeredBy: "P3-T01",
+ },
+ ],
+ REGISTERED_PAGES: [],
+ };
+});
+
+/**
+ * The public entry with the demo pages live, as in the E2E image. The
+ * registry and the preference store are read when their modules load, so
+ * each test imports them afresh after seeding storage.
+ */
+async function loadApi() {
+ vi.stubEnv("VITE_V1_DEV_ROUTES", "true");
+ vi.resetModules();
+ const api = await import("@v1/app/api");
+ const { useOldVersionStore } = await import("@v1/stores/oldVersion");
+ return { ...api, useOldVersionStore };
+}
+
+const REVIEW = "/:orgName/v1-demo-review";
+const acme = { orgName: "acme" };
+
+beforeEach(() => {
+ window.localStorage.clear();
+});
+
+describe("resolveUrl", () => {
+ it("follows the signed-in user's old-version choice on a toggle page", async () => {
+ const { resolveUrl, setOldVersion, useOldVersionStore } = await loadApi();
+ useOldVersionStore.getState().setUser("7");
+ expect(resolveUrl("DEMO-REVIEW", REVIEW, acme)).toBe(
+ "/acme/v1-demo-review",
+ );
+
+ expect(setOldVersion(true)).toBe(true);
+ expect(resolveUrl("DEMO-REVIEW", REVIEW, acme)).toBe(
+ "/acme/v0-demo-review",
+ );
+ });
+
+ it("before the session loads, uses the last user known to this browser", async () => {
+ window.localStorage.setItem(LAST_USER_STORAGE_KEY, "7");
+ window.localStorage.setItem(oldVersionStorageKey("7"), OLD_VERSION_ON);
+ const { resolveUrl } = await loadApi();
+ expect(resolveUrl("DEMO-REVIEW", REVIEW, acme)).toBe(
+ "/acme/v0-demo-review",
+ );
+ });
+
+ it("links a declared page that is not live yet to its v0 URL", async () => {
+ const { resolveUrl } = await loadApi();
+ expect(
+ resolveUrl("UN05", "/:orgName/flows/:id", { orgName: "acme", id: "9" }),
+ ).toBe("/acme/workflows/9");
+ });
+
+ it("is null for a page this build does not declare", async () => {
+ const { resolveUrl } = await loadApi();
+ expect(resolveUrl("UN11", "/:orgName/hitl", acme)).toBeNull();
+ });
+});
diff --git a/frontend/src/v1/app/api.ts b/frontend/src/v1/app/api.ts
new file mode 100644
index 0000000000..5e849743fe
--- /dev/null
+++ b/frontend/src/v1/app/api.ts
@@ -0,0 +1,49 @@
+/*
+ * The helpers the public entry (`@v1`, src/v1/index.ts) exposes to v0. They
+ * live here so v1 code and tests can import them without the entry.
+ */
+
+import { LIVE_REGISTRY, V1_DEV_ROUTES } from "@v1/app/registry/live";
+import { resolvePageUrl } from "@v1/app/registry/resolve";
+import type { PageKey } from "@v1/app/registry/types";
+import { useOldVersionStore } from "@v1/stores/oldVersion";
+
+/** True in a build with the development-only demo routes (VITE_V1_DEV_ROUTES). */
+export const v1DevRoutesEnabled: boolean = V1_DEV_ROUTES;
+
+/**
+ * Sets the signed-in user's old-version preference, which only Human Review
+ * honours (D14). The version switch then moves the user to the equivalent
+ * screen without a reload. True once set; if storage refuses the write, the
+ * choice lasts for this session only. False, and no change, while no user is
+ * known.
+ * For the switch controls on both versions of Human Review (P6-T07).
+ */
+export function setOldVersion(on: boolean): boolean {
+ return useOldVersionStore.getState().setOldVersion(on);
+}
+
+/**
+ * The URL that opens `page` at one of its v1 route patterns, in the version
+ * the user gets: the v1 URL when the page is live (unless it is Human Review
+ * and the user chose the old version), else its mapped v0 URL. Params are raw
+ * values and are encoded here. Null when the page is not declared, the
+ * pattern is not one of its v1 routes, or a `:param` of the pattern is missing
+ * or empty. Reads the preference when called, so it does not re-render on
+ * its own. Before the session loads it uses the choice of the last user known
+ * to this browser, as the switch does. In the one render where a newly
+ * signed-in user is not yet synced to the store (a layout effect, before
+ * paint), it can still give the previous user's choice; the switch masks it.
+ *
+ * resolveUrl("UN05", "/:orgName/workflows/:id", { orgName, id })
+ */
+export function resolveUrl(
+ page: PageKey,
+ v1Pattern: string,
+ params: Readonly>,
+): string | null {
+ return resolvePageUrl(page, v1Pattern, params, LIVE_REGISTRY.pages, {
+ entries: LIVE_REGISTRY.entries,
+ oldVersion: useOldVersionStore.getState().oldVersion,
+ });
+}
diff --git a/frontend/src/v1/app/pages.ts b/frontend/src/v1/app/pages.ts
new file mode 100644
index 0000000000..8aff24a2bb
--- /dev/null
+++ b/frontend/src/v1/app/pages.ts
@@ -0,0 +1,21 @@
+/*
+ * The component for each declared page, loaded on first render. Add a
+ * page's line here when it is declared in PAGES; validate.test.ts fails if a
+ * declared page has none.
+ */
+
+import type { PageKey } from "@v1/app/registry/types";
+import { type JSX, type LazyExoticComponent, lazy } from "react";
+
+type PageComponent = LazyExoticComponent<() => JSX.Element>;
+
+export const PAGE_COMPONENTS: Readonly<
+ Partial>
+> = {
+ "DEMO-PAGE": lazy(() =>
+ import("@v1/pages/demo").then((m) => ({ default: m.DemoPage })),
+ ),
+ "DEMO-REVIEW": lazy(() =>
+ import("@v1/pages/demo").then((m) => ({ default: m.DemoReviewPage })),
+ ),
+};
diff --git a/frontend/src/v1/app/registry/entries.ts b/frontend/src/v1/app/registry/entries.ts
new file mode 100644
index 0000000000..98375f476a
--- /dev/null
+++ b/frontend/src/v1/app/registry/entries.ts
@@ -0,0 +1,48 @@
+/*
+ * The route registry data (UN-4271). Pure data: see the note in ./types.ts.
+ *
+ * A page is declared in PAGES once its v1 routes exist, and goes live when
+ * its go-live task adds its key to REGISTERED_PAGES (D9). Going live, and
+ * reverting it, is that one line.
+ */
+
+import type { PageKey, RegistryEntry } from "./types";
+
+/** Every declared v1 page, live or not. */
+export const PAGES: readonly RegistryEntry[] = [];
+
+/** The pages users get as v1 (D9): a page's go-live task adds its key here. */
+export const REGISTERED_PAGES: readonly PageKey[] = [];
+
+/**
+ * Development-only demo pages that prove the switch. Registered only in a
+ * build with VITE_V1_DEV_ROUTES=true (the E2E image), never in a release.
+ * Their v0 side is the stand-in rendered by routes/Router.jsx.
+ */
+export const DEMO_PAGES: readonly RegistryEntry[] = [
+ {
+ page: "DEMO-PAGE",
+ v1Routes: ["/:orgName/v1-demo"],
+ replaces: ["/:orgName/v0-demo"],
+ toV0: { "/:orgName/v1-demo": "/:orgName/v0-demo" },
+ toV1: { "/:orgName/v0-demo": "/:orgName/v1-demo" },
+ toggle: false,
+ distributions: ["oss", "cloud", "onprem"],
+ registeredBy: "P0-T03",
+ },
+ {
+ // Shaped like Human Review: honours the old-version preference, and has
+ // a v1-only sub-route that maps back to the nearest v0 route.
+ page: "DEMO-REVIEW",
+ v1Routes: ["/:orgName/v1-demo-review", "/:orgName/v1-demo-review/details"],
+ replaces: ["/:orgName/v0-demo-review"],
+ toV0: {
+ "/:orgName/v1-demo-review": "/:orgName/v0-demo-review",
+ "/:orgName/v1-demo-review/details": "/:orgName/v0-demo-review",
+ },
+ toV1: { "/:orgName/v0-demo-review": "/:orgName/v1-demo-review" },
+ toggle: true,
+ distributions: ["oss", "cloud", "onprem"],
+ registeredBy: "P0-T03",
+ },
+];
diff --git a/frontend/src/v1/app/registry/live.load.test.ts b/frontend/src/v1/app/registry/live.load.test.ts
new file mode 100644
index 0000000000..67fb5ccc2e
--- /dev/null
+++ b/frontend/src/v1/app/registry/live.load.test.ts
@@ -0,0 +1,52 @@
+import { afterEach, expect, it, vi } from "vitest";
+
+/*
+ * load() runs once, when live.ts is imported, so each case imports it afresh.
+ * Kept apart from live.test.ts, which mocks ./entries for every test there.
+ */
+async function importLive() {
+ vi.resetModules();
+ return import("./live");
+}
+
+afterEach(() => {
+ vi.doUnmock("./entries");
+});
+
+it("falls back to OSS, with a console error, on an unknown distribution", async () => {
+ vi.stubEnv("VITE_DISTRIBUTION", "saas");
+ vi.stubEnv("VITE_V1_DEV_ROUTES", "true");
+ const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
+ const { LIVE_REGISTRY } = await importLive();
+ expect(error).toHaveBeenCalledWith(
+ "VITE_DISTRIBUTION=saas is not one of oss, cloud, onprem; using oss",
+ );
+ // v0 imports this module eagerly, so it must load rather than throw, and
+ // as OSS: the demo pages ship in every distribution, so both stay live.
+ expect(LIVE_REGISTRY.entries.map((e) => e.page)).toEqual([
+ "DEMO-PAGE",
+ "DEMO-REVIEW",
+ ]);
+});
+
+it("fails loudly in development when the registry is invalid", async () => {
+ expect(import.meta.env.DEV).toBe(true);
+ vi.doMock("./entries", async (importOriginal) => ({
+ ...(await importOriginal()),
+ PAGES: [
+ {
+ page: "UN05",
+ v1Routes: [],
+ replaces: [],
+ toV0: {},
+ toV1: {},
+ toggle: false,
+ distributions: ["oss"],
+ registeredBy: "P3-T01",
+ },
+ ],
+ }));
+ await expect(importLive()).rejects.toThrow(
+ "Invalid v1 route registry:\nUN05: has no v1 routes",
+ );
+});
diff --git a/frontend/src/v1/app/registry/live.test.ts b/frontend/src/v1/app/registry/live.test.ts
new file mode 100644
index 0000000000..66ca799719
--- /dev/null
+++ b/frontend/src/v1/app/registry/live.test.ts
@@ -0,0 +1,100 @@
+import { describe, expect, it, vi } from "vitest";
+import {
+ buildLiveRegistry,
+ PREVIEW_STORAGE_KEY,
+ parseDistribution,
+ readPreviewPages,
+} from "./live";
+
+// A declared but unregistered cloud-only page, beside the real demo pages.
+vi.mock("./entries", async (importOriginal) => {
+ const real = await importOriginal();
+ return {
+ ...real,
+ PAGES: [
+ {
+ page: "UN05",
+ v1Routes: ["/:orgName/flows"],
+ replaces: ["/:orgName/workflows"],
+ toV0: { "/:orgName/flows": "/:orgName/workflows" },
+ toV1: { "/:orgName/workflows": "/:orgName/flows" },
+ toggle: false,
+ distributions: ["cloud"],
+ registeredBy: "P3-T01",
+ },
+ ],
+ REGISTERED_PAGES: [],
+ };
+});
+
+const storageWith = (value: string | null) => ({ getItem: () => value });
+const livePages = (options: Parameters[0]) =>
+ buildLiveRegistry(options).entries.map((e) => e.page);
+
+describe("parseDistribution", () => {
+ it("defaults to OSS and accepts any case", () => {
+ expect(parseDistribution(undefined)).toBe("oss");
+ expect(parseDistribution("")).toBe("oss");
+ expect(parseDistribution(" Cloud ")).toBe("cloud");
+ expect(parseDistribution("onprem")).toBe("onprem");
+ });
+ it("is null for an unknown value (vite.config.js fails the build on it)", () => {
+ expect(parseDistribution("saas")).toBeNull();
+ });
+});
+
+describe("readPreviewPages", () => {
+ it("reads a JSON array of valid page keys", () => {
+ expect(
+ readPreviewPages(storageWith('["UN05", 3, "LW02", "UN5", "x"]')),
+ ).toEqual(["UN05", "LW02"]);
+ });
+ it("treats missing, malformed or unreadable storage as no preview", () => {
+ expect(readPreviewPages(null)).toEqual([]);
+ expect(readPreviewPages(storageWith(null))).toEqual([]);
+ expect(readPreviewPages(storageWith("{"))).toEqual([]);
+ expect(readPreviewPages(storageWith('{"UN05":1}'))).toEqual([]);
+ expect(
+ readPreviewPages({
+ getItem: () => {
+ throw new Error("denied");
+ },
+ }),
+ ).toEqual([]);
+ });
+ it("uses its own key", () => {
+ expect(PREVIEW_STORAGE_KEY).toBe("unstract.v1.preview");
+ });
+});
+
+describe("buildLiveRegistry", () => {
+ const preview = storageWith('["UN05"]');
+
+ it("a declared page stays v0 until registered", () => {
+ expect(
+ livePages({ devRoutes: false, distribution: "cloud", storage: null }),
+ ).toEqual([]);
+ });
+
+ it("a release build ignores the preview override and has no demo pages", () => {
+ const r = buildLiveRegistry({
+ devRoutes: false,
+ distribution: "cloud",
+ storage: preview,
+ });
+ expect(r.entries).toEqual([]);
+ expect(r.pages.map((p) => p.page)).toEqual(["UN05"]);
+ });
+
+ it("with dev routes, the demo pages and previewed pages are live", () => {
+ expect(
+ livePages({ devRoutes: true, distribution: "cloud", storage: preview }),
+ ).toEqual(["UN05", "DEMO-PAGE", "DEMO-REVIEW"]);
+ });
+
+ it("drops pages the build's distribution lacks, previewed or not", () => {
+ expect(
+ livePages({ devRoutes: true, distribution: "oss", storage: preview }),
+ ).toEqual(["DEMO-PAGE", "DEMO-REVIEW"]);
+ });
+});
diff --git a/frontend/src/v1/app/registry/live.ts b/frontend/src/v1/app/registry/live.ts
new file mode 100644
index 0000000000..1d26d56dac
--- /dev/null
+++ b/frontend/src/v1/app/registry/live.ts
@@ -0,0 +1,115 @@
+/*
+ * The registry this build and browser run with: the pure data in ./entries,
+ * narrowed by build flags and the dev/test preview override.
+ */
+
+import { DEMO_PAGES, PAGES, REGISTERED_PAGES } from "./entries";
+import { activeEntries } from "./resolve";
+import {
+ DISTRIBUTIONS,
+ type Distribution,
+ type PageKey,
+ PREVIEW_STORAGE_KEY,
+ type RegistryEntry,
+} from "./types";
+import { isPageKey, registryProblems } from "./validate";
+
+/**
+ * VITE_V1_DEV_ROUTES=true registers the demo pages and honours the preview
+ * override. Set only for the E2E image and local development, never in a
+ * release build.
+ */
+export const V1_DEV_ROUTES: boolean =
+ import.meta.env.VITE_V1_DEV_ROUTES === "true";
+
+// Declared in types.ts so the E2E harness can import it without Vite.
+export { PREVIEW_STORAGE_KEY };
+
+/**
+ * The build's distribution from VITE_DISTRIBUTION: OSS when unset,
+ * null when the value is not a distribution. vite.config.js fails the build
+ * on such a value, so null is reached only by a build that skipped it.
+ */
+export function parseDistribution(
+ raw: string | undefined,
+): Distribution | null {
+ const value = raw?.trim().toLowerCase() || "oss";
+ return DISTRIBUTIONS.find((d) => d === value) ?? null;
+}
+
+/** Valid page keys in the preview override; anything unreadable or malformed means none. */
+export function readPreviewPages(
+ storage: Pick | null | undefined,
+): PageKey[] {
+ try {
+ const parsed: unknown = JSON.parse(
+ storage?.getItem(PREVIEW_STORAGE_KEY) ?? "[]",
+ );
+ return Array.isArray(parsed) ? parsed.filter(isPageKey) : [];
+ } catch {
+ return [];
+ }
+}
+
+export type LiveRegistry = Readonly<{
+ /** Every page declared in this build, live or not. */
+ pages: readonly RegistryEntry[];
+ /** The pages that render as v1. */
+ entries: readonly RegistryEntry[];
+}>;
+
+export function buildLiveRegistry(options: {
+ devRoutes: boolean;
+ distribution: Distribution;
+ storage: Pick | null;
+}): LiveRegistry {
+ const { devRoutes, distribution, storage } = options;
+ const pages = devRoutes ? [...PAGES, ...DEMO_PAGES] : [...PAGES];
+ const live = new Set(REGISTERED_PAGES);
+ if (devRoutes) {
+ for (const key of [
+ ...DEMO_PAGES.map((p) => p.page),
+ ...readPreviewPages(storage),
+ ]) {
+ live.add(key);
+ }
+ }
+ return { pages, entries: activeEntries(pages, live, distribution) };
+}
+
+function browserStorage(): Storage | null {
+ try {
+ return typeof window === "undefined" ? null : window.localStorage;
+ } catch {
+ return null;
+ }
+}
+
+function load(): LiveRegistry {
+ const raw = import.meta.env.VITE_DISTRIBUTION;
+ let distribution = parseDistribution(raw);
+ if (distribution === null) {
+ // Never blank the app over it: v0 loads this module eagerly.
+ console.error(
+ `VITE_DISTRIBUTION=${raw} is not one of ${DISTRIBUTIONS.join(", ")}; using oss`,
+ );
+ distribution = "oss";
+ }
+ const registry = buildLiveRegistry({
+ devRoutes: V1_DEV_ROUTES,
+ distribution,
+ storage: browserStorage(),
+ });
+ // CI rejects an invalid registry through validate.test.ts; development
+ // builds fail loudly too. Release builds skip the check (and its code).
+ if (import.meta.env.DEV) {
+ const problems = registryProblems(registry.pages);
+ if (problems.length) {
+ throw new Error(`Invalid v1 route registry:\n${problems.join("\n")}`);
+ }
+ }
+ return registry;
+}
+
+/** Read once at load: the preview override is set before the app starts. */
+export const LIVE_REGISTRY: LiveRegistry = load();
diff --git a/frontend/src/v1/app/registry/patterns.ts b/frontend/src/v1/app/registry/patterns.ts
new file mode 100644
index 0000000000..ed6367425b
--- /dev/null
+++ b/frontend/src/v1/app/registry/patterns.ts
@@ -0,0 +1,60 @@
+/** Route-pattern helpers for the registry: the subset of React Router patterns it accepts. */
+
+const PARAM = /^:[A-Za-z_]\w*$/;
+const STATIC = /^[A-Za-z0-9._~-]+$/;
+
+function segments(pattern: string): string[] {
+ return pattern.split("/").filter((s) => s !== "");
+}
+
+/**
+ * Why a pattern is not accepted, or null. Accepted: absolute, static
+ * segments, `:name` params and a final `*`. Optional segments (`:id?`) are
+ * not, so that overlap checking stays exact.
+ */
+export function patternProblem(pattern: string): string | null {
+ if (!pattern.startsWith("/")) {
+ return "must start with /";
+ }
+ const segs = segments(pattern);
+ for (const [i, seg] of segs.entries()) {
+ if (seg === "*") {
+ if (i !== segs.length - 1) {
+ return "* is allowed only as the last segment";
+ }
+ } else if (!PARAM.test(seg) && !STATIC.test(seg)) {
+ return `segment '${seg}' is not a static name, :param or a final *`;
+ }
+ }
+ return null;
+}
+
+/** The `:name` params of a pattern, plus `*` when it ends in a splat. */
+export function paramNames(pattern: string): string[] {
+ return segments(pattern)
+ .filter((s) => s === "*" || s.startsWith(":"))
+ .map((s) => (s === "*" ? "*" : s.slice(1)));
+}
+
+/** True when some URL matches both patterns. Static segments compare case-insensitively, as React Router does. */
+export function patternsOverlap(a: string, b: string): boolean {
+ const sa = segments(a);
+ const sb = segments(b);
+ for (let i = 0; ; i++) {
+ const x = sa[i];
+ const y = sb[i];
+ if (x === "*" || y === "*") {
+ return true; // a splat also matches nothing, so this holds even when the other pattern ended
+ }
+ if (x === undefined || y === undefined) {
+ return x === y;
+ }
+ if (
+ !x.startsWith(":") &&
+ !y.startsWith(":") &&
+ x.toLowerCase() !== y.toLowerCase()
+ ) {
+ return false;
+ }
+ }
+}
diff --git a/frontend/src/v1/app/registry/resolve.test.ts b/frontend/src/v1/app/registry/resolve.test.ts
new file mode 100644
index 0000000000..1be9d6918f
--- /dev/null
+++ b/frontend/src/v1/app/registry/resolve.test.ts
@@ -0,0 +1,310 @@
+import { describe, expect, it } from "vitest";
+import { DEMO_PAGES, PAGES } from "./entries";
+import {
+ activeEntries,
+ fillPattern,
+ resolvePageUrl,
+ resolveRoute,
+} from "./resolve";
+import type { RegistryEntry } from "./types";
+import { registryProblems } from "./validate";
+
+const plain: RegistryEntry = {
+ page: "UN05",
+ v1Routes: ["/:orgName/workflows", "/:orgName/flows/:id/*"],
+ replaces: ["/:orgName/workflows", "/:orgName/workflows/:id"],
+ toV0: {
+ "/:orgName/workflows": "/:orgName/workflows",
+ "/:orgName/flows/:id/*": "/:orgName/workflows/:id",
+ },
+ toV1: {
+ "/:orgName/workflows": "/:orgName/workflows",
+ "/:orgName/workflows/:id": "/:orgName/flows/:id/*",
+ },
+ toggle: false,
+ distributions: ["oss", "cloud"],
+ registeredBy: "P3-T01",
+};
+
+const review: RegistryEntry = {
+ page: "UN11",
+ v1Routes: ["/:orgName/hitl", "/:orgName/hitl/:id/approve"],
+ replaces: ["/:orgName/review", "/:orgName/review/approve"],
+ toV0: {
+ "/:orgName/hitl": "/:orgName/review",
+ "/:orgName/hitl/:id/approve": "/:orgName/review/approve",
+ },
+ toV1: {
+ "/:orgName/review": "/:orgName/hitl",
+ "/:orgName/review/approve": "/:orgName/hitl",
+ },
+ toggle: true,
+ distributions: ["cloud"],
+ registeredBy: "P6-T07",
+};
+
+const entries = [plain, review];
+
+it("the fixtures are valid registry entries", () => {
+ expect(registryProblems(entries)).toEqual([]);
+});
+const at = (pathname: string, oldVersion: boolean | null = false) =>
+ resolveRoute({ pathname }, { entries, oldVersion });
+
+describe("resolveRoute", () => {
+ it("renders v0 for an unregistered URL, and everywhere with an empty registry", () => {
+ expect(at("/acme/logs")).toEqual({ kind: "v0" });
+ for (const path of ["/acme/workflows", "/acme/review", "/acme/hitl"]) {
+ expect(
+ resolveRoute({ pathname: path }, { entries: [], oldVersion: true }),
+ ).toEqual({
+ kind: "v0",
+ });
+ }
+ });
+
+ it("renders v1 for a registered page's v1 route, whatever the preference", () => {
+ for (const pref of [true, false, null]) {
+ expect(at("/acme/flows/7/runs", pref)).toEqual({
+ kind: "v1",
+ entry: plain,
+ });
+ // A route that is both v0 and v1 renders v1 without redirecting.
+ expect(at("/acme/workflows", pref)).toEqual({ kind: "v1", entry: plain });
+ }
+ });
+
+ it("redirects a registered page's v0 URL to its v1 route, keeping query and hash", () => {
+ expect(
+ resolveRoute(
+ { pathname: "/acme/workflows/7", search: "?q=1", hash: "#x" },
+ { entries, oldVersion: false },
+ ),
+ ).toEqual({ kind: "redirect", to: "/acme/flows/7?q=1#x" });
+ });
+
+ it("keeps encoded params inside their segment when redirecting", () => {
+ expect(at("/a%2Fb/workflows/x%3Fy%23z")).toEqual({
+ kind: "redirect",
+ to: "/a%2Fb/flows/x%3Fy%23z",
+ });
+ });
+
+ it("toggle page, new version: v1 renders and v0 URLs redirect", () => {
+ expect(at("/acme/hitl", false)).toEqual({ kind: "v1", entry: review });
+ expect(at("/acme/review/approve", false)).toEqual({
+ kind: "redirect",
+ to: "/acme/hitl",
+ });
+ });
+
+ it("toggle page, old version: v0 renders and v1 URLs redirect to the mapped v0 route", () => {
+ expect(at("/acme/review", true)).toEqual({ kind: "v0" });
+ expect(at("/acme/hitl/9/approve", true)).toEqual({
+ kind: "redirect",
+ to: "/acme/review/approve",
+ });
+ });
+
+ it("toggle page, user unknown: v0 URLs stay on v0 and v1 URLs on v1", () => {
+ expect(at("/acme/review", null)).toEqual({ kind: "v0" });
+ expect(at("/acme/hitl", null)).toEqual({ kind: "v1", entry: review });
+ });
+
+ it("the preference changes nothing on a page without the toggle", () => {
+ expect(at("/acme/workflows/7", true)).toEqual(
+ at("/acme/workflows/7", false),
+ );
+ });
+
+ it("defensively, the most specific pattern wins if two pages overlap", () => {
+ const logs: RegistryEntry = {
+ ...plain,
+ page: "UN10",
+ v1Routes: ["/:orgName/flows/:id/logs"],
+ replaces: [],
+ toV0: {},
+ toV1: {},
+ };
+ // A valid registry has no such overlap; validation rejects it.
+ expect(registryProblems([plain, logs])).toContain(
+ "UN05 /:orgName/flows/:id/* overlaps UN10 /:orgName/flows/:id/logs",
+ );
+ const both = { entries: [plain, logs], oldVersion: false };
+ expect(resolveRoute({ pathname: "/acme/flows/7/logs" }, both)).toEqual({
+ kind: "v1",
+ entry: logs,
+ });
+ expect(resolveRoute({ pathname: "/acme/flows/7/runs" }, both)).toEqual({
+ kind: "v1",
+ entry: plain,
+ });
+ });
+});
+
+describe("activeEntries", () => {
+ it("keeps registered pages shipped in the distribution only", () => {
+ const live = new Set(["UN05", "UN11"] as const);
+ expect(activeEntries(entries, live, "oss")).toEqual([plain]);
+ expect(activeEntries(entries, live, "cloud")).toEqual([plain, review]);
+ expect(activeEntries(entries, live, "onprem")).toEqual([]);
+ expect(activeEntries(entries, new Set(["UN11"] as const), "cloud")).toEqual(
+ [review],
+ );
+ });
+});
+
+describe("resolvePageUrl", () => {
+ const params = { orgName: "acme", id: "9" };
+ it("gives the v1 URL for a live page", () => {
+ expect(
+ resolvePageUrl("UN11", "/:orgName/hitl/:id/approve", params, entries, {
+ entries,
+ oldVersion: false,
+ }),
+ ).toBe("/acme/hitl/9/approve");
+ });
+ it("gives the mapped v0 URL in old-version mode or when the page is not live", () => {
+ for (const ctx of [
+ { entries, oldVersion: true },
+ { entries: [], oldVersion: false },
+ ]) {
+ expect(
+ resolvePageUrl(
+ "UN11",
+ "/:orgName/hitl/:id/approve",
+ params,
+ entries,
+ ctx,
+ ),
+ ).toBe("/acme/review/approve");
+ }
+ });
+ it("ignores the old-version choice on a page without the toggle", () => {
+ expect(
+ resolvePageUrl("UN05", "/:orgName/flows/:id/*", params, entries, {
+ entries,
+ oldVersion: true,
+ }),
+ ).toBe("/acme/flows/9");
+ });
+ it("gives a declared, not-live page's v0 URL", () => {
+ expect(
+ resolvePageUrl("UN05", "/:orgName/flows/:id/*", params, entries, {
+ entries: [],
+ oldVersion: false,
+ }),
+ ).toBe("/acme/workflows/9");
+ });
+ it("decides liveness by page key, not object identity", () => {
+ expect(
+ resolvePageUrl("UN11", "/:orgName/hitl", params, entries, {
+ entries: [{ ...review }],
+ oldVersion: false,
+ }),
+ ).toBe("/acme/hitl");
+ });
+ it("encodes raw param values", () => {
+ expect(
+ resolvePageUrl(
+ "UN11",
+ "/:orgName/hitl/:id/approve",
+ { orgName: "a/b", id: "c?d" },
+ entries,
+ {
+ entries,
+ oldVersion: false,
+ },
+ ),
+ ).toBe("/a%2Fb/hitl/c%3Fd/approve");
+ });
+ it("is null for an undeclared page or an unknown pattern", () => {
+ const ctx = { entries: [], oldVersion: false };
+ expect(
+ resolvePageUrl("UN09", "/:orgName/hitl", params, entries, ctx),
+ ).toBeNull();
+ expect(
+ resolvePageUrl("UN11", "/:orgName/nope", params, entries, ctx),
+ ).toBeNull();
+ });
+ it("is null, not a throw or a wrong URL, when a param is missing or empty", () => {
+ const missingOrEmpty: Record[] = [
+ { orgName: "acme" },
+ { orgName: "", id: "9" },
+ ];
+ for (const ctx of [
+ { entries, oldVersion: false },
+ { entries: [], oldVersion: false },
+ ]) {
+ for (const bad of missingOrEmpty) {
+ expect(
+ resolvePageUrl(
+ "UN11",
+ "/:orgName/hitl/:id/approve",
+ bad,
+ entries,
+ ctx,
+ ),
+ ).toBeNull();
+ }
+ }
+ });
+ it("lets a final splat be omitted", () => {
+ expect(
+ resolvePageUrl("UN05", "/:orgName/flows/:id/*", params, entries, {
+ entries,
+ oldVersion: false,
+ }),
+ ).toBe("/acme/flows/9");
+ });
+});
+
+/** Mapping both ways, for every entry the app declares. */
+describe.each([...PAGES, ...DEMO_PAGES])("$page mapping", (entry) => {
+ const self = { entries: [entry] };
+ // Placeholder params; a splat is left empty, as generatePath drops it.
+ const fill = (pattern: string) =>
+ fillPattern(pattern, {
+ ...Object.fromEntries(
+ [...pattern.matchAll(/:(\w+)/g)].map(([, name]) => [name, `p-${name}`]),
+ ),
+ "*": "",
+ });
+
+ it("each v0 route redirects to its mapped v1 route, which renders v1", () => {
+ for (const [v0, v1] of Object.entries(entry.toV1)) {
+ const r = resolveRoute(
+ { pathname: fill(v0) },
+ { ...self, oldVersion: false },
+ );
+ if (v0 === v1) {
+ expect(r).toEqual({ kind: "v1", entry });
+ } else {
+ expect(r).toEqual({ kind: "redirect", to: fill(v1) });
+ expect(
+ resolveRoute({ pathname: fill(v1) }, { ...self, oldVersion: false }),
+ ).toEqual({ kind: "v1", entry });
+ }
+ }
+ });
+
+ it.runIf(entry.toggle)(
+ "in old-version mode each v1 route redirects to its mapped v0 route, which renders v0",
+ () => {
+ for (const [v1, v0] of Object.entries(entry.toV0)) {
+ const r = resolveRoute(
+ { pathname: fill(v1) },
+ { ...self, oldVersion: true },
+ );
+ expect(r).toEqual(
+ v1 === v0 ? { kind: "v0" } : { kind: "redirect", to: fill(v0) },
+ );
+ expect(
+ resolveRoute({ pathname: fill(v0) }, { ...self, oldVersion: true }),
+ ).toEqual({
+ kind: "v0",
+ });
+ }
+ },
+ );
+});
diff --git a/frontend/src/v1/app/registry/resolve.ts b/frontend/src/v1/app/registry/resolve.ts
new file mode 100644
index 0000000000..f25f5d2f97
--- /dev/null
+++ b/frontend/src/v1/app/registry/resolve.ts
@@ -0,0 +1,154 @@
+import { generatePath, matchRoutes, type Params } from "react-router-dom";
+import { paramNames } from "./patterns";
+import type { Distribution, PageKey, RegistryEntry } from "./types";
+
+/** What the version switch does with a URL. */
+export type Resolution =
+ | Readonly<{ kind: "v0" }>
+ | Readonly<{ kind: "v1"; entry: RegistryEntry }>
+ | Readonly<{ kind: "redirect"; to: string }>;
+
+export type ResolveContext = Readonly<{
+ /** The pages that are live in this build and session (see activeEntries). */
+ entries: readonly RegistryEntry[];
+ /**
+ * The user's old-version preference: honoured by toggle pages only. `null`
+ * while the user is unknown, which leaves a toggle page's v0 URLs on v0 so
+ * v0 can load the session (and with it the preference).
+ */
+ oldVersion: boolean | null;
+}>;
+
+type RouteLocation = Readonly<{
+ pathname: string;
+ search?: string;
+ hash?: string;
+}>;
+
+/** The live entries: declared, registered (or previewed) and shipped in this distribution. */
+export function activeEntries(
+ pages: readonly RegistryEntry[],
+ live: ReadonlySet,
+ distribution: Distribution,
+): RegistryEntry[] {
+ return pages.filter(
+ (e) => live.has(e.page) && e.distributions.includes(distribution),
+ );
+}
+
+type Owned = Readonly<{ path: string; entry: RegistryEntry }>;
+
+/** The registry route a pathname lands on; React Router ranks the most specific pattern first. */
+export function matchRegistryRoute(
+ pathname: string,
+ entries: readonly RegistryEntry[],
+): { route: Owned; params: Params } | null {
+ const routes: Owned[] = entries.flatMap((entry) =>
+ [...new Set([...entry.v1Routes, ...entry.replaces])].map((path) => ({
+ path,
+ entry,
+ })),
+ );
+ const found = matchRoutes(routes, pathname)?.[0];
+ return found ? { route: found.route, params: found.params } : null;
+}
+
+/**
+ * Fills a pattern with raw (decoded) param values, encoding each one, so a
+ * value holding `/`, `?` or `#` stays inside its segment. A splat keeps its
+ * slashes and encodes each of its segments.
+ */
+export function fillPattern(
+ pattern: string,
+ params: Readonly>,
+): string {
+ const encoded: Record = {};
+ for (const [name, value] of Object.entries(params)) {
+ if (value !== undefined) {
+ encoded[name] =
+ name === "*"
+ ? value.split("/").map(encodeURIComponent).join("/")
+ : encodeURIComponent(value);
+ }
+ }
+ return generatePath(pattern, encoded);
+}
+
+function redirect(
+ target: string,
+ params: Params,
+ location: RouteLocation,
+): Resolution {
+ return {
+ kind: "redirect",
+ to: `${fillPattern(target, params)}${location.search ?? ""}${location.hash ?? ""}`,
+ };
+}
+
+/**
+ * The resolution rule. An unregistered URL renders v0. A registered page
+ * renders v1, and its v0 URLs redirect to their mapped v1 route. A toggle page
+ * (Human Review) does the reverse while the user has chosen the old version,
+ * and keeps its v0 URLs on v0 while the user is unknown.
+ */
+export function resolveRoute(
+ location: RouteLocation,
+ ctx: ResolveContext,
+): Resolution {
+ const found = matchRegistryRoute(location.pathname, ctx.entries);
+ if (!found) {
+ return { kind: "v0" };
+ }
+ const { entry, path } = found.route;
+ const isV1 = entry.v1Routes.includes(path);
+ const isV0 = entry.replaces.includes(path);
+
+ if (entry.toggle && ctx.oldVersion !== false) {
+ if (isV0) {
+ return { kind: "v0" };
+ }
+ if (ctx.oldVersion === true) {
+ const target = entry.toV0[path];
+ return target ? redirect(target, found.params, location) : { kind: "v0" };
+ }
+ // Unknown user on one of the page's v1 URLs: render v1, the default.
+ }
+ if (isV1) {
+ return { kind: "v1", entry };
+ }
+ const target = entry.toV1[path];
+ return target ? redirect(target, found.params, location) : { kind: "v0" };
+}
+
+/**
+ * The URL that opens a page's v1 route in the version the user gets: the v1
+ * URL when the page is live (unless it is a toggle page and the user chose the
+ * old version), else its mapped v0 URL. Params are raw values; they are
+ * encoded here. Null when the page is not declared, the pattern is not one
+ * of its v1 routes, or a `:param` of the pattern is missing or empty (a final
+ * `*` may be omitted).
+ */
+export function resolvePageUrl(
+ page: PageKey,
+ v1Pattern: string,
+ params: Readonly>,
+ pages: readonly RegistryEntry[],
+ ctx: ResolveContext,
+): string | null {
+ const entry = pages.find((e) => e.page === page);
+ if (!entry?.v1Routes.includes(v1Pattern)) {
+ return null;
+ }
+ // generatePath throws on a missing param and drops an empty one's segment
+ // (`/:orgName/x` with "" gives `/x`), so neither can build a right URL.
+ if (paramNames(v1Pattern).some((name) => name !== "*" && !params[name])) {
+ return null;
+ }
+ const live = ctx.entries.some((e) => e.page === page);
+ const useV0 = !live || (entry.toggle && ctx.oldVersion === true);
+ if (!useV0) {
+ return fillPattern(v1Pattern, params);
+ }
+ const target = entry.toV0[v1Pattern];
+ return target ? fillPattern(target, params) : null;
+}
diff --git a/frontend/src/v1/app/registry/types.ts b/frontend/src/v1/app/registry/types.ts
new file mode 100644
index 0000000000..df9a7880e1
--- /dev/null
+++ b/frontend/src/v1/app/registry/types.ts
@@ -0,0 +1,58 @@
+/*
+ * Route registry types (UN-4271).
+ *
+ * This folder's data files (types.ts, entries.ts) import only each other, with
+ * `./`, and never touch `import.meta.env`, so a Playwright E2E harness (no
+ * Vite, no `@v1` alias) can import them; P0-T09 (UN-4345) wires one in. The
+ * same holds for `lib/oldVersion.ts` (the old-version preference keys), which
+ * imports nothing. Keep it that way: `live.ts` reads `import.meta.env` when it
+ * loads, so a key the harness needs is declared here, not there.
+ */
+
+/**
+ * Dev/test override: a JSON array of declared page keys to render as v1
+ * before they are registered (D9), e.g. `["UN05"]`, read when the app loads.
+ * Honoured only with VITE_V1_DEV_ROUTES=true.
+ */
+export const PREVIEW_STORAGE_KEY = "unstract.v1.preview";
+
+/** The builds the frontend ships in. */
+export const DISTRIBUTIONS = ["oss", "cloud", "onprem"] as const;
+
+export type Distribution = (typeof DISTRIBUTIONS)[number];
+
+/**
+ * Target page key: UN01 to UN20 (Unstract), LW01 to LW07 (LLMWhisperer), or a
+ * development-only demo page (`DEMO-…`) that is live only when
+ * VITE_V1_DEV_ROUTES is on. The exact format is checked by registryProblems.
+ */
+export type PageKey = `UN${string}` | `LW${string}` | `DEMO-${string}`;
+
+/**
+ * One v1 page. Route patterns are absolute React Router patterns
+ * (`/:orgName/workflows/:id`); a trailing `/*` matches the rest of the URL.
+ */
+export type RegistryEntry = Readonly<{
+ page: PageKey;
+ /** The v1 route patterns, including v1-only sub-routes (tabs, stages). */
+ v1Routes: readonly string[];
+ /** The v0 route patterns this page takes over. */
+ replaces: readonly string[];
+ /**
+ * v1 pattern -> the v0 pattern it maps back to; covers every v1 route. Used
+ * by Human Review's old-version mode, and by resolveUrl while the page is
+ * not live. A v1-only sub-route maps to the nearest v0 route.
+ */
+ toV0: Readonly>;
+ /** v0 pattern -> the v1 pattern it redirects to; covers every `replaces` pattern. */
+ toV1: Readonly>;
+ /**
+ * Honours the old-version preference. Allowed only for UN11 (Human Review,
+ * D14) and its demo stand-in; see TOGGLE_PAGES.
+ */
+ toggle: boolean;
+ /** Builds the page exists in; an entry the current build lacks is ignored. */
+ distributions: readonly Distribution[];
+ /** The go-live task that registers the page, e.g. "P6-T07". */
+ registeredBy: string;
+}>;
diff --git a/frontend/src/v1/app/registry/validate.test.ts b/frontend/src/v1/app/registry/validate.test.ts
new file mode 100644
index 0000000000..00e1341810
--- /dev/null
+++ b/frontend/src/v1/app/registry/validate.test.ts
@@ -0,0 +1,222 @@
+import { PAGE_COMPONENTS } from "@v1/app/pages";
+import { describe, expect, it } from "vitest";
+import { DEMO_PAGES, PAGES, REGISTERED_PAGES } from "./entries";
+import { patternProblem, patternsOverlap } from "./patterns";
+import type { RegistryEntry } from "./types";
+import { registryProblems } from "./validate";
+
+describe("the app's registry", () => {
+ const declared = [...PAGES, ...DEMO_PAGES];
+
+ it("is valid", () => {
+ expect(registryProblems(declared)).toEqual([]);
+ });
+
+ it("registers only declared pages", () => {
+ const keys = PAGES.map((p) => p.page);
+ expect(REGISTERED_PAGES.filter((k) => !keys.includes(k))).toEqual([]);
+ });
+
+ it("has a component for every declared page", () => {
+ expect(
+ declared.filter((p) => !PAGE_COMPONENTS[p.page]).map((p) => p.page),
+ ).toEqual([]);
+ });
+});
+
+const base: RegistryEntry = {
+ page: "UN05",
+ v1Routes: ["/:orgName/workflows"],
+ replaces: ["/:orgName/old-workflows"],
+ toV0: { "/:orgName/workflows": "/:orgName/old-workflows" },
+ toV1: { "/:orgName/old-workflows": "/:orgName/workflows" },
+ toggle: false,
+ distributions: ["oss"],
+ registeredBy: "P3-T01",
+};
+
+/** An entry with only v1 routes, each mapped back to one v0 route. */
+function v1Only(
+ page: RegistryEntry["page"],
+ v1Routes: string[],
+): RegistryEntry {
+ return {
+ ...base,
+ page,
+ v1Routes,
+ replaces: ["/:orgName/old-workflows"],
+ toV0: Object.fromEntries(
+ v1Routes.map((r) => [r, "/:orgName/old-workflows"]),
+ ),
+ toV1: { "/:orgName/old-workflows": v1Routes[0] ?? "" },
+ };
+}
+
+const problemsOf = (...entries: RegistryEntry[]) => registryProblems(entries);
+
+describe("registryProblems", () => {
+ it("accepts a well-formed entry", () => {
+ expect(problemsOf(base)).toEqual([]);
+ });
+
+ it("rejects the toggle on any page but Human Review (D14)", () => {
+ expect(problemsOf({ ...base, toggle: true })).toEqual([
+ "UN05: only Human Review (UN11) may honour the old-version preference (D14)",
+ ]);
+ expect(problemsOf({ ...base, page: "UN11", toggle: true })).toEqual([]);
+ });
+
+ it("requires every v1 route to map back to v0, on every page", () => {
+ for (const page of ["UN05", "UN11"] as const) {
+ expect(
+ problemsOf({ ...base, page, toggle: page === "UN11", toV0: {} }),
+ ).toEqual([
+ `${page}: v1 route /:orgName/workflows has no v0 route to map back to`,
+ ]);
+ }
+ });
+
+ it("requires every replaced v0 route to redirect somewhere", () => {
+ expect(problemsOf({ ...base, toV1: {} })).toEqual([
+ "UN05: v0 route /:orgName/old-workflows has no v1 route to redirect to",
+ ]);
+ });
+
+ it("rejects mappings to routes the page does not own, or needing params the source lacks", () => {
+ expect(
+ problemsOf({
+ ...base,
+ toV1: { "/:orgName/old-workflows": "/:orgName/x" },
+ }),
+ ).toEqual([
+ "UN05: toV1[/:orgName/old-workflows] -> /:orgName/x is not one of its routes",
+ ]);
+ expect(
+ problemsOf({
+ ...base,
+ v1Routes: ["/:orgName/workflows/:id"],
+ toV0: { "/:orgName/workflows/:id": "/:orgName/old-workflows" },
+ toV1: { "/:orgName/old-workflows": "/:orgName/workflows/:id" },
+ }),
+ ).toEqual([
+ "UN05: toV1[/:orgName/old-workflows] -> /:orgName/workflows/:id needs params /:orgName/old-workflows lacks: id",
+ ]);
+ });
+
+ it("rejects a v1 and a v0 route of the same page that match the same URL", () => {
+ // v0 `/hr` outranks the v1 splat `/hr/*` for `/org/hr`: an endless redirect.
+ expect(
+ problemsOf({
+ ...base,
+ v1Routes: ["/:orgName/hr/*"],
+ replaces: ["/:orgName/hr"],
+ toV0: { "/:orgName/hr/*": "/:orgName/hr" },
+ toV1: { "/:orgName/hr": "/:orgName/hr/*" },
+ }),
+ ).toEqual(["UN05: v1 route /:orgName/hr/* overlaps v0 route /:orgName/hr"]);
+ // The same shape in Human Review's old-version mode.
+ expect(
+ problemsOf({
+ ...base,
+ page: "UN11",
+ toggle: true,
+ v1Routes: ["/:orgName/review"],
+ replaces: ["/:orgName/review/*"],
+ toV0: { "/:orgName/review": "/:orgName/review/*" },
+ toV1: { "/:orgName/review/*": "/:orgName/review" },
+ }),
+ ).toEqual([
+ "UN11: v1 route /:orgName/review overlaps v0 route /:orgName/review/*",
+ ]);
+ // A param equal to a static segment: `/old/settings` would redirect to
+ // `/new/settings`, which the v0 route takes, and on to `/settings`.
+ expect(
+ problemsOf({
+ ...base,
+ v1Routes: ["/new/:id", "/settings"],
+ replaces: ["/old/:id", "/new/settings"],
+ toV0: { "/new/:id": "/old/:id", "/settings": "/new/settings" },
+ toV1: { "/old/:id": "/new/:id", "/new/settings": "/settings" },
+ }),
+ ).toEqual(["UN05: v1 route /new/:id overlaps v0 route /new/settings"]);
+ });
+
+ it("accepts a route both versions share (same-URL takeover)", () => {
+ expect(
+ problemsOf({
+ ...base,
+ v1Routes: ["/:orgName/workflows"],
+ replaces: ["/:orgName/workflows"],
+ toV0: { "/:orgName/workflows": "/:orgName/workflows" },
+ toV1: { "/:orgName/workflows": "/:orgName/workflows" },
+ }),
+ ).toEqual([]);
+ });
+
+ it("rejects overlapping patterns across pages, but not shared prefixes", () => {
+ expect(problemsOf(base, v1Only("UN10", ["/:orgName/workflows/*"]))).toEqual(
+ expect.arrayContaining([
+ "UN05 /:orgName/workflows overlaps UN10 /:orgName/workflows/*",
+ ]),
+ );
+ expect(
+ problemsOf(base, {
+ ...v1Only("UN10", ["/:orgName/workflows/logs"]),
+ replaces: ["/:orgName/old-logs"],
+ toV0: { "/:orgName/workflows/logs": "/:orgName/old-logs" },
+ toV1: { "/:orgName/old-logs": "/:orgName/workflows/logs" },
+ }),
+ ).toEqual([]);
+ });
+
+ it("rejects malformed page keys", () => {
+ for (const page of ["UN5", "UN21", "UNKNOWN", "LW08", "DEMO-"] as const) {
+ expect(problemsOf({ ...base, page })).toEqual([
+ `${page}: not a page key (UN01–UN20, LW01–LW07 or DEMO-…)`,
+ ]);
+ }
+ });
+
+ it("rejects duplicates, empty routes or distributions, and malformed patterns", () => {
+ expect(problemsOf(base, base)).toContain("UN05: declared twice");
+ expect(
+ problemsOf({
+ ...base,
+ v1Routes: [],
+ replaces: [],
+ toV0: {},
+ toV1: {},
+ distributions: [],
+ }),
+ ).toEqual(["UN05: has no v1 routes", "UN05: ships in no distribution"]);
+ expect(
+ problemsOf(v1Only("UN05", ["workflows", "/:orgName/:id?", "/a/*/b"])),
+ ).toEqual(
+ expect.arrayContaining([
+ "UN05: route workflows must start with /",
+ "UN05: route /:orgName/:id? segment ':id?' is not a static name, :param or a final *",
+ "UN05: route /a/*/b * is allowed only as the last segment",
+ ]),
+ );
+ });
+});
+
+describe("patterns", () => {
+ it.each([
+ ["/:o/a", "/:o/a", true],
+ ["/:o/a", "/x/a", true],
+ ["/:o/a", "/:o/b", false],
+ ["/:o/a", "/:o/a/b", false],
+ ["/:o/a/*", "/:o/a", true],
+ ["/:o/a/*", "/:o/a/b/c", true],
+ ["/:o/a/*", "/:o/b/c", false],
+ ["/:o/A", "/:o/a", true],
+ ])("%s and %s overlap: %s", (a, b, expected) => {
+ expect(patternsOverlap(a, b)).toBe(expected);
+ expect(patternsOverlap(b, a)).toBe(expected);
+ });
+
+ it("accepts static, param and final-splat segments", () => {
+ expect(patternProblem("/llm-whisperer/:orgName/api-keys/*")).toBeNull();
+ });
+});
diff --git a/frontend/src/v1/app/registry/validate.ts b/frontend/src/v1/app/registry/validate.ts
new file mode 100644
index 0000000000..3e8accc240
--- /dev/null
+++ b/frontend/src/v1/app/registry/validate.ts
@@ -0,0 +1,157 @@
+import { paramNames, patternProblem, patternsOverlap } from "./patterns";
+import type { PageKey, RegistryEntry } from "./types";
+
+/** Pages allowed to honour the old-version preference: Human Review (D14) and its demo stand-in. */
+export const TOGGLE_PAGES: readonly PageKey[] = ["UN11", "DEMO-REVIEW"];
+
+const PAGE_KEY = /^(UN(0[1-9]|1\d|20)|LW0[1-7]|DEMO-[A-Z0-9]+(-[A-Z0-9]+)*)$/;
+
+/** True for UN01–UN20, LW01–LW07 and `DEMO-…` keys. */
+export function isPageKey(value: unknown): value is PageKey {
+ return typeof value === "string" && PAGE_KEY.test(value);
+}
+
+function checkMapping(
+ entry: RegistryEntry,
+ name: "toV0" | "toV1",
+ from: readonly string[],
+ to: readonly string[],
+ problems: string[],
+): void {
+ for (const [source, target] of Object.entries(entry[name])) {
+ const where = `${entry.page}: ${name}[${source}] -> ${target}`;
+ if (!from.includes(source)) {
+ problems.push(
+ `${entry.page}: ${name} key ${source} is not one of its routes`,
+ );
+ }
+ if (!to.includes(target)) {
+ problems.push(`${where} is not one of its routes`);
+ continue;
+ }
+ // A splat may be empty, so a target ending in `*` needs nothing from the source.
+ const available = new Set([...paramNames(source), "*"]);
+ const missing = paramNames(target).filter((p) => !available.has(p));
+ if (missing.length) {
+ problems.push(
+ `${where} needs params ${source} lacks: ${missing.join(", ")}`,
+ );
+ }
+ }
+}
+
+/** The entry's own fields: key, pattern syntax, and that it has routes and distributions. */
+function checkShape(entry: RegistryEntry, problems: string[]): void {
+ const { page, v1Routes, replaces } = entry;
+ if (!isPageKey(page)) {
+ problems.push(`${page}: not a page key (UN01–UN20, LW01–LW07 or DEMO-…)`);
+ }
+ for (const pattern of [...v1Routes, ...replaces]) {
+ const problem = patternProblem(pattern);
+ if (problem) {
+ problems.push(`${page}: route ${pattern} ${problem}`);
+ }
+ }
+ if (!v1Routes.length) {
+ problems.push(`${page}: has no v1 routes`);
+ }
+ if (!entry.distributions.length) {
+ problems.push(`${page}: ships in no distribution`);
+ }
+}
+
+function checkToggle(entry: RegistryEntry, problems: string[]): void {
+ if (entry.toggle && !TOGGLE_PAGES.includes(entry.page)) {
+ problems.push(
+ `${entry.page}: only Human Review (UN11) may honour the old-version preference (D14)`,
+ );
+ }
+}
+
+/**
+ * A URL matching both a v1 route and a different v0 route of the page is
+ * ambiguous: the more specific pattern wins, so a redirect can land on the
+ * other version and bounce on to the wrong screen, or back, forever.
+ * Identical patterns (the page keeps its v0 URL) are fine.
+ */
+function checkSamePageOverlap(entry: RegistryEntry, problems: string[]): void {
+ for (const v1 of entry.v1Routes) {
+ for (const v0 of entry.replaces) {
+ if (v1 !== v0 && patternsOverlap(v1, v0)) {
+ problems.push(`${entry.page}: v1 route ${v1} overlaps v0 route ${v0}`);
+ }
+ }
+ }
+}
+
+/** Every route in `routes` has a key in the `name` mapping. */
+function checkCoverage(
+ entry: RegistryEntry,
+ name: "toV0" | "toV1",
+ routes: readonly string[],
+ missing: (pattern: string) => string,
+ problems: string[],
+): void {
+ for (const pattern of routes) {
+ if (!(pattern in entry[name])) {
+ problems.push(`${entry.page}: ${missing(pattern)}`);
+ }
+ }
+}
+
+function checkEntry(entry: RegistryEntry, problems: string[]): void {
+ const { v1Routes, replaces } = entry;
+ checkShape(entry, problems);
+ checkToggle(entry, problems);
+ checkSamePageOverlap(entry, problems);
+ checkMapping(entry, "toV1", replaces, v1Routes, problems);
+ checkMapping(entry, "toV0", v1Routes, replaces, problems);
+ checkCoverage(
+ entry,
+ "toV1",
+ replaces,
+ (p) => `v0 route ${p} has no v1 route to redirect to`,
+ problems,
+ );
+ // Every v1 route maps back: Human Review's old-version mode needs it, and
+ // so does resolveUrl for a page that is declared but not live yet.
+ checkCoverage(
+ entry,
+ "toV0",
+ v1Routes,
+ (p) => `v1 route ${p} has no v0 route to map back to`,
+ problems,
+ );
+}
+
+/**
+ * Every problem with a registry, as readable lines; empty when it is valid.
+ * Run over the real registry by its unit test, so a bad entry fails CI.
+ */
+export function registryProblems(entries: readonly RegistryEntry[]): string[] {
+ const problems: string[] = [];
+ const seen = new Set();
+ for (const entry of entries) {
+ if (seen.has(entry.page)) {
+ problems.push(`${entry.page}: declared twice`);
+ }
+ seen.add(entry.page);
+ checkEntry(entry, problems);
+ }
+ // Two pages may share a prefix (/:o/workflows and /:o/workflows/logs), but
+ // no URL may match patterns of both.
+ const owned = entries.flatMap((entry) =>
+ [...new Set([...entry.v1Routes, ...entry.replaces])].map((pattern) => ({
+ page: entry.page,
+ pattern,
+ })),
+ );
+ for (const [i, a] of owned.entries()) {
+ for (const b of owned.slice(i + 1)) {
+ if (a.page !== b.page && patternsOverlap(a.pattern, b.pattern)) {
+ problems.push(`${a.page} ${a.pattern} overlaps ${b.page} ${b.pattern}`);
+ }
+ }
+ }
+ return problems;
+}
diff --git a/frontend/src/v1/index.ts b/frontend/src/v1/index.ts
index 48afca1c00..4ce98a3cfb 100644
--- a/frontend/src/v1/index.ts
+++ b/frontend/src/v1/index.ts
@@ -2,6 +2,18 @@
* Public entry of the v1 frontend — the only v1 module v0 may import, as
* `@v1`. Lint rejects any other v0 → v1 import (see src/v1/README.md).
*
- * Empty until the version switch (UN-4271) mounts v1 through it.
+ * Keep this light: v0 loads it eagerly, so it holds the version switch and
+ * the registry, and the v1 tree itself loads only when a v1 page renders.
*/
-export {};
+
+export {
+ resolveUrl,
+ setOldVersion,
+ v1DevRoutesEnabled,
+} from "@v1/app/api";
+export type {
+ Distribution,
+ PageKey,
+ RegistryEntry,
+} from "@v1/app/registry/types";
+export { VersionSwitch, type VersionSwitchProps } from "@v1/app/VersionSwitch";
diff --git a/frontend/src/v1/lib/chunkReload.test.ts b/frontend/src/v1/lib/chunkReload.test.ts
new file mode 100644
index 0000000000..38acb6d047
--- /dev/null
+++ b/frontend/src/v1/lib/chunkReload.test.ts
@@ -0,0 +1,57 @@
+import { describe, expect, it, vi } from "vitest";
+import { isChunkLoadError, reloadOnce } from "./chunkReload";
+
+function memoryStorage() {
+ const data = new Map();
+ return {
+ getItem: (k: string) => data.get(k) ?? null,
+ setItem: (k: string, v: string) => {
+ data.set(k, v);
+ },
+ };
+}
+
+describe("isChunkLoadError", () => {
+ it.each([
+ new TypeError("Failed to fetch dynamically imported module: /a.js"),
+ new TypeError("error loading dynamically imported module"),
+ new TypeError("Importing a module script failed."),
+ Object.assign(new Error("x"), { name: "ChunkLoadError" }),
+ ])("matches %s", (error) => {
+ expect(isChunkLoadError(error)).toBe(true);
+ });
+
+ it("does not match render errors or non-errors", () => {
+ expect(isChunkLoadError(new Error("Cannot read properties of null"))).toBe(
+ false,
+ );
+ expect(
+ isChunkLoadError("Failed to fetch dynamically imported module"),
+ ).toBe(false);
+ });
+});
+
+describe("reloadOnce", () => {
+ it("reloads once, then not again within ten seconds, then again after", () => {
+ const storage = memoryStorage();
+ const reload = vi.fn();
+ expect(reloadOnce(storage, reload, 100_000)).toBe(true);
+ expect(reloadOnce(storage, reload, 105_000)).toBe(false);
+ expect(reloadOnce(storage, reload, 110_000)).toBe(false); // exactly ten seconds
+ expect(reloadOnce(storage, reload, 110_001)).toBe(true);
+ expect(reload).toHaveBeenCalledTimes(2);
+ });
+
+ it("never reloads without usable session storage", () => {
+ const reload = vi.fn();
+ expect(reloadOnce(null, reload)).toBe(false);
+ const refusing = {
+ getItem: () => null,
+ setItem: () => {
+ throw new Error("denied");
+ },
+ };
+ expect(reloadOnce(refusing, reload)).toBe(false);
+ expect(reload).not.toHaveBeenCalled();
+ });
+});
diff --git a/frontend/src/v1/lib/chunkReload.ts b/frontend/src/v1/lib/chunkReload.ts
new file mode 100644
index 0000000000..8680021520
--- /dev/null
+++ b/frontend/src/v1/lib/chunkReload.ts
@@ -0,0 +1,45 @@
+/*
+ * Recovery from a stale code chunk after a deploy: the browser's page names a
+ * hashed file the server no longer has. Mirrors v0's handleRouteError
+ * (components/error/LazyOutlet), which v1 may not import.
+ */
+
+const RELOAD_KEY = "unstract.v1.chunkReloadAt";
+const RELOAD_WINDOW_MS = 10_000;
+
+/** A failed dynamic import; matching is best-effort across browsers. */
+export function isChunkLoadError(error: unknown): boolean {
+ if (!(error instanceof Error)) {
+ return false;
+ }
+ const { message } = error;
+ return (
+ error.name === "ChunkLoadError" ||
+ message.includes("Failed to fetch dynamically imported module") ||
+ message.includes("error loading dynamically imported module") ||
+ message.includes("Importing a module script failed")
+ );
+}
+
+/**
+ * Reloads once to fetch the new deploy's chunks. A repeat failure within ten
+ * seconds does not reload again, so a chunk that is really gone shows the
+ * manual fallback instead of looping. True when it reloaded.
+ */
+export function reloadOnce(
+ storage: Pick | null,
+ reload: () => void,
+ now: number = Date.now(),
+): boolean {
+ try {
+ const last = Number(storage?.getItem(RELOAD_KEY) ?? 0);
+ if (!storage || now - last <= RELOAD_WINDOW_MS) {
+ return false;
+ }
+ storage.setItem(RELOAD_KEY, String(now));
+ } catch {
+ return false; // no session storage: leave recovery to the fallback
+ }
+ reload();
+ return true;
+}
diff --git a/frontend/src/v1/lib/oldVersion.ts b/frontend/src/v1/lib/oldVersion.ts
new file mode 100644
index 0000000000..38d41bb7d0
--- /dev/null
+++ b/frontend/src/v1/lib/oldVersion.ts
@@ -0,0 +1,72 @@
+/*
+ * Storage for the old-version preference (UN-4271, D14): per user, per
+ * browser, no backend. Only toggle pages (Human Review) read it.
+ */
+
+const KEY_PREFIX = "unstract.v1.oldVersion.";
+
+/** The last user signed in on this browser, so a reload can resolve before the session loads. */
+export const LAST_USER_STORAGE_KEY = "unstract.v1.lastUser";
+
+/** The value stored while a user has chosen the old version; absent means v1. */
+export const OLD_VERSION_ON = "true";
+
+/** The storage key holding one user's preference. */
+export function oldVersionStorageKey(userId: string): string {
+ return `${KEY_PREFIX}${userId}`;
+}
+
+/** The stored preference; missing or unreadable means v1 (false). */
+export function readOldVersion(
+ storage: Pick | null | undefined,
+ userId: string,
+): boolean {
+ try {
+ return storage?.getItem(oldVersionStorageKey(userId)) === OLD_VERSION_ON;
+ } catch {
+ return false;
+ }
+}
+
+/** Stores the preference; false if storage is absent or refuses the write. */
+export function writeOldVersion(
+ storage: Pick | null | undefined,
+ userId: string,
+ on: boolean,
+): boolean {
+ if (!storage) {
+ return false;
+ }
+ try {
+ const key = oldVersionStorageKey(userId);
+ if (on) {
+ storage.setItem(key, OLD_VERSION_ON);
+ } else {
+ storage.removeItem(key);
+ }
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+export function readLastUser(
+ storage: Pick | null | undefined,
+): string | null {
+ try {
+ return storage?.getItem(LAST_USER_STORAGE_KEY) || null;
+ } catch {
+ return null;
+ }
+}
+
+export function writeLastUser(
+ storage: Pick | null | undefined,
+ userId: string,
+): void {
+ try {
+ storage?.setItem(LAST_USER_STORAGE_KEY, userId);
+ } catch {
+ // Unwritable storage: the next load waits for the session instead.
+ }
+}
diff --git a/frontend/src/v1/pages/.gitkeep b/frontend/src/v1/pages/.gitkeep
deleted file mode 100644
index e69de29bb2..0000000000
diff --git a/frontend/src/v1/pages/demo/index.tsx b/frontend/src/v1/pages/demo/index.tsx
new file mode 100644
index 0000000000..c3c873fa49
--- /dev/null
+++ b/frontend/src/v1/pages/demo/index.tsx
@@ -0,0 +1,36 @@
+/*
+ * Development-only demo pages that prove the version switch (UN-4271). They
+ * ship in every build but are reachable only with VITE_V1_DEV_ROUTES=true.
+ * DemoReviewPage stands in for Human Review: its "Switch to old version"
+ * control is the shape of the one P6-T07 builds.
+ */
+
+import { useOldVersionStore } from "@v1/stores/oldVersion";
+import type { JSX } from "react";
+import { Link, useParams } from "react-router-dom";
+
+export function DemoPage(): JSX.Element {
+ return (
+
+ v1 demo page
+
+ );
+}
+
+export function DemoReviewPage(): JSX.Element {
+ // useParams decodes, so encode again to keep e.g. `a%2Fb` one segment.
+ const org = encodeURIComponent(useParams().orgName ?? "");
+ const setOldVersion = useOldVersionStore((s) => s.setOldVersion);
+ return (
+
+ v1 demo review
+
+ Queue
+ Details
+
+ setOldVersion(true)}>
+ Switch to old version
+
+
+ );
+}
diff --git a/frontend/src/v1/stores/oldVersion.test.ts b/frontend/src/v1/stores/oldVersion.test.ts
new file mode 100644
index 0000000000..9488d0c191
--- /dev/null
+++ b/frontend/src/v1/stores/oldVersion.test.ts
@@ -0,0 +1,104 @@
+import {
+ LAST_USER_STORAGE_KEY,
+ oldVersionStorageKey,
+ readOldVersion,
+ writeOldVersion,
+} from "@v1/lib/oldVersion";
+import { describe, expect, it } from "vitest";
+import { createOldVersionStore } from "./oldVersion";
+
+function memoryStorage(initial: Record = {}) {
+ const data = new Map(Object.entries(initial));
+ return {
+ data,
+ getItem: (k: string) => data.get(k) ?? null,
+ setItem: (k: string, v: string) => {
+ data.set(k, v);
+ },
+ removeItem: (k: string) => {
+ data.delete(k);
+ },
+ };
+}
+
+const refusing = {
+ getItem: () => {
+ throw new Error("denied");
+ },
+ setItem: () => {
+ throw new Error("denied");
+ },
+ removeItem: () => {
+ throw new Error("denied");
+ },
+};
+
+describe("old-version storage", () => {
+ it("is keyed per user and absent means v1", () => {
+ const s = memoryStorage();
+ expect(readOldVersion(s, "7")).toBe(false);
+ expect(writeOldVersion(s, "7", true)).toBe(true);
+ expect(s.data.get(oldVersionStorageKey("7"))).toBe("true");
+ expect(readOldVersion(s, "7")).toBe(true);
+ expect(readOldVersion(s, "8")).toBe(false);
+ writeOldVersion(s, "7", false);
+ expect(s.data.has(oldVersionStorageKey("7"))).toBe(false);
+ });
+
+ it("unreadable or refusing storage reads as v1 and reports the failed write", () => {
+ expect(readOldVersion(refusing, "7")).toBe(false);
+ expect(writeOldVersion(refusing, "7", true)).toBe(false);
+ expect(writeOldVersion(null, "7", true)).toBe(false);
+ });
+});
+
+describe("old-version store", () => {
+ it("knows no user and no choice on a fresh browser, and cannot be set", () => {
+ const store = createOldVersionStore(memoryStorage());
+ expect(store.getState()).toMatchObject({ userId: null, oldVersion: null });
+ expect(store.getState().setOldVersion(true)).toBe(false);
+ expect(store.getState().oldVersion).toBeNull();
+ });
+
+ it("survives reload and re-login: a new store reads the last user's choice", () => {
+ const s = memoryStorage();
+ const first = createOldVersionStore(s);
+ first.getState().setUser("7");
+ expect(first.getState().oldVersion).toBe(false);
+ expect(first.getState().setOldVersion(true)).toBe(true);
+ expect(s.data.get(LAST_USER_STORAGE_KEY)).toBe("7");
+
+ const reloaded = createOldVersionStore(s);
+ expect(reloaded.getState()).toMatchObject({
+ userId: "7",
+ oldVersion: true,
+ });
+ reloaded.getState().setUser(null); // signed out: keeps the last user
+ expect(reloaded.getState()).toMatchObject({
+ userId: "7",
+ oldVersion: true,
+ });
+ reloaded.getState().setUser("7");
+ expect(reloaded.getState().oldVersion).toBe(true);
+ });
+
+ it("is per user", () => {
+ const s = memoryStorage({ [oldVersionStorageKey("7")]: "true" });
+ const store = createOldVersionStore(s);
+ store.getState().setUser("8");
+ expect(store.getState().oldVersion).toBe(false);
+ store.getState().setUser("7");
+ expect(store.getState().oldVersion).toBe(true);
+ });
+
+ it("with refusing storage, defaults to v1 and the choice still holds for the session", () => {
+ const store = createOldVersionStore(refusing);
+ store.getState().setUser("7");
+ expect(store.getState().oldVersion).toBe(false);
+ expect(store.getState().setOldVersion(true)).toBe(true);
+ expect(store.getState().oldVersion).toBe(true);
+ store.getState().setUser("8");
+ store.getState().setUser("7");
+ expect(store.getState().oldVersion).toBe(true);
+ });
+});
diff --git a/frontend/src/v1/stores/oldVersion.ts b/frontend/src/v1/stores/oldVersion.ts
new file mode 100644
index 0000000000..e85a66af0b
--- /dev/null
+++ b/frontend/src/v1/stores/oldVersion.ts
@@ -0,0 +1,86 @@
+import {
+ readLastUser,
+ readOldVersion,
+ writeLastUser,
+ writeOldVersion,
+} from "@v1/lib/oldVersion";
+import { create, type UseBoundStore } from "zustand";
+import { createStore, type StateCreator, type StoreApi } from "zustand/vanilla";
+
+export type OldVersionState = {
+ /** The user the preference belongs to: the signed-in user, else the last one on this browser. */
+ userId: string | null;
+ /** That user's choice; null while no user is known. Only toggle pages read it. */
+ oldVersion: boolean | null;
+ /** Choices made this session, so they hold when storage refuses the write. */
+ chosen: Readonly>;
+ /** Follows the signed-in user. Signing out (null) keeps the last user. */
+ setUser: (userId: string | null) => void;
+ /**
+ * Sets the current user's choice and saves it to localStorage. The version
+ * switch then moves the user to the equivalent screen, with no reload.
+ * True once the choice is set, even if storage refused the write: then it
+ * lasts for this session only. False (and no change) while no user is known.
+ */
+ setOldVersion: (on: boolean) => boolean;
+};
+
+type PreferenceStorage = Pick<
+ Storage,
+ "getItem" | "setItem" | "removeItem"
+> | null;
+
+function oldVersionState(
+ storage: PreferenceStorage,
+): StateCreator {
+ const lastUser = readLastUser(storage);
+ return (set, get) => ({
+ userId: lastUser,
+ oldVersion: lastUser === null ? null : readOldVersion(storage, lastUser),
+ chosen: {},
+ setUser: (userId) => {
+ if (userId === null || userId === get().userId) {
+ return;
+ }
+ writeLastUser(storage, userId);
+ set((state) => ({
+ userId,
+ oldVersion: state.chosen[userId] ?? readOldVersion(storage, userId),
+ }));
+ },
+ setOldVersion: (on) => {
+ const { userId } = get();
+ if (userId === null) {
+ return false;
+ }
+ writeOldVersion(storage, userId, on);
+ set((state) => ({
+ oldVersion: on,
+ chosen: { ...state.chosen, [userId]: on },
+ }));
+ return true;
+ },
+ });
+}
+
+/** A standalone store, for tests and isolated trees. */
+export function createOldVersionStore(
+ storage: PreferenceStorage = null,
+): StoreApi {
+ return createStore()(oldVersionState(storage));
+}
+
+/** Reading localStorage can throw in sandboxed documents; treat that as no storage. */
+function browserStorage(): Storage | null {
+ if (typeof window === "undefined") {
+ return null;
+ }
+ try {
+ return window.localStorage;
+ } catch {
+ return null;
+ }
+}
+
+export const useOldVersionStore: UseBoundStore> =
+ create()(oldVersionState(browserStorage()));
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
index fc4f72eb42..92da3927ce 100644
--- a/frontend/vite.config.js
+++ b/frontend/vite.config.js
@@ -139,6 +139,16 @@ export default defineConfig(({ mode }) => {
// Load env file based on `mode` in the current working directory.
const env = loadEnv(mode, process.cwd(), "");
+ // The v1 route registry drops pages the build's distribution lacks. Fail
+ // the build on a value it would not recognise; the list mirrors
+ // DISTRIBUTIONS in src/v1/app/registry/types.ts.
+ const distribution = env.VITE_DISTRIBUTION?.trim().toLowerCase();
+ if (distribution && !["oss", "cloud", "onprem"].includes(distribution)) {
+ throw new Error(
+ `VITE_DISTRIBUTION=${env.VITE_DISTRIBUTION} is not one of oss, cloud, onprem`,
+ );
+ }
+
return {
plugins: [
optionalPluginImports(),