diff --git a/change/@fluentui-contrib-react-resize-handle-1516651a-bb12-4174-b5c7-45306d230d00.json b/change/@fluentui-contrib-react-resize-handle-1516651a-bb12-4174-b5c7-45306d230d00.json new file mode 100644 index 000000000..0be86a0e6 --- /dev/null +++ b/change/@fluentui-contrib-react-resize-handle-1516651a-bb12-4174-b5c7-45306d230d00.json @@ -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" +} diff --git a/packages/react-resize-handle/src/hooks/useResizeHandle.component-browser-spec.tsx b/packages/react-resize-handle/src/hooks/useResizeHandle.component-browser-spec.tsx index 6529db0b9..839374723 100644 --- a/packages/react-resize-handle/src/hooks/useResizeHandle.component-browser-spec.tsx +++ b/packages/react-resize-handle/src/hooks/useResizeHandle.component-browser-spec.tsx @@ -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 }); + }); + } + }); }); diff --git a/packages/react-resize-handle/src/hooks/useResizeHandle.test.tsx b/packages/react-resize-handle/src/hooks/useResizeHandle.test.tsx index 187979d5d..4d9a12e5e 100644 --- a/packages/react-resize-handle/src/hooks/useResizeHandle.test.tsx +++ b/packages/react-resize-handle/src/hooks/useResizeHandle.test.tsx @@ -4,13 +4,17 @@ import * as React from 'react'; import { useResizeHandle, UseResizeHandleParams } from './useResizeHandle'; const TestComponent = ( - props: Pick + 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, @@ -28,6 +32,77 @@ const TestComponent = ( jest.useFakeTimers(); describe('useResizeHandle', () => { + describe('refs', () => { + const RefsTestComponent = ( + props: Pick & { withWrapper?: boolean } + ) => { + const { relative, withWrapper = true } = props; + const { elementRef, handleRef, wrapperRef } = useResizeHandle({ + growDirection: 'end', + variableName: '--width', + relative, + }); + + return ( + <> + {withWrapper &&
} +
+
+ + ); + }; + + it.each([ + ['relative', true], + ['absolute', false], + ])( + 'does not measure the element or update the handle when unmounting (%s mode)', + (_, relative) => { + const { getByTestId, unmount } = render( + + ); + + 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( + + ); + + const elementEl = getByTestId('element'); + const handleEl = getByTestId('handle'); + + jest + .spyOn(elementEl, 'getBoundingClientRect') + .mockReturnValue({ width: 120 } as DOMRect); + + rerender(); + expect(handleEl.getAttribute('aria-valuetext')).toBe('120px'); + + handleEl.removeAttribute('aria-valuetext'); + rerender(); + + 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(); diff --git a/packages/react-resize-handle/src/hooks/useResizeHandle.ts b/packages/react-resize-handle/src/hooks/useResizeHandle.ts index 5130e6830..8ae269610 100644 --- a/packages/react-resize-handle/src/hooks/useResizeHandle.ts +++ b/packages/react-resize-handle/src/hooks/useResizeHandle.ts @@ -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),