From 4560c2098b1fde9e2ee5727fed579c71f45f1480 Mon Sep 17 00:00:00 2001 From: AK <144495202+AKnassa@users.noreply.github.com> Date: Fri, 3 Jul 2026 13:30:01 -0700 Subject: [PATCH] fix: measure overlay natural width before positioning Reset the overlay's left/right insets before measuring in useOverlayPosition, mirroring the existing top/bottom reset. A stale inset from the previous positioning pass clamps an auto-width overlay's shrink-to-fit width, so overlays near the right page edge opened too narrow and visibly widened step by step as each ResizeObserver pass grew them a little further. Both insets must be reset together, since physical-left placements position via a right inset and a leftover value would stretch the overlay during measurement instead. Closes #10050, closes #7017 --- .../src/overlays/useOverlayPosition.ts | 8 + .../overlays/UseOverlayPosition.stories.tsx | 101 +++++++++ .../test/overlays/useOverlayPosition.test.tsx | 192 ++++++++++++++++++ 3 files changed, 301 insertions(+) diff --git a/packages/react-aria/src/overlays/useOverlayPosition.ts b/packages/react-aria/src/overlays/useOverlayPosition.ts index b50f57525de..3a185980aa2 100644 --- a/packages/react-aria/src/overlays/useOverlayPosition.ts +++ b/packages/react-aria/src/overlays/useOverlayPosition.ts @@ -275,6 +275,14 @@ export function useOverlayPosition(props: AriaPositionProps): PositionAria { overlay.style.maxHeight = (window.visualViewport?.height ?? window.innerHeight) + 'px'; } + // Reset the horizontal insets before measuring as well. An auto-width overlay is otherwise + // clamped by the left/right position applied in a previous pass (shrink-to-fit against the + // containing block), so its natural width could never be measured after its content changes. + // Both insets must be reset together: leaving a stale `right` while setting `left: 0px` + // would stretch the overlay between them and over-measure it instead. + overlay.style.left = '0px'; + overlay.style.right = ''; + let position = calculatePosition({ placement: translateRTL(placement, direction), overlayNode: overlayRef.current, diff --git a/packages/react-aria/stories/overlays/UseOverlayPosition.stories.tsx b/packages/react-aria/stories/overlays/UseOverlayPosition.stories.tsx index 54d3c006b02..d12c76154f5 100644 --- a/packages/react-aria/stories/overlays/UseOverlayPosition.stories.tsx +++ b/packages/react-aria/stories/overlays/UseOverlayPosition.stories.tsx @@ -88,6 +88,91 @@ function Trigger(props: { ); } +function GrowingContentTrigger(props: {placement: Placement}): JSX.Element { + const {placement} = props; + const targetRef = React.useRef(null); + const state = useOverlayTriggerState({ + defaultOpen: false + }); + const {triggerProps, overlayProps} = useOverlayTrigger( + { + type: 'menu' + }, + state, + targetRef + ); + + return ( + // Pin the trigger to the right viewport edge regardless of story decorators, + // so the overlay has to fit its width against the page boundary (issue #10050). +
+ + {state.isOpen && + ReactDOM.createPortal( + , + document.body + )} +
+ ); +} + +// The overlay is a separate component that mounts when the trigger opens, like +// usePopover-based components, so that useOverlayPosition's ResizeObserver is +// attached to the overlay element and repositions it when its content changes. +function GrowingContentOverlay(props: { + overlayProps: React.HTMLAttributes; + placement: Placement; + targetRef: React.RefObject; +}): JSX.Element { + const {targetRef, overlayProps, placement} = props; + const overlayRef = React.useRef(null); + const {overlayProps: overlayPositionProps} = useOverlayPosition({ + targetRef, + overlayRef, + placement + }); + + // Simulate content that renders after the initial positioning pass, + // e.g. menu items with descriptions populating on a second render (issue #10050). + const [showDescriptions, setShowDescriptions] = React.useState(false); + React.useEffect(() => { + const timeout = setTimeout(() => setShowDescriptions(true), 1500); + return () => clearTimeout(timeout); + }, []); + + return ( +
+
    + {[...Array(3)].map((_, i) => ( +
  • + Menu item {i} + {showDescriptions && + ' — a longer description that widens the overlay after it has been positioned'} +
  • + ))} +
