books reader: fix the error when leaving a PDF (back button)
Some checks failed
Deploy production / deploy (push) Has been cancelled

pdf.js 6 removed PDFDocumentProxy.destroy(), so the PdfReader teardown threw
"destroy is not a function" on every unmount, breaking the client-side
navigation back to the book page (and sometimes showing the error page).
Close the document through its loading task instead (closePdf), which also
cancels a load still in flight. Test covers the pdf.js API and closePdf.
This commit is contained in:
2026-10-24 02:39:33 +00:00
parent eb80a830f5
commit 81ee02f11a
3 changed files with 64 additions and 4 deletions

View File

@@ -14,6 +14,7 @@
type Rect
} from '#lib/books/shared.js';
import { clampZoom, pdfRows, rowIndexOf, type Spread } from './layout.js';
import { closePdf } from './pdf.js';
import { type EngineProps, type TocItem } from './types.js';
let {
@@ -370,6 +371,7 @@
onMount(() => {
let disposed = false;
let loadingTask: ReturnType<typeof import('pdfjs-dist').getDocument> | undefined;
const measure = () => {
width = scroller.clientWidth;
height = scroller.clientHeight;
@@ -450,15 +452,15 @@
pdfjs = await import('pdfjs-dist');
pdfjs.GlobalWorkerOptions.workerSrc = workerUrl;
onloading(0);
const task = pdfjs.getDocument({
const task = (loadingTask = pdfjs.getDocument({
url,
withCredentials: true,
rangeChunkSize: 1 << 20
});
}));
task.onProgress = ({ loaded, total }: { loaded: number; total: number }) =>
onloading(total ? Math.min(1, loaded / total) : null);
const doc = await task.promise;
if (disposed) return void doc.destroy();
if (disposed) return void closePdf(task);
pdf = doc;
onloading(null);
// Use the first page's size for every page up front; real sizes fill in quickly.
@@ -499,7 +501,8 @@
resize.disconnect();
observer?.disconnect();
for (const num of [...live.keys()]) unrender(num);
void pdf?.destroy();
// pdf.js 6 has no PDFDocumentProxy.destroy(); calling it threw on every unmount.
void closePdf(loadingTask);
};
});
</script>

View File

@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';
import { closePdf } from './pdf.js';
// A tiny one-page PDF (pdf.js tolerates the missing xref table).
const PDF =
'%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n' +
'2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n' +
'3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 200 300]>>endobj\ntrailer<</Root 1 0 R>>\n%%EOF';
const open = async () => {
const pdfjs = await import('pdfjs-dist/legacy/build/pdf.mjs');
return pdfjs.getDocument({ data: new TextEncoder().encode(PDF) });
};
describe('closePdf', () => {
it('the document itself has no destroy() in pdf.js 6 (the old teardown threw)', async () => {
const task = await open();
const doc = await task.promise;
expect(doc.numPages).toBe(1);
expect((doc as unknown as { destroy?: unknown }).destroy).toBeUndefined();
await closePdf(task);
});
it('destroys a loaded document via its loading task, idempotently', async () => {
const task = await open();
await task.promise;
await closePdf(task);
expect(task.destroyed).toBe(true);
await expect(closePdf(task)).resolves.toBeUndefined();
});
it('can close while still loading, or with nothing open', async () => {
const task = await open();
task.promise.catch(() => {}); // rejected by the destroy
await closePdf(task);
expect(task.destroyed).toBe(true);
await expect(closePdf(undefined)).resolves.toBeUndefined();
});
});

View File

@@ -0,0 +1,18 @@
// pdf.js lifecycle helpers for the reader.
type LoadingTask = { destroyed: boolean; destroy(): Promise<void> };
/**
* Closes a PDF opened with pdfjs.getDocument(). pdf.js 6 removed PDFDocumentProxy.destroy();
* the loading task owns the document, its network requests and the worker, so tearing it
* down works both mid-load and after. Never throws: it runs while the reader unmounts
* (e.g. the back button), where an exception would break the navigation.
*/
export async function closePdf(task: LoadingTask | null | undefined): Promise<void> {
if (!task || task.destroyed) return;
try {
await task.destroy();
} catch {
// already torn down
}
}