Compare commits

...
Author SHA1 Message Date
posthog-eu[bot] 1fcc958460 Fix Prettier formatting in PdfEngineBoundary test
frontend:format:check failed CI because the new test file wasn't
Prettier-formatted. No logic change.

Generated-By: PostHog Code
Task-Id: 3aa5a9d0-e661-4860-8e51-4610b2599780
2026-07-15 11:40:54 +00:00
posthog-eu[bot] a835e68ac2 Recover from PDF engine load failures instead of spinning forever
The viewer gated on `isLoading || !engine || !pdfUrl` and only rendered an
error when the `usePdfiumEngine` hook rejected. When the WASM engine load
hung (never resolving or rejecting), the center pane stayed on
"Loading PDF Engine..." indefinitely with no error and no way to recover, so
no selected/dropped PDF ever rendered for the whole session.

- Add `PdfEngineBoundary`, which owns the engine load, times it out, and
  surfaces an error state with a Retry button (nudging the user to update the
  app) instead of an infinite spinner. Retry remounts the boundary so the
  load re-runs from scratch.
- Harden `wasmPrecompiler` to fall back to fetch + `arrayBuffer()` +
  `WebAssembly.compile` when `compileStreaming` is unavailable or fails
  (e.g. wrong MIME type / content-encoding under the `tauri://` protocol).
- Add component tests reproducing the hang → error + retry transition.