+
+ ); +} + export default { title: 'UseOverlayPosition' }; @@ -147,3 +232,19 @@ export const MaxHeight200ContainerTop: TriggerStory = () => ( MaxHeight200ContainerTop.story = { name: 'maxHeight=200 container top' }; + +export const GrowingContentRightEdge: StoryFn = () => ( + +); + +GrowingContentRightEdge.story = { + name: 'growing content at right page edge (#10050)' +}; + +export const GrowingContentRightEdgeStart: StoryFn = () => ( + +); + +GrowingContentRightEdgeStart.story = { + name: 'growing content at right page edge, start top (#10050)' +}; diff --git a/packages/react-aria/test/overlays/useOverlayPosition.test.tsx b/packages/react-aria/test/overlays/useOverlayPosition.test.tsx index cb517994598..124560586b6 100644 --- a/packages/react-aria/test/overlays/useOverlayPosition.test.tsx +++ b/packages/react-aria/test/overlays/useOverlayPosition.test.tsx @@ -11,6 +11,7 @@ */ import {fireEvent, render} from '@react-spectrum/test-utils-internal'; +import {I18nProvider} from '../../src/i18n/I18nProvider'; import React, {useRef} from 'react'; import {useOverlayPosition} from '../../src/overlays/useOverlayPosition'; @@ -47,6 +48,31 @@ function Example({ ); } +function AutoWidthExample({triggerLeft = 360, ...props}) { + let targetRef = useRef(null); + let overlayRef = useRef(null); + let {overlayProps, placement} = useOverlayPosition({ + targetRef, + overlayRef, + ...props + }); + return ( + +
+ Trigger +
+
+
+ placement: {placement} +
+
+
+ ); +} + let original = window.HTMLElement.prototype.getBoundingClientRect; HTMLElement.prototype.getBoundingClientRect = function () { let rect = original.apply(this); @@ -260,6 +286,172 @@ describe('useOverlayPosition', function () { expect(arrow).toHaveAttribute('aria-hidden', 'true'); expect(arrow).toHaveAttribute('role', 'presentation'); }); + + describe('auto-width overlay (shrink-to-fit)', function () { + // The natural (preferred) width of the overlay's content. Mutated by tests to + // simulate content that grows after the initial positioning pass (e.g. RAC + // collections populating on a second render). + let overlayNaturalWidth = 60; + + beforeEach(() => { + overlayNaturalWidth = 60; + + // setupTests.js defines document.documentElement.clientWidth = 1024, which would + // disagree with the 500px visualViewport/body used by this suite. Align them so + // boundary, containing block, and viewport all share one width. + Object.defineProperty(document.documentElement, 'clientWidth', { + writable: true, + configurable: true, + value: 500 + }); + + // Simulate the browser's shrink-to-fit sizing of a position: absolute element + // with width: auto: the available width is capped by the applied left/right + // insets against a 500px containing block, and setting both insets stretches + // the element between them. + jest + .spyOn(HTMLElement.prototype, 'offsetWidth', 'get') + .mockImplementation(function (this: HTMLElement) { + if (this.getAttribute?.('data-testid') === 'overlay') { + let containingBlockWidth = 500; + let hasLeft = this.style.left !== ''; + let hasRight = this.style.right !== ''; + if (hasLeft && hasRight) { + return Math.max( + containingBlockWidth - + (parseInt(this.style.left, 10) || 0) - + (parseInt(this.style.right, 10) || 0), + 0 + ); + } + let inset = hasLeft + ? parseInt(this.style.left, 10) || 0 + : hasRight + ? parseInt(this.style.right, 10) || 0 + : 0; + return Math.min(overlayNaturalWidth, containingBlockWidth - inset); + } + return parseInt(this.style.width, 10) || 0; + }); + }); + + it('should measure natural width when the content grows, not the width clamped by the stale position', function () { + let res = render(); + let overlay = res.getByTestId('overlay'); + + // 60px wide overlay aligned to the trigger's right edge (360 + 100 - 60). + expect(overlay).toHaveStyle(` + left: 400px; + top: 350px; + `); + + // The content grows (e.g. menu items with descriptions render). In the browser + // this fires the overlay's ResizeObserver; in jsdom useResizeObserver falls + // back to the window resize event. + overlayNaturalWidth = 300; + fireEvent(window, new Event('resize')); + + // The overlay must be measured at its natural width (300px) and repositioned in + // a single pass (360 + 100 - 300). With a stale left of 400px the measurement + // is clamped to 100px and the overlay only creeps toward its final position. + expect(overlay).toHaveStyle(` + left: 160px; + top: 350px; + `); + }); + + it('should not measure the overlay stretched between a stale right inset and the measurement reset', function () { + // A physical left placement positions the overlay via a `right` inset. + let res = render(); + let overlay = res.getByTestId('overlay'); + + // 60px wide overlay anchored to the left of the trigger: right = 500 - 180. + expect(overlay).toHaveStyle(` + right: 320px; + top: 250px; + `); + expect(overlay).toHaveTextContent('placement: left'); + + // The content grows, but still fits to the left of the trigger (100px < 168px + // of available space), so the overlay must stay on the left. + overlayNaturalWidth = 100; + fireEvent(window, new Event('resize')); + + // If the measurement reset sets left: 0px while the stale right: 320px is still + // applied, the overlay is stretched between both insets (500 - 0 - 320 = 180px), + // over-measuring its width and spuriously flipping it to the right. + expect(overlay).toHaveStyle(` + right: 320px; + top: 250px; + `); + expect(overlay).toHaveTextContent('placement: left'); + }); + + it('should measure natural width when the user provides a maxHeight', function () { + // A user maxHeight only affects the vertical measurement reset; the horizontal + // reset must happen regardless. + let res = render(); + let overlay = res.getByTestId('overlay'); + + expect(overlay).toHaveStyle(` + left: 400px; + top: 350px; + `); + + overlayNaturalWidth = 300; + fireEvent(window, new Event('resize')); + + expect(overlay).toHaveStyle(` + left: 160px; + top: 350px; + `); + }); + + it('should measure natural width in RTL, where end placements position via a right inset', function () { + let res = render( + + + + ); + let overlay = res.getByTestId('overlay'); + + // In RTL, 'end top' resolves to the physical 'left top' placement. + expect(overlay).toHaveStyle(` + right: 320px; + top: 250px; + `); + expect(overlay).toHaveTextContent('placement: left'); + + overlayNaturalWidth = 100; + fireEvent(window, new Event('resize')); + + expect(overlay).toHaveStyle(` + right: 320px; + top: 250px; + `); + expect(overlay).toHaveTextContent('placement: left'); + }); + + it('should leave only one horizontal inset applied after the placement changes', function () { + let res = render(); + let overlay = res.getByTestId('overlay'); + + expect(overlay).toHaveStyle(` + right: 320px; + top: 250px; + `); + + res.rerender(); + + // 180 + 100 - 60 = 220. The stale right inset from the previous placement must + // be gone so the overlay is positioned by exactly one horizontal inset. + expect(overlay).toHaveStyle(` + left: 220px; + top: 350px; + `); + expect(overlay.style.right).toBe(''); + }); + }); }); describe('useOverlayPosition with positioned container', () => {