Skip to content

Add onResizeEnd and currentWidth props to PageLayout.Pane for controlled resizable width - #7348

Merged
mattcosta7 merged 29 commits into
mainfrom
make-primer-react-pagelayout-accept-persister
Feb 11, 2026
Merged

Add onResizeEnd and currentWidth props to PageLayout.Pane for controlled resizable width#7348
mattcosta7 merged 29 commits into
mainfrom
make-primer-react-pagelayout-accept-persister

Conversation

@mattcosta7

@mattcosta7mattcosta7 commented Dec 16, 2025

Copy link
Copy Markdown
Contributor

Summary

Adds onResizeEnd and currentWidth props to PageLayout.Pane for controlled resizable width, addressing #7311.

Problem

The current resizable={true} implementation always persists to localStorage. Users have no way to:

  1. Use custom persistence (server-side, IndexedDB, sessionStorage, etc.)
  2. Control the pane width from React state
  3. Avoid the SSR hydration mismatch caused by localStorage reads during render

Solution

Add two new props (only available when resizable={true}):

PropTypeDescription
onResizeEnd(width: number) => voidRequired for controlled mode. Callback fired when resize ends (pointer release or keyboard key up). Replaces localStorage persistence.
currentWidthnumberCurrent displayed width in pixels. Overrides internal state. The width prop still defines the default used on reset (double-click). Requires onResizeEnd.

API Design

Discriminated union types enforce valid prop combinations at the type level:

// ✅ Default behavior — localStorage persistence (unchanged)<PageLayout.Paneresizable/>// ✅ Fully controlled — currentWidth + onResizeEnd<PageLayout.PaneresizablecurrentWidth={width}onResizeEnd={setWidth}/>// ❌ Type error — currentWidth requires onResizeEnd<PageLayout.PaneresizablecurrentWidth={width}/>// ❌ Type error — these props require resizable={true}<PageLayout.PanecurrentWidth={width}onResizeEnd={setWidth}/>

Three union branches on PageLayoutPaneProps:

BranchresizableonResizeEndcurrentWidth
Default persistencetruenevernever
Custom callbacktruerequiredoptional
Not resizablefalse / omittednevernever

Key behaviors

  • onResizeEnd without currentWidth: The hook manages width internally but calls onResizeEnd instead of writing to localStorage. Good for fire-and-forget persistence (e.g., save to server).
  • currentWidth + onResizeEnd: Fully controlled. The consumer owns the width state. width prop still defines constraints (min/max) and the reset value for double-click.
  • Values are rounded: saveWidth rounds to integer pixels before persisting or calling onResizeEnd — sub-pixel floats from pointer drags are meaningless for persistence.
  • SSR: Default resizable={true} uses localStorage which can cause a hydration mismatch (suppressHydrationWarning is only applied in this case). Using onResizeEnd avoids localStorage reads entirely, so no mismatch occurs and suppression is not needed.

New export

  • defaultPaneWidth{small: 256, medium: 296, large: 320} — useful for initializing controlled width state.

Example: Custom persistence (avoids SSR CLS)

import{PageLayout,defaultPaneWidth}from'@primer/react'functionMyPage(){const[width,setWidth]=useState(defaultPaneWidth.medium)consthandleResizeEnd=(newWidth: number)=>{setWidth(newWidth)myServerStorage.save('pane-width',newWidth)}return(<PageLayout><PageLayout.PaneresizablecurrentWidth={width}onResizeEnd={handleResizeEnd}aria-label="Side pane">{/* ... */}</PageLayout.Pane><PageLayout.Content>{/* ... */}</PageLayout.Content></PageLayout>)}

Files changed

FileChange
PageLayout.tsxDiscriminated union types, pass new props to usePaneWidth, scoped suppressHydrationWarning
usePaneWidth.tsonResizeEnd/currentWidth params, inline state sync, pixel rounding, localStoragePersister extraction
usePaneWidth.test.ts54 tests covering controlled mode, callback, fallbacks
PageLayout.features.stories.tsx4 new stories (without persistence, custom persistence, number width, controlled width)
PageLayout.docs.jsonNew props + story IDs, SSR note on resizable
SplitPageLayout.docs.jsonNew props (shares PageLayoutPaneProps)
index.tsExports defaultPaneWidth
ChangesetMinor bump for @primer/react

Testing

  • 54 unit tests for usePaneWidth — initialization, controlled mode, callback, localStorage, resize listener
  • 15 PageLayout component tests — all pass
  • TypeScript compilation clean
  • ESLint + Prettier clean

Fixes#7311

@mattcosta7mattcosta7 self-assigned this Dec 16, 2025
@changeset-bot

