feat(FN-2172): add embedded dev server preview panel

- Add usePreviewEmbed hook to derive preview embed URLs and availability state
- Add PreviewIframe component with iframe load/error wiring and deduplicated error callbacks
- Integrate preview panel actions, fallback messaging, and embedded iframe rendering in DevServerView
- Expand test coverage for usePreviewEmbed, PreviewIframe, and DevServerView preview integration scenarios
This commit is contained in:
Fusion
2026-04-20 19:48:32 -07:00
committed by gsxdsm
parent 64f5c0862b
commit b0defea550
8 changed files with 918 additions and 152 deletions

View File

@@ -0,0 +1,142 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { usePreviewEmbed } from "../usePreviewEmbed";
async function flushMicrotasks(): Promise<void> {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe("usePreviewEmbed", () => {
it("initial status is unknown when URL is null", () => {
const { result } = renderHook(() => usePreviewEmbed(null));
expect(result.current.embedStatus).toBe("unknown");
expect(result.current.isEmbedded).toBe(false);
expect(result.current.isBlocked).toBe(false);
});
it("status transitions to loading when URL is set", async () => {
const { result } = renderHook(() => usePreviewEmbed("http://localhost:3000"));
await waitFor(() => {
expect(result.current.embedStatus).toBe("loading");
});
});
it("resets to loading when URL changes", async () => {
const { result, rerender } = renderHook(
({ url }) => usePreviewEmbed(url),
{ initialProps: { url: "http://localhost:3000" as string | null } },
);
await flushMicrotasks();
act(() => {
result.current.handleIframeLoad();
});
expect(result.current.embedStatus).toBe("embedded");
rerender({ url: "http://localhost:4000" });
await waitFor(() => {
expect(result.current.embedStatus).toBe("loading");
});
});
it("handleIframeLoad sets status to embedded", async () => {
const { result } = renderHook(() => usePreviewEmbed("http://localhost:3000"));
await flushMicrotasks();
act(() => {
result.current.handleIframeLoad();
});
expect(result.current.embedStatus).toBe("embedded");
});
it("handleIframeError sets status to error", async () => {
const { result } = renderHook(() => usePreviewEmbed("http://localhost:3000"));
await flushMicrotasks();
act(() => {
result.current.handleIframeError();
});
expect(result.current.embedStatus).toBe("error");
});
it("resetEmbed sets status to unknown", async () => {
const { result } = renderHook(() => usePreviewEmbed("http://localhost:3000"));
await flushMicrotasks();
act(() => {
result.current.handleIframeLoad();
});
expect(result.current.embedStatus).toBe("embedded");
act(() => {
result.current.resetEmbed();
});
expect(result.current.embedStatus).toBe("unknown");
});
it("isEmbedded is true only when embedded", async () => {
const { result } = renderHook(() => usePreviewEmbed("http://localhost:3000"));
await flushMicrotasks();
expect(result.current.isEmbedded).toBe(false);
act(() => {
result.current.handleIframeLoad();
});
expect(result.current.isEmbedded).toBe(true);
act(() => {
result.current.handleIframeError();
});
expect(result.current.isEmbedded).toBe(false);
});
it("isBlocked is true for blocked and error states", async () => {
const { result } = renderHook(() => usePreviewEmbed("http://localhost:3000"));
await flushMicrotasks();
expect(result.current.isBlocked).toBe(false);
const mutableRef = result.current.iframeRef as unknown as {
current: { src: string; contentWindow: { location: { href: string } } } | null;
};
mutableRef.current = {
src: "http://localhost:3000",
contentWindow: {
location: {
href: "about:blank",
},
},
};
act(() => {
result.current.handleIframeLoad();
});
expect(result.current.embedStatus).toBe("blocked");
expect(result.current.isBlocked).toBe(true);
act(() => {
result.current.handleIframeError();
});
expect(result.current.embedStatus).toBe("error");
expect(result.current.isBlocked).toBe(true);
});
});

View File

@@ -0,0 +1,73 @@
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
export type EmbedStatus = "unknown" | "loading" | "embedded" | "blocked" | "error";
interface UsePreviewEmbedResult {
embedStatus: EmbedStatus;
iframeRef: RefObject<HTMLIFrameElement | null>;
handleIframeLoad: () => void;
handleIframeError: () => void;
resetEmbed: () => void;
isEmbedded: boolean;
isBlocked: boolean;
}
export function usePreviewEmbed(url: string | null): UsePreviewEmbedResult {
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const [embedStatus, setEmbedStatus] = useState<EmbedStatus>("unknown");
useEffect(() => {
if (!url) {
setEmbedStatus("unknown");
return;
}
queueMicrotask(() => {
setEmbedStatus("loading");
});
}, [url]);
const handleIframeLoad = useCallback(() => {
const iframeEl = iframeRef.current;
if (!iframeEl) {
setEmbedStatus("embedded");
return;
}
try {
const frameHref = iframeEl.contentWindow?.location?.href;
if (frameHref === "about:blank" && iframeEl.src !== "about:blank") {
setEmbedStatus("blocked");
return;
}
} catch {
// Cross-origin access can throw; do not treat it as blocked.
}
setEmbedStatus("embedded");
}, []);
const handleIframeError = useCallback(() => {
setEmbedStatus("error");
}, []);
const resetEmbed = useCallback(() => {
setEmbedStatus("unknown");
}, []);
const isEmbedded = useMemo(() => embedStatus === "embedded", [embedStatus]);
const isBlocked = useMemo(
() => embedStatus === "blocked" || embedStatus === "error",
[embedStatus],
);
return {
embedStatus,
iframeRef,
handleIframeLoad,
handleIframeError,
resetEmbed,
isEmbedded,
isBlocked,
};
}