diff --git a/.changeset/fix-inert-react-19-compat.md b/.changeset/fix-inert-react-19-compat.md new file mode 100644 index 00000000000..2068744f5a9 --- /dev/null +++ b/.changeset/fix-inert-react-19-compat.md @@ -0,0 +1,5 @@ +--- +"@clerk/ui": patch +--- + +Fix `inert` attribute to work correctly in React 19. The previous value `''` (empty string) is falsy and not set by React 19's boolean attribute handler, leaving hidden panel and collapsible content interactive. Switches to `'true'` which is truthy in both React 18 and 19. diff --git a/packages/headless/src/global.d.ts b/packages/headless/src/global.d.ts new file mode 100644 index 00000000000..34c921452ae --- /dev/null +++ b/packages/headless/src/global.d.ts @@ -0,0 +1,13 @@ +// `export {}` makes this file a module so `declare module 'react'` augments React's +// types instead of replacing them (an ambient declaration would shadow React, breaking +// type resolution package-wide). +export {}; + +declare module 'react' { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + interface HTMLAttributes { + // `inert` landed in @types/react v19; augment for React 18 compatibility. + // Use 'true' (truthy string) — '' is falsy in React 19 and won't set the attribute. + inert?: 'true' | undefined; + } +} diff --git a/packages/headless/src/primitives/tabs/tabs.test.tsx b/packages/headless/src/primitives/tabs/tabs.test.tsx index 24a8ec960dc..ca344c0107c 100644 --- a/packages/headless/src/primitives/tabs/tabs.test.tsx +++ b/packages/headless/src/primitives/tabs/tabs.test.tsx @@ -506,6 +506,29 @@ describe('Tabs', () => { const panels = document.querySelectorAll('[data-cl-slot="tabs-panel"][data-cl-hidden]'); expect(panels).toHaveLength(2); }); + + it('non-selected panels have inert attribute, selected panel does not', () => { + renderTabs(); + const panels = document.querySelectorAll('[data-cl-slot="tabs-panel"]'); + const inert = Array.from(panels).filter(p => p.hasAttribute('inert')); + const notInert = Array.from(panels).filter(p => !p.hasAttribute('inert')); + // Presence check only — React 18 renders inert="true", React 19 normalises to inert="" + expect(inert).toHaveLength(2); + expect(notInert).toHaveLength(1); + }); + + it('inert updates when selection changes', async () => { + const user = userEvent.setup(); + renderTabs(); + + await user.click(screen.getByText('Settings')); + + const panels = document.querySelectorAll('[data-cl-slot="tabs-panel"]'); + const [account, settings, billing] = Array.from(panels); + expect(account).toHaveAttribute('inert'); + expect(settings).not.toHaveAttribute('inert'); + expect(billing).toHaveAttribute('inert'); + }); }); describe('roving tabindex', () => { diff --git a/packages/ui/src/components/PricingTable/PricingTableMatrix.tsx b/packages/ui/src/components/PricingTable/PricingTableMatrix.tsx index e6ab609343b..4c17b858aac 100644 --- a/packages/ui/src/components/PricingTable/PricingTableMatrix.tsx +++ b/packages/ui/src/components/PricingTable/PricingTableMatrix.tsx @@ -265,7 +265,6 @@ export function PricingTableMatrix({ }), feePeriodNoticeAnimation, ]} - // @ts-ignore - Needed until React 19 support inert={planPeriod !== 'annual' ? 'true' : undefined} >
{ }); describe('Inert Attribute', () => { - it('sets inert to empty string when open={false}', async () => { + it('sets inert when open={false}', async () => { const { wrapper } = await createFixtures(); const { container, rerender } = render(Content, { wrapper }); @@ -375,7 +375,8 @@ describe('Collapsible', () => { rerender(Content); const element = container.querySelector('.cl-collapsible') as HTMLElement; - expect(element).toHaveAttribute('inert', ''); + // Check presence only — React 18 renders inert="true", React 19 normalises to inert="" + expect(element).toHaveAttribute('inert'); }); it('does not set inert when open={true}', async () => { diff --git a/packages/ui/src/global.d.ts b/packages/ui/src/global.d.ts index 7bbffaeda66..19db755dffa 100644 --- a/packages/ui/src/global.d.ts +++ b/packages/ui/src/global.d.ts @@ -1,6 +1,14 @@ import type { Clerk } from '@clerk/shared/types'; import type { ClerkUIConstructor } from '@clerk/shared/ui'; +declare module 'react' { + interface HTMLAttributes { + // `inert` landed in @types/react v19; augment for React 18 compatibility. + // Use 'true' (truthy string) — '' is falsy in React 19 and won't set the attribute. + inert?: 'true' | undefined; + } +} + declare module '*.svg' { const value: React.FC>; export default value;