mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-06 04:42:57 +00:00
Merge pull request #2129 from PR0M3TH3AN/perf-docs-scroll-spy-io-10444847613200646363
⚡ Optimize Docs ScrollSpy with IntersectionObserver
This commit is contained in:
6 files changed
+325
-99
No files matched your search
@@ -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>
|
||||
@@ -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();
|
||||
})();
|
||||
Vendored
+37
-49
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -26,6 +26,7 @@ const IGNORED_DIRS = new Set([
|
||||
"logs",
|
||||
"playwright-report",
|
||||
"test-results",
|
||||
"benchmarks",
|
||||
]);
|
||||
|
||||
const DIST_SCAN_PREFIXES = new Set([
|
||||
|
||||
Reference in new issue
Block a user