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 };
+}
+