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: each visible streaming delta re-walks the whole markdown node array in the session-ui store #54305
Each streaming delta in the visible session costs ~1.2ms of main-thread time, and the cost is in the session-ui markdown store rather than in the event fan-out.
Measured on a production build with 8 sessions streaming (benchmark in #54302), holding the event count fixed and varying only the visible session's deltas:
visible deltas
events
elapsed
0
1401
45.5 / 45.5 / 48.1 ms
200
1601
~290 ms
A renderer profile (CDP, sourcemap-mapped) attributes that to:
solid-js/store self time — 72.7ms, of which unwrap is 47ms. The chain is markdown-solid.tsxupdate → setStore → updateArray/reconcile → unwrap, and unwrap walks the whole node array.
dompurify — 14.8ms
insertBefore — 10ms
For comparison, the whole event fan-out chain (sync.tsx → global-sync → connection.ts → data.ts) is ~11µs per event, and the eviction pass is 0.6ms over 1401 events.
So the per-event store walk is real, but it happens in the markdown store, once per visible delta.
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.
The updateBlock / setStore / unwrap chain dominates; the cost scales with the number of visible deltas, not with the number of sessions.
Thanks for the detailed profile. I reproduced this outside the desktop app with a benchmark of the session-ui live markdown renderer (createMarkdownRenderer in markdown-solid.tsx, run under happy-dom). The script grows one paragraph by one word per delta, the way streaming does.
The cost of each update() grows with the size of the live block, even though each delta adds only one word:
words in live block
ms per update
ms re-parsing the HTML
100
0.54
0.05
500
1.67
0.12
1000
3.50
0.25
3000
14.75
1.09
About 93% of each update is setNodes(reconcile(...)) diffing the block's whole node tree, not parsing. That matches your reconcile → unwrap attribution. The numbers are the same on v2.0.26 and on the latest v2 (4617210); markdown-solid.tsx hasn't changed between them. The script below reproduces it.
#!/usr/bin/env bash# Reproduces #54305: per-delta cost of the session-ui live markdown renderer grows linearly# with the size of the live block, even though each delta adds one word.# Usage: run from a v2 checkout after `bun install`: bash markdown-live-bench.sh /path/to/opencodeset -euo pipefail
REPO=${1:-$(pwd)}cd"$REPO/packages/session-ui"
mkdir -p repro-bench
echo'export { createMarkdownRenderer, parseMarkdownNodes } from "../src/components/markdown-solid"'> repro-bench/entry.ts
cat > repro-bench/vite.config.ts <<'EOF'import { defineConfig } from "vite"import solid from "vite-plugin-solid"export default defineConfig({ plugins: [solid()], resolve: { conditions: ["browser"] }, build: { outDir: "repro-bench/dist", emptyOutDir: true, minify: false, target: "esnext", lib: { entry: new URL("./entry.ts", import.meta.url).pathname, formats: ["es"], fileName: "bench" } },})EOF
cat > repro-bench/run.ts <<'EOF'import { GlobalRegistrator } from "@happy-dom/global-registrator"GlobalRegistrator.register()const { createMarkdownRenderer, parseMarkdownNodes } = await import(new URL("./dist/bench.js", import.meta.url).href)const words = Array.from({ length: 3000 }, (_, i) => `word${i}`)const html = (n: number) => `<p>${words.slice(0, n).join(" ")}</p>`const root = document.createElement("div")document.body.appendChild(root)const r = createMarkdownRenderer(root, html(1), true)const checkpoints = new Set([100, 250, 500, 1000, 2000, 3000])let bucket: number[] = []console.log("live words | ms per update() (mean of last 20) | ms parseMarkdownNodes only")for (let n = 2; n <= 3000; n++) { const t = performance.now() r.update(html(n), true, true) // one more word per delta, as during streaming bucket.push(performance.now() - t) if (bucket.length > 20) bucket.shift() if (checkpoints.has(n)) { const p = performance.now(); for (let i = 0; i < 20; i++) parseMarkdownNodes(html(n), true, true) const parse = (performance.now() - p) / 20 const mean = bucket.reduce((a, b) => a + b, 0) / bucket.length console.log(`${String(n).padStart(10)} | ${mean.toFixed(3).padStart(8)} | ${parse.toFixed(3).padStart(8)}`) }}r.dispose()EOF
bun --bun x vite build --config repro-bench/vite.config.ts >/dev/null
bun repro-bench/run.ts
Your reproduction matches ours: the store walk is the cost, not the parse. Our profile of the same scenario (600 visible deltas, 8 sessions, production build) puts unwrap at 536ms of 2880ms sampled, and parseMarkdownNodes at 68ms.
PR #54427 addresses exactly that. The first attempt there handed reconcile a tree rebuilt from the store's own nodes, which moved the cost into the comparison — reuseNodes read the whole old tree through the store proxy (~5ms per update at 3000 words) and the live node was still rebuilt, so reconcile diffed all of its children (~7ms). It now keeps the plain parsed tree, walks the tail by path, and writes only the changed node and the new children with setNodes(...path).
Per-update cost for one paragraph growing by a word, mean of the last 100 updates, production build:
The V2 port of #48432 is now open as #55857 — it optimizes the worker-side project() in markdown-stream.ts (incremental live-tail projection), the projection layer noted as separate from the store walk here. It does not touch markdown-solid.tsx, so it complements #54427 rather than overlapping.
Activity
opencode-agent commentedon Oct 10, 2026
Thanks for the detailed profile. I reproduced this outside the desktop app with a benchmark of the session-ui live markdown renderer (
createMarkdownRendererinmarkdown-solid.tsx, run under happy-dom). The script grows one paragraph by one word per delta, the way streaming does.The cost of each
update()grows with the size of the live block, even though each delta adds only one word:About 93% of each update is
setNodes(reconcile(...))diffing the block's whole node tree, not parsing. That matches yourreconcile→unwrapattribution. The numbers are the same on v2.0.26 and on the latestv2(4617210);markdown-solid.tsxhasn't changed between them. The script below reproduces it.Related:
markdown-stream.ts, a different step on the same pathReproduction script (markdown-live-bench.sh)
Run it:
bash markdown-live-bench.shtest(app): add a parallel-session event benchmark
Te-River commentedon Oct 11, 2026
Your reproduction matches ours: the store walk is the cost, not the parse. Our profile of the same scenario (600 visible deltas, 8 sessions, production build) puts
unwrapat 536ms of 2880ms sampled, andparseMarkdownNodesat 68ms.PR #54427 addresses exactly that. The first attempt there handed
reconcilea tree rebuilt from the store's own nodes, which moved the cost into the comparison —reuseNodesread the whole old tree through the store proxy (~5ms per update at 3000 words) and the live node was still rebuilt, soreconcilediffed all of its children (~7ms). It now keeps the plainparsedtree, walks the tail by path, and writes only the changed node and the new children withsetNodes(...path).Per-update cost for one paragraph growing by a word, mean of the last 100 updates, production build:
The store walk alone is 0.18ms at 3000 words and near-constant. What remains is the parse and
For's DOM diff, which the base pays too.The block re-parse you and #54428 describe is a separate step on the same path.
test(app): add a parallel-session event benchmark
test(app): carry the parallel-session benchmark for measurement
test(app): add a parallel-session event benchmark
test(app): carry the parallel-session benchmark for measurement
test(app): add a parallel-session event benchmark
test(app): carry the parallel-session benchmark for measurement
dcerisano commentedon Oct 15, 2026
The V2 port of #48432 is now open as #55857 — it optimizes the worker-side
project()inmarkdown-stream.ts(incremental live-tail projection), the projection layer noted as separate from the store walk here. It does not touchmarkdown-solid.tsx, so it complements #54427 rather than overlapping.test(app): add a parallel-session event benchmark
test(app): carry the parallel-session benchmark for measurement
test(app): add a parallel-session event benchmark
test(app): carry the parallel-session benchmark for measurement
test(app): add a parallel-session event benchmark
test(app): carry the parallel-session benchmark for measurement
test(app): add a parallel-session event benchmark
test(app): carry the parallel-session benchmark for measurement
test(app): add a parallel-session event benchmark
test(app): carry the parallel-session benchmark for measurement
test(app): add a parallel-session event benchmark
test(app): carry the parallel-session benchmark for measurement
test(app): add a parallel-session event benchmark
test(app): carry the parallel-session benchmark for measurement
test(app): add a parallel-session event benchmark
test(app): carry the parallel-session benchmark for measurement
test(app): add a parallel-session event benchmark
test(app): carry the parallel-session benchmark for measurement