Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/direct-dom-no-container.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/react-virtual': patch
---

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`).
2 changes: 2 additions & 0 deletions docs/framework/react/react-virtual.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ const virtualizer = useVirtualizer({

> ⚠️ 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.

> **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.

#### Example

```tsx
Expand Down
6 changes: 5 additions & 1 deletion packages/react-virtual/e2e/app/direct-dom-updates/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ const App = () => {

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

const renderCount = React.useRef(0)
renderCount.current += 1
Expand Down Expand Up @@ -40,7 +44,7 @@ const App = () => {
style={{ height: 400, overflow: 'auto' }}
>
<div
ref={rowVirtualizer.containerRef}
ref={noContainer ? undefined : rowVirtualizer.containerRef}
id="inner"
style={{ position: 'relative', width: '100%' }}
>
Expand Down
36 changes: 36 additions & 0 deletions packages/react-virtual/e2e/app/test/direct-dom-updates.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,5 +89,41 @@ for (const mode of ['position', 'transform'] as const) {
expect(style).toMatch(/translate3d\(0px,\s*20000px,\s*0px\)/)
}
})

test('without containerRef the virtualizer makes no direct DOM writes but still skips re-renders', async ({
page,
}) => {
await page.goto(`/direct-dom-updates/?mode=${mode}&noContainer=1`)

// Container size is NOT written by the virtualizer when containerRef is omitted.
const inner = page.locator('#inner')
const innerStyle = (await inner.getAttribute('style')) ?? ''
expect(innerStyle).not.toMatch(/height:\s*\d/)

// Item positions are NOT written by the virtualizer either.
const first = page.locator('[data-testid="item-0"]')
await expect(first).toBeVisible()
const firstStyle = (await first.getAttribute('style')) ?? ''
if (mode === 'position') {
expect(firstStyle).not.toMatch(/top:\s*\d/)
} else {
expect(firstStyle).not.toMatch(/transform:/)
}

// Re-render skipping still applies: scrolling by one item (absorbed by
// overscan) must not trigger React re-renders.
const initialRenders = Number(
await page.locator('[data-testid="render-count"]').textContent(),
)
await page.locator('#scroll-container').evaluate((el, by) => {
el.scrollTop = by
}, ITEM_SIZE)
// Give onChange a chance to fire.
await page.waitForTimeout(50)
const afterRenders = Number(
await page.locator('[data-testid="render-count"]').textContent(),
)
expect(afterRenders - initialRenders).toBeLessThanOrEqual(2)
})
})
}
4 changes: 2 additions & 2 deletions packages/react-virtual/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,10 +111,10 @@ function useVirtualizerBase<
instance: Virtualizer<TScrollElement, TItemElement>,
) => {
const state = directRef.current
if (!state.enabled) return
if (!state.enabled || !state.container) return

const totalSize = instance.getTotalSize()
if (state.container && totalSize !== state.lastSize) {
if (totalSize !== state.lastSize) {
state.lastSize = totalSize
const sizeAxis = instance.options.horizontal ? 'width' : 'height'
state.container.style[sizeAxis] = `${totalSize}px`
Expand Down
Loading