Merge pull request #2129 from PR0M3TH3AN/perf-docs-scroll-spy-io-10444847613200646363

⚡ Optimize Docs ScrollSpy with IntersectionObserver
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2026-02-05 16:29:41 -05:00
commit b00ca0b3b3
6 files changed
+325 -99

No files matched your search

+166
View File
@@ -0,0 +1,166 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Scroll Spy Benchmark</title>
<style>
body { font-family: sans-serif; line-height: 1.6; margin: 0; padding: 0; }
nav { position: fixed; top: 0; left: 0; width: 200px; height: 100vh; overflow-y: auto; background: #f0f0f0; padding: 20px; box-sizing: border-box; }
main { margin-left: 220px; padding: 20px; }
section { height: 800px; border-bottom: 1px solid #ccc; padding-top: 100px; }
.active { color: red; font-weight: bold; }
h2 { margin: 0; }
.heavy {
display: flex;
flex-wrap: wrap;
}
.heavy div {
width: 10px;
height: 10px;
margin: 1px;
background: #ddd;
}
</style>
</head>
<body>
<nav id="toc"></nav>
<main id="content"></main>
<script>
const NUM_SECTIONS = 200;
const OFFSET = 96;
const headings = [];
const toc = document.getElementById('toc');
const content = document.getElementById('content');
for (let i = 0; i < NUM_SECTIONS; i++) {
const id = `section-${i}`;
const link = document.createElement('a');
link.href = `#${id}`;
link.textContent = `Section ${i}`;
link.style.display = 'block';
link.id = `link-${id}`;
toc.appendChild(link);
const section = document.createElement('section');
const h2 = document.createElement('h2');
h2.id = id;
h2.textContent = `Section ${i}`;
section.appendChild(h2);
const heavy = document.createElement('div');
heavy.className = 'heavy';
for (let j = 0; j < 500; j++) {
const d = document.createElement('div');
heavy.appendChild(d);
}
section.appendChild(heavy);
content.appendChild(section);
headings.push(h2);
}
let activeId = "";
function setActive(id) {
if (activeId === id) return;
if (activeId) {
const prev = document.getElementById(`link-${activeId}`);
if (prev) prev.classList.remove('active');
}
activeId = id;
if (id) {
const next = document.getElementById(`link-${id}`);
if (next) next.classList.add('active');
}
}
let totalTime = 0;
let count = 0;
function updateLegacy() {
const start = performance.now();
let nextId = headings[0].id;
for (const heading of headings) {
const top = heading.getBoundingClientRect().top - OFFSET;
if (top <= 0) {
nextId = heading.id;
} else {
break;
}
}
setActive(nextId);
totalTime += (performance.now() - start);
count++;
}
let observer;
function setupOptimized() {
if (observer) observer.disconnect();
observer = new IntersectionObserver((entries) => {
const start = performance.now();
entries.forEach(entry => {
if (entry.isIntersecting) {
setActive(entry.target.id);
} else if (entry.boundingClientRect.top > 96) {
// Scrolling up simulation
const index = headings.findIndex((h) => h.id === entry.target.id);
if (index > 0) {
setActive(headings[index - 1].id);
}
}
});
totalTime += (performance.now() - start);
count++;
}, {
rootMargin: "-96px 0px -50% 0px",
threshold: 0
});
headings.forEach(h => observer.observe(h));
}
let rafId;
window.startLegacy = () => {
totalTime = 0;
count = 0;
window.onScrollLegacy = () => {
// To measure pure layout thrashing cost, we should NOT wrap in RAF in the benchmark logic
// if we want to ensure it runs every scroll event.
// But realistically, it IS wrapped in RAF in production.
// So we stick to RAF, but we measure the execution time of the *inner* function.
if (rafId) return;
rafId = requestAnimationFrame(() => {
updateLegacy();
rafId = null;
});
};
window.addEventListener('scroll', window.onScrollLegacy);
};
window.stopLegacy = () => {
window.removeEventListener('scroll', window.onScrollLegacy);
if (rafId) cancelAnimationFrame(rafId);
rafId = null;
};
window.startOptimized = () => {
totalTime = 0;
count = 0;
setupOptimized();
};
window.stopOptimized = () => {
if (observer) observer.disconnect();
}
window.getMetrics = () => ({
avgTime: count ? totalTime / count : 0,
totalTime,
count
});
</script>
</body>
</html>
+82
View File
@@ -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();
})();
+37 -49
View File
@@ -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;
}
+37 -49
View File
@@ -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;
}
+2 -1
View File
@@ -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 = [
+1
View File
@@ -26,6 +26,7 @@ const IGNORED_DIRS = new Set([
"logs",
"playwright-report",
"test-results",
"benchmarks",
]);
const DIST_SCAN_PREFIXES = new Set([