Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions src/hooks/useRive.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export default function useRive(
): RiveState {
const [canvasElem, setCanvasElem] = useState<HTMLCanvasElement | null>(null);
const containerRef = useRef<HTMLElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const riveRef = useRef<Rive | null>(null);

const [rive, setRive] = useState<Rive | null>(null);
Expand Down Expand Up @@ -153,11 +154,17 @@ export default function useRive(
*/
const setCanvasRef: RefCallback<HTMLCanvasElement> = 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);
},
[]
Expand Down Expand Up @@ -264,6 +271,7 @@ export default function useRive(
observe(canvasElem, onChange);
}
return () => {
clearTimeout(timeoutId);
if (canvasElem) {
unobserve(canvasElem);
}
Expand Down
54 changes: 54 additions & 0 deletions test/useRive.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <RiveComponent />;
}

const { unmount } = render(<Harness />);
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',
Expand Down
90 changes: 90 additions & 0 deletions test/useRiveIntersection.test.tsx
Original file line number Diff line number Diff line change
@@ -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<rive.Rive>;
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 <RiveComponent />;
}

const { unmount } = render(<Harness />);
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
);
});
});
Loading