Skip to content

SSR Persistence Script#521

Closed
isBatak wants to merge 28 commits intobvaughn:mainfrom
isBatak:ssr-persistence-script
Closed

SSR Persistence Script#521
isBatak wants to merge 28 commits intobvaughn:mainfrom
isBatak:ssr-persistence-script

Conversation

@isBatak
Copy link

@isBatak isBatak commented Oct 19, 2025

This PR explores an approach to eliminate layout shift during SSR hydration by persisting panel sizes using inline scripts that execute before React hydration.

Changes Added

New Workspace Project

  • Added a Next.js example project (examples/nextjs) to test and demonstrate server-side rendering behavior
  • This allows testing the SSR persistence script in a real-world SSR environment

New Component: PersistScript

  • Added PersistScript.tsx component that renders an inline <script> tag in each panel
  • The script reads saved panel sizes from localStorage and applies them via CSS variables before React hydration

Panel Component Updates

  • Modified Panel.ts to render PersistScript inside each panel when autoSaveId is provided
  • Added suppressHydrationWarning attribute to panel elements to handle the dynamic script injection
  • Added data-panel-order attribute to enable script to match panels with their saved state

Storage Format Changes

  • Breaking Change: Updated localStorage format to include panel order information
  • Changed layout signature from number[] to PanelLayoutItem[]:
    type PanelLayoutItem = {
      order: number;
      size: number;
    };
  • Updated serialization.ts to handle both old and new formats for backward compatibility
  • The script uses panel order to reliably match saved sizes even when panels are reordered

Breaking change

  • order prop is required for this feature to work properly

TODO

  • Minify script source - The inline script should be minified to reduce the HTML size
  • Remove suppressHydrationWarning requirement - Find alternative approach (e.g., global CSS variables scoped by groupId + order: --panel-size-<groupId>-<order>)
  • Add comprehensive tests - Test SSR scenarios, storage format migration
  • Handle custom storage prop - Decide what to do when users provide custom storage implementation

Open Questions

  1. Should we maintain backward compatibility with the old number[] format indefinitely?
  2. What's the best way to detect SSR context to conditionally render the script?
  3. How should custom storage prop interact with the persistence script?

Recordings

Before After
https://github.com/user-attachments/assets/dd77c32e-9a43-4405-8243-56f6abe87421 https://github.com/user-attachments/assets/9397d169-e872-49c0-8b09-962704941521

@vercel
Copy link

vercel bot commented Oct 19, 2025

@isBatak is attempting to deploy a commit to the Brian Vaughn's projects Team on Vercel.

A member of the Team first needs to authorize it.

@bvaughn
Copy link
Owner

bvaughn commented Oct 20, 2025

This seems like a promising idea, but it also seems like it will require a lot of testing and validating to reduce causing regressions. Server rendering is unfortunately not something I use often, so I don't really feel comfortable championing this feature and leading that testing.

@bvaughn
Copy link
Owner

bvaughn commented Oct 20, 2025

I wonder: is there a more incremental/opt-in approach, where panels be updated to read the size value, but rely on external code to actually set it initially for the SSR case? (Obviously we could document how to do that.)

@isBatak
Copy link
Author

isBatak commented Oct 21, 2025

Thanks, that makes sense. Yeah I agree SSR adds some risk so keeping it opt-in sounds like a good idea.

We can remove the logic from Panel and make it composable instead, something like:

<PanelGroup autoSaveId="persistence1" direction="horizontal">
  <Panel defaultSize={20} minSize={10} id="panel1" order={1}>
    <PanelPersistScript autoSaveId="persistence1" panelId="panel1" />
    <div>left</div>
  </Panel>
</PanelGroup>

I’ll rename PersistScript to PanelPersistScript so it’s clearer what it’s for.

For the persistence format change, I added a fallback so older data should still work fine.

On testing: if all client tests pass we should be safe from regressions, and I can add a few more for the fallback logic. For SSR I might try adding one Playwright e2e test with the Next.js example repo, does that sound ok?

@bvaughn
Copy link
Owner

bvaughn commented Oct 22, 2025

Haven’t forgotten about this. Just busy with work. Will try to get back to you soonish

@ybelakov
Copy link

any update?

@bvaughn
Copy link
Owner

bvaughn commented Oct 30, 2025

No. Work has been very busy the past several days and honestly I don't have the mental bandwidth to really think through the ramifications of a change of this size right now. Sorry.

If you're in a hurry to get this functionality shipped, you can always do a forked release for now. That's definitely the fastest path.

@bvaughn
Copy link
Owner

bvaughn commented Oct 31, 2025

