diff --git a/packages/studio/src/components/MediaPreview.test.tsx b/packages/studio/src/components/MediaPreview.test.tsx new file mode 100644 index 0000000000..a1b8c923fa --- /dev/null +++ b/packages/studio/src/components/MediaPreview.test.tsx @@ -0,0 +1,69 @@ +// @vitest-environment happy-dom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it } from "vitest"; +import { MediaPreview } from "./MediaPreview"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +let root: Root | null = null; +let host: HTMLDivElement | null = null; + +afterEach(() => { + if (root) act(() => root?.unmount()); + root = null; + host = null; + document.body.replaceChildren(); +}); + +async function renderPreview(projectId: string, filePath: string): Promise { + if (!root) { + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); + } + await act(async () => { + root?.render(); + }); + return host as HTMLDivElement; +} + +describe("MediaPreview", () => { + it("retries a repaired file after visiting another media source", async () => { + const host = await renderPreview("test", "broken.png"); + const failedImage = host.querySelector("img"); + expect(failedImage).not.toBeNull(); + + await act(async () => { + failedImage?.dispatchEvent(new Event("error")); + }); + expect(host.textContent).toContain("Couldn't load this file"); + + await renderPreview("test", "healthy.png"); + expect(host.querySelector("img")?.getAttribute("src")).toContain("healthy.png"); + + await renderPreview("test", "broken.png"); + expect(host.querySelector("img")?.getAttribute("src")).toContain("broken.png"); + }); + + it("resets failure state when the project changes, but preserves a current failure", async () => { + const host = await renderPreview("first", "broken.png"); + const image = host.querySelector("img"); + await act(async () => { + image?.dispatchEvent(new Event("error")); + }); + expect(host.textContent).toContain("Couldn't load this file"); + + await renderPreview("second", "broken.png"); + expect(host.querySelector("img")?.getAttribute("src")).toContain("/second/preview/broken.png"); + + const secondImage = host.querySelector("img"); + await act(async () => { + secondImage?.dispatchEvent(new Event("error")); + }); + expect(host.textContent).toContain("Couldn't load this file"); + + await renderPreview("second", "broken.png"); + expect(host.querySelector("img")).toBeNull(); + }); +}); diff --git a/packages/studio/src/components/MediaPreview.tsx b/packages/studio/src/components/MediaPreview.tsx index 2a8c013003..06c2b00176 100644 --- a/packages/studio/src/components/MediaPreview.tsx +++ b/packages/studio/src/components/MediaPreview.tsx @@ -1,5 +1,5 @@ import { buildProjectApiPath } from "../utils/projectRouting"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { IMAGE_EXT, VIDEO_EXT, AUDIO_EXT } from "../utils/mediaTypes"; import { encodeUrlPath } from "@hyperframes/parsers"; @@ -32,10 +32,15 @@ function MediaErrorPanel({ name, filePath }: { name: string; filePath: string }) export function MediaPreview({ projectId, filePath }: { projectId: string; filePath: string }) { const serveUrl = buildProjectApiPath(projectId, `/preview/${encodeUrlPath(filePath)}`); const name = filePath.split("/").pop() ?? filePath; - // Keyed by path so switching to another file clears a previous failure. - const [failedPath, setFailedPath] = useState(null); - const failed = failedPath === filePath; - const setFailed = () => setFailedPath(filePath); + const [failure, setFailure] = useState<{ source: string; failed: boolean }>({ + source: serveUrl, + failed: false, + }); + useEffect(() => { + setFailure({ source: serveUrl, failed: false }); + }, [serveUrl]); + const failed = failure.source === serveUrl && failure.failed; + const setFailed = () => setFailure({ source: serveUrl, failed: true }); if (failed) return ;