Skip to content

feat: use clientLoader + localStorage for resizable panel persistence#3386

Closed
devin-ai-integration[bot] wants to merge 4 commits intomainfrom
devin/1776258399-clientloader-panel-persistence
Closed

feat: use clientLoader + localStorage for resizable panel persistence#3386
devin-ai-integration[bot] wants to merge 4 commits intomainfrom
devin/1776258399-clientloader-panel-persistence

Conversation

@devin-ai-integration
Copy link
Copy Markdown
Contributor

Summary

Replaces server-side cookie reading (getResizableSnapshot) with client-side localStorage for persisting resizable panel sizes. Cookies were hitting the ~4KB size limit; localStorage supports 5-10MB.

Two routes modified, two approaches used:

  1. Run detail route (runs.$runParam/route.tsx): Uses Remix clientLoader with hydrate = true to read panel snapshots from localStorage before first render. A getLocalStorageSnapshot helper validates the stored JSON shape before returning it.

  2. Prompts route (prompts.$promptSlug/route.tsx): Simply removes server-side cookie reading and returns undefined for snapshots. The react-window-splitter library already defaults to autosaveStrategy="localStorage" and reads from it when no snapshot prop is provided. A clientLoader was not used here because this route uses remix-typedjson (typedjson/useTypedLoaderData), which has its own serialization layer that doesn't compose cleanly with clientLoader.

In both cases, the library continues to automatically write to localStorage on resize (via autosaveId), so no save-side changes were needed.

