perf(renderer): wrap heavy view components in React.memo

TeamDetailView (3166L), TeamListView (1180L), DateGroupedSessions (1117L),
and MarkdownViewer (1198L) were re-rendering on every parent render cycle.
Wrapping them in memo() prevents cascading re-renders when their props and
store subscriptions have not changed, targeting VSCode-level UI responsiveness.
This commit is contained in:
Mike 2026-05-02 20:29:19 +05:00
parent 7609c548c5
commit f764af17d8
4 changed files with 2344 additions and 2314 deletions

View file

@ -946,47 +946,200 @@ export const CompactMarkdownPreview: React.FC<CompactMarkdownPreviewProps> = Rea
}
);
export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
content,
maxHeight = 'max-h-96',
className = '',
label,
itemId,
searchQueryOverride,
copyable = false,
bare = false,
baseDir,
teamColorByName: providedTeamColorByName,
onTeamClick: providedOnTeamClick,
}) => {
const [showRaw, setShowRaw] = React.useState(false);
const [rawLimit, setRawLimit] = React.useState(LARGE_PREVIEW_CHARS);
const { isLight } = useTheme();
const { teamColorByName, onTeamClick } = useResolvedViewerTeamContext(
providedTeamColorByName,
providedOnTeamClick
);
export const MarkdownViewer: React.FC<MarkdownViewerProps> = React.memo(
({
content,
maxHeight = 'max-h-96',
className = '',
label,
itemId,
searchQueryOverride,
copyable = false,
bare = false,
baseDir,
teamColorByName: providedTeamColorByName,
onTeamClick: providedOnTeamClick,
}) => {
const [showRaw, setShowRaw] = React.useState(false);
const [rawLimit, setRawLimit] = React.useState(LARGE_PREVIEW_CHARS);
const { isLight } = useTheme();
const { teamColorByName, onTeamClick } = useResolvedViewerTeamContext(
providedTeamColorByName,
providedOnTeamClick
);
const isTooLarge = content.length > MAX_MARKDOWN_CHARS;
const disableHighlight = content.length > DISABLE_HIGHLIGHT_CHARS;
const isTooLarge = content.length > MAX_MARKDOWN_CHARS;
const disableHighlight = content.length > DISABLE_HIGHLIGHT_CHARS;
// Only re-render if THIS item has search matches
const { searchQuery, searchMatches, currentSearchIndex } = useStore(
useShallow((s) => {
const hasMatch = itemId ? s.searchMatchItemIds.has(itemId) : false;
return {
searchQuery: hasMatch ? s.searchQuery : '',
searchMatches: hasMatch ? s.searchMatches : EMPTY_SEARCH_MATCHES,
currentSearchIndex: hasMatch ? s.currentSearchIndex : -1,
};
})
);
// Only re-render if THIS item has search matches
const { searchQuery, searchMatches, currentSearchIndex } = useStore(
useShallow((s) => {
const hasMatch = itemId ? s.searchMatchItemIds.has(itemId) : false;
return {
searchQuery: hasMatch ? s.searchQuery : '',
searchMatches: hasMatch ? s.searchMatches : EMPTY_SEARCH_MATCHES,
currentSearchIndex: hasMatch ? s.currentSearchIndex : -1,
};
})
);
// Guard: very large markdown can freeze the renderer (remark/rehype + highlighting).
// For large content, default to a lightweight raw preview with manual expansion.
if (isTooLarge || showRaw) {
const shown = content.slice(0, Math.min(rawLimit, content.length));
const isTruncated = shown.length < content.length;
return (
<div
className={`min-w-0 overflow-hidden ${bare ? '' : 'rounded-lg shadow-sm'} ${copyable && !label ? 'group relative' : ''} ${className}`}
style={
bare
? undefined
: {
backgroundColor: CODE_BG,
border: `1px solid ${CODE_BORDER}`,
}
}
>
{copyable && !label && (
<CopyButton text={content} bgColor={bare ? 'transparent' : undefined} />
)}
{label && (
<div
className="flex items-center gap-2 px-3 py-2"
style={{
backgroundColor: CODE_HEADER_BG,
borderBottom: `1px solid ${CODE_BORDER}`,
}}
>
<FileText className="size-4 shrink-0" style={{ color: COLOR_TEXT_MUTED }} />
<span className="text-sm font-medium" style={{ color: COLOR_TEXT_SECONDARY }}>
{label}
</span>
<span className="ml-2 text-[11px]" style={{ color: COLOR_TEXT_MUTED }}>
Raw
</span>
<span className="flex-1" />
<button
type="button"
className="text-xs underline"
style={{ color: PROSE_LINK }}
onClick={() => setShowRaw(false)}
disabled={isTooLarge}
title={
isTooLarge
? 'Large content is shown as raw to prevent UI freeze'
: 'Render markdown'
}
>
Render markdown
</button>
{copyable && <CopyButton text={content} inline />}
</div>
)}
{!label && (
<div
className="flex items-center justify-between px-3 py-2 text-xs"
style={{ color: COLOR_TEXT_MUTED }}
>
<span>Raw preview</span>
<button
type="button"
className="underline"
style={{ color: PROSE_LINK }}
onClick={() => setShowRaw(false)}
disabled={isTooLarge}
title={
isTooLarge
? 'Large content is shown as raw to prevent UI freeze'
: 'Render markdown'
}
>
Render markdown
</button>
</div>
)}
{isTooLarge && (
<div className="px-3 pb-2 text-[11px]" style={{ color: COLOR_TEXT_MUTED }}>
Content is very large ({content.length.toLocaleString()} chars). Showing raw preview
to keep the UI responsive.
</div>
)}
<div className={`min-w-0 overflow-auto ${maxHeight}`}>
<pre
className="min-w-0 whitespace-pre-wrap break-words p-4 font-mono text-xs leading-relaxed"
style={{ color: PROSE_BODY }}
>
{shown}
</pre>
{isTruncated && (
<div className="flex items-center justify-between gap-2 px-4 pb-4 text-xs">
<span style={{ color: COLOR_TEXT_MUTED }}>
Showing {shown.length.toLocaleString()} / {content.length.toLocaleString()} chars
</span>
<div className="flex items-center gap-2">
<button
type="button"
className="rounded border px-2 py-1"
style={{ borderColor: CODE_BORDER, color: PROSE_LINK }}
onClick={() => setRawLimit((v) => Math.min(content.length, v * 2))}
>
Show more
</button>
<button
type="button"
className="rounded border px-2 py-1"
style={{ borderColor: CODE_BORDER, color: PROSE_LINK }}
onClick={() => setRawLimit(content.length)}
>
Show all
</button>
</div>
</div>
)}
</div>
</div>
);
}
// Create search context (fresh each render so counter starts at 0)
const effectiveQuery = (searchQueryOverride ?? searchQuery).trim();
const effectiveMatches = searchQueryOverride ? [] : searchMatches;
const effectiveIndex = searchQueryOverride ? -1 : currentSearchIndex;
const searchCtx =
effectiveQuery && itemId
? createSearchContext(effectiveQuery, itemId, effectiveMatches, effectiveIndex)
: null;
// Local search (Claude logs): use bright highlight for all matches (no "current result" concept).
if (searchCtx && searchQueryOverride) {
searchCtx.forceAllActive = true;
}
// Create markdown components with optional search highlighting
// When search is active, create fresh each render (match counter is stateful and must start at 0)
// useMemo would cache stale closures when parent re-renders without search deps changing
const baseComponents = searchCtx
? createViewerMarkdownComponents(searchCtx, isLight, teamColorByName, onTeamClick, copyable)
: isLight
? createViewerMarkdownComponents(null, true, teamColorByName, onTeamClick, copyable)
: createViewerMarkdownComponents(null, false, teamColorByName, onTeamClick, copyable);
// When baseDir is set (editor preview), override img to load local files via IPC
const components = baseDir
? {
...baseComponents,
img: ({ src, alt }: { src?: string; alt?: string }) => {
if (src && isRelativeUrl(src)) {
return <LocalImage src={src} alt={alt} baseDir={baseDir} />;
}
return <img src={src} alt={alt || ''} className="my-2 max-w-full rounded" />;
},
}
: baseComponents;
// Guard: very large markdown can freeze the renderer (remark/rehype + highlighting).
// For large content, default to a lightweight raw preview with manual expansion.
if (isTooLarge || showRaw) {
const shown = content.slice(0, Math.min(rawLimit, content.length));
const isTruncated = shown.length < content.length;
return (
<div
className={`min-w-0 overflow-hidden ${bare ? '' : 'rounded-lg shadow-sm'} ${copyable && !label ? 'group relative' : ''} ${className}`}
@ -999,10 +1152,12 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
}
}
>
{/* Copy button overlay (when no label header) */}
{copyable && !label && (
<CopyButton text={content} bgColor={bare ? 'transparent' : undefined} />
)}
{/* Optional header - matches CodeBlockViewer style */}
{label && (
<div
className="flex items-center gap-2 px-3 py-2"
@ -1015,184 +1170,31 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
<span className="text-sm font-medium" style={{ color: COLOR_TEXT_SECONDARY }}>
{label}
</span>
<span className="ml-2 text-[11px]" style={{ color: COLOR_TEXT_MUTED }}>
Raw
</span>
<span className="flex-1" />
<button
type="button"
className="text-xs underline"
style={{ color: PROSE_LINK }}
onClick={() => setShowRaw(false)}
disabled={isTooLarge}
title={
isTooLarge
? 'Large content is shown as raw to prevent UI freeze'
: 'Render markdown'
}
>
Render markdown
</button>
{copyable && <CopyButton text={content} inline />}
</div>
)}
{!label && (
<div
className="flex items-center justify-between px-3 py-2 text-xs"
style={{ color: COLOR_TEXT_MUTED }}
>
<span>Raw preview</span>
<button
type="button"
className="underline"
style={{ color: PROSE_LINK }}
onClick={() => setShowRaw(false)}
disabled={isTooLarge}
title={
isTooLarge
? 'Large content is shown as raw to prevent UI freeze'
: 'Render markdown'
}
>
Render markdown
</button>
</div>
)}
{isTooLarge && (
<div className="px-3 pb-2 text-[11px]" style={{ color: COLOR_TEXT_MUTED }}>
Content is very large ({content.length.toLocaleString()} chars). Showing raw preview to
keep the UI responsive.
{copyable && (
<>
<span className="flex-1" />
<CopyButton text={content} inline />
</>
)}
</div>
)}
{/* Markdown content with scroll */}
<div className={`min-w-0 overflow-auto ${maxHeight}`}>
<pre
className="min-w-0 whitespace-pre-wrap break-words p-4 font-mono text-xs leading-relaxed"
style={{ color: PROSE_BODY }}
>
{shown}
</pre>
{isTruncated && (
<div className="flex items-center justify-between gap-2 px-4 pb-4 text-xs">
<span style={{ color: COLOR_TEXT_MUTED }}>
Showing {shown.length.toLocaleString()} / {content.length.toLocaleString()} chars
</span>
<div className="flex items-center gap-2">
<button
type="button"
className="rounded border px-2 py-1"
style={{ borderColor: CODE_BORDER, color: PROSE_LINK }}
onClick={() => setRawLimit((v) => Math.min(content.length, v * 2))}
>
Show more
</button>
<button
type="button"
className="rounded border px-2 py-1"
style={{ borderColor: CODE_BORDER, color: PROSE_LINK }}
onClick={() => setRawLimit(content.length)}
>
Show all
</button>
</div>
</div>
)}
<div className="min-w-0 break-words p-2">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={disableHighlight ? REHYPE_PLUGINS_NO_HIGHLIGHT : REHYPE_PLUGINS}
components={components}
urlTransform={allowCustomProtocols}
allowElement={isAllowedElement}
unwrapDisallowed
>
{content}
</ReactMarkdown>
</div>
</div>
</div>
);
}
// Create search context (fresh each render so counter starts at 0)
const effectiveQuery = (searchQueryOverride ?? searchQuery).trim();
const effectiveMatches = searchQueryOverride ? [] : searchMatches;
const effectiveIndex = searchQueryOverride ? -1 : currentSearchIndex;
const searchCtx =
effectiveQuery && itemId
? createSearchContext(effectiveQuery, itemId, effectiveMatches, effectiveIndex)
: null;
// Local search (Claude logs): use bright highlight for all matches (no "current result" concept).
if (searchCtx && searchQueryOverride) {
searchCtx.forceAllActive = true;
}
// Create markdown components with optional search highlighting
// When search is active, create fresh each render (match counter is stateful and must start at 0)
// useMemo would cache stale closures when parent re-renders without search deps changing
const baseComponents = searchCtx
? createViewerMarkdownComponents(searchCtx, isLight, teamColorByName, onTeamClick, copyable)
: isLight
? createViewerMarkdownComponents(null, true, teamColorByName, onTeamClick, copyable)
: createViewerMarkdownComponents(null, false, teamColorByName, onTeamClick, copyable);
// When baseDir is set (editor preview), override img to load local files via IPC
const components = baseDir
? {
...baseComponents,
img: ({ src, alt }: { src?: string; alt?: string }) => {
if (src && isRelativeUrl(src)) {
return <LocalImage src={src} alt={alt} baseDir={baseDir} />;
}
return <img src={src} alt={alt || ''} className="my-2 max-w-full rounded" />;
},
}
: baseComponents;
return (
<div
className={`min-w-0 overflow-hidden ${bare ? '' : 'rounded-lg shadow-sm'} ${copyable && !label ? 'group relative' : ''} ${className}`}
style={
bare
? undefined
: {
backgroundColor: CODE_BG,
border: `1px solid ${CODE_BORDER}`,
}
}
>
{/* Copy button overlay (when no label header) */}
{copyable && !label && (
<CopyButton text={content} bgColor={bare ? 'transparent' : undefined} />
)}
{/* Optional header - matches CodeBlockViewer style */}
{label && (
<div
className="flex items-center gap-2 px-3 py-2"
style={{
backgroundColor: CODE_HEADER_BG,
borderBottom: `1px solid ${CODE_BORDER}`,
}}
>
<FileText className="size-4 shrink-0" style={{ color: COLOR_TEXT_MUTED }} />
<span className="text-sm font-medium" style={{ color: COLOR_TEXT_SECONDARY }}>
{label}
</span>
{copyable && (
<>
<span className="flex-1" />
<CopyButton text={content} inline />
</>
)}
</div>
)}
{/* Markdown content with scroll */}
<div className={`min-w-0 overflow-auto ${maxHeight}`}>
<div className="min-w-0 break-words p-2">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={disableHighlight ? REHYPE_PLUGINS_NO_HIGHLIGHT : REHYPE_PLUGINS}
components={components}
urlTransform={allowCustomProtocols}
allowElement={isAllowedElement}
unwrapDisallowed
>
{content}
</ReactMarkdown>
</div>
</div>
</div>
);
};
);

