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;
}
}