Full disclosure: Because this is such a large change, and it would be a backwards breaking one (if we have to change order to a required prop) I'm going to try to find some time to re-think the API at a higher level. There may be other changes I'd like to make too.

@isBatak
Copy link
Author

isBatak commented Nov 2, 2025

I just managed to implement this part too

next thing I wanna try is render just one PersistScript for all local storage entries and move css vars to :root. This will remove need for suppressHydrationWarning on PanelGroup. The idea is to use a fallback var for flex-grow like flex-grow: var(--panel-${order}-size, var(--panel-${autoSaveId}-${order}-size)); where --panel-${autoSaveId}-${order}-size is injected to :root by the PersistScript.

here is the commit isBatak@170b782

  • only one PersistScript is required and it can be anywhere in the DOM
  • all suppressHydrationWarning attributes have been removed, they’re no longer needed
  • CSS variables are injected in the :root using the new CSSStyleSheet() API here

I'm amazed at how well this turned out!

@bvaughn
Copy link
Owner

bvaughn commented Nov 7, 2025

Wanted to share a quick update here. I'm only able to work on this a little each day because my day job is pretty intense right now, but I think I can make a few nice API simplifications in my version 4 branch:

  • Min/max panel size constraints can be controlled entirely with CSS classes/styles
  • Resize handle elements will be optional; the parent group will treat panel edges as drag handles
  • Won't need order props anymore; parent group can determine order based on element rects

I'm not totally sure how the CSS variable stuff fits into that yet but I assume your work in this branch has plenty of useful information there. (Thanks!)

I'm thinking if autoSize is enabled for a group, and it detects an SSR environment (???) maybe it can just automatically render the persist script...but I'll need to give that some more thought.

@bvaughn bvaughn mentioned this pull request Nov 7, 2025
@isBatak
Copy link
Author

isBatak commented Nov 7, 2025

Thanks for the update! Totally understand about the limited time, I appreciate you still making progress on this.

About the “no order props” change, my implementation relies heavily on the order number. I use it as a unique identifier for each panel together with autoSaveId. We can’t just replace order with id because useId generates non-constant values, so anything saved to local storage could change after a refresh.

For example:
• --panel-${order}-size is created by the PanelGroup, which needs to know each panel’s order.
• --panel-${autoSaveId}-${order}-size is created by the PersistScript, which reads the order from local storage and uses autoSaveId from props.

So we’d need to keep some kind of stable order reference for these parts to work.

@isBatak
Copy link
Author

isBatak commented Nov 7, 2025

I'm thinking if autoSize is enabled for a group, and it detects an SSR environment (???) maybe it can just automatically render the persist script...but I'll need to give that some more thought.

My latest implementation of PersistScript actually reads all local storage entries and injects the variables into the global CSS, so it works independently from PanelGroup. That means we can have multiple PanelGroup compositions but only one PersistScript, and it could even live in the document head.

@bvaughn
Copy link
Owner

bvaughn commented Nov 7, 2025

RE: the order prop– I'd like to question that a little more. It seems plausible that:

  1. During the initial render (server side) we can assume panels will render in order; conditional rendering won't yet have had time to mess up the ordering.
  2. After mount/on update, the Group can infer panel order using bounding rects.

I think we may be able to come up with a heuristic that supports this without needing to require both order and id props. That's always felt a bit awkward to me (and does occasionally trip people up).

RE: PersistScript– I think we might still accomplish the goal of one script per page using some kind of module level flag. (The library already relies on module level state for things like pointer event tracking because it's more efficient for drags that impact multiple Groups.) I think it's a nicer API if users don't have to remember to render an additional tag for autoSave to work properly in some cases.

@bvaughn
Copy link
Owner

bvaughn commented Nov 15, 2025

Still making slow and steady progress on v4. Writing the component and docs at the same time, thinking through what will make for an easy API.

Screenshot 2025-11-15 at 3 32 06 PM

Had to back away from inferring min/max size from CSS styles for a few reasons:

  • Doesn't really work well with the initial render
  • Doesn't support the concept of a collapsed size

Decided instead to export component props that support CSS units.

Screenshot 2025-11-15 at 3 31 57 PM

@bvaughn bvaughn mentioned this pull request Nov 17, 2025
*
* This file is the source of truth for the persist logic.
* It gets minified and transformed by minify-persist.ts during the build.
*/
Copy link
Owner

Choose a reason for hiding this comment

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

Hey @isBatak– clarifying question about this as I'm thinking about how best to port it to #528

How does the timing for this actually work?

My understanding of server rendered React was that static HTML would be sent to the client and rendered/painted before "hydration" (or any other JavaScript) actually runs. Wouldn't this injected script be too late to actually prevent the layout flicker then?

Copy link
Author

Choose a reason for hiding this comment

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

The idea is to rely on this behavior: https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/script#blocking

When the browser parses the HTML sent from the server, it will stop at the script tag and execute the inlined JS. The JS then reads the localStorage values and injects the global CSS variables to avoid needing suppressHydrationWarning.
Since the browser can’t render without parsing the DOM, rendering is indirectly blocked.

Copy link
Owner

@bvaughn bvaughn Nov 19, 2025

Choose a reason for hiding this comment

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

I see. Thanks for sharing that information.

I guess that’s actually a pretty important thing for users to consider then, since that could potentially delay the initial paint significantly in some case cases?

Copy link
Author

Choose a reason for hiding this comment

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

That’s definitely something for users to consider. There’s always a trade-off.
That’s why I went with a single PersistScript per page — it only blocks parsing once. It reads all the relevant localStorage entries and injects them in one go, and then the CSS picks up the variables it needs during the render phase.

I’ve also been thinking about adding an option to specify which autoSaveIds to inject, since localStorage may contain entries from other routes. Something like:

export function PageA() {
  return (
    <>
      <PersistScript autoSaveIds={['a1', 'a2', 'a3']} />
      <PanelGroup autoSaveId="a1">
        {/* ... */}
        <PanelGroup autoSaveId="a2">
          {/* ... */}
          <PanelGroup autoSaveId="a3">
            {/* ... */}
          </PanelGroup>
        </PanelGroup>
      </PanelGroup>
    </>
  );
}
export function PageB() {
  return (
    <>
      <PersistScript autoSaveIds={['b1']} />
      <PanelGroup autoSaveId="b1">
        {/* ... */}
      </PanelGroup>
    </>
  );
}

@bvaughn
Copy link
Owner

bvaughn commented Nov 28, 2025

How are you thinking about auto-save groups with dynamic layouts? These groups may save multiple combinations of panel configurations, e.g.

{
  "center,left,right":{"left":20,"center":30,"right":30},
  "left,right":{"left":40,"right":60}
}

How would the injected script know which CSS variables to write in this kind of scenario? the panels won't yet have registered, so the parent group wouldn't know whether there will be two or three panels.

@isBatak
Copy link
Author

isBatak commented Nov 29, 2025

I haven’t thought deeply about this case.
My assumption is that the server will always render one layout or the other based on the initial condition state.

But I think the solution is to store which panel was removed in localStorage.
Then, when the script loads, it can inject something like --panel-id-center-ssr-display: none (and the same for the handler). That way the panel is hidden visually until JS takes over and actually removes it from the DOM.

By reading which panel is removed from localStorage, the script can know which layout it should load.

@bvaughn
Copy link
Owner

bvaughn commented Nov 29, 2025

Hmm. We could also store timestamps with layouts and restore the most recent one, but… I’m not sure it’s safe to assume that the most recent combination of panels will necessarily be the one to render on a new page load. Panels could be based on eg the size of the viewport.

@bvaughn
Copy link
Owner

bvaughn commented Nov 29, 2025

TBH even if I switched the persistence mechanism to cookies instead of localStorage (which we probably could do since the payload is small) this would still present a potential problem 🤔 since the group would need to know what's about to be rendered

@bvaughn
Copy link
Owner

bvaughn commented Nov 29, 2025

We could also store timestamps with layouts and restore the most recent one

This is probably the best option I can think of. I could change the API to be more explicit (e.g. <PeristenceScript panelIds={["left", "right"]} />) or something but I don't think the edge case warrants that DevX impact.

I do find myself considering whether it would be easier to use cookies to store and retrieve the most recent layout. The whole injected blocking script that needs to be precompiled and minified as a separate build step bit is fairly awkward.

@bvaughn
Copy link
Owner

bvaughn commented Nov 29, 2025

Commit 199a897 adds this behavior to the v4 rewrite, restoring the most recently saved Group layout and assuming that will probably almost always be the one we're rendering.

I still need to decide how/if to support custom storage prop values. Maybe that's no longer necessary. The primary use case was offering an escape hatch for SSR (via cookies) and a secondary use case was unit testing (though that can be accomplished by spying/mocking localStorage).

I've also decided, for now at least, that Group will automatically render the blocking script if autoSave is enabled because I think it's an awkward API to require people to manually render it.

@isBatak
Copy link
Author

isBatak commented Nov 29, 2025

I've also decided, for now at least, that Group will automatically render the blocking script if autoSave is enabled because I think it's an awkward API to require people to manually render it.

This will always bundle the blocking script. I would rather go with the akward API then always bundle the script whether it's used or not.

@isBatak
Copy link
Author

isBatak commented Nov 29, 2025

I still need to decide how/if to support custom storage prop values. Maybe that's no longer necessary. The primary use case was offering an escape hatch for SSR (via cookies) and a secondary use case was unit testing (though that can be accomplished by spying/mocking localStorage).

Maybe it's best to leave this decision to the user and design a low level API that allows any type of storage.

For example:

const { restoredLayout, storeLayaot } = useLayoutStore(); // could use LocalStorage, SessionStorage, Cookies or IndexDB

<Group initialLayout={restoredLayout} onLayoutChange={storeLayaot}>

Ark UI splitter is doing something similar https://ark-ui.com/docs/components/splitter

@bvaughn
Copy link
Owner

bvaughn commented Nov 29, 2025

// could use LocalStorage, SessionStorage, Cookies or IndexDB

I don't think IndexedDB should be a target for whatever solution this library supports, since operations are asynchronous. Storage APIs and Cookies make sense though.

I think that lifting the save/restore logic out of Group and into a hook might be a good approach though, since it leaves things fully in user land to customize.

My main concern is the potential for confusion or conflicts between group-default-layout and panel-default-size. I think the documentation would have to be very clear that:

  1. The Group prop is used for the initial render, without any validation
  2. If group default layout is provided, it will supersede Panel default size
  3. Panel constraints (e.g. min/max size) may override a Group default layout on mount

@bvaughn
Copy link
Owner

bvaughn commented Nov 30, 2025

Slept on this and have decided that I don't like that API direction. Something with this general shape seems ideal from an ergonomics/DevX perspective:

<Group autoSave id="my-group">
  ...
</Group>

Though more flexible, having to use something like this throughout an app with a lot of resizable panels would be annoying:

const { defaultLayout, onLayoutChange } = useDefaultLayout({
  groupId: "my-group",
  panelIds: ["left", "right"]
});

<Group
  defaultLayout={defaultLayout}
  id="my-group"
  onLayoutChange={onLayoutChange}
>
  ...
</Group>

Injecting render blocking scripts isn't ideal. On the other hand, layout shift sucks and I'm not sure why you'd ever consider it an acceptable trade off in an SSR context. I can think of a few possible options here I guess.

  1. Export the "PersistScript" as you called it and let folks render it directly. I don't really like the ergonomics of this approach though.
  2. Build this into auto-saved Groups by default (what I tried in my branch). I'm not convinced the performance impact of this would actually be much different than the "PersistScript" approach, but I agree it's not ideal.
  3. Require auto-save groups to suspend to trying to load saved layouts (though this would also require users to save their own layouts, similar to the useDefaultLayout hook above).

I'm really feeling fairly torn about this.

@bvaughn
Copy link
Owner

bvaughn commented Nov 30, 2025

In some ways, I quite like separating persistence logic from rendering logic. I'm not sure how the proposal above (the new hook and defaultValue) could avoid layout shift though, (except for maybe Suspense?). Without injecting a blocking script tag, how would the proposed change actually using layoutStorage or sessionStorage without causing layout shift?

I guess I could punt on that and say: either use a sync storage API (like cookies) or use Suspense

Further more I guess if the storage continues to include enough metadata to pick out the most recently updated layout, then Group could also extract panel keys from that layout and use those keys for the initial CSS variables it renders.

If I limit the amount of data saved to only include the most recent layout, then it might be less likely to run into cookie size limits. It's probably mostly fine to not separately remember different combinations of panel layouts for any given group. 🤔

Seems like the sort of thing that probably cause frequent confusion and GitHub issue questions though. I would prefer a more out-of-the-box solution.

@bvaughn
Copy link
Owner

bvaughn commented Nov 30, 2025

Okay after a lot of back and forth, I'm now leaning toward this:
28a0778

Docs example here:
https://react-resizable-panels-git-v4-brian-vaughns-projects.vercel.app/examples/persistent-layout

@isBatak
Copy link
Author

isBatak commented Dec 1, 2025

I skimmed the changes, and I really like the direction this is going.
Now I just need to play with it and get my hands dirty to confirm that feeling.
Could I ask you to publish a v4 beta release on npm so I can try it out in my project and see how all these ideas fit together in a real project context?

@bvaughn
Copy link
Owner

bvaughn commented Dec 1, 2025

Lovely. Thanks for taking a look and sharing feedback!

Just published my branch to NPM as react-resizable-panels@4.0.0-alpha.0

@bvaughn
Copy link
Owner

bvaughn commented Dec 7, 2025

Going to go ahead and close this in favor of #528. Not sure how soon I'll release that (hopefully before the end of the year) but I'm pretty committed to the rewrite at this point.

Thanks for all of the feedback and testing you've done on that PR!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants