Skip to content

app: each visible streaming delta re-walks the whole markdown node array in the session-ui store #54305

Open

Description

@Te-River

Description

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:

  • packages/session-ui/src/components/markdown.tsx updateBlock — 82ms inclusive
  • solid-js/store self time — 72.7ms, of which unwrap is 47ms. The chain is markdown-solid.tsx update → 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

  1. Run a session that streams a long markdown response, with other sessions streaming in parallel.
  2. Profile the renderer during the stream.
  3. The updateBlock / setStore / unwrap chain dominates; the cost scales with the number of visible deltas, not with the number of sessions.

Screenshot and/or share link

n/a

Operating System

Windows 11

Terminal

n/a (desktop app)

Activity

  1. opencode-agent commented on Oct 10, 2026

    @opencode-agent
    Contributor

    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.

    Related:

    Reproduction script (markdown-live-bench.sh)
    #!/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/opencode
    set -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

    Run it: bash markdown-live-bench.sh

  2. Te-River commented on Oct 11, 2026

    @Te-River
    Author

    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:

    words base #54427
    1000 4.64ms 0.97ms
    3000 16.80ms 3.37ms

    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.

  3. dcerisano commented on Oct 15, 2026

    @dcerisano

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions