Skip to content

Commit e8531a3

Browse files
samejrclaude
andcommitted
fix(webapp): resizable inspector panel glitching/locking on Firefox
react-window-splitter drives its collapse animation through @react-spring/rafz, which has timing/interaction issues in Firefox that produced visual glitches (alternating frames, panels stuck at min, panelHasSpace invariant violations). Skip the animation on Firefox; it works correctly in Chromium and Safari. Extends the @window-splitter/state patch with three small fixes that surfaced while debugging: - Skip auto-collapse-on-drag for parent-controlled panels (avoids a state-machine deadlock when the parent has an empty handler). - Keep collapsible panels in pixel form on commit (avoids CSS minmax() flooring percent-typed tracks back up to min). - Fall back to the panel's default before its min when expanding a panel that has never previously been open, so the first span click opens the inspector at 500px instead of at min. On the run-view inspector: bumps min from 50px to 250px so dragging can't shrink the panel into a near-useless width, and bumps the panel group's autosaveId v2->v3 so existing users' persisted snapshots (which have the old 50px min baked in) are invalidated. Also reverts the speculative snapshot validator added earlier on this branch -- it didn't address the actual root cause and cost app-wide panel-size persistence. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f58d78f commit e8531a3

5 files changed

Lines changed: 112 additions & 106 deletions

File tree

.server-changes/fix-resizable-panel-stuck.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@ area: webapp
33
type: fix
44
---
55

6-
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.
6+
Fix the run-view inspector panel glitching out and locking up in Firefox. Disabled the underlying resizable library's collapse animation on Firefox (where its `requestAnimationFrame`-driven actor caused visual glitches and intermittent state-machine errors) while keeping it intact for Chromium and Safari, and bumped the inspector minimum from 50px to 250px so dragging can't shrink the panel into a near-useless width.

apps/webapp/app/components/primitives/Resizable.tsx

Lines changed: 17 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -4,85 +4,15 @@ import React, { useRef } from "react";
44
import { PanelGroup, Panel, PanelResizer } from "react-window-splitter";
55
import { cn } from "~/utils/cn";
66

7-
const ResizablePanelGroup = ({
8-
className,
9-
autosaveId,
10-
snapshot: snapshotProp,
11-
...props
12-
}: React.ComponentProps<typeof PanelGroup>) => {
13-
return (
14-
<PanelGroup
15-
className={cn(
16-
"flex w-full overflow-hidden data-[panel-group-direction=vertical]:flex-col",
17-
className
18-
)}
19-
autosaveId={autosaveId}
20-
snapshot={getSafeSnapshot(autosaveId, snapshotProp)}
21-
{...props}
22-
/>
23-
);
24-
};
25-
26-
// react-window-splitter reads the persisted snapshot from localStorage during
27-
// render and feeds it straight into prepareSnapshot + the state machine. If the
28-
// value is corrupt (extension interference, JSON parse failure) or in a shape
29-
// the library can't safely consume on restore — notably items committed with
30-
// percent-typed currentValues, which trip a `panelHasSpace only works with
31-
// number values` invariant on the next expand — the panel locks at min size
32-
// with no working drag.
33-
//
34-
// We read the snapshot ourselves with try/catch + structural validation. On
35-
// failure we pass `true` (the library's sentinel for "snapshot already
36-
// resolved") so it skips its own localStorage read and falls back to defaults.
37-
// Pure read — safe to call on every render. PanelGroup captures via useState
38-
// on first render, so later calls are wasted work but never wrong.
39-
function getSafeSnapshot(
40-
autosaveId: string | undefined,
41-
ssrSnapshot: React.ComponentProps<typeof PanelGroup>["snapshot"]
42-
) {
43-
if (typeof window === "undefined") return ssrSnapshot;
44-
if (ssrSnapshot && isValidSnapshot(ssrSnapshot)) return ssrSnapshot;
45-
if (!autosaveId) return undefined;
46-
47-
try {
48-
const raw = window.localStorage.getItem(autosaveId);
49-
if (!raw) return SNAPSHOT_RESOLVED;
50-
const parsed: unknown = JSON.parse(raw);
51-
if (!isValidSnapshot(parsed)) return SNAPSHOT_RESOLVED;
52-
return parsed as React.ComponentProps<typeof PanelGroup>["snapshot"];
53-
} catch {
54-
return SNAPSHOT_RESOLVED;
55-
}
56-
}
57-
58-
const SNAPSHOT_RESOLVED = true as unknown as React.ComponentProps<typeof PanelGroup>["snapshot"];
59-
60-
function isValidSnapshot(value: unknown): boolean {
61-
if (!value || typeof value !== "object") return false;
62-
const obj = value as Record<string, unknown>;
63-
if (!("status" in obj) || !("context" in obj)) return false;
64-
const ctx = obj.context as Record<string, unknown> | null;
65-
if (!ctx || typeof ctx !== "object" || !Array.isArray(ctx.items)) return false;
66-
67-
for (const item of ctx.items) {
68-
if (!item || typeof item !== "object") return false;
69-
const it = item as Record<string, unknown>;
70-
if (it.type !== "panel") continue;
71-
const cv = it.currentValue as Record<string, unknown> | null;
72-
if (!cv || typeof cv !== "object" || cv.type !== "pixel") return false;
73-
// value must parse as a finite number so prepareSnapshot's
74-
// `new Big(value)` rehydration can't throw — guards against strings
75-
// like "50%" or "" that satisfy typeof but break Big.
76-
if (!isFiniteNumeric(cv.value)) return false;
77-
}
78-
return true;
79-
}
80-
81-
function isFiniteNumeric(v: unknown): boolean {
82-
if (typeof v === "number") return Number.isFinite(v);
83-
if (typeof v === "string" && v.trim() !== "") return Number.isFinite(Number(v));
84-
return false;
85-
}
7+
const ResizablePanelGroup = ({ className, ...props }: React.ComponentProps<typeof PanelGroup>) => (
8+
<PanelGroup
9+
className={cn(
10+
"flex w-full overflow-hidden data-[panel-group-direction=vertical]:flex-col",
11+
className
12+
)}
13+
{...props}
14+
/>
15+
);
8616