changeset-botBot commented Dec 16, 2025

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 77259b8

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
NameType
@primer/reactMinor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actionsgithub-actionsBot added the integration-tests: recommended This change needs to be tested for breaking changes. See https://arc.net/l/quote/tdmpakpm label Dec 16, 2025
@github-actions

Copy link
Copy Markdown
Contributor

👋 Hi, this pull request contains changes to the source code that github/github-ui depends on. If you are GitHub staff, test these changes with github/github-ui using the integration workflow. Or, apply the integration-tests: skipped manually label to skip these checks.

- Add onWidthChange prop to PageLayout.Pane for tracking width changes
- Support controlled mode by syncing internal state from width prop changes
- onWidthChange fires at drag end and double-click reset (not during drag)
- Both onWidthChange and persister.save fire when both are provided
- Add error handling for onWidthChange callback errors
- Add comprehensive tests for new functionality
- Replace Record<string, never> with explicit NoPersistConfig type
- {persist: false} is more self-documenting than {}
- Make save required on WidthPersister (cleaner type)
- Rename isResizableWithoutPersistence to isNoPersistConfig
- Export NoPersistConfig type
- Update tests for new API
- Add PaneWidthValue type (PaneWidth | number | CustomWidthOptions)
- When width is a number, use minWidth prop and viewport-based max
- Export PaneWidthValue type and isNumericWidth helper
- Add 'Resizable pane with number width' story
- Update changeset documentation
@mattcosta7

Copy link
Copy Markdown
ContributorAuthor

@copilot

We received some feedback on this

tiny API note: how do you feel about the idea of persist: false | 'localStorage' | fn where the default is localStorage for now

Let's open a new pr based on this one that tweaks the resizable API in this way

CopilotAI commented Dec 30, 2025

Copy link
Copy Markdown
Contributor

@mattcosta7 I've opened a new pull request, #7395, to work on those changes. Once the pull request is ready, I'll request review from you.

@siddharthkp

siddharthkp commented Feb 11, 2026

Copy link
Copy Markdown
Member

Because me and Matt discussed this change before this PR, going to request a review from @francinelucca for an additional set of eyes

While the component could definitely use a round of improvements on the API, we thought this was a good way of solving the problem without blowing up scope.

@github-actions
github-actionsBot requested a deployment to storybook-preview-7348 February 11, 2026 15:25 Abandoned

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds a controlled/resizable-width mode to PageLayout.Pane so consumers can manage persistence/state themselves (and avoid SSR hydration issues caused by localStorage reads), while preserving the existing default localStorage behavior.

Changes:

  • Add onResizeEnd and currentWidth props (type-enforced combinations) for controlled/uncontrolled callback-based resizing.
  • Update usePaneWidth to support callback-based persistence, controlled width, and integer rounding on save.
  • Add docs/stories/tests and export defaultPaneWidth for consumer initialization.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 4 comments.

Show a summary per file
FileDescription
packages/react/src/SplitPageLayout/SplitPageLayout.docs.jsonDocuments new pane resizing props/SSR note for SplitPageLayout (shared props).
packages/react/src/PageLayout/usePaneWidth.tsAdds callback persistence + controlled width support and refactoring around persistence.
packages/react/src/PageLayout/usePaneWidth.test.tsAdds unit tests covering controlled mode and callback behavior.
packages/react/src/PageLayout/index.tsExports defaultPaneWidth.
packages/react/src/PageLayout/PageLayout.tsxAdds discriminated union prop types + wires new props into usePaneWidth; scopes hydration suppression.
packages/react/src/PageLayout/PageLayout.features.stories.tsxAdds stories demonstrating new resizing modes.
packages/react/src/PageLayout/PageLayout.docs.jsonDocuments new props and adds story IDs.
.changeset/pagelayout-resizable-persistence.mdPublishes the new API + export as a minor bump.
Comments suppressed due to low confidence (2)

packages/react/src/PageLayout/usePaneWidth.ts:126

  • updateAriaValues writes aria-valuemin/max/now using the numeric values verbatim. If values.current/min/max can be a float (e.g., legacy localStorage values or a controlled currentWidth), this will set ARIA slider attributes to non-integers. Consider normalizing here (e.g., Math.round) before calling setAttribute to keep ARIA values as integers and avoid hydration inconsistencies.
 }
}

packages/react/src/PageLayout/PageLayout.features.stories.tsx:466

  • The story name/label “Resizable pane with number width” doesn’t match what’s being demonstrated here (the width prop is still the preset string 'medium'). Consider renaming this story to reflect the actual distinction (e.g., preset width constraints) or adjusting the props/example so it matches the story intent.
