Skip to content

Commit 55cb047

Browse files
committed
refactor approach
1 parent 91077cd commit 55cb047

12 files changed

Lines changed: 74 additions & 32 deletions

File tree

.changeset/fix-inert-react-19-compat.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
"@clerk/ui": patch
33
---
44

5-
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.
5+
Add support for the `inert` attribute usage under React 19. Inert content is now correctly non-interactive on both React 18 and 19.

packages/headless/src/global.d.ts

Lines changed: 0 additions & 13 deletions
This file was deleted.

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

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useMergeRefs } from '@floating-ui/react';
44
import React, { useRef } from 'react';
55

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

@@ -51,11 +52,9 @@ export const TabsPanel = React.forwardRef<HTMLDivElement, TabsPanelProps>(functi
5152
role: 'tabpanel' as const,
5253
'aria-labelledby': tabId,
5354
tabIndex: 0,
54-
// `inert` must be a truthy string, not a boolean or empty string, to stay
55-
// correct across React 18 and 19: React 18 drops a boolean `true` and React
56-
// 19 treats `''` as falsy. `'true'` renders the (presence-based) attribute in
57-
// both. Matches the existing pattern in packages/ui PricingTableMatrix.
58-
inert: !isSelected ? 'true' : undefined,
55+
// `inert` reflects differently across React majors; `inertProps` emits the value
56+
// each one actually serializes (see packages/headless/src/utils/inert.ts).
57+
...inertProps(!isSelected),
5958
hidden: !isSelected && !shouldForceMount ? true : undefined,
6059
ref: combinedRef,
6160
...(shouldForceMount

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -512,7 +512,8 @@ describe('Tabs', () => {
512512
const panels = document.querySelectorAll('[data-cl-slot="tabs-panel"]');
513513
const inert = Array.from(panels).filter(p => p.hasAttribute('inert'));
514514
const notInert = Array.from(panels).filter(p => !p.hasAttribute('inert'));
515-
// Presence check only — React 18 renders inert="true", React 19 normalises to 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="".
516517
expect(inert).toHaveLength(2);
517518
expect(notInert).toHaveLength(1);
518519
});
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export { cssVars } from './css-vars';
2+
export { inertProps } from './inert';
23
export { type ComponentProps, mergeProps, type RenderProp, renderElement } from './render-element';
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+
* Props to spread onto an element to apply (or omit) the `inert` attribute correctly
16+
* across React 18 and 19.
17+
*
18+
* Returned 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 & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import * as React from 'react';
44
import { Avatar } from '@/ui/elements/Avatar';
55
import { SegmentedControl } from '@/ui/elements/SegmentedControl';
66
import { colors } from '@/ui/utils/colors';
7+
import { inertProps } from '@/ui/utils/inert';
78

89
import { usePlansContext } from '../../contexts';
910
import {
@@ -265,7 +266,7 @@ export function PricingTableMatrix({
265266
}),
266267
feePeriodNoticeAnimation,
267268
]}
268-
inert={planPeriod !== 'annual' ? 'true' : undefined}
269+
{...inertProps(planPeriod !== 'annual')}
269270
>
270271
<Box
271272
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
@@ -4,6 +4,7 @@ import { css } from '@emotion/react';
44
import { type ReactNode, useId, useMemo, useState } from 'react';
55

66
import { InternalThemeProvider } from '../../../styledSystem';
7+
import { inertProps } from '../../../utils/inert';
78
import { handleDashboardUrlParsing } from '../shared';
89
import { useDragToCorner } from './use-drag-to-corner';
910
import { useRevalidateEnvironment } from './use-revalidate-environment';
@@ -506,7 +507,7 @@ function KeylessPromptInternal(props: KeylessPromptProps) {
506507
</button>
507508
<div
508509
id={id}
509-
{...(!isOpen && { inert: 'true' })}
510+
{...inertProps(!isOpen)}
510511
css={css`
511512
${CSS_RESET};
512513
display: grid;

packages/ui/src/elements/Collapsible.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { type PropsWithChildren, useEffect, useState } from 'react';
33
import { Box, descriptors, useAppearance } from '../customizables';
44
import { usePrefersReducedMotion } from '../hooks';
55
import type { ThemableCssProp } from '../styledSystem';
6+
import { inertProps } from '../utils/inert';
67

78
type CollapsibleProps = PropsWithChildren<{
89
open: boolean;
@@ -75,7 +76,7 @@ export function Collapsible({ open, children, sx }: CollapsibleProps): JSX.Eleme
7576
}),
7677
sx,
7778
]}
78-
inert={!open ? 'true' : undefined}
79+
{...inertProps(!open)}
7980
>
8081
<Box
8182
elementDescriptor={descriptors.collapsibleInner}

packages/ui/src/elements/__tests__/Collapsible.test.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -375,7 +375,8 @@ describe('Collapsible', () => {
375375
rerender(<Collapsible open={false}>Content</Collapsible>);
376376

377377
const element = container.querySelector('.cl-collapsible') as HTMLElement;
378-
// Check presence only — React 18 renders inert="true", React 19 normalises to inert=""
378+
// Presence check only — `inertProps` emits the value each React major reflects
379+
// (string '' on 18, boolean true on 19), both of which serialize to inert="".
379380
expect(element).toHaveAttribute('inert');
380381
});
381382

0 commit comments

Comments
 (0)