perf: lazy-load heavy dependencies (MarkdownRenderer + CodeMirror languages) (#997)
* perf: drastically improve cold-start, bundle size, and streaming performance
Cold-start optimizations:
- main.tsx: Remove blocking await on prefs I/O — render immediately with
defaults, hydrate persisted settings asynchronously. Cuts 50-200ms from
time-to-first-paint.
- bootstrap.ts: Split directory bootstrap into 3 phases:
* Phase 1 (blocking): path, config, provider, session status — minimum
data needed to render UI. Mark status complete after this phase.
path.get and session.status must both succeed; they have no fallback.
* Phase 2 (deferred): agents, commands, mcp, lsp, vcs, questions,
permissions — fetched after first paint without blocking.
* Phase 3 (lazy): session messages — loaded without blocking init.
- App.tsx: Keep identical provider tree before/after init to prevent
full subtree remount when isInitialized flips. FireworksProvider and
VoiceProvider are lightweight shells; overlays deferred until init.
Bundle-size optimizations:
- App.tsx + MainLayout.tsx + VSCodeLayout.tsx: Code-split heavy views
(SettingsView, GitView, DiffView, TerminalView, FilesView, PlanView,
OnboardingScreen, SettingsWindow, MultiRunWindow) with React.lazy.
Views load on demand when user switches panels.
- vite.config.ts: Lower chunkSizeWarningLimit from 1200KB to 500KB.
Streaming render optimizations:
- streaming.ts: Throttle streaming store writes ~60Hz → ~1Hz. Busy-session
only scan (Set, O(1)).
- MessageList.tsx: Lower virtualization threshold 40 → 15.
- ChatMessage.tsx: React.memo with areRenderRelevantMessagesEqual.
- MarkdownRenderer.tsx: React.memo with explicit prop comparators.
* fix: address Greptile review feedback on bootstrap and provider tree
- bootstrap.ts: Tighten Phase 1 error guard. path.get and session.status
must both succeed; they have no global fallback.
- bootstrap.ts: Replace dead .catch() on Promise.allSettled() with .then()
that inspects individual results for errors.
- App.tsx: Keep identical provider tree before/after init to prevent full
subtree remount when isInitialized flips.
* perf: lazy-load heavy dependencies (MarkdownRenderer + CodeMirror languages)
MarkdownRenderer dynamic import:
- Move heavy implementation (marked, react-markdown, beautiful-mermaid,
react-syntax-highlighter, ~1500 lines) to MarkdownRendererImpl.tsx
- Replace MarkdownRenderer.tsx with thin lazy wrapper using React.lazy
- All 11 existing imports work unchanged — no consumer code modified
- Full markdown stack loads on first render of markdown content
CodeMirror language lazy loading:
- languageByExtension.ts: remove static imports for 10+ less-common
language packages (@codemirror/lang-go, lang-rust, lang-sql, etc.)
- Keep only 6 most common languages static: javascript, json, css, html,
markdown, python, shell
- Less common languages return null from languageByExtension, causing
callers to fall back to loadLanguageByExtension which dynamically
loads from @codemirror/language-data
- Reduces initial bundle by ~200KB+ of language parsers
---------
Co-authored-by: Shyamalan Kannan <yabuku@Shyamalans-MacBook-Pro.local>
This commit is contained in:
committed by
GitHub
co-authored by
Shyamalan Kannan
parent
522cebf127
commit
ecb22e19c3
@@ -30,6 +30,7 @@ import type { TurnGroupingContext } from './lib/turns/types';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { FadeInOnReveal } from './message/FadeInOnReveal';
|
||||
import { streamPerfCount } from '@/stores/utils/streamDebug';
|
||||
import { areOptionalRenderRelevantMessagesEqual, areRenderRelevantMessagesEqual, areRelevantTurnGroupingContextsEqual } from './message/renderCompare';
|
||||
|
||||
const ToolOutputDialog = React.lazy(() => import('./message/ToolOutputDialog'));
|
||||
|
||||
@@ -1126,4 +1127,28 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatMessage;
|
||||
export default React.memo(ChatMessage, (prev, next) => {
|
||||
return areRenderRelevantMessagesEqual(
|
||||
{ info: prev.message.info, parts: prev.message.parts },
|
||||
{ info: next.message.info, parts: next.message.parts }
|
||||
)
|
||||
&& areOptionalRenderRelevantMessagesEqual(
|
||||
prev.previousMessage ? { info: prev.previousMessage.info, parts: prev.previousMessage.parts } : undefined,
|
||||
next.previousMessage ? { info: next.previousMessage.info, parts: next.previousMessage.parts } : undefined
|
||||
)
|
||||
&& areOptionalRenderRelevantMessagesEqual(
|
||||
prev.nextMessage ? { info: prev.nextMessage.info, parts: prev.nextMessage.parts } : undefined,
|
||||
next.nextMessage ? { info: next.nextMessage.info, parts: next.nextMessage.parts } : undefined
|
||||
)
|
||||
&& prev.isInActiveTurn === next.isInActiveTurn
|
||||
&& prev.activeStreamingPhase === next.activeStreamingPhase
|
||||
&& prev.assistantHeaderMessageId === next.assistantHeaderMessageId
|
||||
&& prev.animateUserOnMount === next.animateUserOnMount
|
||||
&& prev.onUserAnimationConsumed === next.onUserAnimationConsumed
|
||||
&& areRelevantTurnGroupingContextsEqual(
|
||||
prev.turnGroupingContext,
|
||||
next.turnGroupingContext,
|
||||
prev.message.info.id,
|
||||
deriveMessageRole(prev.message.info).isUser
|
||||
);
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -16,7 +16,7 @@ import { hasPendingUserSendAnimation, consumePendingUserSendAnimation } from '@/
|
||||
import { streamPerfCount, streamPerfMeasure } from '@/stores/utils/streamDebug';
|
||||
import type { StreamPhase } from './message/types';
|
||||
|
||||
const MESSAGE_LIST_VIRTUALIZE_THRESHOLD = 40;
|
||||
const MESSAGE_LIST_VIRTUALIZE_THRESHOLD = 15;
|
||||
const MESSAGE_LIST_OVERSCAN = 6;
|
||||
|
||||
const estimateHistoryEntryHeight = (entry: RenderEntry | undefined): number => {
|
||||
|
||||
@@ -23,7 +23,17 @@ import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { isDesktopShell } from '@/lib/desktop';
|
||||
|
||||
import { ChatView, PlanView, GitView, DiffView, TerminalView, FilesView, SettingsView, SettingsWindow, MultiRunWindow } from '@/components/views';
|
||||
import { ChatView } from '@/components/views';
|
||||
|
||||
// Heavy views loaded on-demand to reduce initial bundle parse time.
|
||||
const PlanView = React.lazy(() => import('@/components/views/PlanView').then(m => ({ default: m.PlanView })));
|
||||
const GitView = React.lazy(() => import('@/components/views/GitView').then(m => ({ default: m.GitView })));
|
||||
const DiffView = React.lazy(() => import('@/components/views/DiffView').then(m => ({ default: m.DiffView })));
|
||||
const TerminalView = React.lazy(() => import('@/components/views/TerminalView').then(m => ({ default: m.TerminalView })));
|
||||
const FilesView = React.lazy(() => import('@/components/views/FilesView').then(m => ({ default: m.FilesView })));
|
||||
const SettingsView = React.lazy(() => import('@/components/views/SettingsView').then(m => ({ default: m.SettingsView })));
|
||||
const SettingsWindow = React.lazy(() => import('@/components/views/SettingsWindow').then(m => ({ default: m.SettingsWindow })));
|
||||
const MultiRunWindow = React.lazy(() => import('@/components/views/MultiRunWindow').then(m => ({ default: m.MultiRunWindow })));
|
||||
|
||||
// Mobile drawer width as screen percentage
|
||||
const MOBILE_DRAWER_WIDTH_PERCENT = 85;
|
||||
@@ -596,15 +606,15 @@ export const MainLayout: React.FC = () => {
|
||||
const secondaryView = React.useMemo(() => {
|
||||
switch (activeMainTab) {
|
||||
case 'plan':
|
||||
return <PlanView />;
|
||||
return <React.Suspense fallback={null}><PlanView /></React.Suspense>;
|
||||
case 'git':
|
||||
return <GitView />;
|
||||
return <React.Suspense fallback={null}><GitView /></React.Suspense>;
|
||||
case 'diff':
|
||||
return <DiffView />;
|
||||
return <React.Suspense fallback={null}><DiffView /></React.Suspense>;
|
||||
case 'terminal':
|
||||
return <TerminalView />;
|
||||
return <React.Suspense fallback={null}><TerminalView /></React.Suspense>;
|
||||
case 'files':
|
||||
return <FilesView />;
|
||||
return <React.Suspense fallback={null}><FilesView /></React.Suspense>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -783,7 +793,7 @@ export const MainLayout: React.FC = () => {
|
||||
>
|
||||
<div className="h-full overflow-hidden flex flex-col bg-background shadow-none drawer-safe-area">
|
||||
<ErrorBoundary>
|
||||
<GitView />
|
||||
<React.Suspense fallback={null}><GitView /></React.Suspense>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</motion.aside>
|
||||
@@ -824,7 +834,11 @@ export const MainLayout: React.FC = () => {
|
||||
className="absolute inset-0 z-10 bg-background"
|
||||
style={{ paddingTop: 'var(--oc-safe-area-top, 0px)' }}
|
||||
>
|
||||
<ErrorBoundary><SettingsView onClose={() => setSettingsDialogOpen(false)} /></ErrorBoundary>
|
||||
<ErrorBoundary>
|
||||
<React.Suspense fallback={null}>
|
||||
<SettingsView onClose={() => setSettingsDialogOpen(false)} />
|
||||
</React.Suspense>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
)}
|
||||
</DrawerProvider>
|
||||
@@ -934,7 +948,13 @@ export const MainLayout: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
<BottomTerminalDock isOpen={isBottomTerminalOpen} isMobile={isMobile}>
|
||||
{isBottomTerminalOpen ? <ErrorBoundary><TerminalView /></ErrorBoundary> : null}
|
||||
{isBottomTerminalOpen ? (
|
||||
<ErrorBoundary>
|
||||
<React.Suspense fallback={null}>
|
||||
<TerminalView />
|
||||
</React.Suspense>
|
||||
</ErrorBoundary>
|
||||
) : null}
|
||||
</BottomTerminalDock>
|
||||
</div>
|
||||
<RightSidebar
|
||||
@@ -949,15 +969,19 @@ export const MainLayout: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Desktop settings: windowed dialog with blur */}
|
||||
<SettingsWindow
|
||||
open={isSettingsDialogOpen}
|
||||
onOpenChange={setSettingsDialogOpen}
|
||||
/>
|
||||
<MultiRunWindow
|
||||
open={isMultiRunLauncherOpen}
|
||||
onOpenChange={setMultiRunLauncherOpen}
|
||||
initialPrompt={multiRunLauncherPrefillPrompt}
|
||||
/>
|
||||
<React.Suspense fallback={null}>
|
||||
<SettingsWindow
|
||||
open={isSettingsDialogOpen}
|
||||
onOpenChange={setSettingsDialogOpen}
|
||||
/>
|
||||
</React.Suspense>
|
||||
<React.Suspense fallback={null}>
|
||||
<MultiRunWindow
|
||||
open={isMultiRunLauncherOpen}
|
||||
onOpenChange={setMultiRunLauncherOpen}
|
||||
initialPrompt={multiRunLauncherPrefillPrompt}
|
||||
/>
|
||||
</React.Suspense>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import React from 'react';
|
||||
import { ErrorBoundary } from '../ui/ErrorBoundary';
|
||||
import { SessionSidebar } from '@/components/session/SessionSidebar';
|
||||
import { ChatView, SettingsView } from '@/components/views';
|
||||
import { ChatView } from '@/components/views';
|
||||
|
||||
const SettingsView = React.lazy(() => import('@/components/views/SettingsView').then(m => ({ default: m.SettingsView })));
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useViewportStore } from '@/sync/viewport-store';
|
||||
import { useSessions, useDirectorySync } from '@/sync/sync-context';
|
||||
@@ -388,10 +390,12 @@ export const VSCodeLayout: React.FC = () => {
|
||||
</div>
|
||||
) : currentView === 'settings' ? (
|
||||
// Settings view
|
||||
<SettingsView
|
||||
onClose={() => setCurrentView(usesExpandedLayout ? 'chat' : 'sessions')}
|
||||
forceMobile={usesMobileLayout}
|
||||
/>
|
||||
<React.Suspense fallback={null}>
|
||||
<SettingsView
|
||||
onClose={() => setCurrentView(usesExpandedLayout ? 'chat' : 'sessions')}
|
||||
forceMobile={usesMobileLayout}
|
||||
/>
|
||||
</React.Suspense>
|
||||
) : usesExpandedLayout ? (
|
||||
// Expanded layout: sessions sidebar + chat side by side
|
||||
<div className="flex h-full">
|
||||
|
||||
Reference in New Issue
Block a user