Skip to content

Commit

Permalink
fix observer and use single instance
Browse files Browse the repository at this point in the history
  • Loading branch information
bodymovin committed May 25, 2024
1 parent 8bb5652 commit db81877
Show file tree
Hide file tree
Showing 7 changed files with 207 additions and 57 deletions.
38 changes: 38 additions & 0 deletions src/hooks/elementObserver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
class FakeIntersectionObserver {
observe() {}
unobserve() {}
disconnect() {}
}

const MyIntersectionObserver =
globalThis.IntersectionObserver || FakeIntersectionObserver;

class ElementObserver {
private observer;

private elementsMap: Map<Element, Function> = new Map();

constructor() {
this.observer = new MyIntersectionObserver(this.onObserved);
}
public onObserved = (entries: IntersectionObserverEntry[]) => {
entries.forEach((entry) => {
const elementCallback = this.elementsMap.get(entry.target as Element);
if (elementCallback) {
elementCallback(entry);
}
});
};

public registerCallback(element: Element, callback: Function) {
this.observer.observe(element);
this.elementsMap.set(element, callback);
}

public removeCallback(element: Element) {
this.observer.unobserve(element);
this.elementsMap.delete(element);
}
}

export default ElementObserver;
35 changes: 35 additions & 0 deletions src/hooks/useIntersectionObserver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { useCallback } from 'react';
import ElementObserver from './elementObserver';

let observer: ElementObserver;
const getObserver = () => {
if(!observer) {
observer = new ElementObserver();
}
return observer;
}

/**
* Hook to listen for a ref element's resize events being triggered. When resized,
* it sets state to an object of {width: number, height: number} indicating the contentRect
* size of the element at the new resize.
*
* @param containerRef - Ref element to listen for resize events on
* @returns - Size object with width and height attributes
*/
export default function useIntersectionObserver() {
const observe = useCallback((element: Element, callback: Function) => {
const observer = getObserver();
observer.registerCallback(element, callback);
}, []);

const unobserve = useCallback((element: Element) => {
const observer = getObserver();
observer.removeCallback(element);
}, []);

return {
observe,
unobserve,
};
}
63 changes: 52 additions & 11 deletions src/hooks/useRive.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { Rive, EventType } from '@rive-app/canvas';
import { UseRiveParameters, UseRiveOptions, RiveState } from '../types';
import useResizeCanvas from './useResizeCanvas';
import { getOptions } from '../utils';
import useIntersectionObserver from './useIntersectionObserver';

