Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/api-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ type PdfSource =

| Prop | Type | Default | Notes |
| -------------- | --------------------- | ------- | --------------------------------------------------------------------- |
| `source` | `PdfSource` | — | Required. |
| `source` | `PdfSource \| PdfSource[] \| null` | — | Optional. |
| `password` | `string` | — | Changing it reloads the document (downloaded file is reused). |
| `initialPage` | `number` | `0` | Applied once per loaded document. Changing it later has no effect. |
| `initialScale` | `number` | `1` | Like `initialPage`, for zoom (relative to the fitted page). Not an `onScaleChange`. |
Expand Down
7 changes: 6 additions & 1 deletion docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Every component, object and type is exported from the package root. The

| Prop | Type | Default | |
| --- | --- | --- | --- |
| `source` | `PdfSource \| PdfSource[]` | — | required; a list is shown as one document, see [Several documents as one](guides/sources-and-loading.md#several-documents-as-one) |
| `source` | `PdfSource \| PdfSource[] \| null` | — | optional; renders an empty viewer when `null` or `undefined`. A list is shown as one document, see [Several documents as one](guides/sources-and-loading.md#several-documents-as-one) |
| `password` | `string` | — | for every source without a `password` of its own; changing it re-opens the document (no re-download) |
| `initialPage` | `number` | `0` | zero-based, rounded to the nearest page, applied once per loaded document |
| `initialScale` | `number` | `1` | zoom applied once per loaded document, relative to the fitted page |
Expand Down Expand Up @@ -75,6 +75,11 @@ each page. The viewer adds no safe-area insets on either platform, like
| `search(query)` / `searchNext()` / `searchPrevious()` / `clearSearch()` | Full-text search. `search` is ignored before `onLoad` |
| `getStats()` | Render and cache statistics, for diagnostics |

Before the native view is mounted, `goToPage`, `zoomTo`, `goToViewport`,
`search`, `searchNext`, `searchPrevious` and `clearSearch` are no-ops,
`getStats()` returns `null`, and the promise methods reject with code
`not-loaded`.

The promise methods reject with a `PdfMethodError`; see
[Method errors](#method-errors).

Expand Down
32 changes: 32 additions & 0 deletions docs/guides/sources-and-loading.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,38 @@ before its `password` option.
break or another control character (tab is fine): such a source fails with
`invalid-source` on both platforms, before anything is sent.

## Empty state

`PdfView` accepts `source={null}` and `source={undefined}` while the document
is not known yet. In this state, it renders a plain `View` container with the
same style without mounting the native viewer; no load runs, no events fire,
and ref methods behave as they do before the native view is mounted:
`goToPage`, `zoomTo`, `goToViewport`, `search`, `searchNext`,
`searchPrevious` and `clearSearch` are no-ops, `getStats()` returns `null`,
and the promise methods reject with a `PdfMethodError` with code `not-loaded`
(see [Method errors](../api.md#method-errors)).

Setting a valid source afterwards mounts the native viewer and loads the
document. Setting the source back to `null` unmounts the native viewer, closing
the document.

## Invalid sources

A source must be a `require()` asset number or an object with `uri`, `filePath`
or `base64`. Anything else — a raw URL string (e.g. `'https://...'`), an empty
object `{}`, property types that do not match (such as `{ uri: 42 }`), or list
items that are not objects (such as `[null]`) — is malformed:

- In `PdfView`: it never throws during render. Nothing is sent to native, and
`onError` is called once with `code: 'invalid-source'` and an explanatory
message. `sourceIndex` is `0` for a single invalid source and identifies the
failing item for a list.
- In `PdfDocument.open` and `PdfCache.download`: the returned promise rejects
with `PdfOpenError` or `PdfDownloadError` (`code: 'invalid-source'`).
- `PdfCache.getEntry` and `PdfCache.remove`: reject their promise instead of
throwing synchronously.
- An empty list `[]` reports an `invalid-source` error with no `sourceIndex`.

### Redirects

Both platforms follow redirects the same way:
Expand Down
4 changes: 2 additions & 2 deletions docs/performance/audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ Screen

| Stage | Thread | Allocations | Copies / serialization | Sync / locks | Lifecycle events | Repeated work |
| --- | --- | --- | --- | --- | --- | --- |
| `PdfView` render | JS | `normalizeSource` object + `sourceKey` string per parent render | none unless value changed | — | mount / unmount | only when the *parent* re-renders; the component has no state |
| `PdfView` render | JS | `inspectSources` result + `sourceKey` string per parent render | none unless value changed | — | mount / unmount | only when the *parent* re-renders; the component has no state |
| Prop transfer | JS → UI | `ReactProp` entry per *changed* prop | `NativePdfSource` converted once per change | Fabric commit | — | none: identity-stable source & callbacks (tested) |
| Load (Android) | worker | FD, `PdfRenderer`, **page-size list** | — | `PdfProcessor.sPdfiumLock` (static, process-wide) | `onOpened` → `onLoad` | **all pages opened once to read sizes** (see B1) |
| Load (iOS) | background queue | `PDFDocument` | — | — | `onLoad` | PDFKit parses lazily |
Expand Down Expand Up @@ -102,7 +102,7 @@ direction.
- Stabilized (each crosses the Nitro boundary, where a new identity costs a
native setter call): source (by value), the three event callbacks, and
`hybridRef`. Covered by `src/PdfView.test.tsx`.
- Not memoized, on purpose: `normalizeSource`/`sourceKey` run per parent
- Not memoized, on purpose: `inspectSources`/`sourceKey` run per parent
render. They are O(headers) string work, cheaper than a memo cache check
would save.
- One `useLayoutEffect` without deps (assigns the latest handlers to a ref):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ internal class DocumentSources(
* reported. Returns once no source is resolving any more.
*/
fun resolveAll(progress: SourcesProgress) {
if (segments.isEmpty()) throw PdfFailure(PdfErrorCode.INVALID_SOURCE, "No PDF source was given", sourceIndex = 0)
if (segments.isEmpty()) throw PdfFailure(PdfErrorCode.INVALID_SOURCE, "No PDF source was given")
val failure = AtomicReference<Throwable?>()
fun resolveOrCancel(segment: Segment) {
try {
Expand Down
4 changes: 2 additions & 2 deletions package/ios/DocumentSetLoader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ final class DocumentSetLoader {
downloads = Array(repeating: nil, count: sources.count)
}

/// Starts resolving all sources and reports the first failure or assembled document.
private func start() {
// Cancelled by the view before the result: the sources that loaded are
// released (those still loading release themselves).
Expand All @@ -57,8 +58,7 @@ final class DocumentSetLoader {
DispatchQueue.main.async { if !self.isFinished { self.releaseLoaded() } }
}
guard !sources.isEmpty else {
var failure = PdfLoadFailure(code: .invalidSource, message: "No PDF source was given")
failure.sourceIndex = 0
let failure = PdfLoadFailure(code: .invalidSource, message: "No PDF source was given")
DispatchQueue.main.async { [self] in finish(.failure(failure)) }
return
}
Expand Down
34 changes: 34 additions & 0 deletions package/src/PdfCache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,4 +207,38 @@ describe('PdfCache', () => {
await PdfCache.clear();
expect(await PdfCache.getByteSize()).toBe(42);
});

test('download rejects with PdfDownloadError invalid-source for null, undefined and malformed values', async () => {
for (const invalid of [
null,
undefined,
'https://example.com/a.pdf',
{},
{ uri: 42 },
]) {
const error = await PdfCache.download(invalid as never).catch(
(e: unknown) => e,
);
expect(error).toBeInstanceOf(PdfDownloadError);
expect((error as InstanceType<typeof PdfDownloadError>).code).toBe(
'invalid-source',
);
}
});

test('getEntry and remove reject instead of throwing synchronously', async () => {
for (const invalid of [null, undefined, 'bad', {}, { uri: 42 }]) {
let getEntryPromise: Promise<unknown>;
expect(() => {
getEntryPromise = PdfCache.getEntry(invalid as never);
}).not.toThrow();
await expect(getEntryPromise!).rejects.toThrow();

let removePromise: Promise<unknown>;
expect(() => {
removePromise = PdfCache.remove(invalid as never);
}).not.toThrow();
await expect(removePromise!).rejects.toThrow();
}
});
});
16 changes: 13 additions & 3 deletions package/src/PdfCache.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { NitroModules } from 'react-native-nitro-modules';
import { abortable, abortReason, untilAborted } from './abort';
import { PdfDownloadError } from './PdfDownloadError';
import type { NativePdfSource } from './types/NativePdfSource';
import type { PdfDownloadOptions } from './types/PdfDownloadOptions';
import { normalizeUriSource, type PdfUriSource } from './source';
import type { BetterPdfCache } from './specs/BetterPdfCache.nitro';
Expand Down Expand Up @@ -67,12 +68,21 @@ export const PdfCache = {
): Promise<PdfCacheEntry> {
const { onProgress, signal } = options;
if (signal?.aborted) throw abortReason(signal);
let normalized: Omit<NativePdfSource, 'base64'>;
try {
normalized = normalizeUriSource(source);
} catch (error) {
throw new PdfDownloadError({
code: 'invalid-source',
message: error instanceof Error ? error.message : 'Invalid URI source',
});
}
const cache = getNativeCache();
const requestId = nextRequestId;
nextRequestId += 1;
const result = cache.download(
requestId,
normalizeUriSource(source),
normalized,
untilAborted(onProgress, signal),
);
return unwrap(
Expand All @@ -86,7 +96,7 @@ export const PdfCache = {
* never checks `maxAgeMs`: compare {@linkcode PdfCacheEntry.validatedAtMs}
* yourself.
*/
getEntry(source: PdfUriSource): Promise<PdfCacheEntry | undefined> {
async getEntry(source: PdfUriSource): Promise<PdfCacheEntry | undefined> {
return getNativeCache().getEntry(normalizeUriSource(source));
},

Expand All @@ -95,7 +105,7 @@ export const PdfCache = {
* Resolves `false` when there was none. A `PdfView` showing the document
* keeps showing it; a download in progress still stores its result.
*/
remove(source: PdfUriSource): Promise<boolean> {
async remove(source: PdfUriSource): Promise<boolean> {
return getNativeCache().remove(normalizeUriSource(source));
},

Expand Down
18 changes: 18 additions & 0 deletions package/src/PdfDocument.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,24 @@ describe('PdfDocument.open', () => {
expect(lastNative().password).toBe('secret');
});

test('rejects with PdfOpenError invalid-source for null, undefined and malformed values', async () => {
for (const invalid of [
null,
undefined,
'https://example.com/a.pdf',
{},
{ uri: 42 },
]) {
const error = await PdfDocument.open(invalid as never).catch(
(e: unknown) => e,
);
expect(error).toBeInstanceOf(PdfOpenError);
expect((error as InstanceType<typeof PdfOpenError>).code).toBe(
'invalid-source',
);
}
});

test("a source's own password takes precedence over the option", async () => {
void PdfDocument.open(
{ uri: '/docs/a.pdf', password: 'own' },
Expand Down
11 changes: 9 additions & 2 deletions package/src/PdfDocument.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { buildOutlineTree } from './outline';
import { toNativeSource } from './nativeSource';
import { PdfMethodError, withMethodErrors } from './PdfMethodError';
import { PdfOpenError } from './PdfOpenError';
import { normalizeSource, sourcePassword, type PdfSource } from './source';
import { sourcePassword, validateSourceItem, type PdfSource } from './source';
import type { BetterPdfDocument } from './specs/BetterPdfDocument.nitro';
import type { PdfDocumentMetadata } from './types/PdfDocumentMetadata';
import type { PdfOpenOptions } from './types/PdfOpenOptions';
Expand Down Expand Up @@ -74,12 +74,19 @@ export class PdfDocument {
): Promise<PdfDocument> {
const { password, onProgress, signal } = options;
if (signal?.aborted) throw abortReason(signal);
const validation = validateSourceItem(source, Image.resolveAssetSource);
if (!validation.ok) {
throw new PdfOpenError({
code: 'invalid-source',
message: validation.error,
});
}
const native =
NitroModules.createHybridObject<BetterPdfDocument>('BetterPdfDocument');
// Async, so that a synchronous throw becomes a rejection too.
const result = (async () =>
native.open(
toNativeSource(normalizeSource(source, Image.resolveAssetSource)),
toNativeSource(validation.source),
sourcePassword(source) ?? password,
untilAborted(onProgress, signal),
))();
Expand Down
Loading
Loading