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.
This commit is contained in:
xiose 2026-04-07 09:41:45 +08:00
parent 1f5b1f76fc
commit 2e8782b6e1
4 changed files with 56 additions and 34 deletions

View file

@ -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 (
<div className={className}>
{/* Reuse the same wrapper styling as Markdown code blocks */}
<div className="my-6 rounded-2xl border border-border/60 bg-gradient-to-br from-secondary/45 via-background to-secondary/20 p-1 shadow-sm">
<div className="max-w-full overflow-x-auto rounded-xl bg-background/80 px-4 py-4 backdrop-blur-sm">
{/* Simplified styling - removed gradient, blur, and shadow for better performance */}
<div className="my-4 rounded-lg border border-border/60 bg-secondary/30">
<div className="max-w-full overflow-x-auto rounded-lg bg-background px-4 py-3">
<pre className="m-0 min-w-max bg-transparent p-0 text-[13px] leading-6">
<code
className="hljs"

View file

@ -1,4 +1,4 @@
import { useState } from 'react'
import { useState, memo, useMemo } from 'react'
import { ChevronRight, ChevronDown, Folder, FolderOpen, FileText, FileCode, File } from 'lucide-react'
import type { FileTreeNode } from './file-tree-builder'
import { getFileIcon } from './file-type-utils'
@ -33,14 +33,23 @@ function formatFileSize(bytes: number): string {
/**
* Recursive file tree node component.
* Renders either a file or directory node with expand/collapse functionality.
* Memoized to prevent unnecessary re-renders when parent updates.
*/
export function FileTreeNodeComponent({ node, onFileClick, defaultExpanded = false }: FileTreeNodeProps) {
export const FileTreeNodeComponent = memo(function FileTreeNodeComponent({
node,
onFileClick,
defaultExpanded = false,
}: FileTreeNodeProps) {
const [isExpanded, setIsExpanded] = useState(defaultExpanded)
// Cache icon computation
const IconComponent = useMemo(
() => getIconComponent(getFileIcon(node.name)),
[node.name]
)
// Render file node
if (node.type === 'file') {
const IconComponent = getIconComponent(getFileIcon(node.name))
return (
<div
className="flex items-center justify-between px-3 py-2 hover:bg-accent/10 cursor-pointer transition-colors group"
@ -94,4 +103,4 @@ export function FileTreeNodeComponent({ node, onFileClick, defaultExpanded = fal
)}
</div>
)
}
})

View file

@ -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 = (
<div>

View file

@ -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 (
<div className={containerClassName}>
@ -103,8 +110,8 @@ export function MarkdownRenderer({ content, className }: MarkdownRendererProps)
</li>
),
pre: ({ children }) => (
<div className="my-6 rounded-2xl border border-border/60 bg-gradient-to-br from-secondary/45 via-background to-secondary/20 p-1 shadow-sm">
<div className="max-w-full overflow-x-auto rounded-xl bg-background/80 px-4 py-4 backdrop-blur-sm">
<div className="my-4 rounded-lg border border-border/60 bg-secondary/30">
<div className="max-w-full overflow-x-auto rounded-lg bg-background px-4 py-3">
<pre className="m-0 min-w-max bg-transparent p-0 text-[13px] leading-6">{children}</pre>
</div>
</div>
@ -132,7 +139,7 @@ export function MarkdownRenderer({ content, className }: MarkdownRendererProps)
blockquote: ({ className: blockquoteClassName, children, ...props }) => (
<blockquote
className={cn(
'relative my-6 overflow-hidden rounded-r-xl border-l-4 border-l-primary/35 bg-secondary/30 px-5 py-4 text-foreground/80 shadow-sm',
'my-4 border-l-4 border-l-primary/40 bg-secondary/20 px-4 py-3 text-foreground/80',
blockquoteClassName
)}
{...props}
@ -144,7 +151,7 @@ export function MarkdownRenderer({ content, className }: MarkdownRendererProps)
<hr className={cn('my-10 mx-auto w-full max-w-full border-border/50', hrClassName)} {...props} />
),
table: ({ children }) => (
<div className="my-6 overflow-hidden rounded-2xl border border-border/80 bg-card/80 shadow-sm">
<div className="my-4 overflow-hidden rounded-lg border border-border/60 bg-card/80">
<div className="max-w-full overflow-x-auto">
<table className="m-0 min-w-full border-separate border-spacing-0 text-sm">{children}</table>
</div>