Skip to content

Commit 4dd2834

Browse files
committed
fix(react-virtual): make directDomUpdates a no-op without containerRef
1 parent 932c358 commit 4dd2834

5 files changed

Lines changed: 50 additions & 3 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/react-virtual': patch
3+
---
4+
5+
Make `directDomUpdates` a no-op for direct DOM writes when `containerRef` is omitted. Previously the virtualizer still wrote item positions while never sizing the container (a broken half-state). Now omitting `containerRef` skips all direct writes while still skipping re-renders, letting consumers own the DOM updates themselves (e.g. in `onChange`).

docs/framework/react/react-virtual.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@ const virtualizer = useVirtualizer({
8989

9090
> ⚠️ This flag is intended to be set once at mount. Toggling it (or `directDomUpdatesMode`) at runtime can leave stale inline styles on items and the container.
9191
92+
> **Note:** If you omit `containerRef`, the virtualizer makes no direct DOM writes — it writes neither item positions nor the container size. You're then responsible for positioning items and sizing the container yourself (e.g. in `onChange`), while still benefiting from the skipped re-renders.
93+
9294
#### Example
9395

9496
```tsx

packages/react-virtual/e2e/app/direct-dom-updates/main.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ const App = () => {
1010

1111
const params = new URLSearchParams(window.location.search)
1212
const mode = (params.get('mode') ?? 'transform') as 'position' | 'transform'
13+
// When set, the consumer omits `containerRef`. The virtualizer must then make
14+
// no direct DOM writes at all (neither item positions nor container size),
15+
// leaving the consumer to own them — while still skipping re-renders.
16+
const noContainer = params.get('noContainer') === '1'
1317

1418
const renderCount = React.useRef(0)
1519
renderCount.current += 1
@@ -40,7 +44,7 @@ const App = () => {
4044
style={{ height: 400, overflow: 'auto' }}
4145
>
4246
<div
43-
ref={rowVirtualizer.containerRef}
47+
ref={noContainer ? undefined : rowVirtualizer.containerRef}
4448
id="inner"
4549
style={{ position: 'relative', width: '100%' }}
4650
>

packages/react-virtual/e2e/app/test/direct-dom-updates.spec.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,5 +89,41 @@ for (const mode of ['position', 'transform'] as const) {
8989
expect(style).toMatch(/translate3d\(0px,\s*20000px,\s*0px\)/)
9090
}
9191
})
92+
93+
test('without containerRef the virtualizer makes no direct DOM writes but still skips re-renders', async ({
94+
page,
95+
}) => {
96+
await page.goto(`/direct-dom-updates/?mode=${mode}&noContainer=1`)
97+
98+
// Container size is NOT written by the virtualizer when containerRef is omitted.
99+
const inner = page.locator('#inner')
100+
const innerStyle = (await inner.getAttribute('style')) ?? ''
101+
expect(innerStyle).not.toMatch(/height:\s*\d/)
102+
103+
// Item positions are NOT written by the virtualizer either.
104+
const first = page.locator('[data-testid="item-0"]')
105+
await expect(first).toBeVisible()
106+
const firstStyle = (await first.getAttribute('style')) ?? ''
107+
if (mode === 'position') {
108+
expect(firstStyle).not.toMatch(/top:\s*\d/)
109+
} else {
110+
expect(firstStyle).not.toMatch(/transform:/)
111+
}
112+
113+
// Re-render skipping still applies: scrolling by one item (absorbed by
114+
// overscan) must not trigger React re-renders.
115+
const initialRenders = Number(
116+
await page.locator('[data-testid="render-count"]').textContent(),
117+
)
118+
await page.locator('#scroll-container').evaluate((el, by) => {
119+
el.scrollTop = by
120+
}, ITEM_SIZE)
121+
// Give onChange a chance to fire.
122+
await page.waitForTimeout(50)
123+
const afterRenders = Number(
124+
await page.locator('[data-testid="render-count"]').textContent(),
125+
)
126+
expect(afterRenders - initialRenders).toBeLessThanOrEqual(2)
127+
})
92128
})
93129
}

packages/react-virtual/src/index.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,10 +111,10 @@ function useVirtualizerBase<
111111
instance: Virtualizer<TScrollElement, TItemElement>,
112112
) => {
113113
const state = directRef.current
114-
if (!state.enabled) return
114+
if (!state.enabled || !state.container) return
115115

116116
const totalSize = instance.getTotalSize()
117-
if (state.container && totalSize !== state.lastSize) {
117+
if (totalSize !== state.lastSize) {
118118
state.lastSize = totalSize
119119
const sizeAxis = instance.options.horizontal ? 'width' : 'height'
120120
state.container.style[sizeAxis] = `${totalSize}px`

0 commit comments

Comments
 (0)