export const ResizablePaneWithNumberWidth: StoryFn = () => {
const key = 'page-layout-features-stories-number-width'
// Read initial width from localStorage (CSR only), falling back to medium preset
const getInitialWidth = (): number => {
if (typeof window !== 'undefined') {
const storedWidth = localStorage.getItem(key)
if (storedWidth !== null) {
const parsed = parseInt(storedWidth, 10)
if (!isNaN(parsed) && parsed > 0) {
return parsed
}
}
}
return defaultPaneWidth.medium
}
const [currentWidth, setCurrentWidth] = React.useState<number>(getInitialWidth)
const handleWidthChange = (newWidth: number) => {
setCurrentWidth(newWidth)
localStorage.setItem(key, newWidth.toString())
}
return (
<PageLayout>
<PageLayout.Header>
<Placeholder height={64} label="Header" />
</PageLayout.Header>
<PageLayout.Pane
width="medium"
resizable
currentWidth={currentWidth}

Comment threadpackages/react/src/PageLayout/usePaneWidth.ts
Comment threadpackages/react/src/PageLayout/usePaneWidth.ts Outdated
Comment threadpackages/react/src/PageLayout/PageLayout.features.stories.tsx Outdated
Comment threadpackages/react/src/PageLayout/PageLayout.tsx Outdated
@github-actions
github-actionsBot requested a deployment to storybook-preview-7348 February 11, 2026 15:30 Abandoned
@github-actions
github-actionsBottemporarily deployed to storybook-preview-7348 February 11, 2026 15:39 Inactive
@github-actions
github-actionsBottemporarily deployed to storybook-preview-7348 February 11, 2026 15:48 Inactive
@github-actions
github-actionsBot requested a deployment to storybook-preview-7348 February 11, 2026 15:55 Abandoned
@github-actions
github-actionsBottemporarily deployed to storybook-preview-7348 February 11, 2026 16:04 Inactive

@francineluccafrancinelucca left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM, non-blocking comment.
Also wondering about next-major steps around this @mattcosta7 , would the cleanup process look like:

  • Remove localStorage logic, default to no persistence, can be handled by user with “onResizeEnd”
  • Remove ‘widthStorageKey’ prop
  • Remove suppressHydrationWarning

if so I can create an issue and make sure its tracked for next major, let me know if something else would be needed though

"type": "string",
"defaultValue": "'paneWidth'",
"description": "Provide a key used by localStorage to persist the size of the pane on the client."
"description": "localStorage key used to persist the pane width across sessions. Only applies when `resizable` is `true` and no `onResizeEnd` callback is provided."

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

is the plan still to get rid of localStorage next major? if so I think we can deprecate this prop

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

@siddharthkp do y'all intend to get rid fo that default, or maybe control it differnetly?

I'm not sure we need to get rid of it, but I imagine the api might be opt in with some server rendering logic somewhere?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

I'll probably avoid deprecating that here, and in the future it might be something that gets handled?

@siddharthkpsiddharthkpFeb 11, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'd like to eventually and instead export a persistInLocalStorage function that you can pass to onResizeEnd but it doesn't have to be now

A tracking issue would be very helpful though, so we can get it on the backlog

Comment threadpackages/react/src/PageLayout/PageLayout.features.stories.tsx Outdated
@primer-integration

Copy link
Copy Markdown

👋 Hi from github/github-ui! Your integration PR is ready: https://github.com/github/github-ui/pull/13350

@primer-integration

Copy link
Copy Markdown

Integration test results from github/github-ui:

Passed CI  Passed
Passed VRT  Passed
Passed Projects  Passed

All checks passed!

- Add contentWrapperRef back to useIsomorphicLayoutEffect dependency
array in usePaneWidth (it sets/removes data-dragging on it)
- Fix ResizablePaneWithoutPersistence story to actually disable
persistence by using onResizeEnd + currentWidth with React state
@github-actions
github-actionsBottemporarily deployed to storybook-preview-7348 February 11, 2026 19:31 Inactive
@mattcosta7mattcosta7 added the integration-tests: skipped manually Changes in this PR do not require an integration test label Feb 11, 2026
@mattcosta7
mattcosta7 added this pull request to the merge queueFeb 11, 2026
Merged via the queue into main with commit 3c160b2Feb 11, 2026
56 checks passed
@mattcosta7
mattcosta7 deleted the make-primer-react-pagelayout-accept-persister branch February 11, 2026 23:17
@primerprimerBot mentioned this pull request Feb 11, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

integration-tests: recommendedThis change needs to be tested for breaking changes. See https://arc.net/l/quote/tdmpakpmintegration-tests: skipped manuallyChanges in this PR do not require an integration test

Projects

None yet

Development

Successfully merging this pull request may close these issues.

PageLayout resizable is not SSR Safe

5 participants

@mattcosta7@siddharthkp@francinelucca