Repository navigation
feat: reveal streamed text word by word with streamingConfig.smooth - #1159
Closed
hryhoriiK97 wants to merge 13 commits into
Closed
hryhoriiK97 wants to merge 13 commits into
hryhoriiK97 wants to merge 13 commits into
Conversation
hryhoriiK97
force-pushed
the
feat/streaming-smooth
branch
from
November 11, 2026 08:36
adae731 to
d36690d
Compare
hryhoriiK97
force-pushed
the
feat/streaming-smooth
branch
from
November 11, 2026 09:42
d36690d to
cf5e6c9
Compare
This was referenced Nov 11, 2026
4 of 6 tasks
hryhoriiK97
force-pushed
the
feat/streaming-smooth
branch
from
November 11, 2026 19:28
399e610 to
af3cfc8
Compare
Models and networks deliver text in bursts. streamingConfig.smooth reveals it word by word at a steady pace instead, the way chat apps do. Off by default. A pacer in the core (StreamingPacer) decides how much of the markdown a view shows at each moment: each chunk spreads over the time until the next one (a running average of the gaps, at most a second behind), and what is left shows within 200 ms when the stream ends. It reveals on word boundaries: a word a chunk ends inside waits for the rest of it, and markdown that only opens a line (a list marker, a heading's ##, a quote's >) waits for the word after it. The views render only the revealed markdown and get more about 30 times a second while words are waiting (a display link on iOS, a Choreographer callback on Android), so lines, list items and blocks grow with the words and a scrolled view follows them line by line. The shadow node already takes the view's laid-out size for markdown that extends what the view rendered; Android's measurement now also takes it when the view laid out a size other than its last answer, which a height request with unchanged markdown used to get back (a new line waited for the next chunk). When streaming ends with words still waiting, the view keeps streaming until they have appeared, then renders in full; a stream starting again in that window cancels the end. On Android a new line can be laid out before Yoga's commit gives the view its height, so the host clips to its bounds while pacing. The iOS host read UITextView.attributedText on every update, which copies the whole text; it reads the text storage instead. The fade runs per word too: a word split across two chunks fades in as one. CJK characters and attachments count as words. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
streamingConfig.smooth gets its reference entry and a Smooth pacing section in the streaming guide: how the text, layout and blocks follow the words, how chunks are spread, and what waits for the next word. The fade is described per word. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When a stream ended with words still waiting, the view kept streaming until the pacer's done callback. Turning smooth off or replacing the text in those 200 ms reset the pacer, so done never ran and the view stayed in streaming mode. The pacer's waiting now also covers a done still to run, and each host stops streaming once the stream has ended and nothing waits. Android paced the first text a view got, since every string starts with an empty one; it now shows it at once, as iOS does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With custom `renderers`, the shadow node measured the whole `markdown` prop, while smooth pacing shows only the words revealed so far: blank space opened below them, and slots for blocks not reached yet were laid out ahead of the words. The whole document is still measured, so every slot keeps its placement and a block's space is reserved as soon as it is revealed. While the view shows only a start of the markdown, the shadow node takes the height it laid that start out at (iOS ENRMLaidOutSize, Android MeasurementStore), and slots that start below it stay hidden until their words arrive. A slot that has started shows while it grows, as text does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The pacer held a chunk's last word until a space or the next chunk arrived, so text written without spaces kept its last character back for up to half a second on every chunk. A character that is a word of its own is complete as it arrives, unless the chunk ends halfway through its surrogate pair. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ENRMStreamingPacer used CADisplayLink, which macOS does not have, with no TARGET_OS_OSX guard, though the podspec builds for macOS. On macOS it now ticks from a timer at the same 30 a second. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The drain after the streaming prop turns off (finish, resume, ending once, ending at once when the drain is cut short) lived in four host copies on top of two wrapper twins, and drifted between them. The core pacer now takes the text, the prop and smooth in one update that says whether the stream ends now, and drained() says when the drain finished, once. The cases the hosts handled are tested here. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each host now hands the pacer its markdown, streaming prop and smooth in one update and stops streaming when it says so; the wrappers are a frame source and a substring. The Android text view paced the text as props were set, so a prop turning on in the same update as text could be seen late; it now updates once every prop is set, in commitProps, like the container. The prop turning off no longer re-renders the unchanged prefix while the last words drain, and a pacer callback that outlives its iOS view does nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A view whose size differs from the last answer stands in for one asking again only while smooth pacing renders part of the document. Without it, that size is a render still in flight, and a repeat measurement of the same markdown took it over the full answer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The partial laid-out size now takes one copy under the lock for both checks, smooth is read without copying streamingConfig into a map, and the draw clip says why it leaves the sides open. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
docs-md/MARKDOWN_STREAMING.md had no smooth section, the API reference's streamingConfig default and the guide's reference left smooth out, and the guide said marker-only lines wait for the next word without saying they show after half a second when the stream stalls. The macOS pages still spoke of the tail fade, which 'fade' and 'gradient' replaced; smooth paces on macOS too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
hryhoriiK97
force-pushed
the
feat/streaming-smooth
branch
from
November 11, 2026 19:50
af3cfc8 to
234aee4
Compare
6 tasks done
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What/Why?
Models and networks deliver text in bursts: a few words, a pause, a few more. Even with the per-chunk fade from #1156, a streamed answer arrives in jumps. Chat apps such as ChatGPT and Claude smooth this out: words appear one after another at a steady pace, and the message grows line by line with them.
This is the eighth PR of the streaming stack, stacked on #1156. It adds
streamingConfig.smooth, and the fade now runs per word.The streaming stack
streamingandstreamingConfig.animation.How
The API:
streamingConfig.smooth(off by default). It works with every animation: words fade in with'fade', the gradient follows the words, and with'none'words appear without fading. Reduce Motion keeps the pacing and turns off only the animation.A pacer in the core decides how much of the markdown a view shows (
StreamingPacer, next toStreamingSession):##, a quote's>) waits for the word after it, so an empty bullet or heading doesn't flash in; after half a second without new text it shows.The views render only the revealed markdown, and get more on a display link (iOS) or Choreographer callback (Android) while words are waiting. Lines, list items and blocks therefore grow with the words, and a view that scrolls to its end follows them line by line.
renderers, the shadow node still measures the whole document, so every block keeps its placement and its space is reserved as soon as it is revealed. While words are waiting it takes the height the view has laid out, and blocks that start below the revealed words stay hidden until their words arrive.smoothoff or replacing the text in that window stops streaming at once.The fade runs per word (#1156 faded per chunk): a word split across two chunks fades in as one. CJK characters and attachments count as words.
A cost found on the way: the iOS host read
UITextView.attributedTexton every update to get the text's length, which copies the whole attributed text. It now reads the text storage. That was 27% of the main thread on the iPhone with smooth on, and it speeds up streaming without smooth too.Testing
Unit tests:
StreamingPacerTests: words spread over the gap, word boundaries, a held word and its timeout, markdown waiting for its word, never more than a second behind, the end of a stream, a replaced text, CJK.StreamingEffectsTests: the per-word fade, a word split across chunks.StreamMeasurementTest: a view showing part of the document hands over each new size.smoothreaches the native view, off by default.CPU on the iOS simulator (Release build, iPhone 17 Pro, iOS 26.5), with the scratch chat screen from #1151: one answer streaming in through a stream, 60 characters per 16 ms timer tick (the simulator's timers run that at about half speed). The table compares
'fade'with'fade'andsmooth, averaged over 2 runs.Custom renderers (a code block renderer, the
answerdocument, bursty stream): on the iOS simulator and the Pixel 9 emulator the view's bottom stays under the revealed words and the code block appears with its words. Before, blank space opened below the words while they caught up. The final height equals the static render with the renderer (1,116.67 iOS, 1,163.81 Android). AndroidStreamMeasurementTestcovers when the revealed height is taken.Visual checks (bursty stream, about 150 characters per 0.3 s,
githubflavor, auto-scroll on):Docs
streamingConfig.smoothhas its reference entry, and the streaming guide a Smooth pacing section.Limits
Changes after review
StreamingPacer(update(text, streaming, smooth, time)anddrained(time)), with doctests foronStreamingEndfiring once across drains, resumes, text replaced mid-drain and smooth turned off mid-drain. Each host calls one entry point; Android'sEnrichedMarkdownTextpaces incommitProps.CADisplayLink).Checked on the whole stack after these changes: core suite and fuzzing, Jest (1,838), Android unit tests (134), iOS unit tests (118), ktlint; streams on an iPhone 15 Pro and a Galaxy A16 call
onStreamingEndonce and end at the static height, with CPU level or lower; the 106 Maestro flows give the same results and screenshots as before.PR Checklist
main, from missing goldens and screenshot size mismatches on this machine; no regressions🤖 Generated with Claude Code