diff --git a/src/DataGrid.tsx b/src/DataGrid.tsx index 9a433e4300..f9bc6427f8 100644 --- a/src/DataGrid.tsx +++ b/src/DataGrid.tsx @@ -313,7 +313,7 @@ export function DataGrid(props: DataGridPr * states */ const { scrollTop, scrollLeft } = useScrollState(gridRef); - const [gridWidth, gridHeight] = useGridDimensions({ gridRef }); + const [gridWidth, gridHeight] = useGridDimensions(gridRef); const [columnWidthsInternal, setColumnWidthsInternal] = useState( (): ColumnWidths => columnWidthsRaw ?? new Map() ); diff --git a/src/EditCell.tsx b/src/EditCell.tsx index 78fd789230..c6ed315b3f 100644 --- a/src/EditCell.tsx +++ b/src/EditCell.tsx @@ -65,7 +65,7 @@ export default function EditCell({ // We need to prevent the `useLayoutEffect` from cleaning up between re-renders, // as `onWindowCaptureMouseDown` might otherwise miss valid mousedown events. - // To that end we instead access the latest props via useLatestFunc. + // To that end we instead access the latest props via useEffectEvent. const commitOnOutsideMouseDown = useEffectEvent(() => { onClose(true, false); }); diff --git a/src/hooks/useGridDimensions.ts b/src/hooks/useGridDimensions.ts index 1dbfb10498..b1d1da173e 100644 --- a/src/hooks/useGridDimensions.ts +++ b/src/hooks/useGridDimensions.ts @@ -1,39 +1,85 @@ -import { useLayoutEffect, useState } from 'react'; -import { flushSync } from 'react-dom'; +import { useCallback, useLayoutEffect, useSyncExternalStore, type RefObject } from 'react'; -export function useGridDimensions({ - gridRef -}: { - gridRef: React.RefObject; -}) { - const [inlineSize, setInlineSize] = useState(1); - const [blockSize, setBlockSize] = useState(1); +const initialSize: ResizeObserverSize = { + inlineSize: 1, + blockSize: 1 +}; - useLayoutEffect(() => { - const { ResizeObserver } = window; +// use an unmanaged WeakMap so we preserve the cache even when +// the component partially unmounts via Suspense or Activity +const sizeMap = new WeakMap, ResizeObserverSize>(); +const targetToRefMap = new WeakMap>(); +const subscribers = new Map, () => void>(); + +// don't break in Node.js (SSR), jsdom, and environments that don't support ResizeObserver +const resizeObserver = + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + globalThis.ResizeObserver == null ? null : new ResizeObserver(resizeObserverCallback); + +function resizeObserverCallback(entries: ResizeObserverEntry[]) { + for (const entry of entries) { + const target = entry.target as HTMLDivElement; + + if (targetToRefMap.has(target)) { + const ref = targetToRefMap.get(target)!; + updateSize(ref, entry.contentBoxSize[0]); + } + } +} + +function updateSize(ref: RefObject, size: ResizeObserverSize) { + if (sizeMap.has(ref)) { + const prevSize = sizeMap.get(ref)!; + if (prevSize.inlineSize === size.inlineSize && prevSize.blockSize === size.blockSize) { + return; + } + } + + sizeMap.set(ref, size); + subscribers.get(ref)?.(); +} + +function getServerSnapshot(): ResizeObserverSize { + return initialSize; +} - // don't break in Node.js (SSR), jsdom, and browsers that don't support ResizeObserver - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (ResizeObserver == null) return; +export function useGridDimensions(gridRef: React.RefObject) { + const subscribe = useCallback( + (onStoreChange: () => void) => { + subscribers.set(gridRef, onStoreChange); - const { clientWidth, clientHeight } = gridRef.current!; + return () => { + subscribers.delete(gridRef); + }; + }, + [gridRef] + ); - setInlineSize(clientWidth); - setBlockSize(clientHeight); + const getSnapshot = useCallback((): ResizeObserverSize => { + // ref.current is null during the initial render, when suspending, or in . + // We use ref as key instead to access stable values regardless of rendering state. + return sizeMap.get(gridRef) ?? initialSize; + }, [gridRef]); + + // We use `useSyncExternalStore` instead of `useState` to avoid tearing, + // which can lead to flashing scrollbars. + const { inlineSize, blockSize } = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); + + useLayoutEffect(() => { + const target = gridRef.current!; - const resizeObserver = new ResizeObserver((entries) => { - const size = entries[0].contentBoxSize[0]; + targetToRefMap.set(target, gridRef); + resizeObserver?.observe(target); - // we use flushSync here to avoid flashing scrollbars - flushSync(() => { - setInlineSize(size.inlineSize); - setBlockSize(size.blockSize); + if (!sizeMap.has(gridRef)) { + updateSize(gridRef, { + inlineSize: target.clientWidth, + blockSize: target.clientHeight }); - }); - resizeObserver.observe(gridRef.current!); + } return () => { - resizeObserver.disconnect(); + resizeObserver?.unobserve(target); }; }, [gridRef]); diff --git a/test/failOnConsole.ts b/test/failOnConsole.ts index d3721f6d90..5c4507a4e3 100644 --- a/test/failOnConsole.ts +++ b/test/failOnConsole.ts @@ -1,20 +1,6 @@ beforeEach(({ onTestFinished }) => { vi.spyOn(console, 'warn').mockName('console.warn'); - - // use split mocks to not increase the calls count when ignoring undesired logs - const errorMock = vi.fn(console.error).mockName('console.error'); - vi.spyOn(console, 'error').mockImplementation(function error(...params) { - // https://github.com/vitest-dev/vitest/blob/0685b6f027576589464fc6109ddc071ef0079f16/packages/browser/src/client/public/error-catcher.js#L34-L38 - // https://github.com/vitest-dev/vitest/blob/0685b6f027576589464fc6109ddc071ef0079f16/test/browser/fixtures/unhandled-non-error/basic.test.ts - if ( - Error.isError(params[0]) && - params[0].message === 'ResizeObserver loop completed with undelivered notifications.' - ) { - return; - } - - return errorMock(...params); - }); + vi.spyOn(console, 'error').mockName('console.error'); // Wait for the test and all `afterEach` hooks to complete to ensure all logs are caught onTestFinished(({ expect, task, signal }) => { @@ -29,7 +15,7 @@ beforeEach(({ onTestFinished }) => { .toHaveBeenCalledTimes(0); expect .soft( - errorMock, + console.error, 'console.error() was called during the test; please resolve unexpected errors' ) .toHaveBeenCalledTimes(0); diff --git a/website/routes/MasterDetail.tsx b/website/routes/MasterDetail.tsx index 0a81104457..c6995c7700 100644 --- a/website/routes/MasterDetail.tsx +++ b/website/routes/MasterDetail.tsx @@ -84,6 +84,8 @@ function MasterDetail() { cellClass(row) { return row.type === 'DETAIL' ? css` + /* allows shrinking the inner grid */ + contain: inline-size; padding: 24px; ` : undefined;