From 1001293a0d56b03c61f59c97c44cb31ebcc792d0 Mon Sep 17 00:00:00 2001 From: caja_autoservicio Date: Tue, 20 Oct 2026 12:15:39 -0300 Subject: [PATCH 1/6] fix: handle null, undefined and malformed sources gracefully (#66) --- docs/api.md | 2 +- docs/guides/sources-and-loading.md | 27 +++ .../nitro/betterpdf/DocumentSources.kt | 2 +- package/ios/DocumentSetLoader.swift | 3 +- package/src/PdfCache.test.ts | 30 ++++ package/src/PdfCache.ts | 17 +- package/src/PdfDocument.test.ts | 14 ++ package/src/PdfDocument.ts | 11 +- package/src/PdfView.test.tsx | 111 ++++++++++++ package/src/PdfView.tsx | 61 +++++-- package/src/source.test.ts | 62 +++++++ package/src/source.ts | 169 +++++++++++++++--- 12 files changed, 461 insertions(+), 48 deletions(-) diff --git a/docs/api.md b/docs/api.md index 80eb860..da95873 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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 | diff --git a/docs/guides/sources-and-loading.md b/docs/guides/sources-and-loading.md index 74ca861..91d96cc 100644 --- a/docs/guides/sources-and-loading.md +++ b/docs/guides/sources-and-loading.md @@ -27,6 +27,33 @@ 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 reject as they do before mount. + +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 (including `sourceIndex` when the failure belongs to an item in 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`. + ## Base64 documents A document you have as a base64 string — made with pdfmake or pdf-lib, or diff --git a/package/android/src/main/java/com/margelo/nitro/betterpdf/DocumentSources.kt b/package/android/src/main/java/com/margelo/nitro/betterpdf/DocumentSources.kt index f5d92fe..4366fa7 100644 --- a/package/android/src/main/java/com/margelo/nitro/betterpdf/DocumentSources.kt +++ b/package/android/src/main/java/com/margelo/nitro/betterpdf/DocumentSources.kt @@ -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() fun resolveOrCancel(segment: Segment) { try { diff --git a/package/ios/DocumentSetLoader.swift b/package/ios/DocumentSetLoader.swift index 2072ecf..820930f 100644 --- a/package/ios/DocumentSetLoader.swift +++ b/package/ios/DocumentSetLoader.swift @@ -57,8 +57,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 } diff --git a/package/src/PdfCache.test.ts b/package/src/PdfCache.test.ts index b9b9d01..7ae94fd 100644 --- a/package/src/PdfCache.test.ts +++ b/package/src/PdfCache.test.ts @@ -207,4 +207,34 @@ 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).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; + expect(() => { + getEntryPromise = PdfCache.getEntry(invalid as never); + }).not.toThrow(); + await expect(getEntryPromise!).rejects.toThrow(); + + let removePromise: Promise; + expect(() => { + removePromise = PdfCache.remove(invalid as never); + }).not.toThrow(); + await expect(removePromise!).rejects.toThrow(); + } + }); }); diff --git a/package/src/PdfCache.ts b/package/src/PdfCache.ts index 3795b60..afc5562 100644 --- a/package/src/PdfCache.ts +++ b/package/src/PdfCache.ts @@ -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'; @@ -67,12 +68,22 @@ export const PdfCache = { ): Promise { const { onProgress, signal } = options; if (signal?.aborted) throw abortReason(signal); + let normalized: Omit; + 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( @@ -86,7 +97,7 @@ export const PdfCache = { * never checks `maxAgeMs`: compare {@linkcode PdfCacheEntry.validatedAtMs} * yourself. */ - getEntry(source: PdfUriSource): Promise { + async getEntry(source: PdfUriSource): Promise { return getNativeCache().getEntry(normalizeUriSource(source)); }, @@ -95,7 +106,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 { + async remove(source: PdfUriSource): Promise { return getNativeCache().remove(normalizeUriSource(source)); }, diff --git a/package/src/PdfDocument.test.ts b/package/src/PdfDocument.test.ts index c3fc010..cf3086d 100644 --- a/package/src/PdfDocument.test.ts +++ b/package/src/PdfDocument.test.ts @@ -148,6 +148,20 @@ 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).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' }, diff --git a/package/src/PdfDocument.ts b/package/src/PdfDocument.ts index cf00995..c12a991 100644 --- a/package/src/PdfDocument.ts +++ b/package/src/PdfDocument.ts @@ -4,7 +4,7 @@ import { abortable, abortReason, untilAborted } from './abort'; import { buildOutlineTree } from './outline'; import { toNativeSource } from './nativeSource'; 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'; @@ -72,12 +72,19 @@ export class PdfDocument { ): Promise { 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'); // 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), ))(); diff --git a/package/src/PdfView.test.tsx b/package/src/PdfView.test.tsx index 4591518..95287d4 100644 --- a/package/src/PdfView.test.tsx +++ b/package/src/PdfView.test.tsx @@ -948,4 +948,115 @@ describe('PdfView', () => { }, ]); }); + + test('null and undefined render an empty box without native view and fire no events', () => { + const events: string[] = []; + render( + events.push('load')} + onError={() => events.push('error')} + />, + ); + expect(hostRenders.length).toBe(0); + expect(boxRenders.length).toBeGreaterThan(0); + expect(events).toEqual([]); + + hostRenders.length = 0; + boxRenders.length = 0; + render( + events.push('load')} + onError={() => events.push('error')} + />, + ); + expect(hostRenders.length).toBe(0); + expect(boxRenders.length).toBeGreaterThan(0); + expect(events).toEqual([]); + }); + + test('null -> source -> null mounts, updates native sources, and unmounts native view', () => { + const renderer = render(); + expect(hostRenders.length).toBe(0); + + act(() => renderer.update()); + expect(hostRenders.length).toBe(1); + expect(lastHost().sources).toEqual([{ uri: 'file:///a.pdf', password: '' }]); + + act(() => renderer.update()); + const countAfterNull = hostRenders.length; + act(() => renderer.update()); + expect(hostRenders.length).toBe(countAfterNull); + }); + + test('malformed values call onError once with invalid-source and do not throw during render', () => { + const stringErrors: unknown[] = []; + render( + stringErrors.push(err)} + />, + ); + expect(hostRenders.length).toBe(0); + expect(stringErrors).toEqual([ + { + code: 'invalid-source', + message: + 'source must be a require() result or an object with uri, filePath or base64; got a string', + sourceIndex: undefined, + }, + ]); + + const objErrors: unknown[] = []; + render( + objErrors.push(err)} + />, + ); + expect(hostRenders.length).toBe(0); + expect(objErrors).toEqual([ + { + code: 'invalid-source', + message: + 'source must be a require() result or an object with uri, filePath or base64; got {}', + sourceIndex: undefined, + }, + ]); + + const uriNumberErrors: unknown[] = []; + render( + uriNumberErrors.push(err)} + />, + ); + expect(hostRenders.length).toBe(0); + expect(uriNumberErrors).toEqual([ + { + code: 'invalid-source', + message: + 'source must be a require() result or an object with uri, filePath or base64; got { uri: 42 }', + sourceIndex: undefined, + }, + ]); + + const listErrors: unknown[] = []; + render( + listErrors.push(err)} + />, + ); + expect(hostRenders.length).toBe(0); + expect(listErrors).toEqual([ + { + code: 'invalid-source', + message: + 'source must be a require() result or an object with uri, filePath or base64; got null', + sourceIndex: 0, + }, + ]); + }); }); diff --git a/package/src/PdfView.tsx b/package/src/PdfView.tsx index baa641e..d9c194d 100644 --- a/package/src/PdfView.tsx +++ b/package/src/PdfView.tsx @@ -1,4 +1,5 @@ import { + useEffect, useId, useImperativeHandle, useLayoutEffect, @@ -30,7 +31,12 @@ import { OverlayRegistryContext, type OverlayRegistry } from './PdfPageOverlay'; import { buildOutlineTree } from './outline'; import { defaultPrintJobName } from './printJobName'; import { toNativeSource } from './nativeSource'; -import { normalizeSources, sourcesKey, type PdfSource } from './source'; +import { + inspectSources, + sourcesKey, + type NormalizedSource, + type PdfSource, +} from './source'; import type { PdfViewMethods, PdfViewProps as NativeProps, @@ -208,7 +214,7 @@ export interface PdfViewProps extends ViewProps { * forms cannot be filled in, and `print`, `exportDocument`, * `getFormFields` and `setFormFieldValues` reject. */ - source: PdfSource | readonly PdfSource[]; + source?: PdfSource | readonly PdfSource[] | null; /** * Opens password-protected documents: every source without a `password` * of its own, including bundled assets. @@ -494,22 +500,18 @@ function useInitialPosition( ); } +const EMPTY_NORMALIZED_SOURCES: readonly NormalizedSource[] = []; + /** * Keeps the native sources prop referentially stable while its value is * unchanged. Nitro diffs props by JS identity, so without this an inline * `source={{ uri }}` would re-send the source to native on every render. */ function useStableSources( - source: PdfSource | readonly PdfSource[], - password: string | undefined, + sources: readonly NormalizedSource[], ): NativePdfSource[] { - const normalized = normalizeSources( - source, - password, - Image.resolveAssetSource, - ); - const key = sourcesKey(normalized); - const base64 = normalized.map((item) => item.base64); + const key = sourcesKey(sources); + const base64 = sources.map((item) => item.base64); const cached = useRef<{ key: string; base64: (string | undefined)[]; @@ -522,7 +524,7 @@ function useStableSources( cached.current?.key !== key || cached.current.base64.some((text, index) => !Object.is(text, base64[index])) ) { - cached.current = { key, base64, value: normalized.map(toNativeSource) }; + cached.current = { key, base64, value: sources.map(toNativeSource) }; } return cached.current.value; /* eslint-enable react-hooks/refs */ @@ -580,7 +582,12 @@ export function PdfView({ children, ...viewProps }: PdfViewProps) { - const nativeSources = useStableSources(source, password); + const inspection = inspectSources(source, password, Image.resolveAssetSource); + const validSources = + inspection.status === 'valid' + ? inspection.sources + : EMPTY_NORMALIZED_SOURCES; + const nativeSources = useStableSources(validSources); const nativeContentInset = useContentInset(contentInset); const { overlays, overlayRegistry } = useOverlays(); const overlayLayerId = `pdf-overlays${useId()}`; @@ -751,6 +758,34 @@ export function PdfView({ return subscribed.current.has(name) ? nativeCallbacks[name] : undefined; }; + const isInvalidSource = inspection.status === 'invalid'; + const invalidMessage = isInvalidSource ? inspection.message : undefined; + const invalidSourceIndex = isInvalidSource ? inspection.sourceIndex : undefined; + + useEffect(() => { + if (isInvalidSource && invalidMessage !== undefined) { + handlers.current.onError?.({ + code: 'invalid-source', + message: invalidMessage, + sourceIndex: invalidSourceIndex, + }); + } + }, [isInvalidSource, invalidMessage, invalidSourceIndex]); + + useEffect(() => { + if (inspection.status !== 'valid') { + nativeRef.current = null; + } + }, [inspection.status]); + + if (inspection.status !== 'valid') { + return ( + + {children} + + ); + } + // The native view fills a plain View that takes the app's style: Android // ignores `borderRadius` on custom native views, and clipping a scrolling // native view to rounded corners is what `overflow: 'hidden'` does on both diff --git a/package/src/source.test.ts b/package/src/source.test.ts index d5f4e0b..ac8087b 100644 --- a/package/src/source.test.ts +++ b/package/src/source.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from 'bun:test'; import { + inspectSources, normalizeSource, normalizeSources, sourceKey, @@ -199,3 +200,64 @@ describe('sourcesKey', () => { ); }); }); + +describe('invalid and empty sources', () => { + test('normalizeSource does not throw for null, undefined, strings, empty objects or invalid types', () => { + expect(normalizeSource(null, resolveAsset)).toBeNull(); + expect(normalizeSource(undefined, resolveAsset)).toBeNull(); + expect(normalizeSource('https://example.com/a.pdf', resolveAsset)).toBeNull(); + expect(normalizeSource({}, resolveAsset)).toBeNull(); + expect(normalizeSource({ uri: 42 as never }, resolveAsset)).toBeNull(); + }); + + test('normalizeSources does not throw and returns [] for null, undefined or malformed sources', () => { + expect(normalizeSources(null, undefined, resolveAsset)).toEqual([]); + expect(normalizeSources(undefined, undefined, resolveAsset)).toEqual([]); + expect(normalizeSources('https://example.com/a.pdf', undefined, resolveAsset)).toEqual([]); + expect(normalizeSources([null as never], undefined, resolveAsset)).toEqual([]); + }); + + test('inspectSources classifies null and undefined as empty', () => { + expect(inspectSources(null, undefined, resolveAsset)).toEqual({ status: 'empty' }); + expect(inspectSources(undefined, undefined, resolveAsset)).toEqual({ status: 'empty' }); + }); + + test('inspectSources reports malformed single sources as invalid with descriptive message', () => { + expect(inspectSources('https://example.com/a.pdf', undefined, resolveAsset)).toEqual({ + status: 'invalid', + message: + 'source must be a require() result or an object with uri, filePath or base64; got a string', + sourceIndex: undefined, + }); + expect(inspectSources({}, undefined, resolveAsset)).toEqual({ + status: 'invalid', + message: + 'source must be a require() result or an object with uri, filePath or base64; got {}', + sourceIndex: undefined, + }); + expect(inspectSources({ uri: 42 as never }, undefined, resolveAsset)).toEqual({ + status: 'invalid', + message: + 'source must be a require() result or an object with uri, filePath or base64; got { uri: 42 }', + sourceIndex: undefined, + }); + }); + + test('inspectSources reports malformed list items with sourceIndex', () => { + expect(inspectSources([null as never], undefined, resolveAsset)).toEqual({ + status: 'invalid', + message: + 'source must be a require() result or an object with uri, filePath or base64; got null', + sourceIndex: 0, + }); + expect( + inspectSources([{ uri: 'https://x.test/a.pdf' }, 'bad' as never], undefined, resolveAsset), + ).toEqual({ + status: 'invalid', + message: + 'source must be a require() result or an object with uri, filePath or base64; got a string', + sourceIndex: 1, + }); + }); +}); + diff --git a/package/src/source.ts b/package/src/source.ts index b4a54cc..6b678d6 100644 --- a/package/src/source.ts +++ b/package/src/source.ts @@ -106,6 +106,79 @@ export type NormalizedSource = Omit & { base64?: string; }; +function describeSourceType(source: unknown): string { + if (source === null) return 'null'; + if (source === undefined) return 'undefined'; + if (Array.isArray(source)) return 'an array'; + const type = typeof source; + if (type === 'string') return 'a string'; + if (type === 'number') return 'a number'; + if (type === 'boolean') return 'a boolean'; + if (type === 'object') { + const obj = source as Record; + const keys = Object.keys(obj); + if (keys.length === 0) return '{}'; + if ('uri' in obj && typeof obj.uri !== 'string') { + return `{ uri: ${JSON.stringify(obj.uri)} }`; + } + if ('filePath' in obj && typeof obj.filePath !== 'string') { + return `{ filePath: ${JSON.stringify(obj.filePath)} }`; + } + if ('base64' in obj && typeof obj.base64 !== 'string') { + return `{ base64: ${JSON.stringify(obj.base64)} }`; + } + return 'an object without uri, filePath or base64'; + } + return type; +} + +export function validateSourceItem( + source: unknown, + resolveAsset: AssetResolver, +): { ok: true; source: NormalizedSource } | { ok: false; error: string } { + if (typeof source === 'number') { + return { ok: true, source: { uri: resolveAsset(source)?.uri ?? '' } }; + } + if (source == null || typeof source !== 'object' || Array.isArray(source)) { + return { + ok: false, + error: `source must be a require() result or an object with uri, filePath or base64; got ${describeSourceType(source)}`, + }; + } + const obj = source as Record; + if ('filePath' in obj) { + if (typeof obj.filePath === 'string') { + return { ok: true, source: { uri: obj.filePath } }; + } + return { + ok: false, + error: `source must be a require() result or an object with uri, filePath or base64; got ${describeSourceType(source)}`, + }; + } + if ('base64' in obj) { + if (typeof obj.base64 === 'string') { + return { ok: true, source: { uri: '', base64: obj.base64 } }; + } + return { + ok: false, + error: `source must be a require() result or an object with uri, filePath or base64; got ${describeSourceType(source)}`, + }; + } + if ('uri' in obj) { + if (typeof obj.uri === 'string') { + return { ok: true, source: normalizeUriSource(source as PdfUriSource) }; + } + return { + ok: false, + error: `source must be a require() result or an object with uri, filePath or base64; got ${describeSourceType(source)}`, + }; + } + return { + ok: false, + error: `source must be a require() result or an object with uri, filePath or base64; got ${describeSourceType(source)}`, + }; +} + /** * The native shape of a URI source. Copies `headers` so later mutation of * the caller's object cannot leak into native code, and leaves absent @@ -114,6 +187,15 @@ export type NormalizedSource = Omit & { export function normalizeUriSource( source: PdfUriSource, ): Omit { + if ( + source == null || + typeof source !== 'object' || + typeof (source as { uri?: unknown }).uri !== 'string' + ) { + throw new Error( + `source must be an object with uri; got ${describeSourceType(source)}`, + ); + } const normalized: Omit = { uri: source.uri }; if (source.headers != null) normalized.headers = { ...source.headers }; if (source.cache != null) normalized.cache = source.cache; @@ -125,22 +207,67 @@ export function normalizeUriSource( export function normalizeSource( source: PdfSource, resolveAsset: AssetResolver, -): NormalizedSource { - if (typeof source === 'number') { - return { uri: resolveAsset(source)?.uri ?? '' }; - } - if ('filePath' in source) { - return { uri: source.filePath }; - } - if ('base64' in source) { - return { uri: '', base64: source.base64 }; - } - return normalizeUriSource(source); +): NormalizedSource; +export function normalizeSource( + source: unknown, + resolveAsset: AssetResolver, +): NormalizedSource | null; +export function normalizeSource( + source: unknown, + resolveAsset: AssetResolver, +): NormalizedSource | null { + const result = validateSourceItem(source, resolveAsset); + return result.ok ? result.source : null; } /** The source's own password, if it has one (bundled assets cannot). */ -export function sourcePassword(source: PdfSource): string | undefined { - return typeof source === 'number' ? undefined : source.password; +export function sourcePassword(source: unknown): string | undefined { + if (source != null && typeof source === 'object' && 'password' in source) { + const pwd = (source as { password?: unknown }).password; + return typeof pwd === 'string' ? pwd : undefined; + } + return undefined; +} + +export type SourceInspection = + | { status: 'empty' } + | { status: 'valid'; sources: NormalizedSource[] } + | { status: 'invalid'; message: string; sourceIndex?: number }; + +/** + * Validates and inspects `source`, detecting whether it is empty (null/undefined), + * valid, or malformed with an explanatory message. + */ +export function inspectSources( + source: unknown, + password: string | undefined, + resolveAsset: AssetResolver, +): SourceInspection { + if (source == null) { + return { status: 'empty' }; + } + const isList = Array.isArray(source); + const items = isList ? source : [source]; + if (items.length === 0) { + return { status: 'valid', sources: [] }; + } + const normalized: NormalizedSource[] = []; + for (let index = 0; index < items.length; index++) { + const item = items[index]; + const validation = validateSourceItem(item, resolveAsset); + if (!validation.ok) { + return { + status: 'invalid', + message: validation.error, + sourceIndex: isList ? index : undefined, + }; + } + normalized.push({ + ...validation.source, + password: sourcePassword(item) ?? password ?? '', + }); + } + return { status: 'valid', sources: normalized }; } /** @@ -148,24 +275,14 @@ export function sourcePassword(source: PdfSource): string | undefined { * password: its own, else `password` (the prop), else `''` for none. */ export function normalizeSources( - source: PdfSource | readonly PdfSource[], + source: unknown, password: string | undefined, resolveAsset: AssetResolver, ): NormalizedSource[] { - const sources: readonly PdfSource[] = isSourceList(source) - ? source - : [source]; - return sources.map((item) => ({ - ...normalizeSource(item, resolveAsset), - password: sourcePassword(item) ?? password ?? '', - })); + const inspection = inspectSources(source, password, resolveAsset); + return inspection.status === 'valid' ? inspection.sources : []; } -function isSourceList( - source: PdfSource | readonly PdfSource[], -): source is readonly PdfSource[] { - return Array.isArray(source); -} /** * Value identity of a normalized source. Equal keys mean equal values, so From ee391cad4c565a77e036e6719a78b917b023c90d Mon Sep 17 00:00:00 2001 From: caja_autoservicio Date: Tue, 20 Oct 2026 13:17:08 -0300 Subject: [PATCH 2/6] fix: harden invalid source reporting --- package/src/PdfView.tsx | 22 +++++++++++++++------- package/src/source.test.ts | 1 + package/src/source.ts | 28 +++++++++++++++++++++++++--- 3 files changed, 41 insertions(+), 10 deletions(-) diff --git a/package/src/PdfView.tsx b/package/src/PdfView.tsx index d9c194d..c6ca83e 100644 --- a/package/src/PdfView.tsx +++ b/package/src/PdfView.tsx @@ -761,16 +761,24 @@ export function PdfView({ const isInvalidSource = inspection.status === 'invalid'; const invalidMessage = isInvalidSource ? inspection.message : undefined; const invalidSourceIndex = isInvalidSource ? inspection.sourceIndex : undefined; + const invalidSourceKey = isInvalidSource + ? JSON.stringify([invalidMessage, invalidSourceIndex]) + : null; + const reportedInvalidSource = useRef(null); useEffect(() => { - if (isInvalidSource && invalidMessage !== undefined) { - handlers.current.onError?.({ - code: 'invalid-source', - message: invalidMessage, - sourceIndex: invalidSourceIndex, - }); + if (invalidSourceKey === null) { + reportedInvalidSource.current = null; + return; } - }, [isInvalidSource, invalidMessage, invalidSourceIndex]); + if (reportedInvalidSource.current === invalidSourceKey) return; + reportedInvalidSource.current = invalidSourceKey; + handlers.current.onError?.({ + code: 'invalid-source', + message: invalidMessage!, + sourceIndex: invalidSourceIndex, + }); + }, [invalidMessage, invalidSourceIndex, invalidSourceKey]); useEffect(() => { if (inspection.status !== 'valid') { diff --git a/package/src/source.test.ts b/package/src/source.test.ts index ac8087b..e80b858 100644 --- a/package/src/source.test.ts +++ b/package/src/source.test.ts @@ -208,6 +208,7 @@ describe('invalid and empty sources', () => { expect(normalizeSource('https://example.com/a.pdf', resolveAsset)).toBeNull(); expect(normalizeSource({}, resolveAsset)).toBeNull(); expect(normalizeSource({ uri: 42 as never }, resolveAsset)).toBeNull(); + expect(normalizeSource({ uri: 42n } as never, resolveAsset)).toBeNull(); }); test('normalizeSources does not throw and returns [] for null, undefined or malformed sources', () => { diff --git a/package/src/source.ts b/package/src/source.ts index 6b678d6..8b70258 100644 --- a/package/src/source.ts +++ b/package/src/source.ts @@ -106,6 +106,28 @@ export type NormalizedSource = Omit & { base64?: string; }; +function describeValue(value: unknown): string { + if (value === null) return 'null'; + switch (typeof value) { + case 'undefined': + return 'undefined'; + case 'string': + return 'a string'; + case 'number': + return Number.isNaN(value) ? 'NaN' : String(value); + case 'bigint': + return `${value}n`; + case 'symbol': + return value.toString(); + case 'function': + return 'a function'; + case 'object': + return Array.isArray(value) ? 'an array' : 'an object'; + default: + return typeof value; + } +} + function describeSourceType(source: unknown): string { if (source === null) return 'null'; if (source === undefined) return 'undefined'; @@ -119,13 +141,13 @@ function describeSourceType(source: unknown): string { const keys = Object.keys(obj); if (keys.length === 0) return '{}'; if ('uri' in obj && typeof obj.uri !== 'string') { - return `{ uri: ${JSON.stringify(obj.uri)} }`; + return `{ uri: ${describeValue(obj.uri)} }`; } if ('filePath' in obj && typeof obj.filePath !== 'string') { - return `{ filePath: ${JSON.stringify(obj.filePath)} }`; + return `{ filePath: ${describeValue(obj.filePath)} }`; } if ('base64' in obj && typeof obj.base64 !== 'string') { - return `{ base64: ${JSON.stringify(obj.base64)} }`; + return `{ base64: ${describeValue(obj.base64)} }`; } return 'an object without uri, filePath or base64'; } From 17c35dcc8933dd8631f98f852bef696a67442a53 Mon Sep 17 00:00:00 2001 From: caja_autoservicio Date: Tue, 20 Oct 2026 13:47:54 -0300 Subject: [PATCH 3/6] fix: clarify invalid source diagnostics --- docs/api.md | 5 +++++ docs/guides/sources-and-loading.md | 8 ++++++-- package/ios/DocumentSetLoader.swift | 1 + package/src/PdfView.test.tsx | 6 +++--- package/src/source.test.ts | 13 ++++++++++--- package/src/source.ts | 20 +++++++++++++++++--- 6 files changed, 42 insertions(+), 11 deletions(-) diff --git a/docs/api.md b/docs/api.md index da95873..88e11fd 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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 asynchronous methods such as `getOutline`, +`getPageSizes`, `renderPage` and `print` reject with `The PDF view is not mounted`. + `goToPage`, `zoomTo` and `goToViewport` ignore `NaN` and `±Infinity`, e.g. `goToPage(Number(text) - 1)` while a page field holds a letter. A fractional page is rounded to the nearest page on both platforms: `goToPage(1.6)` opens diff --git a/docs/guides/sources-and-loading.md b/docs/guides/sources-and-loading.md index 91d96cc..fbbd885 100644 --- a/docs/guides/sources-and-loading.md +++ b/docs/guides/sources-and-loading.md @@ -32,7 +32,10 @@ break or another control character (tab is fine): such a source fails with `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 reject as they do before mount. +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 asynchronous methods reject with `The PDF view is not mounted`. Setting a valid source afterwards mounts the native viewer and loads the document. Setting the source back to `null` unmounts the native viewer, closing @@ -47,7 +50,8 @@ 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 (including `sourceIndex` when the failure belongs to an item in a list). + 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 diff --git a/package/ios/DocumentSetLoader.swift b/package/ios/DocumentSetLoader.swift index 820930f..bde8a3f 100644 --- a/package/ios/DocumentSetLoader.swift +++ b/package/ios/DocumentSetLoader.swift @@ -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). diff --git a/package/src/PdfView.test.tsx b/package/src/PdfView.test.tsx index 95287d4..5863eaa 100644 --- a/package/src/PdfView.test.tsx +++ b/package/src/PdfView.test.tsx @@ -1004,7 +1004,7 @@ describe('PdfView', () => { code: 'invalid-source', message: 'source must be a require() result or an object with uri, filePath or base64; got a string', - sourceIndex: undefined, + sourceIndex: 0, }, ]); @@ -1021,7 +1021,7 @@ describe('PdfView', () => { code: 'invalid-source', message: 'source must be a require() result or an object with uri, filePath or base64; got {}', - sourceIndex: undefined, + sourceIndex: 0, }, ]); @@ -1038,7 +1038,7 @@ describe('PdfView', () => { code: 'invalid-source', message: 'source must be a require() result or an object with uri, filePath or base64; got { uri: 42 }', - sourceIndex: undefined, + sourceIndex: 0, }, ]); diff --git a/package/src/source.test.ts b/package/src/source.test.ts index e80b858..3987827 100644 --- a/package/src/source.test.ts +++ b/package/src/source.test.ts @@ -3,6 +3,7 @@ import { inspectSources, normalizeSource, normalizeSources, + normalizeUriSource, sourceKey, sourcesKey, } from './source'; @@ -37,6 +38,12 @@ describe('normalizeSource', () => { }); }); + test('describes a filePath when a URI-only source receives one', () => { + expect(() => normalizeUriSource({ filePath: '/a.pdf' } as never)).toThrow( + 'source must be an object with uri; got an object with filePath', + ); + }); + test('resolves required assets', () => { expect(normalizeSource(7, resolveAsset)).toEqual({ uri: 'http://localhost:8081/assets/doc.pdf', @@ -228,19 +235,19 @@ describe('invalid and empty sources', () => { status: 'invalid', message: 'source must be a require() result or an object with uri, filePath or base64; got a string', - sourceIndex: undefined, + sourceIndex: 0, }); expect(inspectSources({}, undefined, resolveAsset)).toEqual({ status: 'invalid', message: 'source must be a require() result or an object with uri, filePath or base64; got {}', - sourceIndex: undefined, + sourceIndex: 0, }); expect(inspectSources({ uri: 42 as never }, undefined, resolveAsset)).toEqual({ status: 'invalid', message: 'source must be a require() result or an object with uri, filePath or base64; got { uri: 42 }', - sourceIndex: undefined, + sourceIndex: 0, }); }); diff --git a/package/src/source.ts b/package/src/source.ts index 8b70258..d35ebbe 100644 --- a/package/src/source.ts +++ b/package/src/source.ts @@ -106,6 +106,7 @@ export type NormalizedSource = Omit & { base64?: string; }; +/** Describes an arbitrary runtime value without invoking unsafe serialization. */ function describeValue(value: unknown): string { if (value === null) return 'null'; switch (typeof value) { @@ -128,7 +129,13 @@ function describeValue(value: unknown): string { } } -function describeSourceType(source: unknown): string { +/** + * Describes the runtime shape of a source for a validation error. + * + * `normalizeUriSource` passes `uriOnly` because that helper accepts only URI + * sources, while the other callers accept `uri`, `filePath`, or `base64`. + */ +function describeSourceType(source: unknown, uriOnly = false): string { if (source === null) return 'null'; if (source === undefined) return 'undefined'; if (Array.isArray(source)) return 'an array'; @@ -149,11 +156,17 @@ function describeSourceType(source: unknown): string { if ('base64' in obj && typeof obj.base64 !== 'string') { return `{ base64: ${describeValue(obj.base64)} }`; } + if (uriOnly) { + if (typeof obj.filePath === 'string') return 'an object with filePath'; + if (typeof obj.base64 === 'string') return 'an object with base64'; + return 'an object without uri'; + } return 'an object without uri, filePath or base64'; } return type; } +/** Validates and normalizes one runtime source value without throwing. */ export function validateSourceItem( source: unknown, resolveAsset: AssetResolver, @@ -215,7 +228,7 @@ export function normalizeUriSource( typeof (source as { uri?: unknown }).uri !== 'string' ) { throw new Error( - `source must be an object with uri; got ${describeSourceType(source)}`, + `source must be an object with uri; got ${describeSourceType(source, true)}`, ); } const normalized: Omit = { uri: source.uri }; @@ -226,6 +239,7 @@ export function normalizeUriSource( return normalized; } +/** Normalizes a source, returning `null` when the runtime value is malformed. */ export function normalizeSource( source: PdfSource, resolveAsset: AssetResolver, @@ -281,7 +295,7 @@ export function inspectSources( return { status: 'invalid', message: validation.error, - sourceIndex: isList ? index : undefined, + sourceIndex: isList ? index : 0, }; } normalized.push({ From e8969ba148cbbee97701bb4fadeca27513584180 Mon Sep 17 00:00:00 2001 From: caja_autoservicio Date: Wed, 21 Oct 2026 09:57:33 -0300 Subject: [PATCH 4/6] fix: keep overlay context when source is empty --- package/src/PdfCache.test.ts | 8 +- package/src/PdfCache.ts | 3 +- package/src/PdfDocument.test.ts | 8 +- package/src/PdfView.test.tsx | 86 ++++++++++++++++-- package/src/PdfView.tsx | 130 ++++++++++++++------------- package/src/source.test.ts | 150 +++++++++++++++++--------------- package/src/source.ts | 33 +------ 7 files changed, 239 insertions(+), 179 deletions(-) diff --git a/package/src/PdfCache.test.ts b/package/src/PdfCache.test.ts index 7ae94fd..79b33f6 100644 --- a/package/src/PdfCache.test.ts +++ b/package/src/PdfCache.test.ts @@ -216,9 +216,13 @@ describe('PdfCache', () => { {}, { uri: 42 }, ]) { - const error = await PdfCache.download(invalid as never).catch((e: unknown) => e); + const error = await PdfCache.download(invalid as never).catch( + (e: unknown) => e, + ); expect(error).toBeInstanceOf(PdfDownloadError); - expect((error as InstanceType).code).toBe('invalid-source'); + expect((error as InstanceType).code).toBe( + 'invalid-source', + ); } }); diff --git a/package/src/PdfCache.ts b/package/src/PdfCache.ts index afc5562..78f5988 100644 --- a/package/src/PdfCache.ts +++ b/package/src/PdfCache.ts @@ -74,8 +74,7 @@ export const PdfCache = { } catch (error) { throw new PdfDownloadError({ code: 'invalid-source', - message: - error instanceof Error ? error.message : 'Invalid URI source', + message: error instanceof Error ? error.message : 'Invalid URI source', }); } const cache = getNativeCache(); diff --git a/package/src/PdfDocument.test.ts b/package/src/PdfDocument.test.ts index cf3086d..85430d6 100644 --- a/package/src/PdfDocument.test.ts +++ b/package/src/PdfDocument.test.ts @@ -156,9 +156,13 @@ describe('PdfDocument.open', () => { {}, { uri: 42 }, ]) { - const error = await PdfDocument.open(invalid as never).catch((e: unknown) => e); + const error = await PdfDocument.open(invalid as never).catch( + (e: unknown) => e, + ); expect(error).toBeInstanceOf(PdfOpenError); - expect((error as InstanceType).code).toBe('invalid-source'); + expect((error as InstanceType).code).toBe( + 'invalid-source', + ); } }); diff --git a/package/src/PdfView.test.tsx b/package/src/PdfView.test.tsx index 5863eaa..ddad71b 100644 --- a/package/src/PdfView.test.tsx +++ b/package/src/PdfView.test.tsx @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, mock, test } from 'bun:test'; -import { createRef, type ReactNode } from 'react'; +import { createRef, useEffect, type ReactNode } from 'react'; import { act, create, type ReactTestRenderer } from 'react-test-renderer'; ( @@ -824,6 +824,81 @@ describe('PdfView', () => { expect(lastHost().overlays).toEqual([{ ...placed[0], page: 2, x: 30 }]); }); + test('PdfPageOverlay does not throw when source is null', () => { + expect(() => + render( + + + , + ), + ).not.toThrow(); + expect(hostRenders).toHaveLength(0); + }); + + test('PdfPageOverlay does not throw for an invalid source and reports invalid-source', () => { + const errors: unknown[] = []; + expect(() => + render( + errors.push(error)}> + + , + ), + ).not.toThrow(); + expect(hostRenders).toHaveLength(0); + expect(errors).toEqual([ + { + code: 'invalid-source', + message: + 'source must be a require() result or an object with uri, filePath or base64; got {}', + sourceIndex: 0, + }, + ]); + }); + + test('an overlay mounted with a null source reaches native when a source arrives', () => { + const overlay = ( + + ); + const renderer = render({overlay}); + expect(hostRenders).toHaveLength(0); + + act(() => + renderer.update( + {overlay}, + ), + ); + const placed = lastHost().overlays as Array<{ id: string }>; + expect(placed.map(({ id: _, ...placement }) => placement)).toEqual([ + { page: 1, x: 2, y: 3, width: 4, height: 5, scalesWithZoom: true }, + ]); + }); + + test('children do not remount when source changes from null to valid', () => { + let mountCount = 0; + function StatefulOverlay() { + useEffect(() => { + mountCount += 1; + }, []); + return ; + } + + const renderer = render( + + + , + ); + expect(mountCount).toBe(1); + + act(() => + renderer.update( + + + , + ), + ); + expect(mountCount).toBe(1); + }); + test('sends no overlays and renders no layer without children', () => { render(); expect(lastHost().overlays).toEqual([]); @@ -982,7 +1057,9 @@ describe('PdfView', () => { act(() => renderer.update()); expect(hostRenders.length).toBe(1); - expect(lastHost().sources).toEqual([{ uri: 'file:///a.pdf', password: '' }]); + expect(lastHost().sources).toEqual([ + { uri: 'file:///a.pdf', password: '' }, + ]); act(() => renderer.update()); const countAfterNull = hostRenders.length; @@ -1010,10 +1087,7 @@ describe('PdfView', () => { const objErrors: unknown[] = []; render( - objErrors.push(err)} - />, + objErrors.push(err)} />, ); expect(hostRenders.length).toBe(0); expect(objErrors).toEqual([ diff --git a/package/src/PdfView.tsx b/package/src/PdfView.tsx index c6ca83e..04519e7 100644 --- a/package/src/PdfView.tsx +++ b/package/src/PdfView.tsx @@ -760,7 +760,9 @@ export function PdfView({ const isInvalidSource = inspection.status === 'invalid'; const invalidMessage = isInvalidSource ? inspection.message : undefined; - const invalidSourceIndex = isInvalidSource ? inspection.sourceIndex : undefined; + const invalidSourceIndex = isInvalidSource + ? inspection.sourceIndex + : undefined; const invalidSourceKey = isInvalidSource ? JSON.stringify([invalidMessage, invalidSourceIndex]) : null; @@ -786,77 +788,73 @@ export function PdfView({ } }, [inspection.status]); - if (inspection.status !== 'valid') { - return ( - - {children} - - ); - } - // The native view fills a plain View that takes the app's style: Android // ignores `borderRadius` on custom native views, and clipping a scrolling // native view to rounded corners is what `overflow: 'hidden'` does on both // platforms. `collapsable={false}`: the box must exist (clip, testID). return ( - + {inspection.status === 'valid' && ( + + )} {children != null && ( // Above the document, passing touches through between overlays; // native code moves the PdfPageOverlays in it with their pages. diff --git a/package/src/source.test.ts b/package/src/source.test.ts index 3987827..e0a90ae 100644 --- a/package/src/source.test.ts +++ b/package/src/source.test.ts @@ -1,39 +1,40 @@ import { describe, expect, test } from 'bun:test'; import { inspectSources, - normalizeSource, - normalizeSources, normalizeUriSource, sourceKey, sourcesKey, + validateSourceItem, } from './source'; const resolveAsset = (asset: number) => asset === 7 ? { uri: 'http://localhost:8081/assets/doc.pdf' } : null; -describe('normalizeSource', () => { +const validatedSource = (source: unknown) => { + const result = validateSourceItem(source, resolveAsset); + if (!result.ok) throw new Error(result.error); + return result.source; +}; + +describe('validateSourceItem', () => { test('passes uri sources through', () => { - expect( - normalizeSource({ uri: 'https://x.test/a.pdf' }, resolveAsset), - ).toEqual({ + expect(validatedSource({ uri: 'https://x.test/a.pdf' })).toEqual({ uri: 'https://x.test/a.pdf', }); }); test('copies headers so later mutation of the caller object cannot leak in', () => { const headers: Record = { Authorization: 'Bearer 1' }; - const normalized = normalizeSource( - { uri: 'https://x.test/a.pdf', headers }, - resolveAsset, - ); + const normalized = validatedSource({ + uri: 'https://x.test/a.pdf', + headers, + }); headers.Authorization = 'Bearer 2'; expect(normalized.headers).toEqual({ Authorization: 'Bearer 1' }); }); test('maps filePath to a plain path uri', () => { - expect( - normalizeSource({ filePath: '/data/doc.pdf' }, resolveAsset), - ).toEqual({ + expect(validatedSource({ filePath: '/data/doc.pdf' })).toEqual({ uri: '/data/doc.pdf', }); }); @@ -45,22 +46,19 @@ describe('normalizeSource', () => { }); test('resolves required assets', () => { - expect(normalizeSource(7, resolveAsset)).toEqual({ + expect(validatedSource(7)).toEqual({ uri: 'http://localhost:8081/assets/doc.pdf', }); }); test('passes cache options through and leaves absent ones absent', () => { expect( - normalizeSource( - { - uri: 'https://x.test/a.pdf', - cache: 'persistent', - maxAgeMs: 0, - cacheKey: 'doc', - }, - resolveAsset, - ), + validatedSource({ + uri: 'https://x.test/a.pdf', + cache: 'persistent', + maxAgeMs: 0, + cacheKey: 'doc', + }), ).toEqual({ uri: 'https://x.test/a.pdf', cache: 'persistent', @@ -68,28 +66,26 @@ describe('normalizeSource', () => { cacheKey: 'doc', }); expect( - Object.keys( - normalizeSource({ uri: 'https://x.test/a.pdf' }, resolveAsset), - ), + Object.keys(validatedSource({ uri: 'https://x.test/a.pdf' })), ).toEqual(['uri']); }); test('passes base64 through with an empty uri, without copying it', () => { const base64 = 'JVBERi0xLjQK'; - const normalized = normalizeSource({ base64 }, resolveAsset); + const normalized = validatedSource({ base64 }); expect(normalized).toEqual({ uri: '', base64 }); expect(Object.keys(normalized)).toEqual(['uri', 'base64']); }); test('passes an empty base64 string on, for native code to reject', () => { - expect(normalizeSource({ base64: '' }, resolveAsset)).toEqual({ + expect(validatedSource({ base64: '' })).toEqual({ uri: '', base64: '', }); }); test('unresolvable assets become an empty uri, which native reports as invalid-source', () => { - expect(normalizeSource(99, resolveAsset)).toEqual({ uri: '' }); + expect(validatedSource(99)).toEqual({ uri: '' }); }); }); @@ -138,20 +134,19 @@ describe('sourceKey', () => { }); }); -describe('normalizeSources', () => { +describe('inspectSources', () => { test('a single source becomes a list of one, with its password', () => { expect( - normalizeSources( - { uri: 'https://x.test/a.pdf' }, - undefined, - resolveAsset, - ), - ).toEqual([{ uri: 'https://x.test/a.pdf', password: '' }]); + inspectSources({ uri: 'https://x.test/a.pdf' }, undefined, resolveAsset), + ).toEqual({ + status: 'valid', + sources: [{ uri: 'https://x.test/a.pdf', password: '' }], + }); }); test("each source keeps its own password, else takes the prop's", () => { expect( - normalizeSources( + inspectSources( [ { uri: 'a', password: 'own' }, { filePath: '/b', password: '' }, @@ -161,17 +156,20 @@ describe('normalizeSources', () => { 'prop', resolveAsset, ), - ).toEqual([ - { uri: 'a', password: 'own' }, - { uri: '/b', password: '' }, - { uri: 'c', password: 'prop' }, - { uri: 'http://localhost:8081/assets/doc.pdf', password: 'prop' }, - ]); + ).toEqual({ + status: 'valid', + sources: [ + { uri: 'a', password: 'own' }, + { uri: '/b', password: '' }, + { uri: 'c', password: 'prop' }, + { uri: 'http://localhost:8081/assets/doc.pdf', password: 'prop' }, + ], + }); }); test('a base64 source keeps its text and its own password', () => { expect( - normalizeSources( + inspectSources( [ { base64: 'JVBERi0xLjQK', password: 'own' }, { base64: 'JVBERi0xLjcK' }, @@ -179,14 +177,20 @@ describe('normalizeSources', () => { 'prop', resolveAsset, ), - ).toEqual([ - { uri: '', base64: 'JVBERi0xLjQK', password: 'own' }, - { uri: '', base64: 'JVBERi0xLjcK', password: 'prop' }, - ]); + ).toEqual({ + status: 'valid', + sources: [ + { uri: '', base64: 'JVBERi0xLjQK', password: 'own' }, + { uri: '', base64: 'JVBERi0xLjcK', password: 'prop' }, + ], + }); }); test('an empty list stays empty (native reports invalid-source)', () => { - expect(normalizeSources([], 'prop', resolveAsset)).toEqual([]); + expect(inspectSources([], 'prop', resolveAsset)).toEqual({ + status: 'valid', + sources: [], + }); }); }); @@ -209,29 +213,32 @@ describe('sourcesKey', () => { }); describe('invalid and empty sources', () => { - test('normalizeSource does not throw for null, undefined, strings, empty objects or invalid types', () => { - expect(normalizeSource(null, resolveAsset)).toBeNull(); - expect(normalizeSource(undefined, resolveAsset)).toBeNull(); - expect(normalizeSource('https://example.com/a.pdf', resolveAsset)).toBeNull(); - expect(normalizeSource({}, resolveAsset)).toBeNull(); - expect(normalizeSource({ uri: 42 as never }, resolveAsset)).toBeNull(); - expect(normalizeSource({ uri: 42n } as never, resolveAsset)).toBeNull(); - }); - - test('normalizeSources does not throw and returns [] for null, undefined or malformed sources', () => { - expect(normalizeSources(null, undefined, resolveAsset)).toEqual([]); - expect(normalizeSources(undefined, undefined, resolveAsset)).toEqual([]); - expect(normalizeSources('https://example.com/a.pdf', undefined, resolveAsset)).toEqual([]); - expect(normalizeSources([null as never], undefined, resolveAsset)).toEqual([]); + test('validateSourceItem reports malformed values without throwing', () => { + for (const source of [ + null, + undefined, + 'https://example.com/a.pdf', + {}, + { uri: 42 as never }, + { uri: 42n } as never, + ]) { + expect(validateSourceItem(source, resolveAsset).ok).toBe(false); + } }); test('inspectSources classifies null and undefined as empty', () => { - expect(inspectSources(null, undefined, resolveAsset)).toEqual({ status: 'empty' }); - expect(inspectSources(undefined, undefined, resolveAsset)).toEqual({ status: 'empty' }); + expect(inspectSources(null, undefined, resolveAsset)).toEqual({ + status: 'empty', + }); + expect(inspectSources(undefined, undefined, resolveAsset)).toEqual({ + status: 'empty', + }); }); test('inspectSources reports malformed single sources as invalid with descriptive message', () => { - expect(inspectSources('https://example.com/a.pdf', undefined, resolveAsset)).toEqual({ + expect( + inspectSources('https://example.com/a.pdf', undefined, resolveAsset), + ).toEqual({ status: 'invalid', message: 'source must be a require() result or an object with uri, filePath or base64; got a string', @@ -243,7 +250,9 @@ describe('invalid and empty sources', () => { 'source must be a require() result or an object with uri, filePath or base64; got {}', sourceIndex: 0, }); - expect(inspectSources({ uri: 42 as never }, undefined, resolveAsset)).toEqual({ + expect( + inspectSources({ uri: 42 as never }, undefined, resolveAsset), + ).toEqual({ status: 'invalid', message: 'source must be a require() result or an object with uri, filePath or base64; got { uri: 42 }', @@ -259,7 +268,11 @@ describe('invalid and empty sources', () => { sourceIndex: 0, }); expect( - inspectSources([{ uri: 'https://x.test/a.pdf' }, 'bad' as never], undefined, resolveAsset), + inspectSources( + [{ uri: 'https://x.test/a.pdf' }, 'bad' as never], + undefined, + resolveAsset, + ), ).toEqual({ status: 'invalid', message: @@ -268,4 +281,3 @@ describe('invalid and empty sources', () => { }); }); }); - diff --git a/package/src/source.ts b/package/src/source.ts index d35ebbe..36478b7 100644 --- a/package/src/source.ts +++ b/package/src/source.ts @@ -239,23 +239,6 @@ export function normalizeUriSource( return normalized; } -/** Normalizes a source, returning `null` when the runtime value is malformed. */ -export function normalizeSource( - source: PdfSource, - resolveAsset: AssetResolver, -): NormalizedSource; -export function normalizeSource( - source: unknown, - resolveAsset: AssetResolver, -): NormalizedSource | null; -export function normalizeSource( - source: unknown, - resolveAsset: AssetResolver, -): NormalizedSource | null { - const result = validateSourceItem(source, resolveAsset); - return result.ok ? result.source : null; -} - /** The source's own password, if it has one (bundled assets cannot). */ export function sourcePassword(source: unknown): string | undefined { if (source != null && typeof source === 'object' && 'password' in source) { @@ -295,7 +278,7 @@ export function inspectSources( return { status: 'invalid', message: validation.error, - sourceIndex: isList ? index : 0, + sourceIndex: index, }; } normalized.push({ @@ -306,20 +289,6 @@ export function inspectSources( return { status: 'valid', sources: normalized }; } -/** - * The native sources of `PdfView`'s `source`, one or several, each with its - * password: its own, else `password` (the prop), else `''` for none. - */ -export function normalizeSources( - source: unknown, - password: string | undefined, - resolveAsset: AssetResolver, -): NormalizedSource[] { - const inspection = inspectSources(source, password, resolveAsset); - return inspection.status === 'valid' ? inspection.sources : []; -} - - /** * Value identity of a normalized source. Equal keys mean equal values, so * the native view never receives the same source twice. (Whether a changed From d518fca71247cbc22a66b41b479d103a800743e8 Mon Sep 17 00:00:00 2001 From: caja_autoservicio Date: Wed, 21 Oct 2026 09:58:12 -0300 Subject: [PATCH 5/6] docs: mark source as optional and nullable in api-design --- docs/api-design.md | 2 +- docs/performance/audit.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/api-design.md b/docs/api-design.md index 652a37f..2775110 100644 --- a/docs/api-design.md +++ b/docs/api-design.md @@ -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`. | diff --git a/docs/performance/audit.md b/docs/performance/audit.md index e20f94e..ff7dbf3 100644 --- a/docs/performance/audit.md +++ b/docs/performance/audit.md @@ -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 | @@ -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): From 446b84b0633b468ddd20a6e6820e686f6c78f495 Mon Sep 17 00:00:00 2001 From: Jakub Kasprzyk Date: Wed, 21 Oct 2026 16:50:44 +0200 Subject: [PATCH 6/6] docs: name the not-loaded code for an unmounted PdfView Since #73 the ref's promise methods reject with a PdfMethodError, and apps match on its code rather than the message. --- docs/api.md | 7 ++++--- docs/guides/sources-and-loading.md | 3 ++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/api.md b/docs/api.md index e2a6555..9e8391d 100644 --- a/docs/api.md +++ b/docs/api.md @@ -76,9 +76,10 @@ each page. The viewer adds no safe-area insets on either platform, like | `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 asynchronous methods such as `getOutline`, -`getPageSizes`, `renderPage` and `print` reject with `The PDF view is not mounted`. +`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). diff --git a/docs/guides/sources-and-loading.md b/docs/guides/sources-and-loading.md index 64bc69f..8bf7d3a 100644 --- a/docs/guides/sources-and-loading.md +++ b/docs/guides/sources-and-loading.md @@ -35,7 +35,8 @@ 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 asynchronous methods reject with `The PDF view is not mounted`. +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