Skip to content

Lay out iOS text off the main thread before its first draw - #59288

Draft
haileyok wants to merge 1 commit into
react:mainfrom
haileyok:hailey/ios-text-prepared-layout
Draft

haileyok wants to merge 1 commit into
react:mainfrom
haileyok:hailey/ios-text-prepared-layout

Conversation

@haileyok

Copy link
Copy Markdown
Contributor

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.

## 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.
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Oct 21, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant