refactor(cli): remove renderLogger and add performance optimizations

- Remove unused renderLogger.ts utility file and all its usages
- Add RAF-style scroll throttling to reduce state updates
- Stabilize useExtensionHost hook return values with useCallback/useMemo
- Add streaming message debouncing to batch rapid partial updates
- Add shallow array equality checks to prevent unnecessary re-renders
This commit is contained in:
cte 2026-01-08 03:05:05 -08:00
parent adc1da129c
commit 9ffe49a22b
3 changed files with 164 additions and 54 deletions

View file

@ -6,7 +6,6 @@ import type { WebviewMessage } from "@roo-code/types"
import { getGlobalCommandsForAutocomplete } from "../utils/globalCommands.js"
import { arePathsEqual } from "../utils/pathUtils.js"
import { getContextWindow } from "../utils/getContextWindow.js"
import type { AppProps } from "./types.js"
import * as theme from "./theme.js"
@ -140,10 +139,9 @@ function AppInner({
} = useUIStateStore()
// Compute context window from router models and API configuration
const contextWindow = useMemo(
() => getContextWindow(routerModels, apiConfiguration),
[routerModels, apiConfiguration],
)
const contextWindow = useMemo(() => {
return getContextWindow(routerModels, apiConfiguration)
}, [routerModels, apiConfiguration])
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const autocompleteRef = useRef<AutocompleteInputHandle<any>>(null)
@ -175,6 +173,10 @@ function AppInner({
const [scrollState, setScrollState] = useState({ scrollTop: 0, maxScroll: 0, isAtBottom: true })
const { scrollToBottomTrigger, scrollToBottom } = useScrollToBottom()
// RAF-style throttle refs for scroll updates (prevents multiple state updates per event loop tick)
const rafIdRef = useRef<NodeJS.Immediate | null>(null)
const pendingScrollRef = useRef<{ scrollTop: number; maxScroll: number; isAtBottom: boolean } | null>(null)
// Toast notifications for ephemeral messages (e.g., mode changes)
const { currentToast, showInfo } = useToast()
@ -275,9 +277,31 @@ function AppInner({
prevMessageCount.current = messages.length
}, [messages.length, scrollState.isAtBottom, scrollToBottom])
// Handle scroll state changes from ScrollArea
// Handle scroll state changes from ScrollArea (RAF-throttled to coalesce rapid updates)
const handleScroll = useCallback((scrollTop: number, maxScroll: number, isAtBottom: boolean) => {
setScrollState({ scrollTop, maxScroll, isAtBottom })
// Store the latest scroll values in ref
pendingScrollRef.current = { scrollTop, maxScroll, isAtBottom }
// Only schedule one update per event loop tick
if (rafIdRef.current === null) {
rafIdRef.current = setImmediate(() => {
rafIdRef.current = null
const pending = pendingScrollRef.current
if (pending) {
setScrollState(pending)
pendingScrollRef.current = null
}
})
}
}, [])
// Cleanup RAF-style timer on unmount
useEffect(() => {
return () => {
if (rafIdRef.current !== null) {
clearImmediate(rafIdRef.current)
}
}
}, [])
// File search handler for the file trigger
@ -350,17 +374,15 @@ function AppInner({
if (fileSearchResults === prevFileSearchResultsRef.current) {
return
}
const currentPickerState = pickerStateRef.current
const willRefresh =
currentPickerState.isOpen && currentPickerState.activeTrigger?.id === "file" && fileSearchResults.length > 0
prevFileSearchResultsRef.current = fileSearchResults
// Read pickerState from ref to avoid dependency
const currentPickerState = pickerStateRef.current
// Only refresh when file picker is open and we have new results
if (
currentPickerState.isOpen &&
currentPickerState.activeTrigger?.id === "file" &&
fileSearchResults.length > 0
) {
if (willRefresh) {
autocompleteRef.current?.refreshSearch()
followupAutocompleteRef.current?.refreshSearch()
}

View file

@ -1,4 +1,4 @@
import { useEffect, useRef, useCallback } from "react"
import { useEffect, useRef, useCallback, useMemo } from "react"
import { useApp } from "ink"
import { randomUUID } from "crypto"
import type { ExtensionMessage, WebviewMessage } from "@roo-code/types"
@ -179,27 +179,28 @@ export function useExtensionHost({
}
}, []) // Run once on mount
// Expose sendToExtension method
const sendToExtension = hostRef.current
? (msg: WebviewMessage) => {
hostRef.current?.sendToExtension(msg)
}
: null
// Stable sendToExtension - uses ref to always access current host
// This function reference never changes, preventing downstream useCallback/useMemo invalidations
const sendToExtension = useCallback((msg: WebviewMessage) => {
hostRef.current?.sendToExtension(msg)
}, [])
// Expose runTask method
const runTask = hostRef.current
? (prompt: string) => {
if (!hostRef.current) {
return Promise.reject(new Error("Extension host not ready"))
}
return hostRef.current.runTask(prompt)
}
: null
// Stable runTask - uses ref to always access current host
const runTask = useCallback((prompt: string): Promise<void> => {
if (!hostRef.current) {
return Promise.reject(new Error("Extension host not ready"))
}
return hostRef.current.runTask(prompt)
}, [])
return {
isReady: isReadyRef.current,
sendToExtension,
runTask,
cleanup,
}
// Memoized return object to prevent unnecessary re-renders in consumers
return useMemo(
() => ({
isReady: isReadyRef.current,
sendToExtension,
runTask,
cleanup,
}),
[sendToExtension, runTask, cleanup],
)
}

View file

@ -5,6 +5,38 @@ import type { TokenUsage, ProviderSettings, TodoItem } from "@roo-code/types"
import type { TUIMessage, PendingAsk, TaskHistoryItem } from "./types.js"
import type { FileResult, SlashCommandResult, ModeResult } from "./components/autocomplete/index.js"
/**
* Shallow array equality check - compares array length and element references.
* Used to prevent unnecessary state updates when array content hasn't changed.
*/
function shallowArrayEqual<T>(a: T[], b: T[]): boolean {
if (a === b) return true
if (a.length !== b.length) return false
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false
}
return true
}
/**
* Streaming message debounce configuration.
* Batches rapid partial message updates to reduce re-renders during streaming.
* Higher values = fewer renders but text appears more "chunky"
* Lower values = smoother text but more renders
*/
const STREAMING_DEBOUNCE_MS = 150 // 150ms debounce for aggressive batching
// Pending streaming updates - batched and flushed after debounce interval
interface PendingStreamUpdate {
id: string
content: string
partial: boolean
timestamp: number
}
const pendingStreamUpdates: Map<string, PendingStreamUpdate> = new Map()
let streamingDebounceTimer: ReturnType<typeof setTimeout> | null = null
/**
* RouterModels type for context window lookup.
* Simplified version - we only need contextWindow from ModelInfo.
@ -120,24 +152,74 @@ const initialState: CLIState = {
previousTodos: [],
}
export const useCLIStore = create<CLIState & CLIActions>((set) => ({
export const useCLIStore = create<CLIState & CLIActions>((set, get) => ({
...initialState,
addMessage: (msg) =>
set((state) => {
// Check if message already exists (by ID).
const existingIndex = state.messages.findIndex((m) => m.id === msg.id)
addMessage: (msg) => {
const state = get()
// Check if message already exists (by ID).
const existingIndex = state.messages.findIndex((m) => m.id === msg.id)
if (existingIndex !== -1) {
// Update existing message in place.
const updated = [...state.messages]
updated[existingIndex] = msg
return { messages: updated }
// For NEW messages (not updates) - always apply immediately
if (existingIndex === -1) {
set({ messages: [...state.messages, msg] })
return
}
// For UPDATES to existing messages:
// If partial (streaming) and message exists, debounce the update
if (msg.partial) {
// Queue the update
pendingStreamUpdates.set(msg.id, {
id: msg.id,
content: msg.content,
partial: true,
timestamp: Date.now(),
})
// Schedule flush if not already scheduled
if (!streamingDebounceTimer) {
streamingDebounceTimer = setTimeout(() => {
// Flush all pending updates as a single batch
const currentState = get()
const updates = Array.from(pendingStreamUpdates.values())
pendingStreamUpdates.clear()
streamingDebounceTimer = null
if (updates.length === 0) return
// Apply all pending updates in one state change
const newMessages = [...currentState.messages]
let hasChanges = false
for (const update of updates) {
const idx = newMessages.findIndex((m) => m.id === update.id)
if (idx !== -1 && newMessages[idx]) {
newMessages[idx] = {
...newMessages[idx],
content: update.content,
partial: update.partial,
}
hasChanges = true
}
}
if (hasChanges) {
set({ messages: newMessages })
}
}, STREAMING_DEBOUNCE_MS)
}
return
}
// Add new message.
return { messages: [...state.messages, msg] }
}),
// Non-partial update (final message) - apply immediately and clear any pending
// This ensures the final complete message is always shown
pendingStreamUpdates.delete(msg.id)
const updated = [...state.messages]
updated[existingIndex] = msg
set({ messages: updated })
},
updateMessage: (id, content, partial) =>
set((state) => {
@ -195,10 +277,15 @@ export const useCLIStore = create<CLIState & CLIActions>((set) => ({
apiConfiguration: state.apiConfiguration,
})),
setIsResumingTask: (isResuming) => set({ isResumingTask: isResuming }),
setFileSearchResults: (results) => set({ fileSearchResults: results }),
setAllSlashCommands: (commands) => set({ allSlashCommands: commands }),
setAvailableModes: (modes) => set({ availableModes: modes }),
setTaskHistory: (history) => set({ taskHistory: history }),
// Use shallow equality to prevent unnecessary re-renders when array content is the same
setFileSearchResults: (results) =>
set((state) => (shallowArrayEqual(state.fileSearchResults, results) ? state : { fileSearchResults: results })),
setAllSlashCommands: (commands) =>
set((state) => (shallowArrayEqual(state.allSlashCommands, commands) ? state : { allSlashCommands: commands })),
setAvailableModes: (modes) =>
set((state) => (shallowArrayEqual(state.availableModes, modes) ? state : { availableModes: modes })),
setTaskHistory: (history) =>
set((state) => (shallowArrayEqual(state.taskHistory, history) ? state : { taskHistory: history })),
setCurrentTaskId: (taskId) => set({ currentTaskId: taskId }),
setCurrentMode: (mode) => set({ currentMode: mode }),
setTokenUsage: (usage) => set({ tokenUsage: usage }),