ui: reduce per-token render cost when streaming (#26053)

* performance harness - the empirical root

Assisted-by: Claude Opus 4.8

* 210.36ms -> 2.67ms per streamed token

Assisted-by: Claude Opus 4.8

* 11.58ms -> 0.62ms per streamed token

Assisted-by: Claude Opus 4.8

* 22.02ms -> 3.33ms per streamed token

Assisted-by: Claude Opus 4.8

* 3.07ms -> 1.36ms per streamed token at 40 messages

Assisted-by: Claude Opus 4.8

---------

Co-authored-by: Zach Winter <dmtommy@icloud.com>
This commit is contained in:
Zach Winter
2026-07-24 22:09:46 +02:00
committed by GitHub
co-authored by Zach Winter
parent 96013c5112
commit 555881ebc8
18 changed files with 1037 additions and 35 deletions
@@ -89,10 +89,15 @@
</Collapsible.Trigger>
<Collapsible.Content>
<div class="pl-1.5 grid min-w-0" style="min-height: var(--min-message-height);">
<div class="min-w-0 border-l border-muted-foreground/20 pl-4 pb-2 my-2">
{@render children()}
<!-- Collapsible.Content renders its children unconditionally and only sets
`hidden`, so a closed block would keep re-rendering its whole body on
every streamed token. Gate on `open` so collapsed content costs nothing. -->
{#if open}
<div class="pl-1.5 grid min-w-0" style="min-height: var(--min-message-height);">
<div class="min-w-0 border-l border-muted-foreground/20 pl-4 pb-2 my-2">
{@render children()}
</div>
</div>
</div>
{/if}
</Collapsible.Content>
</Collapsible.Root>
@@ -90,8 +90,12 @@
</Collapsible.Trigger>
<Collapsible.Content>
<div class="p-3 pt-1">
{@render children()}
</div>
<!-- See CollapsibleContentBlock: bits-ui keeps closed content mounted, which
makes a collapsed tool result re-render on every streamed token. -->
{#if open}
<div class="p-3 pt-1">
{@render children()}
</div>
{/if}
</Collapsible.Content>
</Collapsible.Root>
@@ -107,6 +107,15 @@
return null;
});
const liveSvgHtml = $derived(streamingSvgCode !== null ? sanitizeSvg(streamingSvgCode) : '');
// Derived rather than called inline in the template so it only recomputes when
// the block actually changes. Auto-detection is disabled while streaming: it
// costs ~38ms a call and re-guesses the language on every chunk.
const streamingCodeHtml = $derived(
incompleteCodeBlock
? highlightCode(incompleteCodeBlock.code, incompleteCodeBlock.language || 'text', false)
: ''
);
let previewDialogOpen = $state(false);
let previewCode = $state('');
let previewLanguage = $state('text');
@@ -903,10 +912,7 @@
>
<pre class="streaming-code-pre"><code
class="hljs language-{incompleteCodeBlock.language || 'text'}"
>{@html highlightCode(
incompleteCodeBlock.code,
incompleteCodeBlock.language || 'text'
)}</code
>{@html streamingCodeHtml}</code
></pre>
</div>
</div>
@@ -28,6 +28,14 @@ export const LATEX_MATH_AND_CODE_PATTERN =
/** Regex to capture the content of a $$...\\\\...$$ block (display-formula with line-break) */
export const LATEX_LINEBREAK_REGEXP = /\$\$([\s\S]*?\\\\[\s\S]*?)\$\$/;
/**
* Cheap gate for `preprocessLaTeX`. Every transformation it performs is triggered
* by a `$` (inline/display math, currency escaping) or a backslash escape
* (`\(`, `\[`, `\ce{`, `\pu{`). Text containing neither is returned untouched, so
* this lets the caller skip the whole protect/restore pipeline.
*/
export const LATEX_TRIGGER_REGEXP = /[$\\]/;
/** map from mchem-regexp to replacement */
export const MHCHEM_PATTERN_MAP: readonly [RegExp, string][] = [
[/(\s)\$\\ce{/g, '$1$\\\\ce{'],
@@ -169,8 +169,21 @@ class ConversationsStore {
* Updates a message at a specific index in active messages
*/
updateMessageAtIndex(index: number, updates: Partial<DatabaseMessage>): void {
if (index !== -1 && this.activeMessages[index]) {
this.activeMessages[index] = { ...this.activeMessages[index], ...updates };
const message = index === -1 ? undefined : this.activeMessages[index];
if (!message) return;
// Assign field by field rather than replacing the object. Replacing it
// changes the array slot, which invalidates every consumer that merely
// walks the list - notably ChatMessages.displayMessages, which rebuilds
// entries for every message in the conversation. Deep $state proxies make
// per-field writes fine-grained, so only readers of the changed field wake.
const target = message as unknown as Record<string, unknown>;
for (const [key, value] of Object.entries(updates)) {
if (target[key] !== value) {
target[key] = value;
}
}
}
+13 -6
View File
@@ -30,13 +30,21 @@ function trimCodePadding(code: string): string {
return code.replace(TRIM_LEADING_PADDING_REGEX, '').replace(TRIM_TRAILING_PADDING_REGEX, '');
}
function escapeCode(code: string): string {
return code.replace(AMPERSAND_REGEX, '&amp;').replace(LT_REGEX, '&lt;').replace(GT_REGEX, '&gt;');
}
/**
* Highlights code using highlight.js
* @param code - The code to highlight
* @param language - The programming language
* @param autoDetect - Fall back to `highlightAuto` when `language` is unknown.
* Callers rendering a still-streaming block should pass false: auto-detection
* costs ~38ms per call and re-guesses on every chunk, so the language (and
* therefore the whole highlight) flickers as the block grows.
* @returns HTML string with syntax highlighting
*/
export function highlightCode(code: string, language: string): string {
export function highlightCode(code: string, language: string, autoDetect = true): string {
if (!code) return '';
const trimmed = trimCodePadding(code);
@@ -47,15 +55,14 @@ export function highlightCode(code: string, language: string): string {
if (isSupported) {
return hljs.highlight(trimmed, { language: lang }).value;
} else {
} else if (autoDetect) {
return hljs.highlightAuto(trimmed).value;
} else {
return escapeCode(trimmed);
}
} catch {
// Fallback to escaped plain text
return trimmed
.replace(AMPERSAND_REGEX, '&amp;')
.replace(LT_REGEX, '&lt;')
.replace(GT_REGEX, '&gt;');
return escapeCode(trimmed);
}
}
+33 -16
View File
@@ -2,6 +2,7 @@ import {
CODE_BLOCK_REGEXP,
LATEX_MATH_AND_CODE_PATTERN,
LATEX_LINEBREAK_REGEXP,
LATEX_TRIGGER_REGEXP,
MHCHEM_PATTERN_MAP
} from '$lib/constants';
@@ -148,6 +149,15 @@ export function preprocessLaTeX(content: string): string {
// See also:
// https://github.com/danny-avila/LibreChat/blob/main/client/src/utils/latex.ts
// Every step below keys off a `$` or a backslash escape (\[ \] \( \) \ce{ \pu{).
// With neither present the protect/restore passes round-trip the input
// unchanged, so skip them: the step 2 scan is O(n^2) in line length and costs
// ~90ms on a 26KB single-line message that contains no math at all. This
// matters during streaming, where the whole message is reprocessed per frame.
if (!LATEX_TRIGGER_REGEXP.test(content)) {
return content;
}
// Step 0: Temporarily remove blockquote markers (>) to process LaTeX correctly
// Store the structure so we can restore it later
const blockquoteMarkers: Map<number, string> = new Map();
@@ -175,24 +185,31 @@ export function preprocessLaTeX(content: string): string {
const latexExpressions: string[] = [];
// Match \S...\[...\] and protect them and insert a line-break.
content = content.replace(/([\S].*?)\\\[([\s\S]*?)\\\](.*)/g, (match, group1, group2, group3) => {
// Check if there are characters following the formula (display-formula in a table-cell?)
if (group1.endsWith('\\')) {
return match; // Backslash before \[, do nothing.
}
const hasSuffix = /\S/.test(group3);
let optBreak;
// Guarded: with no `\[` present this pattern still probes every start offset,
// expanding `.*?` to the end of each line before failing - O(n^2) for nothing.
if (content.includes('\\[')) {
content = content.replace(
/([\S].*?)\\\[([\s\S]*?)\\\](.*)/g,
(match, group1, group2, group3) => {
// Check if there are characters following the formula (display-formula in a table-cell?)
if (group1.endsWith('\\')) {
return match; // Backslash before \[, do nothing.
}
const hasSuffix = /\S/.test(group3);
let optBreak;
if (hasSuffix) {
latexExpressions.push(`\\(${group2.trim()}\\)`); // Convert into inline.
optBreak = '';
} else {
latexExpressions.push(`\\[${group2}\\]`);
optBreak = '\n';
}
if (hasSuffix) {
latexExpressions.push(`\\(${group2.trim()}\\)`); // Convert into inline.
optBreak = '';
} else {
latexExpressions.push(`\\[${group2}\\]`);
optBreak = '\n';
}
return `${group1}${optBreak}<<LATEX_${latexExpressions.length - 1}>>${optBreak}${group3}`;
});
return `${group1}${optBreak}<<LATEX_${latexExpressions.length - 1}>>${optBreak}${group3}`;
}
);
}
// Match \(...\), \[...\], $$...$$ and protect them
content = content.replace(