From b1f72fc320a2dd4db9524fe288cdad27ae9f64d5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 10 Aug 2026 22:58:06 +0000 Subject: [PATCH] Fix Settings scroll jank from section nav and sticky blur IntersectionObserver was calling getBoundingClientRect on every scroll callback (layout thrashing). Coalesce updates to rAF, use observer entry geometry only, skip no-op active-id updates, and replace sticky backdrop-filter with solid paints. Long sections use content-visibility to skip off-screen paint. Co-authored-by: Damon --- CHANGELOG.md | 1 + apps/ui/src/components/PageSectionNav.tsx | 87 ++++++++++++++++------- apps/ui/src/styles.css | 11 +-- 3 files changed, 71 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 252b28a..fb99904 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Versions follow [SemVer](https://semver.org/) (`0.1.0-alpha.x` while the public - Windows packaging uses NSIS only — WiX `.msi` rejects semver pre-releases like `0.1.0-alpha.1` - Desktop AI: stop implying Cursor `crsr_` keys are a chat API; clearer network errors; Test saves then probes with visible status - Empty-workspace onboarding: optional EM name at create, single setup checklist, less Home clutter +- Settings / long-page scroll jank: section nav no longer forces layout on scroll; drop sticky chrome backdrop blur ## [0.1.0-alpha.1] - TBD diff --git a/apps/ui/src/components/PageSectionNav.tsx b/apps/ui/src/components/PageSectionNav.tsx index 0b46a24..cd1b168 100644 --- a/apps/ui/src/components/PageSectionNav.tsx +++ b/apps/ui/src/components/PageSectionNav.tsx @@ -5,6 +5,11 @@ export type SectionNavItem = { label: string; }; +/** + * Sticky “On this page” links with active-section highlighting. + * Scroll tracking avoids layout thrashing: use IntersectionObserver entry + * geometry only, coalesce updates to animation frames, and skip no-op sets. + */ export function PageSectionNav({ items, ariaLabel = "On this page", @@ -15,55 +20,89 @@ export function PageSectionNav({ const [activeId, setActiveId] = useState(items[0]?.id ?? ""); const sectionKey = items.map((item) => item.id).join("|"); const clickLockUntil = useRef(0); + const itemsRef = useRef(items); + itemsRef.current = items; useEffect(() => { - if (items[0]?.id) setActiveId(items[0].id); - }, [sectionKey, items]); + const first = sectionKey.split("|")[0]; + if (first) setActiveId(first); + }, [sectionKey]); useEffect(() => { - const nodes = items - .map((item) => document.getElementById(item.id)) + const ids = sectionKey.split("|").filter(Boolean); + const nodes = ids + .map((id) => document.getElementById(id)) .filter((el): el is HTMLElement => Boolean(el)); if (nodes.length === 0) return; const ratios = new Map(); + const tops = new Map(); + let raf = 0; + + const commit = () => { + raf = 0; + if (Date.now() < clickLockUntil.current) return; + + const ordered = itemsRef.current; + let bestId = ""; + let bestRatio = 0; + for (const item of ordered) { + const ratio = ratios.get(item.id) ?? 0; + if (ratio > bestRatio) { + bestRatio = ratio; + bestId = item.id; + } + } + if (bestId && bestRatio > 0.02) { + setActiveId((prev) => (prev === bestId ? prev : bestId)); + return; + } + + let aboveId = ""; + let aboveTop = -Infinity; + for (const item of ordered) { + const top = tops.get(item.id); + if (top == null) continue; + if (top <= 140 && top >= aboveTop) { + aboveTop = top; + aboveId = item.id; + } + } + if (aboveId) setActiveId((prev) => (prev === aboveId ? prev : aboveId)); + }; + const observer = new IntersectionObserver( (entries) => { - if (Date.now() < clickLockUntil.current) return; for (const entry of entries) { - ratios.set(entry.target.id, entry.isIntersecting ? entry.intersectionRatio : 0); - } - const visible = items - .map((item) => document.getElementById(item.id)) - .filter((el): el is HTMLElement => Boolean(el)) - .filter((el) => (ratios.get(el.id) ?? 0) > 0.02) - .sort((a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top); - if (visible[0]) { - setActiveId(visible[0].id); - return; + const id = entry.target.id; + ratios.set(id, entry.isIntersecting ? entry.intersectionRatio : 0); + // Prefer observer-provided rect — do not call getBoundingClientRect (forces layout). + tops.set(id, entry.boundingClientRect.top); } - const above = nodes - .filter((n) => n.getBoundingClientRect().top <= 140) - .sort((a, b) => b.getBoundingClientRect().top - a.getBoundingClientRect().top)[0]; - if (above) setActiveId(above.id); + if (!raf) raf = requestAnimationFrame(commit); }, { root: null, - rootMargin: "-12% 0px -60% 0px", - threshold: [0, 0.1, 0.25, 0.5, 1], + rootMargin: "-10% 0px -55% 0px", + // Fewer thresholds → fewer callbacks while scrolling. + threshold: [0, 0.25, 0.6, 1], }, ); for (const node of nodes) observer.observe(node); - return () => observer.disconnect(); - }, [sectionKey, items]); + return () => { + if (raf) cancelAnimationFrame(raf); + observer.disconnect(); + }; + }, [sectionKey]); function goTo(id: string) { const el = document.getElementById(id); if (!el) return; clickLockUntil.current = Date.now() + 700; setActiveId(id); - el.scrollIntoView({ behavior: "smooth", block: "start" }); + const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + el.scrollIntoView({ behavior: reduceMotion ? "auto" : "smooth", block: "start" }); } return ( diff --git a/apps/ui/src/styles.css b/apps/ui/src/styles.css index fb9ff07..f2063e9 100644 --- a/apps/ui/src/styles.css +++ b/apps/ui/src/styles.css @@ -308,7 +308,6 @@ a:hover { color: var(--brand-strong); } gap: var(--space-3); border-bottom: 1px solid var(--line); background: var(--sidebar-bg); - backdrop-filter: blur(12px); } .topbar-brand { @@ -356,7 +355,7 @@ a:hover { color: var(--brand-strong); } padding-bottom: max(var(--space-6), env(safe-area-inset-bottom)); border-right: 1px solid var(--line); background: var(--sidebar-bg); - backdrop-filter: blur(12px); + /* Solid sidebar paint — blur while scrolling was a WebView jank source on long pages. */ display: flex; flex-direction: column; overflow-y: auto; @@ -468,6 +467,9 @@ a:hover { color: var(--brand-strong); } .settings-section, .page-section { scroll-margin-top: 1.25rem; + /* Skip paint/layout for off-screen sections on long pages (Settings, Person, Writing desk). */ + content-visibility: auto; + contain-intrinsic-size: auto 24rem; } .page-section-nav { position: sticky; @@ -475,6 +477,8 @@ a:hover { color: var(--brand-strong); } align-self: start; padding: var(--space-3) 0 var(--space-3) var(--space-3); border-left: 1px solid var(--line); + /* Avoid backdrop-filter here — sticky + blur is expensive while scrolling (esp. WebView). */ + background: var(--bg); } .page-section-nav-label { font-size: 0.72rem; @@ -533,8 +537,7 @@ a:hover { color: var(--brand-strong); } padding: var(--space-2) var(--space-4); border-left: none; border-bottom: 1px solid var(--line); - background: color-mix(in srgb, var(--bg) 88%, transparent); - backdrop-filter: blur(10px); + background: var(--bg); } .page-section-nav-label { display: none; } .page-section-nav-list {