Skip to content

feat(core): prepare streamed markdown in one core session - #1141

Merged
hryhoriiK97 merged 9 commits into
perf/stream-measure-reusefrom
perf/stream-session
Nov 12, 2026
Merged

hryhoriiK97 merged 9 commits into
perf/stream-measure-reusefrom
perf/stream-session

Conversation

@hryhoriiK97

@hryhoriiK97 hryhoriiK97 commented Nov 8, 2026 •

Copy link
Copy Markdown
Collaborator

What/Why?

While a document streams with streamingAnimation, half-written Markdown reaches the screen: **bold te shows its asterisks, [docs](https://exa shows as raw text, and $O(n \log shows raw LaTeX until the closing $ arrives. The streaming filter only knew about trailing blocks (tables, fences, media, $$), and the repair module that handles inline syntax (#830, #1092) wasn't compiled into any native build. The commonmark flavor had no streaming handling at all.

This is the second PR of the streaming stack, stacked on #1140. It adds the core StreamingSession from the streaming plan: one place that prepares a streamed document for every consumer.

The streaming stack

  1. feat: rebuild markdown streaming and add a streaming API #1140: measure and schedule. The shadow node takes the size the view laid out, and both platforms show every finished render.
  2. This PR: the core streaming session. Every consumer gets the same snapshot; finished blocks are left alone; trailing blocks are withheld and inline syntax is repaired.
  3. Render only the tail: render the open block and patch the end of the text, using the finished-blocks boundary this PR reports.
  4. iOS memory: draw only the visible part of long views.
  5. The streaming API: streaming, streamingConfig presets, append / reset.

How

One session in core. streamingSnapshot(source, options) in packages/core/cpp/parser/StreamingSession.{hpp,cpp}:

  1. finishedBlocksEnd() finds where the blocks that later text can no longer change end. That is the last line that starts a top-level block after a blank line, outside fenced code, $$ math and HTML blocks that may hold blank lines. It uses the filter's own fence and quote rules, and only grows as the stream grows.
  2. The filter runs on what follows: it withholds tables, fences, media and $$ that haven't finished arriving.
  3. Repair runs on the same tail, with options derived from the parser flags (streamingRepairOptions).
    • Emphasis and code get their closers.
    • A link shows its label as plain text until its URL is complete.
    • An image, unclosed math, a lone marker line or a trailing $ waits.

The work after step 1 is proportional to the unfinished tail. On an 80 KB answer, a snapshot costs about 30 µs, against about 800 µs to filter and repair the whole document.

Same snapshot everywhere. DocumentStore::filter becomes DocumentStore::snapshot, keyed by the source, the block modes and the parser flags. The iOS and Android views, their view-free measurement and the JS block collector all go through it, so measurement, rendering and slot collection agree.

The filter owns blocks, repair owns inline syntax. The filter's inline link cut is gone. Repair takes over the cases the cut handled:

  • A link URL is read as the parser reads it. It is incomplete inside nested parentheses, a <...> destination or an open title, and prose when other text follows the destination.
  • A [label] at the very end waits for a possible (.
  • An escaped \! starts no image.
  • An HTML block line ends a paragraph.
  • A [ left open more than 512 bytes back is a stray bracket. This also keeps the bracket scans linear.

These are documented as divergences from the reference in MarkdownRepair.hpp. The single-tilde escape is off for streaming: it rewrites every ~ in the document, so a paragraph would change once it was finished.

commonmark too. EnrichedMarkdownText on both platforms now takes the streaming modes and prepares its text through the session while streamingAnimation is on. Toggling streamingAnimation or a mode now re-renders it.

Builds. repair/ is compiled wherever parser/ is: the RN podspec and Android CMake, the core podspec, the standalone Android parser and iOS SPM package (new core/repair link), the example app's display benchmark, and the core test script.

Docs. The streaming guide and the API reference no longer say streamingConfig is inert under commonmark, and the guide gains a short section on half-written inline syntax.

Testing

Core (scripts/test-core-parser.sh, yarn core:test):

  • New streaming_session_test:
    • repair wiring, the parser flags and the finished-block rules;
    • every prefix of every fixture filters the same split at the boundary as whole;
    • the boundary only grows, and the part before it is never edited.
  • The md4c fuzz now streams every fixture byte by byte through the session. No link, image or video syntax that the finished document parses as syntax may show as text.
  • The cost bench now covers the session, including the 80 KB case above.
  • Repair: 56,081 assertions pass, including the new cases. The reference parity cases are unchanged.

Visible text that changes instead of growing. The documents stream at a fixed chunk size; every update is parsed with md4c, and the harness counts the updates whose visible text isn't an append of the previous one:

Document Chunk Raw markdown Before (#1140) This PR
Assistant-style answer, 2.6 KB 8 B 25 15 3
Same 60 B 6 2 1
Example app sample, 16 KB 8 B 167 139 4
Same 60 B 47 32 0

What remains: a full reference link before its definition arrives, and a lone | that becomes a spoiler's ||.

Unit tests: iOS 70 of 70, Android 96 of 96, ktlint clean.

Devices: Release builds of the example app at #1140 and at this PR, on an iPhone 15 Pro (iOS 26.6.1) and a Galaxy A16 (Android 16, device farm).

  • Performance is unchanged. An 80 KB streamingAnimation stream:
    • iPhone: 22.2 s on both;
    • A16: 1,108 updates in 33.6 s before, 1,107 in 33.5 s after.
  • On screen: a screen recording of the assistant answer on the A16 shows it.
    • Before: raw <https, [label](https://… and $O(n \log while each arrives.
    • After: none of those. The link label shows as text and the math appears once it closes.
  • commonmark: streams the same answer the same way on both phones.
  • Heights: the end of each stream equals mounting the same document in one go.

Not covered: web has no streaming filter yet. It comes with the streaming API in the last PR of the stack.

Changes after review

  • The finished-blocks boundary skips a closed HTML block (a comment, <pre>, <script>, <style>, <textarea>, <?, <!) instead of stopping there for good. Tag names compare as md4c does: ignoring case and ending at a space, > or the line's end.
  • Math whose closing $ ends the streamed text shows. $$ counts as math in the filter only with latexMath.
  • iOS re-renders on a streamingConfig mode change only while streaming, and reads the mode strings with one helper. Android looks up the Md4cFlags field ids once.
  • Tests: closed and capitalised HTML blocks, math at the end, and every streamed prefix repaired as the whole document would be.
  • The example apps' Podfile.lock checksums follow the podspec changes.

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 (streaming guide and API reference)
  • Ran example app to verify changes
  • E2E tests are passing (not run)
  • Required E2E tests have been added (if applicable): core, iOS and Android tests

🤖 Generated with Claude Code

hryhoriiK97 and others added 9 commits November 11, 2026 20:37
Every streaming consumer (the iOS and Android views, their view-free
measurement and the JS block collector) now gets the same snapshot from a
core StreamingSession behind DocumentStore: the blocks that can no longer
change stay as they are, the filter withholds trailing blocks still
arriving, and repair, now compiled into every native build, handles inline
syntax still being typed. The commonmark flavor gets the same handling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
finishedBlocksEnd stopped at the first HTML block that may hold blank
lines (a comment, <pre>, <script>, <style>, <textarea>, <?, <!), closed
or not. After one such block every update filtered, repaired and parsed
the whole document again. It now skips the block until the text that
closes it, as md4c ends it. Tag names compare ignoring case and end at a
space, > or the line's end: <PRE> opened a block the boundary didn't see,
and <prefix> one it did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A trailing `$` run was always withheld as a possible opener, so finished
math at the very end (`$O(n)$`, a `$$` block without its newline) and
currency like `5$` vanished until the next character. The run now shows
when it closes math opened earlier in its scope or follows a character
after which no math opens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The parser's flags reached repair but not the filter or finishedBlocksEnd,
so with latexMath off a `$$` line still cut everything after it and held
the finished boundary there until the stream ended.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hole

Every streamed prefix of the fixtures now also compares the snapshot with
repairing the whole filtered document, and runs once with latexMath off.
The HTML block case is left out of the repair comparison: repair reads
raw HTML as Markdown, so only the tail repairs it right.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A later reference definition still turns an earlier `[docs][r]` into a
link, so the finished part is fixed text, not fixed blocks. Also drops
HTML from a comment where startsTopLevelBlock already rules it out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The "hidden" check was written out at three places: both views'
updateProps and the view-free measurement.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The modes only shape the streaming snapshot, so a change while not
streaming re-rendered the same text. Android already skips it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every parse and streaming snapshot looked up the class and ten field ids
again. The ids are now cached on first use, and a missing field clears
its NoSuchFieldError instead of leaving it pending for later JNI calls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@hryhoriiK97
hryhoriiK97 merged commit 704ce8a into main Nov 12, 2026
23 of 24 checks passed
@hryhoriiK97
hryhoriiK97 deleted the perf/stream-session branch November 12, 2026 09:50
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