Skip to content
Open
2 changes: 1 addition & 1 deletion src/EditCell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export default function EditCell<R, SR>({

// 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);
});
Expand Down
94 changes: 71 additions & 23 deletions src/hooks/useGridDimensions.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,86 @@
import { useLayoutEffect, useRef, useState } from 'react';
import { flushSync } from 'react-dom';
import { useCallback, useLayoutEffect, useRef, useSyncExternalStore, type RefObject } from 'react';

const initialSize: ResizeObserverSize = {
inlineSize: 1,
blockSize: 1
};

// use an unmanaged WeakMap so we preserve the cache even when
// the component partially unmounts via Suspense or Activity
const sizeMap = new WeakMap<RefObject<HTMLDivElement | null>, ResizeObserverSize>();
const targetToRefMap = new WeakMap<HTMLDivElement, RefObject<HTMLDivElement | null>>();
const subscribers = new Map<RefObject<HTMLDivElement | null>, () => 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);
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We'll have 1 RO instance for all rendered grids on the page, so all resize events will be batched together -> improved perf?


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<HTMLDivElement | null>, 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;
}

export function useGridDimensions() {
const gridRef = useRef<HTMLDivElement>(null);
const [inlineSize, setInlineSize] = useState(1);
const [blockSize, setBlockSize] = useState(1);
const ref = useRef<HTMLDivElement>(null);

useLayoutEffect(() => {
const { ResizeObserver } = window;
const subscribe = useCallback((onStoreChange: () => void) => {
subscribers.set(ref, onStoreChange);

// 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;
return () => {
subscribers.delete(ref);
};
}, []);

const getSnapshot = useCallback((): ResizeObserverSize => {
// ref.current is null during the initial render, when suspending, or in <Activity mode="hidden">.
// We use ref as key instead to access stable values regardless of rendering state.
return sizeMap.has(ref) ? sizeMap.get(ref)! : initialSize;
}, []);

const { clientWidth, clientHeight } = gridRef.current!;
// We use `useSyncExternalStore` instead of `useState` to avoid tearing,
// which can lead to flashing scrollbars.
const { inlineSize, blockSize } = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);

setInlineSize(clientWidth);
setBlockSize(clientHeight);
useLayoutEffect(() => {
const target = ref.current!;

const resizeObserver = new ResizeObserver((entries) => {
const size = entries[0].contentBoxSize[0];
targetToRefMap.set(target, ref);
resizeObserver?.observe(target);

// we use flushSync here to avoid flashing scrollbars
flushSync(() => {
setInlineSize(size.inlineSize);
setBlockSize(size.blockSize);
if (!sizeMap.has(ref)) {
updateSize(ref, {
inlineSize: target.clientWidth,
blockSize: target.clientHeight
});
});
resizeObserver.observe(gridRef.current!);
}

return () => {
resizeObserver.disconnect();
resizeObserver?.unobserve(target);
};
}, []);

return [gridRef, inlineSize, blockSize] as const;
return [ref, inlineSize, blockSize] as const;
}
7 changes: 0 additions & 7 deletions test/failOnConsole.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,6 @@ beforeAll(() => {
globalThis.console = {
...console,
error(...params) {
if (
params[0] instanceof Error &&
params[0].message === 'ResizeObserver loop completed with undelivered notifications.'
) {
return;
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just hoping this will be resolved 🤞

}

consoleErrorOrConsoleWarnWereCalled = true;
console.log(...params);
},
Expand Down
2 changes: 2 additions & 0 deletions website/routes/MasterDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ function MasterDetail() {
cellClass(row) {
return row.type === 'DETAIL'
? css`
/* allows shrinking the inner grid */
contain: inline-size;
padding: 24px;
`
: undefined;
Expand Down