From 09cb0ffe70587179b6d147b00b5d0923daa55471 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 03:16:54 +0000 Subject: [PATCH] =?UTF-8?q?=E2=9A=A1=20Bolt:=20optimize=20scroll=20perform?= =?UTF-8?q?ance=20and=20reduce=20redundant=20re-renders?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit implements several performance optimizations in the main App component: - Replaces `scrollProgress` state with `progressBarRef` and direct DOM manipulation to eliminate component-wide re-renders during scrolling. - Throttles the scroll handler using `requestAnimationFrame` and adds `{ passive: true }` to the listener for smoother interactions. - Uses `activeHeadingRef` to ensure `setActiveHeading` state updates only occur when the active heading actually changes. - Converts `filePath` state to a ref as it was only used for logic and not rendering. - Resolves linting errors related to unused variables, redundant React imports, and control characters in regex. - Improves robustness by adding a check for `totalScrollable > 0` before calculating progress. Co-authored-by: VagueDustin <127912586+VagueDustin@users.noreply.github.com> --- .jules/bolt.md | 3 ++ package-lock.json | 57 +++++++++++++++++++++++---------- src/App.jsx | 81 +++++++++++++++++++++++++++-------------------- 3 files changed, 90 insertions(+), 51 deletions(-) create mode 100644 .jules/bolt.md diff --git a/.jules/bolt.md b/.jules/bolt.md new file mode 100644 index 0000000..7d1f782 --- /dev/null +++ b/.jules/bolt.md @@ -0,0 +1,3 @@ +## 2026-06-30 - Throttled Scroll & Ref-based DOM Updates +**Learning:** High-frequency events like scrolling can bottleneck React apps by triggering component-wide re-renders for non-critical UI updates (like progress bars) or redundant state changes (like active heading tracking). Bypassing React reconciliation for the progress bar via direct DOM refs and throttling state updates for the TOC using `requestAnimationFrame` and `useRef` comparisons significantly reduces the workload on the main thread during interaction. +**Action:** Always prefer direct DOM updates via refs for purely decorative, high-frequency UI elements. Use `useRef` to track state-like values needed for logic but not rendering (like current heading or file path) to avoid 'state dependency loops' in `useEffect` and unnecessary re-renders. diff --git a/package-lock.json b/package-lock.json index bc9d93a..8bef1f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "fate-markdown-viewer", - "version": "1.3.0", + "version": "1.4.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "fate-markdown-viewer", - "version": "1.3.0", + "version": "1.4.2", "license": "MIT", "dependencies": { "@phosphor-icons/react": "^2.1.10", @@ -73,7 +73,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -606,6 +605,7 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, + "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -627,6 +627,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -636,6 +637,31 @@ "node": ">=14.14" } }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", @@ -643,6 +669,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -1534,7 +1561,6 @@ "integrity": "sha512-esJiCAnl0kfpNdE69f3So4WJUXy95dLZydX0KwK46riIHDzHM7O9Vtf9xCHW0PXIqvgqNrswl522kA/5yx+F4w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1644,7 +1670,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1678,7 +1703,6 @@ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -2172,7 +2196,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -2692,7 +2715,8 @@ "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/cross-env": { "version": "10.1.0", @@ -2939,7 +2963,6 @@ "integrity": "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", @@ -3326,6 +3349,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -3346,6 +3370,7 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -3361,6 +3386,7 @@ "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", "dev": true, "license": "MIT", + "peer": true, "optionalDependencies": { "graceful-fs": "^4.1.6" } @@ -3371,6 +3397,7 @@ "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 4.0.0" } @@ -3498,7 +3525,6 @@ "integrity": "sha512-AyIKhnOBuOAdueD7RB3xB+YeAWScb9jHsJBgH2Hcde8InP5JYhqrRR6iTMHyTEwgENK54Cp44e4v8BwNhsuHuw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -5100,7 +5126,6 @@ "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", "license": "MIT", - "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -5252,6 +5277,7 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -5678,7 +5704,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -5810,6 +5835,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -5827,6 +5853,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -5947,7 +5974,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -5957,7 +5983,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -6086,6 +6111,7 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -6456,6 +6482,7 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -6694,7 +6721,6 @@ "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -7081,7 +7107,6 @@ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/App.jsx b/src/App.jsx index 0ebee62..7efe293 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; import { useDropzone } from 'react-dropzone'; import { marked } from 'marked'; import DOMPurify from 'dompurify'; @@ -25,13 +25,12 @@ marked.use(markedKatex({ throwOnError: false, nonStandard: true })); function App() { const [fileContent, setFileContent] = useState(''); const [fileName, setFileName] = useState(''); - const [filePath, setFilePath] = useState(null); + const filePathRef = useRef(null); const [isViewing, setIsViewing] = useState(false); const [appVersion, setAppVersion] = useState(''); const [updateStatus, setUpdateStatus] = useState(''); const [updateAction, setUpdateAction] = useState(null); const [toc, setToc] = useState([]); - const [scrollProgress, setScrollProgress] = useState(0); const [isSidebarOpen, setIsSidebarOpen] = useState(true); const [activeHeading, setActiveHeading] = useState(''); const [sidebarWidth, setSidebarWidth] = useState(300); @@ -59,6 +58,9 @@ function App() { const [isLoading, setIsLoading] = useState(false); const contentRef = useRef(null); + const progressBarRef = useRef(null); + const activeHeadingRef = useRef(''); + const scrollRafRef = useRef(null); const isResizing = useRef(false); const startResizing = useCallback((e) => { @@ -87,6 +89,7 @@ function App() { setIsLoading(true); // Repair mathematically corrupted control-characters from unescaped markdown generators + /* eslint-disable no-control-regex */ const repairedContent = content .replace(/\x09heta/g, '\\theta') .replace(/\x09ext/g, '\\text') @@ -101,6 +104,7 @@ function App() { .replace(/\x0Dho/g, '\\rho') .replace(/\x0B/g, '\\v') .replace(/\\ /g, '\\\\ '); + /* eslint-enable no-control-regex */ const rawHtml = marked.parse(repairedContent); const cleanHtml = DOMPurify.sanitize(rawHtml, { @@ -182,16 +186,13 @@ function App() { }); window.electronAPI.onOpenFile((content, name, path) => { setFileName(name); - setFilePath(path); + filePathRef.current = path; processMarkdown(content, path); }); window.electronAPI.onFileChanged((content) => { // Keep the same path - setFilePath(prevPath => { - processMarkdown(content, prevPath); - return prevPath; - }); + processMarkdown(content, filePathRef.current); }); window.electronAPI.getAppVersion().then(version => setAppVersion(version)); @@ -262,43 +263,53 @@ function App() { // Scroll Progress and Active Heading Tracking useEffect(() => { const handleScroll = () => { - if (!contentRef.current) return; - - const { scrollTop, scrollHeight, clientHeight } = contentRef.current; - const progress = (scrollTop / (scrollHeight - clientHeight)) * 100; - setScrollProgress(progress || 0); + if (scrollRafRef.current) cancelAnimationFrame(scrollRafRef.current); - const headings = Array.from(contentRef.current.querySelectorAll('h1, h2, h3')); - let currentActive = activeHeading; - - for (const h of headings) { - const rect = h.getBoundingClientRect(); - if (rect.top <= window.innerHeight * 0.4) { - currentActive = h.id; - } else { - break; + scrollRafRef.current = requestAnimationFrame(() => { + if (!contentRef.current) return; + + const { scrollTop, scrollHeight, clientHeight } = contentRef.current; + const totalScrollable = scrollHeight - clientHeight; + const progress = totalScrollable > 0 ? (scrollTop / totalScrollable) * 100 : 0; + + if (progressBarRef.current) { + progressBarRef.current.style.width = `${progress}%`; } - } - - if (headings.length > 0 && currentActive === '' && headings[0].getBoundingClientRect().top > window.innerHeight * 0.4) { - currentActive = headings[0].id; - } - - if (currentActive !== activeHeading) { - setActiveHeading(currentActive); - } + + const headings = Array.from(contentRef.current.querySelectorAll('h1, h2, h3')); + let currentActive = activeHeadingRef.current; + + for (const h of headings) { + const rect = h.getBoundingClientRect(); + if (rect.top <= window.innerHeight * 0.4) { + currentActive = h.id; + } else { + break; + } + } + + if (headings.length > 0 && currentActive === '' && headings[0].getBoundingClientRect().top > window.innerHeight * 0.4) { + currentActive = headings[0].id; + } + + if (currentActive !== activeHeadingRef.current) { + activeHeadingRef.current = currentActive; + setActiveHeading(currentActive); + } + }); }; const scrollContainer = contentRef.current; if (scrollContainer) { - scrollContainer.addEventListener('scroll', handleScroll); + scrollContainer.addEventListener('scroll', handleScroll, { passive: true }); handleScroll(); // Initial check } return () => { if (scrollContainer) scrollContainer.removeEventListener('scroll', handleScroll); + if (scrollRafRef.current) cancelAnimationFrame(scrollRafRef.current); }; - }, [isViewing, fileContent, activeHeading]); + }, [isViewing, fileContent]); const handleUpdateAction = () => { if (updateAction === 'install') { @@ -317,7 +328,7 @@ function App() { return; } setFileName(file.name); - setFilePath(file.path || null); // path is available in Electron via webkitRelativePath or path property + filePathRef.current = file.path || null; // path is available in Electron via webkitRelativePath or path property const reader = new FileReader(); reader.onload = (e) => { const text = e.target.result; @@ -347,7 +358,7 @@ function App() {