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 ( + <> + + + {selectedProfileId}|{location.pathname}{location.search} + + ); +} + +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)} > + {location.pathname}{location.search} + {location.state?.kitImport?.profile_id ?? "none"} + + ); +} + +function renderImport() { + const router = createMemoryRouter([{ path: "*", element: }], { + initialEntries: ["/sources?profile=1"], + }); + render(); +} + +describe("useImportSharedBuild", () => { + afterEach(() => { + cleanup(); + sessionStorage.clear(); + Object.values(deps).forEach((mock) => mock.mockReset()); + }); + + it("waits for the current Build save before uploading or changing route", async () => { + let finishSave!: () => void; + deps.pick.mockResolvedValue(new File(["kit"], "shared.zip")); + deps.flush.mockReturnValue(new Promise((resolve) => { finishSave = resolve; })); + deps.upload.mockResolvedValue({ profile_id: 2, profile_name: "Imported", parts_imported: 1, layers_imported: 0 }); + deps.reload.mockResolvedValue(undefined); + renderImport(); + + fireEvent.click(screen.getByRole("button", { name: "Import Build" })); + await waitFor(() => expect(deps.flush).toHaveBeenCalledTimes(1)); + expect(deps.upload).not.toHaveBeenCalled(); + expect(screen.getByTestId("route").textContent).toBe("/sources?profile=1"); + + await act(async () => finishSave()); + await waitFor(() => expect(screen.getByTestId("route").textContent).toBe("/sources?profile=2")); + expect(screen.getByTestId("import-state").textContent).toBe("2"); + expect(deps.select).not.toHaveBeenCalled(); + expect(deps.upload.mock.invocationCallOrder[0]).toBeLessThan(deps.reload.mock.invocationCallOrder[0] ?? 0); + }); + + it("does not upload when the current Build save fails", async () => { + deps.pick.mockResolvedValue(new File(["kit"], "shared.zip")); + deps.flush.mockRejectedValue(new Error("offline")); + renderImport(); + + fireEvent.click(screen.getByRole("button", { name: "Import Build" })); + await waitFor(() => expect(deps.flush).toHaveBeenCalledTimes(1)); + expect(deps.upload).not.toHaveBeenCalled(); + expect(screen.getByTestId("route").textContent).toBe("/sources?profile=1"); + }); +}); diff --git a/web/apps/web/src/hooks/useImportSharedBuild.ts b/web/apps/web/src/hooks/useImportSharedBuild.ts index 3f1e2ce3..b4ded280 100644 --- a/web/apps/web/src/hooks/useImportSharedBuild.ts +++ b/web/apps/web/src/hooks/useImportSharedBuild.ts @@ -1,16 +1,19 @@ import { useCallback } from "react"; -import { useNavigate } from "react-router-dom"; +import { useLocation, useNavigate } from "react-router-dom"; import { toast } from "sonner"; import { pickKitBundle } from "../api/endpoints/browserFiles"; import { uploadKitBundle } from "../api/endpoints/imports"; import { useProfileSelection } from "../context/ProfileContext"; -import { buildRoute } from "../lib/routes"; +import { buildRoute, isPlanPath, isSourcesPath } from "../lib/routes"; import { stashKitImportResult } from "../lib/kitImportStash"; +import { useFlushBuildPageSaves } from "./useFlushBuildPageSaves"; /** Pick a .print-partner-kit.zip and import it as a new plan. */ export function useImportSharedBuild() { const navigate = useNavigate(); - const { setSelectedProfileId, reloadProfiles } = useProfileSelection(); + const location = useLocation(); + const flushSaves = useFlushBuildPageSaves(); + const { reloadProfiles } = useProfileSelection(); return useCallback(async () => { const picked = await pickKitBundle(); @@ -19,21 +22,28 @@ export function useImportSharedBuild() { return; } try { + if (isSourcesPath(location.pathname) || isPlanPath(location.pathname)) { + await flushSaves(); + } const result = await uploadKitBundle(picked); if (!result.profile_id) { toast.error("Import did not create a plan"); return; } stashKitImportResult(result); - setSelectedProfileId(result.profile_id); + try { + await reloadProfiles(); + } catch { + toast.error(`Imported “${result.profile_name}”, but could not load the new Build. Refresh to open it.`); + return; + } navigate(buildRoute(result.profile_id), { replace: true, state: { kitImport: result }, }); - void reloadProfiles(); toast.success(`Imported “${result.profile_name}”`); } catch (e) { toast.error(e instanceof Error ? e.message : String(e)); } - }, [navigate, reloadProfiles, setSelectedProfileId]); + }, [flushSaves, location.pathname, navigate, reloadProfiles]); } diff --git a/web/apps/web/src/hooks/useKitManifestAutosave.test.ts b/web/apps/web/src/hooks/useKitManifestAutosave.test.ts index 99f34e79..713e830c 100644 --- a/web/apps/web/src/hooks/useKitManifestAutosave.test.ts +++ b/web/apps/web/src/hooks/useKitManifestAutosave.test.ts @@ -83,6 +83,30 @@ describe("useKitManifestAutosave", () => { vi.clearAllMocks(); }); + it("keeps its registered flush available when the Plan refresh callback changes", () => { + const register = vi.fn(); + const unregister = vi.fn(); + const onSaved = vi.fn(); + const props = { onPersisted: vi.fn().mockResolvedValue(undefined) }; + const hook = renderHook(({ onPersisted }) => useKitManifestAutosave({ + profileId: 7, + pendingSelections: {}, + savedSelections: {}, + loaded: true, + userEdited: false, + disabled: false, + baseKit: kit({}), + onPersisted, + onSaved, + onRegisterFlush: register, + onUnregisterFlush: unregister, + }), { initialProps: props }); + expect(register).toHaveBeenCalledTimes(1); + hook.rerender({ onPersisted: vi.fn().mockResolvedValue(undefined) }); + expect(unregister).not.toHaveBeenCalled(); + expect(register).toHaveBeenCalledTimes(1); + }); + it("rejects a failed flush so navigation can keep the editor open", async () => { mocks.savePlanKitManifest.mockRejectedValue(new Error("offline")); const { result } = renderAutosave(); diff --git a/web/apps/web/src/hooks/useKitManifestAutosave.ts b/web/apps/web/src/hooks/useKitManifestAutosave.ts index fadaa691..7f34b684 100644 --- a/web/apps/web/src/hooks/useKitManifestAutosave.ts +++ b/web/apps/web/src/hooks/useKitManifestAutosave.ts @@ -206,6 +206,14 @@ export function useKitManifestAutosave({ } }, [saveSelections, saveState]); + const registeredFlushRef = useRef({ profileId, flush: flushSave }); + if (registeredFlushRef.current.profileId !== profileId) { + registeredFlushRef.current = { profileId, flush: flushSave }; + } else { + registeredFlushRef.current.flush = flushSave; + } + const registeredFlush = registeredFlushRef.current; + const saveUserEdit = useCallback( (selections: ManifestSelections) => { saveState.pendingSelections = selections; @@ -218,22 +226,23 @@ export function useKitManifestAutosave({ useEffect(() => { if (!onRegisterFlush) return; - onRegisterFlush(profileId, flushSave); + onRegisterFlush(profileId, () => registeredFlush.flush()); return () => onUnregisterFlush?.(profileId); - }, [flushSave, onRegisterFlush, onUnregisterFlush, profileId]); + }, [onRegisterFlush, onUnregisterFlush, profileId, registeredFlush]); useEffect(() => { + const flushForProfile = () => registeredFlush.flush(); const flushOnHidden = () => { if (document.visibilityState === "hidden") { - void flushSave().catch(() => {}); + void flushForProfile().catch(() => {}); } }; document.addEventListener("visibilitychange", flushOnHidden); return () => { document.removeEventListener("visibilitychange", flushOnHidden); - void flushSave().catch(() => {}); + void flushForProfile().catch(() => {}); }; - }, [flushSave]); + }, [registeredFlush]); useEffect(() => { return () => clearSavedTimer(); diff --git a/web/apps/web/src/layout/AppLayout.accessibility.test.tsx b/web/apps/web/src/layout/AppLayout.accessibility.test.tsx index 3e2e4537..637a0a17 100644 --- a/web/apps/web/src/layout/AppLayout.accessibility.test.tsx +++ b/web/apps/web/src/layout/AppLayout.accessibility.test.tsx @@ -3,8 +3,9 @@ import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { useState } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { Link, MemoryRouter, Route, Routes } from "react-router-dom"; +import { createMemoryRouter, Link, MemoryRouter, Route, RouterProvider, Routes } from "react-router-dom"; import AppLayout from "./AppLayout"; +import BuildSaveNavigationGuard from "../components/BuildSaveNavigationGuard"; import { useImportRulesAutosave } from "../hooks/useImportRulesAutosave"; vi.mock("../components/CommandPalette", () => ({ default: () => null })); @@ -311,4 +312,48 @@ describe("application shell accessibility", () => { await waitFor(() => expect(screen.getByRole("heading", { name: "Plan" })).toBeTruthy()); expect(saveRegistry.saveImportRules).toHaveBeenCalledTimes(3); }); + + it("uses one API write when the link handler and router blocker both flush", async () => { + let resolveSave!: (value: { rules: string[] }) => void; + saveRegistry.saveImportRules.mockReturnValue(new Promise((resolve) => { + resolveSave = resolve; + })); + function SourcesEditor() { + const [pendingRules, setPendingRules] = useState([]); + const [savedRules, setSavedRules] = useState([]); + const { saveUserEdit } = useImportRulesAutosave({ + sourceId: 5, + pendingRules, + savedRules, + rulesLoaded: true, + userEdited: true, + disabled: false, + onSaved: setSavedRules, + onRegisterFlush: (id, flush) => saveRegistry.registered.set(id, flush), + onUnregisterFlush: (id) => saveRegistry.registered.delete(id), + }); + return <>

Sources

Open Plan; + } + const router = createMemoryRouter([{ + path: "/", + element: <>, + children: [ + { path: "sources", element: }, + { path: "plan", element:

Plan

}, + ], + }], { initialEntries: ["/sources"] }); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Choose file" })); + expect(saveRegistry.saveImportRules).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByRole("link", { name: "Open Plan" })); + expect(router.state.location.pathname).toBe("/sources"); + resolveSave({ rules: ["latest.stl"] }); + await waitFor(() => expect(router.state.location.pathname).toBe("/plan")); + expect(saveRegistry.flush).toHaveBeenCalledTimes(2); + expect(saveRegistry.saveImportRules).toHaveBeenCalledTimes(1); + }); }); diff --git a/web/apps/web/src/main.tsx b/web/apps/web/src/main.tsx index 0f8382da..a7fbb122 100644 --- a/web/apps/web/src/main.tsx +++ b/web/apps/web/src/main.tsx @@ -1,7 +1,7 @@ import React from "react"; import ReactDOM from "react-dom/client"; import { QueryClientProvider } from "@tanstack/react-query"; -import { BrowserRouter } from "react-router-dom"; +import { createBrowserRouter, RouterProvider } from "react-router-dom"; import App from "./App"; import { ThemeProvider } from "./context/ThemeContext"; import { queryClient } from "./queries/queryClient"; @@ -10,13 +10,13 @@ import { registerServiceWorker } from "./lib/registerServiceWorker"; registerServiceWorker(); +const router = createBrowserRouter([{ path: "*", element: }]); + ReactDOM.createRoot(document.getElementById("root")!).render( - - - + ,