diff --git a/CHANGELOG.md b/CHANGELOG.md index e370998..f0fc75f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ What changed in each release of SCN Chat, for the people who use it, run it, or ### Fixed - A chat left open for a long time no longer keeps a copy of every reply it streamed in memory once the reply is saved. +- A long code block streaming into a reply uses about half the CPU it did, since each new character no longer re-renders and re-lays out every line above it. ### Changed diff --git a/apps/web/src/parts/markdown.css b/apps/web/src/parts/markdown.css index 7ddbb18..4eae4e2 100644 --- a/apps/web/src/parts/markdown.css +++ b/apps/web/src/parts/markdown.css @@ -115,6 +115,12 @@ border-radius: var(--radius-card); } + /* Each line is a block of its own, so a line arriving lays out alone, rather than the browser + laying out the whole block again for each character a long one streams. */ + & pre .line { + display: block; + } + /* A formula holds the hidden MathML that screen readers read, which is placed absolutely, so it stays beside the formula rather than stretching the page to wherever the formula sits. */ & .katex { diff --git a/apps/web/src/parts/markdown.tsx b/apps/web/src/parts/markdown.tsx index 100d5f7..927c730 100644 --- a/apps/web/src/parts/markdown.tsx +++ b/apps/web/src/parts/markdown.tsx @@ -172,6 +172,41 @@ const Line = memo(function Line(props: LineProps) { ) }) +/** How many lines a `LineGroup` holds. */ +const GROUP_SIZE = 32 + +interface LineGroupProps { + lines: string[] + /** The lines' tokens, as far as the highlight has reached. */ + tokens: (ThemedToken[] | undefined)[] + /** Whether the group ends the block, so its last line has no newline after it. */ + last: boolean +} + +const sameGroup = (a: LineGroupProps, b: LineGroupProps) => + a.last === b.last && + a.lines.length === b.lines.length && + a.lines.every((line, i) => line === b.lines[i] && a.tokens[i] === b.tokens[i]) + +/** + * A run of a block's lines, memoized on their text and tokens, so a streaming block rerenders only + * its last group as it grows, rather than every line it has, which made a long block's every new + * character cost more than the last. + */ +const LineGroup = memo(function LineGroup(props: LineGroupProps) { + return ( + <> + {props.lines.map((line, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: lines have no identity beyond their place. + + + {!(props.last && i === props.lines.length - 1) && '\n'} + + ))} + + ) +}, sameGroup) + interface CodeBlockProps { className?: string children?: ReactNode @@ -189,19 +224,23 @@ function CodeBlock(props: CodeBlockProps) { const code = String(props.children ?? '').replace(/\n$/, '') const result = useHighlight(code, language, incomplete) const lines = code.split('\n') + const groups: ReactNode[] = [] + for (let start = 0; start < lines.length; start += GROUP_SIZE) { + const end = start + GROUP_SIZE + groups.push( + = lines.length} + />, + ) + } return (
{/* Code reads left to right whatever the text around it. */}
-        
-          {lines.map((line, i) => (
-            // biome-ignore lint/suspicious/noArrayIndexKey: lines have no identity beyond their place.
-            
-              
-              {i < lines.length - 1 && '\n'}
-            
-          ))}
-        
+        {groups}
       
{!incomplete && (