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"...@@ -15,7 +15,11 @@ import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"
15import { cn, isMacPlatform } from "../lib/utils";15import { cn, isMacPlatform } from "../lib/utils";
16import { primaryServerKeybindingsAtom } from "../state/server";16import { primaryServerKeybindingsAtom } from "../state/server";
17import { useEnvironmentIdentificationMode, useLegacySidebarEnabled } from "../hooks/useSettings";17import { useEnvironmentIdentificationMode, useLegacySidebarEnabled } from "../hooks/useSettings";
18import { usePanelAnimationSettings } from "../panelAnimations";18import {
19 PanelAnimationSuppressionProvider,
20 usePanelAnimationSettings,
21 usePanelNavigationSuppression,
22} from "../panelAnimations";
19import LegacyThreadSidebar from "./LegacySidebar";23import LegacyThreadSidebar from "./LegacySidebar";
20import ThreadSidebar from "./Sidebar";24import ThreadSidebar from "./Sidebar";
21import { SettingsSidebarNav } from "./settings/SettingsSidebarNav";25import { SettingsSidebarNav } from "./settings/SettingsSidebarNav";
...@@ -145,6 +149,8 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {...@@ -145,6 +149,8 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
145 // Settings routes show the settings nav in place of whichever thread149 // Settings routes show the settings nav in place of whichever thread
146 // sidebar is active.150 // sidebar is active.
147 const pathname = useLocation({ select: (location) => location.pathname });151 const pathname = useLocation({ select: (location) => location.pathname });
152 const panelAnimationsSuppressed = usePanelNavigationSuppression(pathname);
153 const routePanelAnimationsActive = panelAnimationsActive && !panelAnimationsSuppressed;
148 const isOnSettings = pathname === "/settings" || pathname.startsWith("/settings/");154 const isOnSettings = pathname === "/settings" || pathname.startsWith("/settings/");
149 const isMacosDesktop = isElectron && isMacPlatform(navigator.platform);155 const isMacosDesktop = isElectron && isMacPlatform(navigator.platform);
150 const [sidebarWidth, setSidebarWidth] = useState(readInitialThreadSidebarWidth);156 const [sidebarWidth, setSidebarWidth] = useState(readInitialThreadSidebarWidth);
...@@ -213,42 +219,44 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {...@@ -213,42 +219,44 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
213 }, [navigate, pathname]);219 }, [navigate, pathname]);
214220
215 return (221 return (
216 <SidebarProvider222 <PanelAnimationSuppressionProvider value={panelAnimationsSuppressed}>
217 className="h-dvh! min-h-0!"223 <SidebarProvider
218 data-panel-animations={panelAnimationsActive ? "true" : "false"}224 className="h-dvh! min-h-0!"
219 defaultOpen225 data-panel-animations={routePanelAnimationsActive ? "true" : "false"}
220 style={sidebarProviderStyle}226 defaultOpen
221 >227 style={sidebarProviderStyle}
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 }}
237 >228 >
238 {isOnSettings ? (229 <ProjectProjectionRetention />
239 <>230 <Sidebar
240 <SidebarChromeHeader isElectron={isElectron} />231 side="left"
241 <SettingsSidebarNav pathname={pathname} />232 collapsible="offcanvas"
242 </>233 data-app-sidebar=""
243 ) : legacySidebarEnabled ? (234 className="border-r border-sidebar-border bg-sidebar text-sidebar-foreground"
244 <LegacyThreadSidebar />235 resizable={{
245 ) : (236 maxWidth: sidebarMaximumWidth,
246 <ThreadSidebar />237 minWidth: THREAD_SIDEBAR_MIN_WIDTH,
247 )}238 shouldAcceptWidth: ({ currentWidth, nextWidth, wrapper }) =>
248 <SidebarRail onDoubleClick={resetSidebarWidth} />239 nextWidth <= currentWidth ||
249 </Sidebar>240 wrapper.clientWidth - nextWidth >= THREAD_MAIN_CONTENT_MIN_WIDTH,
250 {children}241 storageKey: THREAD_SIDEBAR_WIDTH_STORAGE_KEY,
251 <SidebarControl />242 onResize: setSidebarWidth,
252 </SidebarProvider>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>
253 );261 );
254}262}
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 @@...@@ -1,9 +1,36 @@
1import { useEffect, useRef, useState } from "react";1import { createContext, useContext, useEffect, useRef, useState } from "react";
2import { type PanelAnimationDurationMs } from "@t3tools/contracts/settings";2import { type PanelAnimationDurationMs } from "@t3tools/contracts/settings";
33
4import { useMediaQuery } from "./hooks/useMediaQuery";4import { useMediaQuery } from "./hooks/useMediaQuery";
5import { useClientSettings } from "./hooks/useSettings";5import { 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
7export function observeResponsiveBreakpointFade(options: {34export function observeResponsiveBreakpointFade(options: {
8 target: HTMLElement;35 target: HTMLElement;
9 container: HTMLElement;36 container: HTMLElement;
...@@ -47,7 +74,8 @@ export function usePanelAnimationSettings(): {...@@ -47,7 +74,8 @@ export function usePanelAnimationSettings(): {
47} {74} {
48 const durationMs = useClientSettings((settings) => settings.panelAnimationDurationMs);75 const durationMs = useClientSettings((settings) => settings.panelAnimationDurationMs);
49 const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)");76 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 };
51}79}
5280
53/** Keeps closing panel content mounted until its opt-in transition ends. */81/** Keeps closing panel content mounted until its opt-in transition ends. */