Skip to content

Commit c204e91

Browse files
useGridDimensions: useSyncExternalStore implementation (#3968)
* useGridDimensions: useSyncExternalStore implementation * increase actionTimeout * fix comment * shrinking master detail * "Come on, Mr. Frodo. I can't access ref.current during render... but I can key the ref object!" — Samwise Gamgee * another weakmap * Update src/hooks/useGridDimensions.ts Co-authored-by: Aman Mahajan <amahajan@stratag.com> --------- Co-authored-by: Aman Mahajan <amahajan@stratag.com>
1 parent 1dcce2a commit c204e91

5 files changed

Lines changed: 78 additions & 44 deletions

File tree

src/DataGrid.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -313,7 +313,7 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr
313313
* states
314314
*/
315315
const { scrollTop, scrollLeft } = useScrollState(gridRef);
316-
const [gridWidth, gridHeight] = useGridDimensions({ gridRef });
316+
const [gridWidth, gridHeight] = useGridDimensions(gridRef);
317317
const [columnWidthsInternal, setColumnWidthsInternal] = useState(
318318
(): ColumnWidths => columnWidthsRaw ?? new Map()
319319
);

src/EditCell.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ export default function EditCell<R, SR>({
6565

6666
// We need to prevent the `useLayoutEffect` from cleaning up between re-renders,
6767
// as `onWindowCaptureMouseDown` might otherwise miss valid mousedown events.
68-
// To that end we instead access the latest props via useLatestFunc.
68+
// To that end we instead access the latest props via useEffectEvent.
6969
const commitOnOutsideMouseDown = useEffectEvent(() => {
7070
onClose(true, false);
7171
});

src/hooks/useGridDimensions.ts

Lines changed: 72 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,85 @@
1-
import { useLayoutEffect, useState } from 'react';
2-
import { flushSync } from 'react-dom';
1+
import { useCallback, useLayoutEffect, useSyncExternalStore, type RefObject } from 'react';
32

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+
};
117

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+
}
1445

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);
1850

19-
const { clientWidth, clientHeight } = gridRef.current!;
51+
return () => {
52+
subscribers.delete(gridRef);
53+
};
54+
},
55+
[gridRef]
56+
);
2057

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!;
2370

24-
const resizeObserver = new ResizeObserver((entries) => {
25-
const size = entries[0].contentBoxSize[0];
71+
targetToRefMap.set(target, gridRef);
72+
resizeObserver?.observe(target);
2673

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
3178
});
32-
});
33-
resizeObserver.observe(gridRef.current!);
79+
}
3480

3581
return () => {
36-
resizeObserver.disconnect();
82+
resizeObserver?.unobserve(target);
3783
};
3884
}, [gridRef]);
3985

test/failOnConsole.ts

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,6 @@
11
beforeEach(({ onTestFinished }) => {
22
vi.spyOn(console, 'warn').mockName('console.warn');
3-
4-
// use split mocks to not increase the calls count when ignoring undesired logs
5-
const errorMock = vi.fn(console.error).mockName('console.error');
6-
vi.spyOn(console, 'error').mockImplementation(function error(...params) {
7-
// https://github.com/vitest-dev/vitest/blob/0685b6f027576589464fc6109ddc071ef0079f16/packages/browser/src/client/public/error-catcher.js#L34-L38
8-
// https://github.com/vitest-dev/vitest/blob/0685b6f027576589464fc6109ddc071ef0079f16/test/browser/fixtures/unhandled-non-error/basic.test.ts
9-
if (
10-
Error.isError(params[0]) &&
11-
params[0].message === 'ResizeObserver loop completed with undelivered notifications.'
12-
) {
13-
return;
14-
}
15-
16-
return errorMock(...params);
17-
});
3+
vi.spyOn(console, 'error').mockName('console.error');
184

195
// Wait for the test and all `afterEach` hooks to complete to ensure all logs are caught
206
onTestFinished(({ expect, task, signal }) => {
@@ -29,7 +15,7 @@ beforeEach(({ onTestFinished }) => {
2915
.toHaveBeenCalledTimes(0);
3016
expect
3117
.soft(
32-
errorMock,
18+
console.error,
3319
'console.error() was called during the test; please resolve unexpected errors'
3420
)
3521
.toHaveBeenCalledTimes(0);

website/routes/MasterDetail.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ function MasterDetail() {
8484
cellClass(row) {
8585
return row.type === 'DETAIL'
8686
? css`
87+
/* allows shrinking the inner grid */
88+
contain: inline-size;
8789
padding: 24px;
8890
`
8991
: undefined;

0 commit comments

Comments
 (0)