From 67118b21e4e86338548bac90d6d59ea03a1d2480 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 11 May 2026 22:38:12 -0600 Subject: [PATCH] perf(back-to-top): keep button mounted, swap mount/unmount for opacity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous `{#if showGlobalBackToTop}` block mounted/unmounted a `position: fixed` button (with box-shadow) every time the user scrolled past the 800px threshold. On 4K under software compositing the unmount path (going up across the threshold) registered as a visible hiccup — the user perceived it as a stutter halfway through scrolling back to the top. The button is now always mounted. Visibility is driven by a `.hidden` class with `opacity: 0; pointer-events: none` and a 150ms opacity transition. `tabindex` and `aria-hidden` reflect the boolean for a11y. Also rewrites the scroll listener: previously each scroll event wrote to a `$state` and triggered a `$derived` recompute (60-120 times/second during active scrolling). Now the listener rAF-coalesces and only assigns when the threshold actually flips. Default scroll cost drops from "per-event reactive churn" to "one boolean flip per threshold cross". cc --- src/routes/+page.svelte | 73 +++++++++++++++++++++++++++++++---------- 1 file changed, 56 insertions(+), 17 deletions(-) diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index dd52b6872..bc7ad1a2b 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -601,30 +601,52 @@ let isAutoUpdating = $state(false); let autoUpdateProgress = $state({ state: 'checking' }); - // Global back-to-top button + // Global back-to-top button. + // + // The previous implementation registered a capture-phase scroll listener + // on .main-content that wrote `globalScrollTop = target.scrollTop` to a + // `$state` on every scroll event, and used `$derived(globalScrollTop > 800)` + // to gate the button. That fired Svelte reactivity 60-120 times/second + // during scroll, and when the derived crossed the 800px threshold the + // button mount/unmount work added an extra repaint that registered as + // a visible hiccup at 4K under software compositing — especially + // pronounced when scrolling up (button unmount). + // + // New impl: track only the boolean visibility state, coalesce reads via + // requestAnimationFrame, and only assign when the threshold flips. This + // takes the per-scroll-tick reactive work from "every event" down to + // "at most once per animation frame, only on state change". let mainContentEl: HTMLElement | null = $state(null); - let globalScrollTop = $state(0); + let showGlobalBackToTop = $state(false); let activeScrollTarget: HTMLElement | null = null; - const showGlobalBackToTop = $derived(globalScrollTop > 800); + const BACK_TO_TOP_THRESHOLD_PX = 800; $effect(() => { if (!mainContentEl) return; + let rafId = 0; const handler = (e: Event) => { const target = e.target as HTMLElement; - if (target !== mainContentEl) { - globalScrollTop = target.scrollTop; - activeScrollTarget = target; - } + if (target === mainContentEl) return; + activeScrollTarget = target; + if (rafId !== 0) return; + rafId = requestAnimationFrame(() => { + rafId = 0; + const next = target.scrollTop > BACK_TO_TOP_THRESHOLD_PX; + if (next !== showGlobalBackToTop) showGlobalBackToTop = next; + }); }; mainContentEl.addEventListener('scroll', handler, true); - return () => mainContentEl?.removeEventListener('scroll', handler, true); + return () => { + mainContentEl?.removeEventListener('scroll', handler, true); + if (rafId !== 0) cancelAnimationFrame(rafId); + }; }); // Reset scroll state on view or item change (but not on back/forward — that restores saved position) $effect(() => { void activeView; void currentNavItemId; - globalScrollTop = 0; + showGlobalBackToTop = false; // Scroll to top for forward navigation (not back/forward, which restores saved position) if (!isBackForward()) { tick().then(() => { @@ -4927,7 +4949,7 @@ // Save scroll position of the view we're leaving if (prevView !== navState.activeView || prevItemId !== navState.activeItemId) { - const scrollTopToSave = activeScrollTarget?.scrollTop ?? globalScrollTop; + const scrollTopToSave = activeScrollTarget?.scrollTop ?? 0; saveScrollPosition(prevView, scrollTopToSave, prevItemId); } @@ -4946,7 +4968,7 @@ tick().then(() => { if (activeScrollTarget) { activeScrollTarget.scrollTop = savedScroll; - globalScrollTop = savedScroll; + showGlobalBackToTop = savedScroll > BACK_TO_TOP_THRESHOLD_PX; } }); } @@ -6642,11 +6664,19 @@ {/if} - {#if showGlobalBackToTop} - - {/if} + + {#if lyricsSidebarVisible && !isQueueOpen} @@ -7263,10 +7293,19 @@ align-items: center; justify-content: center; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); - transition: background 150ms ease, color 150ms ease; + /* Opacity-only transition for show/hide so the button can stay mounted + at all times. Mounting/unmounting a position:fixed element on every + scroll-threshold crossing forced an extra paint event that the user + perceived as a stutter, especially on scroll-up at 4K. */ + transition: background 150ms ease, color 150ms ease, opacity 150ms ease; z-index: 200; } + .back-to-top-global.hidden { + opacity: 0; + pointer-events: none; + } + .back-to-top-global:hover { background: var(--bg-tertiary); color: var(--text-primary);