Repository navigation
perf(stream): group tokens by line in the React renderer - #1311
Conversation
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
â Deploy Preview for shiki-next failed.
|
â Deploy Preview for shiki-matsu failed.
|
Codecov Reportâ
All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #1311 +/- ##
=======================================
Coverage 89.75% 89.75%
=======================================
Files 79 79
Lines 3552 3552
Branches 1008 1006 -2
=======================================
Hits 3188 3188
Misses 327 327
Partials 37 37 â View full report in Codecov by Harness. ð New features to boost your workflow:
|
|
The 5 failing Netlify checks are not caused by this PR â the docs deploy is currently broken on They fail identically on #1308 and #1309, and I reproduced it locally on an unmodified
All GitHub Actions checks on this PR pass (lint, typecheck, and test on 22.x/24.x à ubuntu/macos/windows). |
f7c2881 to
7e192b7
Compare
|
Thanks for merging these, @antfu! |
xbetween the brackts.Description
ShikiStreamRenderer(React) renders its tokens as one flattokens.map(...)list, so every chunk that arrives re-reconciles the entire code block. Per-chunk work grows with the length of the block rather than with the size of the chunk, which is the wrong shape for the case this package targets â a long block streaming out of an LLM.This groups tokens into lines and wraps each finished line in
memo, so React skips everything above the line currently being appended to.The rendered DOM does not change. Lines are grouped with a
Fragment, which emits no element, so the output is still one flat list of<span>s directly under<code>. In the benchmark below this PR andmainproduce byte-identical DOM mutation and node-creation counts.Measured with the reproduction from #1310 â a ~640-line TypeScript block streamed 32 bytes per animation frame, 783 frames, 5 interleaved rounds (each round rotates which arm runs first so a drifting system load hits all of them equally), median Âą IQR:
ShikiStreamRendereronmainShikiStreamRendererwith this PRDOM writes are unchanged and wall clock drops by ~47%, which puts the win entirely in reconciliation. With this change, streaming a long highlighted block costs the same as not highlighting it at all.
I also tried switching the span key from
objectId(token)to an index, which lets React patch recalled tokens in place instead of replacing the node. It only moved wall clock from 15.52s to 15.27s â inside the noise â and it would change the "a replaced token is a brand-new node" behaviour thatsvelte-renderer.test.tsasserts on purpose, so I left the key alone and kept this PR to the grouping change.recallhandling: the tokenizer setsrecalltotokensUnstable.length, i.e. only ever the tokens of the line currently being streamed, sorecall()just trims the tail.Linked Issues
fixes #1310
Additional context
New test file
packages/stream/test/react-renderer.test.ts, modelled on the existing Svelte renderer test. Two of its three cases fail onmainand pass with this change â a token in a completed line gets its style read 22 times as 20 further chunks arrive, and 2 times with this PR:The test observes re-render scope through a token whose
htmlStylegetter counts reads, since reconciliation depth isn't visible in the DOM.react-domand@types/react-domare added as devDependencies (both already in the workspace catalog) â the package hadreactbut no renderer, so there was no way to mount the React component in a test.This PR was written with the help of an AI agent; I've read through the diff and run the benchmark and tests myself.