-
Notifications
You must be signed in to change notification settings - Fork 99
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: new hook
useResizeObserver
(#67)
* feat: new hook `useResizeObserver` * test: `useResizeObserver` tests complete * docs: update README.md
- Loading branch information
Showing
7 changed files
with
346 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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]); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
}); | ||
}); |