Skip to content

perf(ios): draw only the part of a long document near the screen - #1149

Closed
hryhoriiK97 wants to merge 9 commits into
perf/stream-tailfrom
perf/ios-viewport
Closed

hryhoriiK97 wants to merge 9 commits into
perf/stream-tailfrom
perf/ios-viewport

Conversation

@hryhoriiK97

@hryhoriiK97 hryhoriiK97 commented Nov 9, 2026 •

Copy link
Copy Markdown
Collaborator

What/Why?

A text view that doesn't scroll keeps one bitmap for its whole frame: about 7.5 MB per screen of text on an iPhone 15 Pro. For an 80 KB document inside a ScrollView, mounting it took 1.1 GB with flavor="github", and 1.2 GB (peaking at 2.4 GB) with flavor="commonmark". Long chat answers get close to the memory limit.

This is the fourth PR of the streaming stack, stacked on #1146. On iOS it draws only the part of a long text near the screen.

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. This PR: iOS memory. Draw only the part of a long view near the screen.
  5. The streaming API: streaming, streamingConfig presets, append / reset.
  6. Custom streaming animations.

How

The text view keeps its whole frame. Selection, the edit menu, hit testing, links and accessibility work on the whole text as before. Only drawing changes.

  • ENRMVisibleArea follows the scroll views above a view and reports the vertical part of the view they show. It observes their contentOffset, and the layer transforms of the views between them (Animated's native driver moves a view by its transform, with no scroll and no layout).
  • ENRMTextViewport turns that into a drawn area: the visible part plus 600 pt on each side, moved in 256 pt steps. Scrolling moves it once per step, not on every frame. A text with no scroll view above it draws whole. A text out of the window draws nothing.
  • ENRMTextTiles draws the text. Owned by ENRMMarkdownTextView, it keeps UIKit's own canvas inside the text container at zero height (and collapses it again whenever UIKit resizes it). 512 pt tiles above it draw the glyphs inside the drawn area with the layout manager. Edits redraw from the edited line on, and a dark mode change redraws the tiles (UIKit only redraws its own canvas). The redraw is found in layoutSubviews, because generating glyphs while the text storage is still editing throws.
  • The text container keeps its size. UIKit resizes a text view's container to the frame on every frame change, which throws TextKit's layout away. While streaming, that meant laying the whole text out again on every update. ENRMTextViewportContainer ignores those resizes; only the viewport sizes it, to (width, unlimited height).
  • Block segments (tables, code blocks, math, images, quotes) off screen drop their bitmaps and draw again when they come near. Text inside quotes uses the same viewport.
  • Both components: the segmented host and EnrichedMarkdownText (commonmark). The viewport updates after each mount, on window attach, and on scroll.

macOS is unchanged: the viewport only sets the frame there.

Testing

Unit tests: ENRMTextViewportTests covers:

  • only the part on screen is drawn, and scrolling moves it;
  • measurement and accessibility cover the whole text;
  • edits, attribute edits and growth redraw the right tiles and keep the layout;
  • frame changes leave the container alone;
  • spoiler overlays survive scrolling;
  • selection rects and hit tests land on the drawn text;
  • an ancestor's transform moves the drawn part;
  • a dark mode change and a line clamp change redraw the tiles;
  • text off screen or out of the window draws nothing.

iOS 95 of 95.

Devices: Release builds of the example app at #1146 and at this PR, on an iPhone 15 Pro (iOS 26.6.1). The documents are the 80 KB "forest" and the 66 KB "chat" from #1146.

Static mount of the 80 KB document Before (#1146) This PR
github: memory after mount 1,136 MB ~100 MB (peak ~163)
commonmark: memory after mount 1,245 MB (peak 2,394) ~82–95 MB (peak ~163)
Streaming, 60 B per 16 ms Before: updates / peak MB This PR: updates / peak MB
github forest 1,320 / 460 1,320 / 301
github chat 781 / 221 1,101 / 245
commonmark forest 969 / 220 1,320 / 292
commonmark chat 866 / 234 1,101 / 255

While streaming, peak memory stays in the same range. The view at the end of the stream is on screen, so most of it is drawn anyway. More updates get shown, because the held text container stops the full re-layouts. Every stream ends at the same height as a static mount.

Blank-frame check on the iPhone: a scratch checker in the example app ran before every Core Animation commit. It logged any frame where visible text wasn't covered by drawn tiles, or a visible block had lost its bitmap. Scripted cases, github and commonmark:

  • a view above the markdown grows and shrinks by 1,500 and 3,000 pt, plain and with LayoutAnimation;
  • the markdown's container moves 1,500 pt by an Animated native-driver translate and by Reanimated, and 300 pt by Reanimated;
  • an inverted FlatList of long messages, scrolled in steps and animated to both ends;
  • pushing a screen over the markdown and going back;
  • jumps of 30,000–40,000 pt and animated scrolls to the top and to the end of a 72,000 pt document.

No blank frames. Before observing transforms, the native-driver translate left up to 700 pt blank for the length of the animation.

By hand on the iPhone:

  • fast flicks through the whole document with no blank areas;
  • tables, images, math, code blocks, quotes and spoilers;
  • selecting a word, dragging the handles across several lines (also while scrolling), and the edit menu's position;
  • long-pressing a link.

Limits

  • The tiles find UIKit's container and canvas views by their private class names (_UITextContainerView, _UITextViewCanvasView). If a future iOS renames them, the text view draws its whole frame as before: rendering stays correct, memory goes back to what it is today.
  • A snapshot of a long scroll view (drawViewHierarchyInRect:) only shows the text near the screen.

Follow-ups

  • Every text segment observes the scroll views on its own: a document with many segments pays a small cost per segment on each scroll frame (cheap while its drawn rect doesn't move). The host could compute the visible area once for all of them.

Changes after review

  • A document's one visible area drives all its text segments, so a long document has one set of scroll observers instead of one per segment. It follows a scroll view that grows without scrolling (it observes the layer's bounds).
  • Block views that own a bitmap adopt ENRMDroppableContent, which replaces the drawRect:/bundle heuristic. The run-loop observer order is a named constant.
  • A debug build says once when UIKit's text canvas classes are missing.
  • Tests: the bitmaps of blocks scrolled away are dropped and drawn again, a block created off screen stays empty, the text follows the document's area, and leaving the window releases the scroll views.

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 (no Android change)
  • Updated documentation/README if applicable (no API change)
  • Ran example app to verify changes
  • E2E tests are passing (not run)
  • Required E2E tests have been added (if applicable): iOS unit tests

🤖 Generated with Claude Code

hryhoriiK97 and others added 9 commits November 11, 2026 20:43
A non-scrolling TextKit 1 text view keeps a bitmap of its whole frame,
about 7.5 MB per screen of text: an 80 KB document held 1.1 GB once
mounted (2.4 GB with the commonmark flavor), and block segments kept
theirs off screen too.

The text view keeps its whole frame, so UIKit's selection, menus, hit
testing and accessibility work on it as before. ENRMTextTiles keeps
UIKit's canvas view 0 high instead and draws the text in 512 pt tiles
with the same layout manager, only over the rect ENRMTextViewport gives
it: the part of the text the enclosing scroll views show, plus 600 pt
on each side, and nothing out of the window. The visible area follows
scrolls and the transforms of the views between the text and its scroll
views (Animated's native driver). Tiles redraw from the line an edit or
a display invalidation touched, worked out once the text storage is
done editing, and on a dark mode change.

The text view builds its TextKit 1 stack with a container whose size
only the viewport sets. UIKit sizes a text view's container to its frame
on every frame change, so a growing text dropped its whole layout and
laid it out again on each streamed update; now every stream shows every
update.

The segmented host drops the bitmaps of block segments (code blocks,
tables, quotes) far off screen and draws them again when they come
back. A block image loaded before it was ever drawn still settles its
height.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The drawn top and bottom, with the content frame, already say whether a
text draws only part of itself, and an off-screen text and one out of the
window take the same branch. Measuring at another width than the frame's
now makes the next update size the container back, where it kept the
measured width until the frame changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both text views set the config only when it is new, so a streamed update
doesn't redraw the whole text; one helper keeps the two from drifting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tiles find UIKit's text canvas by private class name; without it a
long text quietly draws its whole frame again. A debug log makes that
visible when an iOS release renames the classes. Also fixes two comments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each text segment, nested quote text included, built a visible area of
its own: on a long document hundreds of objects observing the same
scroll views. The EnrichedMarkdown host's area now updates its segments'
viewports, which read it in their own coordinates; a text with no
document (EnrichedMarkdownText) still follows the scroll views itself.
updateDrawnBlocks becomes updateBlockBitmaps, apart from
updateDrawnSegments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The visible area observed only contentOffset, so a scroll view getting
taller (a keyboard going away, a resized sheet) showed more of a text
than it drew. It observes the scroll views' layer bounds instead, which
a scroll moves too. The header now says the area holds the views above
it until a read after it left the window, and that it doesn't notice a
move under other scroll views in the same window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Which views of a block to drop off screen was guessed from a drawRect:
override outside UIKit's bundle, which would also take a view of any
other framework. The library's five self-drawing block views now adopt
ENRMDroppableContent instead, and the run loop order that puts the drop
after Core Animation's commit gets a name and a reason.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A code block or table scrolled past the margin loses its bitmap and
draws again when it comes near, one within the margin keeps it, and one
made off screen stays empty after the run loop turn that would draw it.
The tests drive a real EnrichedMarkdown, laid out through its segment
layout as layoutSubviews does once Fabric set its frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A text segment of EnrichedMarkdown has the document's visible area and
draws the part a scroll shows, with no area of its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
hryhoriiK97 added a commit that referenced this pull request Nov 12, 2026
The test resized the text container to make the text reflow. A document's
viewport holds the container's size (#1149), so the resize did nothing
and the elements' frames never changed. Growing the font reflows the text
with either container.

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