Skip to content

feat: reveal streamed text word by word with streamingConfig.smooth - #1159

Closed
hryhoriiK97 wants to merge 13 commits into
feat/streaming-effectsfrom
feat/streaming-smooth
Closed

hryhoriiK97 wants to merge 13 commits into
feat/streaming-effectsfrom
feat/streaming-smooth

Conversation

@hryhoriiK97

@hryhoriiK97 hryhoriiK97 commented Nov 10, 2026 •

Copy link
Copy Markdown
Collaborator

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.

<EnrichedMarkdownText stream={stream} streamingConfig={{ smooth: true }} />

The streaming stack

  1. feat: rebuild markdown streaming and add a streaming API #1140: measure and schedule.
  2. feat(core): prepare streamed markdown in one core session #1141: the core streaming session.
  3. perf: render only the tail of a streamed text segment #1146: render only the tail.
  4. perf(ios): draw only the part of a long document near the screen #1149: iOS memory, drawing only the part of a long document near the screen.
  5. feat: add the streaming prop and streamingConfig.animation #1150: streaming and streamingConfig.animation.
  6. feat: stream markdown into a view without re-rendering it #1151: the stream object.
  7. feat: add a gradient streaming animation and animation options #1156: custom streaming animations.
  8. This PR: smooth pacing.

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 to StreamingSession):

  • Each chunk spreads over the time until the next one (a running average of the gaps), at most a second behind. The first chunk of a stream appears at once.
  • It reveals on word boundaries. A word a chunk ends inside waits for the rest of it (at most half a second). Markdown that only opens a line (a list marker, a heading's ##, 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.
  • When the stream ends, what is still waiting appears within 200 ms, then the view renders in full.

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.

  • At most about 30 reveals a second: each reveal renders the text again, and this keeps smooth from rendering more often than a stream's own updates would. Words still fade in at the display's rate.
  • Measurement needs nothing new: the shadow node already takes the view's laid-out size for markdown that extends what the view rendered (feat: rebuild markdown streaming and add a streaming API #1140). Android's measurement now also takes it when the view laid out a size other than its last answer. Before, a height request with unchanged markdown got the previous answer, and a new line waited for the next chunk: 330 ms on the A16, now 25 ms.
  • A new line can be laid out before Yoga's commit gives the view its height. iOS already clips to its bounds; the Android host now clips vertically while pacing, so the line appears when the height lands instead of drawing over what follows.
  • With custom 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.
  • When streaming ends with words still waiting, the view keeps streaming until they have appeared. Streaming starting again in that window cancels the end; turning smooth off 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.attributedText on 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:

  • Core 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.
  • Core StreamingEffectsTests: the per-word fade, a word split across chunks.
  • Android StreamMeasurementTest: a view showing part of the document hands over each new size.
  • Jest: smooth reaches the native view, off by default.
  • Results: Jest 1,832 of 1,832, iOS 103 of 103, Android all passing, core 3 of 3 suites; typecheck and ktlint clean.

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' and smooth, averaged over 2 runs.

process CPU main thread renders
github chat +13.0% +14.5% 556 vs 558
github forest +13.4% +12.5% 725 vs 668
commonmark chat +7.4% +8.4% 557 vs 559
commonmark forest +14.9% +16.2% 731 vs 666
  • Dropped frames are the same with and without smooth, and the screen never re-renders.
  • Smooth renders about as often as the stream itself; the extra cost is the per-word fade and the frame callbacks.
  • On the iPhone 15 Pro (Release, same screen and stream, 2 runs each): +10.4–10.7% process CPU and +10.4–10.5% of the main thread for github forest, commonmark chat and commonmark forest; +12% and +13% for github chat (one run each, the phone's speed changed mid-matrix). Renders, dropped frames and screen re-renders (0) are the same with and without smooth.

Custom renderers (a code block renderer, the answer document, 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). Android StreamMeasurementTest covers when the revealed height is taken.

Visual checks (bursty stream, about 150 characters per 0.3 s, github flavor, auto-scroll on):

  • iOS simulator and iPhone 15 Pro: words appear one after another, each fading in; bullets come with their first word; the view's bottom stays right under the last line; 6–8% of frames are still. The final height equals the static one (60,587.67 on the simulator, 62,587.67 on the iPhone). Both flavors.
  • Galaxy A16 (Android 16), before the 30-a-second cap: a reveal reaches the screen in 6 ms; words appear one or two per frame; a new line waits 25 ms for its height.
  • Pixel 9 emulator: the same, with longer waits for height, because the emulator's JS thread is slow.

Docs

  • streamingConfig.smooth has its reference entry, and the streaming guide a Smooth pacing section.
  • The fade is described per word.

Limits

  • A new line waits for Yoga's commit before it shows: a frame or two on the A16 and the iPhone, longer when the JS thread is busy.
  • On Android, high-quality line breaking re-balances a paragraph's earlier lines as words arrive. That happens without smooth too; smooth makes it more visible.
  • For up to 200 ms after streaming ends, the height already measures the whole document while the last words appear.

Changes after review

  • The drain logic lives in the core StreamingPacer (update(text, streaming, smooth, time) and drained(time)), with doctests for onStreamingEnd firing once across drains, resumes, text replaced mid-drain and smooth turned off mid-drain. Each host calls one entry point; Android's EnrichedMarkdownText paces in commitProps.
  • A chunk's last CJK character shows without waiting. macOS paces with a timer (it has no CADisplayLink).
  • Android: the pacer's frame callback stops after release, and a stale laid-out size counts only with smooth pacing.
  • Docs: smooth in docs-md, its default, and its macOS behaviour.

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 onStreamingEnd once 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

  • Code compiles and runs on iOS
  • Code compiles and runs on Android
  • Updated documentation/README if applicable
  • Ran example app to verify changes
  • E2E tests are passing: the full Maestro suite on the iOS simulator and the Android emulator (Release builds) fails the same tests as main, from missing goldens and screenshot size mismatches on this machine; no regressions
  • Required E2E tests have been added (if applicable): core, Android and Jest unit tests

🤖 Generated with Claude Code

hryhoriiK97 and others added 13 commits November 11, 2026 20:43
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>
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