type RiveComponentProps = {
setContainerRef: RefCallback<HTMLElement>;
Expand Down Expand Up @@ -99,7 +100,6 @@ export default function useRive(
*/
const setCanvasRef: RefCallback<HTMLCanvasElement> = useCallback(
(canvas: HTMLCanvasElement | null) => {

if (canvas === null && canvasElem) {
canvasElem.height = 0;
canvasElem.width = 0;
Expand All @@ -111,7 +111,7 @@ export default function useRive(
);

useEffect(() => {
if(!canvasElem || !riveParams) {
if (!canvasElem || !riveParams) {
return;
}
if (rive == null) {
Expand All @@ -132,7 +132,7 @@ export default function useRive(
}
});
}
},[canvasElem, isParamsLoaded, rive]);
}, [canvasElem, isParamsLoaded, rive]);
/**
* Ref callback called when the container element mounts
*/
Expand All @@ -147,21 +147,62 @@ export default function useRive(
* Set up IntersectionObserver to stop rendering if the animation is not in
* view.
*/
const { observe, unobserve } = useIntersectionObserver();

useEffect(() => {
const observer = new IntersectionObserver(([entry]) => {
let timeoutId: ReturnType<typeof setTimeout>;
let isPaused = false;
// This is a workaround to retest whether an element is offscreen or not.
// There seems to be a bug in Chrome that triggers an intersection change when an element
// is moved within the DOM using insertBefore.
// For some reason, when this is called whithin the context of a React application, the
// intersection callback is called only once reporting isIntersecting as false but never
// triggered back with isIntersecting as true.
// For this reason we retest after 10 millisecond whether the element is actually off the
// viewport or not.
const retestIntersection = () => {
if (canvasElem && isPaused) {
const size = canvasElem.getBoundingClientRect();
const isIntersecting =
size.width > 0 &&
size.height > 0 &&
size.top <
(window.innerHeight || document.documentElement.clientHeight) &&
size.bottom > 0 &&
size.left <
(window.innerWidth || document.documentElement.clientWidth) &&
size.right > 0;
if (isIntersecting) {
rive?.startRendering();
isPaused = false;
}
}
};
const onChange = (entry: IntersectionObserverEntry) => {
entry.isIntersecting
? rive && rive.startRendering()
: rive && rive.stopRendering();
});

if (canvasElem) {
observer.observe(canvasElem);
isPaused = !entry.isIntersecting;
clearTimeout(timeoutId);
if (!entry.isIntersecting && entry.boundingClientRect.width === 0) {
timeoutId = setTimeout(retestIntersection, 10);
}
};
if (canvasElem && options.shouldUseIntersectionObserver) {
observe(canvasElem, onChange);
}

return () => {
observer.disconnect();
if (canvasElem) {
unobserve(canvasElem);
}
};
}, [rive, canvasElem]);
}, [
observe,
unobserve,
rive,
canvasElem,
options.shouldUseIntersectionObserver,
]);

/**
* On unmount, call cleanup to cleanup any WASM generated objects that need
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export type UseRiveOptions = {
fitCanvasToArtboardHeight: boolean;
useOffscreenRenderer: boolean;
shouldResizeCanvasToContainer: boolean;
shouldUseIntersectionObserver?: boolean;
};

export type Dimensions = {
Expand Down
39 changes: 39 additions & 0 deletions test/elementObserver.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// TODO move this
const observe = jest.fn();
const unobserve = jest.fn();
const disconnect = jest.fn();

jest.spyOn(globalThis, 'IntersectionObserver').mockImplementation(() => {
return {
observe,
unobserve,
disconnect,
root: null,
thresholds: [],
rootMargin: '',
takeRecords: () => [],
};
});

import ElementObserver from '../src/hooks/elementObserver';

describe('elementObserver', () => {
it('registers a callback and observes the element', () => {
const observer = new ElementObserver();
const element = document.createElement('li');
observer.registerCallback(element, ()=>{});
expect(observe).toHaveBeenCalled();
expect(observe).toHaveBeenCalledWith(element);
});

it('unregisters a callback and unobserves the element', () => {
const observer = new ElementObserver();
const element = document.createElement('li');
observer.removeCallback(element);
expect(unobserve).toHaveBeenCalled();
expect(unobserve).toHaveBeenCalledWith(element);
});

});

jest.clearAllMocks();
42 changes: 42 additions & 0 deletions test/useIntersectionObserver.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { renderHook, act } from '@testing-library/react-hooks';
import ElementObserver from '../src/hooks/elementObserver';
jest.mock('../src/hooks/elementObserver');

import useIntersectionObserver from '../src/hooks/useIntersectionObserver';

describe('useIntersectionObserver', () => {
it('returns an object on initialization', () => {
const { result } = renderHook(() => useIntersectionObserver());
expect(result.current).toBeDefined();
});

it('registers a callback', () => {
const { result } = renderHook(() => useIntersectionObserver());
const element = document.createElement('li');
const callback = () => {};
act(() => {
result.current.observe(element, callback);
});
const mockElementObserver = (ElementObserver as jest.Mock).mock
.instances[0];
const registerCallback = mockElementObserver.registerCallback;
expect(registerCallback.mock.calls.length).toBe(1);
expect(registerCallback.mock.calls[0].length).toBe(2);
expect(registerCallback.mock.calls[0][0]).toBe(element);
expect(registerCallback.mock.calls[0][1]).toBe(callback);
});

it('unregisters a callback', () => {
const { result } = renderHook(() => useIntersectionObserver());
const element = document.createElement('li');
act(() => {
result.current.unobserve(element);
});
const mockElementObserver = (ElementObserver as jest.Mock).mock
.instances[0];
const removeCallback = mockElementObserver.removeCallback;
expect(removeCallback.mock.calls.length).toBe(1);
expect(removeCallback.mock.calls[0].length).toBe(1);
expect(removeCallback.mock.calls[0][0]).toBe(element);
});
});
46 changes: 0 additions & 46 deletions test/useRive.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -308,52 +308,6 @@ describe('useRive', () => {
expect(containerSpy).toHaveAttribute('style', 'height: 50px;');
});

it('configures a IntersectionObserver on mounting', async () => {
const params = {
src: 'file-src',
};

const observeMock = jest.fn();

const restore = global.IntersectionObserver;
global.IntersectionObserver = jest.fn().mockImplementation(() => ({
observe: observeMock,
disconnect: ()=>{}
}));

const riveMock = {
...baseRiveMock,
bounds: {
maxX: 100,
maxY: 50,
},
};

// @ts-ignore
mocked(rive.Rive).mockImplementation(() => riveMock);

const canvasSpy = document.createElement('canvas');

const { result } = renderHook(() => useRive(params));

await act(async () => {
result.current.setCanvasRef(canvasSpy);
});
await waitFor(() => {
expect(result.current.canvas).toBe(canvasSpy);
});
await act(async () => {
controlledRiveloadCb();
});
await waitFor(() => {
expect(result.current.rive).toBe(riveMock);
});

expect(observeMock).toBeCalledWith(canvasSpy);

global.IntersectionObserver = restore;
});

it('updates the playing animations when the animations param changes', async () => {
const params = {
src: 'file-src',
Expand Down

0 comments on commit db81877

Please sign in to comment.