From 2e8782b6e17591d7ecef02eaaca395ef114746cd Mon Sep 17 00:00:00 2001 From: xiose Date: Tue, 7 Apr 2026 09:41:45 +0800 Subject: [PATCH] perf: optimize file tree and preview rendering performance - Add useMemo to cache tree structure and syntax highlighting results - Add React.memo to FileTreeNodeComponent to prevent unnecessary re-renders - Add useCallback for stable callback references - Simplify CSS styles (remove gradients, blur, shadows) to reduce GPU load Fixes performance issues with file preview lag reported by users. --- web/src/features/skill/code-renderer.tsx | 35 ++++++++++---------- web/src/features/skill/file-tree-node.tsx | 19 ++++++++--- web/src/features/skill/file-tree.tsx | 19 +++++++---- web/src/features/skill/markdown-renderer.tsx | 17 +++++++--- 4 files changed, 56 insertions(+), 34 deletions(-) diff --git a/web/src/features/skill/code-renderer.tsx b/web/src/features/skill/code-renderer.tsx index 6784d607..ec4fca94 100644 --- a/web/src/features/skill/code-renderer.tsx +++ b/web/src/features/skill/code-renderer.tsx @@ -1,3 +1,4 @@ +import { useMemo } from 'react' import { common, createLowlight } from 'lowlight' // Create lowlight instance with common languages @@ -14,30 +15,28 @@ interface CodeRendererProps { /** * Renders code with syntax highlighting using lowlight (highlight.js wrapper). * Reuses the same styling as Markdown code blocks for visual consistency. + * Memoized to prevent re-highlighting on every render. */ export function CodeRenderer({ code, language, className }: CodeRendererProps) { - let highlightedCode: string - - try { - if (language && lowlight.registered(language)) { - // Highlight with specified language - const tree = lowlight.highlight(language, code, { prefix: 'hljs-' }) - highlightedCode = treeToHtml(tree) - } else { - // Fallback to plain text (no highlighting) - highlightedCode = escapeHtml(code) + // Cache syntax highlighting result + const highlightedCode = useMemo(() => { + try { + if (language && lowlight.registered(language)) { + const tree = lowlight.highlight(language, code, { prefix: 'hljs-' }) + return treeToHtml(tree) + } + return escapeHtml(code) + } catch (error) { + console.error('Syntax highlighting failed:', error) + return escapeHtml(code) } - } catch (error) { - // If highlighting fails, escape HTML and display as plain text - console.error('Syntax highlighting failed:', error) - highlightedCode = escapeHtml(code) - } + }, [code, language]) return (
- {/* Reuse the same wrapper styling as Markdown code blocks */} -
-
+ {/* Simplified styling - removed gradient, blur, and shadow for better performance */} +
+
              getIconComponent(getFileIcon(node.name)),
+    [node.name]
+  )
+
   // Render file node
   if (node.type === 'file') {
-    const IconComponent = getIconComponent(getFileIcon(node.name))
-
     return (
       
) -} +}) diff --git a/web/src/features/skill/file-tree.tsx b/web/src/features/skill/file-tree.tsx index 96e1ec8b..9e9ef3f3 100644 --- a/web/src/features/skill/file-tree.tsx +++ b/web/src/features/skill/file-tree.tsx @@ -1,3 +1,4 @@ +import { useMemo, useCallback } from 'react' import { useTranslation } from 'react-i18next' import { Folder } from 'lucide-react' import type { SkillFile } from '@/api/types' @@ -18,13 +19,19 @@ interface FileTreeProps { */ export function FileTree({ files, onFileClick, bare }: FileTreeProps) { const { t } = useTranslation() - const tree = buildFileTree(files) - const handleFileClick = (node: FileTreeNode) => { - if (node.type === 'file' && onFileClick) { - onFileClick(node) - } - } + // Cache tree structure to avoid rebuilding on every render + const tree = useMemo(() => buildFileTree(files), [files]) + + // Stable callback reference to prevent child re-renders + const handleFileClick = useCallback( + (node: FileTreeNode) => { + if (node.type === 'file' && onFileClick) { + onFileClick(node) + } + }, + [onFileClick] + ) const treeContent = (
diff --git a/web/src/features/skill/markdown-renderer.tsx b/web/src/features/skill/markdown-renderer.tsx index 31833567..1f787021 100644 --- a/web/src/features/skill/markdown-renderer.tsx +++ b/web/src/features/skill/markdown-renderer.tsx @@ -1,3 +1,4 @@ +import { useMemo } from 'react' import ReactMarkdown from 'react-markdown' import rehypeHighlight from 'rehype-highlight' import rehypeSanitize from 'rehype-sanitize' @@ -17,6 +18,7 @@ interface MarkdownRendererProps { * Renders markdown from skill packages using a constrained plugin stack. * Frontmatter is stripped before render because package metadata is surfaced in * dedicated UI sections and should not appear twice in the document body. + * Memoized to prevent re-parsing on every render. */ export function MarkdownRenderer({ content, className }: MarkdownRendererProps) { const containerClassName = [ @@ -25,7 +27,12 @@ export function MarkdownRenderer({ content, className }: MarkdownRendererProps) ] .filter(Boolean) .join(' ') - const normalizedContent = stripMarkdownFrontmatter(content) + + // Cache the normalized content to prevent re-parsing on every render + const normalizedContent = useMemo( + () => stripMarkdownFrontmatter(content), + [content] + ) return (
@@ -103,8 +110,8 @@ export function MarkdownRenderer({ content, className }: MarkdownRendererProps) ), pre: ({ children }) => ( -
-
+
+
{children}
@@ -132,7 +139,7 @@ export function MarkdownRenderer({ content, className }: MarkdownRendererProps) blockquote: ({ className: blockquoteClassName, children, ...props }) => (
), table: ({ children }) => ( -
+
{children}