Skip to content

Commit dff7004

Browse files
fix: resolve React error #310 hydration mismatch in AnimatedSidepanelContainer
Refactor AnimatedSidepanelContainer to use a single render path to fix hydration mismatch: - Remove conditional returns based on mounted and isMobile states - Always render both desktop (PanelGroup) and mobile (bottom sheet) structures - Use CSS classes (hidden md:flex and md:hidden) for responsive behavior - Remove isMobile state and useLayoutEffect that set it - Simplify setLayout effect to only check mounted, not isMobile This ensures the server and client always render the same tree structure, preventing React error #310 (hydration mismatch) that was occurring on the preview deployment. Co-Authored-By: Kapil Gowru <[email protected]>
1 parent e8cff6e commit dff7004

File tree

1 file changed

+77
-104
lines changed

1 file changed

+77
-104
lines changed

packages/fern-dashboard/src/components/layout/AnimatedSidepanelContainer.tsx

Lines changed: 77 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -19,130 +19,103 @@ export function AnimatedSidepanelContainer({
1919
const panelNode = content ?? sidepanel;
2020

2121
const [mounted, setMounted] = React.useState(false);
22-
const [isMobile, setIsMobile] = React.useState<boolean>(false);
2322
const groupRef = React.useRef<ImperativePanelGroupHandle>(null);
2423

2524
React.useEffect(() => {
2625
setMounted(true);
2726
}, []);
2827

29-
React.useLayoutEffect(() => {
30-
const mql = window.matchMedia("(max-width: 1023px)");
31-
const onChange = (e: MediaQueryListEvent) => {
32-
setIsMobile(e.matches);
33-
};
34-
setIsMobile(mql.matches);
35-
if (typeof mql.addEventListener === "function") {
36-
mql.addEventListener("change", onChange);
37-
return () => {
38-
mql.removeEventListener("change", onChange);
39-
};
40-
} else if (typeof (mql as any).addListener === "function") {
41-
(mql as any).addListener(onChange);
42-
return () => (mql as any).removeListener(onChange);
43-
}
44-
return () => {
45-
void mql.matches;
46-
};
47-
}, []);
48-
4928
React.useEffect(() => {
50-
if (!mounted || isMobile) return;
29+
if (!mounted) return;
5130

5231
if (groupRef.current) {
5332
groupRef.current.setLayout(panelOpen ? [70, 30] : [100, 0]);
5433
}
55-
}, [panelOpen, mounted, isMobile]);
56-
57-
if (!mounted) {
58-
return <div className="flex min-w-0 flex-1 md:pr-2">{children}</div>;
59-
}
34+
}, [panelOpen, mounted]);
6035

