[radio] SSR markup has no aria-labelledby on the radiogroup and tabindex="-1" on every radio
- Dominant language
- TypeScript
- Stars
- 10.9k
- Forks
- 543
- Avg merge
- 1d 20h
- Merged PRs (30d)
- 101
Description
# Bug report
Two related defects in the server-rendered markup of `Radio` / `RadioGroup` / `Fieldset`. Both self-correct on hydration, so they're invisible in a client-only app, but the SSR HTML ships an unlabelled radio group that is also outside the tab order.
They share a cause: both the label association and the roving tabindex are resolved in **layout effects**, which never run on the server.
## Current behavior
Server-rendering a `Fieldset.Root` + `RadioGroup` + `Radio.Root` tree produces:
```html
good boy
good girl
```
1. **The `radiogroup` has no `aria-labelledby`**, so it has no accessible name, even though `Fieldset.Legend` rendered and has an `id`.
2. **Every `role="radio"` has `tabindex="-1"`**, so the group is not reachable by Tab — there is no roving `tabindex="0"` entry point.
Both are corrected once hydration runs, so this only affects the pre-hydration window and anything consuming the static HTML.
## Expected behavior
The server markup should match what hydration produces:
1. `role="radiogroup"` carries `aria-labelledby` pointing at the legend.
2. The first non-disabled radio (or the checked one) carries `tabindex="0"`, the rest `-1`.
## Reproducible example
No bundler or framework needed — plain `renderToString`. Save as `repro.mjs` in a project with `react`, `react-dom` and `@base-ui/react` installed, then `node repro.mjs`:
```js
import { createElement as h } from 'react';
import { renderToString } from 'react-dom/server';
import { RadioGroup } from '@base-ui/react/radio-group';
import { Radio } from '@base-ui/react/radio';
import { Fieldset } from '@base-ui/react/fieldset';
const html = renderToString(
h(Fieldset.Root, { render: h(RadioGroup, null) },
h(Fieldset.Legend, null, 'Is your pet a boy or a girl?'),
h(Radio.Root, { value: 'male' }, 'good boy'),
h(Radio.Root, { value: 'female' }, 'good girl'),
),
);
console.log('radiogroup has aria-labelledby :', /role="radiogroup"[^>]*aria-labelledby/.test(html));
console.log('tabindex on role=radio :', html.match(/role="radio" tabindex="(-?\d)"/g));
```
Actual output:
```
radiogroup has aria-labelledby : false
tabindex on role=radio : [ 'role="radio" tabindex="-1"', 'role="radio" tabindex="-1"' ]
```
I originally hit this in a TanStack Start app, but as the repro shows it is not framework-specific.
## Base UI version
v1.7.0
## Which browser are you using?
Chrome 131 (the defect is in the server output, so it is browser-independent)
## Which OS are you using?
macOS 26
## Which assistive tech are you using (if applicable)?
None — found by inspecting SSR markup during an accessibility audit, then confirmed against the hydrated accessibility tree.
## Additional context
### Cause of (2), the `tabindex`
`useCompositeItem` derives the tabindex from the highlight:
```js
const isHighlighted = highlightedIndex === index;
const compositeProps = { tabIndex: isHighlighted ? 0 : -1, ... };
```
`useCompositeRoot` initialises `internalHighlightedIndex` to `0` during render, so on the server `highlightedIndex === 0`. But `useCompositeListItem` leaves `index` at `-1` until its `useIsoLayoutEffect` registration runs — so on the server no item matches and every item gets `-1`.
`useCompositeListItem` already has the remedy in the `guess` option, which computes the index from render order inside a `useState` initialiser (so it does run during SSR):
```js
// Guess the index from the render order. This avoids a re-render after mount for
// flat lists rendered in DOM order; ...
```
`MenuItem`, `MenuRadioItem`, `MenuCheckboxItem`, `MenuLinkItem`, `MenuSubmenuTrigger`, `SelectItem`, `ComboboxItem` and `OTPFieldInput` all pass `guess: true`. `RadioRoot` does not — and it goes through `CompositeItem`, which currently neither accepts nor forwards `guess`:
```js
// internals/composite/item/CompositeItem.js
const { render, className, style, state, props, refs, metadata, stateAttributesMapping, tag = 'div', ...elementProps } = componentProps;
const { compositeProps, compositeRef } = useCompositeItem({ metadata });
```
### Suggested fix for (2)
Accept and forward `guess` in `CompositeItem` (destructured so it doesn't leak onto the DOM), and pass `guess: true` from `RadioRoot`'s `CompositeItem`.
I patched exactly that locally against 1.7.0 and the repro then emits `tabindex="0"` / `tabindex="-1"`. In a browser I confirmed hydration produces no mismatch warning, arrow-key roving and selection still work, and tabbing back into the group still lands on the checked radio. Happy to open a PR if that approach looks right.
This is likely also the cause of #4174's observation that a `RadioGroup` "first mounts with `tabIndex=-1` ... then after a render they appear with `tabIndex=0`".
`Tabs`, `Toolbar` and the other composites that don't opt into `guess` are presumably affected the same way; I only verified `Radio`.
### Cause of (1), the `aria-labelledby`
`FieldsetRoot` holds the legend id in state and only learns it from the child:
```js
const [legendId, setLegendId] = React.useState(undefined);
// ...
props: [{ 'aria-labelledby': legendId, disabled }, elementProps]
```
`FieldsetLegend` pushes it up through `useRegisteredLabelId`, which registers in a layout effect. On the server that effect never runs, so `legendId` is `undefined` and the attribute is dropped. `RadioGroup`'s own `const ariaLabelledby = labelId ?? fieldsetContext?.legendId` inherits the same problem.
I don't have a clean fix to propose here, since it's a child-to-parent registration. The obvious direction would be for `FieldsetRoot` to generate the id during render and expose it via context for `FieldsetLegend` to consume, inverting the flow — but that presumably conflicts with supporting a caller-supplied `id` on the legend, so I'd rather leave the design call to you.
### Workaround for (1)
Because both `FieldsetRoot` and `CompositeRoot` merge caller props last, passing the id explicitly in both directions wins over the internal wiring and works on the server today:
```jsx
const legendId = useId();
}>
Is your pet a boy or a girl?
...
```
Contributor guide
Research direction
Run the provided repro.mjs first to observe the server-rendered attributes. Then read internals/composite/item/CompositeItem.js, RadioRoot, FieldsetRoot, FieldsetLegend, and RadioGroup to trace the SSR paths described in the issue. Done means SSR includes the radiogroup label and correct roving tabindex, while hydration remains warning-free and interactive behavior still works.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- react, typescript
- Domain
- accessibility, frontend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 57/100