diff --git a/.changeset/fix-inert-react-19-compat.md b/.changeset/fix-inert-react-19-compat.md new file mode 100644 index 00000000000..34d476d5c6b --- /dev/null +++ b/.changeset/fix-inert-react-19-compat.md @@ -0,0 +1,5 @@ +--- +"@clerk/ui": patch +--- + +Add support for the `inert` attribute usage under React 19. Inert content is now correctly non-interactive on both React 18 and 19. diff --git a/.changeset/shared-inert-props-helper.md b/.changeset/shared-inert-props-helper.md new file mode 100644 index 00000000000..8c09a4a08c1 --- /dev/null +++ b/.changeset/shared-inert-props-helper.md @@ -0,0 +1,5 @@ +--- +"@clerk/shared": patch +--- + +Add an `inertProps` helper (`@clerk/shared/inert`) that resolves the correct `inert` attribute value for the consumer's React major (React 19 dropped the `inert` attribute for falsy string values). diff --git a/packages/headless/package.json b/packages/headless/package.json index 8a46326ac62..81e7ea7378b 100644 --- a/packages/headless/package.json +++ b/packages/headless/package.json @@ -58,6 +58,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@clerk/shared": "workspace:^", "@floating-ui/react": "catalog:repo" }, "devDependencies": { diff --git a/packages/headless/src/primitives/tabs/tabs-panel.tsx b/packages/headless/src/primitives/tabs/tabs-panel.tsx index dfe659c10bb..0f13c5fcf7c 100644 --- a/packages/headless/src/primitives/tabs/tabs-panel.tsx +++ b/packages/headless/src/primitives/tabs/tabs-panel.tsx @@ -1,5 +1,6 @@ 'use client'; +import { inertProps } from '@clerk/shared/inert'; import { useMergeRefs } from '@floating-ui/react'; import React, { useRef } from 'react'; @@ -51,11 +52,9 @@ export const TabsPanel = React.forwardRef(functi role: 'tabpanel' as const, 'aria-labelledby': tabId, tabIndex: 0, - // `inert` must be a truthy string, not a boolean or empty string, to stay - // correct across React 18 and 19: React 18 drops a boolean `true` and React - // 19 treats `''` as falsy. `'true'` renders the (presence-based) attribute in - // both. Matches the existing pattern in packages/ui PricingTableMatrix. - inert: !isSelected ? 'true' : undefined, + // `inert` reflects differently across React majors; `inertProps` emits the value + // each one actually serializes (see packages/headless/src/utils/inert.ts). + ...inertProps(!isSelected), hidden: !isSelected && !shouldForceMount ? true : undefined, ref: combinedRef, ...(shouldForceMount diff --git a/packages/headless/src/primitives/tabs/tabs.test.tsx b/packages/headless/src/primitives/tabs/tabs.test.tsx index 24a8ec960dc..6f6cd4d5c92 100644 --- a/packages/headless/src/primitives/tabs/tabs.test.tsx +++ b/packages/headless/src/primitives/tabs/tabs.test.tsx @@ -506,6 +506,30 @@ 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 — `inertProps` emits the value each React major reflects + // (string '' on 18, boolean true on 19), both of which serialize 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/headless/vite.config.ts b/packages/headless/vite.config.ts index 8e7f6f2f831..153d0aa443b 100644 --- a/packages/headless/vite.config.ts +++ b/packages/headless/vite.config.ts @@ -31,7 +31,7 @@ export default defineConfig({ formats: ['es'], }, rollupOptions: { - external: ['react', 'react-dom', 'react/jsx-runtime', '@floating-ui/react'], + external: ['react', 'react-dom', 'react/jsx-runtime', '@floating-ui/react', /^@clerk\/shared(\/.*)?$/], // Preserve module-level directives such as `'use client'`. Rollup otherwise // strips them when bundling (emitting a warning), which would drop the // client boundary for React Server Component consumers of the primitives. diff --git a/packages/shared/src/inert.ts b/packages/shared/src/inert.ts new file mode 100644 index 00000000000..293ed341734 --- /dev/null +++ b/packages/shared/src/inert.ts @@ -0,0 +1,29 @@ +import { version } from 'react'; + +// React 19 turned `inert` into a real boolean attribute, so a falsy value like `''` +// is no longer reflected to the DOM. React 18 doesn't know `inert` and only serializes +// a (non-undefined) string value. Resolve the consumer's React major once at module +// load — `react` is a peer dependency, so this reads the same copy the component renders +// with — and emit the value that major actually reflects. +// +// `parseInt` handles prerelease strings like `19.0.0-rc-...`. Experimental builds report +// `0.0.0-experimental-...` (major 0) but ship React 19 behavior, so treat 0 as modern too. +const major = parseInt(version, 10); +const isModernReact = major >= 19 || major === 0; + +/** + * Returns props to spread onto an element to apply (or omit) the `inert` attribute + * correctly across React 18 and 19. + * + * Typed as `Record` on purpose: React 18's types reject `inert` and + * React 19's type it as `boolean`, so an untyped spread sidesteps both type-level shapes + * regardless of which `@types/react` a consumer compiles against. + * + * @param active - Whether the element should be inert. + */ +export function inertProps(active: boolean): Record { + if (!active) { + return {}; + } + return { inert: isModernReact ? true : '' }; +} diff --git a/packages/ui/src/components/PricingTable/PricingTableMatrix.tsx b/packages/ui/src/components/PricingTable/PricingTableMatrix.tsx index e6ab609343b..c698bc8ca76 100644 --- a/packages/ui/src/components/PricingTable/PricingTableMatrix.tsx +++ b/packages/ui/src/components/PricingTable/PricingTableMatrix.tsx @@ -1,3 +1,4 @@ +import { inertProps } from '@clerk/shared/inert'; import type { BillingPlanResource, BillingSubscriptionPlanPeriod } from '@clerk/shared/types'; import * as React from 'react'; @@ -265,8 +266,7 @@ export function PricingTableMatrix({ }), feePeriodNoticeAnimation, ]} - // @ts-ignore - Needed until React 19 support - inert={planPeriod !== 'annual' ? 'true' : undefined} + {...inertProps(planPeriod !== 'annual')} >
{ }); 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,9 @@ describe('Collapsible', () => { rerender(Content); const element = container.querySelector('.cl-collapsible') as HTMLElement; - expect(element).toHaveAttribute('inert', ''); + // Presence check only — `inertProps` emits the value each React major reflects + // (string '' on 18, boolean true on 19), both of which serialize to inert="". + expect(element).toHaveAttribute('inert'); }); it('does not set inert when open={true}', async () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index abac24e25af..b9264b81294 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -650,6 +650,9 @@ importers: packages/headless: dependencies: + '@clerk/shared': + specifier: workspace:^ + version: link:../shared '@floating-ui/react': specifier: catalog:repo version: 0.27.12(react-dom@18.3.1(react@18.3.1))(react@18.3.1)