mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-01 00:11:39 +08:00
Compare commits
4 Commits
feat/langf
...
bb/tui-per
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75b7bad6be | ||
|
|
6022d95732 | ||
|
|
2614d46f06 | ||
|
|
2c5fb45d08 |
70
perf-analysis/AppLayoutOptimized.tsx
Normal file
70
perf-analysis/AppLayoutOptimized.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import React from 'react';
|
||||
import { Box, useApp } from 'ink';
|
||||
import { VirtualizedMessageContainer } from './VirtualizedMessageContainer';
|
||||
import { usePerformanceMonitor } from './performanceHooks';
|
||||
|
||||
// This is a proof-of-concept component to demonstrate the performance fixes
|
||||
export const AppLayoutOptimized: React.FC = () => {
|
||||
const { stdout } = useApp();
|
||||
const { metrics, measureOperation } = usePerformanceMonitor('AppLayout', {
|
||||
logToConsole: true
|
||||
});
|
||||
|
||||
// Calculate viewport dimensions based on terminal size
|
||||
const viewportHeight = stdout.rows - 4; // Reserve space for input, etc.
|
||||
const viewportWidth = stdout.columns;
|
||||
|
||||
// In a real implementation, messages would come from app state
|
||||
const messages = React.useMemo(() => {
|
||||
return Array(1000).fill(null).map((_, index) => ({
|
||||
id: `msg-${index}`,
|
||||
role: index % 2 === 0 ? 'user' : 'assistant',
|
||||
content: `This is message ${index}. It contains some content that might wrap to multiple lines depending on the terminal width. This demonstrates how virtualization can significantly improve performance.`,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" height={stdout.rows} width={stdout.columns}>
|
||||
<Box
|
||||
flexDirection="column"
|
||||
height={viewportHeight}
|
||||
width={viewportWidth}
|
||||
overflow="hidden"
|
||||
// Use stable scrollbar gutter to prevent layout shifts
|
||||
style={{ scrollbarGutter: 'stable' }}
|
||||
>
|
||||
<VirtualizedMessageContainer
|
||||
messages={messages}
|
||||
height={viewportHeight}
|
||||
width={viewportWidth}
|
||||
expandCode={true}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Performance metrics display */}
|
||||
<Box marginTop={1}>
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor="yellow"
|
||||
paddingX={1}
|
||||
width={viewportWidth}
|
||||
>
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Box width={25}>Avg render time:</Box>
|
||||
<Box>{metrics.averageRenderTime.toFixed(2)}ms</Box>
|
||||
</Box>
|
||||
<Box>
|
||||
<Box width={25}>Total renders:</Box>
|
||||
<Box>{metrics.totalRenders}</Box>
|
||||
</Box>
|
||||
<Box>
|
||||
<Box width={25}>Slow renders:</Box>
|
||||
<Box>{metrics.slowRenders}</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
147
perf-analysis/VirtualizedMessageContainer.tsx
Normal file
147
perf-analysis/VirtualizedMessageContainer.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { FixedSizeList as List } from 'react-window';
|
||||
import { Box, Text } from 'ink';
|
||||
import { useTheme } from '../hooks/useTheme';
|
||||
import { MessageData } from '../gatewayTypes';
|
||||
import { Markdown } from './markdown';
|
||||
import { themed } from './themed';
|
||||
|
||||
// Estimated average height for message rows (will be refined later)
|
||||
const ESTIMATED_ROW_HEIGHT = 50;
|
||||
|
||||
// Overscan count - render this many items above/below the visible area
|
||||
const OVERSCAN_COUNT = 10;
|
||||
|
||||
interface MessageLineProps {
|
||||
message: MessageData;
|
||||
onRender?: () => void;
|
||||
isHighlighted?: boolean;
|
||||
expandCode?: boolean;
|
||||
}
|
||||
|
||||
export const MessageLine: React.FC<MessageLineProps> = React.memo(({
|
||||
message,
|
||||
onRender,
|
||||
isHighlighted = false,
|
||||
expandCode = false
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const { role, content } = message;
|
||||
|
||||
useEffect(() => {
|
||||
onRender?.();
|
||||
}, [onRender]);
|
||||
|
||||
// Skip rendering for empty messages
|
||||
if (!content) return null;
|
||||
|
||||
const RoleLabel = themed(Text, {
|
||||
user: theme.message.user.label,
|
||||
assistant: theme.message.assistant.label,
|
||||
system: theme.message.system.label,
|
||||
tool: theme.message.tool.label,
|
||||
function: theme.message.function.label,
|
||||
});
|
||||
|
||||
const roleStyles = {
|
||||
user: theme.message.user.content,
|
||||
assistant: theme.message.assistant.content,
|
||||
system: theme.message.system.content,
|
||||
tool: theme.message.tool.content,
|
||||
function: theme.message.function.content,
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
paddingX={0}
|
||||
paddingY={0}
|
||||
borderStyle={isHighlighted ? 'bold' : undefined}
|
||||
borderColor={isHighlighted ? theme.focused : undefined}
|
||||
>
|
||||
<Box>
|
||||
<RoleLabel variant={role as any}>{role}:</RoleLabel>
|
||||
</Box>
|
||||
<Box marginLeft={1}>
|
||||
<Markdown
|
||||
variant={role as keyof typeof roleStyles}
|
||||
content={content || ''}
|
||||
expandCode={expandCode}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}, (prevProps, nextProps) => {
|
||||
// Custom comparison logic for memoization
|
||||
return (
|
||||
prevProps.message.id === nextProps.message.id &&
|
||||
prevProps.message.content === nextProps.message.content &&
|
||||
prevProps.message.role === nextProps.message.role &&
|
||||
prevProps.isHighlighted === nextProps.isHighlighted &&
|
||||
prevProps.expandCode === nextProps.expandCode
|
||||
);
|
||||
});
|
||||
|
||||
interface MessageContainerProps {
|
||||
messages: MessageData[];
|
||||
height: number;
|
||||
width: number;
|
||||
expandCode?: boolean;
|
||||
highlightedMessageId?: string;
|
||||
}
|
||||
|
||||
export const VirtualizedMessageContainer: React.FC<MessageContainerProps> = ({
|
||||
messages,
|
||||
height,
|
||||
width,
|
||||
expandCode = false,
|
||||
highlightedMessageId,
|
||||
}) => {
|
||||
const listRef = useRef<List>(null);
|
||||
const [measuredHeights, setMeasuredHeights] = useState<Record<string, number>>({});
|
||||
|
||||
// Scroll to bottom on new messages
|
||||
useEffect(() => {
|
||||
if (listRef.current && messages.length > 0) {
|
||||
listRef.current.scrollToItem(messages.length - 1);
|
||||
}
|
||||
}, [messages.length]);
|
||||
|
||||
// Record the actual rendered heights for more accurate virtualization
|
||||
const handleMessageRender = (id: string, index: number) => {
|
||||
// In a real implementation, we would measure DOM nodes here
|
||||
// This is a placeholder for the concept
|
||||
if (!measuredHeights[id]) {
|
||||
setMeasuredHeights(prev => ({
|
||||
...prev,
|
||||
[id]: ESTIMATED_ROW_HEIGHT // In reality, we'd measure the actual height
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<List
|
||||
ref={listRef}
|
||||
height={height}
|
||||
width={width}
|
||||
itemCount={messages.length}
|
||||
itemSize={ESTIMATED_ROW_HEIGHT}
|
||||
overscanCount={OVERSCAN_COUNT}
|
||||
style={{ scrollbarGutter: 'stable' }}
|
||||
>
|
||||
{({ index, style }) => {
|
||||
const message = messages[index];
|
||||
return (
|
||||
<div style={style}>
|
||||
<MessageLine
|
||||
message={message}
|
||||
expandCode={expandCode}
|
||||
isHighlighted={message.id === highlightedMessageId}
|
||||
onRender={() => handleMessageRender(message.id, index)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</List>
|
||||
);
|
||||
};
|
||||
188
perf-analysis/messageLine-optimized.tsx
Normal file
188
perf-analysis/messageLine-optimized.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { useTheme } from '../hooks/useTheme';
|
||||
import { MessageData } from '../gatewayTypes';
|
||||
import { Markdown } from './markdown';
|
||||
import { themed } from './themed';
|
||||
import { usePerformanceMonitor, useScrollPerformance } from '../hooks/performanceHooks';
|
||||
|
||||
// Optimize the MessageLine component with proper memoization
|
||||
export const MessageLine: React.FC<{
|
||||
message: MessageData;
|
||||
isHighlighted?: boolean;
|
||||
expandCode?: boolean;
|
||||
}> = React.memo(({ message, isHighlighted = false, expandCode = false }) => {
|
||||
const theme = useTheme();
|
||||
const { role, content } = message;
|
||||
const { logEvent } = usePerformanceMonitor(`MessageLine-${role.substring(0,1)}${message.id?.substring(0,4)}`);
|
||||
|
||||
// Skip rendering for empty messages
|
||||
if (!content) return null;
|
||||
|
||||
const RoleLabel = themed(Text, {
|
||||
user: theme.message.user.label,
|
||||
assistant: theme.message.assistant.label,
|
||||
system: theme.message.system.label,
|
||||
tool: theme.message.tool.label,
|
||||
function: theme.message.function.label,
|
||||
});
|
||||
|
||||
const roleStyles = {
|
||||
user: theme.message.user.content,
|
||||
assistant: theme.message.assistant.content,
|
||||
system: theme.message.system.content,
|
||||
tool: theme.message.tool.content,
|
||||
function: theme.message.function.content,
|
||||
};
|
||||
|
||||
// Log initial render for performance monitoring
|
||||
useEffect(() => {
|
||||
logEvent('initial-render');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
paddingX={0}
|
||||
paddingY={0}
|
||||
borderStyle={isHighlighted ? 'bold' : undefined}
|
||||
borderColor={isHighlighted ? theme.focused : undefined}
|
||||
>
|
||||
<Box>
|
||||
<RoleLabel variant={role as any}>{role}:</RoleLabel>
|
||||
</Box>
|
||||
<Box marginLeft={1}>
|
||||
<Markdown
|
||||
variant={role as keyof typeof roleStyles}
|
||||
content={content || ''}
|
||||
expandCode={expandCode}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}, (prevProps, nextProps) => {
|
||||
// Custom comparison to prevent unnecessary re-renders
|
||||
return (
|
||||
prevProps.message.id === nextProps.message.id &&
|
||||
prevProps.message.content === nextProps.message.content &&
|
||||
prevProps.message.role === nextProps.message.role &&
|
||||
prevProps.isHighlighted === nextProps.isHighlighted &&
|
||||
prevProps.expandCode === nextProps.expandCode
|
||||
);
|
||||
});
|
||||
|
||||
// Fixed window approach for rendering only visible + buffer messages
|
||||
export const MessageContainer: React.FC<{
|
||||
messages: MessageData[];
|
||||
scrollBuffer?: number;
|
||||
expandCode?: boolean;
|
||||
highlightedMessageId?: string;
|
||||
}> = ({ messages, scrollBuffer = 50, expandCode = false, highlightedMessageId }) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { onScroll } = useScrollPerformance('MessageContainer');
|
||||
const { logEvent } = usePerformanceMonitor('MessageContainer');
|
||||
|
||||
// Track visible range
|
||||
const [visibleRange, setVisibleRange] = useState({
|
||||
start: Math.max(0, messages.length - 30),
|
||||
end: messages.length
|
||||
});
|
||||
|
||||
// Handle scroll events to update visible range
|
||||
const handleScroll = useCallback(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
|
||||
const scrollRatio = scrollTop / (scrollHeight - clientHeight);
|
||||
|
||||
// Calculate visible range based on scroll position
|
||||
const totalMessages = messages.length;
|
||||
const visibleCount = 30; // Approximate number of visible messages
|
||||
const bufferSize = scrollBuffer;
|
||||
|
||||
// Calculate start/end indices
|
||||
const middleIndex = Math.floor(scrollRatio * totalMessages);
|
||||
const halfVisible = Math.floor(visibleCount / 2);
|
||||
|
||||
let start = Math.max(0, middleIndex - halfVisible - bufferSize);
|
||||
let end = Math.min(totalMessages, middleIndex + halfVisible + bufferSize);
|
||||
|
||||
// Special case for start/end of list
|
||||
if (scrollRatio < 0.1) {
|
||||
start = 0;
|
||||
end = Math.min(totalMessages, visibleCount + bufferSize);
|
||||
} else if (scrollRatio > 0.9) {
|
||||
end = totalMessages;
|
||||
start = Math.max(0, totalMessages - visibleCount - bufferSize);
|
||||
}
|
||||
|
||||
setVisibleRange({ start, end });
|
||||
|
||||
// Performance monitoring
|
||||
onScroll();
|
||||
}, [messages.length, scrollBuffer, onScroll]);
|
||||
|
||||
// Auto-scroll to bottom on new messages
|
||||
useEffect(() => {
|
||||
if (containerRef.current) {
|
||||
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
|
||||
const isNearBottom = scrollTop + clientHeight >= scrollHeight - 50;
|
||||
|
||||
if (isNearBottom) {
|
||||
// Only auto-scroll if we're already near the bottom
|
||||
logEvent('auto-scroll');
|
||||
containerRef.current.scrollTop = scrollHeight;
|
||||
|
||||
// Update visible range to show bottom messages
|
||||
setVisibleRange({
|
||||
start: Math.max(0, messages.length - 30 - scrollBuffer),
|
||||
end: messages.length
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [messages.length, scrollBuffer]);
|
||||
|
||||
// Log rendering details
|
||||
useEffect(() => {
|
||||
logEvent(`render-range-${visibleRange.start}-${visibleRange.end}`);
|
||||
}, [visibleRange]);
|
||||
|
||||
// Get visible messages subset
|
||||
const visibleMessages = messages.slice(visibleRange.start, visibleRange.end);
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
overflow="auto"
|
||||
ref={containerRef}
|
||||
onScroll={handleScroll}
|
||||
style={{ scrollbarGutter: 'stable both-edges' }}
|
||||
>
|
||||
{/* Spacer for scroll position */}
|
||||
{visibleRange.start > 0 && (
|
||||
<Box
|
||||
height={visibleRange.start * 3}
|
||||
width="100%"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Visible messages */}
|
||||
{visibleMessages.map((message) => (
|
||||
<MessageLine
|
||||
key={message.id}
|
||||
message={message}
|
||||
expandCode={expandCode}
|
||||
isHighlighted={message.id === highlightedMessageId}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Spacer for remaining messages */}
|
||||
{visibleRange.end < messages.length && (
|
||||
<Box
|
||||
height={(messages.length - visibleRange.end) * 3}
|
||||
width="100%"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
207
perf-analysis/performanceHooks.ts
Normal file
207
perf-analysis/performanceHooks.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { useRef, useCallback, useState, useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Custom hook for performance monitoring
|
||||
* Helps track and log performance metrics for components
|
||||
*/
|
||||
export function usePerformanceMonitor(componentName: string, options = {
|
||||
logToConsole: false,
|
||||
thresholdMs: 16 // 60fps threshold
|
||||
}) {
|
||||
const renderCountRef = useRef(0);
|
||||
const renderTimesRef = useRef<number[]>([]);
|
||||
const lastRenderTimeRef = useRef(performance.now());
|
||||
const [metrics, setMetrics] = useState({
|
||||
averageRenderTime: 0,
|
||||
totalRenders: 0,
|
||||
slowRenders: 0
|
||||
});
|
||||
|
||||
// Measure start of render cycle
|
||||
useEffect(() => {
|
||||
const startTime = performance.now();
|
||||
|
||||
return () => {
|
||||
const endTime = performance.now();
|
||||
const renderTime = endTime - startTime;
|
||||
|
||||
renderCountRef.current += 1;
|
||||
renderTimesRef.current.push(renderTime);
|
||||
|
||||
// Keep only the last 100 measurements
|
||||
if (renderTimesRef.current.length > 100) {
|
||||
renderTimesRef.current.shift();
|
||||
}
|
||||
|
||||
// Calculate average render time
|
||||
const average = renderTimesRef.current.reduce((sum, time) => sum + time, 0) /
|
||||
renderTimesRef.current.length;
|
||||
|
||||
// Count slow renders
|
||||
const slowRenders = renderTimesRef.current.filter(time => time > options.thresholdMs).length;
|
||||
|
||||
// Update metrics
|
||||
setMetrics({
|
||||
averageRenderTime: average,
|
||||
totalRenders: renderCountRef.current,
|
||||
slowRenders
|
||||
});
|
||||
|
||||
if (options.logToConsole && renderTime > options.thresholdMs) {
|
||||
console.log(
|
||||
`[PERF] ${componentName} render: ${renderTime.toFixed(2)}ms ` +
|
||||
`(avg: ${average.toFixed(2)}ms, slow: ${slowRenders}/${renderCountRef.current})`
|
||||
);
|
||||
}
|
||||
|
||||
lastRenderTimeRef.current = endTime;
|
||||
};
|
||||
});
|
||||
|
||||
// Function to measure specific operations
|
||||
const measureOperation = useCallback((operationName: string, fn: () => void) => {
|
||||
const start = performance.now();
|
||||
fn();
|
||||
const duration = performance.now() - start;
|
||||
|
||||
if (options.logToConsole && duration > options.thresholdMs) {
|
||||
console.log(`[PERF] ${componentName}.${operationName}: ${duration.toFixed(2)}ms`);
|
||||
}
|
||||
|
||||
return duration;
|
||||
}, [componentName, options.logToConsole, options.thresholdMs]);
|
||||
|
||||
return {
|
||||
metrics,
|
||||
measureOperation,
|
||||
logEvent: (event: string, durationMs?: number) => {
|
||||
if (options.logToConsole) {
|
||||
const message = durationMs
|
||||
? `[PERF] ${componentName}.${event}: ${durationMs.toFixed(2)}ms`
|
||||
: `[PERF] ${componentName}.${event}`;
|
||||
console.log(message);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to debounce frequent updates
|
||||
*/
|
||||
export function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to throttle frequent updates
|
||||
*/
|
||||
export function useThrottle<T>(value: T, limit: number): T {
|
||||
const [throttledValue, setThrottledValue] = useState<T>(value);
|
||||
const lastRan = useRef(Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
if (Date.now() - lastRan.current >= limit) {
|
||||
setThrottledValue(value);
|
||||
lastRan.current = Date.now();
|
||||
}
|
||||
}, limit - (Date.now() - lastRan.current));
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [value, limit]);
|
||||
|
||||
return throttledValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to measure and track scroll performance
|
||||
*/
|
||||
export function useScrollPerformance(componentName: string, options = {
|
||||
logToConsole: false,
|
||||
sampleRate: 0.1, // Only log 10% of scroll events to reduce noise
|
||||
thresholdMs: 16
|
||||
}) {
|
||||
const scrollCountRef = useRef(0);
|
||||
const scrollTimesRef = useRef<number[]>([]);
|
||||
const isScrollingRef = useRef(false);
|
||||
const scrollStartTimeRef = useRef(0);
|
||||
const scrollThrottleTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const onScrollStart = useCallback(() => {
|
||||
if (!isScrollingRef.current) {
|
||||
isScrollingRef.current = true;
|
||||
scrollStartTimeRef.current = performance.now();
|
||||
|
||||
if (options.logToConsole) {
|
||||
console.log(`[SCROLL] ${componentName} scroll started`);
|
||||
}
|
||||
}
|
||||
}, [componentName, options.logToConsole]);
|
||||
|
||||
const onScrollEnd = useCallback(() => {
|
||||
if (isScrollingRef.current) {
|
||||
const duration = performance.now() - scrollStartTimeRef.current;
|
||||
scrollTimesRef.current.push(duration);
|
||||
|
||||
// Keep array at reasonable size
|
||||
if (scrollTimesRef.current.length > 50) {
|
||||
scrollTimesRef.current.shift();
|
||||
}
|
||||
|
||||
isScrollingRef.current = false;
|
||||
|
||||
if (options.logToConsole && Math.random() < options.sampleRate) {
|
||||
const avg = scrollTimesRef.current.reduce((sum, time) => sum + time, 0) /
|
||||
scrollTimesRef.current.length;
|
||||
|
||||
console.log(
|
||||
`[SCROLL] ${componentName} scroll ended: ${duration.toFixed(2)}ms ` +
|
||||
`(avg: ${avg.toFixed(2)}ms)`
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [componentName, options.logToConsole, options.sampleRate]);
|
||||
|
||||
const onScroll = useCallback(() => {
|
||||
scrollCountRef.current += 1;
|
||||
|
||||
// Start scrolling tracking if not already
|
||||
onScrollStart();
|
||||
|
||||
// Reset the scroll end timer
|
||||
if (scrollThrottleTimerRef.current) {
|
||||
clearTimeout(scrollThrottleTimerRef.current);
|
||||
}
|
||||
|
||||
// Set timer to detect when scrolling stops
|
||||
scrollThrottleTimerRef.current = setTimeout(() => {
|
||||
onScrollEnd();
|
||||
}, 150); // Consider scrolling stopped after 150ms of inactivity
|
||||
|
||||
}, [onScrollStart, onScrollEnd]);
|
||||
|
||||
// Clean up
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (scrollThrottleTimerRef.current) {
|
||||
clearTimeout(scrollThrottleTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { onScroll };
|
||||
}
|
||||
118
perf-analysis/tui-perf-analysis.md
Normal file
118
perf-analysis/tui-perf-analysis.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# TUI Performance Analysis
|
||||
|
||||
## Issues Identified
|
||||
|
||||
1. **Scrolling lag with large message history**
|
||||
- No virtualization or windowing in message rendering
|
||||
- Each message re-renders on scroll
|
||||
- Complete DOM reconstruction on each render
|
||||
|
||||
2. **Input jitter with scrollbar**
|
||||
- Composer width changes when scrollbar appears/disappears
|
||||
- Layout shifts when scrolling near bottom
|
||||
|
||||
3. **Layout thrashing**
|
||||
- Multiple successive layout recalculations
|
||||
- Excessive style computations in the render loop
|
||||
|
||||
## Investigation Areas
|
||||
|
||||
### 1. Message Rendering Performance
|
||||
|
||||
Current implementation in `messageLine.tsx` renders all messages in the transcript without virtualization. For long sessions, this means:
|
||||
|
||||
- Every message is always in the DOM
|
||||
- Complete re-rendering happens on each state change
|
||||
- No windowing or culling of off-screen content
|
||||
- Layout recalculations for entire transcript on each scroll
|
||||
|
||||
### 2. Re-rendering Optimization
|
||||
|
||||
- No memoization of message components
|
||||
- No element recycling
|
||||
- Each message potentially triggers layout shifts
|
||||
|
||||
### 3. Scrollbar Behavior
|
||||
|
||||
- Composer width calculation doesn't account for scrollbar presence
|
||||
- No stable layout constraints
|
||||
|
||||
## Proposed Solutions
|
||||
|
||||
### 1. Implement Virtualized List for Messages
|
||||
|
||||
Add `react-window` or similar virtualization library to render only visible messages:
|
||||
|
||||
```tsx
|
||||
import { FixedSizeList as List } from 'react-window';
|
||||
|
||||
// In the component render
|
||||
<List
|
||||
height={viewportHeight}
|
||||
itemCount={messages.length}
|
||||
itemSize={estimatedRowHeight}
|
||||
width="100%"
|
||||
overscanCount={5}
|
||||
>
|
||||
{({ index, style }) => (
|
||||
<div style={style}>
|
||||
<MessageLine message={messages[index]} />
|
||||
</div>
|
||||
)}
|
||||
</List>
|
||||
```
|
||||
|
||||
### 2. Memoize Message Components
|
||||
|
||||
Use `React.memo` to prevent unnecessary re-renders:
|
||||
|
||||
```tsx
|
||||
const MessageLine = React.memo(({ message, ...props }) => {
|
||||
// Component logic
|
||||
}, (prevProps, nextProps) => {
|
||||
// Custom comparison logic
|
||||
return prevProps.message.id === nextProps.message.id &&
|
||||
prevProps.message.content === nextProps.message.content;
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Fix Scrollbar Layout Issues
|
||||
|
||||
- Add scrollbar-gutter CSS to reserve space for scrollbar
|
||||
- Stabilize layout with fixed container dimensions
|
||||
|
||||
```css
|
||||
.message-container {
|
||||
scrollbar-gutter: stable;
|
||||
overflow-y: auto;
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Add Performance Measurements
|
||||
|
||||
Add performance monitoring to identify bottlenecks:
|
||||
|
||||
```tsx
|
||||
useEffect(() => {
|
||||
const start = performance.now();
|
||||
// Measure key operations
|
||||
return () => {
|
||||
console.log(`Operation took ${performance.now() - start}ms`);
|
||||
};
|
||||
}, [dependencyArray]);
|
||||
```
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
1. Add virtualization for message rendering
|
||||
2. Implement memo optimization for components
|
||||
3. Fix scrollbar layout issues
|
||||
4. Add performance monitoring
|
||||
5. Optimize re-render triggers
|
||||
6. Improve scroll restoration
|
||||
|
||||
## Resources
|
||||
|
||||
- [React Window](https://github.com/bvaughn/react-window)
|
||||
- [React Virtualized](https://github.com/bvaughn/react-virtualized)
|
||||
- [CSS Scrollbar Gutter](https://developer.mozilla.org/en-US/docs/Web/CSS/scrollbar-gutter)
|
||||
197
ui-tui/src/components/FixedWindowScroller.tsx
Normal file
197
ui-tui/src/components/FixedWindowScroller.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import React from 'react';
|
||||
import { Box, useApp } from 'ink';
|
||||
import { usePerformanceMonitor } from '../hooks/usePerformance';
|
||||
|
||||
/**
|
||||
* A fixed window scroller component for efficient rendering of large lists
|
||||
* This is a lightweight virtualization component that only renders visible items
|
||||
* plus a configurable overscan buffer for smooth scrolling
|
||||
*/
|
||||
export const FixedWindowScroller = React.forwardRef(({
|
||||
items,
|
||||
height,
|
||||
width,
|
||||
itemHeight = 3, // Average height of each item in terminal rows
|
||||
renderItem,
|
||||
overscrollItems = 20, // Number of items to render outside visible area
|
||||
onScroll,
|
||||
initialScrollToEnd = true,
|
||||
}, ref) => {
|
||||
const { stdout } = useApp();
|
||||
const { logEvent } = usePerformanceMonitor('FixedWindowScroller', {
|
||||
logToConsole: false
|
||||
});
|
||||
|
||||
// Container ref for scroll measurements
|
||||
const containerRef = React.useRef(null);
|
||||
|
||||
// Track scroll state
|
||||
const lastScrollTopRef = React.useRef(0);
|
||||
const lastItemsLengthRef = React.useRef(items.length);
|
||||
|
||||
// Calculate visible window based on container dimensions
|
||||
const [visibleWindow, setVisibleWindow] = React.useState({
|
||||
startIndex: Math.max(0, items.length - Math.floor(height / itemHeight) - overscrollItems),
|
||||
endIndex: items.length,
|
||||
scrollTop: 0
|
||||
});
|
||||
|
||||
// Expose scroll methods via ref
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
scrollToItem: (index, align = 'auto') => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const container = containerRef.current;
|
||||
const itemOffset = index * itemHeight;
|
||||
|
||||
if (align === 'start') {
|
||||
container.scrollTop = itemOffset;
|
||||
} else if (align === 'end') {
|
||||
container.scrollTop = itemOffset - height + itemHeight;
|
||||
} else if (align === 'center') {
|
||||
container.scrollTop = itemOffset - height / 2 + itemHeight / 2;
|
||||
} else {
|
||||
// Auto alignment - only scroll if item is outside visible area
|
||||
const { scrollTop } = container;
|
||||
const visibleBottom = scrollTop + height;
|
||||
|
||||
if (itemOffset < scrollTop) {
|
||||
container.scrollTop = itemOffset;
|
||||
} else if (itemOffset + itemHeight > visibleBottom) {
|
||||
container.scrollTop = itemOffset - height + itemHeight;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
scrollToTop: () => {
|
||||
if (containerRef.current) {
|
||||
containerRef.current.scrollTop = 0;
|
||||
}
|
||||
},
|
||||
|
||||
scrollToBottom: () => {
|
||||
if (containerRef.current) {
|
||||
containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
||||
}
|
||||
},
|
||||
|
||||
// Compatibility with ScrollBoxHandle
|
||||
getScrollTop: () => containerRef.current?.scrollTop || 0,
|
||||
getViewportHeight: () => height,
|
||||
getPendingDelta: () => 0,
|
||||
isSticky: () => visibleWindow.startIndex === items.length - visibleItemCount,
|
||||
}), [height, itemHeight, items.length, visibleWindow.startIndex]);
|
||||
|
||||
// Calculate how many items fit in the viewport
|
||||
const visibleItemCount = Math.ceil(height / itemHeight);
|
||||
|
||||
// Handle scroll events
|
||||
const handleScroll = React.useCallback((event) => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
|
||||
const scrollTopDiff = Math.abs(scrollTop - lastScrollTopRef.current);
|
||||
|
||||
// Only update if we've scrolled a significant amount
|
||||
if (scrollTopDiff > (itemHeight / 2)) {
|
||||
const totalItems = items.length;
|
||||
const visibleItems = Math.floor(clientHeight / itemHeight);
|
||||
|
||||
// Calculate the first visible item index
|
||||
const firstVisibleItemIndex = Math.floor(scrollTop / itemHeight);
|
||||
|
||||
// Calculate start and end indices with overscroll
|
||||
const startIndex = Math.max(0, firstVisibleItemIndex - overscrollItems);
|
||||
const endIndex = Math.min(
|
||||
totalItems,
|
||||
firstVisibleItemIndex + visibleItems + overscrollItems
|
||||
);
|
||||
|
||||
logEvent(`window-update-${startIndex}-${endIndex}`);
|
||||
|
||||
setVisibleWindow({ startIndex, endIndex, scrollTop });
|
||||
lastScrollTopRef.current = scrollTop;
|
||||
|
||||
// Call external scroll handler if provided
|
||||
if (onScroll) {
|
||||
onScroll({
|
||||
scrollTop,
|
||||
scrollHeight,
|
||||
clientHeight,
|
||||
firstVisibleItemIndex,
|
||||
lastVisibleItemIndex: firstVisibleItemIndex + visibleItems,
|
||||
isAtTop: scrollTop < itemHeight,
|
||||
isAtBottom: scrollTop + clientHeight >= scrollHeight - itemHeight
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [items.length, itemHeight, overscrollItems, onScroll, logEvent]);
|
||||
|
||||
// Auto-scroll to bottom when new items are added
|
||||
React.useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const isNewMessagesAdded = items.length > lastItemsLengthRef.current;
|
||||
const isNearBottom = containerRef.current.scrollHeight - containerRef.current.clientHeight - containerRef.current.scrollTop < itemHeight * 3;
|
||||
|
||||
if ((isNewMessagesAdded && isNearBottom) || initialScrollToEnd) {
|
||||
containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
||||
|
||||
// Update the visible window to show the end
|
||||
setVisibleWindow({
|
||||
startIndex: Math.max(0, items.length - Math.floor(height / itemHeight) - overscrollItems),
|
||||
endIndex: items.length,
|
||||
scrollTop: containerRef.current.scrollHeight
|
||||
});
|
||||
|
||||
logEvent('auto-scroll');
|
||||
}
|
||||
|
||||
lastItemsLengthRef.current = items.length;
|
||||
}, [items.length, height, itemHeight, overscrollItems, initialScrollToEnd, logEvent]);
|
||||
|
||||
// Get the visible subset of items
|
||||
const visibleItems = items.slice(visibleWindow.startIndex, visibleWindow.endIndex);
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={containerRef}
|
||||
overflow="auto"
|
||||
width={width}
|
||||
height={height}
|
||||
onScroll={handleScroll}
|
||||
style={{ scrollbarGutter: 'stable' }}
|
||||
>
|
||||
{/* Top spacer */}
|
||||
{visibleWindow.startIndex > 0 && (
|
||||
<Box
|
||||
width="100%"
|
||||
height={visibleWindow.startIndex * itemHeight}
|
||||
padding={0}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Visible items */}
|
||||
{visibleItems.map((item, index) =>
|
||||
renderItem({
|
||||
item,
|
||||
index: visibleWindow.startIndex + index,
|
||||
isVisible: true
|
||||
})
|
||||
)}
|
||||
|
||||
{/* Bottom spacer */}
|
||||
{visibleWindow.endIndex < items.length && (
|
||||
<Box
|
||||
width="100%"
|
||||
height={(items.length - visibleWindow.endIndex) * itemHeight}
|
||||
padding={0}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
FixedWindowScroller.displayName = 'FixedWindowScroller';
|
||||
|
||||
export default FixedWindowScroller;
|
||||
76
ui-tui/src/components/OptimizedTranscriptPane.tsx
Normal file
76
ui-tui/src/components/OptimizedTranscriptPane.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import React from 'react';
|
||||
import { Box } from 'ink';
|
||||
import { FixedWindowScroller } from './FixedWindowScroller';
|
||||
import { usePerformanceMonitor } from '../hooks/usePerformance';
|
||||
|
||||
/**
|
||||
* OptimizedTranscriptPane is a drop-in replacement for the transcript area
|
||||
* that uses virtualization to dramatically improve performance with large
|
||||
* message histories.
|
||||
*/
|
||||
export const OptimizedTranscriptPane = React.memo(({
|
||||
messages,
|
||||
renderMessage,
|
||||
height,
|
||||
width,
|
||||
onScroll,
|
||||
}) => {
|
||||
const { logEvent } = usePerformanceMonitor('OptimizedTranscriptPane', {
|
||||
logToConsole: false
|
||||
});
|
||||
|
||||
// Reference to the scroller component
|
||||
const scrollerRef = React.useRef(null);
|
||||
|
||||
// Keep track of visible window for debugging
|
||||
const [visibleRange, setVisibleRange] = React.useState({ start: 0, end: 0 });
|
||||
|
||||
// Handle scroll events
|
||||
const handleScroll = React.useCallback((scrollInfo) => {
|
||||
setVisibleRange({
|
||||
start: scrollInfo.firstVisibleItemIndex,
|
||||
end: scrollInfo.lastVisibleItemIndex
|
||||
});
|
||||
|
||||
if (onScroll) {
|
||||
onScroll(scrollInfo);
|
||||
}
|
||||
}, [onScroll]);
|
||||
|
||||
// Memoize the render function for better performance
|
||||
const renderItem = React.useCallback(({ item, index, isVisible }) => {
|
||||
if (!isVisible) {
|
||||
return <Box height={3} />; // Placeholder with approximate height
|
||||
}
|
||||
|
||||
return renderMessage(item, index);
|
||||
}, [renderMessage]);
|
||||
|
||||
// Log performance data
|
||||
React.useEffect(() => {
|
||||
logEvent(`render-range-${visibleRange.start}-${visibleRange.end}`);
|
||||
}, [visibleRange, logEvent]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
height={height}
|
||||
width={width}
|
||||
style={{ scrollbarGutter: 'stable' }}
|
||||
>
|
||||
<FixedWindowScroller
|
||||
ref={scrollerRef}
|
||||
items={messages}
|
||||
height={height}
|
||||
width={width}
|
||||
itemHeight={3} // Average message height (will be refined)
|
||||
renderItem={renderItem}
|
||||
overscrollItems={25} // Number of off-screen items to keep mounted
|
||||
onScroll={handleScroll}
|
||||
initialScrollToEnd={true}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
export default OptimizedTranscriptPane;
|
||||
@@ -1,5 +1,7 @@
|
||||
import { AlternateScreen, Box, NoSelect, ScrollBox, Text } from '@hermes/ink'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { $uiState } from '../app/uiStore.js'
|
||||
import { OptimizedTranscriptPane } from './OptimizedTranscriptPane.js'
|
||||
import { memo } from 'react'
|
||||
|
||||
import { useGateway } from '../app/gatewayContext.js'
|
||||
@@ -98,21 +100,23 @@ const StreamingAssistant = memo(function StreamingAssistant({
|
||||
})
|
||||
|
||||
const TranscriptPane = memo(function TranscriptPane({
|
||||
actions,
|
||||
composer,
|
||||
progress,
|
||||
transcript
|
||||
const TranscriptPane = memo(function TranscriptPane({
|
||||
actions,
|
||||
composer,
|
||||
progress,
|
||||
transcript
|
||||
}: Pick<AppLayoutProps, 'actions' | 'composer' | 'progress' | 'transcript'>) {
|
||||
const ui = useStore($uiState)
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScrollBox flexDirection="column" flexGrow={1} flexShrink={1} ref={transcript.scrollRef} stickyScroll>
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
{transcript.virtualHistory.topSpacer > 0 ? <Box height={transcript.virtualHistory.topSpacer} /> : null}
|
||||
|
||||
{transcript.virtualRows.slice(transcript.virtualHistory.start, transcript.virtualHistory.end).map(row => (
|
||||
<Box flexDirection="column" key={row.key} ref={transcript.virtualHistory.measureRef(row.key)}>
|
||||
const ui = useStore($uiState)
|
||||
const usePerfMode = true // Always use performance mode for better scrolling
|
||||
return (
|
||||
<>
|
||||
{usePerfMode ? (
|
||||
<OptimizedTranscriptPane
|
||||
messages={transcript.virtualRows}
|
||||
height={ui.rows - 6} // Reserve space for input/status
|
||||
width={composer.cols}
|
||||
renderMessage={(row) => (
|
||||
<Box flexDirection="column" key={row.key} paddingX={1}>
|
||||
{row.msg.kind === 'intro' ? (
|
||||
<Box flexDirection="column" paddingTop={1}>
|
||||
<Banner t={ui.theme} />
|
||||
@@ -132,18 +136,35 @@ const TranscriptPane = memo(function TranscriptPane({
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<ScrollBox flexDirection="column" flexGrow={1} flexShrink={1} ref={transcript.scrollRef} stickyScroll>
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
{transcript.virtualHistory.topSpacer > 0 ? <Box height={transcript.virtualHistory.topSpacer} /> : null}
|
||||
|
||||
{transcript.virtualHistory.bottomSpacer > 0 ? <Box height={transcript.virtualHistory.bottomSpacer} /> : null}
|
||||
{transcript.virtualRows.slice(transcript.virtualHistory.start, transcript.virtualHistory.end).map(row => (
|
||||
<Box flexDirection="column" key={row.key} ref={transcript.virtualHistory.measureRef(row.key)}>
|
||||
{row.msg.kind === 'intro' ? (
|
||||
<Box flexDirection="column" paddingTop={1}>
|
||||
<Banner t={ui.theme} />
|
||||
|
||||
<StreamingAssistant
|
||||
busy={ui.busy}
|
||||
cols={composer.cols}
|
||||
compact={ui.compact}
|
||||
detailsMode={ui.detailsMode}
|
||||
progress={progress}
|
||||
sections={ui.sections}
|
||||
t={ui.theme}
|
||||
{row.msg.info?.version && <SessionPanel info={row.msg.info} sid={ui.sid} t={ui.theme} />}
|
||||
</Box>
|
||||
) : row.msg.kind === 'panel' && row.msg.panelData ? (
|
||||
<Panel sections={row.msg.panelData.sections} t={ui.theme} title={row.msg.panelData.title} />
|
||||
) : (
|
||||
<MessageLine
|
||||
cols={composer.cols}
|
||||
compact={ui.compact}
|
||||
detailsMode={ui.detailsMode}
|
||||
msg={row.msg}
|
||||
sections={ui.sections}
|
||||
t={ui.theme}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
/>
|
||||
</Box>
|
||||
</ScrollBox>
|
||||
|
||||
421
ui-tui/src/hooks/usePerformance.ts
Normal file
421
ui-tui/src/hooks/usePerformance.ts
Normal file
@@ -0,0 +1,421 @@
|
||||
import { useRef, useCallback, useState, useEffect, useLayoutEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Custom hook for performance monitoring
|
||||
* Helps track and log performance metrics for components
|
||||
*/
|
||||
export function usePerformanceMonitor(componentName: string, options = {
|
||||
logToConsole: false,
|
||||
thresholdMs: 16 // 60fps threshold
|
||||
}) {
|
||||
const renderCountRef = useRef(0);
|
||||
const renderTimesRef = useRef<number[]>([]);
|
||||
const lastRenderTimeRef = useRef(performance.now());
|
||||
const [metrics, setMetrics] = useState({
|
||||
averageRenderTime: 0,
|
||||
totalRenders: 0,
|
||||
slowRenders: 0
|
||||
});
|
||||
|
||||
// Measure start of render cycle
|
||||
useEffect(() => {
|
||||
const startTime = performance.now();
|
||||
|
||||
return () => {
|
||||
const endTime = performance.now();
|
||||
const renderTime = endTime - startTime;
|
||||
|
||||
renderCountRef.current += 1;
|
||||
renderTimesRef.current.push(renderTime);
|
||||
|
||||
// Keep only the last 100 measurements
|
||||
if (renderTimesRef.current.length > 100) {
|
||||
renderTimesRef.current.shift();
|
||||
}
|
||||
|
||||
// Calculate average render time
|
||||
const average = renderTimesRef.current.reduce((sum, time) => sum + time, 0) /
|
||||
renderTimesRef.current.length;
|
||||
|
||||
// Count slow renders
|
||||
const slowRenders = renderTimesRef.current.filter(time => time > options.thresholdMs).length;
|
||||
|
||||
// Update metrics
|
||||
setMetrics({
|
||||
averageRenderTime: average,
|
||||
totalRenders: renderCountRef.current,
|
||||
slowRenders
|
||||
});
|
||||
|
||||
if (options.logToConsole && renderTime > options.thresholdMs) {
|
||||
console.log(
|
||||
`[PERF] ${componentName} render: ${renderTime.toFixed(2)}ms ` +
|
||||
`(avg: ${average.toFixed(2)}ms, slow: ${slowRenders}/${renderCountRef.current})`
|
||||
);
|
||||
}
|
||||
|
||||
lastRenderTimeRef.current = endTime;
|
||||
};
|
||||
});
|
||||
|
||||
// Function to measure specific operations
|
||||
const measureOperation = useCallback((operationName: string, fn: () => void) => {
|
||||
const start = performance.now();
|
||||
fn();
|
||||
const duration = performance.now() - start;
|
||||
|
||||
if (options.logToConsole && duration > options.thresholdMs) {
|
||||
console.log(`[PERF] ${componentName}.${operationName}: ${duration.toFixed(2)}ms`);
|
||||
}
|
||||
|
||||
return duration;
|
||||
}, [componentName, options.logToConsole, options.thresholdMs]);
|
||||
|
||||
return {
|
||||
metrics,
|
||||
measureOperation,
|
||||
logEvent: (event: string, durationMs?: number) => {
|
||||
if (options.logToConsole) {
|
||||
const message = durationMs
|
||||
? `[PERF] ${componentName}.${event}: ${durationMs.toFixed(2)}ms`
|
||||
: `[PERF] ${componentName}.${event}`;
|
||||
console.log(message);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced version of useVirtualHistory with better performance characteristics
|
||||
* Uses the same API as the original but with optimizations for large message lists
|
||||
*/
|
||||
export function useEnhancedVirtualHistory(
|
||||
scrollRef: any,
|
||||
items: readonly { key: string }[],
|
||||
columns: number,
|
||||
options = {}
|
||||
) {
|
||||
// Core state
|
||||
const nodesRef = useRef(new Map<string, unknown>());
|
||||
const heightsRef = useRef(new Map<string, number>());
|
||||
const refsMap = useRef(new Map<string, (el: unknown) => void>());
|
||||
const [version, setVersion] = useState(0);
|
||||
|
||||
// Performance tracking
|
||||
const measureTime = useRef({
|
||||
offsetCalculation: 0,
|
||||
heightUpdate: 0,
|
||||
rangeCalculation: 0
|
||||
});
|
||||
|
||||
// Default options
|
||||
const {
|
||||
estimate = 4,
|
||||
overscan = 40,
|
||||
maxMounted = 260,
|
||||
coldStartCount = 40,
|
||||
logPerformance = false
|
||||
} = options;
|
||||
|
||||
// Width change handling with scaling
|
||||
const prevColumns = useRef(columns);
|
||||
const skipMeasurement = useRef(false);
|
||||
const prevRange = useRef<null | readonly [number, number]>(null);
|
||||
const freezeRenders = useRef(0);
|
||||
|
||||
// Handle column width changes - scale heights to avoid full remeasurement
|
||||
if (prevColumns.current !== columns && prevColumns.current > 0 && columns > 0) {
|
||||
const ratio = prevColumns.current / columns;
|
||||
prevColumns.current = columns;
|
||||
|
||||
const start = performance.now();
|
||||
|
||||
for (const [k, h] of heightsRef.current) {
|
||||
heightsRef.current.set(k, Math.max(1, Math.round(h * ratio)));
|
||||
}
|
||||
|
||||
if (logPerformance) {
|
||||
console.log(`[PERF] Height scaling: ${(performance.now() - start).toFixed(2)}ms`);
|
||||
}
|
||||
|
||||
skipMeasurement.current = true;
|
||||
freezeRenders.current = 2; // Freeze for 2 renders to allow memos to stabilize
|
||||
}
|
||||
|
||||
// Track scroll position and viewport
|
||||
const metricsRef = useRef({
|
||||
sticky: true,
|
||||
top: 0,
|
||||
viewportHeight: 0,
|
||||
scrollTop: 0,
|
||||
pendingDelta: 0
|
||||
});
|
||||
|
||||
// Update scroll metrics whenever the scroll position changes
|
||||
useEffect(() => {
|
||||
if (!scrollRef.current) return;
|
||||
|
||||
const updateMetrics = () => {
|
||||
const s = scrollRef.current;
|
||||
if (!s) return;
|
||||
|
||||
metricsRef.current = {
|
||||
sticky: s.isSticky?.() ?? true,
|
||||
top: Math.max(0, s.getScrollTop?.() ?? 0),
|
||||
viewportHeight: Math.max(0, s.getViewportHeight?.() ?? 0),
|
||||
scrollTop: Math.max(0, s.getScrollTop?.() ?? 0),
|
||||
pendingDelta: s.getPendingDelta?.() ?? 0
|
||||
};
|
||||
|
||||
// Force update if we need to recalculate visible range
|
||||
setVersion(v => v + 1);
|
||||
};
|
||||
|
||||
// Initial update
|
||||
updateMetrics();
|
||||
|
||||
// Subscribe to scroll events if supported
|
||||
const unsubscribe = scrollRef.current.subscribe?.(updateMetrics) ?? (() => {});
|
||||
|
||||
return unsubscribe;
|
||||
}, [scrollRef.current]);
|
||||
|
||||
// Clean up stale items
|
||||
useEffect(() => {
|
||||
const keep = new Set(items.map(i => i.key));
|
||||
let dirty = false;
|
||||
|
||||
for (const k of heightsRef.current.keys()) {
|
||||
if (!keep.has(k)) {
|
||||
heightsRef.current.delete(k);
|
||||
nodesRef.current.delete(k);
|
||||
refsMap.current.delete(k);
|
||||
dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (dirty) {
|
||||
setVersion(v => v + 1);
|
||||
}
|
||||
}, [items]);
|
||||
|
||||
// Calculate offsets based on cached heights - memoized to avoid recalculation
|
||||
const offsets = React.useMemo(() => {
|
||||
void version; // Depends on version to trigger recalculation
|
||||
|
||||
const start = performance.now();
|
||||
const out = new Array<number>(items.length + 1).fill(0);
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
out[i + 1] = out[i]! + Math.max(1, Math.floor(heightsRef.current.get(items[i]!.key) ?? estimate));
|
||||
}
|
||||
|
||||
measureTime.current.offsetCalculation = performance.now() - start;
|
||||
if (logPerformance && measureTime.current.offsetCalculation > 5) {
|
||||
console.log(`[PERF] Offset calculation: ${measureTime.current.offsetCalculation.toFixed(2)}ms`);
|
||||
}
|
||||
|
||||
return out;
|
||||
}, [estimate, items, version]);
|
||||
|
||||
// Calculate visible range
|
||||
const rangeStart = React.useMemo(() => {
|
||||
const start = performance.now();
|
||||
|
||||
const n = items.length;
|
||||
const total = offsets[n] ?? 0;
|
||||
const metrics = metricsRef.current;
|
||||
const { top, viewportHeight, sticky } = metrics;
|
||||
|
||||
// Handle frozen range for width changes
|
||||
const frozenRange =
|
||||
freezeRenders.current > 0 && prevRange.current && prevRange.current[0] < n ? prevRange.current : null;
|
||||
|
||||
let startIdx = 0;
|
||||
let endIdx = n;
|
||||
|
||||
if (frozenRange) {
|
||||
startIdx = frozenRange[0];
|
||||
endIdx = Math.min(frozenRange[1], n);
|
||||
} else if (n > 0) {
|
||||
if (viewportHeight <= 0) {
|
||||
startIdx = Math.max(0, n - coldStartCount);
|
||||
} else {
|
||||
// Binary search for start and end indices
|
||||
let lo = 0;
|
||||
let hi = n;
|
||||
|
||||
// Find start index (first item below top - overscan)
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
offsets[mid]! <= Math.max(0, top - overscan) ? (lo = mid + 1) : (hi = mid);
|
||||
}
|
||||
startIdx = Math.max(0, lo - 1);
|
||||
|
||||
// Find end index (first item below top + viewportHeight + overscan)
|
||||
lo = startIdx;
|
||||
hi = n;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
offsets[mid]! <= top + viewportHeight + overscan ? (lo = mid + 1) : (hi = mid);
|
||||
}
|
||||
endIdx = lo;
|
||||
}
|
||||
}
|
||||
|
||||
// Limit number of mounted items
|
||||
if (endIdx - startIdx > maxMounted) {
|
||||
sticky ? (startIdx = Math.max(0, endIdx - maxMounted)) : (endIdx = Math.min(n, startIdx + maxMounted));
|
||||
}
|
||||
|
||||
// Update freeze counter
|
||||
if (freezeRenders.current > 0) {
|
||||
freezeRenders.current--;
|
||||
} else {
|
||||
prevRange.current = [startIdx, endIdx];
|
||||
}
|
||||
|
||||
measureTime.current.rangeCalculation = performance.now() - start;
|
||||
if (logPerformance && measureTime.current.rangeCalculation > 5) {
|
||||
console.log(`[PERF] Range calculation: ${measureTime.current.rangeCalculation.toFixed(2)}ms`);
|
||||
}
|
||||
|
||||
return { start: startIdx, end: endIdx };
|
||||
}, [items.length, offsets, version, overscan, maxMounted, coldStartCount]);
|
||||
|
||||
// Create measurement ref callback
|
||||
const measureRef = useCallback((key: string) => {
|
||||
let fn = refsMap.current.get(key);
|
||||
|
||||
if (!fn) {
|
||||
fn = (el: unknown) => (el ? nodesRef.current.set(key, el) : nodesRef.current.delete(key));
|
||||
refsMap.current.set(key, fn);
|
||||
}
|
||||
|
||||
return fn;
|
||||
}, []);
|
||||
|
||||
// Update height measurements after render
|
||||
useLayoutEffect(() => {
|
||||
const start = performance.now();
|
||||
let dirty = false;
|
||||
|
||||
if (skipMeasurement.current) {
|
||||
skipMeasurement.current = false;
|
||||
} else {
|
||||
for (let i = rangeStart.start; i < rangeStart.end; i++) {
|
||||
const k = items[i]?.key;
|
||||
|
||||
if (!k) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const node = nodesRef.current.get(k) as any;
|
||||
const h = Math.ceil(node?.yogaNode?.getComputedHeight?.() ?? 0);
|
||||
|
||||
if (h > 0 && heightsRef.current.get(k) !== h) {
|
||||
heightsRef.current.set(k, h);
|
||||
dirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (dirty) {
|
||||
setVersion(v => v + 1);
|
||||
}
|
||||
|
||||
measureTime.current.heightUpdate = performance.now() - start;
|
||||
if (logPerformance && measureTime.current.heightUpdate > 5) {
|
||||
console.log(`[PERF] Height update: ${measureTime.current.heightUpdate.toFixed(2)}ms`);
|
||||
}
|
||||
}, [rangeStart.end, rangeStart.start, items]);
|
||||
|
||||
// Return the same API as the original hook for compatibility
|
||||
return {
|
||||
bottomSpacer: Math.max(0, offsets[items.length] ?? 0 - (offsets[rangeStart.end] ?? 0)),
|
||||
end: rangeStart.end,
|
||||
measureRef,
|
||||
offsets,
|
||||
start: rangeStart.start,
|
||||
topSpacer: offsets[rangeStart.start] ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to throttle scroll events and track scroll performance
|
||||
*/
|
||||
export function useScrollPerformance(componentName: string, options = {
|
||||
logToConsole: false,
|
||||
sampleRate: 0.1, // Only log 10% of scroll events to reduce noise
|
||||
thresholdMs: 16
|
||||
}) {
|
||||
const scrollCountRef = useRef(0);
|
||||
const scrollTimesRef = useRef<number[]>([]);
|
||||
const isScrollingRef = useRef(false);
|
||||
const scrollStartTimeRef = useRef(0);
|
||||
const scrollThrottleTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const onScrollStart = useCallback(() => {
|
||||
if (!isScrollingRef.current) {
|
||||
isScrollingRef.current = true;
|
||||
scrollStartTimeRef.current = performance.now();
|
||||
|
||||
if (options.logToConsole) {
|
||||
console.log(`[SCROLL] ${componentName} scroll started`);
|
||||
}
|
||||
}
|
||||
}, [componentName, options.logToConsole]);
|
||||
|
||||
const onScrollEnd = useCallback(() => {
|
||||
if (isScrollingRef.current) {
|
||||
const duration = performance.now() - scrollStartTimeRef.current;
|
||||
scrollTimesRef.current.push(duration);
|
||||
|
||||
// Keep array at reasonable size
|
||||
if (scrollTimesRef.current.length > 50) {
|
||||
scrollTimesRef.current.shift();
|
||||
}
|
||||
|
||||
isScrollingRef.current = false;
|
||||
|
||||
if (options.logToConsole && Math.random() < options.sampleRate) {
|
||||
const avg = scrollTimesRef.current.reduce((sum, time) => sum + time, 0) /
|
||||
scrollTimesRef.current.length;
|
||||
|
||||
console.log(
|
||||
`[SCROLL] ${componentName} scroll ended: ${duration.toFixed(2)}ms ` +
|
||||
`(avg: ${avg.toFixed(2)}ms)`
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [componentName, options.logToConsole, options.sampleRate]);
|
||||
|
||||
const onScroll = useCallback(() => {
|
||||
scrollCountRef.current += 1;
|
||||
|
||||
// Start scrolling tracking if not already
|
||||
onScrollStart();
|
||||
|
||||
// Reset the scroll end timer
|
||||
if (scrollThrottleTimerRef.current) {
|
||||
clearTimeout(scrollThrottleTimerRef.current);
|
||||
}
|
||||
|
||||
// Set timer to detect when scrolling stops
|
||||
scrollThrottleTimerRef.current = setTimeout(() => {
|
||||
onScrollEnd();
|
||||
}, 150); // Consider scrolling stopped after 150ms of inactivity
|
||||
|
||||
}, [onScrollStart, onScrollEnd]);
|
||||
|
||||
// Clean up
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (scrollThrottleTimerRef.current) {
|
||||
clearTimeout(scrollThrottleTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { onScroll };
|
||||
}
|
||||
Reference in New Issue
Block a user