diff --git a/src/hooks/useRive.tsx b/src/hooks/useRive.tsx index 3627513..8049b4f 100644 --- a/src/hooks/useRive.tsx +++ b/src/hooks/useRive.tsx @@ -69,6 +69,7 @@ export default function useRive( ): RiveState { const [canvasElem, setCanvasElem] = useState(null); const containerRef = useRef(null); + const canvasRef = useRef(null); const riveRef = useRef(null); const [rive, setRive] = useState(null); @@ -153,11 +154,17 @@ export default function useRive( */ const setCanvasRef: RefCallback = useCallback( (canvas: HTMLCanvasElement | null) => { - if (canvas === null && canvasElem) { - canvasElem.height = 0; - canvasElem.width = 0; + // Safari releases a canvas' backing store when the element is + // collected, which can lag well behind unmount. Zeroing the dimensions + // frees it synchronously. Read the previous canvas from a ref, because + // canvasElem is never updated in this callback + const previousCanvas = canvasRef.current; + if (canvas === null && previousCanvas) { + previousCanvas.height = 0; + previousCanvas.width = 0; } + canvasRef.current = canvas; setCanvasElem(canvas); }, [] @@ -264,6 +271,7 @@ export default function useRive( observe(canvasElem, onChange); } return () => { + clearTimeout(timeoutId); if (canvasElem) { unobserve(canvasElem); } diff --git a/test/useRive.test.tsx b/test/useRive.test.tsx index b81aa26..06512cc 100644 --- a/test/useRive.test.tsx +++ b/test/useRive.test.tsx @@ -431,6 +431,60 @@ describe('useRive', () => { expect(container.firstChild).not.toHaveStyle('width: 50%'); }); + it('releases the canvas backing store when the component unmounts', async () => { + // @ts-ignore + mocked(rive.Rive).mockImplementation(() => baseRiveMock); + + let captured: HTMLCanvasElement | null = null; + function Harness() { + const { RiveComponent, canvas } = useRive({ src: 'file-src' }); + React.useEffect(() => { + if (canvas) captured = canvas; + }, [canvas]); + return ; + } + + const { unmount } = render(); + await waitFor(() => expect(captured).not.toBeNull()); + + await act(async () => { + captured!.width = 800; + captured!.height = 600; + controlledRiveloadCb(); + }); + expect(captured!.width).toBe(800); + expect(captured!.height).toBe(600); + + unmount(); + + expect(captured!.width).toBe(0); + expect(captured!.height).toBe(0); + }); + + it('keeps setCanvasRef referentially stable across renders', async () => { + const params = { src: 'file-src' }; + + // @ts-ignore + mocked(rive.Rive).mockImplementation(() => baseRiveMock); + + const canvasSpy = document.createElement('canvas'); + const { result } = renderHook(() => useRive(params)); + const initialSetCanvasRef = result.current.setCanvasRef; + + await act(async () => { + result.current.setCanvasRef(canvasSpy); + }); + await waitFor(() => expect(result.current.canvas).toBe(canvasSpy)); + expect(result.current.setCanvasRef).toBe(initialSetCanvasRef); + + // Loading sets `rive` state, re-rendering the hook. + await act(async () => { + controlledRiveloadCb(); + }); + await waitFor(() => expect(result.current.rive).not.toBeNull()); + expect(result.current.setCanvasRef).toBe(initialSetCanvasRef); + }); + it('has a canvas size of 0 by default', async () => { const params = { src: 'file-src', diff --git a/test/useRiveIntersection.test.tsx b/test/useRiveIntersection.test.tsx new file mode 100644 index 0000000..3a653a8 --- /dev/null +++ b/test/useRiveIntersection.test.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { mocked } from 'jest-mock'; +import { act, render, waitFor } from '@testing-library/react'; + +import useRive from '../src/hooks/useRive'; +import * as rive from '@rive-app/canvas'; +import useIntersectionObserver from '../src/hooks/useIntersectionObserver'; + +// Capture the callback useRive registers, so the test can drive intersection +// changes directly. The real ElementObserver is a module-level singleton around +// a browser IntersectionObserver, which never fires under jsdom. +let onChange: ((entry: unknown) => void) | null = null; +const unobserve = jest.fn(); + +jest.mock('../src/hooks/useIntersectionObserver', () => ({ + __esModule: true, + default: jest.fn(), +})); + +describe('useRive intersection observer teardown', () => { + let riveMock: Partial; + let loadCb: (() => void) | null = null; + + beforeEach(() => { + jest.useFakeTimers(); + onChange = null; + unobserve.mockClear(); + + mocked(useIntersectionObserver).mockReturnValue({ + observe: (_element: Element, callback: Function) => { + onChange = callback as (entry: unknown) => void; + }, + unobserve, + }); + + riveMock = { + on: (_: rive.EventType, cb: rive.EventCallback) => { + loadCb = cb as () => void; + }, + stop: jest.fn(), + stopRendering: jest.fn(), + startRendering: jest.fn(), + cleanup: jest.fn(), + resizeToCanvas: jest.fn(), + }; + // @ts-ignore + mocked(rive.Rive).mockImplementation(() => riveMock); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('does not fire the offscreen retest after unmount', async () => { + let captured: HTMLCanvasElement | null = null; + function Harness() { + const { RiveComponent, canvas } = useRive({ src: 'file-src' }); + React.useEffect(() => { + if (canvas) captured = canvas; + }, [canvas]); + return ; + } + + const { unmount } = render(); + await waitFor(() => expect(captured).not.toBeNull()); + await act(async () => { + loadCb!(); + }); + await waitFor(() => expect(onChange).not.toBeNull()); + + captured!.getBoundingClientRect = () => + ({ width: 100, height: 100, top: 0, bottom: 100, left: 0, right: 100 } as DOMRect); + + // Going offscreen with a zero-width rect is what schedules the 10ms retest. + act(() => { + onChange!({ isIntersecting: false, boundingClientRect: { width: 0 } }); + }); + + const callsBeforeUnmount = mocked(riveMock.startRendering!).mock.calls.length; + unmount(); + + act(() => { + jest.advanceTimersByTime(50); + }); + + expect(mocked(riveMock.startRendering!).mock.calls.length).toBe( + callsBeforeUnmount + ); + }); +});