mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fcc958460 | ||
|
|
a835e68ac2 |
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user