Skip to content

[radio] Put a roving tab stop in server-rendered markup - #5712

Open
cpruijsen wants to merge 2 commits into
mui:masterfrom
cpruijsen:fix/issue-5597
Open

cpruijsen wants to merge 2 commits into
mui:masterfrom
cpruijsen:fix/issue-5597

Conversation

@cpruijsen

Copy link
Copy Markdown
Contributor

Server-rendered RadioGroup markup now puts tabindex="0" on the first radio and tabindex="-1" on the rest, so the group is in the tab order before hydration. Radio.Root passes guess into CompositeItem, which forwards it through useCompositeItem (destructured so it does not leak onto the DOM). A renderToString assertion checks that the first radio is tabindex="0" and the rest -1.

Related to #5597 (the tabindex half). This should not close the issue: server markup still omits aria-labelledby on the radiogroup.

Composite highlight starts at index 0 during render, but Radio items did not guess their index from render order, so every item stayed at -1 and none matched. MenuItem, SelectItem, ComboboxItem, and OTPFieldInput already pass guess: true into useCompositeListItem so the index exists during SSR. Radio went through CompositeItem, which did not accept or forward guess.

After hydration, the existing map flush still moves the tab stop onto the checked or first enabled radio. Arrow-key roving and the client "checked radio starts with tabIndex=0" test are unchanged.

Decision

Use the index-guess already used by other composites, scoped to Radio.

Alternative: invert Fieldset legend-id generation so SSR also emits aria-labelledby, and/or default guess on Tabs, Toolbar, Toggle, and Navigation Menu.

The labelled-by half is a child-to-parent registration that current Fieldset SSR tests explicitly keep unset until hydration. Changing it needs a design for missing legends and caller-supplied legend ids. Can follow up on the labelled-by half.

The reporter already confirmed locally (against 1.7.0) that forwarding guess emits tabindex="0" / tabindex="-1", hydration does not mismatch, and tabbing back into the group still lands on the checked radio.

Test plan

  • New RadioGroup SSR test fails without the source change and passes with it.
  • RadioGroup tests (jsdom + Chromium), RadioRoot tests, Composite tests.
  • Pre-hydration Tab in a real SSR app (TanStack Start / Next) if you want a manual check.

Fixes #5597

@pkg-pr-new

pkg-pr-new Bot commented Sep 11, 2026 •

Copy link
Copy Markdown

commit: df9987c

@code-infra-dashboard

code-infra-dashboard Bot commented Sep 11, 2026 •

Copy link
Copy Markdown

Bundle size

Bundle Parsed size Gzip size
@base-ui/react 🔺+25B(+0.01%) 🔺+16B(+0.01%)

Details of bundle changes

Performance

Total duration: 1,157.27 ms -100.30 ms(-8.0%) | Renders: 76 (+0) | Paint: 1,877.23 ms -144.80 ms(-7.2%)

Test Duration Renders
Tabs mount (200 instances) 215.85 ms ▼-56.63 ms(-20.8%) 3 (+0)
Slider mount (300 instances) 110.76 ms ▼-30.29 ms(-21.5%) 2 (+0)

13 tests within noise — details


Check out the code infra dashboard for more information about this PR.

@netlify

netlify Bot commented Sep 11, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for base-ui ready!

Built without sensitive environment variables

Name Link
🔨 Latest commit df9987c
🔍 Latest deploy log https://app.netlify.com/projects/base-ui/deploys/6aa96ba442dc83000845b171
😎 Deploy Preview https://deploy-preview-5712--base-ui.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

Copy link
Copy Markdown
Member

PR review

Two merge-blocking SSR regressions affect disabled-radio focus and React 18 Strict Mode hydration. Both were reproduced on PR head 040a3cf4a and were absent on parent a4f5e0ed2 using isolated SSR and jsdom hydration checks. Full suites were not run; browser policy blocked the live Tab-key check.

Bugs (2)

1. 🔴 Keep disabled radios out of the initial tab order

Location: packages/react/src/radio/root/RadioRoot.tsx:260

guess

Enabling index guessing assigns the first radio tabindex="0" without accounting for its disabled state. The default <span aria-disabled="true"> remains keyboard-focusable. The parent emitted tabindex="-1" on the disabled radio.

Failure scenario: Server-rendering <RadioGroup disabled> or a group whose first <Radio.Root> is disabled makes the disabled first radio tabbable before hydration. In a partially disabled group, the enabled second radio still receives tabindex="-1".

Fix: Account for disabled status when resolving the initial tab stop: choose the first enabled radio and leave fully disabled groups untabbable. Add SSR coverage for disabled-first and fully disabled groups.

2. 🔴 Make index guessing stable during React 18 Strict Mode hydration

Location: packages/react/src/radio/root/RadioRoot.tsx:260

guess

Index guessing increments the shared nextIndexRef during state initialization. React 18 Strict Mode’s discarded render consumes an index, causing the first radio’s client index to differ from its server index. Instrumentation confirmed that two radios allocate indexes 0, 1 normally but 0, 1, 2, 3 under Strict Mode.

Failure scenario: Hydrating a two-radio group under React 18.3.1 Strict Mode produces Prop tabIndex did not match. Server: "0" Client: "-1". Layout correction restores [0, -1], but does not prevent the warning. Neither the parent nor the head without Strict Mode emits it.

Fix: Make initial index assignment stable across discarded Strict Mode renders. Extend SSR coverage to hydrate under React 18 Strict Mode and assert both warning-free hydration and the resulting tab order.

Verdict

Request changes - disabled-radio focus and React 18 Strict Mode hydration regressions should be fixed before merging.


🤖 Review generated with Codex

@michaldudak michaldudak added component: radio group Changes related to the radio group component. and removed radio labels Sep 15, 2026
@cpruijsen

Copy link
Copy Markdown
Contributor Author

Fixed the first one in 3ccb929. Disabled radios now sit out index guessing, so the server-rendered tab stop lands on the first enabled radio and a fully disabled group stays out of the tab order, with SSR tests for both.

The Strict Mode one looks pre-existing rather than new here. guess: true already ships on Menu, Select, Combobox and OTPField items, and OTPFieldInput calls useCompositeListItem directly, so nothing from this PR is in its path. On this branch it still allocates indexes 0 through 5 for three inputs under React 18 Strict Mode. Radios make it visible because they are the first guess consumer rendered inline on the server.

I do not think it can be closed at the item level. Memoising the guess by useId does not work: React 18 gives the discarded render and the replay different ids, :r0: then :r3:. Resetting the counter per render in CompositeList does not work either, because each component is double-invoked immediately, so the list resets before any child pair runs.

Allocating from a shared counter during render is what Strict Mode is built to catch, so closing it means changing what guess does. One option, proposed not decided: derive the server tab stop from the existing ACTIVE_COMPOSITE_ITEM marker, which needs no counter, though it only covers groups that have a value selected.

Separate PR for that, or here?

Radio items skipped index guessing, so SSR markup left every radio at
tabindex=-1 until layout effects ran.
Index guessing gave the first radio tabindex=0 in the server markup
without checking whether it was disabled, so a disabled first radio, or
a fully disabled group, rendered a tab stop on an element that should
not hold one.

Disabled radios now sit out the guess, so the server-rendered tab stop
lands on the first enabled radio and a fully disabled group stays out of
the tab order.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

component: radio group Changes related to the radio group component.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[radio] SSR markup has no aria-labelledby on the radiogroup and tabindex="-1" on every radio

3 participants