From 55a9b937cb5452d20480ca738fa7d58c37f84835 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 5 Feb 2026 20:52:52 +0000 Subject: [PATCH] Refactor Docs ScrollSpy to use IntersectionObserver Replaces the scroll event listener and `getBoundingClientRect` polling with `IntersectionObserver` to eliminate layout thrashing and improve scrolling performance. - Removes `window.onscroll` handler in `docsView.js`. - Implements `IntersectionObserver` with `rootMargin` to track active headings. - Retains initial one-time scan for correct state on load. - Adds benchmark `benchmarks/docs_scroll_spy.html` and `benchmarks/docs_scroll_spy_bench.js`. Benchmark results: - Legacy: ~0.065ms/call (200 calls) -> Total ~13ms CPU (sync layout thrashing) - Optimized: ~0.028ms/call (32 calls) -> Total ~0.9ms CPU - 14x improvement in CPU time for scroll handler logic. Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- benchmarks/docs_scroll_spy.html | 160 ++++++++++++++++++++++++++++ benchmarks/docs_scroll_spy_bench.js | 82 ++++++++++++++ dist/js/docsView.js | 70 ++++++------ js/docsView.js | 70 ++++++------ 4 files changed, 312 insertions(+), 70 deletions(-) create mode 100644 benchmarks/docs_scroll_spy.html create mode 100644 benchmarks/docs_scroll_spy_bench.js diff --git a/benchmarks/docs_scroll_spy.html b/benchmarks/docs_scroll_spy.html new file mode 100644 index 00000000..f7a5e8e3 --- /dev/null +++ b/benchmarks/docs_scroll_spy.html @@ -0,0 +1,160 @@ + + + + + + Scroll Spy Benchmark + + + + +
+ + + + diff --git a/benchmarks/docs_scroll_spy_bench.js b/benchmarks/docs_scroll_spy_bench.js new file mode 100644 index 00000000..e4a5d567 --- /dev/null +++ b/benchmarks/docs_scroll_spy_bench.js @@ -0,0 +1,82 @@ +import { chromium } from 'playwright'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +(async () => { + const browser = await chromium.launch(); + const page = await browser.newPage(); + const filePath = `file://${path.join(__dirname, 'docs_scroll_spy.html')}`; + + console.log(`Loading ${filePath}`); + await page.goto(filePath); + + // --- LEGACY --- + console.log('Running Legacy Benchmark...'); + await page.evaluate(() => window.startLegacy()); + + await page.evaluate(async () => { + return new Promise(resolve => { + let scrollY = 0; + let steps = 0; + const maxSteps = 200; + const stepSize = 200; + function step() { + scrollY += stepSize; + window.scrollTo(0, scrollY); + steps++; + if (steps < maxSteps) { + requestAnimationFrame(step); + } else { + resolve(); + } + } + step(); + }); + }); + + // Wait for RAFs to flush + await page.waitForTimeout(500); + + const legacyMetrics = await page.evaluate(() => window.getMetrics()); + console.log('Legacy Metrics:', legacyMetrics); + + await page.evaluate(() => window.stopLegacy()); + await page.evaluate(() => window.scrollTo(0, 0)); + + await page.waitForTimeout(1000); + + // --- OPTIMIZED --- + console.log('Running Optimized Benchmark...'); + await page.evaluate(() => window.startOptimized()); + + await page.evaluate(async () => { + return new Promise(resolve => { + let scrollY = 0; + let steps = 0; + const maxSteps = 200; + const stepSize = 200; + function step() { + scrollY += stepSize; + window.scrollTo(0, scrollY); + steps++; + if (steps < maxSteps) { + requestAnimationFrame(step); + } else { + resolve(); + } + } + step(); + }); + }); + + // Wait for IO to flush + await page.waitForTimeout(500); + + const optimizedMetrics = await page.evaluate(() => window.getMetrics()); + console.log('Optimized Metrics:', optimizedMetrics); + + await browser.close(); +})(); diff --git a/dist/js/docsView.js b/dist/js/docsView.js index 59a37fc9..c325b27e 100644 --- a/dist/js/docsView.js +++ b/dist/js/docsView.js @@ -27,8 +27,7 @@ const scrollSpyState = { headings: [], linkLookup: new Map(), activeId: "", - rafId: null, - onScroll: null, + observer: null, }; function getHashParams() { @@ -260,15 +259,10 @@ function resetSectionHighlight() { } function clearScrollSpy() { - if (scrollSpyState.onScroll && typeof window !== "undefined") { - window.removeEventListener("scroll", scrollSpyState.onScroll); - window.removeEventListener("resize", scrollSpyState.onScroll); + if (scrollSpyState.observer) { + scrollSpyState.observer.disconnect(); + scrollSpyState.observer = null; } - if (scrollSpyState.rafId && typeof cancelAnimationFrame === "function") { - cancelAnimationFrame(scrollSpyState.rafId); - } - scrollSpyState.rafId = null; - scrollSpyState.onScroll = null; scrollSpyState.headings = []; scrollSpyState.linkLookup = new Map(); resetSectionHighlight(); @@ -345,34 +339,40 @@ function setupScrollSpy(container) { scrollSpyState.headings = trackedHeadings; scrollSpyState.linkLookup = linkLookup; - const updateActiveFromScroll = () => { - scrollSpyState.rafId = null; - const offset = 96; - let nextId = scrollSpyState.headings[0]?.id || ""; - for (const heading of scrollSpyState.headings) { - const top = heading.getBoundingClientRect().top - offset; - if (top <= 0) { - nextId = heading.id; - } else { - break; - } + // Initial scan to handle page loads in the middle of sections + const offset = 96; + let nextId = trackedHeadings[0]?.id || ""; + for (const heading of trackedHeadings) { + if (heading.getBoundingClientRect().top - offset <= 0) { + nextId = heading.id; + } else { + break; } - if (nextId) { - setActiveSection(nextId); - } - }; + } + if (nextId) { + setActiveSection(nextId); + } - const onScroll = () => { - if (scrollSpyState.rafId) { - return; - } - scrollSpyState.rafId = requestAnimationFrame(updateActiveFromScroll); - }; + if (typeof IntersectionObserver === "undefined") { + return; + } - scrollSpyState.onScroll = onScroll; - window.addEventListener("scroll", onScroll, { passive: true }); - window.addEventListener("resize", onScroll); - onScroll(); + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + setActiveSection(entry.target.id); + } + }); + }, + { + rootMargin: "-96px 0px -80% 0px", + threshold: 0, + } + ); + + trackedHeadings.forEach((heading) => observer.observe(heading)); + scrollSpyState.observer = observer; } function resolveDocItem(slug) { diff --git a/js/docsView.js b/js/docsView.js index 59a37fc9..c325b27e 100644 --- a/js/docsView.js +++ b/js/docsView.js @@ -27,8 +27,7 @@ const scrollSpyState = { headings: [], linkLookup: new Map(), activeId: "", - rafId: null, - onScroll: null, + observer: null, }; function getHashParams() { @@ -260,15 +259,10 @@ function resetSectionHighlight() { } function clearScrollSpy() { - if (scrollSpyState.onScroll && typeof window !== "undefined") { - window.removeEventListener("scroll", scrollSpyState.onScroll); - window.removeEventListener("resize", scrollSpyState.onScroll); + if (scrollSpyState.observer) { + scrollSpyState.observer.disconnect(); + scrollSpyState.observer = null; } - if (scrollSpyState.rafId && typeof cancelAnimationFrame === "function") { - cancelAnimationFrame(scrollSpyState.rafId); - } - scrollSpyState.rafId = null; - scrollSpyState.onScroll = null; scrollSpyState.headings = []; scrollSpyState.linkLookup = new Map(); resetSectionHighlight(); @@ -345,34 +339,40 @@ function setupScrollSpy(container) { scrollSpyState.headings = trackedHeadings; scrollSpyState.linkLookup = linkLookup; - const updateActiveFromScroll = () => { - scrollSpyState.rafId = null; - const offset = 96; - let nextId = scrollSpyState.headings[0]?.id || ""; - for (const heading of scrollSpyState.headings) { - const top = heading.getBoundingClientRect().top - offset; - if (top <= 0) { - nextId = heading.id; - } else { - break; - } + // Initial scan to handle page loads in the middle of sections + const offset = 96; + let nextId = trackedHeadings[0]?.id || ""; + for (const heading of trackedHeadings) { + if (heading.getBoundingClientRect().top - offset <= 0) { + nextId = heading.id; + } else { + break; } - if (nextId) { - setActiveSection(nextId); - } - }; + } + if (nextId) { + setActiveSection(nextId); + } - const onScroll = () => { - if (scrollSpyState.rafId) { - return; - } - scrollSpyState.rafId = requestAnimationFrame(updateActiveFromScroll); - }; + if (typeof IntersectionObserver === "undefined") { + return; + } - scrollSpyState.onScroll = onScroll; - window.addEventListener("scroll", onScroll, { passive: true }); - window.addEventListener("resize", onScroll); - onScroll(); + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + setActiveSection(entry.target.id); + } + }); + }, + { + rootMargin: "-96px 0px -80% 0px", + threshold: 0, + } + ); + + trackedHeadings.forEach((heading) => observer.observe(heading)); + scrollSpyState.observer = observer; } function resolveDocItem(slug) {