8717
const ResizablePanel = Panel;
8818

@@ -139,10 +69,14 @@ const ResizableHandle = ({
13969
</PanelResizer>
14070
);
14171

142-
const RESIZABLE_PANEL_ANIMATION = {
143-
easing: "ease-in-out" as const,
144-
duration: 300,
145-
};
72+
// react-window-splitter drives the collapse animation through @react-spring/rafz,
73+
// which has timing/interaction issues with Firefox that produce visual glitches
74+
// (alternating frames, panels stuck at min, panelHasSpace invariant violations).
75+
// Disable the animation on Firefox; it works correctly in Chromium and Safari.
76+
const RESIZABLE_PANEL_ANIMATION =
77+
typeof navigator !== "undefined" && /firefox/i.test(navigator.userAgent)
78+
? undefined
79+
: ({ easing: "ease-in-out", duration: 300 } as const);
14680

14781
const COLLAPSIBLE_HANDLE_CLASSNAME = "transition-opacity duration-200";
14882

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ import { SpanView } from "../resources.orgs.$organizationSlug.projects.$projectP
115115

116116
const resizableSettings = {
117117
parent: {
118-
autosaveId: "panel-run-parent-v2",
118+
autosaveId: "panel-run-parent-v3",
119119
handleId: "parent-handle",
120120
main: {
121121
id: "run",
@@ -124,7 +124,7 @@ const resizableSettings = {
124124
inspector: {
125125
id: "inspector",
126126
default: "500px" as const,
127-
min: "50px" as const,
127+
min: "250px" as const,
128128
},
129129
},
130130
tree: {

patches/@window-splitter__state@0.4.1.patch

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
diff --git a/dist/commonjs/index.js b/dist/commonjs/index.js
2-
index acb542b1b71a7e808173d938d16f45a484334f94..dc9289461b761f8f0d5c72919f48f94e394addfd 100644
2+
index acb542b1b71a7e808173d938d16f45a484334f94..fd21cccca33c34fdd3563c279aefbd4bb54eef50 100644
33
--- a/dist/commonjs/index.js
44
+++ b/dist/commonjs/index.js
55
@@ -107,6 +107,9 @@ function prepareSnapshot(snapshot) {
@@ -12,8 +12,47 @@ index acb542b1b71a7e808173d938d16f45a484334f94..dc9289461b761f8f0d5c72919f48f94e
1212
}
1313
else {
1414
item.size.value = new big_js_1.default(item.size.value);
15+
@@ -648,8 +651,12 @@ function updateLayout(context, dragEvent) {
16+
}
17+
const panelBeforeIsAboutToCollapse = panelBefore.currentValue.value.eq(getUnitPixelValue(context, panelBefore.min));
18+
// If the panel was expanded and now is at it's min size, collapse it
19+
+ // Skip auto-collapse-on-drag for controlled panels: the parent owns the
20+
+ // `collapsed` prop and an empty/no-op handler would otherwise leave the
21+
+ // state machine wedged with accumulating dragOvershoot.
22+
if (!dragEvent.disregardCollapseBuffer &&
23+
panelBefore.collapsible &&
24+
+ !panelBefore.collapseIsControlled &&
25+
panelBeforeIsAboutToCollapse) {
26+
if (panelBefore.onCollapseChange?.current &&
27+
panelBefore.collapseIsControlled &&
28+
@@ -693,7 +700,10 @@ function commitLayout(context) {
29+
});
30+
const staticWidth = getStaticWidth({ ...context, items: newItems });
31+
newItems.forEach((item, index) => {
32+
- if (item.type !== "panel" || item.collapsed || item.isStaticAtRest) {
33+
+ // Skip collapsible panels: they need to stay in pixel form so a fully
34+
+ // collapsed (0px) state doesn't get floored back up to `min` by the
35+
+ // CSS minmax() that buildTemplate emits for percent-typed panels.
36+
+ if (item.type !== "panel" || item.collapsed || item.isStaticAtRest || item.collapsible) {
37+
return;
38+
}
39+
newItems[index] = {
40+
@@ -821,7 +831,12 @@ function clearLastKnownSize(items) {
41+
function getDeltaForEvent(context, event) {
42+
const panel = getPanelWithId(context, event.panelId);
43+
if (event.type === "expandPanel") {
44+
- return new big_js_1.default(panel.sizeBeforeCollapse ?? getUnitPixelValue(context, panel.min)).minus(panel.currentValue.value);
45+
+ // Fall back to `default` before `min` so the first-ever expand of a
46+
+ // panel that started life collapsed lands at its configured default
47+
+ // size rather than getting stuck at `min`.
48+
+ const defaultPx = panel.default ? getUnitPixelValue(context, panel.default) : undefined;
49+
+ const target = panel.sizeBeforeCollapse ?? defaultPx ?? getUnitPixelValue(context, panel.min);
50+
+ return new big_js_1.default(target).minus(panel.currentValue.value);
51+
}
52+
const collapsedSize = getUnitPixelValue(context, panel.collapsedSize);
53+
return panel.currentValue.value.minus(collapsedSize);
1554
diff --git a/dist/esm/index.js b/dist/esm/index.js
16-
index 8891ac0141135a3a885bd704d9d443458c7a01bf..34cd7251f2298e7f9bfedfe4cadb797aa790b59a 100644
55+
index 8891ac0141135a3a885bd704d9d443458c7a01bf..80032b5a852c0117959808cff58b4cbf2b2eee11 100644
1756
--- a/dist/esm/index.js
1857
+++ b/dist/esm/index.js
1958
@@ -81,6 +81,9 @@ export function prepareSnapshot(snapshot) {
@@ -26,3 +65,42 @@ index 8891ac0141135a3a885bd704d9d443458c7a01bf..34cd7251f2298e7f9bfedfe4cadb797a
2665
}
2766
else {
2867
item.size.value = new Big(item.size.value);
68+
@@ -622,8 +625,12 @@ function updateLayout(context, dragEvent) {
69+
}
70+
const panelBeforeIsAboutToCollapse = panelBefore.currentValue.value.eq(getUnitPixelValue(context, panelBefore.min));
71+
// If the panel was expanded and now is at it's min size, collapse it
72+
+ // Skip auto-collapse-on-drag for controlled panels: the parent owns the
73+
+ // `collapsed` prop and an empty/no-op handler would otherwise leave the
74+
+ // state machine wedged with accumulating dragOvershoot.
75+
if (!dragEvent.disregardCollapseBuffer &&
76+
panelBefore.collapsible &&
77+
+ !panelBefore.collapseIsControlled &&
78+
panelBeforeIsAboutToCollapse) {
79+
if (panelBefore.onCollapseChange?.current &&
80+
panelBefore.collapseIsControlled &&
81+
@@ -667,7 +674,10 @@ function commitLayout(context) {
82+
});
83+
const staticWidth = getStaticWidth({ ...context, items: newItems });
84+
newItems.forEach((item, index) => {
85+
- if (item.type !== "panel" || item.collapsed || item.isStaticAtRest) {
86+
+ // Skip collapsible panels: they need to stay in pixel form so a fully
87+
+ // collapsed (0px) state doesn't get floored back up to `min` by the
88+
+ // CSS minmax() that buildTemplate emits for percent-typed panels.
89+
+ if (item.type !== "panel" || item.collapsed || item.isStaticAtRest || item.collapsible) {
90+
return;
91+
}
92+
newItems[index] = {
93+
@@ -795,7 +805,12 @@ function clearLastKnownSize(items) {
94+
function getDeltaForEvent(context, event) {
95+
const panel = getPanelWithId(context, event.panelId);
96+
if (event.type === "expandPanel") {
97+
- return new Big(panel.sizeBeforeCollapse ?? getUnitPixelValue(context, panel.min)).minus(panel.currentValue.value);
98+
+ // Fall back to `default` before `min` so the first-ever expand of a
99+
+ // panel that started life collapsed lands at its configured default
100+
+ // size rather than getting stuck at `min`.
101+
+ const defaultPx = panel.default ? getUnitPixelValue(context, panel.default) : undefined;
102+
+ const target = panel.sizeBeforeCollapse ?? defaultPx ?? getUnitPixelValue(context, panel.min);
103+
+ return new Big(target).minus(panel.currentValue.value);
104+
}
105+
const collapsedSize = getUnitPixelValue(context, panel.collapsedSize);
106+
return panel.currentValue.value.minus(collapsedSize);

pnpm-lock.yaml

Lines changed: 12 additions & 18 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)