diff --git a/packages/react-pdf/package.json b/packages/react-pdf/package.json index 1fed6582f..558d69b4f 100644 --- a/packages/react-pdf/package.json +++ b/packages/react-pdf/package.json @@ -56,6 +56,7 @@ "license": "MIT", "dependencies": { "clsx": "^2.0.0", + "dequal": "^2.0.3", "make-cancellable-promise": "^1.3.1", "make-event-props": "^1.6.0", "merge-refs": "^1.2.1", diff --git a/packages/react-pdf/src/Document.spec.tsx b/packages/react-pdf/src/Document.spec.tsx index 30012b09d..b2223a148 100644 --- a/packages/react-pdf/src/Document.spec.tsx +++ b/packages/react-pdf/src/Document.spec.tsx @@ -604,4 +604,92 @@ describe('Document', () => { expect(onTouchStart).toHaveBeenCalled(); }); + + it('does not warn if file prop was memoized', () => { + const spy = vi.spyOn(global.console, 'error').mockImplementation(() => { + // Intentionally empty + }); + + const file = { data: pdfFile.arrayBuffer }; + + const { rerender } = render(); + + rerender(); + + expect(spy).not.toHaveBeenCalled(); + + vi.mocked(global.console.error).mockRestore(); + }); + + it('warns if file prop was not memoized', () => { + const spy = vi.spyOn(global.console, 'error').mockImplementation(() => { + // Intentionally empty + }); + + const { rerender } = render(); + + rerender(); + + expect(spy).toHaveBeenCalledTimes(1); + + vi.mocked(global.console.error).mockRestore(); + }); + + it('does not warn if file prop was not memoized, but was changed', () => { + const spy = vi.spyOn(global.console, 'error').mockImplementation(() => { + // Intentionally empty + }); + + const { rerender } = render(); + + rerender(); + + expect(spy).not.toHaveBeenCalled(); + + vi.mocked(global.console.error).mockRestore(); + }); + + it('does not warn if options prop was memoized', () => { + const spy = vi.spyOn(global.console, 'error').mockImplementation(() => { + // Intentionally empty + }); + + const options = {}; + + const { rerender } = render(); + + rerender(); + + expect(spy).not.toHaveBeenCalled(); + + vi.mocked(global.console.error).mockRestore(); + }); + + it('warns if options prop was not memoized', () => { + const spy = vi.spyOn(global.console, 'error').mockImplementation(() => { + // Intentionally empty + }); + + const { rerender } = render(); + + rerender(); + + expect(spy).toHaveBeenCalledTimes(1); + + vi.mocked(global.console.error).mockRestore(); + }); + + it('does not warn if options prop was not memoized, but was changed', () => { + const spy = vi.spyOn(global.console, 'error').mockImplementation(() => { + // Intentionally empty + }); + + const { rerender } = render(); + + rerender(); + + expect(spy).not.toHaveBeenCalled(); + + vi.mocked(global.console.error).mockRestore(); + }); }); diff --git a/packages/react-pdf/src/Document.tsx b/packages/react-pdf/src/Document.tsx index 09dd4d5e1..0116d681c 100644 --- a/packages/react-pdf/src/Document.tsx +++ b/packages/react-pdf/src/Document.tsx @@ -14,6 +14,7 @@ import makeCancellable from 'make-cancellable-promise'; import clsx from 'clsx'; import invariant from 'tiny-invariant'; import warning from 'warning'; +import { dequal } from 'dequal'; import pdfjs from './pdfjs.js'; import DocumentContext from './DocumentContext.js'; @@ -233,6 +234,14 @@ const defaultOnPassword: OnPassword = (callback, reason) => { } }; +function isParameterObject(file: File): file is Source { + return ( + typeof file === 'object' && + file !== null && + ('data' in file || 'range' in file || 'url' in file) + ); +} + /** * Loads a document passed using `file` prop. */ @@ -271,6 +280,32 @@ const Document = forwardRef(function Document( const pages = useRef([]); + const prevFile = useRef(); + const prevOptions = useRef(); + + useEffect(() => { + if (file && file !== prevFile.current && isParameterObject(file)) { + warning( + !dequal(file, prevFile.current), + `File prop passed to changed, but it's equal to previous one. This might result in unnecessary reloads. Consider memoizing the value passed to "file" prop.`, + ); + + prevFile.current = file; + } + }, [file]); + + // Detect non-memoized changes in options prop + useEffect(() => { + if (options && options !== prevOptions.current) { + warning( + !dequal(options, prevOptions.current), + `Options prop passed to changed, but it's equal to previous one. This might result in unnecessary reloads. Consider memoizing the value passed to "options" prop.`, + ); + + prevOptions.current = options; + } + }, [options]); + const viewer = useRef({ // Handling jumping to internal links target scrollPageIntoView: (args: ScrollPageIntoViewArgs) => { @@ -385,7 +420,7 @@ const Document = forwardRef(function Document( ); invariant( - 'data' in file || 'range' in file || 'url' in file, + isParameterObject(file), 'Invalid parameter object: need either .data, .range or .url', ); diff --git a/yarn.lock b/yarn.lock index 900c15b27..8fa1fd42a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4528,6 +4528,7 @@ __metadata: canvas: "npm:^2.11.2" clsx: "npm:^2.0.0" cpy-cli: "npm:^5.0.0" + dequal: "npm:^2.0.3" eslint: "npm:^8.26.0" eslint-config-wojtekmaj: "npm:^0.9.0" jsdom: "npm:^21.1.0"