Skip to content

Commit e51e22a

Browse files
fix(ui): inert attribute for React 18/19 compatibility (#8820)
1 parent 57a58ac commit e51e22a

12 files changed

Lines changed: 80 additions & 19 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@clerk/ui": patch
3+
---
4+
5+
Add support for the `inert` attribute usage under React 19. Inert content is now correctly non-interactive on both React 18 and 19.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@clerk/shared": patch
3+
---
4+
5+
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).

packages/headless/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
"typecheck": "tsc --noEmit"
5959
},
6060
"dependencies": {
61+
"@clerk/shared": "workspace:^",
6162
"@floating-ui/react": "catalog:repo"
6263
},
6364
"devDependencies": {

packages/headless/src/primitives/tabs/tabs-panel.tsx

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,13 @@
11
'use client';
22

3+
import { inertProps } from '@clerk/shared/inert';
34
import { useMergeRefs } from '@floating-ui/react';
4-
import React, { useRef, version } from 'react';
5+
import React, { useRef } from 'react';
56

67
import { useTransition } from '../../hooks/use-transition';
78
import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
89
import { useTabsContext } from './tabs-context';
910

10-
const major = parseInt(version, 10);
11-
const isModernReact = major >= 19 || major === 0;
12-
13-
export function inertProps(active: boolean): Record<string, unknown> {
14-
if (!active) {
15-
return {};
16-
}
17-
return { inert: isModernReact ? true : '' };
18-
}
19-
2011
export interface TabsPanelProps extends ComponentProps<'div'> {
2112
value: string;
2213
/** When true, removes `hidden` so the panel stays in layout flow. */

packages/headless/src/primitives/tabs/tabs.test.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -506,6 +506,30 @@ describe('Tabs', () => {
506506
const panels = document.querySelectorAll('[data-cl-slot="tabs-panel"][data-cl-hidden]');
507507
expect(panels).toHaveLength(2);
508508
});
509+
510+
it('non-selected panels have inert attribute, selected panel does not', () => {
511+
renderTabs();
512+
const panels = document.querySelectorAll('[data-cl-slot="tabs-panel"]');
513+
const inert = Array.from(panels).filter(p => p.hasAttribute('inert'));
514+
const notInert = Array.from(panels).filter(p => !p.hasAttribute('inert'));
515+
// Presence check only — `inertProps` emits the value each React major reflects
516+
// (string '' on 18, boolean true on 19), both of which serialize to inert="".
517+
expect(inert).toHaveLength(2);
518+
expect(notInert).toHaveLength(1);
519+
});
520+
521+
it('inert updates when selection changes', async () => {
522+
const user = userEvent.setup();
523+
renderTabs();
524+
525+
await user.click(screen.getByText('Settings'));
526+
527+
const panels = document.querySelectorAll('[data-cl-slot="tabs-panel"]');
528+
const [account, settings, billing] = Array.from(panels);
529+
expect(account).toHaveAttribute('inert');
530+
expect(settings).not.toHaveAttribute('inert');
531+
expect(billing).toHaveAttribute('inert');
532+
});
509533
});
510534

511535
describe('roving tabindex', () => {

packages/headless/vite.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export default defineConfig({
3131
formats: ['es'],
3232
},
3333
rollupOptions: {
34-
external: ['react', 'react-dom', 'react/jsx-runtime', '@floating-ui/react'],
34+
external: ['react', 'react-dom', 'react/jsx-runtime', '@floating-ui/react', /^@clerk\/shared(\/.*)?$/],
3535
// Preserve module-level directives such as `'use client'`. Rollup otherwise
3636
// strips them when bundling (emitting a warning), which would drop the
3737
// client boundary for React Server Component consumers of the primitives.

packages/shared/src/inert.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { version } from 'react';
2+
3+
// React 19 turned `inert` into a real boolean attribute, so a falsy value like `''`
4+
// is no longer reflected to the DOM. React 18 doesn't know `inert` and only serializes
5+
// a (non-undefined) string value. Resolve the consumer's React major once at module
6+
// load — `react` is a peer dependency, so this reads the same copy the component renders
7+
// with — and emit the value that major actually reflects.
8+
//
9+
// `parseInt` handles prerelease strings like `19.0.0-rc-...`. Experimental builds report
10+
// `0.0.0-experimental-...` (major 0) but ship React 19 behavior, so treat 0 as modern too.
11+
const major = parseInt(version, 10);
12+
const isModernReact = major >= 19 || major === 0;
13+
14+
/**
15+
* Returns props to spread onto an element to apply (or omit) the `inert` attribute
16+
* correctly across React 18 and 19.
17+
*
18+
* Typed as `Record<string, unknown>` on purpose: React 18's types reject `inert` and
19+
* React 19's type it as `boolean`, so an untyped spread sidesteps both type-level shapes
20+
* regardless of which `@types/react` a consumer compiles against.
21+
*
22+
* @param active - Whether the element should be inert.
23+
*/
24+
export function inertProps(active: boolean): Record<string, unknown> {
25+
if (!active) {
26+
return {};
27+
}
28+
return { inert: isModernReact ? true : '' };
29+
}

packages/ui/src/components/PricingTable/PricingTableMatrix.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { inertProps } from '@clerk/shared/inert';
12
import type { BillingPlanResource, BillingSubscriptionPlanPeriod } from '@clerk/shared/types';
23
import * as React from 'react';
34

@@ -265,8 +266,7 @@ export function PricingTableMatrix({
265266
}),
266267
feePeriodNoticeAnimation,
267268
]}
268-
// @ts-ignore - Needed until React 19 support
269-
inert={planPeriod !== 'annual' ? 'true' : undefined}
269+
{...inertProps(planPeriod !== 'annual')}
270270
>
271271
<Box
272272
elementDescriptor={descriptors.pricingTableMatrixFeePeriodNoticeInner}

packages/ui/src/components/devPrompts/KeylessPrompt/index.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { inertProps } from '@clerk/shared/inert';
12
import { useUser } from '@clerk/shared/react';
23
// eslint-disable-next-line no-restricted-imports
34
import { css } from '@emotion/react';
@@ -506,7 +507,7 @@ function KeylessPromptInternal(props: KeylessPromptProps) {
506507
</button>
507508
<div
508509
id={id}
509-
{...(!isOpen && { inert: '' as any })}
510+
{...inertProps(!isOpen)}
510511
css={css`
511512
${CSS_RESET};
512513
display: grid;

packages/ui/src/elements/Collapsible.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { inertProps } from '@clerk/shared/inert';
12
import { type PropsWithChildren, useEffect, useState } from 'react';
23

34
import { Box, descriptors, useAppearance } from '../customizables';
@@ -75,8 +76,7 @@ export function Collapsible({ open, children, sx }: CollapsibleProps): JSX.Eleme
7576
}),
7677
sx,
7778
]}
78-
// @ts-ignore - inert not yet in React types
79-
inert={!open ? '' : undefined}
79+
{...inertProps(!open)}
8080
>
8181
<Box
8282
elementDescriptor={descriptors.collapsibleInner}

0 commit comments

Comments
 (0)