Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
4e6cf0d
perf: derive state during render instead of effects
mattcosta7 Jun 16, 2026
2ab8318
docs: justify remaining set-state-in-effect suppressions
mattcosta7 Jun 16, 2026
590aced
chore: merge main into copilot/derive-state-from-effects
Copilot Jun 16, 2026
5333763
Initial plan
Copilot Jun 17, 2026
7b94315
refactor: derive follow-up state during render
Copilot Jun 17, 2026
cfb76f4
refactor: finalize render-time state derivation
Copilot Jun 17, 2026
b804c74
Potential fix for pull request finding
mattcosta7 Jun 23, 2026
2325c8f
Merge branch 'main' into copilot/derive-state-from-effects
mattcosta7 Jun 23, 2026
67bf158
Merge branch 'copilot/derive-state-from-effects' into copilot/copilot…
mattcosta7 Jun 23, 2026
37371f2
test(useMedia): assert expected SSR hydration warning
mattcosta7 Jun 23, 2026
d96365e
Merge branch 'copilot/derive-state-from-effects' into copilot/copilot…
mattcosta7 Jun 23, 2026
5d24be4
fix(Autocomplete): derive close-time sort safely despite menu remount…
mattcosta7 Jun 23, 2026
691fb1b
Merge branch 'main' into copilot/derive-state-from-effects
mattcosta7 Jun 23, 2026
87741d5
Merge branch 'copilot/derive-state-from-effects' into copilot/copilot…
mattcosta7 Jun 23, 2026
3fc8578
Merge remote-tracking branch 'origin/main' into copilot/derive-state-…
mattcosta7 Jul 7, 2026
a475aef
fix(useMedia): only warn about missing defaultState on the server, no…
mattcosta7 Jul 7, 2026
d23a8cf
Merge branch 'main' into copilot/derive-state-from-effects
mattcosta7 Jul 7, 2026
bc36c9f
Merge branch 'copilot/derive-state-from-effects' into copilot/copilot…
mattcosta7 Jul 7, 2026
926d10b
Merge branch 'main' into copilot/derive-state-from-effects
mattcosta7 Jul 7, 2026
b9cb329
Merge branch 'copilot/derive-state-from-effects' into copilot/copilot…
mattcosta7 Jul 7, 2026
7686c40
Merge branch 'main' into copilot/derive-state-from-effects
mattcosta7 Jul 7, 2026
1d63818
Merge branch 'copilot/derive-state-from-effects' into copilot/copilot…
mattcosta7 Jul 7, 2026
11214ba
Merge remote-tracking branch 'origin/main' into copilot/copilotderive…
mattcosta7 Jul 7, 2026
702f528
refactor: scope PR to Autocomplete + LabelGroup render-derivation
mattcosta7 Jul 7, 2026
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,5 @@
---
'@primer/react': patch
---

Derive `AutocompleteMenu` close-time re-sorting and `LabelGroup` numeric truncation during render instead of in effects, removing redundant render passes. No public API or visual changes.
45 changes: 28 additions & 17 deletions packages/react/src/Autocomplete/Autocomplete.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -289,50 +289,61 @@ describe('Autocomplete', () => {
})

it('calls a custom sort function when the menu closes', async () => {
const user = userEvent.setup()
const sortOnCloseFnMock = vi.fn()
const onOpenChangeMock = vi.fn()
const {container} = render(
<LabelledAutocomplete
menuProps={{
items: mockItems,
selectedItemIds: [],
sortOnCloseFn: sortOnCloseFnMock,
onOpenChange: onOpenChangeMock,
['aria-labelledby']: 'autocompleteLabel',
}}
/>,
)
const inputNode = container.querySelector('#autocompleteInput')
const inputNode = container.querySelector('#autocompleteInput') as HTMLInputElement

// `sortOnCloseFnMock` will be called in a `.sort()` on render to check if the
// current sort order matches the result of `sortOnCloseFnMock`
expect(sortOnCloseFnMock).toHaveBeenCalledTimes(mockItems.length - 1)
if (inputNode) {
await user.type(inputNode, 'ze')
// eslint-disable-next-line github/no-blur
fireEvent.blur(inputNode)
}
// The menu starts closed, so the items are sorted during render.
expect(sortOnCloseFnMock).toHaveBeenCalled()

// wait a tick for blur to finish
// Opening the menu does not run the close-time sort.
sortOnCloseFnMock.mockClear()
fireEvent.click(inputNode)
fireEvent.keyDown(inputNode, {key: 'ArrowDown'})
await waitFor(() => expect(onOpenChangeMock).toHaveBeenLastCalledWith(true))
expect(sortOnCloseFnMock).not.toHaveBeenCalled()

// Closing the menu re-runs the sort.
// eslint-disable-next-line github/no-blur
fireEvent.blur(inputNode)
await waitFor(() => expect(sortOnCloseFnMock).toHaveBeenCalled())
})

