fix(tools): stop the preview spinner hanging when thumbnail generation stalls

This commit is contained in:
Anthony Stirling
2026-08-21 08:07:13 +01:00
parent e092b487ec
commit 66ac7ff0d6
3 changed files with 162 additions and 8 deletions
@@ -406,13 +406,19 @@ export const useToolOperation = <TParams>(
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);
@@ -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([""]);
});
});
@@ -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<T>(
work: Promise<T>,
fallback: T,
fileName: string,
): Promise<T> {
return new Promise<T>((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<string[]>([]);
@@ -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`,
);