Skip to content
Merged
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/perf-pageheader-has-selectors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': patch
---

PageHeader: improve rendering performance by removing expensive `:has()` selectors. No visual or API changes.
40 changes: 21 additions & 19 deletions packages/react/src/PageHeader/PageHeader.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -33,25 +33,28 @@
line-height is calculated with calc(height/font-size) and the below numbers are from @primer/primitives.
--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.

Size + nav-visibility data attributes are hoisted to the root by the React
component to keep selectors as plain attribute matches (cheap) rather than
relying on `:has()` (per-descendant invalidation).
*/
/* 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']) {
&[data-title-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));
line-height: var(--custom-line-height, var(--text-title-lineHeight-large, 1.5)); /* calc(48/32) */

--title-line-height: var(--custom-line-height, var(--text-title-lineHeight-large, 1.5));
}

&:has([data-component='TitleArea'][data-size-variant='medium']) {
&[data-title-size-variant='medium'] {
font-size: var(--custom-font-size, var(--text-title-size-medium, 1.25rem));
font-weight: var(--custom-font-weight, var(--base-text-weight-semibold, 600));
line-height: var(--custom-line-height, var(--text-title-lineHeight-medium, 1.6)); /* calc(32/20) */

--title-line-height: var(--custom-line-height, var(--text-title-lineHeight-medium, 1.6));
}

&:has([data-component='TitleArea'][data-size-variant='subtitle']) {
&[data-title-size-variant='subtitle'] {
font-size: var(--custom-font-size, var(--text-title-size-medium, 1.25rem));
font-weight: var(--custom-font-weight, var(--base-text-weight-normal, 400));
line-height: var(--custom-line-height, var(--text-title-lineHeight-medium, 1.6)); /* calc(32/20) */
Expand All @@ -61,23 +64,23 @@

/* Responsive size variants */
@media (--viewportRange-narrow) {
&:has([data-component='TitleArea'][data-size-variant-narrow='large']) {
&[data-title-size-variant-narrow='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));
line-height: var(--custom-line-height, var(--text-title-lineHeight-large, 1.5));

--title-line-height: var(--custom-line-height, var(--text-title-lineHeight-large, 1.5));
}

&:has([data-component='TitleArea'][data-size-variant-narrow='medium']) {
&[data-title-size-variant-narrow='medium'] {
font-size: var(--custom-font-size, var(--text-title-size-medium, 1.25rem));
font-weight: var(--custom-font-weight, var(--base-text-weight-semibold, 600));
line-height: var(--custom-line-height, var(--text-title-lineHeight-medium, 1.6));

--title-line-height: var(--custom-line-height, var(--text-title-lineHeight-medium, 1.6));
}

&:has([data-component='TitleArea'][data-size-variant-narrow='subtitle']) {
&[data-title-size-variant-narrow='subtitle'] {
font-size: var(--custom-font-size, var(--text-title-size-medium, 1.25rem));
font-weight: var(--custom-font-weight, var(--base-text-weight-normal, 400));
line-height: var(--custom-line-height, var(--text-title-lineHeight-medium, 1.6));
Expand All @@ -87,23 +90,23 @@
}

@media (--viewportRange-regular) {
&:has([data-component='TitleArea'][data-size-variant-regular='large']) {
&[data-title-size-variant-regular='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));
line-height: var(--custom-line-height, var(--text-title-lineHeight-large, 1.5));

--title-line-height: var(--custom-line-height, var(--text-title-lineHeight-large, 1.5));
}

&:has([data-component='TitleArea'][data-size-variant-regular='medium']) {
&[data-title-size-variant-regular='medium'] {
font-size: var(--custom-font-size, var(--text-title-size-medium, 1.25rem));
font-weight: var(--custom-font-weight, var(--base-text-weight-semibold, 600));
line-height: var(--custom-line-height, var(--text-title-lineHeight-medium, 1.6));

--title-line-height: var(--custom-line-height, var(--text-title-lineHeight-medium, 1.6));
}

&:has([data-component='TitleArea'][data-size-variant-regular='subtitle']) {
&[data-title-size-variant-regular='subtitle'] {
font-size: var(--custom-font-size, var(--text-title-size-medium, 1.25rem));
font-weight: var(--custom-font-weight, var(--base-text-weight-normal, 400));
line-height: var(--custom-line-height, var(--text-title-lineHeight-medium, 1.6));
Expand All @@ -113,23 +116,23 @@
}

@media (--viewportRange-wide) {
&:has([data-component='TitleArea'][data-size-variant-wide='large']) {
&[data-title-size-variant-wide='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));
line-height: var(--custom-line-height, var(--text-title-lineHeight-large, 1.5));

--title-line-height: var(--custom-line-height, var(--text-title-lineHeight-large, 1.5));
}

&:has([data-component='TitleArea'][data-size-variant-wide='medium']) {
&[data-title-size-variant-wide='medium'] {
font-size: var(--custom-font-size, var(--text-title-size-medium, 1.25rem));
font-weight: var(--custom-font-weight, var(--base-text-weight-semibold, 600));
line-height: var(--custom-line-height, var(--text-title-lineHeight-medium, 1.6));

--title-line-height: var(--custom-line-height, var(--text-title-lineHeight-medium, 1.6));
}

&:has([data-component='TitleArea'][data-size-variant-wide='subtitle']) {
&[data-title-size-variant-wide='subtitle'] {
font-size: var(--custom-font-size, var(--text-title-size-medium, 1.25rem));
font-weight: var(--custom-font-weight, var(--base-text-weight-normal, 400));
line-height: var(--custom-line-height, var(--text-title-lineHeight-medium, 1.6));
Expand All @@ -138,33 +141,32 @@
}
}

&[data-has-border='true']:has([data-component='PH_Navigation'][data-hidden-all]),
&[data-has-border='true']:not(:has([data-component='PH_Navigation'])) {
&[data-has-border='true'][data-nav-hidden-all],
&[data-has-border='true']:not([data-has-nav]) {
border-block-end: var(--borderWidth-thin) solid var(--borderColor-default);
padding-block-end: var(--base-size-8);
}

@media (--viewportRange-narrow) {
&[data-has-border='true']:has([data-component='PH_Navigation'][data-hidden-narrow]) {
&[data-has-border='true'][data-nav-hidden-narrow] {
border-block-end: var(--borderWidth-thin) solid var(--borderColor-default);
padding-block-end: var(--base-size-8);
}
}

@media (--viewportRange-regular) {
&[data-has-border='true']:has([data-component='PH_Navigation'][data-hidden-regular]) {
&[data-has-border='true'][data-nav-hidden-regular] {
border-block-end: var(--borderWidth-thin) solid var(--borderColor-default);
padding-block-end: var(--base-size-8);
}
}

@media (--viewportRange-wide) {
&[data-has-border='true']:has([data-component='PH_Navigation'][data-hidden-wide]) {
&[data-has-border='true'][data-nav-hidden-wide] {
border-block-end: var(--borderWidth-thin) solid var(--borderColor-default);
padding-block-end: var(--base-size-8);
}
}
/* stylelint-enable selector-pseudo-class-disallowed-list */

& [data-component='PH_LeadingAction'],
& [data-component='PH_TrailingAction'],
Expand Down
100 changes: 100 additions & 0 deletions packages/react/src/PageHeader/PageHeader.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,106 @@ describe('PageHeader', () => {
expect(container.firstChild).toHaveAttribute('aria-label', 'Custom aria-label')
})

describe('hoisted root data attributes', () => {
it('hoists the TitleArea variant onto the root as "data-title-size-variant"', () => {
const {container} = render(
<PageHeader role="banner">
<PageHeader.TitleArea variant="large">
<PageHeader.Title>Title</PageHeader.Title>
</PageHeader.TitleArea>
</PageHeader>,
)
expect(container.firstChild).toHaveAttribute('data-title-size-variant', 'large')
})

it('defaults the hoisted "data-title-size-variant" to "medium" when TitleArea has no variant', () => {
const {container} = render(
<PageHeader role="banner">
<PageHeader.TitleArea>
<PageHeader.Title>Title</PageHeader.Title>
</PageHeader.TitleArea>
</PageHeader>,
)
expect(container.firstChild).toHaveAttribute('data-title-size-variant', 'medium')
})

it('hoists the TitleArea variant through fragment wrappers', () => {
const {container} = render(
<PageHeader role="banner">
<>
<PageHeader.TitleArea variant="large">
<PageHeader.Title>Title</PageHeader.Title>
</PageHeader.TitleArea>
</>
</PageHeader>,
)
expect(container.firstChild).toHaveAttribute('data-title-size-variant', 'large')
})

it('does not emit "data-title-size-variant" when no TitleArea is rendered', () => {
const {container} = render(
<PageHeader role="banner">
<PageHeader.Navigation as="nav" aria-label="Custom">
Navigation
</PageHeader.Navigation>
</PageHeader>,
)
expect(container.firstChild).not.toHaveAttribute('data-title-size-variant')
})

it('emits "data-has-nav" on the root when a Navigation child is rendered', () => {
const {container} = render(
<PageHeader role="banner">
<PageHeader.TitleArea>
<PageHeader.Title>Title</PageHeader.Title>
</PageHeader.TitleArea>
<PageHeader.Navigation as="nav" aria-label="Custom">
Navigation
</PageHeader.Navigation>
</PageHeader>,
)
expect(container.firstChild).toHaveAttribute('data-has-nav')
})

it('hoists a Navigation child through fragment wrappers', () => {
const {container} = render(
<PageHeader role="banner">
<>
<PageHeader.Navigation as="nav" aria-label="Custom">
Navigation
</PageHeader.Navigation>
</>
</PageHeader>,
)
expect(container.firstChild).toHaveAttribute('data-has-nav')
})

it('does not emit "data-has-nav" on the root when no Navigation child is rendered', () => {
const {container} = render(
<PageHeader role="banner">
<PageHeader.TitleArea>
<PageHeader.Title>Title</PageHeader.Title>
</PageHeader.TitleArea>
</PageHeader>,
)
expect(container.firstChild).not.toHaveAttribute('data-has-nav')
})

it('hoists the Navigation hidden state onto the root as "data-nav-hidden-*"', () => {
const {container} = render(
<PageHeader role="banner">
<PageHeader.TitleArea>
<PageHeader.Title>Title</PageHeader.Title>
</PageHeader.TitleArea>
<PageHeader.Navigation as="nav" aria-label="Custom" hidden={{narrow: true, regular: false, wide: false}}>
Navigation
</PageHeader.Navigation>
</PageHeader>,
)
expect(container.firstChild).toHaveAttribute('data-nav-hidden-narrow', 'true')
})
})

it('forwards rest props to the underlying element in ParentLink', () => {
const {getByTestId} = render(
<PageHeader role="banner" aria-label="Title">
Expand Down
Loading
Loading