diff --git a/.changeset/perf-remove-expensive-has-selectors.md b/.changeset/perf-remove-expensive-has-selectors.md new file mode 100644 index 00000000000..936a69d70d7 --- /dev/null +++ b/.changeset/perf-remove-expensive-has-selectors.md @@ -0,0 +1,5 @@ +--- +'@primer/react': patch +--- + +Improve rendering performance in Safari by removing expensive `:has()` selectors from ActionList/NavList, Breadcrumbs, SelectPanel, and SegmentedControl. No visual or API changes. diff --git a/packages/react/src/ActionList/ActionList.module.css b/packages/react/src/ActionList/ActionList.module.css index 6195a4a1bb8..aa7d807f673 100644 --- a/packages/react/src/ActionList/ActionList.module.css +++ b/packages/react/src/ActionList/ActionList.module.css @@ -557,9 +557,17 @@ display: none; } - /* show active indicator on parent collapse if child is active */ - /* stylelint-disable-next-line selector-pseudo-class-disallowed-list -- scoped to CSS Module, audited (github/github-ui#17224) */ - &:has(~ .SubGroup [data-active='true']) { + /* + * Show the active indicator on a collapsed parent when one of its subnav + * items is active. NavList sets `active` (→ the parent
  • 's `data-active`) + * to exactly `!isOpen && containsCurrentItem`, which is the same condition as + * `:has(~ .SubGroup [data-active='true'])`. Keying off the ancestor's + * `data-active` avoids Safari's `:has()` invalidation cost, which is + * especially high here because the old anchor (`[data-active]`) toggles on + * every navigation and combined a general-sibling with a descendant match. + * The ancestor prefix keeps the original selector specificity (0,4,0). + */ + .ActionListItem[data-active='true'] & { background: var(--control-transparent-bgColor-selected); & .ItemLabel { @@ -655,13 +663,6 @@ default block */ word-break: normal; } - /* stylelint-disable-next-line selector-pseudo-class-disallowed-list -- scoped to CSS Module, audited (github/github-ui#17224) */ - &:has([data-truncate='true']) { - & .ItemLabel { - flex: 1 0 auto; - } - } - & .Description { /* stylelint-disable-next-line primer/typography */ line-height: 16px; diff --git a/packages/react/src/Breadcrumbs/Breadcrumbs.module.css b/packages/react/src/Breadcrumbs/Breadcrumbs.module.css index bd1691f28f0..17cf5ee538a 100644 --- a/packages/react/src/Breadcrumbs/Breadcrumbs.module.css +++ b/packages/react/src/Breadcrumbs/Breadcrumbs.module.css @@ -117,8 +117,9 @@ list-style: none; /* allow menu items to wrap line */ - /* stylelint-disable-next-line selector-pseudo-class-disallowed-list -- scoped to CSS Module, audited (github/github-ui#17224) */ - &:has(.MenuOverlay) { + /* The overflow-menu item is marked in React (`data-overflow-menu`) instead of using + `:has(.MenuOverlay)`, avoiding Safari's `:has()` invalidation cost. */ + &[data-overflow-menu] { white-space: normal; } diff --git a/packages/react/src/Breadcrumbs/Breadcrumbs.tsx b/packages/react/src/Breadcrumbs/Breadcrumbs.tsx index f348609e88b..00f3f266787 100644 --- a/packages/react/src/Breadcrumbs/Breadcrumbs.tsx +++ b/packages/react/src/Breadcrumbs/Breadcrumbs.tsx @@ -313,7 +313,7 @@ function Breadcrumbs({className, children, style, overflow = 'wrap', variant = ' // In 'menu-with-root' mode the root stays visible, so drop it from the menu. const effectiveMenuItems = effectiveHideRoot ? menuItems : menuItems.slice(1) const menuElement = ( -
  • +
  • ( ) + +/** + * A collapsed parent item whose sub-nav contains the current item shows an + * active indicator. NavList auto-expands sub-navs that contain the current + * item, so the play function collapses it to surface the collapsed-parent + * active styling for visual regression coverage. This exercises the CSS that + * replaced `:has(~ .SubGroup [data-active])` with the parent's `data-active`. + */ +export const CollapsedSubNavWithCurrentItem = () => ( + + + + Item 1 + + Item with current sub-item + + + Current sub-item + + Other sub-item + + + Item 3 + + + + +) + +CollapsedSubNavWithCurrentItem.storyName = 'Collapsed SubNav With Current Item' +CollapsedSubNavWithCurrentItem.play = async ({canvasElement}: {canvasElement: HTMLElement}) => { + const canvas = within(canvasElement) + const parentButton = await canvas.findByRole('button', {name: /Item with current sub-item/i}) + await userEvent.click(parentButton) +} diff --git a/packages/react/src/SegmentedControl/SegmentedControl.module.css b/packages/react/src/SegmentedControl/SegmentedControl.module.css index 05bc0feed8f..f16cb5c3425 100644 --- a/packages/react/src/SegmentedControl/SegmentedControl.module.css +++ b/packages/react/src/SegmentedControl/SegmentedControl.module.css @@ -188,11 +188,6 @@ } } - /* stylelint-disable-next-line selector-pseudo-class-disallowed-list -- scoped to CSS Module, audited (github/github-ui#17224) */ - &:focus-within:has(:focus-visible) { - background-color: transparent; - } - &:first-child { /* stylelint-disable-next-line primer/spacing */ margin-left: -1px; diff --git a/packages/react/src/experimental/SelectPanel2/SelectPanel.module.css b/packages/react/src/experimental/SelectPanel2/SelectPanel.module.css index 16afc9dd10a..80c58ea1f0e 100644 --- a/packages/react/src/experimental/SelectPanel2/SelectPanel.module.css +++ b/packages/react/src/experimental/SelectPanel2/SelectPanel.module.css @@ -121,8 +121,11 @@ .TextInput { padding-left: var(--base-size-8) !important; - /* stylelint-disable-next-line selector-class-pattern, selector-no-qualifying-type, selector-pseudo-class-disallowed-list -- :has() scoped to CSS Module, audited (github/github-ui#17224) */ - &:has(input:placeholder-shown) :global(.TextInput-action) { + /* Hide the trailing action while the input is empty (showing its placeholder). Uses a + forward sibling selector on the input instead of `:has()` to avoid Safari's `:has()` + invalidation cost; `input` and `.TextInput-action` are siblings inside the wrapper. */ + /* stylelint-disable-next-line selector-class-pattern */ + & input:placeholder-shown ~ :global(.TextInput-action) { display: none; } }