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) {