diff --git a/docs/api-design.md b/docs/api-design.md index 5db6b8f..fecf905 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/api.md b/docs/api.md index 9f600d6..9e8391d 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 | @@ -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). diff --git a/docs/guides/sources-and-loading.md b/docs/guides/sources-and-loading.md index 8dcfe9d..8bf7d3a 100644 --- a/docs/guides/sources-and-loading.md +++ b/docs/guides/sources-and-loading.md @@ -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: 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): 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 a16c7a4..f744d05 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..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). @@ -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 } diff --git a/package/src/PdfCache.test.ts b/package/src/PdfCache.test.ts index b9b9d01..79b33f6 100644 --- a/package/src/PdfCache.test.ts +++ b/package/src/PdfCache.test.ts @@ -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).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..78f5988 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,21 @@ 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 +96,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 +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 { + 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 8176a23..33286e0 100644 --- a/package/src/PdfDocument.test.ts +++ b/package/src/PdfDocument.test.ts @@ -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).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 6689288..62f3a15 100644 --- a/package/src/PdfDocument.ts +++ b/package/src/PdfDocument.ts @@ -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'; @@ -74,12 +74,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 efd9bfa..3829b10 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'; ( @@ -852,6 +852,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([]); @@ -977,6 +1052,116 @@ 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: 0, + }, + ]); + + 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: 0, + }, + ]); + + 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: 0, + }, + ]); + + 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, + }, + ]); + }); + test('promise methods reject with coded PdfMethodErrors', async () => { const ref = createRef(); render(); diff --git a/package/src/PdfView.tsx b/package/src/PdfView.tsx index 546c8d1..7608cc3 100644 --- a/package/src/PdfView.tsx +++ b/package/src/PdfView.tsx @@ -1,4 +1,5 @@ import { + useEffect, useId, useImperativeHandle, useLayoutEffect, @@ -32,7 +33,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, @@ -214,7 +220,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. @@ -513,22 +519,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)[]; @@ -541,7 +543,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 */ @@ -599,7 +601,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()}`; @@ -771,69 +778,103 @@ 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; + const invalidSourceKey = isInvalidSource + ? JSON.stringify([invalidMessage, invalidSourceIndex]) + : null; + const reportedInvalidSource = useRef(null); + + useEffect(() => { + if (invalidSourceKey === null) { + reportedInvalidSource.current = null; + return; + } + 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') { + nativeRef.current = null; + } + }, [inspection.status]); + // 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 d5f4e0b..e0a90ae 100644 --- a/package/src/source.test.ts +++ b/package/src/source.test.ts @@ -1,58 +1,64 @@ import { describe, expect, test } from 'bun:test'; import { - normalizeSource, - normalizeSources, + inspectSources, + 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', }); }); + 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({ + 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', @@ -60,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: '' }); }); }); @@ -130,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: '' }, @@ -153,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' }, @@ -171,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: [], + }); }); }); @@ -199,3 +211,73 @@ describe('sourcesKey', () => { ); }); }); + +describe('invalid and empty sources', () => { + 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', + }); + }); + + 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: 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: 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: 0, + }); + }); + + 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 97f1498..6843cc5 100644 --- a/package/src/source.ts +++ b/package/src/source.ts @@ -110,6 +110,114 @@ 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) { + 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; + } +} + +/** + * 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'; + 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: ${describeValue(obj.uri)} }`; + } + if ('filePath' in obj && typeof obj.filePath !== 'string') { + return `{ filePath: ${describeValue(obj.filePath)} }`; + } + 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, +): { 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 @@ -118,6 +226,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, true)}`, + ); + } const normalized: Omit = { uri: source.uri }; if (source.headers != null) normalized.headers = { ...source.headers }; if (source.cache != null) normalized.cache = source.cache; @@ -126,49 +243,54 @@ export function normalizeUriSource( return normalized; } -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 }; +/** 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) { + const pwd = (source as { password?: unknown }).password; + return typeof pwd === 'string' ? pwd : undefined; } - return normalizeUriSource(source); + return undefined; } -/** 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 type SourceInspection = + | { status: 'empty' } + | { status: 'valid'; sources: NormalizedSource[] } + | { status: 'invalid'; message: string; sourceIndex?: number }; /** - * The native sources of `PdfView`'s `source`, one or several, each with its - * password: its own, else `password` (the prop), else `''` for none. + * Validates and inspects `source`, detecting whether it is empty (null/undefined), + * valid, or malformed with an explanatory message. */ -export function normalizeSources( - source: PdfSource | readonly PdfSource[], +export function inspectSources( + 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 ?? '', - })); -} - -function isSourceList( - source: PdfSource | readonly PdfSource[], -): source is readonly PdfSource[] { - return Array.isArray(source); +): 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: index, + }; + } + normalized.push({ + ...validation.source, + password: sourcePassword(item) ?? password ?? '', + }); + } + return { status: 'valid', sources: normalized }; } /**