Skip to content

app: markdown projection re-lexes the whole accumulated message on every streaming delta #56202

Closed

Description

@dcerisano

Description

While a response streams, project() in packages/session-ui/src/components/markdown-stream.ts re-lexes the entire accumulated message text on every delta unless the open tail is a fenced code block. For ordinary prose, lists, and tables the live tail is rebuilt from a full lex, so a stream of n deltas over a message that reaches length L costs O(n·L) ≈ O(L²). Projection work therefore grows quadratically and the UI stalls on long responses.

This is the V2 counterpart of the worker-side projection cost fixed for V1 in #48432 (which closed #36043 and is tracked by #48430). The renderer-side per-delta costs are separate layers: the store walk (#54427, issue #54305) and the DOM re-parse (#54428).

The projection lives in the shared session-ui package, so web and desktop inherit the same cost as the TUI.

Plugins

none

OpenCode version

dev (V2)

Steps to reproduce

  1. Stream a long assistant response — many short paragraphs, one long boundary-less paragraph, or a growing list/table.
  2. Watch the session-ui markdown worker while it streams.
  3. The worker re-lexes the whole accumulated text on every delta. For a single boundary-less paragraph the baseline lexer character count grows O(n²) (197K → 789K → 3.16M → 12.6M for 250 → 2,000 words).

Screenshot and/or share link

n/a — performance issue.

Operating System

Any (session-ui is shared by web and desktop).

Terminal

n/a (web / desktop app).

Activity

  1. opencode-agent commented on Oct 16, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed report. I reproduced this on the latest dev (bc5ebf00a4c1).

    I streamed synthetic messages through project(previous, text, true) one appended delta at a time, counting the characters passed to marked's lexer:

    • One paragraph, 250 → 2000 words: one full lex per delta. Characters lexed grow 215K → 861K → 3.4M → 13.8M, about 4× each time the length doubles. Time goes from 15 ms to about 8.4 s.
    • Many short paragraphs: 221K → 14.2M characters lexed.
    • Growing list, 250 → 2000 items: 323K → 22.3M characters lexed, 63 ms → 5.1 s.
    • Open code fence: stays linear (a single lex in total), because only that tail grows in place.

    The 2000-word paragraph took far longer than its lexed-character count would suggest, so another superlinear cost may be on that path, possibly the heal/remend step over the tail. I haven't confirmed this.

    The script below reproduces this from packages/session-ui. Your open PR #55857 targets this path. #54305 and #54428 cover the separate store and DOM costs for each delta.

    Reproduction script (lex-count.ts)
    // Reproduces #56202: counts characters fed to marked's lexer while project() streams a message.
    // Run from /workspace/repo/packages/session-ui:  bun /workspace/repro/lex-count.ts
    const { Lexer } = await import(Bun.resolveSync("marked", process.cwd() + "/src/components"))
    let chars = 0, calls = 0
    const orig = Lexer.prototype.lex
    Lexer.prototype.lex = function (src: string) { chars += src.length; calls++; return orig.call(this, src) }
    const { project } = await import(process.cwd() + "/src/components/markdown-stream.ts")
    
    function run(label: string, deltas: string[]) {
      chars = 0; calls = 0
      let p: any, text = ""
      const t = performance.now()
      for (const d of deltas) { text += d; p = project(p, text, true) }
      console.log(`${label.padEnd(34)} len=${String(text.length).padStart(7)} deltas=${String(deltas.length).padStart(5)} lexCalls=${String(calls).padStart(5)} lexChars=${String(chars).padStart(10)} ms=${(performance.now() - t).toFixed(0)}`)
    }
    const word = (i: number) => `word${i % 97} `
    for (const n of [250, 500, 1000, 2000]) {
      run(`paragraph ${n} words`, Array.from({ length: n }, (_, i) => word(i)))
    }
    for (const n of [250, 500, 1000, 2000]) {
      run(`short paragraphs ${n} words`, Array.from({ length: n }, (_, i) => word(i) + (i % 10 === 9 ? "\n\n" : "")))
    }
    for (const n of [250, 500, 1000, 2000]) {
      run(`list ${n} items`, Array.from({ length: n }, (_, i) => `- item ${i}\n`))
    }
    // control: open code fence tail (has a fast path)
    for (const n of [250, 500, 1000, 2000]) {
      run(`open code fence ${n} lines`, ["```ts\n", ...Array.from({ length: n }, (_, i) => `const x${i} = ${i}\n`)])
    }

    Run it: bun lex-count.ts

  2. dcerisano commented on Oct 26, 2026

    @dcerisano
    Author

    Fixed by #57016 (merged Oct 20), which replaced the marked + remend re-lex path with @opencode/patchmark. The projection cost was diagnosed and benchmarked here first; the prior implementation is #55857, and the benchmarks and tests that still apply are in #59391.

    For the record, the project's own bot reproduced this on Oct 16 — three days before #57016 — and noted "Your open PR #55857 targets this path."

    Please close this against #57016.

  3. Tarquinen commented on Nov 2, 2026

    @Tarquinen
    Member

    Thanks for the report! As you noted, #57016 fixed this, so I'm closing this issue.

    — from 𝕺𝖕𝖊𝖓𝕮𝖔𝖉𝖊

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