Skip to content

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

Merged
mattcosta7 merged 29 commits intomainfrom
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 intomainfrom
make-primer-react-pagelayout-accept-persister

Conversation

@mattcosta7
Copy link
Copy Markdown
Contributor

@mattcosta7 mattcosta7 commented Dec 16, 2025

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}):

Prop Type Description
onResizeEnd (width: number) => void Required for controlled mode. Callback fired when resize ends (pointer release or keyboard key up). Replaces localStorage persistence.
currentWidth number Current 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.Pane resizable />

// ✅ Fully controlled — currentWidth + onResizeEnd
<PageLayout.Pane resizable currentWidth={width} onResizeEnd={setWidth} />

// ❌ Type error — currentWidth requires onResizeEnd
<PageLayout.Pane resizable currentWidth={width} />

// ❌ Type error — these props require resizable={true}
<PageLayout.Pane currentWidth={width} onResizeEnd={setWidth} />

Three union branches on PageLayoutPaneProps:

Branch resizable onResizeEnd currentWidth
Default persistence true never never
Custom callback true required optional
Not resizable false / omitted never never

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'

function MyPage() {
  const [width, setWidth] = useState(defaultPaneWidth.medium)

  const handleResizeEnd = (newWidth: number) => {
    setWidth(newWidth)
    myServerStorage.save('pane-width', newWidth)
  }

  return (
    <PageLayout>
      <PageLayout.Pane
        resizable
        currentWidth={width}
        onResizeEnd={handleResizeEnd}
        aria-label="Side pane"
      >
        {/* ... */}
      </PageLayout.Pane>
      <PageLayout.Content>{/* ... */}</PageLayout.Content>
    </PageLayout>
  )
}

Files changed

File Change
PageLayout.tsx Discriminated union types, pass new props to usePaneWidth, scoped suppressHydrationWarning
usePaneWidth.ts onResizeEnd/currentWidth params, inline state sync, pixel rounding, localStoragePersister extraction
usePaneWidth.test.ts 54 tests covering controlled mode, callback, fallbacks
PageLayout.features.stories.tsx 4 new stories (without persistence, custom persistence, number width, controlled width)
PageLayout.docs.json New props + story IDs, SSR note on resizable
SplitPageLayout.docs.json New props (shares PageLayoutPaneProps)
index.ts Exports defaultPaneWidth
Changeset Minor 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

@mattcosta7 mattcosta7 self-assigned this Dec 16, 2025
@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Dec 16, 2025

🦋 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
Name Type
@primer/react Minor

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-actions github-actions bot 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
Contributor Author

@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

Copy link
Copy Markdown
Contributor

Copilot AI commented Dec 30, 2025

@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
Copy link
Copy Markdown
Member

siddharthkp commented Feb 11, 2026

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-actions bot requested a deployment to storybook-preview-7348 February 11, 2026 15:25 Abandoned
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

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
File Description
packages/react/src/SplitPageLayout/SplitPageLayout.docs.json Documents new pane resizing props/SSR note for SplitPageLayout (shared props).
packages/react/src/PageLayout/usePaneWidth.ts Adds callback persistence + controlled width support and refactoring around persistence.
packages/react/src/PageLayout/usePaneWidth.test.ts Adds unit tests covering controlled mode and callback behavior.
packages/react/src/PageLayout/index.ts Exports defaultPaneWidth.
packages/react/src/PageLayout/PageLayout.tsx Adds discriminated union prop types + wires new props into usePaneWidth; scopes hydration suppression.
packages/react/src/PageLayout/PageLayout.features.stories.tsx Adds stories demonstrating new resizing modes.
packages/react/src/PageLayout/PageLayout.docs.json Documents new props and adds story IDs.
.changeset/pagelayout-resizable-persistence.md Publishes 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}

@github-actions github-actions bot requested a deployment to storybook-preview-7348 February 11, 2026 15:30 Abandoned
@github-actions github-actions bot temporarily deployed to storybook-preview-7348 February 11, 2026 15:39 Inactive
@github-actions github-actions bot had a problem deploying to storybook-preview-7348 February 11, 2026 15:48 Failure
@github-actions github-actions bot requested a deployment to storybook-preview-7348 February 11, 2026 15:55 Abandoned
@github-actions github-actions bot temporarily deployed to storybook-preview-7348 February 11, 2026 16:04 Inactive
Copy link
Copy Markdown
Member

@francinelucca francinelucca left a comment

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
Contributor Author

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
Contributor Author

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?

Copy link
Copy Markdown
Member

@siddharthkp siddharthkp Feb 11, 2026

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

@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
@mattcosta7 mattcosta7 enabled auto-merge February 11, 2026 23:08
@mattcosta7 mattcosta7 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 queue Feb 11, 2026
Merged via the queue into main with commit 3c160b2 Feb 11, 2026
56 checks passed
@mattcosta7 mattcosta7 deleted the make-primer-react-pagelayout-accept-persister branch February 11, 2026 23:17
@primer primer bot mentioned this pull request Feb 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

integration-tests: recommended This change needs to be tested for breaking changes. See https://arc.net/l/quote/tdmpakpm integration-tests: skipped manually Changes 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