Skip to content

Docs: Scope control input ids to each <Controls /> block instance#34793

Merged
Sidnioulz merged 2 commits into
storybookjs:nextfrom
TheSeydiCharyyev:fix/issue-29295-non-unique-control-ids
May 21, 2026
Merged

Docs: Scope control input ids to each <Controls /> block instance#34793
Sidnioulz merged 2 commits into
storybookjs:nextfrom
TheSeydiCharyyev:fix/issue-29295-non-unique-control-ids

Conversation

@TheSeydiCharyyev
Copy link
Copy Markdown
Contributor

@TheSeydiCharyyev TheSeydiCharyyev commented May 14, 2026

What I did

Multiple <Controls /> blocks rendered for the same story on a single docs page produce control inputs with duplicate id and name attributes. This breaks <label htmlFor> association and causes radio button groups across the blocks to merge — the browser groups <input type="radio"> by shared name, so editing the selection in one block silently changes the other.

#34021 added a storyId parameter to getControlId to disambiguate <Controls /> blocks for different stories on the same page. It does not cover the case in this issue because both blocks pass the same storyId.

This PR threads a per-<Controls />-instance controlsId through the same chain that already carries storyId:

Controls.tsxArgsTableArgRowArgControl → individual controls (Boolean, Radio, Checkbox, Select, Range, Text, Object, Files, Date, Color, Number).

controlsId is produced by React.useId() (with colons stripped so the value is safe inside CSS selectors) and added as an optional third argument to getControlId and getControlSetterButtonId:

control-{controlsId}-{storyId}-{name}
set-{controlsId}-{storyId}-{name}

Both extra arguments remain optional, so direct consumers of <ArgsTable> outside the <Controls /> block are unaffected.

Fixes #29295

How to test

  1. cd code && yarn storybook:ui
  2. Navigate to Blocks → Controls → Multiple Controls For Same Story On Same Page
  3. The bundled play function asserts that all control inputs across both blocks have unique IDs.

Manual radio-grouping check (any story that exposes a radio control):

  1. Render the same story in two <Controls /> blocks on one page
  2. Change the radio selection in the second block
  3. The first block's selection should remain untouched (without this fix, both blocks moved together)

Manual testing

  • yarn fmt:write
  • yarn nx run-many -t check (clean for changed projects)
  • yarn nx compile addon-docs --skip-nx-cache
  • Verified the new MultipleControlsForSameStoryOnSamePage story passes its play assertions in the internal Storybook UI

Summary by CodeRabbit

  • Bug Fixes

    • Prevented control element ID collisions when multiple Controls blocks for the same story appear on one page.
  • Tests

    • Added a Storybook play/test that verifies control IDs are unique across multiple Controls blocks.
  • Documentation

    • Added a Storybook example demonstrating multiple Controls blocks on the same story page.

Review Change Stack

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 14, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d8d3d2ae-0915-4688-b36f-5508d4c37b04

📥 Commits

Reviewing files that changed from the base of the PR and between deeb872 and 6ead8f8.

📒 Files selected for processing (1)
  • code/addons/docs/src/blocks/blocks/Controls.tsx

📝 Walkthrough

Walkthrough

