From 477db49ed051515b5991cafcbda3d550676fce1f Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 8 May 2026 18:08:42 +0100 Subject: [PATCH 1/3] Fix for resizable side panel getting stuck at its min-size --- .server-changes/fix-resizable-panel-stuck.md | 6 ++ .../app/components/primitives/Resizable.tsx | 83 ++++++++++++++++--- 2 files changed, 79 insertions(+), 10 deletions(-) create mode 100644 .server-changes/fix-resizable-panel-stuck.md diff --git a/.server-changes/fix-resizable-panel-stuck.md b/.server-changes/fix-resizable-panel-stuck.md new file mode 100644 index 00000000000..a47e4096568 --- /dev/null +++ b/.server-changes/fix-resizable-panel-stuck.md @@ -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. diff --git a/apps/webapp/app/components/primitives/Resizable.tsx b/apps/webapp/app/components/primitives/Resizable.tsx index 2efaae4258e..b87c5a99e2a 100644 --- a/apps/webapp/app/components/primitives/Resizable.tsx +++ b/apps/webapp/app/components/primitives/Resizable.tsx @@ -4,15 +4,78 @@ import React, { useRef } from "react"; import { PanelGroup, Panel, PanelResizer } from "react-window-splitter"; import { cn } from "~/utils/cn"; -const ResizablePanelGroup = ({ className, ...props }: React.ComponentProps) => ( - -); +const ResizablePanelGroup = ({ + className, + autosaveId, + snapshot: snapshotProp, + ...props +}: React.ComponentProps) => { + return ( + + ); +}; + +// 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["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["snapshot"]; + } catch { + return SNAPSHOT_RESOLVED; + } +} + +const SNAPSHOT_RESOLVED = true as unknown as React.ComponentProps["snapshot"]; + +function isValidSnapshot(value: unknown): boolean { + if (!value || typeof value !== "object") return false; + const obj = value as Record; + if (!("status" in obj) || !("context" in obj)) return false; + const ctx = obj.context as Record | 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; + if (it.type !== "panel") continue; + const cv = it.currentValue as Record | null; + if (!cv || typeof cv !== "object" || cv.type !== "pixel") return false; + // value must be numeric (number or numeric string) so prepareSnapshot's + // `new Big(value)` rehydration can't throw on us. + if (typeof cv.value !== "string" && typeof cv.value !== "number") return false; + } + return true; +} const ResizablePanel = Panel; @@ -71,7 +134,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"; From e04c7bcdeb0e9a1b9ab2531f3577e371cb4cf4e1 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 8 May 2026 22:01:10 +0100 Subject: [PATCH 2/3] Code rabbit fixes --- apps/webapp/app/components/primitives/Resizable.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/webapp/app/components/primitives/Resizable.tsx b/apps/webapp/app/components/primitives/Resizable.tsx index b87c5a99e2a..d62f7eb3d5b 100644 --- a/apps/webapp/app/components/primitives/Resizable.tsx +++ b/apps/webapp/app/components/primitives/Resizable.tsx @@ -70,13 +70,20 @@ function isValidSnapshot(value: unknown): boolean { if (it.type !== "panel") continue; const cv = it.currentValue as Record | null; if (!cv || typeof cv !== "object" || cv.type !== "pixel") return false; - // value must be numeric (number or numeric string) so prepareSnapshot's - // `new Big(value)` rehydration can't throw on us. - if (typeof cv.value !== "string" && typeof cv.value !== "number") 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; } +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; const ResizableHandle = ({ From f58d78f9f715da5550574d9fc3418e8127fe91fd Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 8 May 2026 22:09:56 +0100 Subject: [PATCH 3/3] Defensive snapshot validation for resizable panels --- apps/webapp/package.json | 2 +- pnpm-lock.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/webapp/package.json b/apps/webapp/package.json index 5b2725288dd..28f6294086d 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b7c70806a1d..37648235a63 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -756,7 +756,7 @@ importers: specifier: 17.5.1 version: 17.5.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) react-window-splitter: - specifier: ^0.4.1 + specifier: 0.4.1 version: 0.4.1(@types/react@18.2.69)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) recharts: specifier: ^2.15.2