Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
9034c42
test(web): reproduce autosave race and hidden save failures
poitee Sep 24, 2026
5fe9313
fix(web): serialize autosaves and hold navigation on save failure
poitee Sep 24, 2026
12531e8
test(web): reproduce unload without unsaved-change warning
poitee Sep 24, 2026
ea9e200
fix(web): warn before unloading unsaved source choices
poitee Sep 24, 2026
55941f2
test(web): expose source switch cleanup flushing wrong source
poitee Sep 24, 2026
72513aa
fix(web): flush the source being detached on source switch
poitee Sep 24, 2026
db35c94
fix(web): let managed navigation close the mobile drawer
poitee Sep 24, 2026
f322b66
fix(web): block navigation until source choices save
poitee Sep 24, 2026
2217c99
test(web): prove route guards share one autosave write
poitee Sep 24, 2026
85e9748
fix(web): preserve guarded Build switches and import ordering
poitee Sep 24, 2026
bc0a799
fix(web): keep save guards registered through editor rerenders
poitee Sep 24, 2026
1fe7775
fix(web): preserve Library Source drafts across tabs
poitee Sep 24, 2026
db3205c
fix(web): block navigation until source choices save
poitee Sep 24, 2026
ba81a6e
test(web): prove route guards share one autosave write
poitee Sep 24, 2026
b7d3496
fix(web): preserve guarded Build switches and import ordering
poitee Sep 24, 2026
da10fe9
fix(web): guard unsaved Library drafts during navigation
poitee Sep 24, 2026
c6889b9
fix(web): keep save guards registered through editor rerenders
poitee Sep 24, 2026
b54c2dd
test(web): provide Library draft state in manifest navigation test
poitee Sep 24, 2026
8787eb0
Merge pull request #92 from poitee/codex/library-navigation-guard
poitee Sep 26, 2026
3229c43
Merge pull request #87 from poitee/codex/gre-298-back-navigation
poitee Sep 26, 2026
c773a8c
Merge Sources navigation guard into Library draft stack
poitee Sep 26, 2026
816edfc
Merge pull request #91 from poitee/codex/library-rules-unsaved
poitee Sep 26, 2026
abf167b
Preserve imported Builds and in-flight rule edits
poitee Sep 26, 2026
bc492e1
Scope Library rule-save locks to each Source
poitee Sep 26, 2026
4cad2c0
Keep first imported Build selected during list refresh failure
poitee Sep 26, 2026
8ec5026
Merge latest main into Sources autosave stack
poitee Sep 26, 2026
8e69258
Run import scan after saved rules despite Source switches
poitee Sep 26, 2026
d717219
Wait for pending rule saves before reloading a revisited Source
poitee Sep 26, 2026
4ccf200
Select imported Build after destination route mounts
poitee Sep 26, 2026
298505f
Keep imported Build selection pending until it appears in list
poitee Sep 26, 2026
51b3b91
Honor known Build navigation after provisional import selection
poitee Sep 26, 2026
1b96637
Reconcile Build selection from cached list after reload errors
poitee Sep 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions web/apps/web/src/App.site-map.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Outlet />;
Expand Down
5 changes: 5 additions & 0 deletions web/apps/web/src/AuthenticatedApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ import { DateFormatProvider } from "./context/DateFormatContext";
import { ImportRulesSaveProvider } from "./context/ImportRulesSaveContext";
import { JobProvider } from "./context/JobContext";
import { KitManifestSaveProvider } from "./context/KitManifestSaveContext";
import { LibraryDraftProvider } from "./context/LibraryDraftContext";
import { PlanActionsProvider } from "./context/PlanActionsContext";
import { PlanWorkspaceProvider } from "./context/PlanWorkspaceContext";
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"));
Expand Down Expand Up @@ -53,6 +55,8 @@ export default function AuthenticatedApp() {
<SaveStatusProvider>
<ImportRulesSaveProvider>
<KitManifestSaveProvider>
<LibraryDraftProvider>
<BuildSaveNavigationGuard />
<Routes>
<Route element={<AppLayout />}>
<Route index element={<IndexRedirect />} />
Expand Down Expand Up @@ -106,6 +110,7 @@ export default function AuthenticatedApp() {
<Route path="*" element={<NotFoundPage />} />
</Route>
</Routes>
</LibraryDraftProvider>
</KitManifestSaveProvider>
</ImportRulesSaveProvider>
</SaveStatusProvider>
Expand Down
162 changes: 162 additions & 0 deletions web/apps/web/src/components/BuildSaveNavigationGuard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
// @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";
import { LibraryDraftProvider, useLibraryDraft } from "../context/LibraryDraftContext";

const saves = vi.hoisted(() => ({ flush: vi.fn<() => Promise<void>>() }));
vi.mock("../hooks/useFlushBuildPageSaves", () => ({
useFlushBuildPageSaves: () => saves.flush,
}));

function renderRoutes(initialEntries = ["/builds", "/sources"], initialIndex = 1) {
const router = createMemoryRouter(
[
{
path: "*",
element: (
<>
<BuildSaveNavigationGuard />
<LocationProbe />
</>
),
},
],
{ initialEntries, initialIndex },
);
render(<LibraryDraftProvider><RouterProvider router={router} /></LibraryDraftProvider>);
return router;
}

function LocationProbe() {
const location = useLocation();
const navigate = useNavigate();
const { setDirty } = useLibraryDraft();
return (
<>
<h1>{location.pathname}</h1>
<Link to="/builds">Builds link</Link>
<button onClick={() => navigate("/builds")}>Builds button</button>
<button onClick={() => setDirty(true)}>Edit Library draft</button>
<button onClick={() => navigate("/library?source=2&tab=rules")}>Switch Source</button>
</>
);
}

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<void>((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<void>((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);
});

it("keeps a dirty Library Source open when route and Source switches are cancelled", async () => {
const confirm = vi.spyOn(window, "confirm").mockReturnValueOnce(false).mockReturnValueOnce(false).mockReturnValueOnce(true);
try {
const router = renderRoutes(["/library?source=1&tab=rules"], 0);
fireEvent.click(screen.getByRole("button", { name: "Edit Library draft" }));
fireEvent.click(screen.getByRole("button", { name: "Builds button" }));
await waitFor(() => expect(confirm).toHaveBeenCalledTimes(1));
expect(router.state.location.pathname).toBe("/library");

fireEvent.click(screen.getByRole("button", { name: "Switch Source" }));
await waitFor(() => expect(confirm).toHaveBeenCalledTimes(2));
expect(router.state.location.search).toBe("?source=1&tab=rules");

fireEvent.click(screen.getByRole("button", { name: "Switch Source" }));
await waitFor(() => expect(router.state.location.search).toBe("?source=2&tab=rules"));
expect(confirm).toHaveBeenCalledTimes(3);
expect(saves.flush).not.toHaveBeenCalled();
} finally {
confirm.mockRestore();
}
});
});
53 changes: 53 additions & 0 deletions web/apps/web/src/components/BuildSaveNavigationGuard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { useEffect, useRef } from "react";
import { useBlocker, useLocation } from "react-router-dom";
import { toast } from "sonner";
import { confirmDiscardSourceChanges, useLibraryDraft } from "../context/LibraryDraftContext";
import { useFlushBuildPageSaves } from "../hooks/useFlushBuildPageSaves";
import { isLibraryPath, isPlanPath, isSourcesPath } from "../lib/routes";

export default function BuildSaveNavigationGuard() {
const flushSaves = useFlushBuildPageSaves();
const { hasDirtyDraft } = useLibraryDraft();
const location = useLocation();
const handlingRef = useRef(false);
const blocker = useBlocker(
({ currentLocation, nextLocation }) => {
if (isLibraryPath(currentLocation.pathname)) {
if (!hasDirtyDraft()) return false;
if (currentLocation.pathname !== nextLocation.pathname) return true;
const currentSource = new URLSearchParams(currentLocation.search).get("source");
const nextSource = new URLSearchParams(nextLocation.search).get("source");
return currentSource !== nextSource;
}
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;
if (isLibraryPath(location.pathname) && hasDirtyDraft()) {
if (confirmDiscardSourceChanges()) blocker.proceed();
else blocker.reset();
handlingRef.current = false;
return;
}
void flushSaves()
.then(() => blocker.proceed())
.catch(() => {
blocker.reset();
toast.error("Save failed. Retry before leaving Sources or Plan.");
})
.finally(() => {
handlingRef.current = false;
});
}, [blocker, flushSaves, hasDirtyDraft, location.pathname]);

return null;
}
2 changes: 1 addition & 1 deletion web/apps/web/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ export default function CommandPalette(_props?: Props) {

const actions: Action[] = useMemo(() => {
const leaveBuildThen = (go: () => void) => {
if (onBuild) void flushBuildSaves().then(go);
if (onBuild) void flushBuildSaves().then(go).catch(() => toast.error("Save failed. Retry before leaving Sources."));
else go();
};

Expand Down
83 changes: 83 additions & 0 deletions web/apps/web/src/components/KitManifestOptions.navigation.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// @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 { LibraryDraftProvider } from "../context/LibraryDraftContext";
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<typeof import("../api/endpoints/planManifests")>();
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" ? <KitManifestOptions profileId={2} /> : <h1>Sources</h1>;
}
const router = createMemoryRouter([{
path: "*",
element: (
<ImportRulesSaveProvider>
<KitManifestSaveProvider>
<BuildSaveNavigationGuard />
<Page />
</KitManifestSaveProvider>
</ImportRulesSaveProvider>
),
}], { initialEntries: ["/sources?profile=2", "/plan?profile=2"], initialIndex: 1 });
render(<LibraryDraftProvider><RouterProvider router={router} /></LibraryDraftProvider>);

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");
});

});
Loading
Loading