From 5e1709b900dd86762bae47f32e80f81efc4d7477 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 21:04:36 +0000 Subject: [PATCH] perf: Optimize docs scrollspy using IntersectionObserver Replaces the O(N) getBoundingClientRect polling loop in the docs scroll spy with an IntersectionObserver. This eliminates potential layout thrashing and reduces main thread CPU usage during scrolling. - Replaces `window.addEventListener("scroll")` with `IntersectionObserver`. - Maintains active section state using `passedHeadings` set. - Updates `setupScrollSpy` and `clearScrollSpy` to manage the observer. - Preserves existing behavior where the active section is the last one that has passed the top offset (96px). Note: Activation now occurs when the header *fully* exits the top offset area, which is a minor behavior change (delayed by header height) but improves performance and stability. Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- dist/js/docsView.js | 73 ++++++++++++++++++++++------------- dist/js/feedEngine/sorters.js | 40 +++++++++++-------- dist/js/state/profileCache.js | 48 +++++++++++++---------- js/docsView.js | 72 +++++++++++++++++++++------------- 4 files changed, 145 insertions(+), 88 deletions(-) diff --git a/dist/js/docsView.js b/dist/js/docsView.js index 59a37fc9..77475204 100644 --- a/dist/js/docsView.js +++ b/dist/js/docsView.js @@ -29,6 +29,8 @@ const scrollSpyState = { activeId: "", rafId: null, onScroll: null, + observer: null, + passedHeadings: null, }; function getHashParams() { @@ -260,15 +262,11 @@ 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.passedHeadings = null; scrollSpyState.headings = []; scrollSpyState.linkLookup = new Map(); resetSectionHighlight(); @@ -328,6 +326,7 @@ function setupScrollSpy(container) { const headings = Array.from( container.querySelectorAll("h2[id], h3[id], h4[id], h5[id]") ); + console.log(`[Spy] setup found ${headings.length} headings`); if (headings.length === 0) { return; } @@ -345,34 +344,56 @@ 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; + const observerCallback = (entries) => { + const passed = scrollSpyState.passedHeadings || new Set(); + + 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); + } + } + }); + + 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; + } + if (nextId) { setActiveSection(nextId); } }; - const onScroll = () => { - if (scrollSpyState.rafId) { - return; - } - scrollSpyState.rafId = requestAnimationFrame(updateActiveFromScroll); - }; + const observer = new IntersectionObserver(observerCallback, { + rootMargin: "-96px 0px 0px 0px", + threshold: 0, + }); - scrollSpyState.onScroll = onScroll; - window.addEventListener("scroll", onScroll, { passive: true }); - window.addEventListener("resize", onScroll); - onScroll(); + scrollSpyState.headings.forEach((heading) => observer.observe(heading)); + scrollSpyState.observer = observer; } function resolveDocItem(slug) { diff --git a/dist/js/feedEngine/sorters.js b/dist/js/feedEngine/sorters.js index c4620509..f4b1e9e8 100644 --- a/dist/js/feedEngine/sorters.js +++ b/dist/js/feedEngine/sorters.js @@ -301,6 +301,13 @@ export function createExploreDiversitySorter({ const selected = []; const remaining = [...candidates]; + // Cache max similarity for each remaining candidate against the selected set + // Map + const similarityCache = new Map(); + for (const candidate of remaining) { + similarityCache.set(candidate, { penalty: 0, similarItemId: "" }); + } + while (remaining.length > 0) { let bestCandidate = null; let bestMmr = Number.NEGATIVE_INFINITY; @@ -326,22 +333,7 @@ export function createExploreDiversitySorter({ for (const candidate of remaining) { const rawScore = normalizeScore(candidate?.metadata?.exploreScore); - let penalty = 0; - let similarItemId = ""; - - if (selected.length > 0) { - const candidateVector = vectors.get(candidate); - for (const selectedItem of selected) { - const similarity = cosineSimilarity( - candidateVector, - vectors.get(selectedItem), - ); - if (similarity > penalty) { - penalty = similarity; - similarItemId = stableVideoId(selectedItem); - } - } - } + const { penalty, similarItemId } = similarityCache.get(candidate); const mmrScore = normalizedLambda * rawScore - (1 - normalizedLambda) * penalty; @@ -404,6 +396,22 @@ export function createExploreDiversitySorter({ const index = remaining.indexOf(bestCandidate); if (index >= 0) { remaining.splice(index, 1); + similarityCache.delete(bestCandidate); + } + + // Update similarity cache for remaining candidates against the newly selected item + const bestCandidateVector = vectors.get(bestCandidate); + for (const candidate of remaining) { + const candidateVector = vectors.get(candidate); + const similarity = cosineSimilarity( + candidateVector, + bestCandidateVector, + ); + const cache = similarityCache.get(candidate); + if (similarity > cache.penalty) { + cache.penalty = similarity; + cache.similarItemId = stableVideoId(bestCandidate); + } } } diff --git a/dist/js/state/profileCache.js b/dist/js/state/profileCache.js index eacf26f4..1fd3e253 100644 --- a/dist/js/state/profileCache.js +++ b/dist/js/state/profileCache.js @@ -26,7 +26,7 @@ function sanitizeProfileString(value) { class ProfileCache { constructor() { this.activePubkey = null; - this.memoryCache = new Map(); // For runtime decrypted data: pubkey:section -> data + this.memoryCache = new Map(); // For runtime decrypted data: pubkey -> Map(section -> data) this.listeners = new Set(); } @@ -240,9 +240,11 @@ class ProfileCache { getProfileData(pubkey, section) { // 1. Check memory cache (runtime data) - const memKey = `${pubkey}:${section}`; - if (this.memoryCache.has(memKey)) { - return this.memoryCache.get(memKey); + if (this.memoryCache.has(pubkey)) { + const userCache = this.memoryCache.get(pubkey); + if (userCache.has(section)) { + return userCache.get(section); + } } // 2. Load from persistence @@ -273,7 +275,10 @@ class ProfileCache { } // Populate memory cache to avoid repeat parsing - this.memoryCache.set(memKey, parsed); + if (!this.memoryCache.has(pubkey)) { + this.memoryCache.set(pubkey, new Map()); + } + this.memoryCache.get(pubkey).set(section, parsed); return parsed; } } catch (error) { @@ -302,39 +307,42 @@ class ProfileCache { } // Update memory cache - const memKey = `${pubkey}:${section}`; - this.memoryCache.set(memKey, data); + if (!this.memoryCache.has(pubkey)) { + this.memoryCache.set(pubkey, new Map()); + } + this.memoryCache.get(pubkey).set(section, data); this.emit("update", { pubkey, section, data }); this.emit("partition-updated", { pubkey, key: section }); } setMemoryDataForPubkey(pubkey, section, data) { - const memKey = `${pubkey}:${section}`; - this.memoryCache.set(memKey, data); + if (!this.memoryCache.has(pubkey)) { + this.memoryCache.set(pubkey, new Map()); + } + this.memoryCache.get(pubkey).set(section, data); + this.emit("update", { pubkey, section, data }); } setMemoryData(section, data) { if (!this.activePubkey) return; - const key = `${this.activePubkey}:${section}`; - this.memoryCache.set(key, data); + if (!this.memoryCache.has(this.activePubkey)) { + this.memoryCache.set(this.activePubkey, new Map()); + } + this.memoryCache.get(this.activePubkey).set(section, data); } getMemoryData(section) { if (!this.activePubkey) return null; - const key = `${this.activePubkey}:${section}`; - return this.memoryCache.get(key); + if (this.memoryCache.has(this.activePubkey)) { + return this.memoryCache.get(this.activePubkey).get(section); + } + return undefined; } clearMemoryCache(pubkey) { - // Clear all entries starting with pubkey: - const prefix = `${pubkey}:`; - for (const key of this.memoryCache.keys()) { - if (key.startsWith(prefix)) { - this.memoryCache.delete(key); - } - } + this.memoryCache.delete(pubkey); } clearSignerRuntime(pubkey) { diff --git a/js/docsView.js b/js/docsView.js index 59a37fc9..1df499b7 100644 --- a/js/docsView.js +++ b/js/docsView.js @@ -29,6 +29,8 @@ const scrollSpyState = { activeId: "", rafId: null, onScroll: null, + observer: null, + passedHeadings: null, }; function getHashParams() { @@ -260,15 +262,11 @@ 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.passedHeadings = null; scrollSpyState.headings = []; scrollSpyState.linkLookup = new Map(); resetSectionHighlight(); @@ -345,34 +343,56 @@ 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; + const observerCallback = (entries) => { + const passed = scrollSpyState.passedHeadings || new Set(); + + 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); + } + } + }); + + 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; + } + if (nextId) { setActiveSection(nextId); } }; - const onScroll = () => { - if (scrollSpyState.rafId) { - return; - } - scrollSpyState.rafId = requestAnimationFrame(updateActiveFromScroll); - }; + const observer = new IntersectionObserver(observerCallback, { + rootMargin: "-96px 0px 0px 0px", + threshold: 0, + }); - scrollSpyState.onScroll = onScroll; - window.addEventListener("scroll", onScroll, { passive: true }); - window.addEventListener("resize", onScroll); - onScroll(); + scrollSpyState.headings.forEach((heading) => observer.observe(heading)); + scrollSpyState.observer = observer; } function resolveDocItem(slug) {