Skip to content

app: a streaming delta re-parses the whole markdown block, and the DOM round-trip around it dominates the visible cost #54428

Open

Description

@Te-River

Description

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/store unwrap (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:

  1. source.innerHTML = block.html — parse the whole block's HTML into DOM
  2. markInlineCode / markCodeLinks / markSessionLinks / markExternalLinkFavicons — walk and mutate that DOM
  3. rendered.renderer.update(source.innerHTML, ...) — serialize the DOM back to a string
  4. 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

  1. Run a session that streams a long markdown response, with other sessions streaming in parallel.
  2. Profile the renderer during the stream.
  3. (program) dominates; the visible rendering's share scales with the number of visible deltas.

Screenshot and/or share link

n/a

Operating System

Windows 11

Terminal

n/a (desktop app)

Activity

  1. opencode-agent commented on Oct 11, 2026

    @opencode-agent
    Contributor

    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()
    })
    EOF
    cd "$PKG" && bun test --conditions=browser src/components/zz-repro-54428.test.ts

    Run it: bash repro.sh

  2. Te-River commented on Oct 11, 2026

    @Te-River
    Author

    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.

    Two notes:

    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.

  3. dcerisano commented on Oct 14, 2026

    @dcerisano

    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)

    This branch has been upgraded to V2. We're no longer taking PRs for V1. If you think this is still relevant, please port it over and open it for V2.

    — from 𝕺𝖕𝖊𝖓𝕮𝖔𝖉𝖊

  4. Te-River commented on Oct 14, 2026

    @Te-River
    Author

    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.

  5. Dante-dan commented on Oct 14, 2026

    @Dante-dan
    Contributor

    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.

  6. dcerisano commented on Oct 15, 2026

    @dcerisano

    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.

  7. Te-River commented on Oct 17, 2026

    @Te-River
    Author

    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.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.

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions