From 533376392ec49d2b1570a9897a84b70db8871c02 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 17 Jun 2026 12:30:28 +0000 Subject: [PATCH 1/3] Initial plan From 7b943151e8b61d6bd97b86e4cd8d2e1a391fd6e8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 17 Jun 2026 12:43:22 +0000 Subject: [PATCH 2/3] refactor: derive follow-up state during render Co-authored-by: mattcosta7 <8616962+mattcosta7@users.noreply.github.com> --- .../src/Autocomplete/Autocomplete.test.tsx | 19 ++++-- .../src/Autocomplete/AutocompleteMenu.tsx | 28 ++++---- .../react/src/LabelGroup/LabelGroup.test.tsx | 17 ++++- packages/react/src/LabelGroup/LabelGroup.tsx | 64 ++++++++++++------- 4 files changed, 84 insertions(+), 44 deletions(-) diff --git a/packages/react/src/Autocomplete/Autocomplete.test.tsx b/packages/react/src/Autocomplete/Autocomplete.test.tsx index 054a9b52e0a..ae93bb6f807 100644 --- a/packages/react/src/Autocomplete/Autocomplete.test.tsx +++ b/packages/react/src/Autocomplete/Autocomplete.test.tsx @@ -317,22 +317,29 @@ describe('Autocomplete', () => { }) it("calls onOpenChange with the menu's open state", async () => { - const user = userEvent.setup() const onOpenChangeMock = vi.fn() - const {container} = render( + const {getByLabelText} = render( , ) - 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 () => { diff --git a/packages/react/src/Autocomplete/AutocompleteMenu.tsx b/packages/react/src/Autocomplete/AutocompleteMenu.tsx index b87472c3149..357a4722561 100644 --- a/packages/react/src/Autocomplete/AutocompleteMenu.tsx +++ b/packages/react/src/Autocomplete/AutocompleteMenu.tsx @@ -169,6 +169,7 @@ function AutocompleteMenu(props: AutocompleteMe const allItemsToRenderRef = useRef([]) const [highlightedItem, setHighlightedItem] = useState() const [sortedItemIds, setSortedItemIds] = useState>(items.map(({id: itemId}) => itemId)) + const [prevShowMenu, setPrevShowMenu] = useState(showMenu) const generatedUniqueId = useId(id) const selectableItems = useMemo( @@ -324,26 +325,25 @@ function AutocompleteMenu(props: AutocompleteMe } }, [highlightedItem, deferredInputValue, selectedItemIds, setAutocompleteSuggestion]) - useEffect(() => { - const itemIdSortResult = [...sortedItemIds].sort( - sortOnCloseFn ? sortOnCloseFn : getDefaultSortFn(itemId => isItemSelected(itemId, selectedItemIds)), - ) - const sortResultMatchesState = - itemIdSortResult.length === sortedItemIds.length && - itemIdSortResult.every((element, index) => element === sortedItemIds[index]) + 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) { - // Re-sort only when the menu closes. This effect also fires `onOpenChange` below, so it - // stays an effect. (Follow-up: the sort could be derived on the open→closed transition - // while keeping `onOpenChange` in an effect.) - // eslint-disable-next-line react-hooks/set-state-in-effect, react-you-might-not-need-an-effect/no-derived-state + if (prevShowMenu !== showMenu) { + setPrevShowMenu(showMenu) + + if (prevShowMenu && showMenu === false && !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 diff --git a/packages/react/src/LabelGroup/LabelGroup.test.tsx b/packages/react/src/LabelGroup/LabelGroup.test.tsx index 2e141fc6d78..f22715d3654 100644 --- a/packages/react/src/LabelGroup/LabelGroup.test.tsx +++ b/packages/react/src/LabelGroup/LabelGroup.test.tsx @@ -111,7 +111,7 @@ describe('LabelGroup', () => { }) it('should truncate labels to a specified number', () => { - const {getByText} = render( + const {getByText, queryByText, rerender} = render( @@ -125,6 +125,21 @@ describe('LabelGroup', () => { const expandButton = getByText('+2') expect(expandButton).toBeDefined() + + rerender( + + + + + + + + + , + ) + + expect(queryByText('+2')).toBeNull() + expect(getByText('+1')).toBeDefined() }) it('should expand all tokens into an overlay when overflowStyle="overlay"', async () => { diff --git a/packages/react/src/LabelGroup/LabelGroup.tsx b/packages/react/src/LabelGroup/LabelGroup.tsx index fa91230cb56..f40667806f7 100644 --- a/packages/react/src/LabelGroup/LabelGroup.tsx +++ b/packages/react/src/LabelGroup/LabelGroup.tsx @@ -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 = {} + + for (let index = 0; index < childCount; index++) { + updatedEntries[index.toString()] = index < truncateAfter + } + + return updatedEntries +} + +const visibilityMapsMatch = (left: Record, right: Record) => { + 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 collapseInlineExpandedChildren: () => void @@ -131,6 +151,7 @@ const LabelGroup: React.FC> = ({ 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]) @@ -163,18 +184,12 @@ const LabelGroup: React.FC> = ({ ) // 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 = {} - 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]) @@ -203,6 +218,15 @@ const LabelGroup: React.FC> = ({ setIsOverflowShown(true) }, [setVisibilityMap, setIsOverflowShown]) + const numericVisibilityMap = + typeof visibleChildCount === 'number' && !isOverflowShown + ? getVisibilityMapAfterIndex(childArray.length, visibleChildCount) + : undefined + + 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 @@ -210,6 +234,7 @@ const LabelGroup: React.FC> = ({ 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( @@ -245,14 +270,7 @@ const LabelGroup: React.FC> = ({ return () => observer.disconnect() } - // We're not auto truncating, so we need to hide children after the given `visibleChildCount`. - // `hideChildrenAfterIndex` reads the rendered children from the DOM, so it must run in an - // effect. (Follow-up: this branch could derive the visibility map from child indices instead.) - 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. @@ -304,7 +322,7 @@ const LabelGroup: React.FC> = ({ className={clsx(className, classes.Container)} data-component="LabelGroup" > - {React.Children.map(children, (child, index) => ( + {childArray.map((child, index) => ( > = ({ hiddenItemIds={hiddenItemIds} isOverflowShown={isOverflowShown} showAllTokensInline={showAllTokensInline} - totalLength={React.Children.toArray(children).length} + totalLength={childArray.length} /> ) : ( > = ({ openOverflowOverlay={openOverflowOverlay} overlayPaddingPx={overlayPaddingPx} overlayWidth={overlayWidth} - totalLength={React.Children.toArray(children).length} + totalLength={childArray.length} > {children} From cfb76f4f9b81553ac4bd68824f0f6e9f3141b1f1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 17 Jun 2026 12:51:03 +0000 Subject: [PATCH 3/3] refactor: finalize render-time state derivation Co-authored-by: mattcosta7 <8616962+mattcosta7@users.noreply.github.com> --- packages/react/src/LabelGroup/LabelGroup.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/react/src/LabelGroup/LabelGroup.tsx b/packages/react/src/LabelGroup/LabelGroup.tsx index f40667806f7..15017e1facd 100644 --- a/packages/react/src/LabelGroup/LabelGroup.tsx +++ b/packages/react/src/LabelGroup/LabelGroup.tsx @@ -218,10 +218,13 @@ const LabelGroup: React.FC> = ({ setIsOverflowShown(true) }, [setVisibilityMap, setIsOverflowShown]) - const numericVisibilityMap = - typeof visibleChildCount === 'number' && !isOverflowShown - ? getVisibilityMapAfterIndex(childArray.length, visibleChildCount) - : undefined + const numericVisibilityMap = React.useMemo( + () => + typeof visibleChildCount === 'number' && !isOverflowShown + ? getVisibilityMapAfterIndex(childArray.length, visibleChildCount) + : undefined, + [childArray.length, isOverflowShown, visibleChildCount], + ) if (numericVisibilityMap && !visibilityMapsMatch(visibilityMap, numericVisibilityMap)) { setVisibilityMap(numericVisibilityMap)