Skip to content

PageLayout resizable is not SSR Safe #7311

@mattcosta7

Description

@mattcosta7

Description

TLDR:

When a resizable PageLayout is used in SSR (and has a nondefault width stored), is always causes a react hydration error and potential tearing, due to the read of localStorage in useState initialization.


When a resizable PageLayout is used the current size is set in localStorage

const updatePaneWidth = (width: number) => {
setPaneWidth(width)
try {
localStorage.setItem(widthStorageKey, width.toString())
} catch (_error) {
// Ignore errors
}
}

On initial render that is read and used to seed the state

const [paneWidth, setPaneWidth] = React.useState(() => {
if (!canUseDOM) {
return getDefaultPaneWidth(width)
}
let storedWidth
try {
storedWidth = localStorage.getItem(widthStorageKey)
} catch (_error) {
storedWidth = null
}
return storedWidth && !isNaN(Number(storedWidth)) ? Number(storedWidth) : getDefaultPaneWidth(width)
})

In CSR scenarios. this is ok, albeit not ideal. React should avoid interacting with DOM apis during the render cycle - even at state initialization, instead syncing them in useLayoutEffect or useEffect.

This would look like:

const [width, setWidth] = useState(defaultValue)
useLayoutEffect(() => {
   const initial = localStorage.getItem(...)
   // validate
   setWidth(initial)
}, [])

<div style={{ --pane-width: width }}>...</div>

In this scenario, we'd reinitialize this value before the browser paints, and generally avoid flashing invalid sizes (as long as it's a layout effect)

However, in SSR scenarios, because the server doesn't have a way to really generate the default width here, we intiially render with the server default width, then on client hydration react receives different values for the width here - since it gets initialized differnelty from the server.

This causes react to warn about hydration errors, and act non-deterministically in what the user sees.

  • if the component doesn't re-render itself, users will see the server applied width only - because react won't sync this
  • if the componet does re-render, react will 'correct' the invalid server value, possibly leading to CLS or a flash change of location
  • in suspense boundaries, react may 'correct' this already, but outside of suspense it won't

You can see this on any page in GitHub that uses a resizable panel and SSRs - the wrong resizable size is shown on reloads, even though react seems to know what it is - and then the resizer (today) will jump, since position is based on width + cursor-diff (#7307 will change that to align with cursor position so it won't be an issue). However, it can also be seen as a jump resize much less frequently if something forces a PageLayout to render unexpectedly.


We should consider how resizable panes work, store values and seed them to enhance SSR safety and ensure that clients display the proper values.

Maybe this is - let's offload all of the localStorage work for this to the consumers, who can set/get widths however they choose, instead of relying on the internal implementation of this?

In this case we might still need to read some width value from a layout effect or something else to sync them properly?

Steps to reproduce

SSR and hydrateRoot a resizable pane
Resize it
Reload the page
See hydration errors and incorrect width OR CLS

Version

v35.x

Browser

Chrome

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions