authorgravatar for 97427849+flamboh@users.noreply.github.comoliver <97427849+flamboh@users.noreply.github.com> 2026-10-10 05:22:18-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-10 07:22:18-05:00
log8bf0a38873aed486a585b2cebd8dd577ae5656b3
tree74e7818d2d38f25df7399862926b553ab45fa479
parentdd4549eede928f9dc514778aeae15d74656a87f8
signature Signed by PGP key B5690EEEBB952194

perf(web): switching project keeps Diagnostics and Providers mounted (#17122)


3 files changed, 142 insertions(+), 9 deletions(-)

apps/web/src/components/settings/SettingsScopeSentence.tsx+8-4
......@@ -152,7 +152,7 @@ function EnvironmentScopeMenu({
152152 >
153153 {!singleEnvironment ? (
154154 <>
155 <MenuRadioItem value={ALL_ENVIRONMENTS_VALUE}>
155 <MenuRadioItem value={ALL_ENVIRONMENTS_VALUE} closeOnClick>
156156 <span className="flex min-w-0 items-center gap-2">
157157 <LayersIcon aria-hidden className="size-3.5" />
158158 <span className="min-w-0 flex-1 truncate">All environments</span>
......@@ -163,7 +163,11 @@ function EnvironmentScopeMenu({
163163 </>
164164 ) : null}
165165 {environments.map((environment) => (
166 <MenuRadioItem key={environment.environmentId} value={environment.environmentId}>
166 <MenuRadioItem
167 key={environment.environmentId}
168 value={environment.environmentId}
169 closeOnClick
170 >
167171 <span className="flex min-w-0 items-center gap-2">
168172 <EnvironmentMachineIcon
169173 aria-hidden
......@@ -199,7 +203,7 @@ function ProjectScopeMenu({ value, groups, onChange }: SettingsScopeMenuProps) {
199203 if (typeof next === "string") onChange(selectProjectAxis(value, next));
200204 }}
201205 >
202 <MenuRadioItem value={ALL_PROJECTS_VALUE}>
206 <MenuRadioItem value={ALL_PROJECTS_VALUE} closeOnClick>
203207 <span className="flex min-w-0 items-center gap-2">
204208 <span className="min-w-0 flex-1 truncate">All projects</span>
205209 <MenuRadioItemIndicator />
......@@ -207,7 +211,7 @@ function ProjectScopeMenu({ value, groups, onChange }: SettingsScopeMenuProps) {
207211 </MenuRadioItem>
208212 <MenuSeparator />
209213 {groups.map((group) => (
210 <MenuRadioItem key={group.projectKey} value={group.projectKey}>
214 <MenuRadioItem key={group.projectKey} value={group.projectKey} closeOnClick>
211215 <span className="flex min-w-0 items-center gap-2">
212216 <ProjectFavicon project={group} className="size-3.5" />
213217 <span className="min-w-0 flex-1 truncate">{group.displayName}</span>
apps/web/src/components/settings/settingsScopeRetention.test.tsx created+128
......@@ -0,0 +1,128 @@
1// @vitest-environment jsdom
2import { act, useState, type ReactNode } from "react";
3import { createRoot, type Root } from "react-dom/client";
4import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
5import type { SettingsScopeSearch } from "./settingsScope";
6
7interface ScopeState {
8 readonly search: SettingsScopeSearch;
9 readonly environmentId: string | null;
10}
11
12const state = vi.hoisted(() => ({
13 pathname: "/settings/diagnostics",
14 scope: { search: {}, environmentId: null } as ScopeState,
15}));
16
17vi.mock("@tanstack/react-router", async (importOriginal) => ({
18 ...(await importOriginal<typeof import("@tanstack/react-router")>()),
19 createFileRoute: () => (options: { component: () => ReactNode }) => ({
20 options,
21 useSearch: () => state.scope.search,
22 useNavigate: () => vi.fn(),
23 }),
24 useLocation: (options?: { select: (location: unknown) => unknown }) => {
25 const location = { pathname: state.pathname, hash: "", state: {} };
26 return options ? options.select(location) : location;
27 },
28 Outlet: () => <StatefulContent />,
29 redirect: vi.fn(),
30}));
31vi.mock("./SettingsScopeContext", async () => {
32 const { createContext, useContext } = await import("react");
33 const Scope = createContext<ScopeState>(state.scope);
34 return {
35 SettingsScopeProvider: ({ children }: { children: ReactNode }) => (
36 <Scope value={state.scope}>{children}</Scope>
37 ),
38 useSettingsScope: () => {
39 const { search, environmentId } = useContext(Scope);
40 return {
41 search,
42 scope: { kind: "all" },
43 environment: environmentId ? { environmentId } : null,
44 connectedEnvironments: environmentId ? [{}] : [],
45 };
46 },
47 };
48});
49vi.mock("../../state/environments", () => ({ useEnvironments: () => ({ environments: [] }) }));
50vi.mock("../ui/sidebar", () => ({
51 SidebarInset: ({ children }: { children: ReactNode }) => <div>{children}</div>,
52}));
53vi.mock("../WorkspacePageHeader", () => ({
54 WorkspacePageHeader: ({ children }: { children: ReactNode }) => <div>{children}</div>,
55}));
56vi.mock("./settingsLayout", () => ({
57 SettingsPageContainer: ({ children }: { children: ReactNode }) => <div>{children}</div>,
58}));
59vi.mock("./SettingsBreadcrumb", () => ({ SettingsBreadcrumb: () => null }));
60vi.mock("../sidebar/mainAppLocation", () => ({ useNavigateToMainApp: () => vi.fn() }));
61vi.mock("../../hooks/useNavigateBack", () => ({ useEscapeToGoBack: () => undefined }));
62vi.mock("./SettingsPanels", () => ({ useSettingsRestore: () => ({ changedSettingLabels: [] }) }));
63
64import { Route } from "../../routes/settings";
65
66function StatefulContent() {
67 const [value, setValue] = useState(0);
68 return (
69 <button aria-label="Retained value" onClick={() => setValue(value + 1)}>
70 {value}
71 </button>
72 );
73}
74
75let root: Root;
76let container: HTMLDivElement;
77const Layout = Route.options.component!;
78await Layout.preload?.();
79
80async function render(scope: ScopeState) {
81 state.scope = scope;
82 await act(async () => root.render(<Layout />));
83}
84function retainedValue() {
85 return container.querySelector('button[aria-label="Retained value"]')?.textContent ?? null;
86}
87
88beforeEach(() => {
89 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
90 container = document.createElement("div");
91 document.body.append(container);
92 root = createRoot(container);
93});
94afterEach(async () => {
95 await act(async () => root.unmount());
96 container.remove();
97 vi.unstubAllGlobals();
98});
99
100const first = { search: { machine: "first", project: "one" }, environmentId: "first" };
101const otherProject = { search: { machine: "first", project: "two" }, environmentId: "first" };
102const otherMachine = { search: { machine: "second", project: "one" }, environmentId: "second" };
103const implicitFirst = { search: { project: "one" }, environmentId: "first" };
104const implicitSecond = { search: { project: "one" }, environmentId: "second" };
105
106const disconnected = { search: first.search, environmentId: null };
107
108it.each([
109 ["/settings/diagnostics", "another project", "1", first, otherProject],
110 ["/settings/providers", "another project", "1", first, otherProject],
111 ["/settings/diagnostics", "another environment", "0", first, otherMachine],
112 ["/settings/providers", "another environment", "0", first, otherMachine],
113 ["/settings/diagnostics", "an implicit environment change", "0", implicitFirst, implicitSecond],
114 ["/settings/providers", "an implicit environment change", "0", implicitFirst, implicitSecond],
115 ["/settings/diagnostics", "a disconnect", "0", first, disconnected],
116 ["/settings/keybindings", "another project", "0", first, otherProject],
117])("%s after %s shows %s", async (pathname, _change, expected, before, after) => {
118 state.pathname = pathname;
119 await render(before);
120 await act(async () =>
121 container.querySelector<HTMLButtonElement>('button[aria-label="Retained value"]')?.click(),
122 );
123 expect(retainedValue()).toBe("1");
124
125 await render(after);
126
127 expect(retainedValue()).toBe(expected);
128});
apps/web/src/routes/settings.tsx+6-5
......@@ -114,7 +114,11 @@ function SettingsContentLayout() {
114114 const location = useLocation();
115115 const navigateToMainApp = useNavigateToMainApp();
116116 useEscapeToGoBack(navigateToMainApp);
117 const { search } = useSettingsScope();
117 const { search, environment } = useSettingsScope();
118 const contentScopeKey =
119 location.pathname === "/settings/diagnostics" || location.pathname === "/settings/providers"
120 ? environment?.environmentId
121 : JSON.stringify(search);
118122 const [restoreSignal, setRestoreSignal] = useState(0);
119123
120124 return (
......@@ -133,10 +137,7 @@ function SettingsContentLayout() {
133137 </div>
134138 </WorkspacePageHeader>
135139
136 <div
137 key={`${JSON.stringify(search)}:${restoreSignal}`}
138 className="min-h-0 flex flex-1 flex-col"
139 >
140 <div key={`${contentScopeKey}:${restoreSignal}`} className="min-h-0 flex flex-1 flex-col">
140141 <SettingsScopeBoundary pathname={location.pathname}>
141142 <Outlet />
142143 </SettingsScopeBoundary>