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. */}