authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-08 02:30:21-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-08 02:30:21-07:00
logd6dbe8dd67facf4a43030993bebb58cc7e7ac141
treede033a132e207ba0bdcdad199b91371b19e39e72
parent134b7194b125dd4881ff6040f66997b23489b72e
signature Signed by PGP key B5690EEEBB952194

fix(web): stop the settings sidebar shifting when switching pages (#10705)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

4 files changed, 3 insertions(+), 570 deletions(-)

apps/web/src/components/settings/SettingsSidebarNav.tsx+2-135
...@@ -8,7 +8,6 @@ import {...@@ -8,7 +8,6 @@ import {
8 useState,8 useState,
9 type ComponentType,9 type ComponentType,
10 type KeyboardEvent,10 type KeyboardEvent,
11 type ReactNode,
12} from "react";11} from "react";
13import {12import {
14 ArchiveIcon,13 ArchiveIcon,
...@@ -24,13 +23,11 @@ import {...@@ -24,13 +23,11 @@ import {
24 Settings2Icon,23 Settings2Icon,
25 XIcon,24 XIcon,
26} from "lucide-react";25} from "lucide-react";
27import { useLocation, useNavigate, useRouterState } from "@tanstack/react-router";26import { useLocation, useNavigate } from "@tanstack/react-router";
2827
29import { Button } from "../ui/button";28import { Button } from "../ui/button";
30import { Collapsible, CollapsiblePanel } from "../ui/collapsible";
31import { Input } from "../ui/input";29import { Input } from "../ui/input";
32import { Kbd } from "../ui/kbd";30import { Kbd } from "../ui/kbd";
33import { cn } from "../../lib/utils";
34import {31import {
35 SidebarContent,32 SidebarContent,
36 SidebarFooter,33 SidebarFooter,
...@@ -38,18 +35,10 @@ import {...@@ -38,18 +35,10 @@ import {
38 SidebarMenu,35 SidebarMenu,
39 SidebarMenuButton,36 SidebarMenuButton,
40 SidebarMenuItem,37 SidebarMenuItem,
41 SidebarMenuSub,
42 SidebarMenuSubButton,
43 SidebarMenuSubItem,
44 useSidebar,38 useSidebar,
45} from "../ui/sidebar";39} from "../ui/sidebar";
46import { SidebarUtilityMenu } from "../sidebar/SidebarChrome";40import { SidebarUtilityMenu } from "../sidebar/SidebarChrome";
47import { scrollToSettingsTarget } from "./settingsLayout";41import { scrollToSettingsTarget } from "./settingsLayout";
48import {
49 getVisibleSettingsSectionIds,
50 observeSettingsSectionVisibility,
51 type SettingsSectionVisibilityState,
52} from "./settingsSectionVisibility";
53import {42import {
54 searchSettings,43 searchSettings,
55 SETTINGS_SECTION_LABELS,44 SETTINGS_SECTION_LABELS,
...@@ -106,102 +95,22 @@ const SETTINGS_NAV_ITEMS: ReadonlyArray<{...@@ -106,102 +95,22 @@ const SETTINGS_NAV_ITEMS: ReadonlyArray<{
106 icon: SETTINGS_SECTION_ICONS[to],95 icon: SETTINGS_SECTION_ICONS[to],
107}));96}));
10897
109const SETTINGS_PAGE_SECTIONS: Partial<
110 Readonly<Record<SettingsPath, ReadonlyArray<{ label: string; targetId: string }>>>
111> = {
112 "/settings/general": [
113 { label: "Organization", targetId: "organization" },
114 { label: "Behavior", targetId: "behavior" },
115 { label: "Projects & threads", targetId: "projects-and-threads" },
116 { label: "Confirmations", targetId: "confirmations" },
117 { label: "Text generation", targetId: "text-generation" },
118 { label: "About", targetId: "about" },
119 { label: "Legacy features", targetId: "legacy-features" },
120 ],
121 "/settings/appearance": [
122 { label: "Colors & themes", targetId: "appearance" },
123 { label: "Interface", targetId: "appearance-interface" },
124 { label: "Motion", targetId: "motion" },
125 { label: "Typography", targetId: "typography" },
126 ],
127 "/settings/source-control": [
128 { label: "Version control", targetId: "source-control" },
129 { label: "Text generation", targetId: "source-control-text-generation" },
130 ],
131 "/settings/connections": [
132 { label: "This environment", targetId: "connections-environment" },
133 { label: "Remote environments", targetId: "remote-environments" },
134 ],
135};
136
137function SettingsSectionIcon({ to }: { to: SettingsPath }) {98function SettingsSectionIcon({ to }: { to: SettingsPath }) {
138 const Icon = SETTINGS_SECTION_ICONS[to];99 const Icon = SETTINGS_SECTION_ICONS[to];
139 return <Icon className="mt-0.5 size-3.5 shrink-0 text-sidebar-muted-foreground/60" />;100 return <Icon className="mt-0.5 size-3.5 shrink-0 text-sidebar-muted-foreground/60" />;
140}101}
141102
142function SettingsSubmenuCollapse({
143 open,
144 children,
145}: {
146 readonly open: boolean;
147 readonly children: ReactNode;
148}) {
149 return (
150 <Collapsible open={open}>
151 <CollapsiblePanel className="duration-150 ease-out motion-reduce:transition-none">
152 {children}
153 </CollapsiblePanel>
154 </Collapsible>
155 );
156}
157
158export function SettingsSidebarNav({ pathname }: { pathname: string }) {103export function SettingsSidebarNav({ pathname }: { pathname: string }) {
159 const navigate = useNavigate();104 const navigate = useNavigate();
160 const currentHash = useLocation({ select: (location) => location.hash });105 const currentHash = useLocation({ select: (location) => location.hash });
161 const resolvedPathname = useRouterState({
162 select: (state) => state.resolvedLocation?.pathname,
163 });
164 const { isMobile, setOpenMobile, open, setOpen } = useSidebar();106 const { isMobile, setOpenMobile, open, setOpen } = useSidebar();
165 const searchInputRef = useRef<HTMLInputElement>(null);107 const searchInputRef = useRef<HTMLInputElement>(null);
166 const [query, setQuery] = useState("");108 const [query, setQuery] = useState("");
167 const [activeResultIndex, setActiveResultIndex] = useState(0);109 const [activeResultIndex, setActiveResultIndex] = useState(0);
168 const [sectionVisibility, setSectionVisibility] = useState<SettingsSectionVisibilityState | null>(
169 null,
170 );
171 const searchableItems = useAvailableSettingsSearchItems();110 const searchableItems = useAvailableSettingsSearchItems();
172 const results = useMemo(() => searchSettings(query, searchableItems), [query, searchableItems]);111 const results = useMemo(() => searchSettings(query, searchableItems), [query, searchableItems]);
173 const isSearching = query.trim().length > 0;112 const isSearching = query.trim().length > 0;
174 const hasResults = results.length > 0;113 const hasResults = results.length > 0;
175 const activeSettingsPath = SETTINGS_NAV_ITEMS.find(
176 (item) => pathname === item.to || pathname.startsWith(`${item.to}/`),
177 )?.to;
178 const observedVisibilityScope = useMemo(() => {
179 const path = SETTINGS_NAV_ITEMS.find(
180 (item) =>
181 resolvedPathname === item.to || resolvedPathname?.startsWith(`${item.to}/`) === true,
182 )?.to;
183 const pageSections = path ? SETTINGS_PAGE_SECTIONS[path] : undefined;
184 return path && pageSections ? { path, pageSections } : null;
185 }, [resolvedPathname]);
186 const visiblePageSectionIds = getVisibleSettingsSectionIds({
187 activePath: activeSettingsPath,
188 scope: observedVisibilityScope,
189 visibility: sectionVisibility,
190 });
191
192 useEffect(() => {
193 if (!observedVisibilityScope) return;
194 const container = document.querySelector<HTMLElement>("[data-settings-page-layout]");
195 if (!container) return;
196
197 return observeSettingsSectionVisibility({
198 container,
199 targetIds: observedVisibilityScope.pageSections.map((section) => section.targetId),
200 onChange(targetIds) {
201 setSectionVisibility({ scope: observedVisibilityScope, targetIds: new Set(targetIds) });
202 },
203 });
204 }, [observedVisibilityScope]);
205114
206 useEffect(() => {115 useEffect(() => {
207 setActiveResultIndex((index) => Math.min(index, Math.max(results.length - 1, 0)));116 setActiveResultIndex((index) => Math.min(index, Math.max(results.length - 1, 0)));
...@@ -261,24 +170,6 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {...@@ -261,24 +170,6 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
261 },170 },
262 [isMobile, navigate, setOpenMobile],171 [isMobile, navigate, setOpenMobile],
263 );172 );
264 const handlePageSectionClick = useCallback(
265 (to: SettingsPath, targetId: string) => {
266 if (isMobile) {
267 setOpenMobile(false);
268 }
269 if (pathname === to && scrollToSettingsTarget(targetId, { highlight: false })) {
270 return;
271 }
272 void navigate({
273 to,
274 hash: targetId,
275 replace: true,
276 hashScrollIntoView: false,
277 state: { settingsTargetHighlight: false },
278 });
279 },
280 [isMobile, navigate, pathname, setOpenMobile],
281 );
282 const clearSearch = useCallback(() => {173 const clearSearch = useCallback(() => {
283 setQuery("");174 setQuery("");
284 setActiveResultIndex(0);175 setActiveResultIndex(0);
...@@ -430,8 +321,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {...@@ -430,8 +321,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
430 <SidebarMenu className="ps-px">321 <SidebarMenu className="ps-px">
431 {SETTINGS_NAV_ITEMS.map((item) => {322 {SETTINGS_NAV_ITEMS.map((item) => {
432 const Icon = item.icon;323 const Icon = item.icon;
433 const pageSections = SETTINGS_PAGE_SECTIONS[item.to];324 const isActive = pathname === item.to || pathname.startsWith(`${item.to}/`);
434 const isActive = activeSettingsPath === item.to;
435 return (325 return (
436 <SidebarMenuItem key={item.to}>326 <SidebarMenuItem key={item.to}>
437 <SidebarMenuButton327 <SidebarMenuButton
...@@ -441,29 +331,6 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {...@@ -441,29 +331,6 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
441 <Icon />331 <Icon />
442 <span className="truncate">{item.label}</span>332 <span className="truncate">{item.label}</span>
443 </SidebarMenuButton>333 </SidebarMenuButton>
444 {pageSections ? (
445 <SettingsSubmenuCollapse open={isActive}>
446 <SidebarMenuSub className="border-l-0">
447 {pageSections.map((section) => (
448 <SidebarMenuSubItem key={section.targetId}>
449 <SidebarMenuSubButton
450 render={<button type="button" />}
451 size="sm"
452 data-visible={visiblePageSectionIds.has(section.targetId)}
453 className={cn(
454 "w-full text-sidebar-muted-foreground/65",
455 visiblePageSectionIds.has(section.targetId) &&
456 "font-medium text-sidebar-foreground",
457 )}
458 onClick={() => handlePageSectionClick(item.to, section.targetId)}
459 >
460 <span className="ms-0.5">{section.label}</span>
461 </SidebarMenuSubButton>
462 </SidebarMenuSubItem>
463 ))}
464 </SidebarMenuSub>
465 </SettingsSubmenuCollapse>
466 ) : null}
467 </SidebarMenuItem>334 </SidebarMenuItem>
468 );335 );
469 })}336 })}
apps/web/src/components/settings/settingsSectionVisibility.test.ts deleted-245
...@@ -1,245 +0,0 @@
1import { describe, expect, it, vi } from "vite-plus/test";
2
3import {
4 getVisibleSettingsSectionIds,
5 observeSettingsSectionVisibility,
6 type SettingsSectionVisibilityEnvironment,
7} from "./settingsSectionVisibility";
8
9type VisibilityEntry = Pick<
10 IntersectionObserverEntry,
11 "intersectionRatio" | "isIntersecting" | "target"
12>;
13
14function createHarness(
15 targetIds: ReadonlyArray<string>,
16 initialRoot: Element | null = { name: "initial-root" } as unknown as Element,
17) {
18 const targets = new Map(
19 targetIds.map((targetId) => [targetId, { targetId } as unknown as Element]),
20 );
21 const observed = new Set<Element>();
22 const unobserve = vi.fn((target: Element) => observed.delete(target));
23 const disconnectIntersections = vi.fn();
24 const disconnectMutations = vi.fn();
25 const intersectionCallbacks: Array<(entries: ReadonlyArray<VisibilityEntry>) => void> = [];
26 const intersectionRoots: Element[] = [];
27 let root = initialRoot;
28 let onMutation = () => {};
29
30 const environment: SettingsSectionVisibilityEnvironment = {
31 findRoot: () => root,
32 findTarget: (_root, targetId) => targets.get(targetId) ?? null,
33 createIntersectionObserver(callback, observedRoot) {
34 intersectionCallbacks.push(callback);
35 intersectionRoots.push(observedRoot);
36 return {
37 observe: (target) => observed.add(target),
38 unobserve,
39 disconnect: disconnectIntersections,
40 };
41 },
42 createMutationObserver(callback) {
43 onMutation = callback;
44 return { disconnect: disconnectMutations };
45 },
46 };
47
48 return {
49 environment,
50 targets,
51 observed,
52 unobserve,
53 disconnectIntersections,
54 disconnectMutations,
55 intersectionCallbacks,
56 intersectionRoots,
57 intersect(entries: ReadonlyArray<VisibilityEntry>) {
58 intersectionCallbacks.at(-1)?.(entries);
59 },
60 mutate() {
61 onMutation();
62 },
63 replaceRoot(nextRoot: Element | null) {
64 root = nextRoot;
65 onMutation();
66 },
67 };
68}
69
70function visibleEntry(
71 target: Element,
72 { isIntersecting = true, intersectionRatio = 1 } = {},
73): VisibilityEntry {
74 return { target, isIntersecting, intersectionRatio };
75}
76
77describe("settings section visibility", () => {
78 it("does not reuse visibility when returning to the same sectioned route", () => {
79 const firstGeneralVisit = { path: "/settings/general" };
80 const firstVisibility = {
81 scope: firstGeneralVisit,
82 targetIds: new Set(["text-generation"]),
83 };
84
85 expect(
86 getVisibleSettingsSectionIds({
87 activePath: "/settings/general",
88 scope: firstGeneralVisit,
89 visibility: firstVisibility,
90 }),
91 ).toEqual(new Set(["text-generation"]));
92 expect(
93 getVisibleSettingsSectionIds({
94 activePath: "/settings/providers",
95 scope: null,
96 visibility: firstVisibility,
97 }),
98 ).toEqual(new Set());
99
100 const secondGeneralVisit = { path: "/settings/general" };
101 expect(
102 getVisibleSettingsSectionIds({
103 activePath: "/settings/general",
104 scope: secondGeneralVisit,
105 visibility: firstVisibility,
106 }),
107 ).toEqual(new Set());
108 });
109
110 it("accumulates visible sections and emits them in sidebar order", () => {
111 const harness = createHarness(["one", "two", "three"]);
112 const emissions: ReadonlyArray<string>[] = [];
113 observeSettingsSectionVisibility({
114 container: {} as Element,
115 targetIds: ["one", "two", "three"],
116 onChange: (visible) => emissions.push(visible),
117 environment: harness.environment,
118 });
119
120 harness.intersect([visibleEntry(harness.targets.get("two")!)]);
121 harness.intersect([visibleEntry(harness.targets.get("one")!)]);
122
123 expect(emissions).toEqual([[], ["two"], ["one", "two"]]);
124 });
125
126 it("treats zero-ratio and non-intersecting entries as hidden", () => {
127 const harness = createHarness(["one", "two"]);
128 const emissions: ReadonlyArray<string>[] = [];
129 observeSettingsSectionVisibility({
130 container: {} as Element,
131 targetIds: ["one", "two"],
132 onChange: (visible) => emissions.push(visible),
133 environment: harness.environment,
134 });
135
136 const one = harness.targets.get("one")!;
137 const two = harness.targets.get("two")!;
138 harness.intersect([visibleEntry(one), visibleEntry(two)]);
139 harness.intersect([visibleEntry(one, { intersectionRatio: 0 })]);
140 harness.intersect([visibleEntry(two, { isIntersecting: false })]);
141
142 expect(emissions).toEqual([[], ["one", "two"], ["two"], []]);
143 });
144
145 it("resyncs replaced and removed targets without retaining stale visibility", () => {
146 const harness = createHarness(["dynamic"]);
147 const emissions: ReadonlyArray<string>[] = [];
148 observeSettingsSectionVisibility({
149 container: {} as Element,
150 targetIds: ["dynamic"],
151 onChange: (visible) => emissions.push(visible),
152 environment: harness.environment,
153 });
154
155 const firstTarget = harness.targets.get("dynamic")!;
156 harness.intersect([visibleEntry(firstTarget)]);
157 const replacementTarget = { targetId: "dynamic-replacement" } as unknown as Element;
158 harness.targets.set("dynamic", replacementTarget);
159 harness.mutate();
160
161 expect(harness.unobserve).toHaveBeenCalledWith(firstTarget);
162 expect(harness.observed.has(replacementTarget)).toBe(true);
163 expect(emissions.at(-1)).toEqual([]);
164
165 harness.intersect([visibleEntry(firstTarget)]);
166 expect(emissions.at(-1)).toEqual([]);
167 harness.intersect([visibleEntry(replacementTarget)]);
168 expect(emissions.at(-1)).toEqual(["dynamic"]);
169
170 harness.targets.delete("dynamic");
171 harness.mutate();
172 expect(harness.unobserve).toHaveBeenCalledWith(replacementTarget);
173 expect(emissions.at(-1)).toEqual([]);
174 });
175
176 it("rebinds when navigation replaces the settings scroll root", () => {
177 const harness = createHarness(["section"]);
178 const emissions: ReadonlyArray<string>[] = [];
179 observeSettingsSectionVisibility({
180 container: {} as Element,
181 targetIds: ["section"],
182 onChange: (visible) => emissions.push(visible),
183 environment: harness.environment,
184 });
185
186 const firstTarget = harness.targets.get("section")!;
187 harness.intersect([visibleEntry(firstTarget)]);
188 const firstObserverCallback = harness.intersectionCallbacks[0]!;
189 const nextRoot = { name: "next-root" } as unknown as Element;
190 const nextTarget = { targetId: "next-section" } as unknown as Element;
191 harness.targets.set("section", nextTarget);
192 harness.replaceRoot(nextRoot);
193
194 expect(harness.disconnectIntersections).toHaveBeenCalledOnce();
195 expect(harness.intersectionRoots.at(-1)).toBe(nextRoot);
196 expect(harness.observed.has(nextTarget)).toBe(true);
197 expect(emissions.at(-1)).toEqual([]);
198
199 firstObserverCallback([visibleEntry(firstTarget)]);
200 expect(emissions.at(-1)).toEqual([]);
201 harness.intersect([visibleEntry(nextTarget)]);
202 expect(emissions.at(-1)).toEqual(["section"]);
203 });
204
205 it("starts observing when the settings scroll root mounts later", () => {
206 const harness = createHarness(["section"], null);
207 const emissions: ReadonlyArray<string>[] = [];
208 observeSettingsSectionVisibility({
209 container: {} as Element,
210 targetIds: ["section"],
211 onChange: (visible) => emissions.push(visible),
212 environment: harness.environment,
213 });
214
215 expect(emissions).toEqual([[]]);
216 expect(harness.intersectionRoots).toEqual([]);
217
218 const root = { name: "mounted-root" } as unknown as Element;
219 harness.replaceRoot(root);
220 harness.intersect([visibleEntry(harness.targets.get("section")!)]);
221
222 expect(harness.intersectionRoots).toEqual([root]);
223 expect(emissions.at(-1)).toEqual(["section"]);
224 });
225
226 it("disconnects both observers and ignores callbacks after cleanup", () => {
227 const harness = createHarness(["one"]);
228 const onChange = vi.fn();
229 const cleanup = observeSettingsSectionVisibility({
230 container: {} as Element,
231 targetIds: ["one"],
232 onChange,
233 environment: harness.environment,
234 });
235
236 cleanup();
237 harness.intersect([visibleEntry(harness.targets.get("one")!)]);
238 harness.mutate();
239
240 expect(harness.disconnectIntersections).toHaveBeenCalledOnce();
241 expect(harness.disconnectMutations).toHaveBeenCalledOnce();
242 expect(onChange).toHaveBeenCalledTimes(1);
243 expect(onChange).toHaveBeenLastCalledWith([]);
244 });
245});
apps/web/src/components/settings/settingsSectionVisibility.ts deleted-186
...@@ -1,186 +0,0 @@
1type VisibilityEntry = Pick<
2 IntersectionObserverEntry,
3 "intersectionRatio" | "isIntersecting" | "target"
4>;
5
6export type SettingsSectionVisibilityScope = {
7 readonly path: string;
8};
9
10export type SettingsSectionVisibilityState = {
11 readonly scope: SettingsSectionVisibilityScope;
12 readonly targetIds: ReadonlySet<string>;
13};
14
15const EMPTY_VISIBLE_SETTINGS_SECTION_IDS: ReadonlySet<string> = new Set();
16
17export function getVisibleSettingsSectionIds({
18 activePath,
19 scope,
20 visibility,
21}: {
22 readonly activePath: string | undefined;
23 readonly scope: SettingsSectionVisibilityScope | null;
24 readonly visibility: SettingsSectionVisibilityState | null;
25}): ReadonlySet<string> {
26 if (!scope || activePath !== scope.path || visibility?.scope !== scope) {
27 return EMPTY_VISIBLE_SETTINGS_SECTION_IDS;
28 }
29 return visibility.targetIds;
30}
31
32type ElementObserver = {
33 observe(target: Element): void;
34 unobserve(target: Element): void;
35 disconnect(): void;
36};
37
38type MutationSubscription = {
39 disconnect(): void;
40};
41
42export type SettingsSectionVisibilityEnvironment = {
43 findRoot(container: Element): Element | null;
44 findTarget(root: Element, targetId: string): Element | null;
45 createIntersectionObserver(
46 onEntries: (entries: ReadonlyArray<VisibilityEntry>) => void,
47 root: Element,
48 ): ElementObserver;
49 createMutationObserver(onMutation: () => void, container: Element): MutationSubscription;
50};
51
52function createBrowserEnvironment(): SettingsSectionVisibilityEnvironment {
53 return {
54 findRoot(container) {
55 return container.querySelector("[data-settings-page-scroll]");
56 },
57 findTarget(root, targetId) {
58 const target = root.ownerDocument.getElementById(targetId);
59 return target && root.contains(target) ? target : null;
60 },
61 createIntersectionObserver(onEntries, scrollRoot) {
62 const observer = new IntersectionObserver(onEntries, {
63 root: scrollRoot,
64 threshold: 0,
65 });
66 return observer;
67 },
68 createMutationObserver(onMutation, container) {
69 const observer = new MutationObserver(onMutation);
70 observer.observe(container, { childList: true, subtree: true });
71 return observer;
72 },
73 };
74}
75
76export function observeSettingsSectionVisibility({
77 container,
78 targetIds,
79 onChange,
80 environment = createBrowserEnvironment(),
81}: {
82 readonly container: Element;
83 readonly targetIds: ReadonlyArray<string>;
84 readonly onChange: (visibleTargetIds: ReadonlyArray<string>) => void;
85 readonly environment?: SettingsSectionVisibilityEnvironment;
86}): () => void {
87 const orderedTargetIds = [...new Set(targetIds)];
88 const targetsById = new Map<string, Element>();
89 const targetIdsByElement = new Map<Element, string>();
90 const visibleTargetIds = new Set<string>();
91 let lastEmission: string | null = null;
92 let stopped = false;
93 let root: Element | null = null;
94 let intersectionObserver: ElementObserver | null = null;
95 let observerGeneration = 0;
96
97 const emit = () => {
98 const visibleInOrder = orderedTargetIds.filter((targetId) => visibleTargetIds.has(targetId));
99 const emissionKey = visibleInOrder.join("\0");
100 if (emissionKey === lastEmission) return;
101 lastEmission = emissionKey;
102 onChange(visibleInOrder);
103 };
104
105 const handleEntries = (entries: ReadonlyArray<VisibilityEntry>, generation: number) => {
106 if (stopped || generation !== observerGeneration) return;
107 let changed = false;
108 for (const entry of entries) {
109 const targetId = targetIdsByElement.get(entry.target);
110 if (!targetId || targetsById.get(targetId) !== entry.target) continue;
111 const visible = entry.isIntersecting && entry.intersectionRatio > 0;
112 if (visible === visibleTargetIds.has(targetId)) continue;
113 changed = true;
114 if (visible) {
115 visibleTargetIds.add(targetId);
116 } else {
117 visibleTargetIds.delete(targetId);
118 }
119 }
120 if (changed) emit();
121 };
122
123 const syncTargets = () => {
124 if (stopped) return;
125 let changed = false;
126 const nextRoot = environment.findRoot(container);
127
128 if (nextRoot !== root) {
129 observerGeneration += 1;
130 intersectionObserver?.disconnect();
131 intersectionObserver = null;
132 root = nextRoot;
133 targetsById.clear();
134 targetIdsByElement.clear();
135 changed = visibleTargetIds.size > 0;
136 visibleTargetIds.clear();
137
138 if (root) {
139 const generation = observerGeneration;
140 intersectionObserver = environment.createIntersectionObserver(
141 (entries) => handleEntries(entries, generation),
142 root,
143 );
144 }
145 }
146
147 if (!root || !intersectionObserver) {
148 if (changed) emit();
149 return;
150 }
151
152 for (const targetId of orderedTargetIds) {
153 const previousTarget = targetsById.get(targetId) ?? null;
154 const nextTarget = environment.findTarget(root, targetId);
155 if (previousTarget === nextTarget) continue;
156
157 if (previousTarget) {
158 intersectionObserver.unobserve(previousTarget);
159 targetsById.delete(targetId);
160 targetIdsByElement.delete(previousTarget);
161 changed = visibleTargetIds.delete(targetId) || changed;
162 }
163 if (nextTarget) {
164 targetsById.set(targetId, nextTarget);
165 targetIdsByElement.set(nextTarget, targetId);
166 intersectionObserver.observe(nextTarget);
167 }
168 }
169
170 if (changed) emit();
171 };
172
173 const mutationObserver = environment.createMutationObserver(syncTargets, container);
174 syncTargets();
175 emit();
176
177 return () => {
178 stopped = true;
179 observerGeneration += 1;
180 intersectionObserver?.disconnect();
181 mutationObserver.disconnect();
182 targetsById.clear();
183 targetIdsByElement.clear();
184 visibleTargetIds.clear();
185 };
186}
apps/web/src/routes/settings.tsx+1-4
...@@ -69,10 +69,7 @@ function SettingsContentLayout() {...@@ -69,10 +69,7 @@ function SettingsContentLayout() {
69 }, [navigateBackWithinApp]);69 }, [navigateBackWithinApp]);
7070
71 return (71 return (
72 <SidebarInset72 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate">
73 className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate"
74 data-settings-page-layout
75 >
76 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground">73 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground">
77 <WorkspacePageHeader electron={isElectron}>74 <WorkspacePageHeader electron={isElectron}>
78 <div className="flex w-full items-center gap-3">75 <div className="flex w-full items-center gap-3">