Skip to content

Commit d0fa0ff

Browse files
hectahertzprimer[bot]jonrohan
authored
perf(css): audit :has() selectors and add stylelint guard for Safari (#7708)
Co-authored-by: hectahertz <24622853+hectahertz@users.noreply.github.com> Co-authored-by: primer[bot] <119360173+primer[bot]@users.noreply.github.com> Co-authored-by: Jon Rohan <yes@jonrohan.codes>
1 parent eb73dee commit d0fa0ff

15 files changed

Lines changed: 57 additions & 15 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@primer/react': patch
3+
'@primer/styled-react': patch
4+
---
5+
6+
perf(css): audit :has() selectors and add stylelint guard for Safari

.github/instructions/css.instructions.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,16 @@ If the file ends with `*.module.css`, it is a CSS Module.
1111
## General Conventions
1212

1313
- After making a change to a file, use `npx stylelint -q --rd --fix` with a path to the file changed to make sure the code lints
14+
15+
## `:has()` Selectors and Safari Performance
16+
17+
`:has()` selectors are blocked by stylelint (`selector-pseudo-class-disallowed-list`) because they can cause catastrophic Safari performance regressions. In Aug 2025, a single `:has()` selector on a broadly-present component froze the entire GitHub UI for 10-20+ seconds on Safari. See [github/github-ui#17224](https://github.com/github/github-ui/issues/17224) for the full audit.
18+
19+
**When you need `:has()`:**
20+
21+
1. Ensure it's scoped to a CSS Module class (`&:has(...)` inside a `.Component` rule). Never use `:has()` on `body`, `html`, `*`, or other high-cardinality selectors.
22+
2. Keep the argument simple. Avoid deeply-nested descendant selectors inside `:has()`.
23+
3. Add a scoped stylelint disable with justification. Use `stylelint-disable-next-line` for individual selectors, or a `stylelint-disable`/`stylelint-enable` block around a group of related selectors. Avoid file-level disables so new `:has()` selectors in the same file still trigger review. Example: `/* stylelint-disable-next-line selector-pseudo-class-disallowed-list -- scoped to CSS Module, audited (github/github-ui#17224) */`
24+
4. Consider alternatives first: data attributes set via JS, `:where()`, or restructuring the DOM so the parent already has the information it needs.
25+
26+
**Why CSS Modules help:** CSS Module class names are unique hashed identifiers, so the browser only evaluates `:has()` on elements matching that specific class, not the entire DOM. This limits the invalidation blast radius. Unscoped selectors like `body:has(...)` force the browser to scan all descendants of `body` on every DOM mutation, which is where WebKit's quadratic invalidation triggers.

packages/react/src/ActionList/ActionList.module.css

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@
103103
border-radius: var(--borderRadius-medium);
104104

105105
/* apply flex if trailing action exists as an immediate child */
106+
/* stylelint-disable-next-line selector-pseudo-class-disallowed-list -- scoped to CSS Module, audited (github/github-ui#17224) */
106107
&:has(> .TrailingAction) {
107108
display: flex;
108109
flex-wrap: nowrap;
@@ -371,6 +372,7 @@
371372
}
372373

373374
/* When TrailingAction is in loading state, keep labels and descriptions accessible */
375+
/* stylelint-disable-next-line selector-pseudo-class-disallowed-list -- scoped to CSS Module, audited (github/github-ui#17224) */
374376
&:has(.TrailingAction [data-loading='true']):not([data-is-disabled]) {
375377
/* Ensure labels and descriptions maintain accessibility contrast */
376378
& .ItemLabel {
@@ -546,6 +548,7 @@
546548
}
547549

548550
/* show active indicator on parent collapse if child is active */
551+
/* stylelint-disable-next-line selector-pseudo-class-disallowed-list -- scoped to CSS Module, audited (github/github-ui#17224) */
549552
&:has(~ .SubGroup [data-active='true']) {
550553
background: var(--control-transparent-bgColor-selected);
551554

@@ -642,6 +645,7 @@ default block */
642645
word-break: normal;
643646
}
644647

648+
/* stylelint-disable-next-line selector-pseudo-class-disallowed-list -- scoped to CSS Module, audited (github/github-ui#17224) */
645649
&:has([data-truncate='true']) {
646650
& .ItemLabel {
647651
flex: 1 0 auto;
@@ -715,6 +719,7 @@ span wrapping svg or text */
715719
height: 100%;
716720

717721
/* Preserve width consistency when loading state is active for text buttons only */
722+
/* stylelint-disable-next-line selector-pseudo-class-disallowed-list -- scoped to CSS Module, audited (github/github-ui#17224) */
718723
&[data-loading='true']:has([data-component='buttonContent']) {
719724
/* Double the left padding to compensate for missing right padding */
720725
padding: 0 0 0 calc(var(--base-size-12) * 2);
@@ -734,10 +739,12 @@ span wrapping svg or text */
734739
}
735740

736741
.InactiveButtonWrap {
742+
/* stylelint-disable-next-line selector-pseudo-class-disallowed-list -- scoped to CSS Module, audited (github/github-ui#17224) */
737743
&:has(.TrailingVisual) {
738744
grid-area: trailingVisual;
739745
}
740746

747+
/* stylelint-disable-next-line selector-pseudo-class-disallowed-list -- scoped to CSS Module, audited (github/github-ui#17224) */
741748
&:has(.LeadingVisual) {
742749
grid-area: leadingVisual;
743750
}

packages/react/src/ActionList/Group.module.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
margin-block-start: var(--base-size-8);
66

77
/* If somebody tries to pass the `title` prop AND a `NavList.GroupHeading` as a child, hide the `ActionList.GroupHeading */
8-
/* stylelint-disable-next-line selector-max-specificity */
8+
/* stylelint-disable-next-line selector-max-specificity, selector-pseudo-class-disallowed-list -- scoped to CSS Module, audited (github/github-ui#17224) */
99
&:has(.GroupHeadingWrap + ul > .GroupHeadingWrap) {
1010
/* stylelint-disable-next-line selector-max-specificity */
1111
& > .GroupHeadingWrap {

packages/react/src/AvatarStack/AvatarStack.module.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@
144144
box-shadow: 0 0 0 var(--avatar-border-width) transparent;
145145
}
146146

147+
/* stylelint-disable-next-line selector-pseudo-class-disallowed-list -- scoped to CSS Module, audited (github/github-ui#17224) */
147148
&:not([data-component='Avatar']):not(:has([data-square])) {
148149
border-radius: 50%;
149150
}

packages/react/src/Breadcrumbs/Breadcrumbs.module.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@
117117
list-style: none;
118118

119119
/* allow menu items to wrap line */
120+
/* stylelint-disable-next-line selector-pseudo-class-disallowed-list -- scoped to CSS Module, audited (github/github-ui#17224) */
120121
&:has(.MenuOverlay) {
121122
white-space: normal;
122123
}

packages/react/src/Button/ButtonBase.module.css

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
justify-content: space-between;
2525
gap: var(--base-size-8);
2626

27+
/* stylelint-disable-next-line selector-pseudo-class-disallowed-list -- scoped to CSS Module, audited (github/github-ui#17224) */
2728
&:has([data-kbd-chord]) {
2829
padding-inline-end: var(--base-size-6);
2930
}
@@ -173,6 +174,7 @@
173174
margin-right: var(--control-large-gap);
174175
}
175176

177+
/* stylelint-disable-next-line selector-pseudo-class-disallowed-list -- scoped to CSS Module, audited (github/github-ui#17224) */
176178
&:has([data-kbd-chord]) {
177179
padding-inline-end: var(--base-size-8);
178180
}
@@ -638,6 +640,7 @@
638640

639641
/* Icon-only + Counter */
640642

643+
/* stylelint-disable-next-line selector-pseudo-class-disallowed-list -- scoped to CSS Module, audited (github/github-ui#17224) */
641644
&:where([data-has-count]):has([data-component='leadingVisual']):not(:has([data-component='text'])) {
642645
/* stylelint-disable-next-line primer/spacing */
643646
padding-inline: var(--control-medium-paddingInline-condensed);

packages/react/src/ButtonGroup/ButtonGroup.module.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
}
3939

4040
/* this is a workaround until portal based tooltips are fully removed from dotcom */
41+
/* stylelint-disable-next-line selector-pseudo-class-disallowed-list -- scoped to CSS Module, audited (github/github-ui#17224) */
4142
&:has(div:last-child:empty) {
4243
button,
4344
a {

packages/react/src/PageHeader/PageHeader.module.css

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
--custom-font-size, --custom-line-height, --custom-font-weight are custom properties that can be used to override the below values.
3535
We don't want these values to be overridden but still want to allow consumers to override them if needed.
3636
*/
37+
/* stylelint-disable selector-pseudo-class-disallowed-list -- :has() scoped to CSS Module, audited (github/github-ui#17224) */
3738
&:has([data-component='TitleArea'][data-size-variant='large']) {
3839
font-size: var(--custom-font-size, var(--text-title-size-large, 2rem));
3940
font-weight: var(--custom-font-weight, var(--base-text-weight-normal, 400));
@@ -163,6 +164,7 @@
163164
padding-block-end: var(--base-size-8);
164165
}
165166
}
167+
/* stylelint-enable selector-pseudo-class-disallowed-list */
166168

167169
& [data-component='PH_LeadingAction'],
168170
& [data-component='PH_TrailingAction'],

packages/react/src/SegmentedControl/SegmentedControl.module.css

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,12 +181,14 @@
181181
background-color: var(--borderColor-default);
182182
}
183183

184+
/* stylelint-disable-next-line selector-pseudo-class-disallowed-list -- scoped to CSS Module, audited (github/github-ui#17224) */
184185
&:has(+ [data-selected])::after,
185186
&:where([data-selected])::after {
186187
background-color: transparent;
187188
}
188189
}
189190

191+
/* stylelint-disable-next-line selector-pseudo-class-disallowed-list -- scoped to CSS Module, audited (github/github-ui#17224) */
190192
&:focus-within:has(:focus-visible) {
191193
background-color: transparent;
192194
}

0 commit comments

Comments
 (0)