Skip to content

🤖 fix: keep a loose list whole in chunked streaming rows - #5668

Merged
ThomasK33 merged 2 commits into
mainfrom
perf/chunked-loose-list-5664
Oct 5, 2026
Merged

ThomasK33 merged 2 commits into
mainfrom
perf/chunked-loose-list-5664

Conversation

@ThomasK33

@ThomasK33 ThomasK33 commented Oct 5, 2026 •

Copy link
Copy Markdown
Member

Summary

A chunked streaming row (#5663) could split one loose list into several lists, and the split stayed after the reply completed. This PR keeps the list whole and makes the completed row render the same structure as a single static render.

Refs #5664

Background

The #5664/#5666 measurement found it: a 30k loose ordered list that mounted chunked rendered as 4 <ol> (start=253/277/301) after completion, and one item lost its <p>. A reload (single render) showed 1 <ol>.

Two causes:

  1. Chunker. While text grows, a frame can end in …\n\n301 before the . arrives. parseMarkdownIntoBlocks then returns [list, "\n\n", "301"]. The chunker started a new group at the blank-line or 301 block, sealed the list, and 301. then started a second list in the open chunk.
  2. Stale render. Streamdown memoizes each element by its source position. When a list turns loose, its first item keeps the same position, so it keeps its old render without <p>. The unchunked row is correct after completion, but a chunked row stays in static mode and never remounts, so the stale item stays.

Implementation

  • MarkdownChunker: a tail that is only blank lines or bare digits (a partial ordered-list marker before its .) never starts a group, together with the blank lines before it. Every other last block still seals the block before it, as on main.
  • ChunkedStreamingMarkdown: while streaming, the open chunk is keyed open-<index>. It mounts fresh when it is sealed and when the stream ends. A sealed chunk keeps its key, so MarkdownCore's memo still skips it.

Cost: each seal does one fresh render of the sealed chunk (about 2,000 chars), and completion does one fresh render of the open chunk. Before #5663, completion re-rendered the whole reply.

Validation

  • New test ChunkedStreamingMarkdown > a loose %s list streamed across chunks renders like one static render (ordered and bullet). It streams 60 items, with one frame per partial marker (\n\n3, \n\n30, \n\n-), then compares ol/ul/li/li > p structure and text with a single MarkdownCore render. Both cases fail on main (ordered: 38 outline entries differ, bullet: 2) and pass here.

  • Local browser harness (production build, loopback mock provider, switch back at 22k emitted chars, 30k loose ordered list, then compare with a reload):

    <ol> count outline lines differing from single render
    main 31616698f2, 2 runs 4 / 4 14 / 23
    this branch (c0f8c67ad1), 2 runs 1 / 1 0 / 0

    A reference-link and footnote reply stays identical to the single render (0 lines differ).

  • React Compiler: ChunkedStreamingMarkdown has the same single bailout (??= on the ref) before and after.

Not in this PR

Risks

Low. The change affects only rows that mount chunked mid-stream (above 20k chars). The worst case is one extra synchronous render of the open chunk at completion. For a trailing block close to 20k chars, that render can take more than 50 ms.


Generated with xum • Model: anthropic:claude-opus-5-5 • Thinking: high • Cost: $33.41

Do not let the last block (or the blank lines before it) start a new chunk, so a
partial list marker cannot seal the list. Remount a chunk when it is sealed and
when the stream ends, so Streamdown's position-keyed memo cannot keep a stale
first list item.

Refs #5664
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-05T09:48:55.664508Z c0f8c67 New commits
🔒 Security Review ✅ Completed 2026-10-05T09:55:04.993061Z c0f8c67 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d1bbee09c7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/browser/features/Messages/ChunkedStreamingMarkdown.tsx Outdated
A closed oversized block followed by a growing paragraph is sealed again, so the
paragraph renders synchronously (Codex review).
@ThomasK33
ThomasK33 added this pull request to the merge queue Oct 5, 2026
Merged via the queue into main with commit d91c307 Oct 5, 2026
30 of 31 checks passed
@ThomasK33
ThomasK33 deleted the perf/chunked-loose-list-5664 branch October 5, 2026 10:13
yermakoffivan pushed a commit to yermakoffivan/mux that referenced this pull request Oct 6, 2026
…er#5705)

## Summary

Partial fix for one case of coder#5666: a huge **list** in a chunked
switch-back row. The chunker now cuts that list at its top-level item
starts into chunks of about 2,000 chars. When you switch back to a
workspace that streams a long list, the newest items appear about 50 ms
after the click instead of after about 5 s, and the list keeps growing
without multi-second stalls. Tables, fresh rows and paragraphs are not
changed.

Refs coder#5666

## Background

Rows longer than 20k chars that mount mid-stream render through
`ChunkedStreamingMarkdown` (coder#5647). Its chunker groups whole Streamdown
blocks, so one huge list stayed one open chunk. That chunk was larger
than the 20k synchronous-render cap, so it used Streamdown's deferred
render. While the stream was live, that render was starved, so the row
stayed empty for about 5 s and then grew in multi-second steps.

## Implementation

1. `listItemRanges(block, maxChars)` (new `streamingMarkdownBlocks.ts`)
lexes one list block with `marked` and groups its top-level items into
ranges that join back to the block.
- `marked` is a new direct dependency, pinned exactly to 17.0.1, the
version Streamdown's block splitter uses (`bun.lock`:
`streamdown/marked`). Streamdown's only public lexer,
`parseMarkdownIntoBlocks`, returns a whole list as one block, so it
cannot give item bounds. mermaid keeps its own nested `marked@16.4.2`.
- Item bounds come from the lexer, so a nested list, an indented fence,
a lazy continuation line and an HTML block stay inside their item.
- An item longer than a range is a range on its own. There is no cut
inside an item.
- If the block is not exactly one list, or the lexer's raw text does not
map back to the block, the block stays whole, as before.
- A list that holds a reference definition (marked's `links`) or a
footnote (`[^x]`) also stays whole. Those resolve across the whole
document, so a cut could put a use and its definition in different
chunks.
2. `MarkdownChunker` gives each range of a cut list its own chunk. A
range is sealed once a later item follows it, like any other block, and
later frames lex only the text after the sealed prefix.
3. `completedChunks()` joins each cut list back into one chunk. The row
uses it once the stream ends, so the completed row renders each list as
one list, as a single render does.
4. Chunk keys are now the chunk's source offset and length (the open
chunk keeps the `open-` prefix). A joined list gets a new key and
remounts. It must not reuse its first range's key: Streamdown memoizes
list items by source position, so a range that was tight would keep its
stale items in a list that is loose as a whole (the coder#5664 bug).
5. The row tracks its oldest mounted chunk by source offset, not by
index. Completion can join a cut list while older chunks are still
mounting, and every chunk after the list then moves to a lower index.
The row now mounts from the chunk that contains that offset, so an
offset inside the joined list keeps the whole list mounted.

## Visible changes

While the row streams:
- **List margins at the cuts.** Each range renders as its own
`<ul>`/`<ol>`, so a list margin shows at every cut.
- **Ordered numbers.** An ordered range takes its `start` from its own
first marker. Sequentially numbered lists keep their numbers. A list
that repeats one marker (`1.` `1.` `1.`) restarts at 1 at each cut while
streaming. Completion merges it into one correctly numbered list (test:
`repeated-marker`).
- **Looseness per range.** A range whose items have no blank line
between them renders tight, even if the list as a whole is loose.
Completion renders it as one list, as a single render does.

At completion:
- **DOM-state loss.** The cut list remounts as one list. A text
selection inside it clears, and a "copied" state on a code block inside
it resets. Chunks outside the cut list keep their DOM nodes.
- **Completion cost.** The end-of-stream commit renders the joined list
once: 103–111 ms at 30k and 157–166 ms at 50k (main: 77–104 ms and
156–198 ms). One more task follows: 87–92 ms at 30k and 129–136 ms at
50k (main: 104–152 ms and 129–171 ms). Completion cost is out of scope
for this PR.

Out of scope: tables, fresh rows (rows that mount at stream start),
paragraphs and the completion stall.

## Validation

Tests (each new rendering and chunker test fails on main):
- `streamingMarkdownBlocks.test.ts`
- `listItemRanges > %s: ranges join back to every prefix and never split
an item` (`bullet`, `ordered` (numbered from 7), `loose`, `task`,
`nested`, `fence` (holds a `- x` line), `lazy`, `html`)
  - `keeps an item longer than a range whole, as a range of its own`
- `returns null for blocks that are not one list, or whose text the
lexer normalizes`
- `ChunkedStreamingMarkdown.test.tsx`
- `cuts a huge streaming list at item starts, so the open chunk stays
small` (warm chunker over prefixes = cold chunker; completed = one
chunk)
  - `completed chunks join only the cut list and keep the other chunks`
- `a huge list that mounts mid-stream shows its newest items in the
first commit`
  - `ordered ranges keep their numbers while the list streams`
- `a %s list cut while streaming renders as one list like a single
render once complete` (bullet, ordered from 7, loose, nested, task,
repeated-marker): one top-level list, same `ol/ul/li/li >
p/input[checked]` outline and `start` as a single render, same text
  - `completion remounts the cut list but keeps the other chunks' DOM`
- `completion during the backfill, with the oldest mounted chunk %s,
unmounts nothing` (`after the cut list`, `inside the cut list`): the
stream ends between backfill frames; every chunk that was visible stays
visible, and the sealed chunks after the list keep their DOM nodes. Both
cases fail on `48dd9359fd`.
  - The coder#5668 loose-list tests are unchanged and pass.

### Fixture gate (production build, switch-back list, 5 runs each)

Phases follow the plan's definitions:
- **cold:** from the click to the first visible text, including the
mount commit;
- **backfill:** the first 500 ms after the first text;
- **incremental:** from there to the server's stream end;
- **completion:** tasks that start at or after the server's stream end.

"Before" is main `9373461eaa` (post-coder#5669, before coder#5702, which touched
only highlighting). "After" is this head (`c1a58ac305`).

| list | build | cold: first text p50 / p95 | cold: tasks >50 ms before
first text, per run (max) | backfill: tasks >50 ms per run |
incremental: longest gap p50 / max | incremental: tasks >50 ms per run |
|---|---|---:|---|---|---:|---|
| 30k | main | 5178 / 5187 ms | 50, 46, 44, 47, 43 (90 ms) | 4, 4, 3, 3,
3 | 4619 / 5208 ms | 49, 47, 42, 41, 36 |
| 30k | this PR | **49 / 57 ms** | 1, 0, 0, 0, 0 (57 ms) | **0 in every
run** | **153 / 174 ms** | **0 in every run** |
| 50k | main | 5411 / 5707 ms | 14, 17, 8, 18, 16 (230 ms) | 1, 0, 1, 0,
0 | 7419 / 7601 ms | 2, 0, 2, 0, 1 |
| 50k | this PR | **53 / 54 ms** | 1, 1, 1, 1, 0 (53 ms) | **0 in every
run** | **202 / 212 ms** | **0 in every run** |

The stream is not free of long tasks:
- The switch-back mount commit is a 51–60 ms task in some runs (in this
run: 1 of 5 at 30k, max 57 ms, and 4 of 5 at 50k, max 53 ms; in earlier
runs of this branch, up to 4 of 5 at 50k, 51–64 ms). It ends before the
first text appears, so it counts in the cold phase.
- Part of that commit is a second lex: Streamdown's block splitter lexes
the text and returns only strings, so `listItemRanges` lexes the list
block again. In V8, the first `MarkdownChunker.update` on a 41k list
takes 12.0 ms (10.4–14.4) on this branch and 6.5 ms (6.4–6.9) on main.
Reusing the splitter's tokens would mean reimplementing it, so this PR
keeps the second lex.
- The completion tasks are listed under "Visible changes".

Other fixture checks:
- coder#5668 loose-list fixture (2 runs): 1 `<ol>` in both the chunked and
the single render, 319/319 items with a `<p>`, 0 outline and 0 text diff
lines.
- Dense 60k fresh row, alternating main and this branch, 3 runs each (on
`85c4fbcfd2`): completion longest task 240–256 ms on main and 239–245 ms
here. Both rendered 97.4–97.5% while live, with an 83–84 ms longest gap
and 0 live tasks. On this head (3 runs, not alternated): 97.5%, 77–84 ms
longest gap, 0 live tasks, completion 257–259 ms.

### Bench: `MarkdownChunker.update`, last 120 frames (+30 chars each) of
a 50k list, one chunker

| runtime | method | main `3469017c97` | this PR | delta |
|---|---|---:|---:|---:|
| Node 22 (V8), primary | scratch harness, separate installs, 10
alternating rounds | 6.71 ms/frame (6.47–6.90) | 0.28 ms/frame
(0.27–0.37) | −95.8% |
| Bun, secondary | `make bench-compare BENCH=ChunkedStreamingMarkdown
RUNTIME=bun ROUNDS=10` | 148.04 ms | 1.25 ms | −99.2% (95% CI −99.2% to
−99.1%) |

`make bench RUNTIME=node` cannot load this bench: the import chain
reaches `MarkdownCore`, which imports KaTeX's `.css`, and node stops
with `ERR_UNKNOWN_FILE_EXTENSION`. The bench runner is unchanged. The V8
row comes from a scratch harness that bundles the same loop with
esbuild. Its CSS stub lives only in the harness.

<details>
<summary>V8 harness: commands, source and evidence path</summary>

Each tree (main in a separate clone with its own `bun install`, and this
branch) is bundled and run alternately:

```bash
bun nodebench/build.ts <tree> <tree>/build/nodebench/b.mjs
for i in $(seq 1 10); do for t in <main-tree> <pr-tree>; do node --expose-gc $t/build/nodebench/b.mjs 5; done; done
```

Evidence (on the measurement host): `$XUM_SCRATCH_DIR/nodebench/final/`
(`per-frame-rounds.txt`, `cold-update-rounds.txt`,
`bench-compare-bun.log`, and the harness sources).

`build.ts`:

```ts
import { build } from "esbuild";
import * as path from "node:path";
const [root, out] = process.argv.slice(2);
await build({
  absWorkingDir: root, entryPoints: [path.join(process.env.XUM_SCRATCH_DIR!, "nodebench/entry.ts")], outfile: out,
  bundle: true, platform: "node", format: "esm", target: "node22", tsconfig: path.join(root, "tsconfig.json"),
  packages: "external", logLevel: "error", nodePaths: [path.join(root, "node_modules")],
  plugins: [{ name: "css-stub", setup(b) {
    b.onResolve({ filter: /\.css$/ }, (a) => ({ path: a.path, namespace: "css-stub" }));
    b.onLoad({ filter: /.*/, namespace: "css-stub" }, () => ({ contents: "", loader: "js" }));
  } }],
});
```

`entry.ts`:

```ts
// Node (V8) per-frame cost of MarkdownChunker.update over the last 120 frames of a 50k list.
import { MarkdownChunker } from "@/browser/features/Messages/ChunkedStreamingMarkdown";
import { CHUNKED_STREAMING_CHUNK_CHARS } from "@/constants/streaming";
const FRAMES = 120;
let list = "";
for (let k = 1; list.length < 50_000; k++) list += `- item ${k} with **bold ${k}** and *em* and \`c${k}\` [l](http://x/${k})\n`;
const frames = Array.from({ length: FRAMES }, (_, i) => list.slice(0, list.length - (FRAMES - 1 - i) * 30));
const passes = Number(process.argv[2] ?? 5);
const perPass: number[] = [];
for (let p = 0; p < passes + 1; p++) {
  const chunker = new MarkdownChunker(CHUNKED_STREAMING_CHUNK_CHARS);
  const t0 = performance.now();
  let sink = 0;
  for (const f of frames) sink += chunker.update(f).length;
  const dt = (performance.now() - t0) / FRAMES;
  if (p > 0) perPass.push(dt); // first pass is warmup
  if (sink < 0) console.log(sink);
}
perPass.sort((a, b) => a - b);
console.log(JSON.stringify({ medianMsPerFrame: perPass[Math.floor(perPass.length / 2)], min: perPass[0], max: perPass.at(-1) }));
```

</details>

## Risks

Medium-low. The change affects only rows longer than 20k chars that
mounted mid-stream (switch-back), and only lists longer than 2,000 chars
in them. Completed rows render whole-block chunks as before. A lexer
disagreement between `marked` and remark on item bounds would show only
while streaming, because completion joins the list and renders it once.

---

_Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking:
`high` • Cost: `$123.22`_

<!-- mux-attribution: model=anthropic:claude-opus-5-5 thinking=high
costs=123.22 -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant