diff --git a/packages/studio/src/components/storyboard/StoryboardViewModeGuard.test.tsx b/packages/studio/src/components/storyboard/StoryboardViewModeGuard.test.tsx index ed537d8f7..c773f086a 100644 --- a/packages/studio/src/components/storyboard/StoryboardViewModeGuard.test.tsx +++ b/packages/studio/src/components/storyboard/StoryboardViewModeGuard.test.tsx @@ -98,8 +98,16 @@ function clickButton(host: HTMLElement, label: string): void { act(() => button.dispatchEvent(new MouseEvent("click", { bubbles: true }))); } +async function goBack(): Promise { + 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()); + }); }); diff --git a/packages/studio/src/contexts/ViewModeContext.tsx b/packages/studio/src/contexts/ViewModeContext.tsx index 365367e37..9370b88a5 100644 --- a/packages/studio/src/contexts/ViewModeContext.tsx +++ b/packages/studio/src/contexts/ViewModeContext.tsx @@ -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(() => readViewModeFromUrl()); const guardsRef = useRef(new Set()); + const acceptedLocationRef = useRef(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);