You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
app: a streaming delta re-parses the whole markdown block, and the DOM round-trip around it dominates the visible cost #54428
A streaming delta costs ~2ms of main-thread time in the visible session, and the largest single item is the block's HTML re-parse, not the store walk.
A renderer profile (CDP, sourcemap-mapped) of 600 visible deltas across 8 streaming sessions, production build, 2880ms of sampled self time:
self ms
%
where
943.5
32.8%
native (program) — HTML parse, style, layout
536.5
18.6%
solid-js/storeunwrap (the tree walked twice per delta)
382.3
13.3%
idle, waiting on the markdown worker
93.3
3.2%
For's diff body
68.2
2.4%
insertBefore
With the visible deltas at 0 (4200 fan-out events), (program) is 415.8ms — so the visible rendering contributes ~528ms of it.
The path in packages/session-ui/src/components/markdown.tsx round-trips the block on every delta:
source.innerHTML = block.html — parse the whole block's HTML into DOM
markInlineCode / markCodeLinks / markSessionLinks / markExternalLinkFavicons — walk and mutate that DOM
rendered.renderer.update(source.innerHTML, ...) — serialize the DOM back to a string
parseMarkdownNodes(html, words) — parse the string again into nodes
So a delta parses the block, serializes it, and parses it again. parseMarkdownNodes itself is only 67.8ms (2.4%) — the cost is the DOM round-trip around it.
The store walk is real and is being halved in #54427, but it is not the dominant cost.
Plugins
none
OpenCode version
2.0.26
Steps to reproduce
Run a session that streams a long markdown response, with other sessions streaming in parallel.
Profile the renderer during the stream.
(program) dominates; the visible rendering's share scales with the number of visible deltas.
Thanks for the detailed profile. I've confirmed the round-trip in the code and measured it, on 2.0.26 and on the latest dev (0d61729).
For a live block, every delta goes through updateBlock in markdown.tsx. It sets source.innerHTML = block.html, runs the mark* passes, reads source.innerHTML back, and passes the string to renderer.update. That runs reconcile(parseMarkdownNodes(...)).
The script below copies that path under happy-dom, using the real parseMarkdownNodes and the same store reconcile. It counts innerHTML reads and writes as a block grows:
Every delta parses the whole block into DOM once and serializes it back once.
Per-delta time grows in step with block size: about 1.6 ms at 0.7k characters up to about 23 ms at 28k.
parseMarkdownNodes alone is only about a quarter of that.
This isn't Chromium, so I didn't check the native parse/style/layout split from your profile. The extra parse-and-serialize on every delta does show up as you describe, though. Related: #54305 and PR #54427, which cover the separate store unwrap walk.
Reproduction script (repro.sh)
#!/usr/bin/env bash# Repro for anomalyco/opencode#54428: each streaming delta to a live markdown block# parses the whole block's HTML into DOM, serializes it back (innerHTML getter) and# parses the string again (parseMarkdownNodes), so per-delta cost grows with block size.# Mirrors the live-block path of updateBlock() in packages/session-ui/src/components/markdown.tsx# using the real createMarkdownRenderer/parseMarkdownNodes, under happy-dom (not Chromium).# Usage: REPO=/workspace/repo bash repro.sh (after `bun install` in the repo)set -euo pipefail
REPO=${REPO:-/workspace/repo}
PKG="$REPO/packages/session-ui"
TEST="$PKG/src/components/zz-repro-54428.test.ts"trap'rm -f "$TEST"' EXIT
cat >"$TEST"<<'EOF'import { GlobalRegistrator } from "@happy-dom/global-registrator"import { test } from "bun:test"import { marked } from "marked"test("issue 54428: per-delta DOM round-trip of the whole live block", async () => { GlobalRegistrator.register() const { parseMarkdownNodes } = await import("./markdown-solid") const { createStore, reconcile } = await import("solid-js/store") // Same as createMarkdownRenderer().update() in markdown-solid.tsx, minus the JSX DOM render. const createMarkdownRenderer = (_root: HTMLElement, html: string, w: boolean) => { const [, setNodes] = createStore(parseMarkdownNodes(html, w)) return { update: (next: string, nw: boolean, a = true) => setNodes(reconcile(parseMarkdownNodes(next, nw, a), { key: "key" })) } } // Count innerHTML parses (setter) and serializations (getter) per delta. const proto = Object.getPrototypeOf(document.createElement("div")) let owner = proto while (owner && !Object.getOwnPropertyDescriptor(owner, "innerHTML")) owner = Object.getPrototypeOf(owner) const desc = Object.getOwnPropertyDescriptor(owner, "innerHTML")! const counts = { set: 0, setBytes: 0, get: 0, getBytes: 0 } Object.defineProperty(owner, "innerHTML", { configurable: true, get() { const v = desc.get!.call(this); counts.get++; counts.getBytes += v.length; return v }, set(v: string) { counts.set++; counts.setBytes += v.length; desc.set!.call(this, v) }, }) const words = Array.from({ length: 4000 }, (_, i) => (i % 40 === 39 ? "word\n\n" : i % 7 === 0 ? "`code`" : "word")) const host = document.createElement("div") let renderer: ReturnType<typeof createMarkdownRenderer> | undefined for (const size of [100, 500, 1000, 2000, 4000]) { const html = marked.parse(words.slice(0, size).join(" ")) as string // One live-block delta, as updateBlock does it: Object.assign(counts, { set: 0, setBytes: 0, get: 0, getBytes: 0 }) const t0 = performance.now() const N = 20 for (let i = 0; i < N; i++) { const source = document.createElement("div") source.innerHTML = html // 1. parse whole block into DOM source.querySelectorAll("code").forEach(() => {}) // 2. mark* passes walk it const serialized = source.innerHTML // 3. serialize back to a string if (!renderer) renderer = createMarkdownRenderer(host, serialized, true) else renderer.update(serialized, true, true) // 4. parse again + reconcile } const roundTrip = (performance.now() - t0) / N const t1 = performance.now() for (let i = 0; i < N; i++) parseMarkdownNodes(html, true, true) const parseOnly = (performance.now() - t1) / N console.log( `block ${String(html.length).padStart(6)} chars: per delta innerHTML set=${counts.set / N} (${counts.setBytes / N} chars) ` + `get=${counts.get / N} (${counts.getBytes / N} chars); full path ${roundTrip.toFixed(2)}ms, parseMarkdownNodes alone ${parseOnly.toFixed(2)}ms`, ) } GlobalRegistrator.unregister()})EOFcd"$PKG"&& bun test --conditions=browser src/components/zz-repro-54428.test.ts
Thanks for confirming the round-trip and measuring it. Your numbers match the shape of ours: per-delta cost grows with block size, and parseMarkdownNodes is only a fraction of it.
We have not measured the native parse/style/layout split outside Chromium either; the (program) share in the profile above is from a Chromium run of the desktop renderer.
According to the OpenCode team this issue will not be resolved in V1 which is being abandoned for V2. There are reports V2 does not have this issue. Whether both sides of the issue (tui and server) have been addressed in V2 remains to be seen.
This one is measured on V2, not V1 — the profile is from a 2.0.26 desktop build, and the round-trip is in the V2 renderer (packages/session-ui/src/components/markdown.tsx sets source.innerHTML, reads it back, and passes the string to renderer.update, which parses it again).
So the "V2 does not have this issue" reports do not cover this path: the per-delta cost still grows with block size on V2 (about 1.6ms at 0.7k characters up to about 23ms at 28k, per the reproduction above).
#48432 is the projection layer (markdown-stream.ts); this is the renderer. Different layer, no shared code, as its own description says.
I'll work on removing the serialization and second HTML parse from the V2 live-block renderer. The marked source DOM can be converted directly to the renderer's node tree, preserving the existing link/code decoration, word animation and store update behavior. This is separate from #54427's store-walk change. I'll check the existing markdown behavior and renderer benchmarks against the current dev branch.
The projection-side half is now open for V2 as #55857 (incremental live-tail project() in markdown-stream.ts). That is a separate cost from the block re-parse/HTML round-trip tracked here, which #55480 targets.
The profile in the issue is from a 2.0.26 desktop build. The round-trip it tracks is markdown.tsx setting source.innerHTML, reading it back, and passing the string to renderer.update, which parses it again — so the per-delta cost grows with block size (about 1.6ms at 0.7k characters up to about 23ms at 28k).
Happy to re-measure on current dev if a baseline would help.
Activity
opencode-agent commentedon Oct 11, 2026
Thanks for the detailed profile. I've confirmed the round-trip in the code and measured it, on 2.0.26 and on the latest
dev(0d61729).For a live block, every delta goes through
updateBlockinmarkdown.tsx. It setssource.innerHTML = block.html, runs themark*passes, readssource.innerHTMLback, and passes the string torenderer.update. That runsreconcile(parseMarkdownNodes(...)).The script below copies that path under happy-dom, using the real
parseMarkdownNodesand the same storereconcile. It countsinnerHTMLreads and writes as a block grows:parseMarkdownNodesalone is only about a quarter of that.This isn't Chromium, so I didn't check the native parse/style/layout split from your profile. The extra parse-and-serialize on every delta does show up as you describe, though. Related: #54305 and PR #54427, which cover the separate store
unwrapwalk.Reproduction script (repro.sh)
Run it:
bash repro.shTe-River commentedon Oct 11, 2026
Thanks for confirming the round-trip and measuring it. Your numbers match the shape of ours: per-delta cost grows with block size, and
parseMarkdownNodesis only a fraction of it.Two notes:
setNodes(reconcile(...))diff, which app: each visible streaming delta re-walks the whole markdown node array in the session-ui store #54305's reproduction puts at ~93% of each update).markdown-stream.ts(the projection), a different layer from this one (markdown.tsx/markdown-solid.tsx). Its own description says the two apply independently.We have not measured the native parse/style/layout split outside Chromium either; the
(program)share in the profile above is from a Chromium run of the desktop renderer.dcerisano commentedon Oct 14, 2026
According to the OpenCode team this issue will not be resolved in V1 which is being abandoned for V2. There are reports V2 does not have this issue. Whether both sides of the issue (tui and server) have been addressed in V2 remains to be seen.
#48432 (comment)
Te-River commentedon Oct 14, 2026
This one is measured on V2, not V1 — the profile is from a 2.0.26 desktop build, and the round-trip is in the V2 renderer (
packages/session-ui/src/components/markdown.tsxsetssource.innerHTML, reads it back, and passes the string torenderer.update, which parses it again).So the "V2 does not have this issue" reports do not cover this path: the per-delta cost still grows with block size on V2 (about 1.6ms at 0.7k characters up to about 23ms at 28k, per the reproduction above).
#48432 is the projection layer (
markdown-stream.ts); this is the renderer. Different layer, no shared code, as its own description says.Dante-dan commentedon Oct 14, 2026
I'll work on removing the serialization and second HTML parse from the V2 live-block renderer. The marked source DOM can be converted directly to the renderer's node tree, preserving the existing link/code decoration, word animation and store update behavior. This is separate from #54427's store-walk change. I'll check the existing markdown behavior and renderer benchmarks against the current dev branch.
dcerisano commentedon Oct 15, 2026
The projection-side half is now open for V2 as #55857 (incremental live-tail
project()inmarkdown-stream.ts). That is a separate cost from the block re-parse/HTML round-trip tracked here, which #55480 targets.Te-River commentedon Oct 17, 2026
Great — thanks for picking this up.
The profile in the issue is from a 2.0.26 desktop build. The round-trip it tracks is
markdown.tsxsettingsource.innerHTML, reading it back, and passing the string torenderer.update, which parses it again — so the per-delta cost grows with block size (about 1.6ms at 0.7k characters up to about 23ms at 28k).Happy to re-measure on current dev if a baseline would help.