authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-04 12:49:52-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 12:49:52-07:00
logcd713679bbe06c435823bafc48e5bcfa3266a9aa
tree359824ebfeac38965bc747ac476fc35d9160db1e
parent9e1fb459a985d13c1d4e57080f49d70dabf3b681
signature Signed by PGP key B5690EEEBB952194

fix(web): stop panel motion during navigation (#9766)


3 files changed, 139 insertions(+), 39 deletions(-)

apps/web/src/components/AppSidebarLayout.tsx+45-37
......@@ -15,7 +15,11 @@ import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"
1515import { cn, isMacPlatform } from "../lib/utils";
1616import { primaryServerKeybindingsAtom } from "../state/server";
1717import { useEnvironmentIdentificationMode, useLegacySidebarEnabled } from "../hooks/useSettings";
18import { usePanelAnimationSettings } from "../panelAnimations";
18import {
19 PanelAnimationSuppressionProvider,
20 usePanelAnimationSettings,
21 usePanelNavigationSuppression,
22} from "../panelAnimations";
1923import LegacyThreadSidebar from "./LegacySidebar";
2024import ThreadSidebar from "./Sidebar";
2125import { SettingsSidebarNav } from "./settings/SettingsSidebarNav";
......@@ -145,6 +149,8 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
145149 // Settings routes show the settings nav in place of whichever thread
146150 // sidebar is active.
147151 const pathname = useLocation({ select: (location) => location.pathname });
152 const panelAnimationsSuppressed = usePanelNavigationSuppression(pathname);
153 const routePanelAnimationsActive = panelAnimationsActive && !panelAnimationsSuppressed;
148154 const isOnSettings = pathname === "/settings" || pathname.startsWith("/settings/");
149155 const isMacosDesktop = isElectron && isMacPlatform(navigator.platform);
150156 const [sidebarWidth, setSidebarWidth] = useState(readInitialThreadSidebarWidth);
......@@ -213,42 +219,44 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
213219 }, [navigate, pathname]);
214220
215221 return (
216 <SidebarProvider
217 className="h-dvh! min-h-0!"
218 data-panel-animations={panelAnimationsActive ? "true" : "false"}
219 defaultOpen
220 style={sidebarProviderStyle}
221 >
222 <ProjectProjectionRetention />
223 <Sidebar
224 side="left"
225 collapsible="offcanvas"
226 data-app-sidebar=""
227 className="border-r border-sidebar-border bg-sidebar text-sidebar-foreground"
228 resizable={{
229 maxWidth: sidebarMaximumWidth,
230 minWidth: THREAD_SIDEBAR_MIN_WIDTH,
231 shouldAcceptWidth: ({ currentWidth, nextWidth, wrapper }) =>
232 nextWidth <= currentWidth ||
233 wrapper.clientWidth - nextWidth >= THREAD_MAIN_CONTENT_MIN_WIDTH,
234 storageKey: THREAD_SIDEBAR_WIDTH_STORAGE_KEY,
235 onResize: setSidebarWidth,
236 }}
222 <PanelAnimationSuppressionProvider value={panelAnimationsSuppressed}>
223 <SidebarProvider
224 className="h-dvh! min-h-0!"
225 data-panel-animations={routePanelAnimationsActive ? "true" : "false"}
226 defaultOpen
227 style={sidebarProviderStyle}
237228 >
238 {isOnSettings ? (
239 <>
240 <SidebarChromeHeader isElectron={isElectron} />
241 <SettingsSidebarNav pathname={pathname} />
242 </>
243 ) : legacySidebarEnabled ? (
244 <LegacyThreadSidebar />
245 ) : (
246 <ThreadSidebar />
247 )}
248 <SidebarRail onDoubleClick={resetSidebarWidth} />
249 </Sidebar>
250 {children}
251 <SidebarControl />
252 </SidebarProvider>
229 <ProjectProjectionRetention />
230 <Sidebar
231 side="left"
232 collapsible="offcanvas"
233 data-app-sidebar=""
234 className="border-r border-sidebar-border bg-sidebar text-sidebar-foreground"
235 resizable={{
236 maxWidth: sidebarMaximumWidth,
237 minWidth: THREAD_SIDEBAR_MIN_WIDTH,
238 shouldAcceptWidth: ({ currentWidth, nextWidth, wrapper }) =>
239 nextWidth <= currentWidth ||
240 wrapper.clientWidth - nextWidth >= THREAD_MAIN_CONTENT_MIN_WIDTH,
241 storageKey: THREAD_SIDEBAR_WIDTH_STORAGE_KEY,
242 onResize: setSidebarWidth,
243 }}
244 >
245 {isOnSettings ? (
246 <>
247 <SidebarChromeHeader isElectron={isElectron} />
248 <SettingsSidebarNav pathname={pathname} />
249 </>
250 ) : legacySidebarEnabled ? (
251 <LegacyThreadSidebar />
252 ) : (
253 <ThreadSidebar />
254 )}
255 <SidebarRail onDoubleClick={resetSidebarWidth} />
256 </Sidebar>
257 {children}
258 <SidebarControl />
259 </SidebarProvider>
260 </PanelAnimationSuppressionProvider>
253261 );
254262}
apps/web/src/panelAnimations.test.tsx created+64
......@@ -0,0 +1,64 @@
1import { act, useLayoutEffect } from "react";
2import { create, type ReactTestRenderer } from "react-test-renderer";
3import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
4
5import { usePanelNavigationSuppression } from "./panelAnimations";
6
7let renderer: ReactTestRenderer | null = null;
8let pendingFrames: FrameRequestCallback[] = [];
9let observed: boolean[] = [];
10
11function SuppressionProbe({ navigationKey }: { navigationKey: string }) {
12 const suppressed = usePanelNavigationSuppression(navigationKey);
13 useLayoutEffect(() => {
14 observed.push(suppressed);
15 }, [suppressed]);
16 return null;
17}
18
19beforeEach(() => {
20 pendingFrames = [];
21 observed = [];
22 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
23 vi.stubGlobal("window", {
24 requestAnimationFrame: vi.fn((callback: FrameRequestCallback) => {
25 pendingFrames.push(callback);
26 return pendingFrames.length;
27 }),
28 cancelAnimationFrame: vi.fn(),
29 });
30});
31
32afterEach(async () => {
33 await act(() => renderer?.unmount());
34 vi.unstubAllGlobals();
35});
36
37async function paintPendingFrame() {
38 const callback = pendingFrames.shift();
39 await act(() => callback?.(0));
40}
41
42describe("usePanelNavigationSuppression", () => {
43 it("suppresses initial and navigated panel state until each route has painted", async () => {
44 await act(() => {
45 renderer = create(<SuppressionProbe navigationKey="/thread/one" />);
46 });
47 expect(observed.at(-1)).toBe(true);
48
49 await paintPendingFrame();
50 expect(observed.at(-1)).toBe(true);
51 await paintPendingFrame();
52 expect(observed.at(-1)).toBe(false);
53
54 await act(() => {
55 renderer?.update(<SuppressionProbe navigationKey="/thread/two" />);
56 });
57 expect(observed.at(-1)).toBe(true);
58
59 await paintPendingFrame();
60 expect(observed.at(-1)).toBe(true);
61 await paintPendingFrame();
62 expect(observed.at(-1)).toBe(false);
63 });
64});
apps/web/src/panelAnimations.ts+30-2
......@@ -1,9 +1,36 @@
1import { useEffect, useRef, useState } from "react";
1import { createContext, useContext, useEffect, useRef, useState } from "react";
22import { type PanelAnimationDurationMs } from "@t3tools/contracts/settings";
33
44import { useMediaQuery } from "./hooks/useMediaQuery";
55import { useClientSettings } from "./hooks/useSettings";
66
7const PanelAnimationSuppressionContext = createContext(false);
8
9export const PanelAnimationSuppressionProvider = PanelAnimationSuppressionContext.Provider;
10
11/**
12 * Suppresses panel motion for the first painted frame of an initial route or navigation.
13 * State restored by a route must be visible immediately; later user actions can animate.
14 */
15export function usePanelNavigationSuppression(navigationKey: string): boolean {
16 const [paintedNavigationKey, setPaintedNavigationKey] = useState<string | null>(null);
17 const suppressed = paintedNavigationKey !== navigationKey;
18
19 useEffect(() => {
20 if (!suppressed) return;
21 let releaseFrame = 0;
22 const paintFrame = window.requestAnimationFrame(() => {
23 releaseFrame = window.requestAnimationFrame(() => setPaintedNavigationKey(navigationKey));
24 });
25 return () => {
26 window.cancelAnimationFrame(paintFrame);
27 window.cancelAnimationFrame(releaseFrame);
28 };
29 }, [navigationKey, suppressed]);
30
31 return suppressed;
32}
33
734export function observeResponsiveBreakpointFade(options: {
835 target: HTMLElement;
936 container: HTMLElement;
......@@ -47,7 +74,8 @@ export function usePanelAnimationSettings(): {
4774} {
4875 const durationMs = useClientSettings((settings) => settings.panelAnimationDurationMs);
4976 const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)");
50 return { active: durationMs > 0 && !prefersReducedMotion, durationMs };
77 const suppressed = useContext(PanelAnimationSuppressionContext);
78 return { active: durationMs > 0 && !prefersReducedMotion && !suppressed, durationMs };
5179}
5280
5381/** Keeps closing panel content mounted until its opt-in transition ends. */