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