From 18e7100d0d783ca903dba44ab8afacfbcd44af80 Mon Sep 17 00:00:00 2001 From: Daniil Filippov Date: Thu, 17 Sep 2026 14:49:04 +0300 Subject: [PATCH] fix: debounce passive grid width commit --- src/components/GridLayout/ReactGridLayout.tsx | 37 +++++++++-------- .../__tests__/passive-resize.test.tsx | 41 +++++++------------ 2 files changed, 36 insertions(+), 42 deletions(-) diff --git a/src/components/GridLayout/ReactGridLayout.tsx b/src/components/GridLayout/ReactGridLayout.tsx index 9c7a8da..027a78a 100644 --- a/src/components/GridLayout/ReactGridLayout.tsx +++ b/src/components/GridLayout/ReactGridLayout.tsx @@ -9,6 +9,7 @@ import ResizeObserverPolyfill from 'resize-observer-polyfill'; import {DROPPING_ELEMENT_CLASS_NAME, OVERLAY_CLASS_NAME} from '../../constants'; const GRID_LAYOUT_CLASS_NAME = 'react-grid-layout'; +const SETTLED_WIDTH_COMMIT_DELAY = 150; const isRefObject = ( value: React.Ref, @@ -535,7 +536,7 @@ class PassiveWidthProvider extends React.Component< private gridItems: HTMLElement[] = []; private lastAppliedWidth?: number; private resizeObserver?: ResizeObserver; - private settledWidthCommitFrame?: number; + private settledWidthCommitTimer?: number; private isComponentMounted = false; componentDidMount() { @@ -554,10 +555,7 @@ class PassiveWidthProvider extends React.Component< this.isComponentMounted = false; this.resizeObserver?.disconnect(); this.resizeObserver = undefined; - if (this.settledWidthCommitFrame !== undefined) { - window.cancelAnimationFrame(this.settledWidthCommitFrame); - this.settledWidthCommitFrame = undefined; - } + this.cancelSettledWidthCommit(); this.element?.removeEventListener('pointerdown', this.handlePointerDown, true); this.element = null; } @@ -655,6 +653,7 @@ class PassiveWidthProvider extends React.Component< return; } + this.cancelSettledWidthCommit(); flushSync(() => { this.setState((state) => ({ interactionRevision: state.interactionRevision + 1, @@ -664,6 +663,7 @@ class PassiveWidthProvider extends React.Component< }; private syncSettledWidth = () => { + this.cancelSettledWidthCommit(); if (this.state.settledWidth === this.widthRef.current) { return; } @@ -673,18 +673,23 @@ class PassiveWidthProvider extends React.Component< }); }; - private scheduleSettledWidthCommit = () => { - if (this.settledWidthCommitFrame !== undefined) { - window.cancelAnimationFrame(this.settledWidthCommitFrame); + private cancelSettledWidthCommit = () => { + if (this.settledWidthCommitTimer === undefined) { + return; } - this.settledWidthCommitFrame = window.requestAnimationFrame(() => { - this.settledWidthCommitFrame = window.requestAnimationFrame(() => { - this.settledWidthCommitFrame = undefined; - if (this.state.settledWidth !== this.widthRef.current) { - this.setState({settledWidth: this.widthRef.current}); - } - }); - }); + window.clearTimeout(this.settledWidthCommitTimer); + this.settledWidthCommitTimer = undefined; + }; + + private scheduleSettledWidthCommit = () => { + this.cancelSettledWidthCommit(); + // DOM grid items update in ResizeObserver; React reconciles only after resizing settles. + this.settledWidthCommitTimer = window.setTimeout(() => { + this.settledWidthCommitTimer = undefined; + if (this.isComponentMounted && this.state.settledWidth !== this.widthRef.current) { + this.setState({settledWidth: this.widthRef.current}); + } + }, SETTLED_WIDTH_COMMIT_DELAY); }; private syncElement = () => { diff --git a/src/components/GridLayout/__tests__/passive-resize.test.tsx b/src/components/GridLayout/__tests__/passive-resize.test.tsx index b7e4dcc..7735d0b 100644 --- a/src/components/GridLayout/__tests__/passive-resize.test.tsx +++ b/src/components/GridLayout/__tests__/passive-resize.test.tsx @@ -18,8 +18,6 @@ type ResizeObserverCallback = (entries: ResizeObserverEntry[]) => void; const resizeObservers: TestResizeObserver[] = []; const mockPolyfillObservers: TestResizeObserver[] = []; -const animationFrameCallbacks = new Map(); -let animationFrameId = 0; jest.mock('resize-observer-polyfill', () => ({ __esModule: true, @@ -86,12 +84,6 @@ const emitPolyfillResizeFor = (element: Element, width: number) => { }); }; -const runAnimationFrame = () => { - const callbacks = Array.from(animationFrameCallbacks.values()); - animationFrameCallbacks.clear(); - callbacks.forEach((callback) => callback(0)); -}; - const gridItem = ( { beforeEach(() => { resizeObservers.length = 0; mockPolyfillObservers.length = 0; - animationFrameCallbacks.clear(); - animationFrameId = 0; global.ResizeObserver = TestResizeObserver as unknown as typeof ResizeObserver; - window.requestAnimationFrame = jest.fn((callback) => { - animationFrameId += 1; - animationFrameCallbacks.set(animationFrameId, callback); - return animationFrameId; - }); - window.cancelAnimationFrame = jest.fn((frame) => { - animationFrameCallbacks.delete(frame); - }); + }); + + afterEach(() => { + jest.useRealTimers(); }); test('does not render ReactGridLayout for passive container resize', () => { @@ -166,7 +152,8 @@ describe('Layout passive resize', () => { }, ); - test('commits only latest width after delivery between two quiet frames', () => { + test('commits only latest width after resize settles', () => { + jest.useFakeTimers(); const renderSpy = jest.spyOn(ReactGridLayout.prototype, 'render'); render( { act(() => { emitResize(900); - runAnimationFrame(); + jest.advanceTimersByTime(100); emitResize(1000); }); expect(renderSpy).toHaveBeenCalledTimes(rendersBeforeResize); act(() => { - runAnimationFrame(); + jest.advanceTimersByTime(149); }); expect(renderSpy).toHaveBeenCalledTimes(rendersBeforeResize); act(() => { - runAnimationFrame(); + jest.advanceTimersByTime(1); }); expect(renderSpy).toHaveBeenCalledTimes(rendersBeforeResize + 1); expect((renderSpy.mock.instances.at(-1) as unknown as ReactGridLayout).props.width).toBe( @@ -201,7 +188,8 @@ describe('Layout passive resize', () => { renderSpy.mockRestore(); }); - test('cancels second quiet frame on unmount', () => { + test('cancels settled width commit on unmount', () => { + jest.useFakeTimers(); const renderSpy = jest.spyOn(ReactGridLayout.prototype, 'render'); const {unmount} = render( { act(() => { emitResize(900); - runAnimationFrame(); + jest.advanceTimersByTime(100); }); expect(renderSpy).toHaveBeenCalledTimes(rendersBeforeResize); + expect(jest.getTimerCount()).toBe(1); unmount(); - expect(window.cancelAnimationFrame).toHaveBeenLastCalledWith(2); + expect(jest.getTimerCount()).toBe(0); act(() => { - runAnimationFrame(); + jest.advanceTimersByTime(150); }); expect(renderSpy).toHaveBeenCalledTimes(rendersBeforeResize); renderSpy.mockRestore();