Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .server-changes/fix-resizable-panel-stuck.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: fix
---

Fix the run-view inspector panel locking at minimum width on reload when the persisted layout snapshot is in a state the underlying library can't safely restore.
90 changes: 80 additions & 10 deletions apps/webapp/app/components/primitives/Resizable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,85 @@ import React, { useRef } from "react";
import { PanelGroup, Panel, PanelResizer } from "react-window-splitter";
import { cn } from "~/utils/cn";

const ResizablePanelGroup = ({ className, ...props }: React.ComponentProps<typeof PanelGroup>) => (
<PanelGroup
className={cn(
"flex w-full overflow-hidden data-[panel-group-direction=vertical]:flex-col",
className
)}
{...props}
/>
);
const ResizablePanelGroup = ({
className,
autosaveId,
snapshot: snapshotProp,
...props
}: React.ComponentProps<typeof PanelGroup>) => {
return (
<PanelGroup
className={cn(
"flex w-full overflow-hidden data-[panel-group-direction=vertical]:flex-col",
className
)}
autosaveId={autosaveId}
snapshot={getSafeSnapshot(autosaveId, snapshotProp)}
{...props}
/>
);
};

// react-window-splitter reads the persisted snapshot from localStorage during
// render and feeds it straight into prepareSnapshot + the state machine. If the
// value is corrupt (extension interference, JSON parse failure) or in a shape
// the library can't safely consume on restore — notably items committed with
// percent-typed currentValues, which trip a `panelHasSpace only works with
// number values` invariant on the next expand — the panel locks at min size
// with no working drag.
//
// We read the snapshot ourselves with try/catch + structural validation. On
// failure we pass `true` (the library's sentinel for "snapshot already
// resolved") so it skips its own localStorage read and falls back to defaults.
// Pure read — safe to call on every render. PanelGroup captures via useState
// on first render, so later calls are wasted work but never wrong.
function getSafeSnapshot(
autosaveId: string | undefined,
ssrSnapshot: React.ComponentProps<typeof PanelGroup>["snapshot"]
) {
if (typeof window === "undefined") return ssrSnapshot;
if (ssrSnapshot && isValidSnapshot(ssrSnapshot)) return ssrSnapshot;
if (!autosaveId) return undefined;

try {
const raw = window.localStorage.getItem(autosaveId);
if (!raw) return SNAPSHOT_RESOLVED;
const parsed: unknown = JSON.parse(raw);
if (!isValidSnapshot(parsed)) return SNAPSHOT_RESOLVED;
return parsed as React.ComponentProps<typeof PanelGroup>["snapshot"];
} catch {
return SNAPSHOT_RESOLVED;
}
}

const SNAPSHOT_RESOLVED = true as unknown as React.ComponentProps<typeof PanelGroup>["snapshot"];
Comment thread
samejr marked this conversation as resolved.

function isValidSnapshot(value: unknown): boolean {
if (!value || typeof value !== "object") return false;
const obj = value as Record<string, unknown>;
if (!("status" in obj) || !("context" in obj)) return false;
const ctx = obj.context as Record<string, unknown> | null;
if (!ctx || typeof ctx !== "object" || !Array.isArray(ctx.items)) return false;

for (const item of ctx.items) {
if (!item || typeof item !== "object") return false;
const it = item as Record<string, unknown>;
if (it.type !== "panel") continue;
const cv = it.currentValue as Record<string, unknown> | null;
if (!cv || typeof cv !== "object" || cv.type !== "pixel") return false;
// value must parse as a finite number so prepareSnapshot's
// `new Big(value)` rehydration can't throw — guards against strings
// like "50%" or "" that satisfy typeof but break Big.
if (!isFiniteNumeric(cv.value)) return false;
}
return true;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

function isFiniteNumeric(v: unknown): boolean {
if (typeof v === "number") return Number.isFinite(v);
if (typeof v === "string" && v.trim() !== "") return Number.isFinite(Number(v));
return false;
}

const ResizablePanel = Panel;

Expand Down Expand Up @@ -71,7 +141,7 @@ const ResizableHandle = ({

const RESIZABLE_PANEL_ANIMATION = {
easing: "ease-in-out" as const,
duration: 200,
duration: 300,
};

const COLLAPSIBLE_HANDLE_CLASSNAME = "transition-opacity duration-200";
Expand Down
2 changes: 1 addition & 1 deletion apps/webapp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@
"react-resizable-panels": "^2.0.9",
"react-stately": "^3.29.1",
"react-use": "17.5.1",
"react-window-splitter": "^0.4.1",
"react-window-splitter": "0.4.1",
"recharts": "^2.15.2",
"regression": "^2.0.1",
"remix-auth": "^3.6.0",
Expand Down
2 changes: 1 addition & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading