An <Option> whose children are not a single text node loses both its textValue and its value. The a11y warning is the visible symptom; the unstable key is the real damage.
Root cause
Option.tsx:24-25:
textValue = extractTextFromFirstChild(children),
value = textValue,
extractTextFromFirstChild (lib/react/remote.ts) bails out immediately:
if (Children.count(children) !== 1) {
return undefined;
}
So any Option with more than one child — text plus a Badge, an icon, anything — gets textValue === undefined and value === undefined.
What that costs
react-aria then falls back to a generated key. From the Select Default story, which renders <Option>Millennium Falcon <Badge>Latest</Badge></Option> next to plain-text options:
| Option |
key |
Millennium Falcon + <Badge> |
react-aria-1 |
X-Wing |
X-Wing |
TIE Fighter |
TIE Fighter |
Three consequences:
- The selected value for that option is
"react-aria-1", not "Millennium Falcon". A form submits a meaningless key.
- That key comes from a render-order-dependent counter, so
defaultSelectedKey / controlled selectedKey cannot reliably target the option, and the key can shift when unrelated markup around it changes.
- Type-to-select skips the option, which is what react-aria's warning is about:
A textValue prop is required for <ListBoxItem> elements with non-plain text children.
Points 1 and 2 are silent — nothing in the console mentions them.
Affected stories
form-controls-select--default, form-controls-select-edge-cases--long-texts, form-controls-select-edge-cases--many-options
The pattern is in the Default story itself, so it is the documented usage rather than an edge case.
Not a duplicate
#3015 covers the same class of warning for GridListItem in List. This is ListBoxItem in Option/Select — a different component, a different inference helper, and it additionally corrupts value, which #3015 does not touch.
Direction
Two separable decisions:
- Inference. Walk the children for text instead of requiring exactly one node, so the common "text + Badge" shape keeps a sensible
textValue.
value defaulting to textValue. Even with better inference this couples the form value to display text. Letting value silently become undefined is the part that should not survive — failing loudly, or decoupling the two, is worth deciding explicitly.
extractTextFromFirstChild also handles the remote-text case, so any change has to keep remote Options working.
Reproducing
Open iframe.html?id=form-controls-select--default&viewMode=story, open the select, and inspect the first option's data-key — it is react-aria-1 while its siblings carry their text.
Found in a Playwright console sweep over all 443 stories (two full passes, identical results).
An
<Option>whose children are not a single text node loses both itstextValueand itsvalue. The a11y warning is the visible symptom; the unstable key is the real damage.Root cause
Option.tsx:24-25:extractTextFromFirstChild(lib/react/remote.ts) bails out immediately:So any
Optionwith more than one child — text plus aBadge, an icon, anything — getstextValue === undefinedandvalue === undefined.What that costs
react-aria then falls back to a generated key. From the
SelectDefault story, which renders<Option>Millennium Falcon <Badge>Latest</Badge></Option>next to plain-text options:Millennium Falcon+<Badge>react-aria-1X-WingX-WingTIE FighterTIE FighterThree consequences:
"react-aria-1", not"Millennium Falcon". A form submits a meaningless key.defaultSelectedKey/ controlledselectedKeycannot reliably target the option, and the key can shift when unrelated markup around it changes.A textValue prop is required for <ListBoxItem> elements with non-plain text children.Points 1 and 2 are silent — nothing in the console mentions them.
Affected stories
form-controls-select--default,form-controls-select-edge-cases--long-texts,form-controls-select-edge-cases--many-optionsThe pattern is in the Default story itself, so it is the documented usage rather than an edge case.
Not a duplicate
#3015 covers the same class of warning for
GridListIteminList. This isListBoxIteminOption/Select— a different component, a different inference helper, and it additionally corruptsvalue, which #3015 does not touch.Direction
Two separable decisions:
textValue.valuedefaulting totextValue. Even with better inference this couples the form value to display text. Lettingvaluesilently becomeundefinedis the part that should not survive — failing loudly, or decoupling the two, is worth deciding explicitly.extractTextFromFirstChildalso handles the remote-text case, so any change has to keep remoteOptions working.Reproducing
Open
iframe.html?id=form-controls-select--default&viewMode=story, open the select, and inspect the first option'sdata-key— it isreact-aria-1while its siblings carry their text.Found in a Playwright console sweep over all 443 stories (two full passes, identical results).