diff --git a/.github/instructions/css.instructions.md b/.github/instructions/css.instructions.md index f40aee8e99d..67f2ffdc75d 100644 --- a/.github/instructions/css.instructions.md +++ b/.github/instructions/css.instructions.md @@ -11,3 +11,16 @@ If the file ends with `*.module.css`, it is a CSS Module. ## General Conventions - 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 + +## `:has()` Selectors and Safari Performance + +`: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. + +**When you need `:has()`:** + +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. +2. Keep the argument simple. Avoid deeply-nested descendant selectors inside `:has()`. +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) */` +4. Consider alternatives first: data attributes set via JS, `:where()`, or restructuring the DOM so the parent already has the information it needs. + +**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. diff --git a/packages/react/src/ActionList/ActionList.module.css b/packages/react/src/ActionList/ActionList.module.css index 29b81e1a630..de45e397de9 100644 --- a/packages/react/src/ActionList/ActionList.module.css +++ b/packages/react/src/ActionList/ActionList.module.css @@ -87,6 +87,7 @@ } /* if a list has a mix of items with and without descriptions, reset the label font-weight to normal */ + /* stylelint-disable-next-line selector-pseudo-class-disallowed-list -- scoped to CSS Module, audited (github/github-ui#17224) */ &:has([data-has-description='true']):has([data-has-description='false']) { & .ItemLabel { font-weight: var(--base-text-weight-normal); @@ -103,6 +104,7 @@ border-radius: var(--borderRadius-medium); /* apply flex if trailing action exists as an immediate child */ + /* stylelint-disable-next-line selector-pseudo-class-disallowed-list -- scoped to CSS Module, audited (github/github-ui#17224) */ &:has(> .TrailingAction) { display: flex; flex-wrap: nowrap; @@ -371,6 +373,7 @@ } /* When TrailingAction is in loading state, keep labels and descriptions accessible */ + /* stylelint-disable-next-line selector-pseudo-class-disallowed-list -- scoped to CSS Module, audited (github/github-ui#17224) */ &:has(.TrailingAction [data-loading='true']):not([data-is-disabled]) { /* Ensure labels and descriptions maintain accessibility contrast */ & .ItemLabel { @@ -546,6 +549,7 @@ } /* 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']) { background: var(--control-transparent-bgColor-selected); @@ -642,6 +646,7 @@ 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; @@ -718,6 +723,7 @@ span wrapping svg or text */ height: 100%; /* Preserve width consistency when loading state is active for text buttons only */ + /* stylelint-disable-next-line selector-pseudo-class-disallowed-list -- scoped to CSS Module, audited (github/github-ui#17224) */ &[data-loading='true']:has([data-component='buttonContent']) { /* Double the left padding to compensate for missing right padding */ padding: 0 0 0 calc(var(--base-size-12) * 2); @@ -737,10 +743,12 @@ span wrapping svg or text */ } .InactiveButtonWrap { + /* stylelint-disable-next-line selector-pseudo-class-disallowed-list -- scoped to CSS Module, audited (github/github-ui#17224) */ &:has(.TrailingVisual) { grid-area: trailingVisual; } + /* stylelint-disable-next-line selector-pseudo-class-disallowed-list -- scoped to CSS Module, audited (github/github-ui#17224) */ &:has(.LeadingVisual) { grid-area: leadingVisual; } diff --git a/packages/react/src/ActionList/Group.module.css b/packages/react/src/ActionList/Group.module.css index 201d3086b86..66297a80d78 100644 --- a/packages/react/src/ActionList/Group.module.css +++ b/packages/react/src/ActionList/Group.module.css @@ -5,7 +5,7 @@ margin-block-start: var(--base-size-8); /* If somebody tries to pass the `title` prop AND a `NavList.GroupHeading` as a child, hide the `ActionList.GroupHeading */ - /* stylelint-disable-next-line selector-max-specificity */ + /* stylelint-disable-next-line selector-max-specificity, selector-pseudo-class-disallowed-list -- scoped to CSS Module, audited (github/github-ui#17224) */ &:has(.GroupHeadingWrap + ul > .GroupHeadingWrap) { /* stylelint-disable-next-line selector-max-specificity */ & > .GroupHeadingWrap { diff --git a/packages/react/src/AvatarStack/AvatarStack.module.css b/packages/react/src/AvatarStack/AvatarStack.module.css index b431dec0aab..3eedffd65da 100644 --- a/packages/react/src/AvatarStack/AvatarStack.module.css +++ b/packages/react/src/AvatarStack/AvatarStack.module.css @@ -144,6 +144,7 @@ box-shadow: 0 0 0 var(--avatar-border-width) transparent; } + /* stylelint-disable-next-line selector-pseudo-class-disallowed-list -- scoped to CSS Module, audited (github/github-ui#17224) */ &:not([data-component='Avatar']):not(:has([data-square])) { border-radius: 50%; } diff --git a/packages/react/src/Breadcrumbs/Breadcrumbs.module.css b/packages/react/src/Breadcrumbs/Breadcrumbs.module.css index e699fb42587..bd1691f28f0 100644 --- a/packages/react/src/Breadcrumbs/Breadcrumbs.module.css +++ b/packages/react/src/Breadcrumbs/Breadcrumbs.module.css @@ -117,6 +117,7 @@ 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) { white-space: normal; } diff --git a/packages/react/src/Button/ButtonBase.module.css b/packages/react/src/Button/ButtonBase.module.css index 9d3fee8bc64..7d39ed491e9 100644 --- a/packages/react/src/Button/ButtonBase.module.css +++ b/packages/react/src/Button/ButtonBase.module.css @@ -24,6 +24,7 @@ justify-content: space-between; gap: var(--base-size-8); + /* stylelint-disable-next-line selector-pseudo-class-disallowed-list -- scoped to CSS Module, audited (github/github-ui#17224) */ &:has([data-kbd-chord]) { padding-inline-end: var(--base-size-6); } @@ -173,6 +174,7 @@ margin-right: var(--control-large-gap); } + /* stylelint-disable-next-line selector-pseudo-class-disallowed-list -- scoped to CSS Module, audited (github/github-ui#17224) */ &:has([data-kbd-chord]) { padding-inline-end: var(--base-size-8); } @@ -638,6 +640,7 @@ /* Icon-only + Counter */ + /* stylelint-disable-next-line selector-pseudo-class-disallowed-list -- scoped to CSS Module, audited (github/github-ui#17224) */ &:where([data-has-count]):has([data-component='leadingVisual']):not(:has([data-component='text'])) { /* stylelint-disable-next-line primer/spacing */ padding-inline: var(--control-medium-paddingInline-condensed); diff --git a/packages/react/src/ButtonGroup/ButtonGroup.module.css b/packages/react/src/ButtonGroup/ButtonGroup.module.css index 7c289b69544..cb942aef85c 100644 --- a/packages/react/src/ButtonGroup/ButtonGroup.module.css +++ b/packages/react/src/ButtonGroup/ButtonGroup.module.css @@ -38,6 +38,7 @@ } /* this is a workaround until portal based tooltips are fully removed from dotcom */ + /* stylelint-disable-next-line selector-pseudo-class-disallowed-list -- scoped to CSS Module, audited (github/github-ui#17224) */ &:has(div:last-child:empty) { button, a { diff --git a/packages/react/src/Dialog/Dialog.module.css b/packages/react/src/Dialog/Dialog.module.css index 0532fed9f22..c5c4e31bf07 100644 --- a/packages/react/src/Dialog/Dialog.module.css +++ b/packages/react/src/Dialog/Dialog.module.css @@ -296,7 +296,7 @@ * When the attribute IS present (flag ON), browser skips :has() evaluation * because the :not() check fails first (O(1) attribute lookup). */ -/* stylelint-disable-next-line selector-no-qualifying-type */ +/* stylelint-disable-next-line selector-no-qualifying-type, selector-pseudo-class-disallowed-list -- :has() on body guarded by feature flag negation, audited (github/github-ui#17224) */ body:not([data-dialog-scroll-optimized]):has(.Dialog.DisableScroll) { /* stylelint-disable-next-line primer/spacing */ padding-right: var(--prc-dialog-scrollgutter) !important; diff --git a/packages/react/src/PageHeader/PageHeader.module.css b/packages/react/src/PageHeader/PageHeader.module.css index 92486619fe8..992f1618d12 100644 --- a/packages/react/src/PageHeader/PageHeader.module.css +++ b/packages/react/src/PageHeader/PageHeader.module.css @@ -34,6 +34,7 @@ --custom-font-size, --custom-line-height, --custom-font-weight are custom properties that can be used to override the below values. We don't want these values to be overridden but still want to allow consumers to override them if needed. */ + /* stylelint-disable selector-pseudo-class-disallowed-list -- :has() scoped to CSS Module, audited (github/github-ui#17224) */ &:has([data-component='TitleArea'][data-size-variant='large']) { font-size: var(--custom-font-size, var(--text-title-size-large, 2rem)); font-weight: var(--custom-font-weight, var(--base-text-weight-normal, 400)); @@ -163,6 +164,7 @@ padding-block-end: var(--base-size-8); } } + /* stylelint-enable selector-pseudo-class-disallowed-list */ & [data-component='PH_LeadingAction'], & [data-component='PH_TrailingAction'], diff --git a/packages/react/src/SegmentedControl/SegmentedControl.module.css b/packages/react/src/SegmentedControl/SegmentedControl.module.css index 22b80ae8f33..05bc0feed8f 100644 --- a/packages/react/src/SegmentedControl/SegmentedControl.module.css +++ b/packages/react/src/SegmentedControl/SegmentedControl.module.css @@ -181,12 +181,14 @@ background-color: var(--borderColor-default); } + /* stylelint-disable-next-line selector-pseudo-class-disallowed-list -- scoped to CSS Module, audited (github/github-ui#17224) */ &:has(+ [data-selected])::after, &:where([data-selected])::after { background-color: transparent; } } + /* 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; } diff --git a/packages/react/src/Timeline/Timeline.module.css b/packages/react/src/Timeline/Timeline.module.css index a841e6b909d..5f25050c59b 100644 --- a/packages/react/src/Timeline/Timeline.module.css +++ b/packages/react/src/Timeline/Timeline.module.css @@ -110,6 +110,7 @@ border: 0; border-top: var(--borderWidth-thicker) solid var(--borderColor-default); + /* stylelint-disable-next-line selector-pseudo-class-disallowed-list -- scoped to CSS Module, audited (github/github-ui#17224) */ &:has(+ [data-condensed]) { margin-bottom: calc(-1 * var(--base-size-12)); } diff --git a/packages/react/src/TreeView/TreeView.module.css b/packages/react/src/TreeView/TreeView.module.css index 1ce05985ef6..57e3da65ff8 100644 --- a/packages/react/src/TreeView/TreeView.module.css +++ b/packages/react/src/TreeView/TreeView.module.css @@ -72,6 +72,7 @@ * TreeViewItemContent > TreeViewItemContentText, not a direct child. * This is acceptable as the search is scoped to this element's subtree. */ + /* stylelint-disable-next-line selector-pseudo-class-disallowed-list -- scoped to CSS Module, audited (github/github-ui#17224) */ &:has(.TreeViewItemSkeleton):hover { cursor: default; background-color: transparent; diff --git a/packages/react/src/experimental/SelectPanel2/SelectPanel.module.css b/packages/react/src/experimental/SelectPanel2/SelectPanel.module.css index cebb847f70c..16afc9dd10a 100644 --- a/packages/react/src/experimental/SelectPanel2/SelectPanel.module.css +++ b/packages/react/src/experimental/SelectPanel2/SelectPanel.module.css @@ -121,7 +121,7 @@ .TextInput { padding-left: var(--base-size-8) !important; - /* stylelint-disable-next-line selector-class-pattern, selector-no-qualifying-type */ + /* 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) { display: none; } diff --git a/packages/styled-react/src/components/BaseStyles.tsx b/packages/styled-react/src/components/BaseStyles.tsx index a467e337430..d2ed1412d8d 100644 --- a/packages/styled-react/src/components/BaseStyles.tsx +++ b/packages/styled-react/src/components/BaseStyles.tsx @@ -74,19 +74,12 @@ const GlobalStyle = createGlobalStyle<{colorScheme?: 'light' | 'dark'}>` /* stylelint-disable-next-line primer/colors */ color: var(--BaseStyles-fgColor, var(--fgColor-default)); - /* Global styles for light mode */ - &:has([data-color-mode='light']) { - input & { - color-scheme: light; - } - } - - /* Global styles for dark mode */ - &:has([data-color-mode='dark']) { - input & { - color-scheme: dark; - } - } + /* + * PERFORMANCE: Removed :has([data-color-mode]) selectors that scanned entire DOM. + * Input color-scheme is already handled by global selectors above: + * [data-color-mode='light'] input { color-scheme: light; } + * [data-color-mode='dark'] input { color-scheme: dark; } + */ /* Low-specificity default link styling */ :where(a:not([class*='prc-']):not([class*='PRC-']):not([class*='Primer_Brand__'])) { diff --git a/stylelint.config.mjs b/stylelint.config.mjs index 5eb82300b04..6b25674f5f8 100644 --- a/stylelint.config.mjs +++ b/stylelint.config.mjs @@ -14,6 +14,17 @@ export default { }, ], 'layer-name-pattern': '^[a-z][a-zA-Z0-9.-]*$', + // :has() can cause severe perf issues in Safari (quadratic style invalidation). + // See github/github-ui#17224 for audit. Existing usages are audited and scoped. + // New usages must be explicitly approved and marked with a stylelint-disable comment. + 'selector-pseudo-class-disallowed-list': [ + ['has'], + { + severity: 'error', + message: + ':has() selectors can cause severe Safari performance issues (github/github-ui#17224). Verify the selector is scoped (CSS Modules) and does not match broadly, then add a scoped stylelint disable (e.g. "stylelint-disable-next-line" or a minimal "stylelint-disable"/"stylelint-enable" block), not a file-level disable.', + }, + ], }, overrides: [ {