perf: optimize file tree and preview rendering performance (#240)

* docs: add Maven mirror config and troubleshooting guide for China developers

- Add Aliyun mirror config in server/.mvn/settings.xml
- Update maven-wrapper.properties to use Aliyun mirror for Maven distribution
- Add detailed error messages in Makefile when backend startup fails
- Add troubleshooting section in quickstart.md for China developers
- Add FAQ entry for local development startup issues
- Update README with link to local development guide

* 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.

* test: update test for React.memo wrapped component
This commit is contained in:
XiaoSeS 2026-04-07 09:54:48 +08:00 • committed by GitHub
parent f2b4525967
commit 8213686601
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 61 additions and 35 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

@ -8,10 +8,14 @@ import * as mod from './file-tree-node'
*
* We verify the module shape so downstream consumers break fast
* if the export contract changes.
*
* Note: FileTreeNodeComponent is wrapped with React.memo, so typeof returns 'object'
* instead of 'function'. We check for both to handle the memo wrapper.
*/
describe('file-tree-node module exports', () => {
it('exports the FileTreeNodeComponent component', () => {
expect(mod.FileTreeNodeComponent).toBeDefined()
expect(typeof mod.FileTreeNodeComponent).toBe('function')
// React.memo wraps the component in an object, so typeof is 'object'
expect(['function', 'object']).toContain(typeof mod.FileTreeNodeComponent)
})
})

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>