Repository navigation
Conversation
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
force-pushed
the
ios-textkit2
branch
from
October 20, 2026 17:07
c3d02da to
c2c5f3a
Compare
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.
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):RCTTextLayoutManagerbuilds 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.UITextViewalready 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 queryRCTTextLayoutManagerused to ask TextKit 1, with TextKit 1 semantics, and caches its line snapshot until the text storage is edited.RCTTextLayoutManager:init, so measuring and drawing always use the same engine.initWithTextKit2Enabled:andusesTextKit2for tests.NSTextStorage+FontScaling: theadjustsFontSizeToFitbinary search now takes a measure block, so it no longer depends onNSLayoutManager. The existing method keeps its TextKit 1 behaviour.enableIOSTextKit2(ossReleaseStage: 'none'), with regenerated accessors and C++ API snapshots.-RCTTextKit2Enabled YES|NOlaunch argument switches engines without a rebuild.React/Tests/Text/RCTTextLayoutManagerTextKit2Tests.mmruns both engines throughRCTTextLayoutManagerand 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,
RCTTextKit2Layoutemulates TextKit 1 so that existing apps keep their rendering:NSTextAlignmentNaturalis resolved for right-to-left paragraphs the way TextKit 1 resolves it.\ngets the metrics of the line break's own line. TextKit 2 uses Helvetica 12 there.adjustsFontSizeToFitdepend on it.onTextLayoutbaselines: they exclude the baseline offset React Native applies forlineHeight.numberOfLines={1}withellipsizeMode="clip"behaves as before.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.
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"withnumberOfLines={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.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.adjustsFontSizeToFitwith 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.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.onTextLayoutbaselines: 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.onTextLayoutxandwidthare off by one space.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
RCTTextLayoutManagerwith each engine, in Release builds:numberOfLines, nested bold spans andlineHeight, at widths from 120 to 360pt.measureNSAttributedString:on prebuilt attributed strings, the call layout makes.drawAttributedString:at the measured size into a 3× bitmap, asRCTParagraphComponentViewdoes.RCTTextKit2Layout(line snapshot and truncation detection) costs about 9 µs per string.TextMeasureCacheabsorbs repeated measurements with either engine.Open questions
1. Justified text.
shouldBreakLineBeforeLocationis never consulted.<TextInput>already breaks justified text this way.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:
textAlignto 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.
textAlign3. 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/Testsruns only through theReact-RCTFabrictest_spec, so OSS CI doesn't runRCTTextLayoutManagerTextKit2Tests.RNTesterUnitTests, or enable the test spec.5. RNTester toggle. Should the
-RCTTextKit2Enabledlaunch argument inSceneDelegate.mmstay? It makes A/B checks easy, but it is RNTester-only code.Changelog:
[IOS] [ADDED] - Add
enableIOSTextKit2feature flag to lay out Text with TextKit 2Test 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.
RCTTextLayoutManagerTextKit2Testshas 6 tests covering 23 cases. They run both engines throughRCTTextLayoutManagerand check measurement, attachments,onTextLayoutlines and pixel-exact drawing, plus two tests that pin intentional differences.React/Tests(see open question 4), so I added the file toRNTesterUnitTestslocally. That change is not part of this PR.xcodebuild test -workspace packages/rn-tester/RNTesterPods.xcworkspace -scheme RNTester -destination 'id=<simulator>' -only-testing:RNTesterUnitTests/RCTTextLayoutManagerTextKit2Testsmain.xcodebuild test … -scheme RNTester), before the rebase: 178 tests on each OS version, 162 passed, 16 skipped, 0 failed. After the rebase I re-ranRCTTextLayoutManagerTextKit2Tests(6 tests) andRCTFontTests(11 tests) on all three versions: 17/17 pass.RCTTextKit2Layout.mmand compares 51 cases between the two engines.lineHeight, newlines, every truncation mode, attachments, emoji, CJK, Arabic, Hebrew, Thai, Hindi, kerning and unbounded width.-RCTTextKit2Enabled NOandYESand compared the Text examples.onTextLayoutoverlay, link accessibility frames,numberOfLineswith an inline image, and typing in multilineTextInput.yarn featureflags, thennode ./scripts/featureflags/index.js --verify-unchangedfrompackages/react-native. Passes.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 onmain.yarn flow-check,yarn linton the changed files,yarn build-types --validateandyarn test-generated-typescriptpass.node scripts/clang-format.js --checkon the changed Objective-C++ files, andyarn format-check-kotlinfor the regenerated flag accessors. Both pass.yarn test-androidreportsBUILD SUCCESSFUL, with 763 tests.yarn fantomon the TextInput,RCTTextInputViewConfigandInputAccessoryViewintegration tests: 39/39 pass.scripts/cxx-api/api-snapshotswith Doxygen 1.16.1, the version CI pins. The diff is the 7 new API lines.xcodebuild -configuration Release, once for the iPhone and once for the simulator.Not covered:
TextInput.🤖 Generated with Claude Code