|
1 | | -import { useLayoutEffect, useRef, useState } from 'react'; |
2 | | -import { flushSync } from 'react-dom'; |
| 1 | +import { useCallback, useId, useLayoutEffect, useRef, useSyncExternalStore } from 'react'; |
| 2 | + |
| 3 | +const initialSize: ResizeObserverSize = { |
| 4 | + inlineSize: 1, |
| 5 | + blockSize: 1 |
| 6 | +}; |
| 7 | + |
| 8 | +const targetToIdMap = new Map<HTMLDivElement, string>(); |
| 9 | +const idToTargetMap = new Map<string, HTMLDivElement>(); |
| 10 | +// use an unmanaged WeakMap so we preserve the cache even when |
| 11 | +// the component partially unmounts via Suspense or Activity |
| 12 | +const sizeMap = new WeakMap<HTMLDivElement, ResizeObserverSize>(); |
| 13 | +const subscribers = new Map<string, () => void>(); |
| 14 | + |
| 15 | +// don't break in Node.js (SSR), jsdom, and environments that don't support ResizeObserver |
| 16 | +const resizeObserver = |
| 17 | + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition |
| 18 | + globalThis.ResizeObserver == null ? null : new ResizeObserver(resizeObserverCallback); |
| 19 | + |
| 20 | +function resizeObserverCallback(entries: ResizeObserverEntry[]) { |
| 21 | + for (const entry of entries) { |
| 22 | + const target = entry.target as HTMLDivElement; |
| 23 | + |
| 24 | + if (!targetToIdMap.has(target)) continue; |
| 25 | + |
| 26 | + const id = targetToIdMap.get(target)!; |
| 27 | + |
| 28 | + updateSize(target, id, entry.contentBoxSize[0]); |
| 29 | + } |
| 30 | +} |
| 31 | + |
| 32 | +function updateSize(target: HTMLDivElement, id: string, size: ResizeObserverSize) { |
| 33 | + if (sizeMap.has(target)) { |
| 34 | + const prevSize = sizeMap.get(target)!; |
| 35 | + if (prevSize.inlineSize === size.inlineSize && prevSize.blockSize === size.blockSize) { |
| 36 | + return; |
| 37 | + } |
| 38 | + } |
| 39 | + |
| 40 | + sizeMap.set(target, size); |
| 41 | + subscribers.get(id)?.(); |
| 42 | +} |
| 43 | + |
| 44 | +function getServerSnapshot(): ResizeObserverSize { |
| 45 | + return initialSize; |
| 46 | +} |
3 | 47 |
|
4 | 48 | export function useGridDimensions() { |
| 49 | + const id = useId(); |
5 | 50 | const gridRef = useRef<HTMLDivElement>(null); |
6 | | - const [inlineSize, setInlineSize] = useState(1); |
7 | | - const [blockSize, setBlockSize] = useState(1); |
8 | | - const [horizontalScrollbarHeight, setHorizontalScrollbarHeight] = useState(0); |
| 51 | + |
| 52 | + const subscribe = useCallback( |
| 53 | + (onStoreChange: () => void) => { |
| 54 | + subscribers.set(id, onStoreChange); |
| 55 | + |
| 56 | + return () => { |
| 57 | + subscribers.delete(id); |
| 58 | + }; |
| 59 | + }, |
| 60 | + [id] |
| 61 | + ); |
| 62 | + |
| 63 | + const getSnapshot = useCallback((): ResizeObserverSize => { |
| 64 | + if (idToTargetMap.has(id)) { |
| 65 | + const target = idToTargetMap.get(id)!; |
| 66 | + if (sizeMap.has(target)) { |
| 67 | + return sizeMap.get(target)!; |
| 68 | + } |
| 69 | + } |
| 70 | + return initialSize; |
| 71 | + }, [id]); |
| 72 | + |
| 73 | + // We use `useSyncExternalStore` instead of `useState` to avoid tearing, |
| 74 | + // which can lead to flashing scrollbars. |
| 75 | + const { inlineSize, blockSize } = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); |
9 | 76 |
|
10 | 77 | useLayoutEffect(() => { |
11 | | - const { ResizeObserver } = window; |
12 | | - |
13 | | - // don't break in Node.js (SSR), jsdom, and browsers that don't support ResizeObserver |
14 | | - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition |
15 | | - if (ResizeObserver == null) return; |
16 | | - |
17 | | - const { clientWidth, clientHeight, offsetWidth, offsetHeight } = gridRef.current!; |
18 | | - const { width, height } = gridRef.current!.getBoundingClientRect(); |
19 | | - const initialHorizontalScrollbarHeight = offsetHeight - clientHeight; |
20 | | - const initialWidth = width - offsetWidth + clientWidth; |
21 | | - const initialHeight = height - initialHorizontalScrollbarHeight; |
22 | | - |
23 | | - setInlineSize(initialWidth); |
24 | | - setBlockSize(initialHeight); |
25 | | - setHorizontalScrollbarHeight(initialHorizontalScrollbarHeight); |
26 | | - |
27 | | - const resizeObserver = new ResizeObserver((entries) => { |
28 | | - const size = entries[0].contentBoxSize[0]; |
29 | | - const { clientHeight, offsetHeight } = gridRef.current!; |
30 | | - |
31 | | - // we use flushSync here to avoid flashing scrollbars |
32 | | - flushSync(() => { |
33 | | - setInlineSize(size.inlineSize); |
34 | | - setBlockSize(size.blockSize); |
35 | | - setHorizontalScrollbarHeight(offsetHeight - clientHeight); |
| 78 | + const target = gridRef.current!; |
| 79 | + |
| 80 | + targetToIdMap.set(target, id); |
| 81 | + idToTargetMap.set(id, target); |
| 82 | + resizeObserver?.observe(target); |
| 83 | + |
| 84 | + if (!sizeMap.has(target)) { |
| 85 | + updateSize(target, id, { |
| 86 | + inlineSize: target.clientWidth, |
| 87 | + blockSize: target.clientHeight |
36 | 88 | }); |
37 | | - }); |
38 | | - resizeObserver.observe(gridRef.current!); |
| 89 | + } |
39 | 90 |
|
40 | 91 | return () => { |
41 | | - resizeObserver.disconnect(); |
| 92 | + targetToIdMap.delete(target); |
| 93 | + idToTargetMap.delete(id); |
| 94 | + resizeObserver?.unobserve(target); |
42 | 95 | }; |
43 | | - }, []); |
| 96 | + }, [id]); |
44 | 97 |
|
45 | | - return [gridRef, inlineSize, blockSize, horizontalScrollbarHeight] as const; |
| 98 | + return [gridRef, inlineSize, blockSize] as const; |
46 | 99 | } |
0 commit comments