From 40387cc5f6b371075f0985e5b111079a446e71f4 Mon Sep 17 00:00:00 2001 From: Matthew Schulz <11466453+twelvearrays@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:26:02 -0500 Subject: [PATCH] fix(VirtualizedList): clear batch timer id on unmount so a Suspense-revived list can schedule updates again `componentWillUnmount` clears the pending `_updateCellsToRenderTimeoutID` timer but never resets the field to `null`. The low-priority branch of `_scheduleCellsToRenderUpdate` only schedules a new batch when the id is `null`, so the stale id blocks every future low-priority update on that instance. Unmount is not final for a class instance that sits inside a Suspense boundary: when already-mounted content is hidden and later revealed (the react-freeze pattern used by react-native-screens `freezeOnBlur`), React calls `componentWillUnmount` and then `componentDidMount` on the same instance. If a batch timer was pending at the hide, the list comes back unable to schedule low-priority updates. When the list also had no items at the hide, `cellsAroundViewport` is `{first: 0, last: -1}` and the high-priority path (`last >= 0`) cannot recover either, so the render window stays at `initialNumToRender` no matter how much data arrives. Reproduced in Jest: a list with `initialNumToRender={12}` that is laid out empty, frozen, revealed and then given 28 items renders 12 cells forever. With the id reset it renders all 28. Changelog: [General] [Fixed] - VirtualizedList: clear the pending batch-update timer id in componentWillUnmount so a list hidden and revived by Suspense (react-freeze / freezeOnBlur) resumes rendering new cells --- .../Lists/VirtualizedList.js | 1 + .../Lists/__tests__/VirtualizedList-test.js | 80 +++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/packages/virtualized-lists/Lists/VirtualizedList.js b/packages/virtualized-lists/Lists/VirtualizedList.js index cea36dfff968..623c8afd2f8c 100644 --- a/packages/virtualized-lists/Lists/VirtualizedList.js +++ b/packages/virtualized-lists/Lists/VirtualizedList.js @@ -855,6 +855,7 @@ class VirtualizedList extends StateSafePureComponent< this._unregisterFromParent(false); // $FlowFixMe[incompatible-type] clearTimeout(this._updateCellsToRenderTimeoutID); + this._updateCellsToRenderTimeoutID = null; this._viewabilityTuples.forEach(tuple => { tuple.viewabilityHelper.dispose(); }); diff --git a/packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js b/packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js index e270af4649c9..5d9cb33b1360 100644 --- a/packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js +++ b/packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js @@ -3048,6 +3048,86 @@ it('handles rapid prepends with coalesced scroll event (regression for #53542)', ).toBeGreaterThanOrEqual(0); }); +it('renders all cells after being hidden and revealed by Suspense with a pending batch update', async () => { + const ITEM_HEIGHT = 40; + const listRef = createRef(); + // The simulate* helpers only need `getInstance()`; the root here is a + // Suspense boundary, so point them at the list instance via the ref. + const list = {getInstance: () => listRef.current}; + + // Mirrors react-freeze (react-native-screens `freezeOnBlur`): suspend on a + // promise that never resolves so React hides the already-mounted subtree + // (componentWillUnmount) and later reveals the same instance + // (componentDidMount) instead of remounting it. + const neverResolves = new Promise(() => {}); + function Freeze({freeze, children}) { + if (freeze) { + throw neverResolves; + } + return children; + } + + const renderList = ({freeze, items}) => ( + + + + + + ); + + let component; + await act(() => { + component = create(renderList({freeze: false, items: []}), { + unstable_isConcurrent: true, + }); + }); + + // Layout with no data schedules a low-priority batch update. Leave the + // timer pending so it is still outstanding when the list is hidden. + await act(() => { + simulateLayout(list, { + viewport: {width: 300, height: 600}, + content: {width: 300, height: 0}, + }); + }); + + await act(() => { + component.update(renderList({freeze: true, items: []})); + }); + await act(() => { + component.update(renderList({freeze: false, items: []})); + }); + + const items = generateItems(28); + await act(() => { + component.update(renderList({freeze: false, items})); + }); + await act(() => { + simulateContentLayout(list, { + width: 300, + height: items.length * ITEM_HEIGHT, + }); + simulateScroll(list, {x: 0, y: 0}); + }); + + // Each low-priority batch grows the render window by at most + // `maxToRenderPerBatch` cells, so advance through several batching periods. + for (let i = 0; i < 5; i++) { + await act(() => { + performAllBatches(); + }); + } + + expect(component.root.findAllByType('MockCellItem')).toHaveLength( + items.length, + ); +}); + function generateItems(count, startKey = 0) { return Array(count) .fill()