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([