From 30604a7bf6da0f9e97537c07ba1a82ab246916f8 Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Mon, 6 Jul 2026 15:11:21 +0000 Subject: [PATCH 1/2] perf(css): remove expensive :has() selectors for Safari style-recalc Replace Safari-costly :has() selectors with cheaper equivalents: - ActionList/NavList: :has(~ .SubGroup [data-active]) -> parent li's data-active (NavList already computes !isOpen && containsCurrentItem); preserves (0,4,0) specificity. Adds a VRT story for the collapsed-active parent state (needs a play fn since NavList auto-expands active subnavs). - Breadcrumbs: :has(.MenuOverlay) -> data-overflow-menu on the menu
  • . - SelectPanel: :has(input:placeholder-shown) -> input:placeholder-shown ~ .TextInput-action (siblings in the wrapper). - ActionList: remove dead :has([data-truncate]) (attribute never rendered). - SegmentedControl: remove no-op :focus-within:has(:focus-visible) (.Item has no background of its own). Button :has([data-kbd-chord]) intentionally kept (no clean React signal; static anchor = low cost). --- .../perf-remove-expensive-has-selectors.md | 5 +++ .../src/ActionList/ActionList.module.css | 21 +++++------ .../src/Breadcrumbs/Breadcrumbs.module.css | 5 +-- .../react/src/Breadcrumbs/Breadcrumbs.tsx | 2 +- .../react/src/NavList/NavList.dev.stories.tsx | 36 +++++++++++++++++++ .../SegmentedControl.module.css | 5 --- .../SelectPanel2/SelectPanel.module.css | 7 ++-- 7 files changed, 61 insertions(+), 20 deletions(-) create mode 100644 .changeset/perf-remove-expensive-has-selectors.md diff --git a/.changeset/perf-remove-expensive-has-selectors.md b/.changeset/perf-remove-expensive-has-selectors.md new file mode 100644 index 00000000000..129d4220200 --- /dev/null +++ b/.changeset/perf-remove-expensive-has-selectors.md @@ -0,0 +1,5 @@ +--- +'@primer/react': patch +--- + +Improve style-recalc performance in Safari by removing expensive `:has()` selectors from ActionList/NavList, Breadcrumbs, SelectPanel, and SegmentedControl, replacing them with cheaper equivalents (data attributes, sibling selectors, or removing dead/no-op rules). 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; } } From b70c1cd870a84df78b10a34fa7a199821ebae316 Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Tue, 7 Jul 2026 15:13:20 +0000 Subject: [PATCH 2/2] docs: make changeset terse and consumer-facing --- .changeset/perf-remove-expensive-has-selectors.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/perf-remove-expensive-has-selectors.md b/.changeset/perf-remove-expensive-has-selectors.md index 129d4220200..936a69d70d7 100644 --- a/.changeset/perf-remove-expensive-has-selectors.md +++ b/.changeset/perf-remove-expensive-has-selectors.md @@ -2,4 +2,4 @@ '@primer/react': patch --- -Improve style-recalc performance in Safari by removing expensive `:has()` selectors from ActionList/NavList, Breadcrumbs, SelectPanel, and SegmentedControl, replacing them with cheaper equivalents (data attributes, sibling selectors, or removing dead/no-op rules). +Improve rendering performance in Safari by removing expensive `:has()` selectors from ActionList/NavList, Breadcrumbs, SelectPanel, and SegmentedControl. No visual or API changes.