From 391ac60ed37623b9940258320f7c246e1131c62b Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Thu, 21 May 2026 13:34:58 +0200 Subject: [PATCH 1/5] Add SelectPanel outside-top regression fixture --- .../SelectPanel.examples.stories.tsx | 55 +++++++++++++++++-- 1 file changed, 49 insertions(+), 6 deletions(-) diff --git a/packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx b/packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx index 5d9058bcefd..b821a9facc9 100644 --- a/packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx @@ -356,7 +356,6 @@ export const RepositionAfterLoading = () => { const [loading, setLoading] = useState(true) React.useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect if (!open) setLoading(true) window.setTimeout(() => { if (open) { @@ -369,7 +368,6 @@ export const RepositionAfterLoading = () => { React.useEffect(() => { if (!loading) { - // eslint-disable-next-line react-hooks/set-state-in-effect setFilteredItems(items.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase()))) } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -405,7 +403,6 @@ export const SelectPanelRepositionInsideDialog = () => { const [loading, setLoading] = useState(true) React.useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect if (!open) setLoading(true) window.setTimeout(() => { if (open) { @@ -418,7 +415,6 @@ export const SelectPanelRepositionInsideDialog = () => { React.useEffect(() => { if (!loading) { - // eslint-disable-next-line react-hooks/set-state-in-effect setFilteredItems(items.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase()))) } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -438,7 +434,6 @@ export const SelectPanelRepositionInsideDialog = () => { selected={selected} onSelectedChange={setSelected} onFilterChange={setFilter} - overlayProps={{anchorSide: 'outside-top'}} message={filteredItems.length === 0 ? NoResultsMessage(filter) : undefined} /> @@ -446,6 +441,55 @@ export const SelectPanelRepositionInsideDialog = () => { ) } +export const AutogrowAfterLoadingWithOutsideTopAnchor = () => { + const autogrowItems = [...items] + + const [selected, setSelected] = React.useState([autogrowItems[0], autogrowItems[1]]) + const [open, setOpen] = useState(false) + const [filter, setFilter] = React.useState('') + const [filteredItems, setFilteredItems] = React.useState([]) + const [loading, setLoading] = useState(true) + + React.useEffect(() => { + if (!open) setLoading(true) + const timer = window.setTimeout(() => { + if (open) { + setFilteredItems(autogrowItems.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase()))) + setLoading(false) + } + }, 2000) + + return () => window.clearTimeout(timer) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]) + + React.useEffect(() => { + if (!loading) { + setFilteredItems(autogrowItems.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase()))) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filter]) + + return ( + +

Autogrow panel after loading with outside-top anchor

+ +
+ ) +} + export const WithDefaultMessage = () => { const [selected, setSelected] = useState(items.slice(1, 3)) const [filter, setFilter] = useState('') @@ -624,7 +668,6 @@ export const VirtualizedConsumerSide = () => { [open], ) - // eslint-disable-next-line react-hooks/incompatible-library const virtualizer = useVirtualizer({ count: filteredItems.length, getScrollElement: () => scrollContainer ?? null, From 2ddd3210fe065762a738be073d568b04453db04e Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Thu, 21 May 2026 12:26:00 +0200 Subject: [PATCH 2/5] chore: add first-open sizing investigation artifacts --- .../SelectPanel.examples.stories.tsx | 99 +++++++++++ .../src/SelectPanel/SelectPanel.test.tsx | 156 ++++++++++++++++++ 2 files changed, 255 insertions(+) diff --git a/packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx b/packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx index b821a9facc9..bc022efcbce 100644 --- a/packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx @@ -886,3 +886,102 @@ export const VirtualizedBuiltIn = () => { ) } + +/** + * Demonstrates the first-open sizing regression where the overlay + * appears too small and shows a scrollbar unnecessarily on first open. + * After closing and reopening, it usually renders correctly. + * + * Issue: https://github.com/primer/react/issues/1831 + */ +export const FirstOpenSizingDebug = () => { + const [selected, setSelected] = useState([]) + const [open, setOpen] = useState(false) + const [loading, setLoading] = useState(true) + const [filteredItems, setFilteredItems] = useState([]) + const [filter, setFilter] = useState('') + const overlayRef = useRef(null) + const [measurements, setMeasurements] = useState<{ + clientHeight?: number + scrollHeight?: number + scrollWidth?: number + hasScrollbar?: boolean + timestamp?: string + }>({}) + + // Simulate loading delay similar to the real issue + React.useEffect(() => { + if (!open) { + setLoading(true) + } + const timer = window.setTimeout(() => { + if (open) { + setFilteredItems(items.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase()))) + setLoading(false) + } + }, 1500) + return () => window.clearTimeout(timer) + }, [open, filter]) + + // Measure overlay dimensions every 100ms to catch sizing changes + React.useEffect(() => { + if (!open) return + + const interval = window.setInterval(() => { + const overlay = document.querySelector('[data-testid="overlay"]') as HTMLElement | null + if (overlay) { + const hasScrollbar = overlay.scrollHeight > overlay.clientHeight + setMeasurements({ + clientHeight: overlay.clientHeight, + scrollHeight: overlay.scrollHeight, + scrollWidth: overlay.scrollWidth, + hasScrollbar, + timestamp: new Date().toLocaleTimeString(), + }) + + console.log( + `[SelectPanel] Overlay measurements: clientHeight=${overlay.clientHeight}, scrollHeight=${overlay.scrollHeight}, hasScrollbar=${hasScrollbar}`, + ) + } + }, 100) + + return () => window.clearInterval(interval) + }, [open]) + + return ( + + First-Open Sizing Debug +
+

+ Open={open} | Loading={loading} | Last measurement: {measurements.timestamp} +

+

+ Overlay size: {measurements.clientHeight}px (client) × {measurements.scrollHeight}px (scroll) +

+

+ Scrollbar: {measurements.hasScrollbar ? '✓ PRESENT (BUG!)' : '✗ Not present'} +

+
+ +
+ ) +} diff --git a/packages/react/src/SelectPanel/SelectPanel.test.tsx b/packages/react/src/SelectPanel/SelectPanel.test.tsx index c1d71b154c7..d43b751faff 100644 --- a/packages/react/src/SelectPanel/SelectPanel.test.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.test.tsx @@ -2291,3 +2291,159 @@ describe('SelectPanel displayInViewport prop', () => { expect(lastCall[2]?.displayInViewport).not.toBe(true) }) }) + +/** + * Test suite for SelectPanel first-open sizing regression + * Issue: https://github.com/primer/react/issues/1831 + * + * On first open with loading state, SelectPanel may render shorter than its content + * and show an unnecessary scrollbar. This occurs because position is calculated before + * items have loaded and rendered, using the spinner/loading state height rather than + * the final content height. + */ +describe('SelectPanel - First-Open Sizing with Loading State', () => { + const items: ItemInput[] = [ + {id: '1', text: 'Item 1'}, + {id: '2', text: 'Item 2'}, + {id: '3', text: 'Item 3'}, + {id: '4', text: 'Item 4'}, + {id: '5', text: 'Item 5'}, + {id: '6', text: 'Item 6'}, + {id: '7', text: 'Item 7'}, + {id: '8', text: 'Item 8'}, + ] + + const TestComponentWithLoadingDelay = () => { + const [selected, setSelected] = React.useState(items[0]) + const [open, setOpen] = React.useState(false) + const [loading, setLoading] = React.useState(true) + const [visibleItems, setVisibleItems] = React.useState([]) + + // Simulate items loading after a delay (typical async data fetch) + React.useEffect(() => { + if (!open) { + setLoading(true) + setVisibleItems([]) + } + + const timer = setTimeout(() => { + if (open) { + setVisibleItems(items) + setLoading(false) + } + }, 500) // 500ms delay simulates network/async operation + + return () => clearTimeout(timer) + }, [open]) + + return ( + <> + +
+ open:{open} loading:{loading} itemsCount:{visibleItems.length} +
+ + ) + } + + it('should measure overlay height correctly while loading', async () => { + const user = userEvent.setup() + render() + + const button = screen.getByRole('button', {name: /Select item/i}) + + // Open SelectPanel for the first time + await user.click(button) + + // Wait for overlay to appear (should show loading spinner) + await waitFor( + () => { + expect(screen.getByText(/^Item 1$/)).toBeInTheDocument() + }, + {timeout: 1000}, + ) + + // Get overlay measurements while still loading + const overlay = document.querySelector('[data-testid="overlay"]') as HTMLElement | null + const clientHeightWhileLoading = overlay?.clientHeight + const scrollHeightWhileLoading = overlay?.scrollHeight + + // Wait for items to load + await waitFor( + () => { + const state = screen.getByTestId('state') + expect(state).toHaveTextContent('loading:false') + }, + {timeout: 1500}, + ) + + // Get overlay measurements after loading + const clientHeightAfterLoading = overlay?.clientHeight + const scrollHeightAfterLoading = overlay?.scrollHeight + + // Log measurements for debugging + console.log('First-open sizing measurements:', { + whileLoading: {clientHeight: clientHeightWhileLoading, scrollHeight: scrollHeightWhileLoading}, + afterLoading: {clientHeight: clientHeightAfterLoading, scrollHeight: scrollHeightAfterLoading}, + }) + + // The overlay should have enough height to fit all content without scrollbar + if (clientHeightAfterLoading && scrollHeightAfterLoading) { + const hasScrollbar = scrollHeightAfterLoading > clientHeightAfterLoading + expect(hasScrollbar).toBe(false) + } + }) + + it('should have consistent height between first and second open', async () => { + const user = userEvent.setup() + const {unmount, rerender} = render() + + const button = screen.getByRole('button', {name: /Select item/i}) + + // First open + await user.click(button) + await waitFor(() => { + const state = screen.getByTestId('state') + expect(state).toHaveTextContent('loading:false') + }) + + const overlay1 = document.querySelector('[data-testid="overlay"]') + const height1 = overlay1?.getBoundingClientRect().height + + // Close + await user.click(button) + await waitFor(() => { + const state = screen.getByTestId('state') + expect(state).toHaveTextContent('open:false') + }) + + // Second open (loading state happens again) + await user.click(button) + await waitFor( + () => { + const state = screen.getByTestId('state') + expect(state).toHaveTextContent('loading:false') + }, + {timeout: 1500}, + ) + + const overlay2 = document.querySelector('[data-testid="overlay"]') + const height2 = overlay2?.getBoundingClientRect().height + + // Heights should be very similar (allowing for minor variations) + if (height1 && height2) { + const difference = Math.abs(height1 - height2) + console.log(`Height consistency check: first=${height1}px, second=${height2}px, diff=${difference}px`) + expect(difference).toBeLessThan(50) // Allow max 50px variance + } + }) +}) From 3bd2e84844164c39b9d9342d2207b1aac7df1cc8 Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Mon, 1 Jun 2026 13:55:44 +0200 Subject: [PATCH 3/5] Add VRT test for outside-top autogrow story --- e2e/components/SelectPanel.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/e2e/components/SelectPanel.test.ts b/e2e/components/SelectPanel.test.ts index da50e58b652..9228b2677bb 100644 --- a/e2e/components/SelectPanel.test.ts +++ b/e2e/components/SelectPanel.test.ts @@ -156,4 +156,21 @@ test.describe('SelectPanel', () => { `SelectPanel-features--with-notice-light.png`, ) }) + + test(`Autogrow after loading with outside-top anchor @vrt`, async ({page}) => { + await visit(page, { + id: 'components-selectpanel-examples--autogrow-after-loading-with-outside-top-anchor', + }) + + // Open select panel + await page.keyboard.press('Tab') + await page.keyboard.press('Enter') + + // Wait for items to load (story has 2s delay) + await page.getByRole('option', {name: 'enhancement'}).waitFor({state: 'visible', timeout: 5000}) + + expect(await page.screenshot({animations: 'disabled', caret: 'hide'})).toMatchSnapshot( + `SelectPanel.Autogrow-after-loading-outside-top.png`, + ) + }) }) From c71532865af5d0ce5e0844a0cdaff037645b9797 Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Mon, 1 Jun 2026 13:57:51 +0200 Subject: [PATCH 4/5] Remove FirstOpenSizingDebug story --- .../SelectPanel.examples.stories.tsx | 99 ------------------- 1 file changed, 99 deletions(-) diff --git a/packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx b/packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx index bc022efcbce..b821a9facc9 100644 --- a/packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx @@ -886,102 +886,3 @@ export const VirtualizedBuiltIn = () => { ) } - -/** - * Demonstrates the first-open sizing regression where the overlay - * appears too small and shows a scrollbar unnecessarily on first open. - * After closing and reopening, it usually renders correctly. - * - * Issue: https://github.com/primer/react/issues/1831 - */ -export const FirstOpenSizingDebug = () => { - const [selected, setSelected] = useState([]) - const [open, setOpen] = useState(false) - const [loading, setLoading] = useState(true) - const [filteredItems, setFilteredItems] = useState([]) - const [filter, setFilter] = useState('') - const overlayRef = useRef(null) - const [measurements, setMeasurements] = useState<{ - clientHeight?: number - scrollHeight?: number - scrollWidth?: number - hasScrollbar?: boolean - timestamp?: string - }>({}) - - // Simulate loading delay similar to the real issue - React.useEffect(() => { - if (!open) { - setLoading(true) - } - const timer = window.setTimeout(() => { - if (open) { - setFilteredItems(items.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase()))) - setLoading(false) - } - }, 1500) - return () => window.clearTimeout(timer) - }, [open, filter]) - - // Measure overlay dimensions every 100ms to catch sizing changes - React.useEffect(() => { - if (!open) return - - const interval = window.setInterval(() => { - const overlay = document.querySelector('[data-testid="overlay"]') as HTMLElement | null - if (overlay) { - const hasScrollbar = overlay.scrollHeight > overlay.clientHeight - setMeasurements({ - clientHeight: overlay.clientHeight, - scrollHeight: overlay.scrollHeight, - scrollWidth: overlay.scrollWidth, - hasScrollbar, - timestamp: new Date().toLocaleTimeString(), - }) - - console.log( - `[SelectPanel] Overlay measurements: clientHeight=${overlay.clientHeight}, scrollHeight=${overlay.scrollHeight}, hasScrollbar=${hasScrollbar}`, - ) - } - }, 100) - - return () => window.clearInterval(interval) - }, [open]) - - return ( - - First-Open Sizing Debug -
-

- Open={open} | Loading={loading} | Last measurement: {measurements.timestamp} -

-

- Overlay size: {measurements.clientHeight}px (client) × {measurements.scrollHeight}px (scroll) -

-

- Scrollbar: {measurements.hasScrollbar ? '✓ PRESENT (BUG!)' : '✗ Not present'} -

-
- -
- ) -} From 9dabdb545003afa4ce27193aa750cb2546f35d8d Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Mon, 1 Jun 2026 14:09:48 +0200 Subject: [PATCH 5/5] Move outside-top autogrow story and VRT test to AnchoredOverlay - Add RepositionAfterContentGrowsWithOutsideTop story to AnchoredOverlay.dev.stories.tsx - Add VRT test for the new story in AnchoredOverlay.test.ts - Remove duplicate SelectPanel story and test --- e2e/components/AnchoredOverlay.test.ts | 5 ++ e2e/components/SelectPanel.test.ts | 17 ------- .../AnchoredOverlay.dev.stories.tsx | 50 +++++++++++++++++++ .../SelectPanel.examples.stories.tsx | 49 ------------------ 4 files changed, 55 insertions(+), 66 deletions(-) diff --git a/e2e/components/AnchoredOverlay.test.ts b/e2e/components/AnchoredOverlay.test.ts index 37bf305df1a..e02a6c89744 100644 --- a/e2e/components/AnchoredOverlay.test.ts +++ b/e2e/components/AnchoredOverlay.test.ts @@ -114,6 +114,11 @@ const stories: Array<{ id: 'components-anchoredoverlay-dev--reposition-after-content-grows', waitForText: 'content with 300px height', }, + { + title: 'Reposition After Content Grows With Outside Top', + id: 'components-anchoredoverlay-dev--reposition-after-content-grows-with-outside-top', + waitForText: 'content with 300px height', + }, { title: 'Reposition After Content Grows Within Dialog', id: 'components-anchoredoverlay-dev--reposition-after-content-grows-within-dialog', diff --git a/e2e/components/SelectPanel.test.ts b/e2e/components/SelectPanel.test.ts index 9228b2677bb..da50e58b652 100644 --- a/e2e/components/SelectPanel.test.ts +++ b/e2e/components/SelectPanel.test.ts @@ -156,21 +156,4 @@ test.describe('SelectPanel', () => { `SelectPanel-features--with-notice-light.png`, ) }) - - test(`Autogrow after loading with outside-top anchor @vrt`, async ({page}) => { - await visit(page, { - id: 'components-selectpanel-examples--autogrow-after-loading-with-outside-top-anchor', - }) - - // Open select panel - await page.keyboard.press('Tab') - await page.keyboard.press('Enter') - - // Wait for items to load (story has 2s delay) - await page.getByRole('option', {name: 'enhancement'}).waitFor({state: 'visible', timeout: 5000}) - - expect(await page.screenshot({animations: 'disabled', caret: 'hide'})).toMatchSnapshot( - `SelectPanel.Autogrow-after-loading-outside-top.png`, - ) - }) }) diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.dev.stories.tsx b/packages/react/src/AnchoredOverlay/AnchoredOverlay.dev.stories.tsx index fc95111e9a3..f0644b63ac3 100644 --- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.dev.stories.tsx +++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.dev.stories.tsx @@ -59,6 +59,56 @@ export const RepositionAfterContentGrows = () => { ) } +export const RepositionAfterContentGrowsWithOutsideTop = () => { + const [open, setOpen] = useState(false) + const [loading, setLoading] = useState(true) + + React.useEffect(() => { + if (!open) setLoading(true) + const timer = window.setTimeout(() => { + if (open) setLoading(false) + }, 2000) + return () => window.clearTimeout(timer) + }, [open]) + + return ( + +
+ What to expect: +
    +
  • The anchored overlay should open above the anchor (outside-top)
  • +
  • After 2000ms, the amount of content in the overlay grows
  • +
  • The overlay should grow upward, staying attached at the bottom to the anchor
  • +
  • Bug: without fix, the overlay briefly flickers downward before repositioning
  • +
+
+ ( + + )} + open={open} + onOpen={() => setOpen(true)} + onClose={() => { + setOpen(false) + setLoading(true) + }} + side="outside-top" + > + {loading ? ( + <> + + loading for 2000ms + + ) : ( +
content with 300px height
+ )} +
+
+ ) +} + export const ScrollRecalculation = () => { const [open, setOpen] = useState(false) diff --git a/packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx b/packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx index b821a9facc9..4d28218fe1b 100644 --- a/packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx @@ -441,55 +441,6 @@ export const SelectPanelRepositionInsideDialog = () => { ) } -export const AutogrowAfterLoadingWithOutsideTopAnchor = () => { - const autogrowItems = [...items] - - const [selected, setSelected] = React.useState([autogrowItems[0], autogrowItems[1]]) - const [open, setOpen] = useState(false) - const [filter, setFilter] = React.useState('') - const [filteredItems, setFilteredItems] = React.useState([]) - const [loading, setLoading] = useState(true) - - React.useEffect(() => { - if (!open) setLoading(true) - const timer = window.setTimeout(() => { - if (open) { - setFilteredItems(autogrowItems.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase()))) - setLoading(false) - } - }, 2000) - - return () => window.clearTimeout(timer) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open]) - - React.useEffect(() => { - if (!loading) { - setFilteredItems(autogrowItems.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase()))) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [filter]) - - return ( - -

Autogrow panel after loading with outside-top anchor

- -
- ) -} - export const WithDefaultMessage = () => { const [selected, setSelected] = useState(items.slice(1, 3)) const [filter, setFilter] = useState('')