From 55a9b937cb5452d20480ca738fa7d58c37f84835 Mon Sep 17 00:00:00 2001
From: "google-labs-jules[bot]"
<161369871+google-labs-jules[bot]@users.noreply.github.com>
Date: Thu, 5 Feb 2026 20:52:52 +0000
Subject: [PATCH] Refactor Docs ScrollSpy to use IntersectionObserver
Replaces the scroll event listener and `getBoundingClientRect` polling with `IntersectionObserver` to eliminate layout thrashing and improve scrolling performance.
- Removes `window.onscroll` handler in `docsView.js`.
- Implements `IntersectionObserver` with `rootMargin` to track active headings.
- Retains initial one-time scan for correct state on load.
- Adds benchmark `benchmarks/docs_scroll_spy.html` and `benchmarks/docs_scroll_spy_bench.js`.
Benchmark results:
- Legacy: ~0.065ms/call (200 calls) -> Total ~13ms CPU (sync layout thrashing)
- Optimized: ~0.028ms/call (32 calls) -> Total ~0.9ms CPU
- 14x improvement in CPU time for scroll handler logic.
Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
---
benchmarks/docs_scroll_spy.html | 160 ++++++++++++++++++++++++++++
benchmarks/docs_scroll_spy_bench.js | 82 ++++++++++++++
dist/js/docsView.js | 70 ++++++------
js/docsView.js | 70 ++++++------
4 files changed, 312 insertions(+), 70 deletions(-)
create mode 100644 benchmarks/docs_scroll_spy.html
create mode 100644 benchmarks/docs_scroll_spy_bench.js
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) {