Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "perf(react-resize-handle): skip measuring and updating handle attributes when the wrapper ref detaches",
"packageName": "@fluentui-contrib/react-resize-handle",
"email": "141153077+andreivms@users.noreply.github.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
Expand Up @@ -510,4 +510,65 @@ test.describe('useResizeHandle', () => {
});
});
});

test.describe('unmount', () => {
type UnmountCounters = {
elementMeasurements: number;
handleAttributeWrites: number;
};

for (const relative of [false, true]) {
test(`does not measure the element or update the handle while unmounting (relative: ${relative})`, async ({
mount,
page,
}) => {
const result = await mountTest(mount, { relative });

await expect(result.dragEl).toHaveAttribute(
'aria-orientation',
'horizontal'
);

await page.evaluate(() => {
const counters: UnmountCounters = {
elementMeasurements: 0,
handleAttributeWrites: 0,
};
const originalGetBoundingClientRect =
Element.prototype.getBoundingClientRect;
const originalSetAttribute = Element.prototype.setAttribute;

(
window as unknown as { __unmountCounters: UnmountCounters }
).__unmountCounters = counters;

Element.prototype.getBoundingClientRect = function () {
if (this.getAttribute('data-testid') === 'resizable') {
counters.elementMeasurements++;
}

return originalGetBoundingClientRect.call(this);
};
Element.prototype.setAttribute = function (name, value) {
if (this.getAttribute('role') === 'slider') {
counters.handleAttributeWrites++;
}

return originalSetAttribute.call(this, name, value);
};
});

await result.component.unmount();

await expect(result.wrapperEl).toHaveCount(0);
expect(
await page.evaluate(
() =>
(window as unknown as { __unmountCounters: UnmountCounters })
.__unmountCounters
)
).toEqual({ elementMeasurements: 0, handleAttributeWrites: 0 });
});
}
});
});
79 changes: 77 additions & 2 deletions packages/react-resize-handle/src/hooks/useResizeHandle.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,17 @@ import * as React from 'react';
import { useResizeHandle, UseResizeHandleParams } from './useResizeHandle';

const TestComponent = (
props: Pick<UseResizeHandleParams, 'onDragStart' | 'onDragEnd' | 'onChange'>
props: Pick<
UseResizeHandleParams,
'onDragStart' | 'onDragEnd' | 'onChange' | 'relative'
>
) => {
const { onChange, onDragStart, onDragEnd } = props;
const { onChange, onDragStart, onDragEnd, relative } = props;

const { elementRef, handleRef, wrapperRef } = useResizeHandle({
growDirection: 'end',
variableName: '--width',
relative,

onChange,
onDragEnd,
Expand All @@ -28,6 +32,77 @@ const TestComponent = (
jest.useFakeTimers();

describe('useResizeHandle', () => {
describe('refs', () => {
const RefsTestComponent = (
props: Pick<UseResizeHandleParams, 'relative'> & { withWrapper?: boolean }
) => {
const { relative, withWrapper = true } = props;
const { elementRef, handleRef, wrapperRef } = useResizeHandle({
growDirection: 'end',
variableName: '--width',
relative,
});

return (
<>
{withWrapper && <div ref={wrapperRef} data-testid="wrapper" />}
<div ref={elementRef} data-testid="element" />
<div ref={handleRef} data-testid="handle" />
</>
);
};

it.each([
['relative', true],
['absolute', false],
])(
'does not measure the element or update the handle when unmounting (%s mode)',
(_, relative) => {
const { getByTestId, unmount } = render(
<TestComponent relative={relative} />
);

const elementEl = getByTestId('element');
const handleEl = getByTestId('handle');

expect(handleEl.getAttribute('role')).toBe('slider');

const getBoundingClientRectSpy = jest.spyOn(
elementEl,
'getBoundingClientRect'
);
const setAttributeSpy = jest.spyOn(handleEl, 'setAttribute');

unmount();

expect(getBoundingClientRectSpy).not.toHaveBeenCalled();
expect(setAttributeSpy).not.toHaveBeenCalled();
}
);

it('updates handle attributes when the wrapper is attached, but not when it is detached', () => {
const { getByTestId, rerender } = render(
<RefsTestComponent relative withWrapper={false} />
);

const elementEl = getByTestId('element');
const handleEl = getByTestId('handle');

jest
.spyOn(elementEl, 'getBoundingClientRect')
.mockReturnValue({ width: 120 } as DOMRect);

rerender(<RefsTestComponent relative withWrapper />);
expect(handleEl.getAttribute('aria-valuetext')).toBe('120px');

handleEl.removeAttribute('aria-valuetext');
rerender(<RefsTestComponent relative withWrapper={false} />);

expect(elementEl.getBoundingClientRect).toHaveBeenCalledTimes(1);
expect(handleEl.hasAttribute('aria-valuetext')).toBe(false);
});
});

describe('events', () => {
it('should call events in proper order', () => {
const onChange = jest.fn();
Expand Down
4 changes: 3 additions & 1 deletion packages/react-resize-handle/src/hooks/useResizeHandle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,7 +351,9 @@ export const useResizeHandle = (
(node) => {
wrapperRef.current = node;

if (elementRef.current) {
// Handle attributes don't depend on the wrapper. Skipping the update on detach avoids measuring the element
// (a forced layout in "relative" mode) and rewriting handle attributes while the elements are being unmounted.
if (node && elementRef.current) {
updateElementsAttrs({
type: EVENTS.wrapperRef,
event: new CustomEvent(EVENTS.wrapperRef),
Expand Down