mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-01 10:22:25 +00:00
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 <[email protected]>
This commit is contained in:
1 parent
9caef9f63b
commit
5e1709b900
4 files changed
+145
-88
No files matched your search
Vendored
+47
-26
@@ -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) {
|
||||
|
||||
Vendored
+24
-16
@@ -301,6 +301,13 @@ export function createExploreDiversitySorter({
|
||||
const selected = [];
|
||||
const remaining = [...candidates];
|
||||
|
||||
// Cache max similarity for each remaining candidate against the selected set
|
||||
// Map<CandidateItem, { penalty: number, similarItemId: string }>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Vendored
+28
-20
@@ -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) {
|
||||
|
||||
+46
-26
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user