Repository navigation
Conversation
## Summary
The first time a text view draws, `drawAttributedString:` runs on the main thread and lays its text out from scratch: it builds the `NSAttributedString` (font lookups), attaches a new layout manager to it (font fixing, glyph generation) and lays out every line. Measuring already did the same work, off the main thread, and that layout is thrown away. Painting the glyphs turns out to be a small part of the cost of a draw.
**Current behavior.** When new text appears, the display pass that shows it for the first time pays for that layout on the main thread. In a trace of a feed scrolling in the app that hit this, first draws of text were the largest single main-thread cost of mounting a row, about 22% of the mount transaction. React Native has `enablePreparedTextLayout` for this, but its implementation is Android only.
**With this change.** `ParagraphShadowNode` asks the text layout manager to lay the text out, on the thread it is laying out on, for the frame the text view will draw in. It does that where it creates the paragraph state for that content and size, which is already the code that knows both, and is exactly when the view redraws. `RCTTextLayoutManager` keeps laid-out text storages in a thread-safe LRU cache, keyed by what the paragraph draws and the frame it draws in, so the draw finds the one made for it and only paints. `drawAttributedString:` otherwise behaves as before: when nothing was prepared for it, it builds the layout itself, as it does today.
The cached object is the layout: the `NSLayoutManager` attached to the text storage holds the line fragments and glyphs, and it is the same object the current code builds on the main thread for every draw. A hit changes nothing about what is drawn. Key equivalence and hashing are the ones prepared text layouts use on Android (`areAttributedStringsEquivalentDisplayWise`): everything drawing reads, including text attributes and attachment sizes, and nothing that only matters for hit testing, such as a fragment's event emitter. Two text views that draw the same content at the same size share one layout, so repeated content in a long list is laid out once.
Preparing is a new optional method on the text layout manager, reached through `TextLayoutManagerExtended` like `measureLines`, so platforms whose manager has no layout to prepare, such as the C++ one, compile it away.
**Why not `enablePreparedTextLayout`.** That flag changes how paragraphs are measured (`rawContentSize()`, exact constraints, prepared layouts carried in `ParagraphState`), and its implementation lives behind Android's text pipeline. Making the iOS `TextLayoutManager` satisfy `TextLayoutManagerWithPreparedTextLayout` is a bigger change with a much wider blast radius, because it changes iOS measurement too. This change leaves measurement, `ParagraphState` and the drawing code alone, so it can be evaluated and reverted on its own. Converging the two is a reasonable follow-up.
**Threading and cost.** Layouts are built on the thread that lays out the shadow tree and are immutable once built, apart from the truncated-text color fix-up that a draw applies. `SimpleThreadSafeCache::get` runs its generator under the cache's lock, so a preparation holds the lock for the length of one layout and waits on other preparations; draws only look entries up, so the main thread never waits on a layout and never blocks text views behind one another. Text is laid out at most twice, once for measurement and once for the first draw, the second on the JS thread rather than the main thread, at about a millisecond per paragraph in a commit. Text that is never displayed is still prepared, because at layout time React Native does not know which paragraphs will be drawn.
## Changelog
[IOS] [CHANGED] - Lay out `Text` off the main thread before its first draw
## Test Plan
- `node scripts/clang-format.js` with the pinned clang-format (21.1.2) on the changed files: passes.
- The new methods are added to `scripts/cxx-api/api-snapshots/{ReactApple,ReactAndroid,ReactCommon}FrameworksCxx.api` in the order the generator sorts them (the members of each block remain ASCII-sorted). `python -m scripts.cxx-api.parser` does not run on this Linux machine (its Doxygen XML parse comes back empty), so those files were edited by hand and should be regenerated before landing.
- **Not built.** This machine has no macOS, Xcode or iOS SDK, so none of this compiled here, not the ObjC++ and not the Fantom tester. A branch that has never compiled is the main risk in this PR: it needs a build and a run in RNTester on a device before it is ready.
- **Not measured.** The numbers below come from the app that motivated the change, where the same approach was developed on top of 0.86.3, not from this branch.
Release build, iPhone 16 Pro Max, 30s fast fling through a feed, with and without the change:
| | before | after |
|---|---:|---:|
| main-thread time in `drawAttributedString:` per 30s | 570 ms | 160 ms |
| hitches | 88 | 40 |
| hitch time per second of scrolling | 40.8 ms | 14.6 ms |
| JS-thread time spent preparing text per 30s | 0 ms | ~300 ms |
What is left in `drawAttributedString:` is painting glyphs, background and highlight rectangles, and the truncated-text color fix-up.
- Things to look at once it runs: truncated text (the fix-up that colors the "…" mutates the text storage, which is now shared, so a second draw must look the same), multi-line text, text with inline views, link press highlighting, and text at several widths in a `ScrollView`.
- Known limitation: with `enableIOSCompressedTextFrameAdjustment` on, a paragraph that sets a line height is drawn in an adjusted frame, so that paragraph is laid out twice and the prepared layout is not the one drawn. Both flags are off by default and independent; making them work together is a follow-up.
- Follow-up: the truncated-text color fix-up could move into the preparation, which would make prepared layouts immutable and take another ~35 ms per 30s of scrolling off the main thread. It is left out to keep this change small.
This branch has not been deployed
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.
Summary
The first time a text view draws,
drawAttributedString:runs on the main thread and lays its text out from scratch: it builds theNSAttributedString(font lookups), attaches a new layout manager to it (font fixing, glyph generation) and lays out every line. Measuring already did the same work, off the main thread, and that layout is thrown away. Painting the glyphs turns out to be a small part of the cost of a draw.Current behavior. When new text appears, the display pass that shows it for the first time pays for that layout on the main thread. In a trace of a feed scrolling in the app that hit this, first draws of text were the largest single main-thread cost of mounting a row, about 22% of the mount transaction. React Native has
enablePreparedTextLayoutfor this, but its implementation is Android only.With this change.
ParagraphShadowNodeasks the text layout manager to lay the text out, on the thread it is laying out on, for the frame the text view will draw in. It does that where it creates the paragraph state for that content and size, which is already the code that knows both, and is exactly when the view redraws.RCTTextLayoutManagerkeeps laid-out text storages in a thread-safe LRU cache, keyed by what the paragraph draws and the frame it draws in, so the draw finds the one made for it and only paints.drawAttributedString:otherwise behaves as before: when nothing was prepared for it, it builds the layout itself, as it does today.The cached object is the layout: the
NSLayoutManagerattached to the text storage holds the line fragments and glyphs, and it is the same object the current code builds on the main thread for every draw. A hit changes nothing about what is drawn. Key equivalence and hashing are the ones prepared text layouts use on Android (areAttributedStringsEquivalentDisplayWise): everything drawing reads, including text attributes and attachment sizes, and nothing that only matters for hit testing, such as a fragment's event emitter. Two text views that draw the same content at the same size share one layout, so repeated content in a long list is laid out once.Preparing is a new optional method on the text layout manager, reached through
TextLayoutManagerExtendedlikemeasureLines, so platforms whose manager has no layout to prepare, such as the C++ one, compile it away.Why not
enablePreparedTextLayout. That flag changes how paragraphs are measured (rawContentSize(), exact constraints, prepared layouts carried inParagraphState), and its implementation lives behind Android's text pipeline. Making the iOSTextLayoutManagersatisfyTextLayoutManagerWithPreparedTextLayoutis a bigger change with a much wider blast radius, because it changes iOS measurement too. This change leaves measurement,ParagraphStateand the drawing code alone, so it can be evaluated and reverted on its own. Converging the two is a reasonable follow-up.Threading and cost. Layouts are built on the thread that lays out the shadow tree and are immutable once built, apart from the truncated-text color fix-up that a draw applies.
SimpleThreadSafeCache::getruns its generator under the cache's lock, so a preparation holds the lock for the length of one layout and waits on other preparations; draws only look entries up, so the main thread never waits on a layout and never blocks text views behind one another. Text is laid out at most twice, once for measurement and once for the first draw, the second on the JS thread rather than the main thread, at about a millisecond per paragraph in a commit. Text that is never displayed is still prepared, because at layout time React Native does not know which paragraphs will be drawn.Changelog
[IOS] [CHANGED] - Lay out
Textoff the main thread before its first drawTest Plan
node scripts/clang-format.jswith the pinned clang-format (21.1.2) on the changed files: passes.The new methods are added to
scripts/cxx-api/api-snapshots/{ReactApple,ReactAndroid,ReactCommon}FrameworksCxx.apiin the order the generator sorts them (the members of each block remain ASCII-sorted).python -m scripts.cxx-api.parserdoes not run on this Linux machine (its Doxygen XML parse comes back empty), so those files were edited by hand and should be regenerated before landing.Not built. This machine has no macOS, Xcode or iOS SDK, so none of this compiled here, not the ObjC++ and not the Fantom tester. A branch that has never compiled is the main risk in this PR: it needs a build and a run in RNTester on a device before it is ready.
Not measured. The numbers below come from the app that motivated the change, where the same approach was developed on top of 0.86.3, not from this branch.
Release build, iPhone 16 Pro Max, 30s fast fling through a feed, with and without the change:
drawAttributedString:per 30sWhat is left in
drawAttributedString:is painting glyphs, background and highlight rectangles, and the truncated-text color fix-up.Things to look at once it runs: truncated text (the fix-up that colors the "…" mutates the text storage, which is now shared, so a second draw must look the same), multi-line text, text with inline views, link press highlighting, and text at several widths in a
ScrollView.Known limitation: with
enableIOSCompressedTextFrameAdjustmenton, a paragraph that sets a line height is drawn in an adjusted frame, so that paragraph is laid out twice and the prepared layout is not the one drawn. Both flags are off by default and independent; making them work together is a follow-up.Follow-up: the truncated-text color fix-up could move into the preparation, which would make prepared layouts immutable and take another ~35 ms per 30s of scrolling off the main thread. It is left out to keep this change small.