diff --git a/frontend/editor/src/core/hooks/tools/shared/useToolOperation.ts b/frontend/editor/src/core/hooks/tools/shared/useToolOperation.ts index fe1ddc7bc7..a341fe6fd7 100644 --- a/frontend/editor/src/core/hooks/tools/shared/useToolOperation.ts +++ b/frontend/editor/src/core/hooks/tools/shared/useToolOperation.ts @@ -406,13 +406,19 @@ export const useToolOperation = ( actions.setFiles(processedFiles); - // Generate thumbnails and download URL concurrently + // Generate thumbnails and download URL concurrently. The flag has to clear in a + // finally: anything thrown here otherwise leaves the spinner up permanently. actions.setGeneratingThumbnails(true); - const [thumbnails, downloadInfo] = await Promise.all([ - generateThumbnails(processedFiles), - createDownloadInfo(processedFiles, config.operationType), - ]); - actions.setGeneratingThumbnails(false); + let thumbnails: string[]; + let downloadInfo: { url: string; filename: string }; + try { + [thumbnails, downloadInfo] = await Promise.all([ + generateThumbnails(processedFiles), + createDownloadInfo(processedFiles, config.operationType), + ]); + } finally { + actions.setGeneratingThumbnails(false); + } actions.setThumbnails(thumbnails); diff --git a/frontend/editor/src/core/hooks/tools/shared/useToolResources.test.ts b/frontend/editor/src/core/hooks/tools/shared/useToolResources.test.ts new file mode 100644 index 0000000000..c2ff3e68d5 --- /dev/null +++ b/frontend/editor/src/core/hooks/tools/shared/useToolResources.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, test, vi, beforeEach, afterEach } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { allowConsole, expectConsole } from "@app/tests/failOnConsole"; + +vi.mock("@app/utils/thumbnailUtils", () => ({ + generateThumbnailForFile: vi.fn(), + generateThumbnailWithMetadata: vi.fn(), +})); + +vi.mock("@app/contexts/PreferencesContext", () => ({ + usePreferences: () => ({ + preferences: { autoUnzip: false, autoUnzipFileLimit: 10 }, + }), +})); + +vi.mock("@app/services/zipFileService", () => ({ + zipFileService: { + extractWithPreferences: vi.fn(), + createZipFromFiles: vi.fn(), + }, +})); + +import { + generateThumbnailForFile, + generateThumbnailWithMetadata, +} from "@app/utils/thumbnailUtils"; +import { useToolResources } from "@app/hooks/tools/shared/useToolResources"; + +const pdf = (name: string) => + new File(["%PDF-1.4"], name, { type: "application/pdf" }); + +describe("useToolResources thumbnail generation", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test("gives up on a thumbnail that never settles instead of hanging", async () => { + expectConsole.warn(/Thumbnail generation timed out for stuck\.pdf/); + vi.useFakeTimers(); + // A wedged pdfium worker never replies, so the promise never settles. + vi.mocked(generateThumbnailForFile).mockReturnValue(new Promise(() => {})); + + const { result } = renderHook(() => useToolResources()); + const pending = result.current.generateThumbnails([pdf("stuck.pdf")]); + + await vi.advanceTimersByTimeAsync(120_000); + + await expect(pending).resolves.toEqual([""]); + }); + + test("gives up on metadata generation that never settles", async () => { + expectConsole.warn(/Thumbnail generation timed out for stuck\.pdf/); + vi.useFakeTimers(); + vi.mocked(generateThumbnailWithMetadata).mockReturnValue( + new Promise(() => {}), + ); + + const { result } = renderHook(() => useToolResources()); + const pending = result.current.generateThumbnailsWithMetadata([ + pdf("stuck.pdf"), + ]); + + await vi.advanceTimersByTimeAsync(120_000); + + await expect(pending).resolves.toEqual([{ thumbnail: "", pageCount: 1 }]); + }); + + test("one stuck file does not block the rest", async () => { + expectConsole.warn(/Thumbnail generation timed out for stuck\.pdf/); + vi.useFakeTimers(); + vi.mocked(generateThumbnailForFile) + .mockReturnValueOnce(new Promise(() => {})) + .mockResolvedValueOnce("data:image/png;base64,ok"); + + const { result } = renderHook(() => useToolResources()); + const pending = result.current.generateThumbnails([ + pdf("stuck.pdf"), + pdf("fine.pdf"), + ]); + + await vi.advanceTimersByTimeAsync(120_000); + + await expect(pending).resolves.toEqual(["", "data:image/png;base64,ok"]); + }); + + test("still returns real thumbnails when generation succeeds", async () => { + vi.mocked(generateThumbnailForFile).mockResolvedValue( + "data:image/png;base64,ok", + ); + + const { result } = renderHook(() => useToolResources()); + + await expect( + result.current.generateThumbnails([pdf("fine.pdf")]), + ).resolves.toEqual(["data:image/png;base64,ok"]); + }); + + test("a rejected thumbnail still resolves to a placeholder", async () => { + allowConsole.warn(/Failed to generate thumbnail/); + vi.mocked(generateThumbnailForFile).mockRejectedValue(new Error("boom")); + + const { result } = renderHook(() => useToolResources()); + + await expect( + result.current.generateThumbnails([pdf("bad.pdf")]), + ).resolves.toEqual([""]); + }); +}); diff --git a/frontend/editor/src/core/hooks/tools/shared/useToolResources.ts b/frontend/editor/src/core/hooks/tools/shared/useToolResources.ts index 16cbe839f0..2de0bff651 100644 --- a/frontend/editor/src/core/hooks/tools/shared/useToolResources.ts +++ b/frontend/editor/src/core/hooks/tools/shared/useToolResources.ts @@ -7,6 +7,34 @@ import { import { zipFileService } from "@app/services/zipFileService"; import { usePreferences } from "@app/contexts/PreferencesContext"; +// A wedged pdfium worker never replies, so awaiting it pinned the review panel on +// "Generating previews..." with no way out but a page reload. +const THUMBNAIL_TIMEOUT_MS = 30_000; + +function withThumbnailTimeout( + work: Promise, + fallback: T, + fileName: string, +): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + console.warn(`Thumbnail generation timed out for ${fileName}`); + resolve(fallback); + }, THUMBNAIL_TIMEOUT_MS); + + work.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (error) => { + clearTimeout(timer); + reject(error); + }, + ); + }); +} + export const useToolResources = () => { const { preferences } = usePreferences(); const [blobUrls, setBlobUrls] = useState([]); @@ -59,7 +87,11 @@ export const useToolResources = () => { console.log( `🖼️ Generating thumbnail for: ${file.name} (${file.type}, ${file.size} bytes)`, ); - const thumbnail = await generateThumbnailForFile(file); + const thumbnail = await withThumbnailTimeout( + generateThumbnailForFile(file), + "", + file.name, + ); console.log(`🖼️ Generated thumbnail for ${file.name}: SUCCESS`); thumbnails.push(thumbnail); } catch (error) { @@ -88,7 +120,11 @@ export const useToolResources = () => { console.log( `🖼️ Generating thumbnail with metadata for: ${file.name} (${file.type}, ${file.size} bytes)`, ); - const result = await generateThumbnailWithMetadata(file); + const result = await withThumbnailTimeout( + generateThumbnailWithMetadata(file), + { thumbnail: "", pageCount: 1 }, + file.name, + ); console.log( `🖼️ Generated thumbnail with metadata for ${file.name}: SUCCESS, ${result.pageCount} pages`, );