Skip to content

[iOS] Migrate Text layout from TextKit 1 to TextKit 2 - #59236

Draft
tomekzaw wants to merge 1 commit into
react:mainfrom
tomekzaw:ios-textkit2
Draft

tomekzaw wants to merge 1 commit into
react:mainfrom
tomekzaw:ios-textkit2

Conversation

@tomekzaw

@tomekzaw tomekzaw commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Note

This idea was originally proposed by @szydlovsky, co-author of react-native-enriched-html.

Summary:

React Native on iOS lays out <Text> with TextKit 1 (NSLayoutManager): RCTTextLayoutManager builds a TextKit 1 stack for every measurement, draw, hit test and accessibility query. TextKit 2 (NSTextLayoutManager) is the engine UIKit has used by default since iOS 16. UITextView already uses it, so multiline <TextInput> renders with TextKit 2 today while Fabric measures it with TextKit 1.

This PR adds a TextKit 2 implementation of every text layout query behind a new feature flag, enableIOSTextKit2. The flag is off by default, and below iOS 16 it falls back to TextKit 1. With the flag off, the TextKit 1 code paths are unchanged.

Changes

  • RCTTextKit2Layout (new): a TextKit 2 stack (NSTextContentStorage → NSTextLayoutManager → NSTextContainer). It answers each query RCTTextLayoutManager used to ask TextKit 1, with TextKit 1 semantics, and caches its line snapshot until the text storage is edited.
  • RCTTextLayoutManager:
    • Reads the flag once in init, so measuring and drawing always use the same engine.
    • Each public method branches to a TextKit 2 implementation.
    • The custom-decoration painter, drawing-frame math, ellipsis-colour fix (【iOS】Fixes ellipsis carries background from trimmed text #39408) and font-size bounds are now helpers that both engines share. The TextKit 1 formulas are unchanged.
    • Adds initWithTextKit2Enabled: and usesTextKit2 for tests.
  • NSTextStorage+FontScaling: the adjustsFontSizeToFit binary search now takes a measure block, so it no longer depends on NSLayoutManager. The existing method keeps its TextKit 1 behaviour.
  • Feature flag: enableIOSTextKit2 (ossReleaseStage: 'none'), with regenerated accessors and C++ API snapshots.
  • RNTester: the -RCTTextKit2Enabled YES|NO launch argument switches engines without a rebuild.
  • Tests: React/Tests/Text/RCTTextLayoutManagerTextKit2Tests.mm runs both engines through RCTTextLayoutManager and compares them.

To enable it in an app:

class AppFeatureFlags : public facebook::react::ReactNativeFeatureFlagsOverridesOSSStable {
 public:
  bool enableIOSTextKit2() override { return true; }
};

// After creating RCTReactNativeFactory, before starting React Native:
facebook::react::ReactNativeFeatureFlags::dangerouslyForceOverride(std::make_unique<AppFeatureFlags>());

Behaviour emulated to match TextKit 1

TextKit 2 lays out plain text the same way as TextKit 1: same line breaks, line heights and pixels. Where the engines differ, RCTTextKit2Layout emulates TextKit 1 so that existing apps keep their rendering:

  • Natural alignment: NSTextAlignmentNatural is resolved for right-to-left paragraphs the way TextKit 1 resolves it.
  • Trailing line breaks: the empty line after a trailing \n gets the metrics of the line break's own line. TextKit 2 uses Helvetica 12 there.
  • Truncation: the truncated range is detected from elided characters. TextKit 2 has no API for it, and the ellipsis colour fix, attachment clipping and adjustsFontSizeToFit depend on it.
  • Last line: when layout stops early, the last line is extended to the end of its paragraph.
  • Inline backgrounds: painted over TextKit 1 enclosing rects, aligned to device pixels.
  • Highlight and accessibility rects, and taps on the ellipsis: these follow TextKit 1 rules near the ellipsis (left-to-right lines).
  • Hit testing: points above, below or past the end of the text, and on line breaks, resolve the way TextKit 1 resolves them.
  • onTextLayout baselines: they exclude the baseline offset React Native applies for lineHeight.
  • Tall scripts: line heights for Thai and similar scripts run to the next line's origin.
  • Single-line clip: numberOfLines={1} with ellipsizeMode="clip" behaves as before.
  • Unbounded width: the container width is clamped to 10,000,000pt, as TextKit 1 does.
  • iOS 16–18: line bounds are clamped to the container.
  • Glyph overflow with a compressed lineHeight: computed from font bounding boxes.

Known differences with the flag on

None of these breaks consistency between measurement and drawing, because both use TextKit 2. All screenshots are RNTester on an iPhone 17 Pro simulator running iOS 26.5.

Area TextKit 1 (flag off) TextKit 2 (flag on)
textAlign: 'justify': TextKit 2 picks different line breaks for justified paragraphs (here the 300pt-wide one fits "are" on the first line). See open question 1.
ellipsizeMode="clip" with numberOfLines={2}. TextKit 1 puts the rest of the text on the last line and cuts it at the edge. TextKit 2 wraps normally and drops what doesn't fit, as Android does.
Inline view hidden by truncation (numberOfLines={2}, a 36pt-tall view at the end). TextKit 1 makes the last line tall enough for the view it then hides. TextKit 2 doesn't.
adjustsFontSizeToFit with right-to-left text. TextKit 1 misses the truncation of right-to-left lines and stops shrinking with the end of the text still cut off (the last word is missing). TextKit 2 shrinks until all of the text fits.
Native underline and strikethrough are drawn about one device pixel apart. Shown at 3× zoom. React Native-painted dashed, dotted and wavy decorations are unchanged.

Differences that don't change pixels:

  • letterSpacing, bidi run boundaries and justified text: hit-test boundaries and accessibility rects shift by up to half the letter spacing or justification gap.
  • onTextLayout baselines: for lines that hold only a line break, TextKit 1 reported the bottom of the line and TextKit 2 reports the real baseline. For Arabic, TextKit 1 reported the first glyph's shaped offset.
  • Right-to-left lines on iOS 16–18: trailing whitespace is counted on the other side of the line, so onTextLayout x and width are off by one space.
  • Head and middle truncation: rects and taps on elided characters are not emulated.
  • Colour emoji: sub-pixel rasterisation differences.

Performance

On an iPhone 14 Pro running iOS 27.2, TextKit 2 is faster than TextKit 1: measuring takes 0.71× as long and drawing 0.74×.

The benchmark goes through RCTTextLayoutManager with each engine, in Release builds:

  • Corpus: 200 strings, a mix of short labels, wrapping sentences, long paragraphs, text truncated with numberOfLines, nested bold spans and lineHeight, at widths from 120 to 360pt.
  • measure: measureNSAttributedString: on prebuilt attributed strings, the call layout makes.
  • draw: drawAttributedString: at the measured size into a 3× bitmap, as RCTParagraphComponentView does.
  • Timing: median µs per string over 9 rounds, after 2 warm-up rounds. Each round alternates which engine runs first.
Device measure TK1 measure TK2 TK2 / TK1 draw TK1 draw TK2 TK2 / TK1
iPhone 14 Pro, iOS 27.2 181.7 129.6 0.71 334.3 248.5 0.74
Simulator, iOS 18.6 54.5 60.1 1.10 112.0 119.8 1.07
Simulator, iOS 26.5 80.0 101.7 1.27 191.4 189.7 0.99
Simulator, iOS 27.0 112.5 143.2 1.27 254.7 289.9 1.14
  • Reproducibility: a second run on the iPhone matched the first to within 0.3 µs.
  • Simulators don't predict devices. On iOS 27 the simulator shows TextKit 2 about 1.27× slower, while the phone shows it about 1.4× faster. The simulators ran on an Apple silicon Mac.
  • Where the time goes on the phone:
    • TextKit 1 spends almost all of its time creating and connecting its layout objects: 162 µs of 182 µs per string.
    • TextKit 2 spends most of its time laying out (107 µs).
    • The TextKit 1-compatibility work in RCTTextKit2Layout (line snapshot and truncation detection) costs about 9 µs per string.
  • Not measured: devices on iOS 16–18. The iOS 18.6 simulator shows TextKit 2 about 1.1× slower.
  • Caching: TextMeasureCache absorbs repeated measurements with either engine.

Open questions

1. Justified text.

  • TextKit 2 chooses line breaks for justified paragraphs with inter-word compression. TextKit 1 breaks greedily, the same way as left-aligned text.
  • No paragraph-style option or delegate restores greedy breaking: shouldBreakLineBeforeLocation is never consulted.
  • In a sweep of widths, breaks differed at 152 of 251 widths at 14pt and 171 of 251 at 16pt. The line count differed at about 1 width in 10.
  • Multiline <TextInput> already breaks justified text this way.
  • Options: accept TextKit 2's line breaking, or keep TextKit 1 for justified paragraphs only.
Text, 16pt, 300pt wide TextKit 1 (flag off) TextKit 2 (flag on)
textAlign: 'justify'

The top paragraph (14pt, full width) breaks the same way in both engines. The bottom one (16pt, 300pt wide) fits "are" on the first line with TextKit 2.

2. Which TextKit 1 emulations to keep long term. Two of the emulations replace TextKit 2 behaviour that is arguably better, so they could be dropped once matching TextKit 1 is no longer the goal:

  • Inline backgrounds: TextKit 2 on its own paints only behind the glyphs, which is closer to how browsers paint inline backgrounds.
  • Natural alignment: in a left-to-right app, TextKit 1 aligns Arabic and Hebrew paragraphs with no textAlign to the left. TextKit 2 on its own aligns them to the right, following each paragraph's direction.

The "TextKit 2 without emulation" column comes from a temporary local patch that is not part of this PR.

Case TextKit 1 (flag off) TextKit 2 (flag on, this PR) TextKit 2 without emulation
Background on a run that wraps
Arabic and Hebrew, no textAlign

3. Performance on older devices. On iOS 27.2 hardware, TextKit 2 is faster (see Performance), but I couldn't benchmark a device on iOS 16–18. If someone can run it on older hardware and it shows a regression, the next step would be to reuse TextKit 2 objects between measuring and drawing.

4. Where the new XCTest should live.

  • React/Tests runs only through the React-RCTFabric test_spec, so OSS CI doesn't run RCTTextLayoutManagerTextKit2Tests.
  • Options: move it to RNTesterUnitTests, or enable the test spec.

5. RNTester toggle. Should the -RCTTextKit2Enabled launch argument in SceneDelegate.mm stay? It makes A/B checks easy, but it is RNTester-only code.

Changelog:

[IOS] [ADDED] - Add enableIOSTextKit2 feature flag to lay out Text with TextKit 2

Test Plan:

All runs used Xcode 27.1 with the iOS 18.6, 26.5 and 27.0 simulators, plus an iPhone 14 Pro on iOS 27.2 for the benchmark.

  • New XCTest: RCTTextLayoutManagerTextKit2Tests has 6 tests covering 23 cases. They run both engines through RCTTextLayoutManager and check measurement, attachments, onTextLayout lines and pixel-exact drawing, plus two tests that pin intentional differences.
    • OSS CI doesn't build React/Tests (see open question 4), so I added the file to RNTesterUnitTests locally. That change is not part of this PR.
    • Command: xcodebuild test -workspace packages/rn-tester/RNTesterPods.xcworkspace -scheme RNTester -destination 'id=<simulator>' -only-testing:RNTesterUnitTests/RCTTextLayoutManagerTextKit2Tests
    • Result: 6/6 pass on iOS 18.6, 26.5 and 27.0, before and after rebasing on main.
  • Full RNTester test scheme (xcodebuild test … -scheme RNTester), before the rebase: 178 tests on each OS version, 162 passed, 16 skipped, 0 failed. After the rebase I re-ran RCTTextLayoutManagerTextKit2Tests (6 tests) and RCTFontTests (11 tests) on all three versions: 17/17 pass.
  • Engine comparison harness (local tool, not in this PR): it compiles the production RCTTextKit2Layout.mm and compares 51 cases between the two engines.
    • It covers wrapping, alignment, lineHeight, newlines, every truncation mode, attachments, emoji, CJK, Arabic, Hebrew, Thai, Hindi, kerning and unbounded width.
    • For each case it compares sizes, lines, attachment frames, truncation, word rects, a hit-test grid, font scaling and pixels.
    • Results: identical in 36 cases on iOS 26.5 and 27.0, and in 32 on iOS 18.6. Every remaining case is one of the known differences above.
  • RNTester:
    • Launched with -RCTTextKit2Enabled NO and YES and compared the Text examples.
    • Spot-checked the onTextLayout overlay, link accessibility frames, numberOfLines with an inline image, and typing in multiline TextInput.
    • The screenshots above are from a Playground screen built for this comparison (not committed).
  • Feature flags: yarn featureflags, then node ./scripts/featureflags/index.js --verify-unchanged from packages/react-native. Passes.
  • JavaScript:
    • yarn test: 6,184 passed, 1 skipped. One suite failed when a Jest worker crashed with SIGSEGV under load; it passes when run on its own, and the same happens on main.
    • yarn flow-check, yarn lint on the changed files, yarn build-types --validate and yarn test-generated-typescript pass.
  • Formatting: node scripts/clang-format.js --check on the changed Objective-C++ files, and yarn format-check-kotlin for the regenerated flag accessors. Both pass.
  • Android (regenerated flag accessors): yarn test-android reports BUILD SUCCESSFUL, with 763 tests.
  • Fantom: yarn fantom on the TextInput, RCTTextInputViewConfig and InputAccessoryView integration tests: 39/39 pass.
  • C++ API snapshots: regenerated the Apple snapshots under scripts/cxx-api/api-snapshots with Doxygen 1.16.1, the version CI pins. The diff is the 7 new API lines.
  • Benchmark (see Performance): a temporary RNTester screen, not in this PR, started with a launch argument.
    • Built in Release with xcodebuild -configuration Release, once for the iPhone and once for the simulator.
    • Run on the iPhone 14 Pro (twice) and on the iOS 18.6, 26.5 and 27.0 simulators, one at a time.

Not covered:

  • iOS 16 and 17: Xcode 27.1 on macOS 27 can't run iOS 16 simulators, and the iOS 17.5 runtime needs a manual download.
  • Physical devices, except the iPhone 14 Pro on iOS 27.2, which was used only for the benchmark.
  • Mac Catalyst, tvOS and visionOS builds, and the internal BUCK build.
  • A VoiceOver walkthrough, the copy menu on selectable text, and auto-growing multiline TextInput.

🤖 Generated with Claude Code

@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 20, 2026
@facebook-github-tools facebook-github-tools Bot added p: Software Mansion Partner: Software Mansion Partner labels Oct 20, 2026
Adds a TextKit 2 (NSTextLayoutManager) implementation of every text layout
query RCTTextLayoutManager makes (measurement, drawing, onTextLayout lines,
hit testing, accessibility rects, adjustsFontSizeToFit), behind the new
enableIOSTextKit2 feature flag (off by default, iOS 16+). TextKit 1 stays
the default and its code paths are unchanged.

RCTTextKit2Layout emulates TextKit 1 where the engines differ, so text
renders the same with the flag on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@tomekzaw tomekzaw changed the title Add TextKit 2 text layout on iOS behind enableIOSTextKit2 [iOS] Migrate Text layout from TextKit 1 to TextKit 2 (behind enableIOSTextKit2) Oct 20, 2026
@tomekzaw tomekzaw changed the title [iOS] Migrate Text layout from TextKit 1 to TextKit 2 (behind enableIOSTextKit2) [iOS] Migrate Text layout from TextKit 1 to TextKit 2 Oct 20, 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. p: Software Mansion Partner: Software Mansion Partner

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant