From 54b4a5067e1856b25010c02b7c1ec7f74f80ed80 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 10 Jun 2026 18:50:25 -0400 Subject: [PATCH 1/6] fix(ui): use 'true' for inert attribute value for React 18/19 compat Empty string '' is falsy in React 19's boolean attribute handler and does not set the inert attribute, leaving hidden content interactive. 'true' is truthy in both React 18 and 19. Also adds global.d.ts type augmentation to packages/headless and packages/ui, removes per-site @ts-ignore suppressions, and adds ESLint no-unknown-property ignore for inert. --- .changeset/fix-inert-react-19-compat.md | 6 +++++ eslint.config.mjs | 2 +- packages/headless/src/global.d.ts | 7 ++++++ .../src/primitives/tabs/tabs.test.tsx | 23 +++++++++++++++++++ .../PricingTable/PricingTableMatrix.tsx | 1 - .../devPrompts/KeylessPrompt/index.tsx | 2 +- packages/ui/src/elements/Collapsible.tsx | 3 +-- .../elements/__tests__/Collapsible.test.tsx | 5 ++-- packages/ui/src/global.d.ts | 8 +++++++ 9 files changed, 50 insertions(+), 7 deletions(-) create mode 100644 .changeset/fix-inert-react-19-compat.md create mode 100644 packages/headless/src/global.d.ts diff --git a/.changeset/fix-inert-react-19-compat.md b/.changeset/fix-inert-react-19-compat.md new file mode 100644 index 00000000000..faa86ed040b --- /dev/null +++ b/.changeset/fix-inert-react-19-compat.md @@ -0,0 +1,6 @@ +--- +"@clerk/headless": patch +"@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/eslint.config.mjs b/eslint.config.mjs index 51d8cf2af92..3b2e3199675 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -386,7 +386,7 @@ export default tseslint.config([ 'react/jsx-sort-props': 'warn', 'react/no-array-index-key': 'warn', 'react/no-unstable-nested-components': 'warn', - 'react/no-unknown-property': ['error', { ignore: ['css'] }], // Emotion + 'react/no-unknown-property': ['error', { ignore: ['css', 'inert'] }], // Emotion; inert not in React 18 types 'react/self-closing-comp': 'warn', 'react/prop-types': 'off', 'react/react-in-jsx-scope': 'off', diff --git a/packages/headless/src/global.d.ts b/packages/headless/src/global.d.ts new file mode 100644 index 00000000000..3ade00635a2 --- /dev/null +++ b/packages/headless/src/global.d.ts @@ -0,0 +1,7 @@ +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; + } +} 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; From 7d7057e4298a7d218b9adad8e643654fe7b1de9d Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 10 Jun 2026 18:51:17 -0400 Subject: [PATCH 2/6] Update fix-inert-react-19-compat.md --- .changeset/fix-inert-react-19-compat.md | 1 - 1 file changed, 1 deletion(-) diff --git a/.changeset/fix-inert-react-19-compat.md b/.changeset/fix-inert-react-19-compat.md index faa86ed040b..2068744f5a9 100644 --- a/.changeset/fix-inert-react-19-compat.md +++ b/.changeset/fix-inert-react-19-compat.md @@ -1,5 +1,4 @@ --- -"@clerk/headless": patch "@clerk/ui": patch --- From 34fc31f1f69daa4666db2b01194735d0d64cd2ce Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 10 Jun 2026 19:04:49 -0400 Subject: [PATCH 3/6] chore(repo): drop redundant inert eslint ignore inert is already a recognized DOM property in eslint-plugin-react, and no usage is on a literal DOM element, so the ignore entry had no effect. --- eslint.config.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 3b2e3199675..51d8cf2af92 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -386,7 +386,7 @@ export default tseslint.config([ 'react/jsx-sort-props': 'warn', 'react/no-array-index-key': 'warn', 'react/no-unstable-nested-components': 'warn', - 'react/no-unknown-property': ['error', { ignore: ['css', 'inert'] }], // Emotion; inert not in React 18 types + 'react/no-unknown-property': ['error', { ignore: ['css'] }], // Emotion 'react/self-closing-comp': 'warn', 'react/prop-types': 'off', 'react/react-in-jsx-scope': 'off', From d6617c1f8fcd1674ce9eb3d96592e8f3da8585a0 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 10 Jun 2026 19:30:28 -0400 Subject: [PATCH 4/6] fix(headless): make react inert augmentation a module to avoid shadowing react types --- packages/headless/src/global.d.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/headless/src/global.d.ts b/packages/headless/src/global.d.ts index 3ade00635a2..34c921452ae 100644 --- a/packages/headless/src/global.d.ts +++ b/packages/headless/src/global.d.ts @@ -1,4 +1,10 @@ +// `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. From 53ff66a8f8d8e051654e28a2fb838ef3bd53cf5c Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 12 Jun 2026 10:32:47 -0400 Subject: [PATCH 5/6] refactor approach --- .changeset/fix-inert-react-19-compat.md | 2 +- packages/headless/src/global.d.ts | 13 --------- .../src/primitives/tabs/tabs-panel.tsx | 9 +++--- .../src/primitives/tabs/tabs.test.tsx | 3 +- packages/headless/src/utils/index.ts | 1 + packages/headless/src/utils/inert.ts | 29 +++++++++++++++++++ .../PricingTable/PricingTableMatrix.tsx | 3 +- .../devPrompts/KeylessPrompt/index.tsx | 3 +- packages/ui/src/elements/Collapsible.tsx | 3 +- .../elements/__tests__/Collapsible.test.tsx | 3 +- packages/ui/src/global.d.ts | 8 ----- packages/ui/src/utils/inert.ts | 29 +++++++++++++++++++ 12 files changed, 74 insertions(+), 32 deletions(-) delete mode 100644 packages/headless/src/global.d.ts create mode 100644 packages/headless/src/utils/inert.ts create mode 100644 packages/ui/src/utils/inert.ts diff --git a/.changeset/fix-inert-react-19-compat.md b/.changeset/fix-inert-react-19-compat.md index 2068744f5a9..34d476d5c6b 100644 --- a/.changeset/fix-inert-react-19-compat.md +++ b/.changeset/fix-inert-react-19-compat.md @@ -2,4 +2,4 @@ "@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. +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/packages/headless/src/global.d.ts b/packages/headless/src/global.d.ts deleted file mode 100644 index 34c921452ae..00000000000 --- a/packages/headless/src/global.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -// `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-panel.tsx b/packages/headless/src/primitives/tabs/tabs-panel.tsx index dfe659c10bb..40feff9ce7e 100644 --- a/packages/headless/src/primitives/tabs/tabs-panel.tsx +++ b/packages/headless/src/primitives/tabs/tabs-panel.tsx @@ -4,6 +4,7 @@ import { useMergeRefs } from '@floating-ui/react'; import React, { useRef } from 'react'; import { useTransition } from '../../hooks/use-transition'; +import { inertProps } from '../../utils/inert'; import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; import { useTabsContext } from './tabs-context'; @@ -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 ca344c0107c..6f6cd4d5c92 100644 --- a/packages/headless/src/primitives/tabs/tabs.test.tsx +++ b/packages/headless/src/primitives/tabs/tabs.test.tsx @@ -512,7 +512,8 @@ describe('Tabs', () => { 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="" + // 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); }); diff --git a/packages/headless/src/utils/index.ts b/packages/headless/src/utils/index.ts index 2f7cb85009c..c5dd27a3317 100644 --- a/packages/headless/src/utils/index.ts +++ b/packages/headless/src/utils/index.ts @@ -1,2 +1,3 @@ export { cssVars } from './css-vars'; +export { inertProps } from './inert'; export { type ComponentProps, type DefaultProps, mergeProps, type RenderProp, renderElement } from './render-element'; diff --git a/packages/headless/src/utils/inert.ts b/packages/headless/src/utils/inert.ts new file mode 100644 index 00000000000..cc0c68d3e98 --- /dev/null +++ b/packages/headless/src/utils/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; + +/** + * Props to spread onto an element to apply (or omit) the `inert` attribute correctly + * across React 18 and 19. + * + * Returned 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 4c17b858aac..e5e1e8be47d 100644 --- a/packages/ui/src/components/PricingTable/PricingTableMatrix.tsx +++ b/packages/ui/src/components/PricingTable/PricingTableMatrix.tsx @@ -4,6 +4,7 @@ import * as React from 'react'; import { Avatar } from '@/ui/elements/Avatar'; import { SegmentedControl } from '@/ui/elements/SegmentedControl'; import { colors } from '@/ui/utils/colors'; +import { inertProps } from '@/ui/utils/inert'; import { usePlansContext } from '../../contexts'; import { @@ -265,7 +266,7 @@ export function PricingTableMatrix({ }), feePeriodNoticeAnimation, ]} - inert={planPeriod !== 'annual' ? 'true' : undefined} + {...inertProps(planPeriod !== 'annual')} >
{ rerender(Content); const element = container.querySelector('.cl-collapsible') as HTMLElement; - // Check presence only — React 18 renders inert="true", React 19 normalises to 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'); }); diff --git a/packages/ui/src/global.d.ts b/packages/ui/src/global.d.ts index 19db755dffa..7bbffaeda66 100644 --- a/packages/ui/src/global.d.ts +++ b/packages/ui/src/global.d.ts @@ -1,14 +1,6 @@ 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; diff --git a/packages/ui/src/utils/inert.ts b/packages/ui/src/utils/inert.ts new file mode 100644 index 00000000000..cc0c68d3e98 --- /dev/null +++ b/packages/ui/src/utils/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; + +/** + * Props to spread onto an element to apply (or omit) the `inert` attribute correctly + * across React 18 and 19. + * + * Returned 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 : '' }; +} From a0d17a56e079290eed3c00109b2442d23f480791 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 12 Jun 2026 10:45:17 -0400 Subject: [PATCH 6/6] refactor(shared): consolidate inert helper into @clerk/shared/inert Replace the duplicated per-package inert.ts utilities in @clerk/ui and @clerk/headless with a single canonical helper at @clerk/shared/inert. Adds @clerk/shared as @clerk/headless's first internal dependency and externalizes it in the headless vite build. --- .changeset/shared-inert-props-helper.md | 5 ++++ packages/headless/package.json | 1 + .../src/primitives/tabs/tabs-panel.tsx | 2 +- packages/headless/src/utils/index.ts | 1 - packages/headless/vite.config.ts | 2 +- .../src/utils => shared/src}/inert.ts | 6 ++-- .../PricingTable/PricingTableMatrix.tsx | 2 +- .../devPrompts/KeylessPrompt/index.tsx | 2 +- packages/ui/src/elements/Collapsible.tsx | 2 +- packages/ui/src/utils/inert.ts | 29 ------------------- pnpm-lock.yaml | 3 ++ 11 files changed, 17 insertions(+), 38 deletions(-) create mode 100644 .changeset/shared-inert-props-helper.md rename packages/{headless/src/utils => shared/src}/inert.ts (84%) delete mode 100644 packages/ui/src/utils/inert.ts 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 40feff9ce7e..0f13c5fcf7c 100644 --- a/packages/headless/src/primitives/tabs/tabs-panel.tsx +++ b/packages/headless/src/primitives/tabs/tabs-panel.tsx @@ -1,10 +1,10 @@ 'use client'; +import { inertProps } from '@clerk/shared/inert'; import { useMergeRefs } from '@floating-ui/react'; import React, { useRef } from 'react'; import { useTransition } from '../../hooks/use-transition'; -import { inertProps } from '../../utils/inert'; import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; import { useTabsContext } from './tabs-context'; diff --git a/packages/headless/src/utils/index.ts b/packages/headless/src/utils/index.ts index c5dd27a3317..2f7cb85009c 100644 --- a/packages/headless/src/utils/index.ts +++ b/packages/headless/src/utils/index.ts @@ -1,3 +1,2 @@ export { cssVars } from './css-vars'; -export { inertProps } from './inert'; export { type ComponentProps, type DefaultProps, mergeProps, type RenderProp, renderElement } from './render-element'; 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/headless/src/utils/inert.ts b/packages/shared/src/inert.ts similarity index 84% rename from packages/headless/src/utils/inert.ts rename to packages/shared/src/inert.ts index cc0c68d3e98..293ed341734 100644 --- a/packages/headless/src/utils/inert.ts +++ b/packages/shared/src/inert.ts @@ -12,10 +12,10 @@ const major = parseInt(version, 10); const isModernReact = major >= 19 || major === 0; /** - * Props to spread onto an element to apply (or omit) the `inert` attribute correctly - * across React 18 and 19. + * Returns props to spread onto an element to apply (or omit) the `inert` attribute + * correctly across React 18 and 19. * - * Returned as `Record` on purpose: React 18's types reject `inert` and + * 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. * diff --git a/packages/ui/src/components/PricingTable/PricingTableMatrix.tsx b/packages/ui/src/components/PricingTable/PricingTableMatrix.tsx index e5e1e8be47d..c698bc8ca76 100644 --- a/packages/ui/src/components/PricingTable/PricingTableMatrix.tsx +++ b/packages/ui/src/components/PricingTable/PricingTableMatrix.tsx @@ -1,10 +1,10 @@ +import { inertProps } from '@clerk/shared/inert'; import type { BillingPlanResource, BillingSubscriptionPlanPeriod } from '@clerk/shared/types'; import * as React from 'react'; import { Avatar } from '@/ui/elements/Avatar'; import { SegmentedControl } from '@/ui/elements/SegmentedControl'; import { colors } from '@/ui/utils/colors'; -import { inertProps } from '@/ui/utils/inert'; import { usePlansContext } from '../../contexts'; import { diff --git a/packages/ui/src/components/devPrompts/KeylessPrompt/index.tsx b/packages/ui/src/components/devPrompts/KeylessPrompt/index.tsx index bdb7e045505..3713ea9665d 100644 --- a/packages/ui/src/components/devPrompts/KeylessPrompt/index.tsx +++ b/packages/ui/src/components/devPrompts/KeylessPrompt/index.tsx @@ -1,10 +1,10 @@ +import { inertProps } from '@clerk/shared/inert'; import { useUser } from '@clerk/shared/react'; // eslint-disable-next-line no-restricted-imports import { css } from '@emotion/react'; import { type ReactNode, useId, useMemo, useState } from 'react'; import { InternalThemeProvider } from '../../../styledSystem'; -import { inertProps } from '../../../utils/inert'; import { handleDashboardUrlParsing } from '../shared'; import { useDragToCorner } from './use-drag-to-corner'; import { useRevalidateEnvironment } from './use-revalidate-environment'; diff --git a/packages/ui/src/elements/Collapsible.tsx b/packages/ui/src/elements/Collapsible.tsx index 6399a548570..03f9fbc8b31 100644 --- a/packages/ui/src/elements/Collapsible.tsx +++ b/packages/ui/src/elements/Collapsible.tsx @@ -1,9 +1,9 @@ +import { inertProps } from '@clerk/shared/inert'; import { type PropsWithChildren, useEffect, useState } from 'react'; import { Box, descriptors, useAppearance } from '../customizables'; import { usePrefersReducedMotion } from '../hooks'; import type { ThemableCssProp } from '../styledSystem'; -import { inertProps } from '../utils/inert'; type CollapsibleProps = PropsWithChildren<{ open: boolean; diff --git a/packages/ui/src/utils/inert.ts b/packages/ui/src/utils/inert.ts deleted file mode 100644 index cc0c68d3e98..00000000000 --- a/packages/ui/src/utils/inert.ts +++ /dev/null @@ -1,29 +0,0 @@ -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; - -/** - * Props to spread onto an element to apply (or omit) the `inert` attribute correctly - * across React 18 and 19. - * - * Returned 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/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)