Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 108 additions & 4 deletions __tests__/Resizable.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,107 @@ 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', () => {
const onResizeStop = jest.fn();
const onResize = jest.fn();
const resizableRef = React.createRef();
render(
<Resizable {...customProps} onResize={onResize} onResizeStop={onResizeStop} ref={resizableRef}>
{resizableBoxChildren}
</Resizable>
);

// Simulate onResizeStart
const startHandler = resizableRef.current.resizeHandler('onResizeStart', 'se');
startHandler(mockEvent, { node, deltaX: 0, deltaY: 0 });

// Simulate dragging - this calls onResize with the new size
const dragHandler = resizableRef.current.resizeHandler('onResize', 'se');
dragHandler(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}.
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(
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 resizableRef = React.createRef();
const testMockClientRect = { left: 0, top: 0 };
const testNode = document.createElement('div');
testNode.getBoundingClientRect = () => ({ ...testMockClientRect });

render(
<Resizable {...customProps} onResize={onResize} onResizeStop={onResizeStop} ref={resizableRef}>
{resizableBoxChildren}
</Resizable>
);

// Simulate onResizeStart - this sets lastHandleRect to {left: 0, top: 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;
dragHandler(mockEvent, { node: testNode, deltaX: -15, deltaY: 0 });
expect(onResize).toHaveBeenLastCalledWith(
mockEvent,
expect.objectContaining({
size: { width: 80, height: 50 },
})
);

// 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;
dragHandler(mockEvent, { node: testNode, deltaX: -10, deltaY: 0 });
expect(onResize).toHaveBeenLastCalledWith(
mockEvent,
expect.objectContaining({
size: { width: 70, height: 50 },
})
);

// 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: 70, height: 50 },
})
);
});
});
});

// ============================================
Expand Down Expand Up @@ -588,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,
Expand Down
15 changes: 14 additions & 1 deletion lib/Resizable.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,14 @@ export default class Resizable extends React.Component<Props, void> {
handleRefs: {[key: ResizeHandleAxis]: ReactRef<HTMLElement>} = {};
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
Expand Down Expand Up @@ -132,8 +133,20 @@ export default class Resizable extends React.Component<Props, void> {
// 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.
Expand Down
Loading