From 4e799a4a116738beb5be2be36043c5d131f7c17c Mon Sep 17 00:00:00 2001 From: Yash Vardhan Date: Mon, 10 Aug 2026 12:57:44 +0530 Subject: [PATCH] perf(docs): implement high-performance DOM-based scroll spy and progress bar --- frontend/app/[topic]/TopicLayout.tsx | 164 ++++++++++++++++++--------- 1 file changed, 110 insertions(+), 54 deletions(-) diff --git a/frontend/app/[topic]/TopicLayout.tsx b/frontend/app/[topic]/TopicLayout.tsx index c0370ef..faf22e9 100644 --- a/frontend/app/[topic]/TopicLayout.tsx +++ b/frontend/app/[topic]/TopicLayout.tsx @@ -1,5 +1,5 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState, useRef, useCallback } from "react"; import { useTheme } from "../../src/context/ThemeContext"; import { useRouter } from "next/navigation"; import { FaGithub } from "react-icons/fa"; @@ -58,13 +58,21 @@ export default function TopicLayout({ // User Engagement State const [feedbackSubmitted, setFeedbackSubmitted] = useState(false); - // Scroll to Top state - const [showScrollTop, setShowScrollTop] = useState(false); - // UX Enhancements: OS detection for the shortcut hint const [isMounted, setIsMounted] = useState(false); const [modifierKey, setModifierKey] = useState("⌘"); + // Track active headings + const [expandedOutline, setExpandedOutline] = useState>({}); + const [activeOutlineId, setActiveOutlineId] = useState(null); + + // High-performance refs to bypass React state re-renders on scroll + const progressBarRef = useRef(null); + const scrollTopBtnRef = useRef(null); + const isClickScrolling = useRef(false); + const scrollTimeout = useRef(null); + const headingElementsRef = useRef<{ id: string; el: HTMLElement }[]>([]); + useEffect(() => { setIsMounted(true); if (typeof window !== "undefined") { @@ -84,15 +92,6 @@ export default function TopicLayout({ }; }, [isMobileMenuOpen, cmdOpen]); - useEffect(() => { - const handleScroll = () => { - setShowScrollTop(window.scrollY > 400); - }; - - window.addEventListener("scroll", handleScroll, { passive: true }); - return () => window.removeEventListener("scroll", handleScroll); - }, []); - useEffect(() => { const article = document.querySelector("article"); const headingNodes = article @@ -106,8 +105,6 @@ export default function TopicLayout({ })); setDomHeadings(nextHeadings); - - // Reset feedback state when navigating to a new topic setFeedbackSubmitted(false); }, [children, topicKey]); @@ -147,7 +144,6 @@ export default function TopicLayout({ if (!hasSearchQuery) return []; return fuse.search(docSearch.trim()).slice(0, 12); }, [docSearch, hasSearchQuery, fuse]); - // ----------------------------------- // Smart check for Home/Directory const searchLower = docSearch.trim().toLowerCase(); @@ -180,47 +176,90 @@ export default function TopicLayout({ return groups; }, [activeHeadings]); - const [expandedOutline, setExpandedOutline] = useState>({}); - const [activeOutlineId, setActiveOutlineId] = useState(null); - + // Cache heading DOM nodes to avoid querying on every frame useEffect(() => { const ids = outlineGroups.flatMap((group) => [ group.id, ...group.items.map((item) => item.id), ]); - if (ids.length === 0) { - setActiveOutlineId(null); - return; - } - - const updateActiveOutline = () => { - const offset = 140; - let currentId: string | null = ids[0] ?? null; + + headingElementsRef.current = ids + .map((id) => { + const el = document.getElementById(id); + return el ? { id, el } : null; + }) + .filter(Boolean) as { id: string; el: HTMLElement }[]; + }, [outlineGroups]); - for (const id of ids) { - const element = document.getElementById(id); - if (!element) continue; + // --- UNIFIED HIGH-PERFORMANCE SCROLL LOOP --- + useEffect(() => { + let ticking = false; - const rect = element.getBoundingClientRect(); - if (rect.top <= offset) { - currentId = id; - } + const handleScroll = () => { + if (!ticking) { + window.requestAnimationFrame(() => { + const currentScrollY = window.scrollY; + + // 1. Scroll-to-Top Button (Direct DOM update) + if (scrollTopBtnRef.current) { + if (currentScrollY > 400) { + scrollTopBtnRef.current.classList.remove("translate-y-8", "opacity-0", "pointer-events-none"); + scrollTopBtnRef.current.classList.add("translate-y-0", "opacity-100"); + } else { + scrollTopBtnRef.current.classList.add("translate-y-8", "opacity-0", "pointer-events-none"); + scrollTopBtnRef.current.classList.remove("translate-y-0", "opacity-100"); + } + } + + // 2. Reading Progress (Direct DOM update, bypassing React state) + if (progressBarRef.current) { + const totalHeight = document.documentElement.scrollHeight - document.documentElement.clientHeight; + if (totalHeight > 0) { + const progress = (currentScrollY / totalHeight) * 100; + const safeProgress = Math.min(100, Math.max(0, progress)); + progressBarRef.current.style.width = `${safeProgress}%`; + progressBarRef.current.style.boxShadow = safeProgress > 0 ? '0 0 10px rgba(198, 153, 255, 0.5)' : 'none'; + } + } + + // 3. Scroll Spy (Active Heading Tracking) + // Bypass calculation if user just clicked a TOC link (prevents flicker) + if (!isClickScrolling.current && headingElementsRef.current.length > 0) { + let currentActiveId = null; + + for (const { id, el } of headingElementsRef.current) { + const rect = el.getBoundingClientRect(); + // 120px offset to account for sticky header and trigger breathing room + if (rect.top <= 120) { + currentActiveId = id; + } else { + break; // Elements are in DOM order; stop iterating once we find one below threshold + } + } + + // Fallback: If scrolled to absolute top, highlight the first item + if (!currentActiveId && currentScrollY < 50) { + currentActiveId = headingElementsRef.current[0].id; + } + + if (currentActiveId) { + // React state is fine here because we only update when the ID actually changes + setActiveOutlineId((prev) => (prev !== currentActiveId ? currentActiveId : prev)); + } + } + + ticking = false; + }); + ticking = true; } - - setActiveOutlineId((previous) => - previous === currentId ? previous : currentId, - ); }; - updateActiveOutline(); - window.addEventListener("scroll", updateActiveOutline, { passive: true }); - window.addEventListener("resize", updateActiveOutline); + window.addEventListener("scroll", handleScroll, { passive: true }); + handleScroll(); // Init immediately on mount - return () => { - window.removeEventListener("scroll", updateActiveOutline); - window.removeEventListener("resize", updateActiveOutline); - }; - }, [outlineGroups]); + return () => window.removeEventListener("scroll", handleScroll); + }, []); // Empty dependency array ensures scroll listener is only bound once + // --------------------------------------------- // Global Command Palette Shortcut and Escape handling useEffect(() => { @@ -268,7 +307,6 @@ export default function TopicLayout({ const getTopicIcon = (topicId: string) => { const value = topicId.toLowerCase(); - if (value.includes("javascript") || value.includes("js")) return TerminalSquare; if (value.includes("python")) return Braces; if (value.includes("react")) return Workflow; @@ -276,14 +314,27 @@ export default function TopicLayout({ return BookOpenText; }; - const scrollToSection = (id: string) => { + const scrollToSection = useCallback((id: string) => { if (!id) return; const target = document.getElementById(id); if (!target) return; + + // Lock scroll spy to prevent flickering + isClickScrolling.current = true; + setActiveOutlineId(id); + + // Clear existing timeout if multiple clicks happen quickly + if (scrollTimeout.current) clearTimeout(scrollTimeout.current); + const headerOffset = 88; const top = target.getBoundingClientRect().top + window.scrollY - headerOffset; window.scrollTo({ top, behavior: "smooth" }); - }; + + // Unlock scroll spy after smooth scroll finishes + scrollTimeout.current = setTimeout(() => { + isClickScrolling.current = false; + }, 800); + }, []); const scrollToTop = () => { window.scrollTo({ top: 0, behavior: "smooth" }); @@ -305,6 +356,13 @@ export default function TopicLayout({ return (
+ {/* READING PROGRESS BAR - Updated for high performance rendering */} +
+ {/* TOAST CONTAINER */} { event.preventDefault(); - setActiveOutlineId(item.id); scrollToSection(item.id); }} className={`block truncate rounded-md px-2 py-1 text-[13px] font-medium transition-colors duration-200 ${ @@ -902,13 +959,12 @@ export default function TopicLayout({
- {/* SCROLL TO TOP FAB */} + {/* SCROLL TO TOP FAB - Updated to completely avoid state triggers */}