Skip to content

Option with mixed children loses both textValue and value, falling back to an unstable generated key #3028

Description

@mfal

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:

  1. The selected value for that option is "react-aria-1", not "Millennium Falcon". A form submits a meaningless key.
  2. 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.
  3. 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

pnpm nx dev components

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).

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions