Skip to content

fix(Option): infer textValue from mixed children and keep value stable - #3076

Merged
mfal merged 4 commits into
mainfrom
fix/option-text-value-3028
Sep 4, 2026
Merged

fix(Option): infer textValue from mixed children and keep value stable#3076
mfal merged 4 commits into
mainfrom
fix/option-text-value-3028

Conversation

@mfal

@mfal mfal commented Sep 2, 2026

Copy link
Copy Markdown
Member

Root cause

Option.tsx defaulted textValue = extractTextFromFirstChild(children) and
value = textValue. extractTextFromFirstChild bails out on
Children.count(children) !== 1, so text next to any element left both
undefined and react-aria assigned the item a key off a render-order counter.

That key is the option's form value and what defaultValue / selectedKey has
to match. So <Option>Millennium Falcon <Badge>Latest</Badge></Option> — the
Select Default story — submitted "react-aria-1", could not be preselected, and
its key shifted when unrelated markup around it changed. Only the missing
textValue warned; the key damage was silent.

Decision 1 — inference walks the children

New helper extractTextFromChildren (src/lib/react/remote.ts) joins the text
among the children instead of demanding exactly one node.

Only text that is a child itself counts. Text inside an element child
belongs to that element, so the option above is "Millennium Falcon", not
"Millennium Falcon Latest" — the Badge is decoration, not identity. Numbers
count, fragments are transparent, and RemoteTextRenderer elements count the
same as strings, which is what keeps remote Options working.

extractTextFromFirstChild is untouched. Its four other callers all depend
on the strict "exactly one text node" reading, and would break on a walk:
Button decides its icon-only layout by it, Markdown takes it as the markdown
source, Initials and Truncate operate on the whole content. Hence a new
helper rather than a changed one; both now carry JSDoc saying which question
they answer.

Decision 2 — value keeps its default, but fails loudly

value still defaults to textValue. Decoupling them is the bigger breaking
change: every app that relies on text-as-value would lose its keys at once, and
that default is a contract with extension developers.

What does not survive is value silently becoming undefined. An Option with
no inferable text now logs

An <Option> has no 'value' and none could be inferred from its children,
so it falls back to a generated, render-order-dependent key. Pass a 'value' to
give the option a stable key.

once per option, from an effect (so StrictMode does not double it). react-aria
already warns about the missing textValue in that situation but says nothing
about the key, which is the expensive half.

Migration entry — yes, needed

packages/codemods/src/migrations/option-value-inferred-from-mixed-children/
(action: none, remotePackage: true). No prop changed shape, so this is a
changed default, not a breaking prop change — but the selected key of an
existing app can change
, from react-aria-N to the option's text. Anywhere
that key was read back needs checking: a stored or server-side selection, a
defaultValue matched against it, a test asserting on it. Not mechanically
decidable from source, so no codemod.

MIGRATION.md and migrations.generated.ts regenerated and committed;
guide.test.ts's pinned newest-entry id updated.

Verification

Test-first. Reverting Option.tsx to the old inference fails 4 of the 6 new
browser tests, with expected [ 'react-aria-1', 'X-Wing', … ] — the issue's
exact symptom.

  • src/lib/react/remote.test.tsx — 20 unit tests over both helpers, including
    the remote-text and fragment paths
  • Option/Option.browser.test.tsx — pins the data-key list of the Select
    Default story shape, the emitted onChange value, defaultValue targeting,
    and the new console error
  • remote-react-components/src/tests/OptionKeys.browser.test.tsx — the same
    keys in Local and Remote, the only guard for the remote-text path

Passed: pnpm nx test:unit components (35 files / 285),
pnpm nx test:compile components,
pnpm nx test:browser components --browser.name=webkit (39 / 268),
pnpm nx test:browser remote-react-components --browser.name=webkit (5 / 12),
pnpm nx test:unit codemods (30 / 309), pnpm nx test:compile codemods,
pnpm lint (0 errors), pnpm build (no further generated diff — props kept
their shape, so view.ts and the auto-generated dirs are unchanged).

No rendered output changes, so no visual test was touched; run-visual-tests is
set to confirm that.

Decision 3 — containsTextChild folds into this walk

#3053 merged meanwhile and added containsTextChild to the same file, built on
its own isTextNode predicate and its own fragment recursion — the same
classification joinTextChildren does here, traversed twice.

It is now one line over the walk:

export const containsTextChild = (children: ReactNode): boolean =>
  extractTextFromChildren(children) !== undefined;