This PR is based on the Remix 2.1→2.17.4 upgrade branch (PR #3372), which is required because clientLoader was introduced in Remix 2.4.

Review & Testing Checklist for Human

  • Test panel persistence on the run detail page: Resize the parent/tree panels, reload the page, and verify sizes are restored from localStorage (check panel-run-parent-v2 and panel-run-tree keys in DevTools → Application → Local Storage)
  • Test panel persistence on the prompts page: Resize the outer/vertical/generations panels, reload, verify sizes restore (keys: prompt-detail, prompt-vertical, prompt-generations)
  • Verify no hydration errors in browser console on initial page load for both routes — the clientLoader.hydrate = true pattern on the run detail route is the highest-risk area
  • Check that getResizableSnapshot / resizablePanel.server is not needed elsewhere for these specific panel IDs — the server module still exists but is no longer imported by these two routes
  • Verify the prompts route still works with useTypedLoaderData — the resizable field now always starts as undefined from the server, so useTypedLoaderData should just pass it through

Notes

  • The getLocalStorageSnapshot helper (run detail route, ~line 302) validates stored data by checking for a "status" property. If the library changes its serialization format, this would silently fall back to default sizes rather than crash.
  • Old cookie data from the previous approach will remain in users' browsers but is harmless — it simply won't be read anymore.
  • The two different approaches (clientLoader vs. library-native) are intentional due to the remix-typedjson constraint, but could be unified later if remix-typedjson is removed.

Link to Devin session: https://app.devin.ai/sessions/d9fa9953b9bf40e5a8d12b8f5ba5b86b
Requested by: @ericallam

devin-ai-integration bot and others added 4 commits April 14, 2026 10:24
Upgraded packages:
- @remix-run/express: 2.1.0 → 2.17.4
- @remix-run/node: 2.1.0 → 2.17.4
- @remix-run/react: 2.1.0 → 2.17.4
- @remix-run/router: 1.15.3 → 1.23.2
- @remix-run/serve: 2.1.0 → 2.17.4
- @remix-run/server-runtime: 2.1.0 → 2.17.4
- @remix-run/dev: 2.1.0 → 2.17.4
- @remix-run/eslint-config: 2.1.0 → 2.17.4
- @remix-run/testing: 2.1.0 → 2.17.4

Also updated tar-fs override for new @remix-run/dev version.

Co-Authored-By: Eric Allam <eallam@icloud.com>
Co-Authored-By: Eric Allam <eallam@icloud.com>
Co-Authored-By: Eric Allam <eallam@icloud.com>
Replace server-side cookie reading with client-side localStorage for
persisting resizable panel sizes. This eliminates cookie size issues
while leveraging Remix 2.4+'s clientLoader feature.

Run detail route: Uses clientLoader.hydrate to read localStorage
snapshots on initial load, providing panel sizes immediately on
client-side navigations.

Prompts route: Removes server-side cookie reading and lets
react-window-splitter's built-in localStorage support handle
persistence (its default autosaveStrategy).

Co-Authored-By: Eric Allam <eallam@icloud.com>
@devin-ai-integration
Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Apr 15, 2026

⚠️ No Changeset found

Latest commit: 37f9fb6

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

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

@github-actions
Copy link
Copy Markdown
Contributor

Thanks for your contribution! We require all external PRs to be opened in draft status first so you can address CodeRabbit review comments and ensure CI passes before requesting a review. Please re-open this PR as a draft. See CONTRIBUTING.md for details.

@github-actions github-actions bot closed this Apr 15, 2026
Copy link
Copy Markdown
Contributor Author

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 2 potential issues.

View 1 additional finding in Devin Review.

Open in Devin Review

Comment on lines +298 to +300
outer: undefined as ResizableSnapshot | undefined,
vertical: undefined as ResizableSnapshot | undefined,
generations: undefined as ResizableSnapshot | undefined,
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.

🟡 Prompts route missing clientLoader — resizable panel snapshots are always undefined

The PR removes the server-side getResizableSnapshot calls from the prompts route and replaces them with undefined, but unlike the runs route (route.tsx:317-327), no clientLoader was added to restore the values from localStorage. This means resizable.outer, resizable.vertical, and resizable.generations will always be undefined on the prompts page, so users' saved panel sizes will never be restored on page load — a regression from the previous behavior where they were read from cookies.

Prompt for agents
The prompts route needs the same clientLoader + localStorage migration that was applied to the runs route. Specifically:

1. Import ClientLoaderFunctionArgs from @remix-run/react (around line 4).
2. Add a getLocalStorageSnapshot helper function (same as the one in the runs route at lines 302-315).
3. Add an exported clientLoader that calls serverLoader, then overrides the resizable field with localStorage lookups for the three keys: "prompt-detail", "prompt-vertical", and "prompt-generations" (these match the autosaveId values used in the ResizablePanelGroup components at lines 582, 589, and 1432).
4. Set clientLoader.hydrate = true as const.

See the runs route file apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx lines 302-327 for the exact pattern to follow.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

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.

This is intentional, not a regression. When the snapshot prop is undefined, the react-window-splitter library reads from localStorage directly — see the library source at PanelGroupImpl lines 300-313:

if (typeof window !== "undefined" && autosaveId && !snapshot && autosaveStrategy === "localStorage") {
  const localSnapshot = localStorage.getItem(autosaveId);
  if (localSnapshot) { setSnapshot(JSON.parse(localSnapshot)); }
}

Since autosaveStrategy defaults to "localStorage" and the component already has autosaveId set, returning undefined from the server lets the library's built-in localStorage persistence handle everything on the client.

A clientLoader was not used here because this route uses typedjson/useTypedLoaderData from remix-typedjson, which has its own serialization layer that doesn't compose cleanly with clientLoader (which routes data through useLoaderData).

},
};
}
clientLoader.hydrate = true as const;
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.

🚩 clientLoader.hydrate = true changes initial render behavior

With clientLoader.hydrate = true as const at route.tsx:327, Remix will not render the page until the clientLoader has run, even on initial page load. This means the server-rendered HTML will show a fallback/loading state until the client loader completes (which includes the full serverLoader() fetch). This is a deliberate trade-off: the resizable panels will always have their correct snapshot on first render, but the initial page load may feel slightly slower compared to the previous SSR approach where the cookie-based snapshot was available during server rendering. This is likely acceptable for a dashboard app but worth being aware of.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

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.

Good observation. One clarification: with clientLoader.hydrate = true, Remix still SSRs the page using the server loader data. On the client, it hydrates with that server data first, then runs clientLoader and re-renders with the localStorage snapshots. So the initial page load isn't blocked — the user sees the server-rendered page immediately, then panels adjust to saved sizes once clientLoader resolves (which is near-instant since localStorage.getItem is synchronous and serverLoader() reuses the already-fetched server data during hydration).

The net effect is: panels flash briefly with default sizes → saved sizes on first SSR load, but on subsequent client-side navigations the saved sizes are available immediately.

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.

1 participant