it("calls onOpenChange with the menu's open state", async () => {
const user = userEvent.setup()
const onOpenChangeMock = vi.fn()
const {container} = render(
const {getByLabelText} = render(
<LabelledAutocomplete
menuProps={{
items: mockItems,
items: [],
selectedItemIds: [],
onOpenChange: onOpenChangeMock,
['aria-labelledby']: 'autocompleteLabel',
}}
/>,
)
const inputNode = container.querySelector('#autocompleteInput')
const inputNode = getByLabelText(AUTOCOMPLETE_LABEL)

inputNode && (await user.type(inputNode, 'ze'))
expect(onOpenChangeMock).toHaveBeenCalled()
expect(onOpenChangeMock).toHaveBeenNthCalledWith(1, false)

fireEvent.click(inputNode)
fireEvent.keyDown(inputNode, {key: 'ArrowDown'})
await waitFor(() => expect(onOpenChangeMock).toHaveBeenLastCalledWith(true))

// eslint-disable-next-line github/no-blur
fireEvent.blur(inputNode)

await waitFor(() => expect(onOpenChangeMock).toHaveBeenLastCalledWith(false))
})

it('calls onSelectedChange with the data for the selected items', async () => {
Expand Down
16 changes: 11 additions & 5 deletions packages/react/src/Autocomplete/AutocompleteMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -324,23 +324,29 @@ function AutocompleteMenu<T extends AutocompleteItemProps>(props: AutocompleteMe
}
}, [highlightedItem, deferredInputValue, selectedItemIds, setAutocompleteSuggestion])

useEffect(() => {
// Re-sort the items during render while the menu is closed so the order is ready the next time
// it opens. `AutocompleteOverlay` mounts this component under a different parent when the menu
// opens vs. closes, so it remounts on each open/close and a render-time previous-value transition
// can't be observed here — guarding on the closed state is what replaces the old close-time effect.
// The comparator therefore runs only while the menu is closed, never on the frequent renders that
// happen while it is open.
if (showMenu === false) {
const itemIdSortResult = [...sortedItemIds].sort(
sortOnCloseFn ? sortOnCloseFn : getDefaultSortFn(itemId => isItemSelected(itemId, selectedItemIds)),
)
const sortResultMatchesState =
itemIdSortResult.length === sortedItemIds.length &&
itemIdSortResult.every((element, index) => element === sortedItemIds[index])

// eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler
if (showMenu === false && !sortResultMatchesState) {
// eslint-disable-next-line react-hooks/set-state-in-effect, react-you-might-not-need-an-effect/no-derived-state
if (!sortResultMatchesState) {
setSortedItemIds(itemIdSortResult)
}
}

useEffect(() => {
// eslint-disable-next-line react-you-might-not-need-an-effect/no-pass-data-to-parent
onOpenChange && onOpenChange(Boolean(showMenu))
}, [showMenu, onOpenChange, selectedItemIds, sortOnCloseFn, sortedItemIds])
}, [showMenu, onOpenChange])

useEffect(() => {
// eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler
Expand Down
17 changes: 16 additions & 1 deletion packages/react/src/LabelGroup/LabelGroup.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ describe('LabelGroup', () => {
})

it('should truncate labels to a specified number', () => {
const {getByText} = render(
const {getByText, queryByText, rerender} = render(
<ThemeAndStyleContainer>
<LabelGroup visibleChildCount={3}>
<Label>One</Label>
Expand All @@ -126,6 +126,21 @@ describe('LabelGroup', () => {
const expandButton = getByText('+2')

expect(expandButton).toBeDefined()

rerender(
<ThemeAndStyleContainer>
<LabelGroup visibleChildCount={4}>
<Label>One</Label>
<Label>Two</Label>
<Label>Three</Label>
<Label>Four</Label>
<Label>Five</Label>
</LabelGroup>
</ThemeAndStyleContainer>,
)

expect(queryByText('+2')).toBeNull()
expect(getByText('+1')).toBeDefined()
})

it('should expand all tokens into an overlay when overflowStyle="overlay"', async () => {
Expand Down
65 changes: 44 additions & 21 deletions packages/react/src/LabelGroup/LabelGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,26 @@ const getOverlayWidth = (
overlayPaddingPx: number,
) => overlayPaddingPx + buttonClientRect.right - (containerRef.current?.getBoundingClientRect().left || 0)

const getVisibilityMapAfterIndex = (childCount: number, truncateAfter: number) => {
const updatedEntries: Record<string, boolean> = {}

for (let index = 0; index < childCount; index++) {
updatedEntries[index.toString()] = index < truncateAfter
}

return updatedEntries
}

const visibilityMapsMatch = (left: Record<string, boolean>, right: Record<string, boolean>) => {
const leftKeys = Object.keys(left)
const rightKeys = Object.keys(right)

return (
leftKeys.length === rightKeys.length &&
leftKeys.every(key => Object.prototype.hasOwnProperty.call(right, key) && left[key] === right[key])
)
}

const InlineToggle: React.FC<{
collapseButtonRef: React.RefObject<HTMLButtonElement>
collapseInlineExpandedChildren: () => void
Expand Down Expand Up @@ -131,6 +151,7 @@ const LabelGroup: React.FC<React.PropsWithChildren<LabelGroupProps>> = ({
bottom: 0,
toJSON: () => undefined,
})
const childArray = React.Children.toArray(children)

const overlayPaddingPx = 8 // var(--base-size-8), hardcoded to do some math
const hiddenItemIds = Object.keys(visibilityMap).filter(key => !visibilityMap[key])
Expand Down Expand Up @@ -163,18 +184,12 @@ const LabelGroup: React.FC<React.PropsWithChildren<LabelGroupProps>> = ({
)

// Sets the visibility map to hide children after the given index.
const hideChildrenAfterIndex = React.useCallback((truncateAfter: number) => {
const containerChildren = containerRef.current?.children || []
const updatedEntries: Record<string, boolean> = {}
for (const child of containerChildren) {
const targetId = child.getAttribute('data-index')
if (targetId) {
updatedEntries[targetId] = parseInt(targetId, 10) < truncateAfter
}
}

setVisibilityMap(updatedEntries)
}, [])
const hideChildrenAfterIndex = React.useCallback(
(truncateAfter: number) => {
setVisibilityMap(getVisibilityMapAfterIndex(childArray.length, truncateAfter))
},
[childArray.length],
)

const openOverflowOverlay = React.useCallback(() => setIsOverflowShown(true), [setIsOverflowShown])

Expand Down Expand Up @@ -203,13 +218,26 @@ const LabelGroup: React.FC<React.PropsWithChildren<LabelGroupProps>> = ({
setIsOverflowShown(true)
}, [setVisibilityMap, setIsOverflowShown])

const numericVisibilityMap = React.useMemo(
() =>
visibleChildCount && typeof visibleChildCount === 'number' && !isOverflowShown
? getVisibilityMapAfterIndex(childArray.length, visibleChildCount)
: undefined,
[childArray.length, isOverflowShown, visibleChildCount],
)

if (numericVisibilityMap && !visibilityMapsMatch(visibilityMap, numericVisibilityMap)) {
setVisibilityMap(numericVisibilityMap)
}

React.useEffect(() => {
// If we're not truncating, we don't need to run this useEffect.
// eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler
if (!visibleChildCount || isOverflowShown) {
return
}

// eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler
if (visibleChildCount === 'auto') {
// Instantiates the IntersectionObserver to track when children fit in the container.
const observer = new IntersectionObserver(
Expand Down Expand Up @@ -245,12 +273,7 @@ const LabelGroup: React.FC<React.PropsWithChildren<LabelGroupProps>> = ({

return () => observer.disconnect()
}
// We're not auto truncating, so we need to hide children after the given `visibleChildCount`.
else {
// eslint-disable-next-line react-hooks/set-state-in-effect
hideChildrenAfterIndex(visibleChildCount)
}
}, [buttonClientRect, visibleChildCount, hideChildrenAfterIndex, isOverflowShown])
}, [buttonClientRect, visibleChildCount, isOverflowShown])

// Updates the index of the first hidden child.
// We need to keep track of this so we can focus the first hidden child when the overflow is shown inline.
Expand Down Expand Up @@ -302,7 +325,7 @@ const LabelGroup: React.FC<React.PropsWithChildren<LabelGroupProps>> = ({
className={clsx(className, classes.Container)}
data-component="LabelGroup"
>
{React.Children.map(children, (child, index) => (
{childArray.map((child, index) => (
<ItemWrapperComponent
// data-index is used as an identifier we can use in the IntersectionObserver
data-index={index}
Expand All @@ -323,7 +346,7 @@ const LabelGroup: React.FC<React.PropsWithChildren<LabelGroupProps>> = ({
hiddenItemIds={hiddenItemIds}
isOverflowShown={isOverflowShown}
showAllTokensInline={showAllTokensInline}
totalLength={React.Children.toArray(children).length}
totalLength={childArray.length}
/>
) : (
<OverlayToggle
Expand All @@ -334,7 +357,7 @@ const LabelGroup: React.FC<React.PropsWithChildren<LabelGroupProps>> = ({
openOverflowOverlay={openOverflowOverlay}
overlayPaddingPx={overlayPaddingPx}
overlayWidth={overlayWidth}
totalLength={React.Children.toArray(children).length}
totalLength={childArray.length}
>
{children}
</OverlayToggle>
Expand Down
Loading