isTextNode is gone. One behavior change comes with the shared reading:
extractTextFromChildren trims, so whitespace-only children stop counting as
text. For the question Button and Link ask — is this a labelled control or
an icon-only one — that is the right answer, and a test pins it. The boolean no
longer short-circuits at the first text child, which does not matter at these
child counts.

extractTextFromFirstChild's doc comment loses Button: #3053 replaced its
call with containsTextChild, so Markdown, Initials and Truncate are the
callers left that need the strict "exactly one text node" reading.

Separate commit (ba1b32e) on top of the merge, so the merge itself is a plain
union of both sides and the consolidation is reviewable on its own.

Still open: #3070 carries the same predicate as a local isText in
Navigation.tsx. Left untouched here — it is Lisa's PR, and
the thread there
has the two-line swap.

fixes #3028

🤖 Generated with Claude Code

@mfal mfal self-assigned this Sep 2, 2026
@mfal mfal added the run-visual-tests Runs the full visual regression suite against the existing baselines and fails the check on mismatch label Sep 2, 2026
@github-actions github-actions Bot removed the run-visual-tests Runs the full visual regression suite against the existing baselines and fails the check on mismatch label Sep 2, 2026
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for ./packages/components/

Status Category Percentage Covered / Total
🔵 Lines 78.84% 764 / 969
🔵 Statements 78.72% 781 / 992
🔵 Functions 80.28% 171 / 213
🔵 Branches 70.79% 400 / 565
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
packages/components/src/lib/react/remote.ts 85.71% 79.31% 85.71% 85.71% 17-21, 55, 88
Generated in workflow #6666 for commit ba1b32e by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

🚀 Preview Deployment

Preview environments are ready:

Type URL
docs pr-3076.docs.review.flow-components.de
storybook pr-3076.storybook.review.flow-components.de

Images:

  • docs: ghcr.io/mittwald/flow/docs:pr-3076
  • storybook: ghcr.io/mittwald/flow/storybook:pr-3076

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

✅ Visual Regression Tests Passed

All visual snapshots match the committed baselines.

Run details

An Option whose children were not a single text node lost both `textValue` and
`value`, so react-aria assigned it a key off a render-order counter
(`react-aria-1`). That key is what the field reports as its selected value and
what `defaultValue` has to match, so the option submitted a meaningless string,
could not be preselected, and shifted when unrelated markup around it changed —
all silently. The Select Default story is the shape that hits it:
`<Option>Millennium Falcon <Badge>Latest</Badge></Option>`.

Inference now walks the children instead of requiring exactly one node. Only
text that is a child itself counts: text inside an element child belongs to that
element, so the option above is "Millennium Falcon", not "Millennium Falcon
Latest". Remote text nodes (`RemoteTextRenderer`) count the same way, which
keeps remote Options working.

That walk is a new helper, `extractTextFromChildren`.
`extractTextFromFirstChild` stays as it is — Button, Markdown, Initials and
Truncate all depend on its "exactly one text node" strictness.

`value` keeps defaulting to `textValue`; decoupling them would break every app
relying on text-as-value. What does not survive is it silently becoming
undefined: an Option with no inferable text now logs a console error naming the
key fallback and asking for an explicit `value`.

MIGRATION.md gains an entry — the key of a mixed-children Option changes from
`react-aria-N` to its text, so anywhere that key was read back has to be
checked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread packages/components/src/lib/react/remote.ts
Comment thread packages/components/src/lib/react/remote.ts Outdated
mfal and others added 2 commits September 4, 2026 08:37
…-3028

# Conflicts:
#	packages/components/src/lib/react/remote.ts
#3053 landed `containsTextChild` with its own `isTextNode` predicate and its
own fragment recursion, which is the same classification `joinTextChildren`
does here. Two traversals, one piece of knowledge.

`containsTextChild` now asks `extractTextFromChildren` and compares against
`undefined`, so `isTextNode` goes away. One behavior change follows from the
shared reading: `extractTextFromChildren` trims, so children that are only
whitespace no longer count as text. That is the right answer for the question
`Button` and `Link` ask — a blank string is not a label, and the control should
keep its icon-only layout. Pinned by a test.

The boolean answer no longer short-circuits at the first text child. Irrelevant
at the child counts these components see.

Also drops `Button` from `extractTextFromFirstChild`'s doc comment: #3053
replaced its call with `containsTextChild`, leaving `Markdown`, `Initials` and
`Truncate` as the callers that depend on the strict reading.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mfal
mfal enabled auto-merge (squash) September 4, 2026 09:16
@mfal
mfal merged commit e80bf3c into main Sep 4, 2026
25 checks passed
@mfal
mfal deleted the fix/option-text-value-3028 branch September 4, 2026 09:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants