From 68bc5a997d4519f692b5e213921001f0ffee3772 Mon Sep 17 00:00:00 2001 From: Daniil Filippov Date: Tue, 1 Sep 2026 14:44:29 +0300 Subject: [PATCH 1/2] fix: avoid React renders during passive grid resize --- package-lock.json | 3 +- package.json | 3 +- src/components/GridItem/GridItem.tsx | 30 +- src/components/GridLayout/ReactGridLayout.tsx | 290 +++++++- .../__tests__/passive-resize.test.tsx | 640 ++++++++++++++++++ 5 files changed, 957 insertions(+), 9 deletions(-) create mode 100644 src/components/GridLayout/__tests__/passive-resize.test.tsx diff --git a/package-lock.json b/package-lock.json index 0b47121..27751ce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,8 @@ "immutability-helper": "^3.1.1", "prop-types": "^15.8.1", "react-grid-layout": "^1.4.4", - "react-transition-group": "^4.4.5" + "react-transition-group": "^4.4.5", + "resize-observer-polyfill": "^1.5.1" }, "devDependencies": { "@commitlint/cli": "^19.6.1", diff --git a/package.json b/package.json index ba345c2..91fc34c 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,8 @@ "immutability-helper": "^3.1.1", "prop-types": "^15.8.1", "react-grid-layout": "^1.4.4", - "react-transition-group": "^4.4.5" + "react-transition-group": "^4.4.5", + "resize-observer-polyfill": "^1.5.1" }, "peerDependencies": { "@gravity-ui/icons": "^2.13.0", diff --git a/src/components/GridItem/GridItem.tsx b/src/components/GridItem/GridItem.tsx index f8d6d75..b06cc4a 100644 --- a/src/components/GridItem/GridItem.tsx +++ b/src/components/GridItem/GridItem.tsx @@ -46,6 +46,19 @@ class WindowFocusObserver { const windowFocusObserver = new WindowFocusObserver(); +type DashkitGridDataAttribute = + | 'data-dashkit-grid-cols' + | 'data-dashkit-grid-h' + | 'data-dashkit-grid-margin-x' + | 'data-dashkit-grid-margin-y' + | 'data-dashkit-grid-padding-x' + | 'data-dashkit-grid-padding-y' + | 'data-dashkit-grid-row-height' + | 'data-dashkit-grid-use-css-transforms' + | 'data-dashkit-grid-w' + | 'data-dashkit-grid-x' + | 'data-dashkit-grid-y'; + type GridItemProps = { adjustWidgetLayout: PluginWidgetProps['adjustWidgetLayout']; gridLayout?: ReactGridLayoutProps; @@ -75,13 +88,13 @@ type GridItemProps = { onTouchStart?: (e: React.TouchEvent) => void; onItemFocus?: DashKitProps['onItemFocus']; onItemBlur?: DashKitProps['onItemBlur']; -}; +} & Partial>; type GridItemState = { isFocused: boolean; }; -class GridItem extends React.PureComponent { +export class GridItem extends React.PureComponent { static contextType = DashKitContext; context!: React.ContextType; @@ -246,6 +259,19 @@ class GridItem extends React.PureComponent {
, ): value is React.RefObject => { @@ -41,6 +45,10 @@ type DragOverLayoutState = { type RGLLayoutWithPlaceholder = RGLLayout & {placeholder?: boolean}; +type PassiveWidthProviderProps = DragOverLayoutProps & { + measureBeforeMount?: boolean; +}; + type OnDragMethod = ( i: string, x: number, @@ -48,6 +56,51 @@ type OnDragMethod = ( sintEv: {e: Event; node: HTMLElement}, ) => void; +const getGridItemStyle = ({ + cols, + containerPadding, + margin, + width, + x, + y, + w, + h, + rowHeight, + useCSSTransforms, +}: { + cols: number; + containerPadding: [number, number]; + h: number; + margin: [number, number]; + rowHeight: number; + useCSSTransforms: boolean; + w: number; + width: number; + x: number; + y: number; +}) => { + const [marginX, marginY] = margin; + const [paddingX, paddingY] = containerPadding; + const columnWidth = (width - marginX * (cols - 1) - paddingX * 2) / cols; + const position = { + height: Math.round(rowHeight * h + marginY * (h - 1)), + left: Math.round((columnWidth + marginX) * x + paddingX), + top: Math.round(paddingY + (rowHeight + marginY) * y), + width: Math.round(columnWidth * w + marginX * (w - 1)), + }; + + return useCSSTransforms + ? {...utils.setTransform(position), left: '', top: ''} + : { + ...utils.setTopLeft(position), + MozTransform: '', + OTransform: '', + WebkitTransform: '', + msTransform: '', + transform: '', + }; +}; + class DragOverLayout extends ReactGridLayout { // @ts-expect-error - TypeScript doesn't allow direct property redeclaration in extending classes. We need to narrow the props type from ReactGridLayoutProps to DragOverLayoutProps for type safety in our custom methods props: DragOverLayoutProps; @@ -61,6 +114,10 @@ class DragOverLayout extends ReactGridLayout { // Without this flag, our setState would trigger onLayoutChange back to the consumer // that just initiated the action, causing a spurious 'change' event. _isRestoringExternalLayout = false; + private readonly gridItemChildren = new WeakMap< + React.ReactElement, + {child: React.ReactElement; metadata: Record} + >(); constructor(props: DragOverLayoutProps, context?: unknown) { super(props, context); @@ -391,6 +448,23 @@ class DragOverLayout extends ReactGridLayout { return gridItem; } + const {cols, containerPadding, h, margin, rowHeight, useCSSTransforms, w, x, y} = + gridItem.props; + const [marginX, marginY] = margin; + const [paddingX, paddingY] = containerPadding; + const gridItemMetadata: Record = { + 'data-dashkit-grid-cols': String(cols), + 'data-dashkit-grid-h': String(h), + 'data-dashkit-grid-margin-x': String(marginX), + 'data-dashkit-grid-margin-y': String(marginY), + 'data-dashkit-grid-padding-x': String(paddingX), + 'data-dashkit-grid-padding-y': String(paddingY), + 'data-dashkit-grid-row-height': String(rowHeight), + 'data-dashkit-grid-use-css-transforms': String(useCSSTransforms), + 'data-dashkit-grid-w': String(w), + 'data-dashkit-grid-x': String(x), + 'data-dashkit-grid-y': String(y), + }; // Lazy proxy for transformScaleRef so react-draggable reads fresh scale without re-render. const {transformScaleRef} = this.props; const lazyScale = transformScaleRef @@ -410,13 +484,219 @@ class DragOverLayout extends ReactGridLayout { }); } + const gridChild = gridItem.props.children as React.ReactElement>; + const cachedGridItemChild = this.gridItemChildren.get(gridChild); + const gridItemChild = + cachedGridItemChild && + Object.keys(gridItemMetadata).every( + (key) => cachedGridItemChild.metadata[key] === gridItemMetadata[key], + ) + ? cachedGridItemChild.child + : React.cloneElement(gridChild, gridItemMetadata); + + if (gridItemChild !== cachedGridItemChild?.child) { + this.gridItemChildren.set(gridChild, { + child: gridItemChild, + metadata: gridItemMetadata, + }); + } + if (lazyScale !== undefined) { - return React.cloneElement(gridItem, {transformScale: lazyScale}); + return React.cloneElement(gridItem, { + children: gridItemChild, + transformScale: lazyScale, + }); } - return gridItem; + return React.cloneElement(gridItem, {children: gridItemChild}); } } -// eslint-disable-next-line new-cap -export const Layout = WidthProvider(DragOverLayout); +class PassiveWidthProvider extends React.Component< + PassiveWidthProviderProps, + {interactionRevision: number; isMounted: boolean; settledWidth: number} +> { + state = {interactionRevision: 0, isMounted: false, settledWidth: 1280}; + + private element: HTMLDivElement | null = null; + private readonly elementRef = React.createRef(); + private readonly widthRef = {current: 1280}; + private gridItems: HTMLElement[] = []; + private lastAppliedWidth?: number; + private resizeObserver?: ResizeObserver; + private settledWidthCommitFrame?: number; + private isComponentMounted = false; + + componentDidMount() { + this.isComponentMounted = true; + this.syncElement(); + this.setState({isMounted: true, settledWidth: this.widthRef.current}); + } + + componentDidUpdate() { + this.syncElement(); + this.cacheGridItems(); + this.updateGridItems(this.widthRef.current); + } + + componentWillUnmount() { + this.isComponentMounted = false; + this.resizeObserver?.disconnect(); + this.resizeObserver = undefined; + if (this.settledWidthCommitFrame !== undefined) { + window.cancelAnimationFrame(this.settledWidthCommitFrame); + this.settledWidthCommitFrame = undefined; + } + this.element?.removeEventListener('pointerdown', this.handlePointerDown, true); + this.element = null; + } + + render() { + const {measureBeforeMount, ...props} = this.props; + + if (measureBeforeMount && !this.state.isMounted) { + return ( +
+ ); + } + + return ( + + ); + } + + private cacheGridItems = () => { + const node = this.element; + this.gridItems = node + ? Array.from(node.children).filter( + (child): child is HTMLElement => + child instanceof HTMLElement && + child.matches('.react-grid-item[data-dashkit-grid-cols]'), + ) + : []; + this.lastAppliedWidth = undefined; + }; + + private updateGridItems = (width: number) => { + if (width === this.lastAppliedWidth) { + return; + } + this.lastAppliedWidth = width; + + this.gridItems.forEach((item) => { + if ( + item.classList.contains('react-draggable-dragging') || + item.classList.contains('resizing') + ) { + return; + } + + const {dataset, style} = item; + const cols = Number(dataset.dashkitGridCols); + const x = Number(dataset.dashkitGridX); + const y = Number(dataset.dashkitGridY); + const w = Number(dataset.dashkitGridW); + const h = Number(dataset.dashkitGridH); + const marginX = Number(dataset.dashkitGridMarginX); + const marginY = Number(dataset.dashkitGridMarginY); + const paddingX = Number(dataset.dashkitGridPaddingX); + const paddingY = Number(dataset.dashkitGridPaddingY); + const rowHeight = Number(dataset.dashkitGridRowHeight); + const useCSSTransforms = dataset.dashkitGridUseCssTransforms === 'true'; + + if ( + [cols, x, y, w, h, marginX, marginY, paddingX, paddingY, rowHeight].some( + (value) => !Number.isFinite(value), + ) + ) { + return; + } + + Object.assign( + style, + getGridItemStyle({ + cols, + containerPadding: [paddingX, paddingY], + h, + margin: [marginX, marginY], + rowHeight, + useCSSTransforms, + w, + width, + x, + y, + }), + ); + }); + }; + + private handlePointerDown = () => { + if (!this.props.isDraggable && !this.props.isResizable) { + return; + } + + flushSync(() => { + this.setState((state) => ({ + interactionRevision: state.interactionRevision + 1, + settledWidth: this.widthRef.current, + })); + }); + }; + + private scheduleSettledWidthCommit = () => { + if (this.settledWidthCommitFrame !== undefined) { + window.cancelAnimationFrame(this.settledWidthCommitFrame); + } + this.settledWidthCommitFrame = window.requestAnimationFrame(() => { + this.settledWidthCommitFrame = window.requestAnimationFrame(() => { + this.settledWidthCommitFrame = undefined; + if (this.state.settledWidth !== this.widthRef.current) { + this.setState({settledWidth: this.widthRef.current}); + } + }); + }); + }; + + private syncElement = () => { + const node = this.elementRef.current; + if (node === this.element) { + return; + } + + if (this.element) { + this.resizeObserver?.unobserve(this.element); + this.element.removeEventListener('pointerdown', this.handlePointerDown, true); + } + + this.element = node; + if (node && this.isComponentMounted) { + this.observeElement(node); + } + }; + + private observeElement = (node: HTMLDivElement) => { + this.widthRef.current = node.clientWidth; + this.cacheGridItems(); + this.updateGridItems(this.widthRef.current); + node.addEventListener('pointerdown', this.handlePointerDown, true); + this.resizeObserver ??= new ( + typeof ResizeObserver === 'function' ? ResizeObserver : ResizeObserverPolyfill + )((entries: ResizeObserverEntry[]) => { + const entry = entries.find((currentEntry) => currentEntry.target === this.element); + if (!entry) { + return; + } + + this.widthRef.current = entry.contentRect.width; + this.updateGridItems(this.widthRef.current); + this.scheduleSettledWidthCommit(); + }); + this.resizeObserver.observe(node); + }; +} + +export const Layout = PassiveWidthProvider; diff --git a/src/components/GridLayout/__tests__/passive-resize.test.tsx b/src/components/GridLayout/__tests__/passive-resize.test.tsx new file mode 100644 index 0000000..21e32cf --- /dev/null +++ b/src/components/GridLayout/__tests__/passive-resize.test.tsx @@ -0,0 +1,640 @@ +/** @jest-environment jsdom */ + +import React from 'react'; + +import {act, fireEvent, render} from '@testing-library/react'; +import ReactGridLayout from 'react-grid-layout'; + +import type {ConfigItem} from '../../../shared'; +import GridItem, {GridItem as GridItemComponent} from '../../GridItem/GridItem'; +import {Layout} from '../ReactGridLayout'; + +jest.mock('../../Item/Item', () => ({ + __esModule: true, + default: () => null, +})); + +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, + default: class { + readonly callback: ResizeObserverCallback; + disconnect = jest.fn(); + elements = new Set(); + + constructor(callback: ResizeObserverCallback) { + this.callback = callback; + mockPolyfillObservers.push(this as unknown as TestResizeObserver); + } + + observe = (element: Element) => { + this.elements.add(element); + }; + + unobserve = (element: Element) => { + this.elements.delete(element); + }; + }, +})); + +class TestResizeObserver { + readonly callback: ResizeObserverCallback; + disconnect = jest.fn(); + elements = new Set(); + + unobserve = jest.fn((element: Element) => { + this.elements.delete(element); + }); + + constructor(callback: ResizeObserverCallback) { + this.callback = callback; + resizeObservers.push(this); + } + + observe(element: Element) { + this.elements.add(element); + } +} + +const emitResize = (width: number) => { + resizeObservers.forEach((observer) => { + observer.elements.forEach((element) => { + observer.callback([{contentRect: {width}, target: element} as ResizeObserverEntry]); + }); + }); +}; + +const emitResizeFor = (element: Element, width: number) => { + resizeObservers + .filter((observer) => observer.elements.has(element)) + .forEach((observer) => { + observer.callback([{contentRect: {width}, target: element} as ResizeObserverEntry]); + }); +}; + +const emitPolyfillResizeFor = (element: Element, width: number) => { + mockPolyfillObservers + .filter((observer) => observer.elements.has(element)) + .forEach((observer) => { + observer.callback([{contentRect: {width}, target: element} as ResizeObserverEntry]); + }); +}; + +const runAnimationFrame = () => { + const callbacks = Array.from(animationFrameCallbacks.values()); + animationFrameCallbacks.clear(); + callbacks.forEach((callback) => callback(0)); +}; + +const gridItem = ( + +); + +describe('Layout passive resize', () => { + 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); + }); + }); + + test('does not render ReactGridLayout for passive container resize', () => { + const renderSpy = jest.spyOn(ReactGridLayout.prototype, 'render'); + + const {container} = render( + +
+ , + ); + const rendersBeforeResize = renderSpy.mock.calls.length; + + act(() => { + emitResize(900); + fireEvent.pointerDown(container.firstElementChild as HTMLElement); + }); + + expect(renderSpy).toHaveBeenCalledTimes(rendersBeforeResize); + renderSpy.mockRestore(); + }); + + test.each([undefined, null] as const)( + 'uses polyfill when native ResizeObserver is %p', + (resizeObserver) => { + global.ResizeObserver = resizeObserver as unknown as typeof ResizeObserver; + const {container} = render( + +
+ , + ); + const layout = container.querySelector('.react-grid-layout'); + const item = container.querySelector('.react-grid-item'); + + expect(mockPolyfillObservers[0].elements.has(layout as HTMLElement)).toBe(true); + act(() => { + emitPolyfillResizeFor(layout as HTMLElement, 900); + }); + + expect(item?.style.width).toBe('213px'); + expect(item?.style.transform).toBe('translate(158px,10px)'); + }, + ); + + test('commits only latest width after delivery between two quiet frames', () => { + const renderSpy = jest.spyOn(ReactGridLayout.prototype, 'render'); + render( + +
+ , + ); + const rendersBeforeResize = renderSpy.mock.calls.length; + + act(() => { + emitResize(900); + runAnimationFrame(); + emitResize(1000); + }); + + expect(renderSpy).toHaveBeenCalledTimes(rendersBeforeResize); + act(() => { + runAnimationFrame(); + }); + expect(renderSpy).toHaveBeenCalledTimes(rendersBeforeResize); + act(() => { + runAnimationFrame(); + }); + expect(renderSpy).toHaveBeenCalledTimes(rendersBeforeResize + 1); + expect((renderSpy.mock.instances.at(-1) as unknown as ReactGridLayout).props.width).toBe( + 1000, + ); + renderSpy.mockRestore(); + }); + + test('cancels second quiet frame on unmount', () => { + const renderSpy = jest.spyOn(ReactGridLayout.prototype, 'render'); + const {unmount} = render( + +
+ , + ); + const rendersBeforeResize = renderSpy.mock.calls.length; + + act(() => { + emitResize(900); + runAnimationFrame(); + }); + expect(renderSpy).toHaveBeenCalledTimes(rendersBeforeResize); + + unmount(); + expect(window.cancelAnimationFrame).toHaveBeenLastCalledWith(2); + act(() => { + runAnimationFrame(); + }); + expect(renderSpy).toHaveBeenCalledTimes(rendersBeforeResize); + renderSpy.mockRestore(); + }); + + test('keeps distinct view item geometry after passive resize', () => { + const {container} = render( + +
+
+ , + ); + + act(() => { + emitResize(1000); + }); + + const first = container.querySelector( + '.react-grid-item[data-dashkit-grid-x="0"]', + ); + const second = container.querySelector( + '.react-grid-item[data-dashkit-grid-x="2"]', + ); + expect(first?.style.transform).toBe('translate(10px,10px)'); + expect(first?.style.width).toBe('73px'); + expect(second?.style.transform).toBe('translate(175px,10px)'); + expect(second?.style.width).toBe('238px'); + }); + + test('forwards transform mode through GridItem and uses transforms by default', () => { + const {container} = render( + + {gridItem} + , + ); + const item = container.querySelector('.react-grid-item'); + + act(() => { + emitResize(900); + }); + + expect(item?.style.width).toBe('213px'); + expect(item?.dataset.dashkitGridUseCssTransforms).toBe('true'); + expect(item?.style.transform).toBe('translate(158px,10px)'); + expect(item?.style.left).toBe(''); + expect(item?.style.top).toBe(''); + }); + + test('switches to top and left without stale transform when CSS transforms are disabled', () => { + const {container, rerender} = render( + + {gridItem} + , + ); + const item = container.querySelector('.react-grid-item'); + + act(() => { + emitResize(900); + }); + rerender( + + {gridItem} + , + ); + act(() => { + emitResize(1000); + }); + + expect(item?.dataset.dashkitGridUseCssTransforms).toBe('false'); + expect(item?.style.width).toBe('238px'); + expect(item?.style.left).toBe('175px'); + expect(item?.style.top).toBe('10px'); + expect(item?.style.transform).toBe(''); + }); + + test('resizes only direct grid items when layouts are nested', () => { + const {container, getByTestId} = render( + +
+ +
+ +
+
, + ); + const layouts = container.querySelectorAll('.react-grid-layout'); + const outerItem = getByTestId('outer-item'); + const innerItem = getByTestId('inner-item'); + + act(() => { + emitResizeFor(layouts[1], 500); + }); + const innerWidth = innerItem.style.width; + const innerTransform = innerItem.style.transform; + expect(innerWidth).toBe('72px'); + expect(innerTransform).toBe('translate(51px,10px)'); + + act(() => { + emitResizeFor(layouts[0], 1000); + }); + + expect(outerItem.style.width).toBe('238px'); + expect(innerItem.style.width).toBe(innerWidth); + expect(innerItem.style.transform).toBe(innerTransform); + }); + + test('updates editable idle geometry and refreshes RGL width before interaction', () => { + const renderSpy = jest.spyOn(ReactGridLayout.prototype, 'render'); + const {container} = render( + +
+ , + ); + const item = container.querySelector( + '.react-grid-item[data-dashkit-grid-cols]', + ); + + act(() => { + emitResize(900); + }); + + expect(item?.style.width).toBe('213px'); + expect(item?.style.transform).toBe('translate(158px,10px)'); + + item?.classList.add('react-draggable-dragging'); + act(() => { + emitResize(1000); + }); + expect(item?.style.width).toBe('213px'); + item?.classList.remove('react-draggable-dragging'); + + act(() => { + fireEvent.pointerDown(item as HTMLElement); + }); + + const lastRenderInstance = renderSpy.mock.instances.at(-1) as unknown as ReactGridLayout; + expect(lastRenderInstance.props.width).toBe(1000); + renderSpy.mockRestore(); + }); + + test('refreshes the outer-item cache when the layout children change', () => { + const {container, rerender} = render( + +
+ , + ); + + act(() => { + emitResize(900); + }); + rerender( + +
+
+ , + ); + + const second = container.querySelector( + '.react-grid-item[data-dashkit-grid-x="1"]', + ); + expect(second?.style.width).toBe('138px'); + expect(second?.style.transform).toBe('translate(84px,10px)'); + }); + + test('measures after mount, accepts a visible width after zero and disconnects observer', () => { + const renderSpy = jest.spyOn(ReactGridLayout.prototype, 'render'); + const {container, unmount} = render( + +
+ , + ); + + const initialRender = renderSpy.mock.instances.at(-1) as unknown as ReactGridLayout; + expect(initialRender.props.width).toBe(0); + act(() => { + emitResize(900); + }); + expect( + container.querySelector('.react-grid-item[data-dashkit-grid-cols]')?.style + .width, + ).toBe('64px'); + + unmount(); + expect(resizeObservers[0].disconnect).toHaveBeenCalledTimes(1); + renderSpy.mockRestore(); + }); + + test('keeps WidthProvider placeholder class names', () => { + render( + +
+ , + ); + + expect(resizeObservers[0].unobserve.mock.calls[0][0].className).toBe( + 'custom-layout react-grid-layout', + ); + }); + + test('moves observer and pointer listener from measurement placeholder to grid root', () => { + const renderSpy = jest.spyOn(ReactGridLayout.prototype, 'render'); + const {container} = render( + +
+ , + ); + const placeholder = resizeObservers[0].unobserve.mock.calls[0][0] as HTMLElement; + const layout = container.querySelector('.react-grid-layout'); + const item = container.querySelector('.react-grid-item'); + const rendersBeforeSync = renderSpy.mock.calls.length; + const widthBeforeOldTargetDelivery = item?.style.width; + + expect(resizeObservers[0].elements.has(placeholder)).toBe(false); + expect(resizeObservers[0].elements.has(layout as HTMLElement)).toBe(true); + act(() => { + resizeObservers[0].callback([ + {contentRect: {width: 700}, target: placeholder} as unknown as ResizeObserverEntry, + ]); + }); + expect(item?.style.width).toBe(widthBeforeOldTargetDelivery); + + act(() => { + emitResizeFor(layout as HTMLElement, 900); + }); + expect(item?.style.width).toBe('213px'); + + fireEvent.pointerDown(placeholder); + expect(renderSpy).toHaveBeenCalledTimes(rendersBeforeSync); + fireEvent.pointerDown(layout as HTMLElement); + expect(renderSpy).toHaveBeenCalledTimes(rendersBeforeSync + 1); + renderSpy.mockRestore(); + }); + + test('installs and removes drag-over listeners on grid root', () => { + const addEventListenerSpy = jest.spyOn(HTMLElement.prototype, 'addEventListener'); + const removeEventListenerSpy = jest.spyOn(HTMLElement.prototype, 'removeEventListener'); + const {container, unmount} = render( + +
+ , + ); + const layout = container.querySelector('.react-grid-layout'); + const dragEventNames = ['mouseup', 'mouseenter', 'mouseleave', 'mousemove']; + const callsOnLayout = (spy: jest.SpyInstance) => + spy.mock.calls.filter( + ([eventName], index) => + spy.mock.contexts[index] === layout && dragEventNames.includes(eventName), + ); + + expect(callsOnLayout(addEventListenerSpy)).toHaveLength(dragEventNames.length); + + unmount(); + + expect(callsOnLayout(removeEventListenerSpy)).toHaveLength(dragEventNames.length); + addEventListenerSpy.mockRestore(); + removeEventListenerSpy.mockRestore(); + }); + + test('re-observes grid root and restores pointerdown after StrictMode remount', () => { + const renderSpy = jest.spyOn(ReactGridLayout.prototype, 'render'); + const {container} = render( + + +
+ + , + ); + const layout = container.querySelector('.react-grid-layout'); + const currentObserver = resizeObservers.at(-1) as TestResizeObserver; + + expect(resizeObservers).toHaveLength(2); + expect(currentObserver.elements.has(layout as HTMLElement)).toBe(true); + act(() => { + currentObserver.callback([ + {contentRect: {width: 900}, target: layout} as unknown as ResizeObserverEntry, + ]); + }); + const rendersBeforePointerDown = renderSpy.mock.calls.length; + + fireEvent.pointerDown(layout as HTMLElement); + + expect(renderSpy.mock.calls.length).toBeGreaterThan(rendersBeforePointerDown); + renderSpy.mockRestore(); + }); + + test('keeps unchanged GridItem child clone during another item layout update', () => { + const renderSpy = jest.spyOn(GridItemComponent.prototype, 'render'); + const first = ; + const second = ; + const {container, rerender} = render( + + {first} + {second} + , + ); + const secondRenders = renderSpy.mock.instances.filter( + (instance) => instance.props.id === 'second', + ).length; + + rerender( + + {first} + {second} + , + ); + + expect( + renderSpy.mock.instances.filter((instance) => instance.props.id === 'second').length, + ).toBe(secondRenders); + expect( + container.querySelector('.react-grid-item[data-dashkit-grid-x="1"]'), + ).not.toBeNull(); + renderSpy.mockRestore(); + }); +}); From 8a2962455a26509600d6b3fb2150d732f0729dda Mon Sep 17 00:00:00 2001 From: Daniil Filippov Date: Tue, 1 Sep 2026 18:47:00 +0300 Subject: [PATCH 2/2] fix: sync grid width before drag over --- src/components/GridLayout/ReactGridLayout.tsx | 28 ++++++++++- .../__tests__/passive-resize.test.tsx | 50 +++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/src/components/GridLayout/ReactGridLayout.tsx b/src/components/GridLayout/ReactGridLayout.tsx index 2155a2a..9c7a8da 100644 --- a/src/components/GridLayout/ReactGridLayout.tsx +++ b/src/components/GridLayout/ReactGridLayout.tsx @@ -36,6 +36,7 @@ type DragOverLayoutProps = ReactGridLayout.ReactGridLayoutProps & { transformScaleRef?: React.MutableRefObject; groupResetRegistryRef?: React.MutableRefObject void>>; externalLayoutRevision?: number; + onBeforeDragOver?: () => void; }; type DragOverLayoutState = { @@ -109,6 +110,7 @@ class DragOverLayout extends ReactGridLayout { parentOnDrag: OnDragMethod; parentOnDragStop: OnDragMethod; + parentOnDragOver: (e: MouseEvent) => false | void; _savedDraggedOutLayout: RGLLayout[] | null = null; // Suppresses onLayoutMaybeChanged during imperative layout restore actions. // Without this flag, our setState would trigger onLayoutChange back to the consumer @@ -131,6 +133,10 @@ class DragOverLayout extends ReactGridLayout { this.parentOnDragStop = this.onDragStop; // @ts-expect-error - assigning custom method to parent's onDragStop this.onDragStop = this.extendedOnDragStop; + // @ts-expect-error - onDragOver is a protected method in parent class + this.parentOnDragOver = this.onDragOver; + // @ts-expect-error - assigning custom method to parent's onDragOver + this.onDragOver = this.extendedOnDragOver; } componentDidMount(): void { @@ -342,6 +348,11 @@ class DragOverLayout extends ReactGridLayout { } }; + extendedOnDragOver = (e: MouseEvent): false | void => { + this.props.onBeforeDragOver?.(); + return this.parentOnDragOver(e); + }; + isSharedDragTarget = (): boolean => { const drag = this.props.dragStateRef?.current; return Boolean(drag?.isDragging); @@ -565,7 +576,12 @@ class PassiveWidthProvider extends React.Component< } return ( - + ); } @@ -647,6 +663,16 @@ class PassiveWidthProvider extends React.Component< }); }; + private syncSettledWidth = () => { + if (this.state.settledWidth === this.widthRef.current) { + return; + } + + flushSync(() => { + this.setState({settledWidth: this.widthRef.current}); + }); + }; + private scheduleSettledWidthCommit = () => { if (this.settledWidthCommitFrame !== undefined) { window.cancelAnimationFrame(this.settledWidthCommitFrame); diff --git a/src/components/GridLayout/__tests__/passive-resize.test.tsx b/src/components/GridLayout/__tests__/passive-resize.test.tsx index 21e32cf..b7e4dcc 100644 --- a/src/components/GridLayout/__tests__/passive-resize.test.tsx +++ b/src/components/GridLayout/__tests__/passive-resize.test.tsx @@ -411,6 +411,56 @@ describe('Layout passive resize', () => { renderSpy.mockRestore(); }); + test('syncs RGL width before native and shared drag-over after passive resize', () => { + const renderSpy = jest.spyOn(ReactGridLayout.prototype, 'render'); + const dragStateRef = {current: {isDragging: true, sourceGroup: 'source'}}; + const widths = [900, 1000]; + const onDropDragOver = jest.fn(() => { + const instance = renderSpy.mock.instances.at(-1) as unknown as ReactGridLayout; + + expect(instance.props.width).toBe(widths[onDropDragOver.mock.calls.length - 1]); + return {}; + }); + const {container} = render( + +
+ , + ); + const layout = container.querySelector('.react-grid-layout'); + const rendersBeforeResize = renderSpy.mock.calls.length; + + act(() => { + emitResizeFor(layout as HTMLElement, 900); + }); + expect(renderSpy).toHaveBeenCalledTimes(rendersBeforeResize); + + act(() => { + fireEvent.dragOver(layout as HTMLElement, {clientX: 100, clientY: 100}); + }); + + act(() => { + emitResizeFor(layout as HTMLElement, 1000); + }); + const rendersBeforeSharedDragOver = renderSpy.mock.calls.length; + + act(() => { + fireEvent.mouseEnter(layout as HTMLElement); + fireEvent.mouseMove(layout as HTMLElement, {clientX: 100, clientY: 100}); + }); + + expect(renderSpy.mock.calls.length).toBeGreaterThan(rendersBeforeSharedDragOver); + expect(onDropDragOver).toHaveBeenCalledTimes(2); + renderSpy.mockRestore(); + }); + test('refreshes the outer-item cache when the layout children change', () => { const {container, rerender} = render(