Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions app/playground/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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) {
Expand Down Expand Up @@ -151,6 +154,7 @@ const PlaygroundPageContent = () => {
handleSave={handleSave}
handleSaveAll={handleSaveAll}
handleDownloadZip={handleDownloadZip}
confirmNavigation={confirmNavigation}
/>

<EditorArea handleDownloadZip={handleDownloadZip} />
Expand Down
9 changes: 8 additions & 1 deletion modules/playground/components/playground-header.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
Expand All @@ -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');
}}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
aria-label="Back to Dashboard"
>
<ArrowLeft className="h-4 w-4" />
Expand Down
67 changes: 67 additions & 0 deletions modules/playground/hooks/useUnsavedChangesWarning.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
80 changes: 80 additions & 0 deletions modules/playground/hooks/useUnsavedChangesWarning.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLAnchorElement>("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 };
}