View file

@ -4,7 +4,7 @@
* Supports multi-select with bulk actions and hidden session filtering.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { recordRecentProjectOpenPaths } from '@features/recent-projects/renderer';
@ -184,7 +184,7 @@ function matchesSessionSearch(session: Session, query: string): boolean {
return haystack.includes(query);
}
export const DateGroupedSessions = (): React.JSX.Element => {
export const DateGroupedSessions = memo((): React.JSX.Element => {
const {
sessions,
selectedSessionId,
@ -1114,4 +1114,4 @@ export const DateGroupedSessions = (): React.JSX.Element => {
</div>
</div>
);
};
});

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { recordRecentProjectOpenPaths } from '@features/recent-projects/renderer';
import { api, isElectronMode } from '@renderer/api';
@ -233,7 +233,7 @@ const StatusBadge = ({ status }: { status: TeamStatus }): React.JSX.Element => {
}
};
export const TeamListView = (): React.JSX.Element => {
export const TeamListView = memo((): React.JSX.Element => {
const { isLight } = useTheme();
const electronMode = isElectronMode();
const [showCreateDialog, setShowCreateDialog] = useState(false);
@ -1177,4 +1177,4 @@ export const TeamListView = (): React.JSX.Element => {
</div>
</TooltipProvider>
);
};
});