Generated-By: PostHog Code
Task-Id: 3aa5a9d0-e661-4860-8e51-4610b2599780
2026-07-15 11:17:55 +00:00
5 changed files with 984 additions and 753 deletions
@@ -10412,6 +10412,9 @@ disableColorFilter = "Disable Color Filter"
dualPageView = "Dual Page View"
enableDarkFilter = "Enable Dark Filter"
enableSepiaFilter = "Enable Sepia Filter"
engineLoadErrorBody = "The PDF engine couldn't be loaded. If this keeps happening, make sure the app is up to date, then try again."
engineLoadErrorRetry = "Retry"
engineLoadErrorTitle = "Couldn't load the PDF viewer"
firstPage = "First Page"
lastPage = "Last Page"
moreOptions = "More"
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,101 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, act, fireEvent } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import type { ReactNode } from "react";
// Controllable stand-in for the library engine hook so we can simulate the
// three states that matter: still loading, loaded, and errored.
let engineState: { engine: unknown; isLoading: boolean; error: Error | null } =
{
engine: null,
isLoading: true,
error: null,
};
vi.mock("@embedpdf/engines/react", () => ({
usePdfiumEngine: () => engineState,
}));
import { PdfEngineBoundary } from "@app/components/viewer/PdfEngineBoundary";
const renderBoundary = (onRetry = vi.fn(), timeoutMs = 1000) => {
const utils = render(
<MantineProvider>
<PdfEngineBoundary
wasmUrl="pdfium.wasm"
onRetry={onRetry}
timeoutMs={timeoutMs}
>
{() => <div>PDF CONTENT</div>}
</PdfEngineBoundary>
</MantineProvider>,
);
return { onRetry, ...utils };
};
const wrap = (node: ReactNode) => <MantineProvider>{node}</MantineProvider>;
describe("PdfEngineBoundary", () => {
beforeEach(() => {
engineState = { engine: null, isLoading: true, error: null };
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("shows the loading fallback while the engine is initialising", () => {
renderBoundary();
expect(screen.getByText("Loading PDF Engine...")).toBeInTheDocument();
expect(screen.queryByText("viewer.engineLoadErrorTitle")).toBeNull();
});
it("surfaces an error with a retry after the load times out (no infinite spinner)", () => {
const { onRetry } = renderBoundary(vi.fn(), 1000);
// Still spinning before the timeout elapses.
expect(screen.getByText("Loading PDF Engine...")).toBeInTheDocument();
act(() => {
vi.advanceTimersByTime(1000);
});
// The spinner is replaced by an actionable error state.
expect(screen.queryByText("Loading PDF Engine...")).toBeNull();
expect(screen.getByText("viewer.engineLoadErrorTitle")).toBeInTheDocument();
const retry = screen.getByText("viewer.engineLoadErrorRetry");
fireEvent.click(retry);
expect(onRetry).toHaveBeenCalledTimes(1);
});
it("surfaces an error immediately when the engine load rejects", () => {
engineState = {
engine: null,
isLoading: false,
error: new Error("boom"),
};
render(
wrap(
<PdfEngineBoundary wasmUrl="pdfium.wasm" onRetry={vi.fn()}>
{() => <div>PDF CONTENT</div>}
</PdfEngineBoundary>,
),
);
expect(screen.getByText("viewer.engineLoadErrorTitle")).toBeInTheDocument();
});
it("renders children once the engine is ready", () => {
engineState = { engine: {}, isLoading: false, error: null };
render(
wrap(
<PdfEngineBoundary wasmUrl="pdfium.wasm" onRetry={vi.fn()}>
{() => <div>PDF CONTENT</div>}
</PdfEngineBoundary>,
),
);
expect(screen.getByText("PDF CONTENT")).toBeInTheDocument();
expect(screen.queryByText("Loading PDF Engine...")).toBeNull();
});
});
@@ -0,0 +1,82 @@
import React, { useEffect, useState } from "react";
import { usePdfiumEngine } from "@embedpdf/engines/react";
import { Center, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Button } from "@app/ui/Button";
import ToolLoadingFallback from "@app/components/tools/ToolLoadingFallback";
/** Engine instance produced by usePdfiumEngine once initialisation succeeds. */
type PdfiumEngine = NonNullable<ReturnType<typeof usePdfiumEngine>["engine"]>;
/**
* How long to wait for the PDFium WASM engine to initialise before treating the
* load as failed. The engine normally initialises in well under a second; if it
* has not resolved after this window it has almost certainly hung (e.g. the WASM
* fetch stalled or the worker never reported back), so we surface an error with
* a retry instead of spinning forever.
*/
const DEFAULT_ENGINE_LOAD_TIMEOUT_MS = 30_000;
interface PdfEngineBoundaryProps {
/** Absolute URL of the pdfium.wasm binary to load. */
wasmUrl: string;
/** Invoked when the user asks to retry after a failed/hung load. */
onRetry: () => void;
/** Override the load timeout (mainly for tests). */
timeoutMs?: number;
/** Rendered once the engine is ready. */
children: (engine: PdfiumEngine) => React.ReactNode;
}
/**
* Loads the PDFium WASM engine and gates its children on success.
*
* The underlying `usePdfiumEngine` hook only re-initialises when its `wasmUrl`
* changes, so retrying is handled by the parent remounting this component via a
* `key`. Because the boundary owns the hook, a fresh mount runs the load again
* from scratch.
*
* Without this boundary a failed or hung WASM load left the viewer showing an
* infinite "Loading PDF Engine..." spinner with no error and no way to recover.
*/
export function PdfEngineBoundary({
wasmUrl,
onRetry,
timeoutMs = DEFAULT_ENGINE_LOAD_TIMEOUT_MS,
children,
}: PdfEngineBoundaryProps) {
const { t } = useTranslation();
const { engine, isLoading, error } = usePdfiumEngine({ wasmUrl });
const [timedOut, setTimedOut] = useState(false);
useEffect(() => {
if (engine || error) return;
const timer = setTimeout(() => setTimedOut(true), timeoutMs);
return () => clearTimeout(timer);
}, [engine, error, isLoading, timeoutMs]);
if (engine) {
return <>{children(engine)}</>;
}
if (error || timedOut) {
return (
<Center h="100%" w="100%">
<Stack align="center" gap="md" style={{ maxWidth: 420, padding: 16 }}>
<div style={{ fontSize: "32px" }}></div>
<Text fw={600} size="md" style={{ textAlign: "center" }}>
{t("viewer.engineLoadErrorTitle")}
</Text>
<Text c="dimmed" size="sm" style={{ textAlign: "center" }}>
{t("viewer.engineLoadErrorBody")}
</Text>
<Button onClick={onRetry} variant="primary">
{t("viewer.engineLoadErrorRetry")}
</Button>
</Stack>
</Center>
);
}
return <ToolLoadingFallback toolName="PDF Engine" />;
}
@@ -28,24 +28,53 @@ export const pdfiumWasmModulePromise = new Promise<WebAssembly.Module | null>(
},
);
/**
* Compile the WASM without streaming by fetching the whole binary first.
*
* `compileStreaming` requires the response to be served with the
* `application/wasm` MIME type and no incompatible `Content-Encoding`. Under the
* `tauri://` asset protocol (and behind some proxies) those headers aren't
* guaranteed, which makes streaming compilation throw. Fetching the bytes and
* compiling them directly sidesteps the MIME/encoding requirement entirely.
*/
async function compileFromArrayBuffer(): Promise<WebAssembly.Module | null> {
try {
const response = await fetch(pdfiumWasmUrl);
if (!response.ok) {
throw new Error(`Unexpected response ${response.status} for pdfium.wasm`);
}
const bytes = await response.arrayBuffer();
return await WebAssembly.compile(bytes);
} catch (err) {
console.warn("Eager WASM ArrayBuffer compilation failed:", err);
return null;
}
}
export function startEagerWasmCompilation(): void {
if (compilationStarted) return;
compilationStarted = true;
if (
typeof WebAssembly === "object" &&
typeof WebAssembly.compileStreaming === "function"
) {
if (typeof WebAssembly !== "object") {
resolvePromise(null);
return;
}
// Prefer streaming compilation, but fall back to fetching the bytes and
// compiling them directly when streaming isn't available or fails (e.g. wrong
// MIME type / content-encoding under the tauri:// protocol). Resolving null on
// total failure lets pdfiumService fall back to its own instantiation path.
if (typeof WebAssembly.compileStreaming === "function") {
WebAssembly.compileStreaming(fetch(pdfiumWasmUrl))
.then(resolvePromise)
.catch((err) => {
console.warn(
"Eager WASM compilation failed or not supported in this environment:",
"Eager WASM streaming compilation failed, falling back to ArrayBuffer:",
err,
);
resolvePromise(null);
compileFromArrayBuffer().then(resolvePromise);
});
} else {
resolvePromise(null);
compileFromArrayBuffer().then(resolvePromise);
}
}