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/shiny-buckets-study.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': patch
---

fix resizeobserver perf regression
23 changes: 17 additions & 6 deletions packages/react/src/PageLayout/PageLayout.module.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
/* Exported values for JavaScript consumption */
:export {
/* Breakpoint where --pane-max-width-diff changes (used in usePaneWidth.ts) */
paneMaxWidthDiffBreakpoint: 1280;
/* Default value for --pane-max-width-diff below the breakpoint */
paneMaxWidthDiffDefault: 511;
}

.PageLayoutRoot {
/* Region Order */
--region-order-header: 0;
Expand All @@ -19,6 +27,7 @@
--pane-width-small: 100%;
--pane-width-medium: 100%;
--pane-width-large: 100%;
/* NOTE: This value is exported via :export for use in usePaneWidth.ts */
--pane-max-width-diff: 511px;

@media screen and (min-width: 768px) {
Expand All @@ -33,6 +42,7 @@
--pane-width-large: 320px;
}

/* NOTE: This breakpoint value is exported via :export for use in usePaneWidth.ts */
@media screen and (min-width: 1280px) {
--pane-max-width-diff: 959px;
}
Expand Down Expand Up @@ -373,10 +383,10 @@

/**
* OPTIMIZATION: Aggressive containment during drag for ContentWrapper
* CSS handles most optimizations automatically via :has() selector
* JavaScript only handles scroll locking (can't be done in CSS)
* data-dragging is set on PageLayoutContent by JavaScript
* This avoids expensive :has() selectors
*/
.PageLayoutContent:has(.DraggableHandle[data-dragging='true']) .ContentWrapper {
.PageLayoutContent[data-dragging='true'] .ContentWrapper {
/* Add paint containment during drag - safe since user can't interact */
contain: layout style paint;

Expand Down Expand Up @@ -422,7 +432,7 @@
* This prevents expensive recalculations of large content areas
* while keeping content visible (just frozen in place)
*/
.PageLayoutContent:has(.DraggableHandle[data-dragging='true']) .Content {
.PageLayoutContent[data-dragging='true'] .Content {
/* Full containment (without size) - isolate from layout recalculations */
contain: layout style paint;
}
Expand Down Expand Up @@ -618,9 +628,10 @@

/**
* OPTIMIZATION: Performance enhancements for Pane during drag
* CSS handles all optimizations automatically - JavaScript only locks scroll
* data-dragging is set on PageLayoutContent by JavaScript
* This avoids expensive :has() selectors
*/
.PaneWrapper:has(.DraggableHandle[data-dragging='true']) .Pane {
.PageLayoutContent[data-dragging='true'] .Pane {
/* Full containment - isolate from layout recalculations */
contain: layout style paint;

Expand Down
55 changes: 50 additions & 5 deletions packages/react/src/PageLayout/PageLayout.performance.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import React from 'react'
import React, {useState} from 'react'
import type {Meta, StoryObj} from '@storybook/react-vite'
import {PageLayout} from './PageLayout'
import {Button} from '../Button'
import Label from '../Label'
import Heading from '../Heading'
import Autocomplete from '../Autocomplete'
import FormControl from '../FormControl'

const meta: Meta<typeof PageLayout> = {
title: 'Components/PageLayout/Performance Tests',
Expand All @@ -14,6 +16,47 @@ export default meta

type Story = StoryObj<typeof PageLayout>

// Autocomplete suggestions data
const suggestions = [
{id: '1', text: 'JavaScript'},
{id: '2', text: 'TypeScript'},
{id: '3', text: 'React'},
{id: '4', text: 'Vue'},
{id: '5', text: 'Angular'},
{id: '6', text: 'Svelte'},
{id: '7', text: 'Node.js'},
{id: '8', text: 'Python'},
{id: '9', text: 'Ruby'},
{id: '10', text: 'Go'},
]

// Reusable stateful autocomplete search component
function SearchInput() {
const [filterValue, setFilterValue] = useState('')
const filteredItems = suggestions.filter(item => item.text.toLowerCase().includes(filterValue.toLowerCase()))

return (
<FormControl>
<FormControl.Label>Search</FormControl.Label>
<Autocomplete>
<Autocomplete.Input
value={filterValue}
onChange={e => setFilterValue(e.target.value)}
placeholder="Search items..."
/>
<Autocomplete.Overlay>
<Autocomplete.Menu
items={filteredItems}
selectedItemIds={[]}
aria-labelledby="autocomplete-label"
selectionVariant="single"
/>
</Autocomplete.Overlay>
</Autocomplete>
</FormControl>
)
}

// ============================================================================
// Story 1: Baseline - Light Content (~100 elements)
// ============================================================================
Expand All @@ -29,7 +72,8 @@ export const BaselineLight: Story = {

<PageLayout.Content>
<div style={{padding: '16px'}}>
<p>Minimal DOM elements to establish baseline.</p>
<SearchInput />
<p style={{marginTop: '16px'}}>Minimal DOM elements to establish baseline.</p>
<p>Should be effortless 60 FPS.</p>
</div>
</PageLayout.Content>
Expand Down Expand Up @@ -58,7 +102,6 @@ export const MediumContent: Story = {
</PageLayout.Header>
<PageLayout.Pane position="start" resizable>
<div style={{padding: '16px'}}>
<p>Performance Monitor</p>
<div
style={{
padding: '12px',
Expand All @@ -77,8 +120,9 @@ export const MediumContent: Story = {
</PageLayout.Pane>
<PageLayout.Content>
<div style={{padding: '16px'}}>
<SearchInput />
{/* Large table with complex cells */}
<h2 style={{marginBottom: '16px'}}>Data Table (300 rows × 10 columns)</h2>
<h2 style={{marginTop: '16px', marginBottom: '16px'}}>Data Table (300 rows × 10 columns)</h2>
<div
tabIndex={0}
style={{
Expand Down Expand Up @@ -209,8 +253,9 @@ export const HeavyContent: Story = {

<PageLayout.Content>
<div tabIndex={0} style={{padding: '16px', overflowY: 'auto', height: '600px'}}>
<SearchInput />
{/* Section 1: Large card grid */}
<section style={{marginBottom: '32px'}}>
<section style={{marginTop: '16px', marginBottom: '32px'}}>
<h2 style={{marginBottom: '16px'}}>Activity Feed (200 cards)</h2>
<div style={{display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))', gap: '12px'}}>
{Array.from({length: 200}).map((_, i) => (
Expand Down
55 changes: 55 additions & 0 deletions packages/react/src/PageLayout/PageLayout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,61 @@ describe('PageLayout', async () => {
const finalWidth = (pane as HTMLElement).style.getPropertyValue('--pane-width')
expect(finalWidth).not.toEqual(initialWidth)
})

it('should set data-dragging attribute during pointer drag', async () => {
const {container} = render(
<PageLayout>
<PageLayout.Pane resizable>
<Placeholder height={320} label="Pane" />
</PageLayout.Pane>
<PageLayout.Content>
<Placeholder height={640} label="Content" />
</PageLayout.Content>
</PageLayout>,
)

const content = container.querySelector('[class*="PageLayoutContent"]')
const divider = await screen.findByRole('slider')

// Before drag - no data-dragging attribute
expect(content).not.toHaveAttribute('data-dragging')

// Start drag
fireEvent.pointerDown(divider, {clientX: 300, clientY: 200, pointerId: 1})
expect(content).toHaveAttribute('data-dragging', 'true')

// End drag - pointer capture lost ends the drag and removes attribute
fireEvent.lostPointerCapture(divider, {pointerId: 1})
expect(content).not.toHaveAttribute('data-dragging')
})

it('should set data-dragging attribute during keyboard resize', async () => {
const {container} = render(
<PageLayout>
<PageLayout.Pane resizable>
<Placeholder height={320} label="Pane" />
</PageLayout.Pane>
<PageLayout.Content>
<Placeholder height={640} label="Content" />
</PageLayout.Content>
</PageLayout>,
)

const content = container.querySelector('[class*="PageLayoutContent"]')
const divider = await screen.findByRole('slider')

// Before interaction - no data-dragging attribute
expect(content).not.toHaveAttribute('data-dragging')

// Start keyboard resize (focus first)
fireEvent.focus(divider)
fireEvent.keyDown(divider, {key: 'ArrowRight'})
expect(content).toHaveAttribute('data-dragging', 'true')

// End keyboard resize - removes attribute
fireEvent.keyUp(divider, {key: 'ArrowRight'})
expect(content).not.toHaveAttribute('data-dragging')
})
})

describe('PageLayout.Content', () => {
Expand Down
Loading
Loading