perf: isolate chat streaming renders and reduce sidebar render cost (#1672)
Reworks the chat and session-sidebar render paths to cut render cascades, memory
churn, and UI jank on large sessions and big session trees. Behavior is preserved;
the changes are about *when* and *how much* the UI re-renders.
## Chat streaming
- Freeze the streaming message's parts in the bulk turn projection during streaming,
and re-inject live parts only in an isolated tail leaf, so a ~60/sec delta stream
no longer re-runs the whole-session projection or re-renders unrelated rows.
session with referential reuse of unchanged turns.
- Memoize message rows with field-aware comparators instead of reference equality.
- Replace the manual child-session polling in the task tool with the live SSE
stream + a one-shot load, removing a fetch/settle state machine.
## History loading & scroll
- Load an initial page fast, then prepend one older page in the background so the
scroll container has headroom and "load older on scroll-up" fires before the user
hits the absolute top.
- Compensate scroll synchronously (in a layout effect, before paint) for prepends —
including background prepends that don't originate from a user scroll — so the
viewport stays stable instead of judder-correcting on the next frame.
## Markdown rendering
- Render markdown synchronously *styled* on first paint (paragraphs, lists, code
cards, tables, inline code) instead of raw escaped text; the async pass then only
upgrades syntax-highlight colors. Eliminates the flash of full-width raw text.
- Load KaTeX CSS eagerly with the main bundle instead of inside the lazy markdown
chunk, avoiding a late stylesheet injection on first render.
## Sidebar
- Hoist per-row recursive tree walks out of row comparators into per-group
precomputed sets/keys; batch live-session lookups into a single map; add a
group-level memo boundary.
- Isolate rename drafts so per-keystroke typing doesn't repaint the row tree.
## Sync layer
- Add a staleness guard so a slow message fetch can't repopulate a session the user
navigated away from.
- Throw on fetch failure for authoritative loaders so a transient blip can't read as
an empty server response.
## Cleanup
- Remove dead code (unused hooks, params, duplicated inline types) surfaced while
reworking the above.
## Known issue
- A rare, purely cosmetic first-paint width flash can still appear on large sessions;
it has no behavioral or data impact and is tracked for a follow-up runtime trace.
This commit is contained in:
@@ -334,24 +334,67 @@ export const useChatTimelineController = ({
|
||||
return messageListRef.current?.restoreViewportAnchor(anchor) ?? false;
|
||||
}, [messageListRef]);
|
||||
|
||||
// Tracks the timeline edges + height of the previous commit so a prepend
|
||||
// that did NOT go through fetchOlderHistory (e.g. the background history
|
||||
// prepend dispatched from useSync) can be compensated too. With
|
||||
// overflow-anchor:none the browser leaves scrollTop unchanged when content
|
||||
// is inserted above, so without this the viewport visibly jumps and
|
||||
// auto-follow yanks it back on the next frame — a one-shot up/down judder.
|
||||
const prependTrackingRef = React.useRef<{
|
||||
oldestId: string | null;
|
||||
newestId: string | null;
|
||||
scrollHeight: number;
|
||||
} | null>(null);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const snap = prePrependScrollRef.current;
|
||||
const container = scrollRef.current;
|
||||
if (!snap || !container) return;
|
||||
prePrependScrollRef.current = null;
|
||||
if (!container) return;
|
||||
|
||||
// When a viewport anchor is available, delegate to MessageList
|
||||
// restoreViewportAnchor which falls back to virtualizer-aware
|
||||
// scrollHistoryIndexIntoView when the element is not in the DOM.
|
||||
if (snap.anchor && restoreViewportAnchor(snap.anchor)) {
|
||||
return;
|
||||
const snap = prePrependScrollRef.current;
|
||||
if (snap) {
|
||||
prePrependScrollRef.current = null;
|
||||
// When a viewport anchor is available, delegate to MessageList
|
||||
// restoreViewportAnchor which falls back to virtualizer-aware
|
||||
// scrollHistoryIndexIntoView when the element is not in the DOM.
|
||||
if (!(snap.anchor && restoreViewportAnchor(snap.anchor))) {
|
||||
// Fallback: height-delta compensation
|
||||
const delta = container.scrollHeight - snap.height;
|
||||
if (delta > 0) {
|
||||
container.scrollTop = snap.top + delta;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Auto-detect a prepend: the oldest message changed while the newest
|
||||
// stayed the same (distinguishes a real prepend from a session
|
||||
// switch, a bottom append, or a streaming part growing). Compensate
|
||||
// synchronously by the exact height delta — for a bottom-pinned
|
||||
// viewport this keeps it pinned, for a released one it preserves the
|
||||
// read position, with no intermediate frame for auto-follow to fight.
|
||||
const prev = prependTrackingRef.current;
|
||||
const currentOldestId = renderedMessages[0]?.info?.id ?? null;
|
||||
const currentNewestId = renderedMessages[renderedMessages.length - 1]?.info?.id ?? null;
|
||||
const isPrepend = Boolean(
|
||||
prev
|
||||
&& prev.oldestId
|
||||
&& currentOldestId
|
||||
&& currentOldestId !== prev.oldestId
|
||||
&& prev.newestId
|
||||
&& currentNewestId
|
||||
&& currentNewestId === prev.newestId,
|
||||
);
|
||||
if (isPrepend && prev) {
|
||||
const delta = container.scrollHeight - prev.scrollHeight;
|
||||
if (delta > 0) {
|
||||
container.scrollTop = container.scrollTop + delta;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: height-delta compensation
|
||||
const delta = container.scrollHeight - snap.height;
|
||||
if (delta > 0) {
|
||||
container.scrollTop = snap.top + delta;
|
||||
}
|
||||
prependTrackingRef.current = {
|
||||
oldestId: renderedMessages[0]?.info?.id ?? null,
|
||||
newestId: renderedMessages[renderedMessages.length - 1]?.info?.id ?? null,
|
||||
scrollHeight: container.scrollHeight,
|
||||
};
|
||||
}, [renderedMessages, scrollRef, restoreViewportAnchor]);
|
||||
|
||||
const revealBufferedTurns = React.useCallback(async (): Promise<boolean> => false, []);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { projectTurnRecords } from '../lib/turns/projectTurnRecords';
|
||||
import type { ChatMessageEntry, TurnProjectionResult, TurnRecord } from '../lib/turns/types';
|
||||
import { buildProjectionCacheKey, getCachedProjection, setCachedProjection } from '../lib/turns/turnProjectionCache';
|
||||
import { streamPerfMeasure } from '@/stores/utils/streamDebug';
|
||||
|
||||
interface UseTurnRecordsOptions {
|
||||
@@ -46,6 +47,18 @@ export const useTurnRecords = (
|
||||
}, [options.sessionKey, options.showTextJustificationActivity, options.showTurnChangedFiles]);
|
||||
|
||||
const projection = React.useMemo(() => {
|
||||
const sessionKey = options.sessionKey ?? '';
|
||||
const cached = getCachedProjection(
|
||||
sessionKey,
|
||||
messages,
|
||||
options.showTextJustificationActivity,
|
||||
options.showTurnChangedFiles,
|
||||
);
|
||||
if (cached) {
|
||||
previousProjectionRef.current = cached;
|
||||
return cached;
|
||||
}
|
||||
|
||||
return streamPerfMeasure('ui.turns.projection_ms', () => {
|
||||
const nextProjection = projectTurnRecords(messages, {
|
||||
previousProjection: previousProjectionRef.current,
|
||||
@@ -53,9 +66,18 @@ export const useTurnRecords = (
|
||||
showTurnChangedFiles: options.showTurnChangedFiles,
|
||||
});
|
||||
previousProjectionRef.current = nextProjection;
|
||||
|
||||
const cacheKey = buildProjectionCacheKey(
|
||||
sessionKey,
|
||||
messages,
|
||||
options.showTextJustificationActivity,
|
||||
options.showTurnChangedFiles,
|
||||
);
|
||||
setCachedProjection(cacheKey, nextProjection);
|
||||
|
||||
return nextProjection;
|
||||
});
|
||||
}, [messages, options.showTextJustificationActivity, options.showTurnChangedFiles]);
|
||||
}, [messages, options.showTextJustificationActivity, options.showTurnChangedFiles, options.sessionKey]);
|
||||
|
||||
const staticTurns = React.useMemo(() => {
|
||||
const nextStatic = projection.turns.length <= 1
|
||||
|
||||
Reference in New Issue
Block a user