Skip to content

Commit

Permalink
feat: new hook useResizeObserver (#67)
Browse files Browse the repository at this point in the history
* feat: new hook `useResizeObserver`

* test: `useResizeObserver` tests complete

* docs: update README.md
  • Loading branch information
xobotyi authored May 25, 2021
1 parent 5369a94 commit ccf2c26
Show file tree
Hide file tree
Showing 7 changed files with 346 additions and 3 deletions.
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,9 +106,14 @@ import { useMountEffect } from "@react-hookz/web/esnext";
- [**`useSyncedRef`**](https://react-hookz.github.io/?path=/docs/miscellaneous-usesyncedref)
— Like `useRef`, but it returns immutable ref that contains actual value.

- #### Side-effects
- #### Side-effect

- [**`useLocalStorageValue`**](https://react-hookz.github.io/?path=/docs/side-effects-uselocalstoragevalue)
- [**`useLocalStorageValue`**](https://react-hookz.github.io/?path=/docs/side-effect-uselocalstoragevalue)
— Manages a single LocalStorage key.
- [**`useSessionStorageValue`**](https://react-hookz.github.io/?path=/docs/side-effects-usesessionstoragevalue)
- [**`useSessionStorageValue`**](https://react-hookz.github.io/?path=/docs/side-effect-usesessionstoragevalue)
— Manages a single SessionStorage key.

- #### Sensor

- [**`useResizeObserver`**](https://react-hookz.github.io/?path=/docs/sensor-useresizeobserver)
— Invokes a callback whenever ResizeObserver detects a change to target's size.
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,6 @@ export { useSyncedRef } from './useSyncedRef';
// SideEffect
export { useLocalStorageValue } from './useLocalStorageValue';
export { useSessionStorageValue } from './useSessionStorageValue';

// Sensor
export { useResizeObserver, IUseResizeObserverCallback } from './useResizeObserver';
109 changes: 109 additions & 0 deletions src/useResizeObserver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { RefObject, useEffect } from 'react';
import { useSyncedRef } from './useSyncedRef';
import { isBrowser } from './util/const';

export type IUseResizeObserverCallback = (entry: ResizeObserverEntry) => void;

interface IResizeObserverSingleton {
observer: ResizeObserver;
subscribe: (target: Element, callback: IUseResizeObserverCallback) => void;
unsubscribe: (target: Element, callback: IUseResizeObserverCallback) => void;
}

let observerSingleton: IResizeObserverSingleton;

function getResizeObserver(): IResizeObserverSingleton | undefined {
if (!isBrowser) return undefined;

if (observerSingleton) return observerSingleton;

const callbacks = new Map<Element, Set<IUseResizeObserverCallback>>();

const observer = new ResizeObserver((entries) => {
entries.forEach((entry) => callbacks.get(entry.target)?.forEach((cb) => cb(entry)));
});

observerSingleton = {
observer,
subscribe: (target, callback) => {
let cbs = callbacks.get(target);

if (!cbs) {
// if target has no observers yet - register it
cbs = new Set<IUseResizeObserverCallback>();
callbacks.set(target, cbs);
observer.observe(target);
}

// as Set is duplicate-safe - simply add callback on each call
cbs.add(callback);
},
unsubscribe: (target, callback) => {
const cbs = callbacks.get(target);

// else branch should never occur in case of normal execution
// because callbacks map is hidden in closure - it is impossible to
// simulate situation with non-existent `cbs` Set
/* istanbul ignore else */
if (cbs) {
// remove current observer
cbs.delete(callback);

if (!cbs.size) {
// if no observers left unregister target completely
callbacks.delete(target);
observer.unobserve(target);
}
}
},
};

return observerSingleton;
}

/**
* Invokes a callback whenever ResizeObserver detects a change to target's size.
*
* @param target React reference or Element to track.
* @param callback Callback that will be invoked on resize.
*/
export function useResizeObserver<T extends Element>(
target: RefObject<T> | T | null,
callback: IUseResizeObserverCallback
): void {
const ro = getResizeObserver();
const cb = useSyncedRef(callback);

useEffect(() => {
// quite difficult to cover with tests, but the 'if' branch is pretty
// straightforward: do nothing, it is safe to exclude from LOC
/* istanbul ignore if */
if (!ro) return;

// as unsubscription in internals of our ResizeObserver abstraction can
// happen a bit later than effect cleanup invocation - we need a marker,
// that this handler should not be invoked anymore
let subscribed = true;

const tgt = target && 'current' in target ? target.current : target;
if (!tgt) return;

const handler: IUseResizeObserverCallback = (...args) => {
// it is reinsurance for the highly asynchronous invocations, almost
// impossible to achieve in tests, thus excluding from LOC
/* istanbul ignore else */
if (subscribed) {
cb.current(...args);
}
};

ro.subscribe(tgt, handler);

// eslint-disable-next-line consistent-return
return () => {
subscribed = false;
ro.unsubscribe(tgt, handler);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [target, ro]);
}
50 changes: 50 additions & 0 deletions stories/Sensor/useResizeObserver.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import * as React from 'react';
import { useRef, useState } from 'react';
import { IUseResizeObserverCallback, useDebounceCallback, useResizeObserver } from '../../src';

export const Example: React.FC = () => {
const ref = useRef<HTMLDivElement | null>(null);
const [rect, setRect] = useState<DOMRectReadOnly>();
useResizeObserver(ref, (e) => setRect(e.contentRect));

return (
<div>
<pre>{JSON.stringify(rect)}</pre>
<div
ref={ref}
style={{
minWidth: 100,
minHeight: 100,
resize: 'both',
overflow: 'auto',
background: 'red',
}}
/>
</div>
);
};

export const ExampleDebounced: React.FC = () => {
const ref = useRef<HTMLDivElement | null>(null);
const [rect, setRect] = useState<DOMRectReadOnly>();
const cb = useDebounceCallback<IUseResizeObserverCallback>((e) => setRect(e.contentRect), 500, [
setRect,
]);
useResizeObserver(ref, cb);

return (
<div>
<pre>{JSON.stringify(rect)}</pre>
<div
ref={ref}
style={{
minWidth: 100,
minHeight: 100,
resize: 'both',
overflow: 'auto',
background: 'red',
}}
/>
</div>
);
};
42 changes: 42 additions & 0 deletions stories/Sensor/useResizeObserver.story.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Canvas, Meta, Story } from '@storybook/addon-docs/blocks';
import { Example, ExampleDebounced } from './useResizeObserver.stories';

<Meta title="Sensor/useResizeObserver" />

# useResizeObserver

Invokes a callback whenever ResizeObserver detects a change to target's size.

- Uses a singe ResizeObserver for all hook instances, as it is more effective than using per-hook
observers.
- No need to wrap callback with `useCallback`, because hook manages callback mutation internally.
- Does not apply any throttle or debounce mechanism - it is on end-developer side.
- Does not produce references for you.
- SSR friendly.
- Provides access to `ResizeObserverEntry`

#### Example

<Canvas isColumn>
Below component uses direct invocation so it is not so optimal in terms of CPU usage, but it gains
most recent data.
<Story name="Example" story={Example} />
As `useResizeObserver` does not apply any debounce or throttle mechanisms to received callback -
it is up to developer to do so if needed passed callback. Below example is almost same as previous
but state is updated within 500ms debounce.
<Story name="ExampleDebounced" story={ExampleDebounced} />
</Canvas>

## Reference

```ts
export function useResizeObserver<T extends Element>(
target: RefObject<T> | T | null,
callback: (entry: ResizeObserverEntry) => void
): void;
```

#### Arguments

- **target** _`RefObject<Element> | Element | null`_ - element to track.
- **callback** _`(entry: ResizeObserverEntry) => void`_ - Callback that will be invoked on resize.
120 changes: 120 additions & 0 deletions tests/dom/useResizeObserver.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { renderHook } from '@testing-library/react-hooks/dom';
import { useResizeObserver } from '../../src';
import Mock = jest.Mock;

describe('useResizeObserver', () => {
const observeSpy = jest.fn();
const unobserveSpy = jest.fn();
const disconnectSpy = jest.fn();

let ResizeObserverSpy: Mock<ResizeObserver>;
const initialRO = global.ResizeObserver;

beforeAll(() => {
ResizeObserverSpy = jest.fn(() => ({
observe: observeSpy,
unobserve: unobserveSpy,
disconnect: disconnectSpy,
}));

global.ResizeObserver = ResizeObserverSpy;
});

beforeEach(() => {
observeSpy.mockClear();
unobserveSpy.mockClear();
disconnectSpy.mockClear();
});

afterAll(() => {
global.ResizeObserver = initialRO;
});

it('should be defined', () => {
expect(useResizeObserver).toBeDefined();
});

it('should render', () => {
const { result } = renderHook(() => useResizeObserver(null, () => {}));

expect(result.error).toBeUndefined();
});

it('should create ResizeObserver instance only on first hook render', () => {
expect(ResizeObserverSpy).toHaveBeenCalledTimes(1);

renderHook(() => useResizeObserver(null, () => {}));
renderHook(() => useResizeObserver(null, () => {}));

expect(ResizeObserverSpy).toHaveBeenCalledTimes(1);
});

it('should invoke each callback listening same element', () => {
const div = document.createElement('div');
const spy1 = jest.fn();
const spy2 = jest.fn();

renderHook(() => useResizeObserver(div, spy1));
renderHook(() => useResizeObserver(div, spy2));

expect(observeSpy).toHaveBeenCalledTimes(1);

const entry = {
target: div,
contentRect: {},
borderBoxSize: {},
contentBoxSize: {},
} as unknown as ResizeObserverEntry;

ResizeObserverSpy.mock.calls[0][0]([entry]);

expect(spy1).toHaveBeenCalledWith(entry);
expect(spy2).toHaveBeenCalledWith(entry);
});

it('should invoke each callback listening different element', () => {
const div = document.createElement('div');
const div2 = document.createElement('div');
const spy1 = jest.fn();
const spy2 = jest.fn();

renderHook(() => useResizeObserver(div, spy1));
renderHook(() => useResizeObserver({ current: div2 }, spy2));

expect(observeSpy).toHaveBeenCalledTimes(2);

const entry1 = {
target: div,
contentRect: {},
borderBoxSize: {},
contentBoxSize: {},
} as unknown as ResizeObserverEntry;
const entry2 = {
target: div2,
contentRect: {},
borderBoxSize: {},
contentBoxSize: {},
} as unknown as ResizeObserverEntry;

ResizeObserverSpy.mock.calls[0][0]([entry1, entry2]);

expect(spy1).toHaveBeenCalledWith(entry1);
expect(spy2).toHaveBeenCalledWith(entry2);
});

it('should unsubscribe on component unmount', () => {
const div = document.createElement('div');
const spy = jest.fn();
const { unmount } = renderHook(() => useResizeObserver(div, spy));

expect(observeSpy).toHaveBeenCalledTimes(1);
expect(observeSpy).toHaveBeenCalledWith(div);
expect(unobserveSpy).toHaveBeenCalledTimes(0);

unmount();

expect(observeSpy).toHaveBeenCalledTimes(1);
expect(unobserveSpy).toHaveBeenCalledTimes(1);
expect(unobserveSpy).toHaveBeenCalledWith(div);
});
});
14 changes: 14 additions & 0 deletions tests/ssr/useResizeObserver.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { renderHook } from '@testing-library/react-hooks/server';
import { useResizeObserver } from '../../src';

describe('useResizeObserver', () => {
it('should be defined', () => {
expect(useResizeObserver).toBeDefined();
});

it('should render', () => {
const { result } = renderHook(() => useResizeObserver(null, () => {}));

expect(result.error).toBeUndefined();
});
});

0 comments on commit ccf2c26

Please sign in to comment.