61-
if (isMobile) {
62-
return (
63-
<>
64-
{children}
65-
<div
66-
aria-hidden
67-
className={cn(
68-
"fixed inset-0 z-[80] bg-black/10 backdrop-blur-sm transition-opacity duration-200",
69-
panelOpen ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"
70-
)}
71-
onClick={() => {
72-
clear();
73-
}}
74-
/>
75-
<div
76-
role="dialog"
77-
aria-modal="true"
36+
return (
37+
<>
38+
{/* Desktop layout: resizable panels (hidden on mobile) */}
39+
<PanelGroup ref={groupRef} direction="horizontal" className="hidden md:flex md:flex-1">
40+
<Panel defaultSize={100} minSize={30} className="h-full overflow-hidden">
41+
<div className="flex min-w-0 flex-1 h-full">{children}</div>
42+
</Panel>
43+
<PanelResizeHandle
7844
className={cn(
79-
"fixed inset-x-0 bottom-0 z-[90] origin-bottom rounded-t-2xl bg-[var(--gray-100)] shadow-2xl transition-transform duration-300 ease-out",
80-
"max-h-[80vh] w-full",
81-
panelOpen ? "translate-y-0" : "translate-y-full"
45+
"group relative w-4 bg-transparent transition-opacity duration-200",
46+
!panelOpen && "pointer-events-none opacity-0"
8247
)}
8348
>
84-
<div className="h-full max-h-[calc(80vh-0.5rem)] overflow-y-auto p-4">
85-
<div className="w-full">{panelNode}</div>
49+
<div className="absolute inset-y-0 left-1 flex flex-col items-center justify-center">
50+
<div
51+
className="absolute top-6 bottom-0 w-px transition-colors duration-200"
52+
style={{
53+
background: "transparent"
54+
}}
55+
/>
56+
<div
57+
className="absolute top-6 bottom-0 w-px opacity-0 transition-opacity duration-200 group-hover:opacity-100 group-data-[resize-handle-active]:opacity-100"
58+
style={{
59+
background:
60+
"linear-gradient(to bottom, transparent 0%, var(--primary) 64px, var(--primary) 100%)"
61+
}}
62+
/>
63+
<div
64+
className="relative z-10 h-6 w-2 rounded-full border transition-colors duration-200"
65+
style={{
66+
background: "var(--sidebar)",
67+
borderColor: "var(--gray-500)"
68+
}}
69+
/>
70+
<div
71+
className="absolute z-10 h-6 w-2 rounded-full border opacity-0 transition-opacity duration-200 group-hover:opacity-100 group-data-[resize-handle-active]:opacity-100"
72+
style={{
73+
background: "var(--primary)",
74+
borderColor: "var(--primary)"
75+
}}
76+
/>
8677
</div>
87-
</div>
88-
</>
89-
);
90-
}
78+
</PanelResizeHandle>
79+
<Panel
80+
defaultSize={0}
81+
minSize={0}
82+
maxSize={50}
83+
className={cn("sidepanel-container", panelOpen && "pr-2")}
84+
style={{ minWidth: panelOpen ? "320px" : "0px" }}
85+
>
86+
<div className="h-full">
87+
<div className="h-full w-full overflow-y-auto bg-[var(--gray-100)] transition-all duration-500 ease-out md:rounded-t-2xl">
88+
{panelNode}
89+
</div>
90+
</div>
91+
</Panel>
92+
</PanelGroup>
9193

92-
return (
93-
<PanelGroup ref={groupRef} direction="horizontal" className="flex-1">
94-
<Panel defaultSize={100} minSize={30} className="h-full overflow-hidden">
95-
<div className="flex min-w-0 flex-1 h-full">{children}</div>
96-
</Panel>
97-
<PanelResizeHandle
94+
{/* Mobile layout: bottom sheet (hidden on desktop) */}
95+
<div className="flex md:hidden flex-1">{children}</div>
96+
<div
97+
aria-hidden
9898
className={cn(
99-
"group relative w-4 bg-transparent transition-opacity duration-200",
100-
!panelOpen && "pointer-events-none opacity-0"
99+
"md:hidden fixed inset-0 z-[80] bg-black/10 backdrop-blur-sm transition-opacity duration-200",
100+
panelOpen ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"
101+
)}
102+
onClick={() => {
103+
clear();
104+
}}
105+
/>
106+
<div
107+
role="dialog"
108+
aria-modal="true"
109+
className={cn(
110+
"md:hidden fixed inset-x-0 bottom-0 z-[90] origin-bottom rounded-t-2xl bg-[var(--gray-100)] shadow-2xl transition-transform duration-300 ease-out",
111+
"max-h-[80vh] w-full",
112+
panelOpen ? "translate-y-0" : "translate-y-full"
101113
)}
102114
>
103-
<div className="absolute inset-y-0 left-1 flex flex-col items-center justify-center">
104-
<div
105-
className="absolute top-6 bottom-0 w-px transition-colors duration-200"
106-
style={{
107-
background: "transparent"
108-
}}
109-
/>
110-
<div
111-
className="absolute top-6 bottom-0 w-px opacity-0 transition-opacity duration-200 group-hover:opacity-100 group-data-[resize-handle-active]:opacity-100"
112-
style={{
113-
background:
114-
"linear-gradient(to bottom, transparent 0%, var(--primary) 64px, var(--primary) 100%)"
115-
}}
116-
/>
117-
<div
118-
className="relative z-10 h-6 w-2 rounded-full border transition-colors duration-200"
119-
style={{
120-
background: "var(--sidebar)",
121-
borderColor: "var(--gray-500)"
122-
}}
123-
/>
124-
<div
125-
className="absolute z-10 h-6 w-2 rounded-full border opacity-0 transition-opacity duration-200 group-hover:opacity-100 group-data-[resize-handle-active]:opacity-100"
126-
style={{
127-
background: "var(--primary)",
128-
borderColor: "var(--primary)"
129-
}}
130-
/>
131-
</div>
132-
</PanelResizeHandle>
133-
<Panel
134-
defaultSize={0}
135-
minSize={0}
136-
maxSize={50}
137-
className={cn("sidepanel-container", panelOpen && "pr-2")}
138-
style={{ minWidth: panelOpen ? "320px" : "0px" }}
139-
>
140-
<div className="h-full">
141-
<div className="h-full w-full overflow-y-auto bg-[var(--gray-100)] transition-all duration-500 ease-out md:rounded-t-2xl">
142-
{panelNode}
143-
</div>
115+
<div className="h-full max-h-[calc(80vh-0.5rem)] overflow-y-auto p-4">
116+
<div className="w-full">{panelNode}</div>
144117
</div>
145-
</Panel>
146-
</PanelGroup>
118+
</div>
119+
</>
147120
);
148121
}

0 commit comments

Comments
 (0)