fix(studio): guard storyboard history navigation

This commit is contained in:
James
2026-07-16 15:49:40 -04:00
parent ded443647d
commit 68beec0e76
2 changed files with 72 additions and 11 deletions
@@ -98,8 +98,16 @@ function clickButton(host: HTMLElement, label: string): void {
act(() => button.dispatchEvent(new MouseEvent("click", { bubbles: true })));
}
async function goBack(): Promise<void> {
await act(async () => {
window.history.back();
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
beforeEach(() => {
window.history.replaceState({}, "", "/?view=storyboard");
window.history.replaceState({ entry: "timeline" }, "", "/");
window.history.pushState({ entry: "storyboard" }, "", "/?view=storyboard");
onSelectComposition.mockReset();
});
@@ -140,4 +148,23 @@ describe("dirty storyboard voiceover view-mode guard", () => {
expect(onSelectComposition).toHaveBeenCalledWith("frames/01-opening.html");
act(() => root.unmount());
});
it("guards browser history transitions on decline and allows them on accept", async () => {
const confirm = vi.spyOn(window, "confirm").mockReturnValue(false);
const { host, root } = renderApp();
makeVoiceoverDirty(host);
await goBack();
expect(host.querySelector("[data-view-mode]")?.textContent).toBe("storyboard");
expect(window.location.search).toBe("?view=storyboard");
expect(window.history.state).toEqual({ entry: "storyboard" });
expect(confirm).toHaveBeenCalledWith("Discard unsaved voiceover changes?");
confirm.mockReturnValue(true);
await goBack();
expect(host.querySelector("[data-view-mode]")?.textContent).toBe("timeline");
expect(window.location.search).toBe("");
expect(window.history.state).toEqual({ entry: "timeline" });
act(() => root.unmount());
});
});
@@ -40,6 +40,19 @@ function writeViewModeToUrl(mode: StudioViewMode): void {
window.history.replaceState(window.history.state, "", url);
}
interface HistoryLocation {
href: string;
state: unknown;
}
function readHistoryLocation(): HistoryLocation | null {
if (typeof window === "undefined") return null;
return {
href: window.location.href,
state: window.history.state,
};
}
export interface ViewModeValue {
viewMode: StudioViewMode;
/** Returns false when an active editor vetoes the transition. */
@@ -54,6 +67,14 @@ export interface ViewModeValue {
export function useViewModeState(): ViewModeValue {
const [viewMode, setMode] = useState<StudioViewMode>(() => readViewModeFromUrl());
const guardsRef = useRef(new Set<ViewModeGuard>());
const acceptedLocationRef = useRef<HistoryLocation | null>(readHistoryLocation());
const canSetViewMode = useCallback((mode: StudioViewMode) => {
for (const guard of guardsRef.current) {
if (!guard(mode)) return false;
}
return true;
}, []);
// Reflect genuine browser back/forward between history entries with a different
// `?view=`. Note: our own writes use `replaceState` (below), which does NOT fire
@@ -62,19 +83,32 @@ export function useViewModeState(): ViewModeValue {
// the mount-time read); a scripted `pushState`/`replaceState` to `?view=` would not be
// reflected here, by design.
useEffect(() => {
const onPopState = () => setMode(readViewModeFromUrl());
const onPopState = () => {
const mode = readViewModeFromUrl();
if (mode !== viewMode && !canSetViewMode(mode)) {
const acceptedLocation = acceptedLocationRef.current;
if (acceptedLocation) {
window.history.pushState(acceptedLocation.state, "", acceptedLocation.href);
}
return;
}
setMode(mode);
acceptedLocationRef.current = readHistoryLocation();
};
window.addEventListener("popstate", onPopState);
return () => window.removeEventListener("popstate", onPopState);
}, []);
}, [canSetViewMode, viewMode]);
const setViewMode = useCallback((mode: StudioViewMode) => {
for (const guard of guardsRef.current) {
if (!guard(mode)) return false;
}
setMode(mode);
writeViewModeToUrl(mode);
return true;
}, []);
const setViewMode = useCallback(
(mode: StudioViewMode) => {
if (!canSetViewMode(mode)) return false;
setMode(mode);
writeViewModeToUrl(mode);
acceptedLocationRef.current = readHistoryLocation();
return true;
},
[canSetViewMode],
);
const registerViewModeGuard = useCallback((guard: ViewModeGuard) => {
guardsRef.current.add(guard);