diff --git a/benchmarks/docs_scroll_spy.html b/benchmarks/docs_scroll_spy.html new file mode 100644 index 00000000..38045e65 --- /dev/null +++ b/benchmarks/docs_scroll_spy.html @@ -0,0 +1,166 @@ + + + + + + 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 77475204..a6756202 100644 --- a/dist/js/docsView.js +++ b/dist/js/docsView.js @@ -27,10 +27,7 @@ const scrollSpyState = { headings: [], linkLookup: new Map(), activeId: "", - rafId: null, - onScroll: null, observer: null, - passedHeadings: null, }; function getHashParams() { @@ -266,7 +263,6 @@ function clearScrollSpy() { scrollSpyState.observer.disconnect(); scrollSpyState.observer = null; } - scrollSpyState.passedHeadings = null; scrollSpyState.headings = []; scrollSpyState.linkLookup = new Map(); resetSectionHighlight(); @@ -344,55 +340,47 @@ function setupScrollSpy(container) { scrollSpyState.headings = trackedHeadings; scrollSpyState.linkLookup = linkLookup; - const observerCallback = (entries) => { - const passed = scrollSpyState.passedHeadings || new Set(); + // 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); + } - entries.forEach((entry) => { - // If the heading intersects (enters the viewport from top or bottom), - // we check if it is above the offset. - if (entry.isIntersecting) { - // If it is intersecting, it is definitely "visible" in the viewport (minus top margin). - // It means it's not "passed" (above the top). - passed.delete(entry.target.id); - } else { - // If not intersecting, check if it's above. - // rootMargin top is -96px. The root starts at 96px from top. - // If entry.boundingClientRect.top < 96, it is above the root. - if (entry.boundingClientRect.top < 96) { - passed.add(entry.target.id); - } else { - passed.delete(entry.target.id); + if (typeof IntersectionObserver === "undefined") { + return; + } + + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + setActiveSection(entry.target.id); + } else if (entry.boundingClientRect.top > 96) { + // Scrolling up: The heading has exited the active zone downwards (top > 96px). + // Activate the previous heading. + const index = trackedHeadings.findIndex((h) => h.id === entry.target.id); + if (index > 0) { + setActiveSection(trackedHeadings[index - 1].id); + } } - } - }); - - scrollSpyState.passedHeadings = passed; - - let nextId = ""; - // Find the last heading in the passed set (by DOM order) - for (let i = scrollSpyState.headings.length - 1; i >= 0; i--) { - const id = scrollSpyState.headings[i].id; - if (passed.has(id)) { - nextId = id; - break; - } - } - // Fallback to the first heading if none are passed yet (scrolled to top) - if (!nextId && scrollSpyState.headings.length > 0) { - nextId = scrollSpyState.headings[0].id; + }); + }, + { + // Top offset 96px. Bottom offset 50% to support shorter screens. + rootMargin: "-96px 0px -50% 0px", + threshold: 0, } + ); - if (nextId) { - setActiveSection(nextId); - } - }; - - const observer = new IntersectionObserver(observerCallback, { - rootMargin: "-96px 0px 0px 0px", - threshold: 0, - }); - - scrollSpyState.headings.forEach((heading) => observer.observe(heading)); + trackedHeadings.forEach((heading) => observer.observe(heading)); scrollSpyState.observer = observer; } diff --git a/js/docsView.js b/js/docsView.js index 1df499b7..4f4d3868 100644 --- a/js/docsView.js +++ b/js/docsView.js @@ -27,10 +27,7 @@ const scrollSpyState = { headings: [], linkLookup: new Map(), activeId: "", - rafId: null, - onScroll: null, observer: null, - passedHeadings: null, }; function getHashParams() { @@ -266,7 +263,6 @@ function clearScrollSpy() { scrollSpyState.observer.disconnect(); scrollSpyState.observer = null; } - scrollSpyState.passedHeadings = null; scrollSpyState.headings = []; scrollSpyState.linkLookup = new Map(); resetSectionHighlight(); @@ -343,55 +339,47 @@ function setupScrollSpy(container) { scrollSpyState.headings = trackedHeadings; scrollSpyState.linkLookup = linkLookup; - const observerCallback = (entries) => { - const passed = scrollSpyState.passedHeadings || new Set(); + // 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); + } - entries.forEach((entry) => { - // If the heading intersects (enters the viewport from top or bottom), - // we check if it is above the offset. - if (entry.isIntersecting) { - // If it is intersecting, it is definitely "visible" in the viewport (minus top margin). - // It means it's not "passed" (above the top). - passed.delete(entry.target.id); - } else { - // If not intersecting, check if it's above. - // rootMargin top is -96px. The root starts at 96px from top. - // If entry.boundingClientRect.top < 96, it is above the root. - if (entry.boundingClientRect.top < 96) { - passed.add(entry.target.id); - } else { - passed.delete(entry.target.id); + if (typeof IntersectionObserver === "undefined") { + return; + } + + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + setActiveSection(entry.target.id); + } else if (entry.boundingClientRect.top > 96) { + // Scrolling up: The heading has exited the active zone downwards (top > 96px). + // Activate the previous heading. + const index = trackedHeadings.findIndex((h) => h.id === entry.target.id); + if (index > 0) { + setActiveSection(trackedHeadings[index - 1].id); + } } - } - }); - - scrollSpyState.passedHeadings = passed; - - let nextId = ""; - // Find the last heading in the passed set (by DOM order) - for (let i = scrollSpyState.headings.length - 1; i >= 0; i--) { - const id = scrollSpyState.headings[i].id; - if (passed.has(id)) { - nextId = id; - break; - } - } - // Fallback to the first heading if none are passed yet (scrolled to top) - if (!nextId && scrollSpyState.headings.length > 0) { - nextId = scrollSpyState.headings[0].id; + }); + }, + { + // Top offset 96px. Bottom offset 50% to support shorter screens. + rootMargin: "-96px 0px -50% 0px", + threshold: 0, } + ); - if (nextId) { - setActiveSection(nextId); - } - }; - - const observer = new IntersectionObserver(observerCallback, { - rootMargin: "-96px 0px 0px 0px", - threshold: 0, - }); - - scrollSpyState.headings.forEach((heading) => observer.observe(heading)); + trackedHeadings.forEach((heading) => observer.observe(heading)); scrollSpyState.observer = observer; } diff --git a/scripts/check-hex.js b/scripts/check-hex.js index 3874779d..08957649 100755 --- a/scripts/check-hex.js +++ b/scripts/check-hex.js @@ -31,7 +31,8 @@ const IGNORED_GLOBS = [ 'tests/unit/embed-accent.test.mjs', 'tests/visual/embed-layout.spec.ts', 'ai/**', - '**/*.ai' + '**/*.ai', + 'benchmarks/**' ]; const gitArgs = [ diff --git a/scripts/check-inline-styles.mjs b/scripts/check-inline-styles.mjs index 2ee3858a..e2d54eea 100644 --- a/scripts/check-inline-styles.mjs +++ b/scripts/check-inline-styles.mjs @@ -26,6 +26,7 @@ const IGNORED_DIRS = new Set([ "logs", "playwright-report", "test-results", + "benchmarks", ]); const DIST_SCAN_PREFIXES = new Set([