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/silent-taxis-eat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@primer/react": patch
---

Fix `usePaneWidth` triggering unnecessary React re-renders on every window resize
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, {useState} from 'react'
import type {Meta, StoryObj} from '@storybook/react-vite'
import {PageLayout} from './PageLayout'
import {SplitPageLayout} from '../SplitPageLayout'
import {Button} from '../Button'
import Label from '../Label'
import Heading from '../Heading'
Expand Down Expand Up @@ -535,3 +536,47 @@ export const KeyboardARIATest: Story = {
)
},
}

// ============================================================================
// Story: Re-render test — reproduces unnecessary re-renders from usePaneWidth
// https://github.com/primer/react/issues/7801
// ============================================================================

export const ResizablePaneReRenderCounter: Story = {
name: 'Re-render Counter (Issue #7801)',
render: () => {
return (
<SplitPageLayout>
<SplitPageLayout.Header>
<div style={{padding: '16px'}}>
<Heading as="h2">Re-render Test — Issue #7801</Heading>
<p style={{marginTop: '8px', fontSize: '14px', color: 'var(--fgColor-muted)'}}>
Open React DevTools Profiler, enable &quot;Highlight updates when components render&quot;, then resize the
browser window. Before the fix, all components re-render on every resize tick even when the computed max
width hasn&apos;t changed. After the fix, renders only happen when crossing the 1280px breakpoint or when
current width is clamped.
</p>
</div>
</SplitPageLayout.Header>

<SplitPageLayout.Pane position="start" resizable aria-label="Side pane">
<div style={{padding: '16px'}}>
<p>This pane is resizable. Try resizing the browser window.</p>
</div>
</SplitPageLayout.Pane>

<SplitPageLayout.Content>
<div style={{padding: '16px'}}>
<p>Content area — should not re-render on resize unless max width changes.</p>
</div>
</SplitPageLayout.Content>

<SplitPageLayout.Footer>
<div style={{padding: '16px'}}>
<p>Footer area.</p>
</div>
</SplitPageLayout.Footer>
</SplitPageLayout>
)
},
}
42 changes: 42 additions & 0 deletions packages/react/src/PageLayout/usePaneWidth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -853,6 +853,48 @@ describe('usePaneWidth', () => {
vi.useRealTimers()
})

it('should skip startTransition when maxPaneWidth has not changed (#7801)', async () => {
vi.useFakeTimers()
// Start at 1000px — below the 1280 breakpoint, so diff = 511
vi.stubGlobal('innerWidth', 1000)
const refs = createMockRefs()

const {result} = renderHook(() =>
usePaneWidth({
width: 'medium',
minWidth: 256,
resizable: true,
widthStorageKey: 'test-skip-transition',
...refs,
}),
)

// Initial max: 1000 - 511 = 489
expect(result.current.maxPaneWidth).toBe(489)

// Resize to 900px — still below 1280 breakpoint, diff stays 511
// New max would be 900 - 511 = 389, which IS different, so state updates
vi.stubGlobal('innerWidth', 900)
window.dispatchEvent(new Event('resize'))
await act(async () => {
await vi.runAllTimersAsync()
})
expect(result.current.maxPaneWidth).toBe(389)

// Now resize again to a different width that produces the SAME max
// 900px -> 900px (no actual viewport change, same max = 389)
const renderCountBefore = result.current.maxPaneWidth
window.dispatchEvent(new Event('resize'))
await act(async () => {
await vi.runAllTimersAsync()
})

// maxPaneWidth should be unchanged — no re-render triggered
expect(result.current.maxPaneWidth).toBe(renderCountBefore)
Comment on lines +884 to +893

Comment on lines +892 to +894
vi.useRealTimers()
})

it('should cleanup resize listener on unmount', () => {
const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener')
const cancelAnimationFrameSpy = vi.spyOn(window, 'cancelAnimationFrame')
Expand Down
23 changes: 16 additions & 7 deletions packages/react/src/PageLayout/usePaneWidth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,8 @@ export function usePaneWidth({
const currentWidthRef = React.useRef(currentWidth)
// Max width for ARIA - SSR uses custom max or a sensible default, updated on mount
const [maxPaneWidth, setMaxPaneWidth] = React.useState(() => customMaxWidth ?? SSR_DEFAULT_MAX_WIDTH)
// Track last maxPaneWidth to skip redundant startTransition calls on resize (see #7801)
const maxPaneWidthRef = React.useRef(maxPaneWidth)

// Keep currentWidthRef in sync with state (ref is used during drag to avoid re-renders)
useIsomorphicLayoutEffect(() => {
Expand Down Expand Up @@ -365,20 +367,27 @@ export function usePaneWidth({
// Update ARIA via DOM - cheap, no React re-render
updateAriaValues(handleRef.current, {max: actualMax, current: currentWidthRef.current})

// Defer state updates so parent re-renders see accurate values
startTransition(() => {
setMaxPaneWidth(actualMax)
if (wasClamped) {
setCurrentWidthState(actualMax)
}
})
// Only trigger React re-render if values actually changed.
// startTransition doesn't bail out on same-value updates like normal setState,
// so we guard explicitly to avoid unnecessary re-renders on every resize tick. (#7801)
const maxChanged = actualMax !== maxPaneWidthRef.current
if (maxChanged || wasClamped) {
maxPaneWidthRef.current = actualMax
startTransition(() => {
setMaxPaneWidth(actualMax)
if (wasClamped) {
setCurrentWidthState(actualMax)
}
})
}
}

// Initial calculation on mount — use viewport-based lookup to avoid
// getComputedStyle which forces a synchronous layout recalc on the
// freshly-committed DOM tree (measured at ~614ms on large pages).
maxWidthDiffRef.current = getMaxWidthDiffFromViewport()
const initialMax = getMaxPaneWidthRef.current()
maxPaneWidthRef.current = initialMax
setMaxPaneWidth(initialMax)
paneRef.current?.style.setProperty('--pane-max-width', `${initialMax}px`)
updateAriaValues(handleRef.current, {min: minPaneWidth, max: initialMax, current: currentWidthRef.current})
Expand Down
Loading