From 00927bc4f55c05830afe6db92309248f724df8b0 Mon Sep 17 00:00:00 2001 From: Shubham Kumar Date: Wed, 3 Jun 2026 12:05:22 +0530 Subject: [PATCH] Warn before leaving with unsaved playground changes --- app/playground/[id]/page.tsx | 4 + .../components/playground-header.tsx | 9 ++- .../hooks/useUnsavedChangesWarning.test.ts | 67 ++++++++++++++++ .../hooks/useUnsavedChangesWarning.ts | 80 +++++++++++++++++++ 4 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 modules/playground/hooks/useUnsavedChangesWarning.test.ts create mode 100644 modules/playground/hooks/useUnsavedChangesWarning.ts diff --git a/app/playground/[id]/page.tsx b/app/playground/[id]/page.tsx index 09be742d..614cfe8d 100644 --- a/app/playground/[id]/page.tsx +++ b/app/playground/[id]/page.tsx @@ -16,6 +16,7 @@ import { useWebContainer } from "@/modules/webcontainers/hooks/useWebContainer"; import { useFileExplorer } from "@/modules/playground/hooks/useFileExplorer"; import { usePlaygroundActions } from "@/modules/playground/hooks/usePlaygroundActions"; import { usePlaygroundUI } from "@/modules/playground/hooks/usePlaygroundUI"; +import { useUnsavedChangesWarning } from "@/modules/playground/hooks/useUnsavedChangesWarning"; // New modular UI components import { PlaygroundSidebar } from "@/modules/playground/components/playground-sidebar"; @@ -42,6 +43,8 @@ const PlaygroundPageContent = () => { const setIsPreviewVisible = usePlaygroundUI((s) => s.setIsPreviewVisible); const setIsCommandPaletteOpen = usePlaygroundUI((s) => s.setIsCommandPaletteOpen); const resetUI = usePlaygroundUI((s) => s.resetUI); + const hasUnsavedChanges = openFiles.some((file) => file.hasUnsavedChanges); + const { confirmNavigation } = useUnsavedChangesWarning(hasUnsavedChanges); useEffect(() => { if (isSuccess && templateData) { @@ -151,6 +154,7 @@ const PlaygroundPageContent = () => { handleSave={handleSave} handleSaveAll={handleSaveAll} handleDownloadZip={handleDownloadZip} + confirmNavigation={confirmNavigation} /> diff --git a/modules/playground/components/playground-header.tsx b/modules/playground/components/playground-header.tsx index 8151dc25..63b50537 100644 --- a/modules/playground/components/playground-header.tsx +++ b/modules/playground/components/playground-header.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { useRouter } from "next/navigation"; import { Button } from "@/components/ui/button"; import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuShortcut } from "@/components/ui/dropdown-menu"; @@ -29,13 +30,16 @@ interface PlaygroundHeaderProps { handleSave: () => void; handleSaveAll: () => void; handleDownloadZip: () => void; + confirmNavigation?: () => boolean; } export const PlaygroundHeader = ({ handleSave, handleSaveAll, handleDownloadZip, + confirmNavigation, }: PlaygroundHeaderProps) => { + const router = useRouter(); const { id, playgroundData } = usePlaygroundContext(); const { isPreviewVisible, @@ -61,7 +65,10 @@ export const PlaygroundHeader = ({ size="icon" variant="ghost" className="h-7 w-7 text-muted-foreground hover:text-foreground" - onClick={() => window.location.href = '/dashboard'} + onClick={() => { + if (confirmNavigation?.() === false) return; + router.push('/dashboard'); + }} aria-label="Back to Dashboard" > diff --git a/modules/playground/hooks/useUnsavedChangesWarning.test.ts b/modules/playground/hooks/useUnsavedChangesWarning.test.ts new file mode 100644 index 00000000..52c8a8f6 --- /dev/null +++ b/modules/playground/hooks/useUnsavedChangesWarning.test.ts @@ -0,0 +1,67 @@ +/** + * @vitest-environment jsdom + */ + +import { renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useUnsavedChangesWarning } from "./useUnsavedChangesWarning"; + +describe("useUnsavedChangesWarning", () => { + afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ""; + }); + + it("does not block unload when there are no unsaved changes", () => { + renderHook(() => useUnsavedChangesWarning(false)); + + const event = new Event("beforeunload", { cancelable: true }); + + expect(window.dispatchEvent(event)).toBe(true); + expect(event.defaultPrevented).toBe(false); + }); + + it("blocks unload when there are unsaved changes", () => { + renderHook(() => useUnsavedChangesWarning(true)); + + const event = new Event("beforeunload", { cancelable: true }); + + expect(window.dispatchEvent(event)).toBe(false); + expect(event.defaultPrevented).toBe(true); + }); + + it("prevents guarded navigation when the user cancels", () => { + const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(false); + renderHook(() => useUnsavedChangesWarning(true)); + + const link = document.createElement("a"); + link.href = "/dashboard"; + document.body.appendChild(link); + + const event = new MouseEvent("click", { + bubbles: true, + cancelable: true, + button: 0, + }); + + expect(link.dispatchEvent(event)).toBe(false); + expect(event.defaultPrevented).toBe(true); + expect(confirmSpy).toHaveBeenCalledTimes(1); + }); + + it("returns a confirmNavigation helper for imperative exits", () => { + const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(false); + const { result } = renderHook(() => useUnsavedChangesWarning(true)); + + expect(result.current.confirmNavigation()).toBe(false); + expect(confirmSpy).toHaveBeenCalledTimes(1); + }); + + it("allows imperative exits when the user confirms", () => { + const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true); + const { result } = renderHook(() => useUnsavedChangesWarning(true)); + + expect(result.current.confirmNavigation()).toBe(true); + expect(confirmSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/modules/playground/hooks/useUnsavedChangesWarning.ts b/modules/playground/hooks/useUnsavedChangesWarning.ts new file mode 100644 index 00000000..8bdd5d24 --- /dev/null +++ b/modules/playground/hooks/useUnsavedChangesWarning.ts @@ -0,0 +1,80 @@ +"use client"; + +import { useCallback, useEffect } from "react"; + +const UNSAVED_CHANGES_MESSAGE = + "You have unsaved changes. Are you sure you want to leave the playground?"; + +export function useUnsavedChangesWarning(hasUnsavedChanges: boolean) { + const confirmNavigation = useCallback(() => { + if (!hasUnsavedChanges) return true; + return window.confirm(UNSAVED_CHANGES_MESSAGE); + }, [hasUnsavedChanges]); + + useEffect(() => { + if (!hasUnsavedChanges) return; + + const handleBeforeUnload = (event: BeforeUnloadEvent) => { + event.preventDefault(); + event.returnValue = UNSAVED_CHANGES_MESSAGE; + return UNSAVED_CHANGES_MESSAGE; + }; + + window.addEventListener("beforeunload", handleBeforeUnload); + return () => window.removeEventListener("beforeunload", handleBeforeUnload); + }, [hasUnsavedChanges]); + + useEffect(() => { + if (!hasUnsavedChanges) return; + + const handleDocumentClick = (event: MouseEvent) => { + if ( + event.defaultPrevented || + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey + ) { + return; + } + + const target = event.target instanceof Element ? event.target : null; + const anchor = target?.closest("a[href]"); + + if ( + !anchor || + anchor.target === "_blank" || + anchor.hasAttribute("download") + ) { + return; + } + + const rawHref = anchor.getAttribute("href"); + if ( + !rawHref || + rawHref.startsWith("#") || + rawHref.startsWith("mailto:") || + rawHref.startsWith("tel:") + ) { + return; + } + + const nextUrl = new URL(anchor.href, window.location.href); + const currentUrl = new URL(window.location.href); + + if (nextUrl.href === currentUrl.href || confirmNavigation()) { + return; + } + + event.preventDefault(); + event.stopImmediatePropagation(); + }; + + document.addEventListener("click", handleDocumentClick, true); + return () => document.removeEventListener("click", handleDocumentClick, true); + }, [confirmNavigation, hasUnsavedChanges]); + + return { confirmNavigation }; +} +