Repository navigation
perf(ios): draw only the part of a long document near the screen - #1149
Closed
hryhoriiK97 wants to merge 9 commits into
Closed
hryhoriiK97 wants to merge 9 commits into
hryhoriiK97 wants to merge 9 commits into
Conversation
This was referenced Nov 9, 2026
hryhoriiK97
force-pushed
the
perf/ios-viewport
branch
from
November 11, 2026 09:42
be47807 to
b765e00
Compare
This was referenced Nov 11, 2026
hryhoriiK97
force-pushed
the
perf/ios-viewport
branch
from
November 11, 2026 19:28
b765e00 to
7d3b930
Compare
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
force-pushed
the
perf/ios-viewport
branch
from
November 11, 2026 19:50
7d3b930 to
015be1c
Compare
6 tasks done
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>
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?
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 withflavor="github", and 1.2 GB (peaking at 2.4 GB) withflavor="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
streaming,streamingConfigpresets,append/reset.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.
ENRMVisibleAreafollows the scroll views above a view and reports the vertical part of the view they show. It observes theircontentOffset, 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).ENRMTextViewportturns 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.ENRMTextTilesdraws the text. Owned byENRMMarkdownTextView, 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 inlayoutSubviews, because generating glyphs while the text storage is still editing throws.ENRMTextViewportContainerignores those resizes; only the viewport sizes it, to (width, unlimited height).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:
ENRMTextViewportTestscovers: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.
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:
LayoutAnimation;Animatednative-driver translate and by Reanimated, and 300 pt by Reanimated;FlatListof long messages, scrolled in steps and animated to both ends;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:
Limits
_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.drawViewHierarchyInRect:) only shows the text near the screen.Follow-ups
Changes after review
ENRMDroppableContent, which replaces thedrawRect:/bundle heuristic. The run-loop observer order is a named constant.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
🤖 Generated with Claude Code