From 2bf017ac6bd2545ac2497f6c09a4d9ef225daafb Mon Sep 17 00:00:00 2001 From: Samuel Reed Date: Tue, 30 Dec 2025 16:48:18 -0500 Subject: [PATCH 1/2] fix: use stored size in onResizeStop to prevent stale data onResizeStop was recalculating size from props.width/height, but due to React's batched state updates, these props may not have updated yet when onResizeStop fires. This caused the callback to report stale/incorrect size data, particularly noticeable with west/north handles. The fix stores the last calculated size during onResize and uses that stored value in onResizeStop instead of recalculating. Fixes: https://github.com/react-grid-layout/react-grid-layout/pull/2224 --- __tests__/Resizable.test.js | 97 +++++++++++++++++++++++++++++++++++++ lib/Resizable.js | 15 +++++- 2 files changed, 111 insertions(+), 1 deletion(-) diff --git a/__tests__/Resizable.test.js b/__tests__/Resizable.test.js index 2107cc26..f5cd5310 100644 --- a/__tests__/Resizable.test.js +++ b/__tests__/Resizable.test.js @@ -404,6 +404,103 @@ describe('render Resizable', () => { }); }); }); + + describe('onResizeStop with stale props', () => { + // This tests the fix for a bug where onResizeStop would report stale size data + // because React's batched state updates mean props.width/height haven't updated yet + // when onResizeStop fires. The fix stores the last size from onResize and uses it + // in onResizeStop. See: https://github.com/react-grid-layout/react-grid-layout/pull/2224 + + test('onResizeStop reports correct size even when props are stale', () => { + // Create a fresh element with fresh mocks for this test + const onResizeStop = jest.fn(); + const onResize = jest.fn(); + const testProps = { + ...customProps, + onResize, + onResizeStop, + }; + const element = shallow({resizableBoxChildren}); + const seHandle = findHandle(element, 'se'); + + // Simulate onResizeStart + seHandle.prop('onStart')(mockEvent, { node, deltaX: 0, deltaY: 0 }); + + // Simulate dragging - this calls onResize with the new size + seHandle.prop('onDrag')(mockEvent, { node, deltaX: 20, deltaY: 30 }); + expect(onResize).toHaveBeenLastCalledWith( + mockEvent, + expect.objectContaining({ + size: { width: 70, height: 80 }, + }) + ); + + // Now simulate onResizeStop. In a real app, React may not have re-rendered yet, + // so props.width/height would still be 50. The deltaX/deltaY from DraggableCore's + // onStop is typically 0 or very small since the mouse hasn't moved since the last + // drag event. Without the fix, this would incorrectly report size: {width: 50, height: 50}. + seHandle.prop('onStop')(mockEvent, { node, deltaX: 0, deltaY: 0 }); + + // With the fix, onResizeStop should report the same size as the last onResize + expect(onResizeStop).toHaveBeenCalledWith( + mockEvent, + expect.objectContaining({ + size: { width: 70, height: 80 }, + }) + ); + }); + + test('onResizeStop reports correct size for west handle with stale props', () => { + const onResizeStop = jest.fn(); + const onResize = jest.fn(); + const testProps = { + ...customProps, + onResize, + onResizeStop, + }; + const testMockClientRect = { left: 0, top: 0 }; + const testNode = document.createElement('div'); + // $FlowIgnore + testNode.getBoundingClientRect = () => ({ ...testMockClientRect }); + + const element = shallow({resizableBoxChildren}); + const wHandle = findHandle(element, 'w'); + + // Simulate onResizeStart - this sets lastHandleRect to {left: 0, top: 0} + wHandle.prop('onStart')(mockEvent, { node: testNode, deltaX: 0, deltaY: 0 }); + + // Simulate dragging west (left) + // deltaX = -15 from drag, plus position adjustment of -15 (handle moved from 0 to -15) + // Total deltaX = -30, reversed for 'w' = +30, so width = 50 + 30 = 80 + testMockClientRect.left = -15; + wHandle.prop('onDrag')(mockEvent, { node: testNode, deltaX: -15, deltaY: 0 }); + expect(onResize).toHaveBeenLastCalledWith( + mockEvent, + expect.objectContaining({ + size: { width: 80, height: 50 }, + }) + ); + + // Continue dragging - element moves further left + testMockClientRect.left = -25; + wHandle.prop('onDrag')(mockEvent, { node: testNode, deltaX: -10, deltaY: 0 }); + expect(onResize).toHaveBeenLastCalledWith( + mockEvent, + expect.objectContaining({ + size: { width: 100, height: 50 }, + }) + ); + + // onResizeStop with stale props - should use stored lastSize + wHandle.prop('onStop')(mockEvent, { node: testNode, deltaX: 0, deltaY: 0 }); + expect(onResizeStop).toHaveBeenCalledWith( + mockEvent, + expect.objectContaining({ + size: { width: 100, height: 50 }, + }) + ); + }); + }); }); // ============================================ diff --git a/lib/Resizable.js b/lib/Resizable.js index a2eede3d..fb62f8cf 100644 --- a/lib/Resizable.js +++ b/lib/Resizable.js @@ -24,13 +24,14 @@ export default class Resizable extends React.Component { handleRefs: {[key: ResizeHandleAxis]: ReactRef} = {}; lastHandleRect: ?ClientRect = null; slack: ?[number, number] = null; + lastSize: ?{width: number, height: number} = null; componentWillUnmount() { this.resetData(); } resetData() { - this.lastHandleRect = this.slack = null; + this.lastHandleRect = this.slack = this.lastSize = null; } // Clamp width and height within provided constraints @@ -132,8 +133,20 @@ export default class Resizable extends React.Component { // Run user-provided constraints. [width, height] = this.runConstraints(width, height); + // For onResizeStop, use the last size from onResize rather than recalculating. + // This avoids issues where props.width/height are stale due to React's batched updates. + if (handlerName === 'onResizeStop' && this.lastSize) { + ({width, height} = this.lastSize); + } + const dimensionsChanged = width !== this.props.width || height !== this.props.height; + // Store the size for use in onResizeStop. We do this after the onResizeStop check + // above so we don't overwrite the stored value with a potentially stale calculation. + if (handlerName !== 'onResizeStop') { + this.lastSize = {width, height}; + } + // Call user-supplied callback if present. const cb = typeof this.props[handlerName] === 'function' ? this.props[handlerName] : null; // Don't call 'onResize' if dimensions haven't changed. From 1483828970270f409cd86f7bdcc980e1c4a3bd5b Mon Sep 17 00:00:00 2001 From: Samuel Reed Date: Tue, 30 Dec 2025 17:21:29 -0500 Subject: [PATCH 2/2] fix: update tests for RTL and stale props fix - Rewrite stale props tests using RTL refs instead of Enzyme - Update onResizeStop test to include onResize call (new behavior) - Fix test expectations for west handle position tracking --- __tests__/Resizable.test.js | 67 ++++++++++++++++++++----------------- 1 file changed, 37 insertions(+), 30 deletions(-) diff --git a/__tests__/Resizable.test.js b/__tests__/Resizable.test.js index f5cd5310..9ad2c71a 100644 --- a/__tests__/Resizable.test.js +++ b/__tests__/Resizable.test.js @@ -412,22 +412,22 @@ describe('render Resizable', () => { // in onResizeStop. See: https://github.com/react-grid-layout/react-grid-layout/pull/2224 test('onResizeStop reports correct size even when props are stale', () => { - // Create a fresh element with fresh mocks for this test const onResizeStop = jest.fn(); const onResize = jest.fn(); - const testProps = { - ...customProps, - onResize, - onResizeStop, - }; - const element = shallow({resizableBoxChildren}); - const seHandle = findHandle(element, 'se'); + const resizableRef = React.createRef(); + render( + + {resizableBoxChildren} + + ); // Simulate onResizeStart - seHandle.prop('onStart')(mockEvent, { node, deltaX: 0, deltaY: 0 }); + const startHandler = resizableRef.current.resizeHandler('onResizeStart', 'se'); + startHandler(mockEvent, { node, deltaX: 0, deltaY: 0 }); // Simulate dragging - this calls onResize with the new size - seHandle.prop('onDrag')(mockEvent, { node, deltaX: 20, deltaY: 30 }); + const dragHandler = resizableRef.current.resizeHandler('onResize', 'se'); + dragHandler(mockEvent, { node, deltaX: 20, deltaY: 30 }); expect(onResize).toHaveBeenLastCalledWith( mockEvent, expect.objectContaining({ @@ -439,7 +439,8 @@ describe('render Resizable', () => { // so props.width/height would still be 50. The deltaX/deltaY from DraggableCore's // onStop is typically 0 or very small since the mouse hasn't moved since the last // drag event. Without the fix, this would incorrectly report size: {width: 50, height: 50}. - seHandle.prop('onStop')(mockEvent, { node, deltaX: 0, deltaY: 0 }); + const stopHandler = resizableRef.current.resizeHandler('onResizeStop', 'se'); + stopHandler(mockEvent, { node, deltaX: 0, deltaY: 0 }); // With the fix, onResizeStop should report the same size as the last onResize expect(onResizeStop).toHaveBeenCalledWith( @@ -453,27 +454,27 @@ describe('render Resizable', () => { test('onResizeStop reports correct size for west handle with stale props', () => { const onResizeStop = jest.fn(); const onResize = jest.fn(); - const testProps = { - ...customProps, - onResize, - onResizeStop, - }; + const resizableRef = React.createRef(); const testMockClientRect = { left: 0, top: 0 }; const testNode = document.createElement('div'); - // $FlowIgnore testNode.getBoundingClientRect = () => ({ ...testMockClientRect }); - const element = shallow({resizableBoxChildren}); - const wHandle = findHandle(element, 'w'); + render( + + {resizableBoxChildren} + + ); // Simulate onResizeStart - this sets lastHandleRect to {left: 0, top: 0} - wHandle.prop('onStart')(mockEvent, { node: testNode, deltaX: 0, deltaY: 0 }); + const startHandler = resizableRef.current.resizeHandler('onResizeStart', 'w'); + startHandler(mockEvent, { node: testNode, deltaX: 0, deltaY: 0 }); // Simulate dragging west (left) // deltaX = -15 from drag, plus position adjustment of -15 (handle moved from 0 to -15) // Total deltaX = -30, reversed for 'w' = +30, so width = 50 + 30 = 80 + const dragHandler = resizableRef.current.resizeHandler('onResize', 'w'); testMockClientRect.left = -15; - wHandle.prop('onDrag')(mockEvent, { node: testNode, deltaX: -15, deltaY: 0 }); + dragHandler(mockEvent, { node: testNode, deltaX: -15, deltaY: 0 }); expect(onResize).toHaveBeenLastCalledWith( mockEvent, expect.objectContaining({ @@ -482,21 +483,24 @@ describe('render Resizable', () => { ); // Continue dragging - element moves further left + // position adjustment: -25 - (-15) = -10, deltaX becomes -10 + (-10) = -20 + // reversed for 'w' = +20, width = 50 + 20 = 70 testMockClientRect.left = -25; - wHandle.prop('onDrag')(mockEvent, { node: testNode, deltaX: -10, deltaY: 0 }); + dragHandler(mockEvent, { node: testNode, deltaX: -10, deltaY: 0 }); expect(onResize).toHaveBeenLastCalledWith( mockEvent, expect.objectContaining({ - size: { width: 100, height: 50 }, + size: { width: 70, height: 50 }, }) ); - // onResizeStop with stale props - should use stored lastSize - wHandle.prop('onStop')(mockEvent, { node: testNode, deltaX: 0, deltaY: 0 }); + // onResizeStop with stale props - should use stored lastSize (70x50 from last onResize) + const stopHandler = resizableRef.current.resizeHandler('onResizeStop', 'w'); + stopHandler(mockEvent, { node: testNode, deltaX: 0, deltaY: 0 }); expect(onResizeStop).toHaveBeenCalledWith( mockEvent, expect.objectContaining({ - size: { width: 100, height: 50 }, + size: { width: 70, height: 50 }, }) ); }); @@ -685,14 +689,17 @@ describe('render Resizable', () => { const node = document.createElement('div'); node.getBoundingClientRect = () => ({ left: 0, top: 0 }); - // First start + // Start resize const startHandler = resizableRef.current.resizeHandler('onResizeStart', 'se'); startHandler(mockEvent, { node, deltaX: 0, deltaY: 0 }); - // Then stop with a delta - Resizable is stateless so size is calculated from - // props.width/height + delta at time of stop + // Drag to resize - this stores the size for onResizeStop to use + const dragHandler = resizableRef.current.resizeHandler('onResize', 'se'); + dragHandler(mockEvent, { node, deltaX: 10, deltaY: 10 }); + + // Stop resize - uses the stored size from the last onResize const stopHandler = resizableRef.current.resizeHandler('onResizeStop', 'se'); - stopHandler(mockEvent, { node, deltaX: 10, deltaY: 10 }); + stopHandler(mockEvent, { node, deltaX: 0, deltaY: 0 }); expect(props.onResizeStop).toHaveBeenCalledWith( mockEvent,