Introduces a per-Controls-instance controlsId (from React's useId) threaded through ArgsTable → ArgRow → ArgControl to control components; helpers include controlsId in generated DOM IDs; adds tests and a Storybook story that verifies IDs are unique when multiple Controls render for the same story.

Changes

Control ID Disambiguation

Layer / File(s) Summary
ID helper functions
code/addons/docs/src/blocks/controls/helpers.ts, code/addons/docs/src/blocks/controls/helpers.test.ts
getControlId and getControlSetterButtonId accept an optional controlsId and build IDs from ordered parts; added tests for controlsId-only and controlsId+storyId.
Type and prop contracts
code/addons/docs/src/blocks/controls/types.ts, code/addons/docs/src/blocks/components/ArgsTable/ArgControl.tsx, code/addons/docs/src/blocks/components/ArgsTable/ArgRow.tsx, code/addons/docs/src/blocks/components/ArgsTable/ArgsTable.tsx
Added optional controlsId?: string to ControlProps<T>, ArgControlProps, ArgRowProps, and ArgsTableOptionProps to propagate the instance identifier.
Controls and ArgsTable wiring
code/addons/docs/src/blocks/blocks/Controls.tsx, code/addons/docs/src/blocks/components/ArgsTable/ArgsTable.tsx, code/addons/docs/src/blocks/components/ArgsTable/ArgRow.tsx, code/addons/docs/src/blocks/components/ArgsTable/ArgControl.tsx
Controls uses useId() to build a sanitized controlsId and passes it to PureArgsTable/TabbedArgsTable; ArgsTable and ArgControl thread controlsId in the shared common props down to control components.
Basic control components
code/addons/docs/src/blocks/controls/Boolean.tsx, .../Color.tsx, .../Date.tsx, .../Files.tsx, .../Number.tsx, .../Range.tsx, .../Text.tsx
Each control now destructures controlsId and passes it into getControlId/getControlSetterButtonId when computing element IDs.
Selection, Object, and Select controls
code/addons/docs/src/blocks/controls/Object.tsx, code/addons/docs/src/blocks/controls/options/Checkbox.tsx, Radio.tsx, Select.tsx
Checkbox, Radio, Select (single/multi), and Object controls accept controlsId and include it in ID generation for inputs, option ids, setter buttons, and textareas.
Test story
code/addons/docs/src/blocks/blocks/Controls.stories.tsx
New MultipleControlsForSameStoryOnSamePage story renders two <Controls> blocks for the same story and asserts via a play function that all control- element IDs on the canvas are unique.

Sequence Diagram:

sequenceDiagram
  participant Controls
  participant PureArgsTable
  participant ArgRow
  participant ArgControl
  participant ControlComponent
  Controls->>Controls: useId() -> controlsId
  Controls->>PureArgsTable: pass controlsId
  PureArgsTable->>ArgRow: include controlsId in common props
  ArgRow->>ArgControl: forward controlsId
  ArgControl->>ControlComponent: forward controlsId
  ControlComponent->>ControlComponent: getControlId(name, storyId, controlsId)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • storybookjs/storybook#34021: Addresses the same control-ID plumbing and ID-collision concerns; closely related to the controlsId disambiguation work.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
code/addons/docs/src/blocks/blocks/Controls.stories.tsx (1)

197-204: ⚡ Quick win

Consider adding explicit verification of radio button name independence.

The test correctly verifies that all control element IDs are unique. However, the original issue #29295 specifically mentions radio inputs being grouped by shared name attributes, causing cross-block interference. While your implementation correctly scopes radio names via controlId (which includes controlsId), the test would be more comprehensive if it explicitly verified that radio button name attributes are unique across blocks.

🧪 Optional: Add explicit radio name verification
  play: async ({ canvasElement }) => {
    const allIds = Array.from(canvasElement.querySelectorAll('[id^="control-"]')).map(
      (el) => el.id
    );
    const uniqueIds = new Set(allIds);
    await expect(allIds.length).toBeGreaterThan(0);
    await expect(uniqueIds.size).toBe(allIds.length);
+
+   // Explicitly verify radio button name independence across blocks
+   const radioNames = Array.from(canvasElement.querySelectorAll('input[type="radio"]')).map(
+     (el) => (el as HTMLInputElement).name
+   );
+   if (radioNames.length > 0) {
+     const uniqueRadioNames = new Set(radioNames);
+     await expect(uniqueRadioNames.size).toBe(radioNames.length);
+   }
  },
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@code/addons/docs/src/blocks/blocks/Controls.stories.tsx` around lines 197 -
204, Extend the current play test in Controls.stories.tsx (the play: async ({
canvasElement }) => { ... }) to also collect radio inputs and assert their name
attributes are unique across blocks: query the canvasElement for radio inputs
(e.g., canvasElement.querySelectorAll('input[type="radio"]') or scoped to
'[id^="control-"] input[type="radio"]'), build a Set of their name values, and
add an expectation that the Set size equals the total radio elements count so
radio name attributes are not shared across blocks (keep existing ID uniqueness
checks intact).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@code/addons/docs/src/blocks/blocks/Controls.stories.tsx`:
- Around line 197-204: Extend the current play test in Controls.stories.tsx (the
play: async ({ canvasElement }) => { ... }) to also collect radio inputs and
assert their name attributes are unique across blocks: query the canvasElement
for radio inputs (e.g., canvasElement.querySelectorAll('input[type="radio"]') or
scoped to '[id^="control-"] input[type="radio"]'), build a Set of their name
values, and add an expectation that the Set size equals the total radio elements
count so radio name attributes are not shared across blocks (keep existing ID
uniqueness checks intact).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f51c3ba2-c267-4d75-b004-7ada095dd5f1

📥 Commits

Reviewing files that changed from the base of the PR and between 589f29c and 87534e6.

📒 Files selected for processing (19)
  • code/addons/docs/src/blocks/blocks/Controls.stories.tsx
  • code/addons/docs/src/blocks/blocks/Controls.tsx
  • code/addons/docs/src/blocks/components/ArgsTable/ArgControl.tsx
  • code/addons/docs/src/blocks/components/ArgsTable/ArgRow.tsx
  • code/addons/docs/src/blocks/components/ArgsTable/ArgsTable.tsx
  • code/addons/docs/src/blocks/controls/Boolean.tsx
  • code/addons/docs/src/blocks/controls/Color.tsx
  • code/addons/docs/src/blocks/controls/Date.tsx
  • code/addons/docs/src/blocks/controls/Files.tsx
  • code/addons/docs/src/blocks/controls/Number.tsx
  • code/addons/docs/src/blocks/controls/Object.tsx
  • code/addons/docs/src/blocks/controls/Range.tsx
  • code/addons/docs/src/blocks/controls/Text.tsx
  • code/addons/docs/src/blocks/controls/helpers.test.ts
  • code/addons/docs/src/blocks/controls/helpers.ts
  • code/addons/docs/src/blocks/controls/options/Checkbox.tsx
  • code/addons/docs/src/blocks/controls/options/Radio.tsx
  • code/addons/docs/src/blocks/controls/options/Select.tsx
  • code/addons/docs/src/blocks/controls/types.ts

@TheSeydiCharyyev
Copy link
Copy Markdown
Contributor Author

@coderabbitai re: the nitpick on explicit radio name verification — the current ID-uniqueness check transitively covers this. In Radio.tsx, name === controlId, so unique IDs imply distinct names across blocks. Within a single radio group, sharing name is intentional (that's how the browser groups radios) — the bug was about names colliding across blocks, which the ID check already catches. The example story uses text args, so a radio-specific assertion wouldn't trigger anyway. Can add a radio-using story if a maintainer would prefer stronger coverage.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 14, 2026

Tip

For best results, initiate chat on the files or code changes.

@TheSeydiCharyyev That's a solid explanation — the transitive coverage argument holds. Since name === controlId in Radio.tsx, the existing ID-uniqueness assertion does implicitly guarantee distinct name attributes across <Controls /> blocks for the same story. Pressing for a redundant explicit radio assertion isn't warranted, and the within-group name sharing is correct browser semantics, not a bug. Your current test design is sufficient for the stated fix. Thanks for walking through the reasoning.


✏️ Learnings added
Learnt from: TheSeydiCharyyev
URL: https://github.com/storybookjs/storybook/pull/34793

Timestamp: 2026-05-14T04:49:22.959Z
Learning: In the Storybook `Radio.tsx` control (`code/addons/docs/src/blocks/controls/options/Radio.tsx`), `name` is set equal to `controlId`. Therefore, a test asserting uniqueness of `control-*` IDs across `<Controls />` block instances transitively guarantees uniqueness of radio `name` attributes across blocks. Within a single radio group, sharing `name` is intentional browser-grouping behavior and should not be flagged. A dedicated radio-specific name-uniqueness story is not required to cover the cross-block collision fix introduced in PR `#34793`.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

Copy link
Copy Markdown
Member

@Sidnioulz Sidnioulz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code LGTM, thanks @TheSeydiCharyyev!

There's a small issue with useId that is incompatible with older React versions, see comment for a proposed fix.

Comment thread code/addons/docs/src/blocks/blocks/Controls.tsx Outdated
@Sidnioulz Sidnioulz force-pushed the fix/issue-29295-non-unique-control-ids branch from 87534e6 to deeb872 Compare May 21, 2026 09:25
@Sidnioulz Sidnioulz merged commit a56a3c7 into storybookjs:next May 21, 2026
134 checks passed
@github-project-automation github-project-automation Bot moved this from Empathy Queue (prioritized) to Done in Core Team Projects May 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

[Bug]: non-unique control input ids

3 participants