From 66f0c41db2da3ff8c6c5296797b776023aa4694e Mon Sep 17 00:00:00 2001 From: Wonho Bae <77373106+lyh4215@users.noreply.github.com> Date: Wed, 11 Nov 2026 13:02:43 +0000 Subject: [PATCH] fix(react-virtual): preserve rerender dispatch during DevTools inspection --- .changeset/common-badgers-camp.md | 5 ++ packages/react-virtual/src/index.tsx | 6 +- .../react-virtual/tests/devtools.test.tsx | 67 +++++++++++++++++++ 3 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 .changeset/common-badgers-camp.md create mode 100644 packages/react-virtual/tests/devtools.test.tsx diff --git a/.changeset/common-badgers-camp.md b/.changeset/common-badgers-camp.md new file mode 100644 index 000000000..8ca66e9b7 --- /dev/null +++ b/.changeset/common-badgers-camp.md @@ -0,0 +1,5 @@ +--- +'@tanstack/react-virtual': patch +--- + +Fix virtualized lists freezing when hovering their elements in the browser's Elements panel with React DevTools' "Record why each component rendered while profiling" setting enabled, even without starting a profiling recording ([#682](https://github.com/TanStack/virtual/issues/682)). Preserve the original reducer dispatch during hook inspection so scrolling continues to render new rows. diff --git a/packages/react-virtual/src/index.tsx b/packages/react-virtual/src/index.tsx index e4082c0f5..4db3e6ffe 100644 --- a/packages/react-virtual/src/index.tsx +++ b/packages/react-virtual/src/index.tsx @@ -85,7 +85,11 @@ function useVirtualizerBase< TScrollElement, TItemElement > { - const rerender = React.useReducer((x: number) => x + 1, 0)[1] + const [, dispatch] = React.useReducer((x: number) => x + 1, 0) + // React DevTools replays hooks with a no-op dispatch while inspecting them. + // Keep the original dispatch so that setOptions below cannot replace the + // virtualizer's render callback with the inspector's no-op. + const rerender = React.useRef(dispatch).current // Mutable across renders so the onChange closure captured by setOptions // always reads the latest values without us having to re-create it. diff --git a/packages/react-virtual/tests/devtools.test.tsx b/packages/react-virtual/tests/devtools.test.tsx new file mode 100644 index 000000000..6b1595e0f --- /dev/null +++ b/packages/react-virtual/tests/devtools.test.tsx @@ -0,0 +1,67 @@ +import '@testing-library/jest-dom/vitest' +import * as React from 'react' +import { act, fireEvent, render, screen } from '@testing-library/react' +import { expect, test, vi } from 'vitest' +import { useVirtualizer } from '../src/index' + +vi.mock('react', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, useReducer: vi.fn(actual.useReducer) } +}) + +test.each([true, false])( + 'keeps rendering after a DevTools no-op dispatch (useFlushSync: %s)', + async (useFlushSync) => { + function List() { + const parentRef = React.useRef(null) + const virtualizer = useVirtualizer({ + count: 200, + getScrollElement: () => parentRef.current, + estimateSize: () => 50, + initialRect: { height: 200, width: 200 }, + observeElementRect: () => undefined, + useFlushSync, + }) + + return ( +
+ {virtualizer.getVirtualItems().map((item) => ( +
Row {item.index}
+ ))} +
+ ) + } + + const { rerender } = render() + expect(screen.getByText('Row 0')).toBeInTheDocument() + + // Regression for #682, reproduced with React DevTools' "Record why each + // component rendered while profiling" setting enabled while hovering rows + // in Elements, without starting a profiling recording. + // DevTools inspects hooks by replaying the component with its existing hook + // state but a no-op dispatch, so inspection cannot trigger state updates. + // setOptions runs during that replay and could save an onChange closure + // that captures the no-op, preventing subsequent scrolls from re-rendering. + // Simulate that dispatch substitution for one render; this unit test does + // not automate the extension UI or the Elements hover interaction. + const useReducer = vi.mocked(React.useReducer).getMockImplementation()! + const spy = vi.mocked(React.useReducer).mockImplementation((...args) => { + const [state] = useReducer(...args) + return [state, () => {}] + }) + try { + rerender() + } finally { + spy.mockImplementation(useReducer) + } + + await act(async () => { + const scroller = screen.getByTestId('scroller') + scroller.scrollTop = 1000 + fireEvent.scroll(scroller) + }) + + expect(screen.getByText('Row 20')).toBeInTheDocument() + expect(screen.queryByText('Row 0')).not.toBeInTheDocument() + }, +)