From cba7afb33a39cf836e89fabc88f7f4b6ab8f43b6 Mon Sep 17 00:00:00 2001 From: Jakub Kasprzyk Date: Tue, 20 Oct 2026 23:46:36 +0200 Subject: [PATCH] feat: type onError's load and page failures apart On Android, page-render-failed and out-of-memory while rendering arrive through onError after onLoad, with error.page set, and the document stays open. The docs read as if every onError ended the load, and the example replaced the document with its error card. onError now receives PdfLoadError | PdfPageError, so error.page !== undefined narrows the type. The Nitro struct is unchanged: PdfView casts native's PdfError once at the boundary, and handlers typed with PdfError still compile. The docs say which errors come after onLoad and what happens to the page, and the example keeps the document for page errors. Closes #68 --- docs/api.md | 19 +++++++++++++-- docs/guides/sources-and-loading.md | 19 +++++++++++++-- docs/limitations.md | 4 ++++ docs/migrating-from-react-native-pdf.md | 2 +- docs/troubleshooting.md | 1 + example/screens/ViewerScreen.tsx | 12 +++++++--- package/src/PdfView.test.tsx | 27 +++++++++++++++++++++ package/src/PdfView.tsx | 22 +++++++++++++++--- package/src/index.ts | 2 +- package/src/types/PdfError.ts | 31 +++++++++++++++++++++---- 10 files changed, 123 insertions(+), 16 deletions(-) diff --git a/docs/api.md b/docs/api.md index 80eb860..f25544f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -42,7 +42,7 @@ Every component, object and type is exported from the package root. The | `onLoad` | `({ pageCount, firstPageWidth, firstPageHeight, filePath?, isStale, sources }) => void` | | first page's size in PDF points, as displayed (crop box, rotation applied); `filePath`: the local file being displayed (downloaded copy, local file or extracted asset; not for `{ base64 }`, `content://` or several sources); `isStale`: an outdated stored copy shown because the server could not be reached (see [Offline reading and caching](guides/offline-and-caching.md)); `sources`: each source's `{ pageCount, firstPage, filePath?, isStale }` | | `onLoadProgress` | `({ loadedBytes, totalBytes? }) => void` | | remote downloads only (not when a stored copy is used); at most every 100 ms plus once on completion | | `onPageChange` | `({ page, pageCount }) => void` | | zero-based | -| `onError` | `(error: PdfError) => void` | | | +| `onError` | `(error: PdfLoadError \| PdfPageError) => void` | | without `error.page`: the load failed and nothing is shown; with `error.page` (Android only): a page could not be rendered after `onLoad`, and the document stays open; see [Errors](#errors) | | `onLinkPress` | `({ url }) => void` | | tap on a URL link; the viewer never opens URLs itself | | `onPageTap` | `({ page, x, y }) => void` | | tap on a page outside links; zero-based page, `x`/`y` in PDF points from the top-left of the page as displayed (crop box, rotation applied) | | `onScaleChange` | `({ scale }) => void` | | once per settled zoom change (pinch/double-tap end, `zoomTo`); relative to the fitted page | @@ -123,7 +123,7 @@ pdf.current?.goToViewport(saved); // a position from onViewportChan `invalid-source` · `file-not-found` · `network-error` · `http-error` (`error.httpStatus`) · `invalid-pdf` · `password-required` · `invalid-password` · `unsupported-pdf` · `page-render-failed` (`error.page`) · -`out-of-memory` +`out-of-memory` (`error.page` while rendering) `error.message` is human-readable and may change; `error.nativeDescription` carries the platform error for logging. Load failures carry @@ -131,6 +131,20 @@ carries the platform error for logging. Load failures carry source; unset when the set failed as a whole, as when Android runs out of memory laying out many sources' pages). +On Android, `onError` also comes after `onLoad` when a page cannot be +rendered: `page-render-failed`, or `out-of-memory` while rendering, both +with `error.page` and without `error.sourceIndex`. The document stays +open and the other pages keep working: + +- `page-render-failed`: the page stays blank until the document is loaded + again, e.g. with a new `key`. Each page is reported once. +- `out-of-memory`: the viewer frees memory and tries the page again later. + Reported once per document. + +`onError` receives a `PdfLoadError` or a `PdfPageError`, and +`error.page !== undefined` tells them apart (it narrows the type to +`PdfPageError`). iOS reports load failures only (PDFKit draws the pages). + ## Types | Type | Description | @@ -140,6 +154,7 @@ memory laying out many sources' pages). | `PdfSource` / `PdfUriSource` / `PdfFileSource` / `PdfBase64Source` | Where a document comes from: a URI (with cache options for downloads), a file path, a base64 string or a bundled asset; all but the asset can carry their own `password` | | `PdfLoadedSource` | One source as `onLoad` reports it: `{ pageCount, firstPage, filePath?, isStale }` | | `PdfError` / `PdfErrorCode` | Errors and their stable codes | +| `PdfLoadError` / `PdfPageError` | `onError`'s argument: a load failure, or a page that could not be rendered after `onLoad` (Android, with `page`) | | `PdfViewport` | A reading position: `{ page, x, y, scale }` | | `PdfFormField` | A form field, discriminated by `type` (`PdfTextFormField`, `PdfCheckboxFormField`, …) | | `PdfOutlineItem` | An outline entry: `{ title, page?, children }` | diff --git a/docs/guides/sources-and-loading.md b/docs/guides/sources-and-loading.md index 74ca861..ff86257 100644 --- a/docs/guides/sources-and-loading.md +++ b/docs/guides/sources-and-loading.md @@ -151,8 +151,9 @@ smaller than its base64 form. ## Loading state -There is no built-in spinner. Show your own until `onLoad` or `onError`; -for remote documents `onLoadProgress` reports the download: +There is no built-in spinner. Show your own until `onLoad`, or `onError` +without `error.page`; for remote documents `onLoadProgress` reports the +download: ```tsx onLoadProgress={({ loadedBytes, totalBytes }) => @@ -162,4 +163,18 @@ onLoadProgress={({ loadedBytes, totalBytes }) => `totalBytes` is `undefined` when the server does not report a size. +An `onError` with `error.page` is not a load failure: on Android, a page +that cannot be rendered is reported after `onLoad`, and the rest of the +document stays open. Keep the viewer on screen: + +```tsx +onError={(error) => { + if (error.page !== undefined) { + console.warn(`${error.code} on page ${error.page + 1}`); + } else { + setLoadError(error); // nothing is shown + } +}} +``` + See the [API reference](../api.md#errors) for the error codes. diff --git a/docs/limitations.md b/docs/limitations.md index 33b1c46..63636a3 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -67,6 +67,10 @@ For an overview of what each platform supports, see the from the viewport is visited. - Documents are parsed in-process; the platform recommends an isolated process for untrusted input, which this library does not provide. + - A page that cannot be rendered, or running out of memory while + rendering, is reported through `onError` after `onLoad` (with + `error.page`), and the document stays open (see + [Errors](api.md#errors)). iOS reports load failures only. - **iOS**: PDFKit does not expose cache statistics; `getStats()` reports `-1` for them. - A base64 source's text is held twice while the document is shown (your diff --git a/docs/migrating-from-react-native-pdf.md b/docs/migrating-from-react-native-pdf.md index 8313d91..61e5141 100644 --- a/docs/migrating-from-react-native-pdf.md +++ b/docs/migrating-from-react-native-pdf.md @@ -72,7 +72,7 @@ users see. | **Page numbers** | 1-based everywhere (`page`, `setPage`, `onPageChanged`, `onPageSingleTap`) | 0-based everywhere | Add or subtract 1 at the boundary. | | **TLS certificates** | `trustAllCerts` defaults to **`true`** — invalid certificates are accepted | Certificates are always validated | Fix the server's certificate. Downloads that "worked" before may now fail with `network-error`. | | **Download progress value** | `onLoadProgress(fraction)` — `0…1`, despite the name | `onLoadProgress({ loadedBytes, totalBytes? })` | `totalBytes === undefined ? undefined : loadedBytes / totalBytes` | -| **Loading UI** | Shows `xx.xx%` text (or `renderActivityIndicator`) while downloading | Shows nothing | Render your own indicator until `onLoad` / `onError`. | +| **Loading UI** | Shows `xx.xx%` text (or `renderActivityIndicator`) while downloading | Shows nothing | Render your own indicator until `onLoad`, or `onError` without `error.page`. | | **Background** | `#EEE` behind the pages | Transparent | `style={{ backgroundColor: '#EEE' }}` | | **Fit** | `fitPolicy` default `2` (fit whole page) | `'width'` when vertical and continuous; `'page'` with `horizontal` or `enablePaging` | Pass `fitPolicy="page"` to keep react-native-pdf's default. | | **Max zoom** | `maxScale` default `3` | `4` | Pass `maxScale={3}` to keep the old limit. | diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 08c5fb4..b97f985 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -7,6 +7,7 @@ | Errors about the legacy architecture | Enable the New Architecture; Nitro Views need Fabric. | | A password-protected document fails with `unsupported-pdf` on Android | The device is below Android 15 without SDK extension 13, where the platform renderer cannot open protected files. | | Search reports `isSupported: false`, long presses select nothing | Same devices as above; see the [capability matrix](../README.md#capability-matrix). | +| On Android, `onError` arrives while the document is shown | It has `error.page`: one page could not be rendered after `onLoad` (`page-render-failed`, or `out-of-memory`), and the document stays open. Keep the viewer on screen; only `onError` without `page` ends the load. See [Errors](api.md#errors). | | A remote document fails with `invalid-pdf` | The server answered with something else, often a login page: check the URL and `headers`. | | A remote document fails with `invalid-source` | Check that the URL is `http(s)://` and the `headers`: often a token read with a trailing newline. `error.message` names the header. | | A signed URL or rotating token downloads the document on every open | Set `cacheKey` on the source to identify the stored copy. | diff --git a/example/screens/ViewerScreen.tsx b/example/screens/ViewerScreen.tsx index c2d9462..0ac1e7e 100644 --- a/example/screens/ViewerScreen.tsx +++ b/example/screens/ViewerScreen.tsx @@ -552,9 +552,15 @@ export function ViewerScreen() { `tap: page ${event.page + 1} at ${Math.round(event.x)}, ${Math.round(event.y)} pt`, ) } - onError={(pdfError) => - setState({ kind: 'failed', source, error: pdfError }) - } + onError={(pdfError) => { + if (pdfError.page != null) { + setLastEvent( + `error: ${pdfError.code} on page ${pdfError.page + 1}`, + ); + } else { + setState({ kind: 'failed', source, error: pdfError }); + } + }} > {entry.overlays === true && ( { ]); }); + test('forwards load and page errors, typed apart by page', () => { + const received: string[] = []; + // Handlers typed with PdfError, written before the split, still compile. + const untyped: (error: PdfError) => void = () => {}; + render(); + render( + { + if (error.page !== undefined) { + const code: 'page-render-failed' | 'out-of-memory' = error.code; + received.push(`${code} on page ${error.page + 1}`); + return; + } + // @ts-expect-error A load failure is never page-render-failed. + if (error.code === 'page-render-failed') return; + received.push(`load ${error.code}`); + }} + />, + ); + const onError = lastHost().onError as { f: (error: PdfError) => void }; + onError.f({ code: 'invalid-pdf', message: 'Not a PDF', sourceIndex: 0 }); + onError.f({ code: 'out-of-memory', message: 'Out of memory', page: 2 }); + expect(received).toEqual(['load invalid-pdf', 'out-of-memory on page 3']); + }); + test('passes no native callback for events the app does not listen to', () => { render( {}} />); expect(lastHost().onLoad).toBeDefined(); diff --git a/package/src/PdfView.tsx b/package/src/PdfView.tsx index baa641e..9924650 100644 --- a/package/src/PdfView.tsx +++ b/package/src/PdfView.tsx @@ -40,7 +40,7 @@ import type { NativePdfFormChangeEvent } from './types/NativePdfFormChangeEvent' import type { NativePdfSource } from './types/NativePdfSource'; import type { PdfColorScheme } from './types/PdfColorScheme'; import type { PdfContentInset } from './types/PdfContentInset'; -import type { PdfError } from './types/PdfError'; +import type { PdfError, PdfLoadError, PdfPageError } from './types/PdfError'; import type { PdfExportOptions } from './types/PdfExportOptions'; import type { PdfExportResult } from './types/PdfExportResult'; import type { PdfFitPolicy } from './types/PdfFitPolicy'; @@ -404,7 +404,20 @@ export interface PdfViewProps extends ViewProps { * server (see `PdfUriSource.cache` and `maxAgeMs`). */ onLoadProgress?: (event: PdfLoadProgressEvent) => void; - onError?: (error: PdfError) => void; + /** + * The document could not be loaded, or (Android only) one of its pages + * could not be rendered. `error.page !== undefined` tells them apart. + * + * - {@linkcode PdfLoadError}, without `page`: the load failed and nothing + * is shown. + * - {@linkcode PdfPageError}, with `page`, Android only: it comes after + * {@linkcode PdfViewProps.onLoad}, and the document stays open. + * `page-render-failed`: the page stays blank until the document is + * loaded again (e.g. a new `key`); each page is reported once. + * `out-of-memory`: the viewer frees memory and tries the page again + * later; reported once per document. + */ + onError?: (error: PdfLoadError | PdfPageError) => void; onPageChange?: (event: PdfPageChangeEvent) => void; /** * The user tapped a link to a URL. Links to other pages of the document @@ -625,7 +638,10 @@ export function PdfView({ onLoadProgress: callback((event: PdfLoadProgressEvent) => handlers.current.onLoadProgress?.(event), ), - onError: callback((error: PdfError) => handlers.current.onError?.(error)), + // Native sets `page` only for page failures. + onError: callback((error: PdfError) => + handlers.current.onError?.(error as PdfLoadError | PdfPageError), + ), onPageChange: callback((event: PdfPageChangeEvent) => handlers.current.onPageChange?.(event), ), diff --git a/package/src/index.ts b/package/src/index.ts index ed6e9bd..0377e02 100644 --- a/package/src/index.ts +++ b/package/src/index.ts @@ -18,7 +18,7 @@ export type { PdfCacheMode } from './types/PdfCacheMode'; export type { PdfColorScheme } from './types/PdfColorScheme'; export type { PdfContentInset } from './types/PdfContentInset'; export type { PdfDocumentMetadata } from './types/PdfDocumentMetadata'; -export type { PdfError } from './types/PdfError'; +export type { PdfError, PdfLoadError, PdfPageError } from './types/PdfError'; export type { PdfErrorCode } from './types/PdfErrorCode'; export type { PdfExportOptions } from './types/PdfExportOptions'; export type { PdfExportResult } from './types/PdfExportResult'; diff --git a/package/src/types/PdfError.ts b/package/src/types/PdfError.ts index b568ddf..632a2cc 100644 --- a/package/src/types/PdfError.ts +++ b/package/src/types/PdfError.ts @@ -8,13 +8,17 @@ export interface PdfError { code: PdfErrorCode; /** Human-readable English description. Not stable — do not match on it. */ message: string; - /** Zero-based page index, set for `page-render-failed`. */ + /** + * Zero-based page index, set when one page failed after `onLoad` + * (`page-render-failed`, and `out-of-memory` while rendering; Android + * only). + */ page?: number; /** * Zero-based index in `PdfView`'s `source` of the document that could not - * be loaded, set by `PdfView` for every code but `page-render-failed` - * (`0` for a single source). Unset when several sources failed as a whole, - * as when Android runs out of memory laying out their pages. + * be loaded, set by `PdfView` for load failures (`0` for a single source). + * Unset for page failures, and when several sources failed as a whole, as + * when Android runs out of memory laying out their pages. */ sourceIndex?: number; /** HTTP status code, set for `http-error`. */ @@ -22,3 +26,22 @@ export interface PdfError { /** Raw platform error description, for logging only. */ nativeDescription?: string; } + +/** + * `PdfView`'s `onError` argument for a document that could not be loaded: + * nothing is shown. + */ +export interface PdfLoadError extends PdfError { + code: Exclude; + page?: undefined; +} + +/** + * `PdfView`'s `onError` argument for a page that could not be rendered after + * `onLoad` (Android only): the document stays open. + */ +export interface PdfPageError extends PdfError { + code: 'page-render-failed' | 'out-of-memory'; + page: number; + sourceIndex?: undefined; +}