diff --git a/src/__test__/index.test.tsx b/src/__test__/index.test.tsx index 24687c1..877319f 100644 --- a/src/__test__/index.test.tsx +++ b/src/__test__/index.test.tsx @@ -1,6 +1,7 @@ import React from 'react'; import {QRCodeSVG, QRCodeCanvas} from '..'; -import {describe, expect, test} from '@jest/globals'; +import type {QRCodeCanvasUpdateAPIs} from '..'; +import {describe, expect, test, jest, beforeAll, afterAll} from '@jest/globals'; import {render} from '@testing-library/react'; import '@testing-library/jest-dom'; @@ -170,3 +171,60 @@ describe('`style` is passed to rendered nodes and merged correctly', () => { expect(container.firstChild).toMatchSnapshot(); }); }); + +describe('onUpdate prop', () => { + beforeAll(() => { + jest.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ + fillRect: jest.fn(), + scale: jest.fn(), + drawImage: jest.fn(), + } as unknown as CanvasRenderingContext2D); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + test('calls onUpdate with canvas APIs', () => { + const onUpdateMock = jest.fn(); + render(); + expect(onUpdateMock).toHaveBeenCalledTimes(1); + expect(onUpdateMock).toHaveBeenCalledWith( + expect.objectContaining({ + toDataURL: expect.any(Function), + toBlob: expect.any(Function), + }) + ); + }); + + test('keeps api reference stable on rerenders to prevent infinite loops', () => { + const onUpdateMock = jest.fn(); + const {rerender} = render( + + ); + + expect(onUpdateMock).toHaveBeenCalledTimes(1); + const firstApis = onUpdateMock.mock.calls[0][0] as QRCodeCanvasUpdateAPIs; + + // Rerender with different props (e.g. value changes) + rerender( + + ); + + expect(onUpdateMock).toHaveBeenCalledTimes(2); + const secondApis = onUpdateMock.mock.calls[1][0] as QRCodeCanvasUpdateAPIs; + + // The API object reference must remain identical + expect(firstApis).toBe(secondApis); + expect(firstApis.toDataURL).toBe(secondApis.toDataURL); + expect(firstApis.toBlob).toBe(secondApis.toBlob); + + // Verify calling toDataURL works and returns a string + const dataUrl = firstApis.toDataURL(); + expect(typeof dataUrl).toBe('string'); + }); +}); diff --git a/src/index.tsx b/src/index.tsx index b6dd37a..8791949 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -135,7 +135,14 @@ type QRProps = { */ imageSettings?: ImageSettings; }; -type QRPropsCanvas = QRProps & React.CanvasHTMLAttributes; +export type QRCodeCanvasUpdateAPIs = { + toDataURL: HTMLCanvasElement['toDataURL']; + toBlob: HTMLCanvasElement['toBlob']; +}; +type QRPropsCanvas = QRProps & + React.CanvasHTMLAttributes & { + onUpdate?: (apis: QRCodeCanvasUpdateAPIs) => void; + }; type QRPropsSVG = QRProps & React.SVGAttributes; const DEFAULT_SIZE = 128; @@ -358,6 +365,7 @@ const QRCodeCanvas = React.forwardRef( boostLevel, marginSize, imageSettings, + onUpdate, ...extraProps } = props; const {style, ...otherProps} = extraProps; @@ -365,6 +373,22 @@ const QRCodeCanvas = React.forwardRef( const _canvas = React.useRef(null); const _image = React.useRef(null); + const apis = React.useMemo(() => { + return { + toDataURL: (type?: string, quality?: number) => { + return _canvas.current?.toDataURL(type, quality) ?? ''; + }, + toBlob: (callback: BlobCallback, type?: string, quality?: number) => { + _canvas.current?.toBlob(callback, type, quality); + }, + }; + }, []); + + const onUpdateRef = React.useRef(onUpdate); + React.useEffect(() => { + onUpdateRef.current = onUpdate; + }); + // Set the local ref (_canvas) and also the forwarded ref from outside const setCanvasRef = React.useCallback( (node: HTMLCanvasElement | null) => { @@ -464,6 +488,10 @@ const QRCodeCanvas = React.forwardRef( calculatedImageSettings.h ); } + + if (onUpdateRef.current) { + onUpdateRef.current(apis); + } } });