From 6bed1893fff5689b64a97637997098f7ef1cdd9d Mon Sep 17 00:00:00 2001 From: Minwook Shin <163576506+minwookshin@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:40:55 -0400 Subject: [PATCH 1/8] feat: support description text in tree items Developed and tested with assistance from OpenAI Codex. --- .../dev/s2-docs/pages/react-aria/Tree.mdx | 5 +- packages/react-aria-components/src/Tree.tsx | 5 +- .../stories/Tree.stories.tsx | 30 +++++++ .../react-aria-components/test/Tree.test.tsx | 84 +++++++++++++++++++ .../src/gridlist/useGridListItem.ts | 2 +- packages/react-aria/src/tree/useTreeItem.ts | 8 ++ 6 files changed, 129 insertions(+), 5 deletions(-) diff --git a/packages/dev/s2-docs/pages/react-aria/Tree.mdx b/packages/dev/s2-docs/pages/react-aria/Tree.mdx index 590a4ce1c26..4bf3eaacda5 100644 --- a/packages/dev/s2-docs/pages/react-aria/Tree.mdx +++ b/packages/dev/s2-docs/pages/react-aria/Tree.mdx @@ -448,15 +448,18 @@ function Example() { ## API +Place a `` inside `TreeItemContent` to provide an accessible description for a row. It is associated with the row using `aria-describedby`, keeping the row's accessible name separate from its description. + -```tsx links={{Tree: '#tree', TreeItem: '#treeitem', TreeItemContent: '#treeitemcontent', TreeSection: '#treesection', TreeHeader: '#treeheader', Button: 'Button', Checkbox: 'Checkbox', TreeLoadMoreItem: '#treeloadmoreitem', SelectionIndicator: 'selection#animated-selectionindicator'}} +```tsx links={{Tree: '#tree', TreeItem: '#treeitem', TreeItemContent: '#treeitemcontent', TreeSection: '#treesection', TreeHeader: '#treeheader', Button: 'Button', Checkbox: 'Checkbox', TreeLoadMoreItem: '#treeloadmoreitem', Text: 'Text', SelectionIndicator: 'selection#animated-selectionindicator'}} +
+ Projects + + Shared project files + +
+ + )} +
+ + +
+ Report + + Last updated today + +
+
+
+
+
+); + export const TreeExampleStatic: StoryObj = { render: args => , args: { diff --git a/packages/react-aria-components/test/Tree.test.tsx b/packages/react-aria-components/test/Tree.test.tsx index c0087e78fd9..841219a7086 100644 --- a/packages/react-aria-components/test/Tree.test.tsx +++ b/packages/react-aria-components/test/Tree.test.tsx @@ -411,6 +411,90 @@ describe('Tree', () => { } }); + describe('descriptions', () => { + function DescriptionTree({ + showDescription = true, + label + }: { + showDescription?: boolean; + label?: string; + }) { + return ( + + + + + Projects + {showDescription && ( + + Shared files + + )} + + + Report + + + + ); + } + + it.each([undefined, 'Team projects'])( + 'keeps the row name separate from its description (%s)', + label => { + let {getByRole, getByText} = render(); + let row = getByRole('row', {name: label || 'Projects'}); + expect(row).toHaveAccessibleDescription('Shared files'); + expect(row).toHaveAttribute('aria-describedby', getByText('Shared files').id); + expect(row).not.toHaveAttribute('aria-labelledby'); + expect(getByRole('button')).toHaveAccessibleName(`Expand ${label || 'Projects'}`); + } + ); + + it('updates the description relationship when the slot is removed and added', () => { + let {getByRole, rerender} = render(); + expect(getByRole('row')).toHaveAccessibleDescription('Shared files'); + rerender(); + expect(getByRole('row')).not.toHaveAttribute('aria-describedby'); + rerender(); + expect(getByRole('row')).toHaveAccessibleDescription('Shared files'); + }); + + it('preserves keyboard selection and expansion with a description', async () => { + let {getByRole} = render(); + await user.tab(); + let row = getByRole('row', {name: 'Projects'}); + expect(row).toHaveFocus(); + await user.keyboard(' '); + expect(row).toHaveAttribute('aria-selected', 'true'); + await user.keyboard('{ArrowRight}{ArrowDown}'); + expect(row).toHaveAttribute('aria-expanded', 'true'); + expect(getByRole('row', {name: 'Report'})).toHaveFocus(); + }); + + it('preserves explicit labels and additional descriptions', () => { + let {getByRole} = render( + <> + Read only + + + + Projects Shared files + + + + + ); + expect(getByRole('row', {name: 'Team projects'})).toHaveAccessibleDescription( + 'Read only Shared files' + ); + }); + }); + it('should render a Tree with custom classes', () => { let {getByRole, getAllByRole} = render( diff --git a/packages/react-aria/src/gridlist/useGridListItem.ts b/packages/react-aria/src/gridlist/useGridListItem.ts index 106ab557fcf..5d0765aa657 100644 --- a/packages/react-aria/src/gridlist/useGridListItem.ts +++ b/packages/react-aria/src/gridlist/useGridListItem.ts @@ -107,7 +107,7 @@ export function useGridListItem( let {direction} = useLocale(); let {onAction, linkBehavior, keyboardNavigationBehavior, shouldSelectOnPressUp} = listMap.get(state)!; - let descriptionId = useSlotId(); + let descriptionId = useSlotId([node]); // We need to track the key of the item at the time it was last focused so that we force // focus to go to the item when the DOM node is reused for a different item in a virtualizer. diff --git a/packages/react-aria/src/tree/useTreeItem.ts b/packages/react-aria/src/tree/useTreeItem.ts index 6dc9f899afe..ae22fe101b5 100644 --- a/packages/react-aria/src/tree/useTreeItem.ts +++ b/packages/react-aria/src/tree/useTreeItem.ts @@ -81,6 +81,14 @@ export function useTreeItem( // TODO: should it return a state specifically for isExpanded? Or is aria attribute sufficient? return { ...gridListAria, + rowProps: { + ...gridListAria.rowProps, + 'aria-labelledby': undefined, + 'aria-describedby': + [node.props['aria-describedby'], gridListAria.descriptionProps.id] + .filter(Boolean) + .join(' ') || undefined + }, expandButtonProps }; } From bb0783f9182fe160d8c012e6a0749a9dd0900ba2 Mon Sep 17 00:00:00 2001 From: Minwook Shin <163576506+minwookshin@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:23:09 -0400 Subject: [PATCH 2/8] fix: inherit GridList description naming in Tree --- .../dev/s2-docs/pages/react-aria/Tree.mdx | 2 +- .../react-aria-components/test/Tree.test.tsx | 40 +++++-------------- packages/react-aria/src/tree/useTreeItem.ts | 8 ---- 3 files changed, 10 insertions(+), 40 deletions(-) diff --git a/packages/dev/s2-docs/pages/react-aria/Tree.mdx b/packages/dev/s2-docs/pages/react-aria/Tree.mdx index 4bf3eaacda5..14823780bb5 100644 --- a/packages/dev/s2-docs/pages/react-aria/Tree.mdx +++ b/packages/dev/s2-docs/pages/react-aria/Tree.mdx @@ -448,7 +448,7 @@ function Example() { ## API -Place a `` inside `TreeItemContent` to provide an accessible description for a row. It is associated with the row using `aria-describedby`, keeping the row's accessible name separate from its description. +Place a `` inside `TreeItemContent` to provide a description for a row. The description is included in the row's accessible name using `aria-labelledby`, matching `GridListItem`. diff --git a/packages/react-aria-components/test/Tree.test.tsx b/packages/react-aria-components/test/Tree.test.tsx index 841219a7086..cf96c7eb61b 100644 --- a/packages/react-aria-components/test/Tree.test.tsx +++ b/packages/react-aria-components/test/Tree.test.tsx @@ -440,30 +440,30 @@ describe('Tree', () => { } it.each([undefined, 'Team projects'])( - 'keeps the row name separate from its description (%s)', + 'includes the description in the row name (%s)', label => { let {getByRole, getByText} = render(); - let row = getByRole('row', {name: label || 'Projects'}); - expect(row).toHaveAccessibleDescription('Shared files'); - expect(row).toHaveAttribute('aria-describedby', getByText('Shared files').id); - expect(row).not.toHaveAttribute('aria-labelledby'); + let row = getByRole('row', {name: `${label || 'Projects'} Shared files`}); + expect(row).toHaveAttribute('aria-labelledby', `${row.id} ${getByText('Shared files').id}`); + expect(row).not.toHaveAttribute('aria-describedby'); expect(getByRole('button')).toHaveAccessibleName(`Expand ${label || 'Projects'}`); } ); it('updates the description relationship when the slot is removed and added', () => { let {getByRole, rerender} = render(); - expect(getByRole('row')).toHaveAccessibleDescription('Shared files'); + expect(getByRole('row')).toHaveAccessibleName('Projects Shared files'); rerender(); - expect(getByRole('row')).not.toHaveAttribute('aria-describedby'); + expect(getByRole('row')).toHaveAccessibleName('Projects'); + expect(getByRole('row')).not.toHaveAttribute('aria-labelledby'); rerender(); - expect(getByRole('row')).toHaveAccessibleDescription('Shared files'); + expect(getByRole('row')).toHaveAccessibleName('Projects Shared files'); }); it('preserves keyboard selection and expansion with a description', async () => { let {getByRole} = render(); await user.tab(); - let row = getByRole('row', {name: 'Projects'}); + let row = getByRole('row', {name: 'Projects Shared files'}); expect(row).toHaveFocus(); await user.keyboard(' '); expect(row).toHaveAttribute('aria-selected', 'true'); @@ -471,28 +471,6 @@ describe('Tree', () => { expect(row).toHaveAttribute('aria-expanded', 'true'); expect(getByRole('row', {name: 'Report'})).toHaveFocus(); }); - - it('preserves explicit labels and additional descriptions', () => { - let {getByRole} = render( - <> - Read only - - - - Projects Shared files - - - - - ); - expect(getByRole('row', {name: 'Team projects'})).toHaveAccessibleDescription( - 'Read only Shared files' - ); - }); }); it('should render a Tree with custom classes', () => { diff --git a/packages/react-aria/src/tree/useTreeItem.ts b/packages/react-aria/src/tree/useTreeItem.ts index ae22fe101b5..6dc9f899afe 100644 --- a/packages/react-aria/src/tree/useTreeItem.ts +++ b/packages/react-aria/src/tree/useTreeItem.ts @@ -81,14 +81,6 @@ export function useTreeItem( // TODO: should it return a state specifically for isExpanded? Or is aria attribute sufficient? return { ...gridListAria, - rowProps: { - ...gridListAria.rowProps, - 'aria-labelledby': undefined, - 'aria-describedby': - [node.props['aria-describedby'], gridListAria.descriptionProps.id] - .filter(Boolean) - .join(' ') || undefined - }, expandButtonProps }; } From 84639928e3abf0c43ed3502959d88f83dd5d73e0 Mon Sep 17 00:00:00 2001 From: Minwook Shin <163576506+minwookshin@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:49:10 -0400 Subject: [PATCH 3/8] test: clarify descriptions and cover GridList slot removal --- .../dev/s2-docs/pages/react-aria/Tree.mdx | 2 +- .../test/GridList.test.js | 26 ++++++++++ .../react-aria-components/test/Tree.test.tsx | 51 +++++++------------ 3 files changed, 44 insertions(+), 35 deletions(-) diff --git a/packages/dev/s2-docs/pages/react-aria/Tree.mdx b/packages/dev/s2-docs/pages/react-aria/Tree.mdx index 14823780bb5..cd993016cea 100644 --- a/packages/dev/s2-docs/pages/react-aria/Tree.mdx +++ b/packages/dev/s2-docs/pages/react-aria/Tree.mdx @@ -448,7 +448,7 @@ function Example() { ## API -Place a `` inside `TreeItemContent` to provide a description for a row. The description is included in the row's accessible name using `aria-labelledby`, matching `GridListItem`. +Place a `` inside `TreeItemContent` to provide a description for a row. diff --git a/packages/react-aria-components/test/GridList.test.js b/packages/react-aria-components/test/GridList.test.js index 627bb9d79d3..3295fe68ed8 100644 --- a/packages/react-aria-components/test/GridList.test.js +++ b/packages/react-aria-components/test/GridList.test.js @@ -45,6 +45,7 @@ import {Popover} from '../src/Popover'; import React from 'react'; import {RouterProvider} from 'react-aria/private/utils/openLink'; import {Tag, TagGroup, TagList} from '../src/TagGroup'; +import {Text} from '../src/Text'; import {Toolbar} from '../src/Toolbar'; import userEvent from '@testing-library/user-event'; import {Virtualizer} from '../src/Virtualizer'; @@ -162,6 +163,31 @@ describe('GridList', () => { } }); + it('updates the description relationship when the slot is removed and added', () => { + function DescriptionGridList({hideDescription = false}) { + return ( + + + Projects + {!hideDescription && ( + + Shared files + + )} + + + ); + } + + let {getByRole, rerender} = render(); + expect(getByRole('row')).toHaveAccessibleName('Projects Shared files'); + rerender(); + expect(getByRole('row')).toHaveAccessibleName('Projects'); + expect(getByRole('row')).not.toHaveAttribute('aria-labelledby'); + rerender(); + expect(getByRole('row')).toHaveAccessibleName('Projects Shared files'); + }); + it('should render with custom classes', () => { let {getByRole, getAllByRole} = renderGridList({className: 'gridlist'}, {className: 'item'}); let grid = getByRole('grid'); diff --git a/packages/react-aria-components/test/Tree.test.tsx b/packages/react-aria-components/test/Tree.test.tsx index cf96c7eb61b..bdd15267e9d 100644 --- a/packages/react-aria-components/test/Tree.test.tsx +++ b/packages/react-aria-components/test/Tree.test.tsx @@ -413,64 +413,47 @@ describe('Tree', () => { describe('descriptions', () => { function DescriptionTree({ - showDescription = true, - label + hideDescription = false, + itemAriaLabel }: { - showDescription?: boolean; - label?: string; + hideDescription?: boolean; + itemAriaLabel?: string; }) { return ( - - + + - Projects - {showDescription && ( + {!hideDescription && ( Shared files )} - - Report - ); } - it.each([undefined, 'Team projects'])( - 'includes the description in the row name (%s)', - label => { - let {getByRole, getByText} = render(); - let row = getByRole('row', {name: `${label || 'Projects'} Shared files`}); - expect(row).toHaveAttribute('aria-labelledby', `${row.id} ${getByText('Shared files').id}`); - expect(row).not.toHaveAttribute('aria-describedby'); - expect(getByRole('button')).toHaveAccessibleName(`Expand ${label || 'Projects'}`); - } - ); + it('includes the description in the row name', () => { + let {getByRole} = render(); + expect(getByRole('row')).toHaveAccessibleName('Projects Shared files'); + }); + + it('includes the description with an explicit item aria-label', () => { + let {getByRole} = render(); + expect(getByRole('row')).toHaveAccessibleName('Team projects Shared files'); + }); it('updates the description relationship when the slot is removed and added', () => { let {getByRole, rerender} = render(); expect(getByRole('row')).toHaveAccessibleName('Projects Shared files'); - rerender(); + rerender(); expect(getByRole('row')).toHaveAccessibleName('Projects'); expect(getByRole('row')).not.toHaveAttribute('aria-labelledby'); rerender(); expect(getByRole('row')).toHaveAccessibleName('Projects Shared files'); }); - - it('preserves keyboard selection and expansion with a description', async () => { - let {getByRole} = render(); - await user.tab(); - let row = getByRole('row', {name: 'Projects Shared files'}); - expect(row).toHaveFocus(); - await user.keyboard(' '); - expect(row).toHaveAttribute('aria-selected', 'true'); - await user.keyboard('{ArrowRight}{ArrowDown}'); - expect(row).toHaveAttribute('aria-expanded', 'true'); - expect(getByRole('row', {name: 'Report'})).toHaveFocus(); - }); }); it('should render a Tree with custom classes', () => { From ae4b5d3432404fa0c71c5c017dda5c28c0d4a1df Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Fri, 16 Oct 2026 06:40:50 +1100 Subject: [PATCH 4/8] Update packages/dev/s2-docs/pages/react-aria/Tree.mdx --- packages/dev/s2-docs/pages/react-aria/Tree.mdx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/dev/s2-docs/pages/react-aria/Tree.mdx b/packages/dev/s2-docs/pages/react-aria/Tree.mdx index cd993016cea..7c21ad49f84 100644 --- a/packages/dev/s2-docs/pages/react-aria/Tree.mdx +++ b/packages/dev/s2-docs/pages/react-aria/Tree.mdx @@ -448,7 +448,6 @@ function Example() { ## API -Place a `` inside `TreeItemContent` to provide a description for a row. From d4330f8065f8087007cad1b95aedfe1edea42e11 Mon Sep 17 00:00:00 2001 From: Minwook Shin <163576506+minwookshin@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:40:16 -0400 Subject: [PATCH 5/8] fix: refresh description slots only when content changes --- .../test/GridList.test.js | 23 ++++++++++++++++ .../react-aria-components/test/Tree.test.tsx | 27 +++++++++++++++++++ .../src/gridlist/useGridListItem.ts | 2 +- 3 files changed, 51 insertions(+), 1 deletion(-) diff --git a/packages/react-aria-components/test/GridList.test.js b/packages/react-aria-components/test/GridList.test.js index 3295fe68ed8..aa584cc33fb 100644 --- a/packages/react-aria-components/test/GridList.test.js +++ b/packages/react-aria-components/test/GridList.test.js @@ -188,6 +188,29 @@ describe('GridList', () => { expect(getByRole('row')).toHaveAccessibleName('Projects Shared files'); }); + it('does not temporarily reference a missing description when only item props change', () => { + let children = Projects; + function DescriptionGridList({revision}) { + return ( + + + {children} + + + ); + } + + let {getByRole, rerender} = render(); + let row = getByRole('row'); + let observer = new MutationObserver(() => {}); + observer.observe(row, {attributes: true, attributeFilter: ['aria-labelledby']}); + rerender(); + let mutations = observer.takeRecords(); + observer.disconnect(); + expect(mutations).toHaveLength(0); + expect(row).toHaveAccessibleName('Projects'); + }); + it('should render with custom classes', () => { let {getByRole, getAllByRole} = renderGridList({className: 'gridlist'}, {className: 'item'}); let grid = getByRole('grid'); diff --git a/packages/react-aria-components/test/Tree.test.tsx b/packages/react-aria-components/test/Tree.test.tsx index bdd15267e9d..67991166a04 100644 --- a/packages/react-aria-components/test/Tree.test.tsx +++ b/packages/react-aria-components/test/Tree.test.tsx @@ -412,6 +412,33 @@ describe('Tree', () => { }); describe('descriptions', () => { + it('does not temporarily reference a missing description when only item props change', () => { + let children = ( + + Projects + + ); + function DescriptionTree({revision}: {revision: number}) { + return ( + + + {children} + + + ); + } + + let {getByRole, rerender} = render(); + let row = getByRole('row'); + let observer = new MutationObserver(() => {}); + observer.observe(row, {attributes: true, attributeFilter: ['aria-labelledby']}); + rerender(); + let mutations = observer.takeRecords(); + observer.disconnect(); + expect(mutations).toHaveLength(0); + expect(row).toHaveAccessibleName('Projects'); + }); + function DescriptionTree({ hideDescription = false, itemAriaLabel diff --git a/packages/react-aria/src/gridlist/useGridListItem.ts b/packages/react-aria/src/gridlist/useGridListItem.ts index 5d0765aa657..e4be6765810 100644 --- a/packages/react-aria/src/gridlist/useGridListItem.ts +++ b/packages/react-aria/src/gridlist/useGridListItem.ts @@ -107,7 +107,7 @@ export function useGridListItem( let {direction} = useLocale(); let {onAction, linkBehavior, keyboardNavigationBehavior, shouldSelectOnPressUp} = listMap.get(state)!; - let descriptionId = useSlotId([node]); + let descriptionId = useSlotId([node.props.children]); // We need to track the key of the item at the time it was last focused so that we force // focus to go to the item when the DOM node is reused for a different item in a virtualizer. From 3a5394adc1905b71c659bce0e796c04ae099a5e2 Mon Sep 17 00:00:00 2001 From: Minwook Shin <163576506+minwookshin@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:42:05 -0400 Subject: [PATCH 6/8] fix: keep item descriptions stable across render functions --- .../test/GridList.test.js | 68 ++++++++++++---- .../react-aria-components/test/Tree.test.tsx | 78 ++++++++++++++----- .../src/gridlist/useGridListItem.ts | 16 +++- 3 files changed, 121 insertions(+), 41 deletions(-) diff --git a/packages/react-aria-components/test/GridList.test.js b/packages/react-aria-components/test/GridList.test.js index aa584cc33fb..fe4aa3e15e2 100644 --- a/packages/react-aria-components/test/GridList.test.js +++ b/packages/react-aria-components/test/GridList.test.js @@ -188,27 +188,61 @@ describe('GridList', () => { expect(getByRole('row')).toHaveAccessibleName('Projects Shared files'); }); - it('does not temporarily reference a missing description when only item props change', () => { - let children = Projects; - function DescriptionGridList({revision}) { - return ( - - - {children} - - - ); + it.each(['stable children', 'inline children', 'inline render function'])( + 'does not temporarily reference a missing description with %s', + childType => { + let children = Projects; + function DescriptionGridList({revision}) { + return ( + + + {childType === 'stable children' ? ( + children + ) : childType === 'inline children' ? ( + Projects + ) : ( + () => Projects + )} + + + ); + } + + let {getByRole, rerender} = render(); + let row = getByRole('row'); + let observer = new MutationObserver(() => {}); + observer.observe(row, {attributes: true, attributeFilter: ['aria-labelledby']}); + rerender(); + let mutations = observer.takeRecords(); + observer.disconnect(); + expect(mutations).toHaveLength(0); + expect(row).toHaveAccessibleName('Projects'); } + ); - let {getByRole, rerender} = render(); + it('updates descriptions from a stable render function when selection changes', async () => { + let children = ({isSelected}) => ( + <> + Projects + {isSelected && Shared files} + + ); + let {getByRole} = render( + + + {children} + + + ); let row = getByRole('row'); - let observer = new MutationObserver(() => {}); - observer.observe(row, {attributes: true, attributeFilter: ['aria-labelledby']}); - rerender(); - let mutations = observer.takeRecords(); - observer.disconnect(); - expect(mutations).toHaveLength(0); expect(row).toHaveAccessibleName('Projects'); + await user.click(row); + expect(row).toHaveAttribute('aria-selected', 'true'); + expect(row).toHaveAccessibleName('Projects Shared files'); + await user.click(row); + expect(row).toHaveAttribute('aria-selected', 'false'); + expect(row).toHaveAccessibleName('Projects'); + expect(row).not.toHaveAttribute('aria-labelledby'); }); it('should render with custom classes', () => { diff --git a/packages/react-aria-components/test/Tree.test.tsx b/packages/react-aria-components/test/Tree.test.tsx index 67991166a04..018a3d0b02a 100644 --- a/packages/react-aria-components/test/Tree.test.tsx +++ b/packages/react-aria-components/test/Tree.test.tsx @@ -412,31 +412,69 @@ describe('Tree', () => { }); describe('descriptions', () => { - it('does not temporarily reference a missing description when only item props change', () => { - let children = ( - - Projects - - ); - function DescriptionTree({revision}: {revision: number}) { - return ( - - - {children} - - + it.each(['stable children', 'inline children', 'inline render function'])( + 'does not temporarily reference a missing description with %s', + childType => { + let children = ( + + Projects + ); + function DescriptionTree({revision}: {revision: number}) { + return ( + + + {childType === 'stable children' ? ( + children + ) : ( + + {childType === 'inline children' ? ( + Projects + ) : ( + () => Projects + )} + + )} + + + ); + } + + let {getByRole, rerender} = render(); + let row = getByRole('row'); + let observer = new MutationObserver(() => {}); + observer.observe(row, {attributes: true, attributeFilter: ['aria-labelledby']}); + rerender(); + let mutations = observer.takeRecords(); + observer.disconnect(); + expect(mutations).toHaveLength(0); + expect(row).toHaveAccessibleName('Projects'); } + ); - let {getByRole, rerender} = render(); + it('updates descriptions from a stable render function when selection changes', async () => { + let children = ({isSelected}: {isSelected: boolean}) => ( + <> + Projects + {isSelected && Shared files} + + ); + let {getByRole} = render( + + + {children} + + + ); let row = getByRole('row'); - let observer = new MutationObserver(() => {}); - observer.observe(row, {attributes: true, attributeFilter: ['aria-labelledby']}); - rerender(); - let mutations = observer.takeRecords(); - observer.disconnect(); - expect(mutations).toHaveLength(0); expect(row).toHaveAccessibleName('Projects'); + await user.click(row); + expect(row).toHaveAttribute('aria-selected', 'true'); + expect(row).toHaveAccessibleName('Projects Shared files'); + await user.click(row); + expect(row).toHaveAttribute('aria-selected', 'false'); + expect(row).toHaveAccessibleName('Projects'); + expect(row).not.toHaveAttribute('aria-labelledby'); }); function DescriptionTree({ diff --git a/packages/react-aria/src/gridlist/useGridListItem.ts b/packages/react-aria/src/gridlist/useGridListItem.ts index e4be6765810..bbb75777ca4 100644 --- a/packages/react-aria/src/gridlist/useGridListItem.ts +++ b/packages/react-aria/src/gridlist/useGridListItem.ts @@ -30,15 +30,16 @@ import {getFocusableTreeWalker} from '../focus/FocusScope'; import {getOwnerDocument} from '../utils/domHelpers'; import {getRowId, listMap} from './utils'; import {getScrollParent} from '../utils/getScrollParent'; -import {HTMLAttributes, KeyboardEvent as ReactKeyboardEvent, useRef} from 'react'; +import {HTMLAttributes, KeyboardEvent as ReactKeyboardEvent, useRef, useState} from 'react'; import {isFocusVisible} from '../interactions/useFocusVisible'; import type {ListState} from 'react-stately/useListState'; import {mergeProps} from '../utils/mergeProps'; import {scrollIntoViewport} from '../utils/scrollIntoView'; import {SelectableItemStates, useSelectableItem} from '../selection/useSelectableItem'; import type {TreeState} from 'react-stately/useTreeState'; +import {useId} from '../utils/useId'; +import {useLayoutEffect} from '../utils/useLayoutEffect'; import {useLocale} from '../i18n/I18nProvider'; -import {useSlotId} from '../utils/useId'; import {useSyntheticLinkProps} from '../utils/openLink'; export interface AriaGridListItemOptions { @@ -107,7 +108,14 @@ export function useGridListItem( let {direction} = useLocale(); let {onAction, linkBehavior, keyboardNavigationBehavior, shouldSelectOnPressUp} = listMap.get(state)!; - let descriptionId = useSlotId([node.props.children]); + let descriptionId = useId(); + let [hasDescription, setHasDescription] = useState(true); + // Keep the id available while absent and recheck after every render because a + // stable render function can change its output. Equal state updates bail out. + // oxlint-disable-next-line react-hooks/exhaustive-deps + useLayoutEffect(() => { + setHasDescription(!!getOwnerDocument(ref.current).getElementById(descriptionId)); + }); // We need to track the key of the item at the time it was last focused so that we force // focus to go to the item when the DOM node is reused for a different item in a virtualizer. @@ -427,7 +435,7 @@ export function useGridListItem( : undefined, 'aria-disabled': state.selectionManager.isDisabled(node.key) || undefined, 'aria-labelledby': - descriptionId && (node['aria-label'] || node.textValue) + hasDescription && (node['aria-label'] || node.textValue) ? `${getRowId(state, node.key)} ${descriptionId}` : undefined, id: getRowId(state, node.key) From b0b3d287f3a65a16621de137bffc676e510a463f Mon Sep 17 00:00:00 2001 From: Minwook Shin <163576506+minwookshin@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:54:47 -0400 Subject: [PATCH 7/8] fix: avoid redundant description presence updates --- packages/react-aria/src/gridlist/useGridListItem.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/react-aria/src/gridlist/useGridListItem.ts b/packages/react-aria/src/gridlist/useGridListItem.ts index bbb75777ca4..de81ec980c1 100644 --- a/packages/react-aria/src/gridlist/useGridListItem.ts +++ b/packages/react-aria/src/gridlist/useGridListItem.ts @@ -110,11 +110,14 @@ export function useGridListItem( listMap.get(state)!; let descriptionId = useId(); let [hasDescription, setHasDescription] = useState(true); - // Keep the id available while absent and recheck after every render because a - // stable render function can change its output. Equal state updates bail out. + // A stable render function can change its output, so recheck after every render. + // Avoid no-op state updates, which can interrupt focus restoration in React 18. // oxlint-disable-next-line react-hooks/exhaustive-deps useLayoutEffect(() => { - setHasDescription(!!getOwnerDocument(ref.current).getElementById(descriptionId)); + let isRendered = !!getOwnerDocument(ref.current).getElementById(descriptionId); + if (isRendered !== hasDescription) { + setHasDescription(isRendered); + } }); // We need to track the key of the item at the time it was last focused so that we force From 553bd0ad44017969661be93e7c6a738068d6ec27 Mon Sep 17 00:00:00 2001 From: Minwook Shin <163576506+minwookshin@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:01:54 -0400 Subject: [PATCH 8/8] fix: keep tree descriptions on the existing gridlist behavior --- .../test/GridList.test.js | 83 ------------------- .../react-aria-components/test/Tree.test.tsx | 73 +--------------- .../src/gridlist/useGridListItem.ts | 19 +---- 3 files changed, 6 insertions(+), 169 deletions(-) diff --git a/packages/react-aria-components/test/GridList.test.js b/packages/react-aria-components/test/GridList.test.js index fe4aa3e15e2..627bb9d79d3 100644 --- a/packages/react-aria-components/test/GridList.test.js +++ b/packages/react-aria-components/test/GridList.test.js @@ -45,7 +45,6 @@ import {Popover} from '../src/Popover'; import React from 'react'; import {RouterProvider} from 'react-aria/private/utils/openLink'; import {Tag, TagGroup, TagList} from '../src/TagGroup'; -import {Text} from '../src/Text'; import {Toolbar} from '../src/Toolbar'; import userEvent from '@testing-library/user-event'; import {Virtualizer} from '../src/Virtualizer'; @@ -163,88 +162,6 @@ describe('GridList', () => { } }); - it('updates the description relationship when the slot is removed and added', () => { - function DescriptionGridList({hideDescription = false}) { - return ( - - - Projects - {!hideDescription && ( - - Shared files - - )} - - - ); - } - - let {getByRole, rerender} = render(); - expect(getByRole('row')).toHaveAccessibleName('Projects Shared files'); - rerender(); - expect(getByRole('row')).toHaveAccessibleName('Projects'); - expect(getByRole('row')).not.toHaveAttribute('aria-labelledby'); - rerender(); - expect(getByRole('row')).toHaveAccessibleName('Projects Shared files'); - }); - - it.each(['stable children', 'inline children', 'inline render function'])( - 'does not temporarily reference a missing description with %s', - childType => { - let children = Projects; - function DescriptionGridList({revision}) { - return ( - - - {childType === 'stable children' ? ( - children - ) : childType === 'inline children' ? ( - Projects - ) : ( - () => Projects - )} - - - ); - } - - let {getByRole, rerender} = render(); - let row = getByRole('row'); - let observer = new MutationObserver(() => {}); - observer.observe(row, {attributes: true, attributeFilter: ['aria-labelledby']}); - rerender(); - let mutations = observer.takeRecords(); - observer.disconnect(); - expect(mutations).toHaveLength(0); - expect(row).toHaveAccessibleName('Projects'); - } - ); - - it('updates descriptions from a stable render function when selection changes', async () => { - let children = ({isSelected}) => ( - <> - Projects - {isSelected && Shared files} - - ); - let {getByRole} = render( - - - {children} - - - ); - let row = getByRole('row'); - expect(row).toHaveAccessibleName('Projects'); - await user.click(row); - expect(row).toHaveAttribute('aria-selected', 'true'); - expect(row).toHaveAccessibleName('Projects Shared files'); - await user.click(row); - expect(row).toHaveAttribute('aria-selected', 'false'); - expect(row).toHaveAccessibleName('Projects'); - expect(row).not.toHaveAttribute('aria-labelledby'); - }); - it('should render with custom classes', () => { let {getByRole, getAllByRole} = renderGridList({className: 'gridlist'}, {className: 'item'}); let grid = getByRole('grid'); diff --git a/packages/react-aria-components/test/Tree.test.tsx b/packages/react-aria-components/test/Tree.test.tsx index 018a3d0b02a..8081c9aca2c 100644 --- a/packages/react-aria-components/test/Tree.test.tsx +++ b/packages/react-aria-components/test/Tree.test.tsx @@ -412,71 +412,6 @@ describe('Tree', () => { }); describe('descriptions', () => { - it.each(['stable children', 'inline children', 'inline render function'])( - 'does not temporarily reference a missing description with %s', - childType => { - let children = ( - - Projects - - ); - function DescriptionTree({revision}: {revision: number}) { - return ( - - - {childType === 'stable children' ? ( - children - ) : ( - - {childType === 'inline children' ? ( - Projects - ) : ( - () => Projects - )} - - )} - - - ); - } - - let {getByRole, rerender} = render(); - let row = getByRole('row'); - let observer = new MutationObserver(() => {}); - observer.observe(row, {attributes: true, attributeFilter: ['aria-labelledby']}); - rerender(); - let mutations = observer.takeRecords(); - observer.disconnect(); - expect(mutations).toHaveLength(0); - expect(row).toHaveAccessibleName('Projects'); - } - ); - - it('updates descriptions from a stable render function when selection changes', async () => { - let children = ({isSelected}: {isSelected: boolean}) => ( - <> - Projects - {isSelected && Shared files} - - ); - let {getByRole} = render( - - - {children} - - - ); - let row = getByRole('row'); - expect(row).toHaveAccessibleName('Projects'); - await user.click(row); - expect(row).toHaveAttribute('aria-selected', 'true'); - expect(row).toHaveAccessibleName('Projects Shared files'); - await user.click(row); - expect(row).toHaveAttribute('aria-selected', 'false'); - expect(row).toHaveAccessibleName('Projects'); - expect(row).not.toHaveAttribute('aria-labelledby'); - }); - function DescriptionTree({ hideDescription = false, itemAriaLabel @@ -510,14 +445,10 @@ describe('Tree', () => { expect(getByRole('row')).toHaveAccessibleName('Team projects Shared files'); }); - it('updates the description relationship when the slot is removed and added', () => { - let {getByRole, rerender} = render(); - expect(getByRole('row')).toHaveAccessibleName('Projects Shared files'); - rerender(); + it('keeps the row name when there is no description', () => { + let {getByRole} = render(); expect(getByRole('row')).toHaveAccessibleName('Projects'); expect(getByRole('row')).not.toHaveAttribute('aria-labelledby'); - rerender(); - expect(getByRole('row')).toHaveAccessibleName('Projects Shared files'); }); }); diff --git a/packages/react-aria/src/gridlist/useGridListItem.ts b/packages/react-aria/src/gridlist/useGridListItem.ts index de81ec980c1..106ab557fcf 100644 --- a/packages/react-aria/src/gridlist/useGridListItem.ts +++ b/packages/react-aria/src/gridlist/useGridListItem.ts @@ -30,16 +30,15 @@ import {getFocusableTreeWalker} from '../focus/FocusScope'; import {getOwnerDocument} from '../utils/domHelpers'; import {getRowId, listMap} from './utils'; import {getScrollParent} from '../utils/getScrollParent'; -import {HTMLAttributes, KeyboardEvent as ReactKeyboardEvent, useRef, useState} from 'react'; +import {HTMLAttributes, KeyboardEvent as ReactKeyboardEvent, useRef} from 'react'; import {isFocusVisible} from '../interactions/useFocusVisible'; import type {ListState} from 'react-stately/useListState'; import {mergeProps} from '../utils/mergeProps'; import {scrollIntoViewport} from '../utils/scrollIntoView'; import {SelectableItemStates, useSelectableItem} from '../selection/useSelectableItem'; import type {TreeState} from 'react-stately/useTreeState'; -import {useId} from '../utils/useId'; -import {useLayoutEffect} from '../utils/useLayoutEffect'; import {useLocale} from '../i18n/I18nProvider'; +import {useSlotId} from '../utils/useId'; import {useSyntheticLinkProps} from '../utils/openLink'; export interface AriaGridListItemOptions { @@ -108,17 +107,7 @@ export function useGridListItem( let {direction} = useLocale(); let {onAction, linkBehavior, keyboardNavigationBehavior, shouldSelectOnPressUp} = listMap.get(state)!; - let descriptionId = useId(); - let [hasDescription, setHasDescription] = useState(true); - // A stable render function can change its output, so recheck after every render. - // Avoid no-op state updates, which can interrupt focus restoration in React 18. - // oxlint-disable-next-line react-hooks/exhaustive-deps - useLayoutEffect(() => { - let isRendered = !!getOwnerDocument(ref.current).getElementById(descriptionId); - if (isRendered !== hasDescription) { - setHasDescription(isRendered); - } - }); + let descriptionId = useSlotId(); // We need to track the key of the item at the time it was last focused so that we force // focus to go to the item when the DOM node is reused for a different item in a virtualizer. @@ -438,7 +427,7 @@ export function useGridListItem( : undefined, 'aria-disabled': state.selectionManager.isDisabled(node.key) || undefined, 'aria-labelledby': - hasDescription && (node['aria-label'] || node.textValue) + descriptionId && (node['aria-label'] || node.textValue) ? `${getRowId(state, node.key)} ${descriptionId}` : undefined, id: getRowId(state, node.key)