From cabd7f5086fd6e181fccc7d55061ba7edb2cbfeb Mon Sep 17 00:00:00 2001 From: Alexander Kireev Date: Mon, 8 Jun 2026 07:26:05 +0700 Subject: [PATCH] =?UTF-8?q?fix(accordion):=20prevent=20controlled=E2=86=92?= =?UTF-8?q?uncontrolled=20flip=20in=20single=20collapsible=20Accordion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `` 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 #3478 --- .../react/accordion/src/accordion.test.tsx | 64 +++++++++++++++++++ packages/react/accordion/src/accordion.tsx | 8 ++- 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/packages/react/accordion/src/accordion.test.tsx b/packages/react/accordion/src/accordion.test.tsx index 93a8161987..a6f419f673 100644 --- a/packages/react/accordion/src/accordion.test.tsx +++ b/packages/react/accordion/src/accordion.test.tsx @@ -291,6 +291,51 @@ describe('given a single Accordion', () => { }); }); +describe('given a controlled single collapsible Accordion', () => { + let rendered: RenderResult; + + afterEach(cleanup); + + beforeEach(() => { + rendered = render(); + }); + + describe('when opening an item and then clicking the same trigger', () => { + let trigger: HTMLElement; + let contentOne: HTMLElement; + + beforeEach(() => { + trigger = rendered.getByText('Trigger One'); + fireEvent.click(trigger); + contentOne = rendered.getByText('Content One'); + }); + + it('should show the content after the first click', () => { + expect(contentOne).toBeVisible(); + }); + + describe('then clicking the trigger again to close', () => { + beforeEach(() => { + fireEvent.click(trigger); + }); + + it('should hide the content after the second click', () => { + expect(contentOne).not.toBeVisible(); + }); + + describe('then clicking the trigger a third time to re-open', () => { + beforeEach(() => { + fireEvent.click(trigger); + }); + + it('should show the content again', () => { + expect(rendered.getByText('Content One')).toBeVisible(); + }); + }); + }); + }); +}); + describe('given a multiple Accordion', () => { let handleValueChange: Mock; let rendered: RenderResult; @@ -412,3 +457,22 @@ function AccordionTest(props: React.ComponentProps) { ); } + +function ControlledAccordionSingle() { + const [value, setValue] = React.useState(undefined); + return ( + setValue(v || undefined)} + > + + + Trigger One + + Content One + + + ); +} diff --git a/packages/react/accordion/src/accordion.tsx b/packages/react/accordion/src/accordion.tsx index 36dbdc686c..ba4f633283 100644 --- a/packages/react/accordion/src/accordion.tsx +++ b/packages/react/accordion/src/accordion.tsx @@ -106,8 +106,14 @@ const AccordionImplSingle = React.forwardRef