diff --git a/web/apps/web/src/App.site-map.test.tsx b/web/apps/web/src/App.site-map.test.tsx
index 778576fa..3bb4aa5b 100644
--- a/web/apps/web/src/App.site-map.test.tsx
+++ b/web/apps/web/src/App.site-map.test.tsx
@@ -42,6 +42,7 @@ vi.mock("./context/KitManifestSaveContext", () => ({
vi.mock("./context/SaveStatusContext", () => ({
SaveStatusProvider: ({ children }: { children: ReactNode }) => children,
}));
+vi.mock("./components/BuildSaveNavigationGuard", () => ({ default: () => null }));
vi.mock("./components/AuthGate", () => ({
default: function AuthGate() {
return ;
diff --git a/web/apps/web/src/AuthenticatedApp.tsx b/web/apps/web/src/AuthenticatedApp.tsx
index 548c3d1f..04fa824a 100644
--- a/web/apps/web/src/AuthenticatedApp.tsx
+++ b/web/apps/web/src/AuthenticatedApp.tsx
@@ -10,6 +10,7 @@ import { ProfileProvider } from "./context/ProfileContext";
import { SaveStatusProvider } from "./context/SaveStatusContext";
import { StlAutoSyncProvider } from "./context/StlAutoSyncContext";
import AppLayout from "./layout/AppLayout";
+import BuildSaveNavigationGuard from "./components/BuildSaveNavigationGuard";
import { buildSourcesRoute } from "./lib/routes";
const BuildPage = lazy(() => import("./pages/BuildPage"));
@@ -53,6 +54,7 @@ export default function AuthenticatedApp() {
+
}>
} />
diff --git a/web/apps/web/src/components/BuildSaveNavigationGuard.test.tsx b/web/apps/web/src/components/BuildSaveNavigationGuard.test.tsx
new file mode 100644
index 00000000..eac92401
--- /dev/null
+++ b/web/apps/web/src/components/BuildSaveNavigationGuard.test.tsx
@@ -0,0 +1,136 @@
+// @vitest-environment jsdom
+
+import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { createMemoryRouter, Link, RouterProvider, useLocation, useNavigate } from "react-router-dom";
+import BuildSaveNavigationGuard from "./BuildSaveNavigationGuard";
+
+const saves = vi.hoisted(() => ({ flush: vi.fn<() => Promise>() }));
+vi.mock("../hooks/useFlushBuildPageSaves", () => ({
+ useFlushBuildPageSaves: () => saves.flush,
+}));
+
+function renderRoutes(initialEntries = ["/builds", "/sources"], initialIndex = 1) {
+ const router = createMemoryRouter(
+ [
+ {
+ path: "*",
+ element: (
+ <>
+
+
+ >
+ ),
+ },
+ ],
+ { initialEntries, initialIndex },
+ );
+ render();
+ return router;
+}
+
+function LocationProbe() {
+ const location = useLocation();
+ const navigate = useNavigate();
+ return (
+ <>
+ {location.pathname}
+ Builds link
+
+ >
+ );
+}
+
+describe("BuildSaveNavigationGuard", () => {
+ afterEach(() => {
+ cleanup();
+ saves.flush.mockReset();
+ });
+
+ it("holds browser Back until a pending save finishes", async () => {
+ let resolveSave!: () => void;
+ saves.flush.mockReturnValue(new Promise((resolve) => {
+ resolveSave = resolve;
+ }));
+ const router = renderRoutes();
+ act(() => {
+ void router.navigate(-1);
+ });
+ expect(router.state.location.pathname).toBe("/sources");
+ expect(saves.flush).toHaveBeenCalledTimes(1);
+ resolveSave();
+ await waitFor(() => expect(router.state.location.pathname).toBe("/builds"));
+ });
+
+ it("keeps Sources open when a save fails and allows a later retry", async () => {
+ saves.flush.mockRejectedValueOnce(new Error("offline")).mockResolvedValue(undefined);
+ const router = renderRoutes();
+ act(() => {
+ void router.navigate(-1);
+ });
+ await waitFor(() => expect(saves.flush).toHaveBeenCalledTimes(1));
+ await waitFor(() => expect([...router.state.blockers.values()][0]?.state).toBe("unblocked"));
+ expect(router.state.location.pathname).toBe("/sources");
+ act(() => {
+ void router.navigate(-1);
+ });
+ await waitFor(() => expect(router.state.location.pathname).toBe("/builds"));
+ });
+
+ it("holds Forward, links, and programmatic navigation", async () => {
+ for (const navigation of ["forward", "link", "button"] as const) {
+ saves.flush.mockReset();
+ let resolveSave!: () => void;
+ saves.flush.mockReturnValue(new Promise((resolve) => {
+ resolveSave = resolve;
+ }));
+ const router = navigation === "forward"
+ ? renderRoutes(["/sources", "/builds"], 0)
+ : renderRoutes(["/sources"], 0);
+ if (navigation === "forward") {
+ act(() => {
+ void router.navigate(1);
+ });
+ } else {
+ fireEvent.click(screen.getByRole(navigation === "link" ? "link" : "button", {
+ name: navigation === "link" ? "Builds link" : "Builds button",
+ }));
+ }
+ expect(router.state.location.pathname).toBe("/sources");
+ expect(saves.flush).toHaveBeenCalledTimes(1);
+ resolveSave();
+ await waitFor(() => expect(router.state.location.pathname).toBe("/builds"));
+ cleanup();
+ }
+ });
+
+ it("flushes Plan choices before leaving but ignores unrelated query changes", async () => {
+ saves.flush.mockResolvedValue(undefined);
+ const router = renderRoutes(["/plan?profile=1"], 0);
+ act(() => {
+ void router.navigate("/plan?profile=1&tab=parts");
+ });
+ await waitFor(() => expect(router.state.location.search).toBe("?profile=1&tab=parts"));
+ expect(saves.flush).not.toHaveBeenCalled();
+ act(() => {
+ void router.navigate("/builds");
+ });
+ await waitFor(() => expect(router.state.location.pathname).toBe("/builds"));
+ expect(saves.flush).toHaveBeenCalledTimes(1);
+ });
+
+ it("keeps the original Build on a failed same-route profile switch", async () => {
+ saves.flush.mockRejectedValueOnce(new Error("offline")).mockResolvedValue(undefined);
+ const router = renderRoutes(["/sources?profile=1"], 0);
+ act(() => {
+ void router.navigate("/sources?profile=2");
+ });
+ await waitFor(() => expect([...router.state.blockers.values()][0]?.state).toBe("unblocked"));
+ expect(router.state.location.search).toBe("?profile=1");
+ act(() => {
+ void router.navigate("/sources?profile=2");
+ });
+ await waitFor(() => expect(router.state.location.search).toBe("?profile=2"));
+ expect(saves.flush).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/web/apps/web/src/components/BuildSaveNavigationGuard.tsx b/web/apps/web/src/components/BuildSaveNavigationGuard.tsx
new file mode 100644
index 00000000..f27fb0f6
--- /dev/null
+++ b/web/apps/web/src/components/BuildSaveNavigationGuard.tsx
@@ -0,0 +1,37 @@
+import { useEffect, useRef } from "react";
+import { useBlocker } from "react-router-dom";
+import { toast } from "sonner";
+import { useFlushBuildPageSaves } from "../hooks/useFlushBuildPageSaves";
+import { isPlanPath, isSourcesPath } from "../lib/routes";
+
+export default function BuildSaveNavigationGuard() {
+ const flushSaves = useFlushBuildPageSaves();
+ const handlingRef = useRef(false);
+ const blocker = useBlocker(
+ ({ currentLocation, nextLocation }) => {
+ if (!isSourcesPath(currentLocation.pathname) && !isPlanPath(currentLocation.pathname)) {
+ return false;
+ }
+ if (currentLocation.pathname !== nextLocation.pathname) return true;
+ const currentProfile = new URLSearchParams(currentLocation.search).get("profile");
+ const nextProfile = new URLSearchParams(nextLocation.search).get("profile");
+ return currentProfile !== nextProfile;
+ },
+ );
+
+ useEffect(() => {
+ if (blocker.state !== "blocked" || handlingRef.current) return;
+ handlingRef.current = true;
+ void flushSaves()
+ .then(() => blocker.proceed())
+ .catch(() => {
+ blocker.reset();
+ toast.error("Save failed. Retry before leaving Sources or Plan.");
+ })
+ .finally(() => {
+ handlingRef.current = false;
+ });
+ }, [blocker, flushSaves]);
+
+ return null;
+}
diff --git a/web/apps/web/src/components/KitManifestOptions.navigation.test.tsx b/web/apps/web/src/components/KitManifestOptions.navigation.test.tsx
new file mode 100644
index 00000000..14cf5b90
--- /dev/null
+++ b/web/apps/web/src/components/KitManifestOptions.navigation.test.tsx
@@ -0,0 +1,82 @@
+// @vitest-environment jsdom
+
+import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { createMemoryRouter, RouterProvider, useLocation } from "react-router-dom";
+import BuildSaveNavigationGuard from "./BuildSaveNavigationGuard";
+import KitManifestOptions from "./KitManifestOptions";
+import { ImportRulesSaveProvider } from "../context/ImportRulesSaveContext";
+import { KitManifestSaveProvider } from "../context/KitManifestSaveContext";
+
+const mocks = vi.hoisted(() => ({ save: vi.fn() }));
+
+vi.mock("../api/endpoints/planManifests", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ fetchPlanManifestBuilder: vi.fn().mockResolvedValue({
+ merged_option_groups: {
+ variants: {
+ rule: "pick_one",
+ label: "Variants",
+ parts: [],
+ variants: [
+ { id: "stock", label: "Stock", parts: [] },
+ { id: "custom", label: "Custom", parts: [] },
+ ],
+ },
+ },
+ resolved_selections: { variants: "stock" },
+ }),
+ fetchPlanKitManifest: vi.fn().mockResolvedValue({
+ name: null,
+ layers: [],
+ base_source_id: null,
+ addon_source_ids: [],
+ selections: {},
+ include: [],
+ exclude: [],
+ replacements: {},
+ choice_tree: [],
+ category_links: [],
+ }),
+ savePlanKitManifest: mocks.save,
+ };
+});
+
+describe("KitManifestOptions guarded navigation", () => {
+ afterEach(() => {
+ cleanup();
+ mocks.save.mockReset();
+ });
+
+ it("keeps a failed variant edit on Plan when browser Back also fails to save", async () => {
+ mocks.save.mockRejectedValue(new Error("offline"));
+ function Page() {
+ const location = useLocation();
+ return location.pathname === "/plan" ? : Sources
;
+ }
+ const router = createMemoryRouter([{
+ path: "*",
+ element: (
+
+
+
+
+
+
+ ),
+ }], { initialEntries: ["/sources?profile=2", "/plan?profile=2"], initialIndex: 1 });
+ render();
+
+ fireEvent.click(await screen.findByRole("button", { name: "Custom" }));
+ await screen.findByText("Save failed — retry");
+ expect(mocks.save).toHaveBeenCalledTimes(1);
+ act(() => { void router.navigate(-1); });
+ await waitFor(() => expect(mocks.save).toHaveBeenCalledTimes(2));
+ await waitFor(() => expect([...router.state.blockers.values()][0]?.state).toBe("unblocked"));
+ expect(router.state.location.pathname).toBe("/plan");
+ expect(screen.getByRole("button", { name: "Custom" }).getAttribute("aria-pressed")).toBe("true");
+ });
+
+});
diff --git a/web/apps/web/src/components/PlanPicker.navigation.test.tsx b/web/apps/web/src/components/PlanPicker.navigation.test.tsx
new file mode 100644
index 00000000..1a6b06f7
--- /dev/null
+++ b/web/apps/web/src/components/PlanPicker.navigation.test.tsx
@@ -0,0 +1,102 @@
+// @vitest-environment jsdom
+
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
+import type { ProfileSummary } from "@print-partner/contracts";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { createMemoryRouter, RouterProvider, useLocation } from "react-router-dom";
+import { PlanActionsProvider } from "../context/PlanActionsContext";
+import { ProfileProvider, useProfileSelection } from "../context/ProfileContext";
+import { useProfileUrlSync } from "../hooks/useProfileUrlSync";
+import BuildSaveNavigationGuard from "./BuildSaveNavigationGuard";
+import PlanPicker from "./PlanPicker";
+
+const saves = vi.hoisted(() => ({ flush: vi.fn<() => Promise>() }));
+const profiles: ProfileSummary[] = [1, 2].map((id) => ({
+ id,
+ name: `Build ${id}`,
+ order_number: null,
+ special_request: null,
+ part_count: 0,
+ accepted_progress: { kind: "empty" },
+ build_stale: false,
+ freshness: {
+ status: "untracked",
+ accepted_input_set_id: null,
+ accepted_at: null,
+ reasons: [{ kind: "no_accepted_inputs" }],
+ },
+ archived_at: null,
+ last_used_at: null,
+}));
+
+vi.mock("../context/AuthContext", () => ({
+ useAuth: () => ({ user: null, multiUser: false, loading: false }),
+}));
+vi.mock("../hooks/useEngineHealth", () => ({
+ useEngineHealth: () => ({ health: { ok: true } }),
+}));
+vi.mock("../hooks/useFlushBuildPageSaves", () => ({
+ useFlushBuildPageSaves: () => saves.flush,
+}));
+vi.mock("../queries/profiles", () => {
+ const mutation = () => ({ isPending: false, mutate: vi.fn(), mutateAsync: vi.fn() });
+ return {
+ useProfilesQuery: () => ({ data: profiles, isLoading: false, isSuccess: true, error: null, refetch: vi.fn() }),
+ useCreateProfileMutation: mutation,
+ useUpdateProfileMutation: mutation,
+ useDeleteProfileMutation: mutation,
+ useDuplicateProfileMutation: mutation,
+ useArchiveProfileMutation: mutation,
+ useTouchProfileLastUsedMutation: mutation,
+ };
+});
+
+function Probe() {
+ useProfileUrlSync();
+ const location = useLocation();
+ const { selectedProfileId } = useProfileSelection();
+ return (
+ <>
+
+
+
+ >
+ );
+}
+
+describe("PlanPicker guarded navigation", () => {
+ beforeEach(() => {
+ sessionStorage.clear();
+ vi.stubGlobal("ResizeObserver", class {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ });
+ Element.prototype.scrollIntoView = vi.fn();
+ });
+ afterEach(() => {
+ cleanup();
+ saves.flush.mockReset();
+ vi.unstubAllGlobals();
+ });
+
+ it("keeps the selected Build and URL together when the save fails, then switches on retry", async () => {
+ saves.flush.mockRejectedValueOnce(new Error("offline")).mockResolvedValue(undefined);
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ const router = createMemoryRouter([
+ { path: "*", element: },
+ ], { initialEntries: ["/plan?profile=1"] });
+ render();
+ await waitFor(() => expect(screen.getByTestId("route").textContent).toBe("1|/plan?profile=1"));
+
+ fireEvent.click(screen.getByRole("combobox", { name: "Select Build" }));
+ fireEvent.click(screen.getByText("Build 2"));
+ await waitFor(() => expect(saves.flush).toHaveBeenCalledTimes(1));
+ await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); });
+ expect(screen.getByTestId("route").textContent).toBe("1|/plan?profile=1");
+
+ fireEvent.click(screen.getByText("Build 2"));
+ await waitFor(() => expect(screen.getByTestId("route").textContent).toBe("2|/plan?profile=2"));
+ });
+});
diff --git a/web/apps/web/src/components/PlanPicker.tsx b/web/apps/web/src/components/PlanPicker.tsx
index 6cec66f2..ae9e40f0 100644
--- a/web/apps/web/src/components/PlanPicker.tsx
+++ b/web/apps/web/src/components/PlanPicker.tsx
@@ -2,7 +2,9 @@ import { useEffect, useMemo, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { Check, ChevronsUpDown, Layers, Plus } from "lucide-react";
import { toast } from "sonner";
-import { buildRoute, isPlansPath } from "../lib/routes";
+import { buildRoute, isPlanPath, isPlansPath, isSourcesPath } from "../lib/routes";
+import { useFlushBuildPageSaves } from "../hooks/useFlushBuildPageSaves";
+import { shouldSyncProfileToPath } from "../hooks/profileUrlSync";
import {
duplicatePlanName,
partitionPlanPickerGroups,
@@ -56,6 +58,7 @@ export default function PlanPicker({
}: Props) {
const navigate = useNavigate();
const location = useLocation();
+ const flushSaves = useFlushBuildPageSaves();
const { profiles, selectedProfileId, setSelectedProfileId, loading } =
useProfileSelection();
const createMutation = useCreateProfileMutation();
@@ -164,14 +167,40 @@ export default function PlanPicker({
setActionTargetId(null);
};
- const activatePlan = (id: number) => {
- setSelectedProfileId(id);
- touchMutation.mutate(id);
+ const selectAfterSaving = async (id: number): Promise => {
+ if (
+ id !== selectedProfileId &&
+ (isSourcesPath(location.pathname) || isPlanPath(location.pathname))
+ ) {
+ try {
+ await flushSaves();
+ } catch {
+ toast.error("Save failed. Retry before switching Builds.");
+ return false;
+ }
+ }
+ return true;
+ };
+
+ const activatePlan = async (id: number): Promise => {
+ if (!(await selectAfterSaving(id))) return false;
navigate(buildRoute(id), { replace: true });
+ touchMutation.mutate(id);
+ return true;
};
- const selectPlan = (id: number) => {
- setSelectedProfileId(id);
+ const selectPlan = async (id: number) => {
+ if (!(await selectAfterSaving(id))) return;
+ if (shouldSyncProfileToPath(location.pathname)) {
+ const params = new URLSearchParams(location.search);
+ params.set("profile", String(id));
+ navigate(
+ { pathname: location.pathname, search: `?${params}`, hash: location.hash },
+ { replace: true },
+ );
+ } else {
+ setSelectedProfileId(id);
+ }
touchMutation.mutate(id);
setOpen(false);
setSearch("");
@@ -188,8 +217,9 @@ export default function PlanPicker({
setSwitchPrompt({ targetId, targetName });
return;
}
- activatePlan(targetId);
- toast.success(`Created Build “${targetName}”`);
+ void activatePlan(targetId).then((activated) => {
+ if (activated) toast.success(`Created Build “${targetName}”`);
+ });
};
const onCreate = async () => {
@@ -251,7 +281,7 @@ export default function PlanPicker({
setSelectedProfileId(copy.id);
touchMutation.mutate(copy.id);
} else {
- activatePlan(copy.id);
+ void activatePlan(copy.id);
}
}
toast.success(`Duplicated Build “${name}”`);
@@ -300,7 +330,7 @@ export default function PlanPicker({
selectPlan(p.id)}
+ onSelect={() => void selectPlan(p.id)}
>