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