Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/graduate-css-has-selector-perf-flag.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': patch
---

Graduate `primer_react_css_has_selector_perf` feature flag: the CSS `:has()` performance optimization (`body[data-dialog-scroll-disabled]`) is now the default behavior for Dialog scroll disabling
19 changes: 0 additions & 19 deletions packages/react/src/Dialog/Dialog.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -285,27 +285,8 @@
}
}

/* DisableScroll class is added to Dialog when scroll should be disabled on body */
.DisableScroll {
/* This class is used as a selector target for the legacy :has() CSS selector */
}

/*
* LEGACY: Scoped :has() selector with negation guard
* Only evaluates when data-dialog-scroll-optimized is NOT present on body.
* 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 */
body:not([data-dialog-scroll-optimized]):has(.Dialog.DisableScroll) {
/* stylelint-disable-next-line primer/spacing */
padding-right: var(--prc-dialog-scrollgutter) !important;
overflow: hidden !important;
}

/*
* PERFORMANCE OPTIMIZATION: Direct attribute on body - O(1) lookup
* Active when primer_react_css_has_selector_perf flag is ON
*/
/* stylelint-disable-next-line selector-no-qualifying-type */
body[data-dialog-scroll-disabled] {
Expand Down
118 changes: 9 additions & 109 deletions packages/react/src/Dialog/Dialog.test.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,13 @@
import React from 'react'
import {render, fireEvent, waitFor} from '@testing-library/react'
import {describe, expect, it, vi, beforeEach} from 'vitest'
import {describe, expect, it, vi} from 'vitest'
import userEvent from '@testing-library/user-event'
import {Dialog} from './Dialog'
import {Button} from '../Button'
import {implementsClassName} from '../utils/testing'
import classes from './Dialog.module.css'
import {FeatureFlags} from '../FeatureFlags'
import {__resetDialogScrollOptimizedCount} from '../FeatureFlags/FeatureFlags'

describe('Dialog', () => {
beforeEach(() => {
__resetDialogScrollOptimizedCount()
})

implementsClassName(Dialog, classes.Dialog)
it('renders with role "dialog" by default', () => {
const {getByRole} = render(<Dialog onClose={() => {}}>Pay attention to me</Dialog>)
Expand Down Expand Up @@ -376,126 +370,32 @@ describe('Footer button loading states', () => {
expect(deleteButton).not.toHaveAttribute('data-loading', 'true')
})

describe('primer_react_css_has_selector_perf feature flag', () => {
it('does not add data-dialog-scroll-optimized or data-dialog-scroll-disabled when flag is OFF', () => {
const {unmount} = render(
<FeatureFlags flags={{primer_react_css_has_selector_perf: false}}>
<Dialog onClose={() => {}}>Dialog content</Dialog>
</FeatureFlags>,
)

expect(document.body.hasAttribute('data-dialog-scroll-optimized')).toBe(false)
expect(document.body.hasAttribute('data-dialog-scroll-disabled')).toBe(false)
describe('scroll disable behavior', () => {
it('sets data-dialog-scroll-disabled on body when dialog mounts', () => {
const {unmount} = render(<Dialog onClose={() => {}}>Dialog content</Dialog>)

unmount()

expect(document.body.hasAttribute('data-dialog-scroll-optimized')).toBe(false)
expect(document.body.hasAttribute('data-dialog-scroll-disabled')).toBe(false)
})

it('adds data-dialog-scroll-optimized at provider level and data-dialog-scroll-disabled when dialog mounts', () => {
const {unmount} = render(
<FeatureFlags flags={{primer_react_css_has_selector_perf: true}}>
<Dialog onClose={() => {}}>Dialog content</Dialog>
</FeatureFlags>,
)

// Provider sets data-dialog-scroll-optimized, Dialog sets data-dialog-scroll-disabled
expect(document.body.hasAttribute('data-dialog-scroll-optimized')).toBe(true)
expect(document.body.hasAttribute('data-dialog-scroll-disabled')).toBe(true)

unmount()

// Both should be removed on unmount
expect(document.body.hasAttribute('data-dialog-scroll-optimized')).toBe(false)
expect(document.body.hasAttribute('data-dialog-scroll-disabled')).toBe(false)
})

it('sets data-dialog-scroll-optimized even when no dialogs are open', () => {
const {unmount} = render(
<FeatureFlags flags={{primer_react_css_has_selector_perf: true}}>
<div>No dialogs here</div>
</FeatureFlags>,
)

// Provider sets the attribute even without dialogs
expect(document.body.hasAttribute('data-dialog-scroll-optimized')).toBe(true)
expect(document.body.hasAttribute('data-dialog-scroll-disabled')).toBe(false)

unmount()

expect(document.body.hasAttribute('data-dialog-scroll-optimized')).toBe(false)
})

it('handles multiple dialogs with ref counting when flag is ON', () => {
const {unmount: unmount1} = render(
<FeatureFlags flags={{primer_react_css_has_selector_perf: true}}>
<Dialog onClose={() => {}}>Dialog 1</Dialog>
</FeatureFlags>,
)
it('handles multiple dialogs with ref counting', () => {
const {unmount: unmount1} = render(<Dialog onClose={() => {}}>Dialog 1</Dialog>)

expect(document.body.hasAttribute('data-dialog-scroll-optimized')).toBe(true)
expect(document.body.hasAttribute('data-dialog-scroll-disabled')).toBe(true)

// Render second dialog
const {unmount: unmount2} = render(
<FeatureFlags flags={{primer_react_css_has_selector_perf: true}}>
<Dialog onClose={() => {}}>Dialog 2</Dialog>
</FeatureFlags>,
)
const {unmount: unmount2} = render(<Dialog onClose={() => {}}>Dialog 2</Dialog>)

// Attributes should still be present
expect(document.body.hasAttribute('data-dialog-scroll-optimized')).toBe(true)
expect(document.body.hasAttribute('data-dialog-scroll-disabled')).toBe(true)

// Unmount first dialog
// Unmount first dialog - attribute should still be present
unmount1()

// Attributes should still be present (second dialog and provider are still mounted)
expect(document.body.hasAttribute('data-dialog-scroll-optimized')).toBe(true)
expect(document.body.hasAttribute('data-dialog-scroll-disabled')).toBe(true)

// Unmount second dialog
unmount2()

// Now both should be removed
expect(document.body.hasAttribute('data-dialog-scroll-optimized')).toBe(false)
expect(document.body.hasAttribute('data-dialog-scroll-disabled')).toBe(false)
})

it('handles multiple dialogs correctly when flag is OFF', () => {
const {unmount: unmount1} = render(
<FeatureFlags flags={{primer_react_css_has_selector_perf: false}}>
<Dialog onClose={() => {}}>Dialog 1</Dialog>
</FeatureFlags>,
)

expect(document.body.hasAttribute('data-dialog-scroll-optimized')).toBe(false)
expect(document.body.hasAttribute('data-dialog-scroll-disabled')).toBe(false)

// Render second dialog
const {unmount: unmount2} = render(
<FeatureFlags flags={{primer_react_css_has_selector_perf: false}}>
<Dialog onClose={() => {}}>Dialog 2</Dialog>
</FeatureFlags>,
)

// Attributes should not be present
expect(document.body.hasAttribute('data-dialog-scroll-optimized')).toBe(false)
expect(document.body.hasAttribute('data-dialog-scroll-disabled')).toBe(false)

// Unmount first dialog
unmount1()

// Attributes should still not be present
expect(document.body.hasAttribute('data-dialog-scroll-optimized')).toBe(false)
expect(document.body.hasAttribute('data-dialog-scroll-disabled')).toBe(false)

// Unmount second dialog
// Unmount second dialog - attribute should be removed
unmount2()

// Attributes should still not be present
expect(document.body.hasAttribute('data-dialog-scroll-optimized')).toBe(false)
expect(document.body.hasAttribute('data-dialog-scroll-disabled')).toBe(false)
})
})
Expand Down
31 changes: 12 additions & 19 deletions packages/react/src/Dialog/Dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ import {useSlots} from '../hooks/useSlots'

/* Dialog Version 2 */

/**
* Ref count for data-dialog-scroll-disabled attribute management.
* Tracks how many dialogs are currently open to know when to remove the attribute.
* This is client-only: it is only accessed inside useEffect, which never runs on the server.
*/
let dialogScrollDisabledCount = 0

/**
* Props that characterize a button to be rendered into the footer of
* a Dialog.
Expand Down Expand Up @@ -267,7 +274,6 @@ const _Dialog = React.forwardRef<HTMLDivElement, React.PropsWithChildren<DialogP
const autoFocusedFooterButtonRef = useRef<HTMLButtonElement>(null)
for (const footerButton of footerButtons) {
if (footerButton.autoFocus) {
// eslint-disable-next-line react-hooks/immutability
footerButton.ref = autoFocusedFooterButtonRef
}
Comment on lines 275 to 278
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The footerButtons loop still mutates footerButton.ref, but the eslint-disable-next-line react-hooks/immutability was removed. If the repo’s immutability lint rule still applies here (it’s used elsewhere), this will fail linting. Either restore the targeted eslint-disable for this assignment or refactor to avoid mutating the footerButtons objects (e.g., derive a new array/object for rendering).

Copilot uses AI. Check for mistakes.
}
Expand Down Expand Up @@ -308,29 +314,16 @@ const _Dialog = React.forwardRef<HTMLDivElement, React.PropsWithChildren<DialogP

React.useEffect(() => {
const scrollbarWidth = window.innerWidth - document.body.clientWidth
const dialog = dialogRef.current
const usePerfOptimization = document.body.hasAttribute('data-dialog-scroll-optimized')

// Add DisableScroll class to this dialog (for legacy :has() selector path)
dialog?.classList.add(classes.DisableScroll)
dialogScrollDisabledCount++
document.body.style.setProperty('--prc-dialog-scrollgutter', `${scrollbarWidth}px`)

if (usePerfOptimization) {
// Optimized path: set attribute on body for direct CSS targeting
document.body.setAttribute('data-dialog-scroll-disabled', '')
}
// Legacy path: no action needed - CSS :has(.Dialog.DisableScroll) handles it
document.body.setAttribute('data-dialog-scroll-disabled', '')

Comment on lines 315 to 321
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--prc-dialog-scrollgutter is recomputed and set on every dialog mount. When a second dialog mounts after scroll has already been disabled, document.body.clientWidth will typically equal window.innerWidth (no scrollbar), making scrollbarWidth become 0 and overwriting the previously-correct gutter value. This can reintroduce layout shift while dialogs are still open. Consider only computing/setting the gutter (and the data-dialog-scroll-disabled attribute) when the ref count transitions from 0 → 1, and only removing them when it transitions back to 0.

See below for a potential fix:

    dialogScrollDisabledCount++
    if (dialogScrollDisabledCount === 1) {
      const scrollbarWidth = window.innerWidth - document.body.clientWidth
      document.body.style.setProperty('--prc-dialog-scrollgutter', `${scrollbarWidth}px`)
      document.body.setAttribute('data-dialog-scroll-disabled', '')
    }

Copilot uses AI. Check for mistakes.
return () => {
dialog?.classList.remove(classes.DisableScroll)

const remainingDialogs = document.querySelectorAll(`.${classes.DisableScroll}`)

if (remainingDialogs.length === 0) {
dialogScrollDisabledCount--
if (dialogScrollDisabledCount === 0) {
document.body.style.removeProperty('--prc-dialog-scrollgutter')
if (usePerfOptimization) {
document.body.removeAttribute('data-dialog-scroll-disabled')
}
document.body.removeAttribute('data-dialog-scroll-disabled')
}
}
}, [])
Expand Down
1 change: 0 additions & 1 deletion packages/react/src/FeatureFlags/DefaultFeatureFlags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import {FeatureFlagScope} from './FeatureFlagScope'

export const DefaultFeatureFlags = FeatureFlagScope.create({
primer_react_breadcrumbs_overflow_menu: false,
primer_react_css_has_selector_perf: false,
primer_react_select_panel_fullscreen_on_narrow: false,
primer_react_select_panel_order_selected_at_top: false,
primer_react_select_panel_remove_active_descendant: false,
Expand Down
40 changes: 0 additions & 40 deletions packages/react/src/FeatureFlags/FeatureFlags.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,57 +2,17 @@ import type React from 'react'
import {useContext, useMemo} from 'react'
import {FeatureFlagContext} from './FeatureFlagContext'
import {FeatureFlagScope, type FeatureFlags} from './FeatureFlagScope'
import useIsomorphicLayoutEffect from '../utils/useIsomorphicLayoutEffect'

export type FeatureFlagsProps = React.PropsWithChildren<{
flags: FeatureFlags
}>

/**
* Ref count for data-dialog-scroll-optimized attribute management.
*
* NOTE: This is temporary infrastructure while we feature flag the CSS :has()
* performance optimization (primer_react_css_has_selector_perf). Once the flag
* is removed and the optimization is the default behavior, this ref counting
* can be removed - the attribute can simply always be present.
*
* @internal - Not part of the public API
*/
let dialogScrollOptimizedCount = 0

/**
* Reset the ref count for testing purposes only.
*
* @internal - Not part of the public API. Only exported for test isolation.
*/
export function __resetDialogScrollOptimizedCount(): void {
dialogScrollOptimizedCount = 0
document.body.removeAttribute('data-dialog-scroll-optimized')
}

export function FeatureFlags({children, flags}: FeatureFlagsProps) {
const parentFeatureFlags = useContext(FeatureFlagContext)
const value = useMemo(() => {
const scope = FeatureFlagScope.merge(parentFeatureFlags, FeatureFlagScope.create(flags))
return scope
}, [parentFeatureFlags, flags])

const isOptimizationEnabled = value.enabled('primer_react_css_has_selector_perf')

// Set body attribute for CSS :has() optimization when flag is enabled
useIsomorphicLayoutEffect(() => {
if (isOptimizationEnabled) {
dialogScrollOptimizedCount++
document.body.setAttribute('data-dialog-scroll-optimized', '')

return () => {
dialogScrollOptimizedCount--
if (dialogScrollOptimizedCount === 0) {
document.body.removeAttribute('data-dialog-scroll-optimized')
}
}
}
}, [isOptimizationEnabled])

return <FeatureFlagContext.Provider value={value}>{children}</FeatureFlagContext.Provider>
}
Loading