From 4dd283470e4dbf623ef430fb7bde208a75843c3a Mon Sep 17 00:00:00 2001 From: Damian Pieczynski Date: Mon, 15 Jun 2026 20:55:11 +0200 Subject: [PATCH] fix(react-virtual): make directDomUpdates a no-op without containerRef --- .changeset/direct-dom-no-container.md | 5 +++ docs/framework/react/react-virtual.md | 2 ++ .../e2e/app/direct-dom-updates/main.tsx | 6 +++- .../e2e/app/test/direct-dom-updates.spec.ts | 36 +++++++++++++++++++ packages/react-virtual/src/index.tsx | 4 +-- 5 files changed, 50 insertions(+), 3 deletions(-) create mode 100644 .changeset/direct-dom-no-container.md diff --git a/.changeset/direct-dom-no-container.md b/.changeset/direct-dom-no-container.md new file mode 100644 index 00000000..3e062501 --- /dev/null +++ b/.changeset/direct-dom-no-container.md @@ -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`). diff --git a/docs/framework/react/react-virtual.md b/docs/framework/react/react-virtual.md index 0ef6bd8b..02d544e0 100644 --- a/docs/framework/react/react-virtual.md +++ b/docs/framework/react/react-virtual.md @@ -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 diff --git a/packages/react-virtual/e2e/app/direct-dom-updates/main.tsx b/packages/react-virtual/e2e/app/direct-dom-updates/main.tsx index e40673d1..79c8b84a 100644 --- a/packages/react-virtual/e2e/app/direct-dom-updates/main.tsx +++ b/packages/react-virtual/e2e/app/direct-dom-updates/main.tsx @@ -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 @@ -40,7 +44,7 @@ const App = () => { style={{ height: 400, overflow: 'auto' }} >
diff --git a/packages/react-virtual/e2e/app/test/direct-dom-updates.spec.ts b/packages/react-virtual/e2e/app/test/direct-dom-updates.spec.ts index c20c7050..71b7f8dc 100644 --- a/packages/react-virtual/e2e/app/test/direct-dom-updates.spec.ts +++ b/packages/react-virtual/e2e/app/test/direct-dom-updates.spec.ts @@ -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) + }) }) } diff --git a/packages/react-virtual/src/index.tsx b/packages/react-virtual/src/index.tsx index a78b948f..a78fbdf6 100644 --- a/packages/react-virtual/src/index.tsx +++ b/packages/react-virtual/src/index.tsx @@ -111,10 +111,10 @@ function useVirtualizerBase< instance: Virtualizer, ) => { 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`