Add onResizeEnd and currentWidth props to PageLayout.Pane for controlled resizable width#7348
Conversation
🦋 Changeset detectedLatest commit: 77259b8 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
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 |
|
👋 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 |
- 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
d672433 to
c490d05
Compare
|
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 |
|
@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. |
|
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. |
There was a problem hiding this comment.
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
onResizeEndandcurrentWidthprops (type-enforced combinations) for controlled/uncontrolled callback-based resizing. - Update
usePaneWidthto support callback-based persistence, controlled width, and integer rounding on save. - Add docs/stories/tests and export
defaultPaneWidthfor 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
updateAriaValueswritesaria-valuemin/max/nowusing the numeric values verbatim. Ifvalues.current/min/maxcan be a float (e.g., legacy localStorage values or a controlledcurrentWidth), this will set ARIA slider attributes to non-integers. Consider normalizing here (e.g.,Math.round) before callingsetAttributeto 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
widthprop 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}
francinelucca
left a comment
There was a problem hiding this comment.
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." |
There was a problem hiding this comment.
is the plan still to get rid of localStorage next major? if so I think we can deprecate this prop
There was a problem hiding this comment.
@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?
There was a problem hiding this comment.
I'll probably avoid deprecating that here, and in the future it might be something that gets handled?
There was a problem hiding this comment.
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
|
👋 Hi from github/github-ui! Your integration PR is ready: https://github.com/github/github-ui/pull/13350 |
- 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
Summary
Adds
onResizeEndandcurrentWidthprops toPageLayout.Panefor controlled resizable width, addressing #7311.Problem
The current
resizable={true}implementation always persists to localStorage. Users have no way to:Solution
Add two new props (only available when
resizable={true}):onResizeEnd(width: number) => voidcurrentWidthnumberwidthprop still defines the default used on reset (double-click). RequiresonResizeEnd.API Design
Discriminated union types enforce valid prop combinations at the type level:
Three union branches on
PageLayoutPaneProps:resizableonResizeEndcurrentWidthtruenevernevertruefalse/ omittedneverneverKey behaviors
onResizeEndwithoutcurrentWidth: The hook manages width internally but callsonResizeEndinstead 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.widthprop still defines constraints (min/max) and the reset value for double-click.saveWidthrounds to integer pixels before persisting or callingonResizeEnd— sub-pixel floats from pointer drags are meaningless for persistence.resizable={true}uses localStorage which can cause a hydration mismatch (suppressHydrationWarningis only applied in this case). UsingonResizeEndavoids 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)
Files changed
PageLayout.tsxusePaneWidth, scopedsuppressHydrationWarningusePaneWidth.tsonResizeEnd/currentWidthparams, inline state sync, pixel rounding,localStoragePersisterextractionusePaneWidth.test.tsPageLayout.features.stories.tsxPageLayout.docs.jsonresizableSplitPageLayout.docs.jsonPageLayoutPaneProps)index.tsdefaultPaneWidth@primer/reactTesting
usePaneWidth— initialization, controlled mode, callback, localStorage, resize listenerFixes #7311