Skip to content

fix(accordion): prevent controlled→uncontrolled flip in single collapsible Accordion#3949

Open
chatman-media wants to merge 1 commit into
radix-ui:mainfrom
chatman-media:fix/accordion-single-controlled-collapsible
Open

fix(accordion): prevent controlled→uncontrolled flip in single collapsible Accordion#3949
chatman-media wants to merge 1 commit into
radix-ui:mainfrom
chatman-media:fix/accordion-single-controlled-collapsible

Conversation

@chatman-media

Copy link
Copy Markdown

Bug

When <Accordion.Root type="single" collapsible> is used in controlled mode with value set to undefined (the natural way to represent "nothing open" in TypeScript), the component malfunctions:

  1. Click to open item → works, onValueChange('item-1') fires
  2. Click to close → onValueChange('') fires; if the handler stores undefined (setValue(v || undefined)), the next render sees value={undefined}
  3. useControllableState treats prop = undefined as uncontrolled, so it falls back to its stale internal state ('item-1') — the item visually re-opens
  4. Clicking again tries to close but gets stuck in the same loop

This produces the "3 clicks to close" symptom reported in #3478.

Root cause

useControllableState uses prop !== undefined to decide whether the component is controlled. When a user passes value={undefined} (meaning "no item open"), the component silently becomes uncontrolled and the stale uncontrolled state resurrects the last-open item.

Fix

In AccordionImplSingle, detect whether the value prop was explicitly provided via 'value' in props. When it was, normalize undefined → '' before handing it to useControllableState. This keeps the component in controlled mode for its entire lifetime regardless of what the parent stores.

+ const isValueControlled = 'value' in props;
  const [value, setValue] = useControllableState({
-   prop: valueProp,
+   prop: isValueControlled ? (valueProp ?? '') : valueProp,
    defaultProp: defaultValue ?? '',
    onChange: onValueChange,
    caller: ACCORDION_NAME,
  });

The change is backward-compatible:

  • Uncontrolled (<Accordion> with no value prop): 'value' in props is false → behaviour unchanged
  • Controlled with a value (value="item-1"): isValueControlled is true, valueProp ?? '' is 'item-1' → behaviour unchanged
  • Controlled with nothing open (value={undefined}): normalized to '' → stays controlled, empty value ✓

Test evidence

A new test given a controlled single collapsible Accordion covers the exact failure scenario. It fails without the fix and passes after:

✓ should show the content after the first click
✓ should hide the content after the second click   ← was failing
✓ should show the content again (re-open)          ← was failing

Full test suite: 32 test files, 350 tests — all pass.

Closes #3478

…sible Accordion

When `<Accordion.Root type="single" collapsible>` is used with a controlled
`value` prop set to `undefined` (representing "nothing open"), the component
would fall into uncontrolled mode because `useControllableState` treats any
`undefined` prop as uncontrolled.  On collapse the parent often sets state
back to `undefined`, which caused `useControllableState` to reuse its stale
internal value (the previously opened item), making the item reappear open.

Fix: detect whether the `value` prop was explicitly provided via `'value' in
props` and, when it was, normalize `undefined` to `''` before passing to
`useControllableState`.  This keeps the component in controlled mode for the
full lifetime of the mount, matching user expectations.

Closes radix-ui#3478
@changeset-bot

changeset-bot Bot commented Jun 8, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: cabd7f5

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

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.

Controlled single collapsible Accordion requires 3 clicks to close due to state update timing

1 participant