Skip to content

Commit 1f1bd62

Browse files
committed
useGridDimensions: useSyncExternalStore implementation
1 parent 48597e7 commit 1f1bd62

28 files changed

Lines changed: 164 additions & 292 deletions

src/DataGrid.tsx

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -340,7 +340,7 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr
340340
[columnWidths]
341341
);
342342

343-
const [gridRef, gridWidth, gridHeight, horizontalScrollbarHeight] = useGridDimensions();
343+
const [gridRef, gridWidth, gridHeight] = useGridDimensions();
344344
const {
345345
columns,
346346
colSpanColumns,
@@ -461,8 +461,6 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr
461461
const maxColIdx = columns.length - 1;
462462
const selectedCellIsWithinSelectionBounds = isCellWithinSelectionBounds(selectedPosition);
463463
const selectedCellIsWithinViewportBounds = isCellWithinViewportBounds(selectedPosition);
464-
const scrollHeight =
465-
headerRowHeight + totalRowHeight + summaryRowsHeight + horizontalScrollbarHeight;
466464

467465
/**
468466
* The identity of the wrapper function is stable so it won't break memoization
@@ -1189,7 +1187,6 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr
11891187
gridTemplateColumns,
11901188
gridTemplateRows: templateRows,
11911189
'--rdg-header-row-height': `${headerRowHeight}px`,
1192-
'--rdg-scroll-height': `${scrollHeight}px`,
11931190
...layoutCssVars
11941191
}}
11951192
dir={direction}

src/hooks/useGridDimensions.ts

Lines changed: 88 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,99 @@
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+
}
347

448
export function useGridDimensions() {
49+
const id = useId();
550
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);
976

1077
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
3688
});
37-
});
38-
resizeObserver.observe(gridRef.current!);
89+
}
3990

4091
return () => {
41-
resizeObserver.disconnect();
92+
targetToIdMap.delete(target);
93+
idToTargetMap.delete(id);
94+
resizeObserver?.unobserve(target);
4295
};
43-
}, []);
96+
}, [id]);
4497

45-
return [gridRef, inlineSize, blockSize, horizontalScrollbarHeight] as const;
98+
return [gridRef, inlineSize, blockSize] as const;
4699
}

test/failOnConsole.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,6 @@ beforeAll(() => {
55
globalThis.console = {
66
...console,
77
error(...params) {
8-
if (
9-
params[0] instanceof Error &&
10-
params[0].message === 'ResizeObserver loop completed with undelivered notifications.'
11-
) {
12-
return;
13-
}
14-
158
consoleErrorOrConsoleWarnWereCalled = true;
169
console.log(...params);
1710
},

vite.config.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,7 @@ export default defineConfig(
8989
target: 'react',
9090
generatedRouteTree: 'website/routeTree.gen.ts',
9191
routesDirectory: 'website/routes',
92-
autoCodeSplitting: true,
93-
verboseFileRoutes: false
92+
autoCodeSplitting: true
9493
}),
9594
react({
9695
exclude: ['./.cache/**/*', './node_modules/**/*', './website/routeTree.gen.ts']

website/root.tsx

Lines changed: 2 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,37 +2,9 @@ import './root.css';
22

33
import { StrictMode } from 'react';
44
import { createRoot } from 'react-dom/client';
5-
import {
6-
createHashHistory,
7-
createRouter,
8-
ErrorComponent,
9-
RouterProvider
10-
} from '@tanstack/react-router';
5+
import { RouterProvider } from '@tanstack/react-router';
116

12-
import { routeTree } from './routeTree.gen';
13-
14-
const router = createRouter({
15-
routeTree,
16-
history: createHashHistory(),
17-
caseSensitive: true,
18-
defaultErrorComponent: ErrorComponent,
19-
defaultNotFoundComponent: NotFound,
20-
defaultPendingMinMs: 0,
21-
defaultPreload: 'intent',
22-
defaultStructuralSharing: true,
23-
scrollRestoration: true
24-
});
25-
26-
// Register the router instance for type safety
27-
declare module '@tanstack/react-router' {
28-
interface Register {
29-
router: typeof router;
30-
}
31-
}
32-
33-
function NotFound() {
34-
return 'Nothing to see here';
35-
}
7+
import { router } from './router';
368

379
createRoot(document.getElementById('root')!).render(
3810
<StrictMode>

0 commit comments

Comments
 (0)