Skip to content

Create one JNI global reference per prepared text layout on Android - #59592

Open
hryhoriiK97 wants to merge 1 commit into
react:mainfrom
hryhoriiK97:android-text-prepared-layout-shared-ref
Open

hryhoriiK97 wants to merge 1 commit into
react:mainfrom
hryhoriiK97:android-text-prepared-layout-shared-ref

Conversation

@hryhoriiK97

@hryhoriiK97 hryhoriiK97 commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Summary:

On Android, every text layout created several JNI global references, and creating one is not free: ART adds it to its global reference table under a lock, and with references being created and deleted all the time this showed up as 10% of the JS thread on a screen of 200 updating numbers.

Where they came from, per text layout:

  • TextLayoutManager read FabricUIManager from the context container in prepareLayout and again in measurePreparedLayout (and in the other measure paths). ContextContainer::at returns the stored jni::global_ref by value, so each read creates a new global reference and deletes it at the end of the scope.
  • PreparedTextLayout was itself a SafeReleaseJniRef<jni::global_ref<JPreparedLayout>>, so every copy creates a global reference: the cache keeps one, prepareLayout returns a copy of the cached entry, and ParagraphShadowNode copies the measured layout into ParagraphState.

With this change:

  • PreparedTextLayout keeps its SafeReleaseJniRef in a std::shared_ptr, so copies share one global reference. It keeps get() and operator bool, which is what its users call, so ParagraphShadowNode, ParagraphState and the measure code are unchanged. The Java PreparedLayout is treated as immutable (new react tags produce a new one through reusePreparedLayoutWithNewReactTags), so sharing one reference between the owners is the same as each holding its own; the last owner releases it, still through SafeReleaseJniRef so it can happen on any thread.
  • TextLayoutManager reads FabricUIManager from the context container once, in its constructor, and every JNI call it makes uses that reference. The context container owns the TextLayoutManager (or the component descriptor that created it), so the reference can't outlive the FabricUIManager it points to.

A new prepared layout now creates one global reference, as does a cached layout reused with new react tags; reusing a cached layout as is, or copying a layout into state, creates none. Besides the time, this keeps fewer global references alive (about one per distinct layout instead of one per text node per owner), further from ART's global reference limit on screens with a lot of text.

TextLayoutManager::PreparedTextLayout changes from an alias to a class in the Android C++ API snapshot.

Changelog:

[ANDROID] [CHANGED] - Create one JNI global reference per prepared text layout, instead of several for every text layout

Test Plan:

  • node scripts/clang-format.js --check reports no changes for the touched files.
  • The C++ API snapshot ReactAndroidFrameworksCxx.api was regenerated with scripts/cxx-api.
  • ./gradlew :packages:rn-tester:android:app:assembleRelease -PreactNativeArchitectures=arm64-v8a succeeds with this change (in the checkout described below).
  • On a device. Two RNTester release APKs built from the same local checkout (with local benchmark screens and a counter of prepareTextLayout calls; TextLayoutManager identical to main at 92629fd), without and with this change, installed alternately on a moto g57 power (Android 16). Playground screens where texts change every 100 ms; JS and main thread CPU time from /proc over 20 s, divided by the number of text layouts:
before this PR change
200 single-style numbers, JS time per text layout 241 / 232 / 207 / 211 / 227 / 232 µs 187 / 187 / 190 / 203 / 187 / 186 µs −16%
60 single-style rows, JS time per text layout 456 / 461 / 460 µs 440 / 444 / 443 µs −4%
60 rich text rows, JS time per text layout 1053 / 982 / 954 µs 973 / 981 / 952 µs −3%, within noise
60 rows remounted every 100 ms, JS thread over 20 s (layouts reused with new react tags) 3.82 s avg 3.66 s avg −4%

The main thread time per update is unchanged in all of them. The 200-number and remount screens ran six rounds and the other two three: the first three rounds used a build that looked FabricUIManager up lazily with std::call_once (an earlier version of this change), the last three the final code, with matching results.

In a simpleperf profile of the 200-number screen, art::JNI::NewGlobalRef went from 10.3% to 2.3% of the JS thread.

  • Text renders the same: a screen with static rich text (interpolations, nested bold, colored and underlined <Text onPress>, wrapping) is pixel-identical between the two builds, and tapping its link fires onPress with both.

🤖 Generated with Claude Code

Every text layout created several JNI global references: TextLayoutManager
read FabricUIManager from the context container, which copies its global
reference, in prepareLayout, measurePreparedLayout and the other measure
paths, and PreparedTextLayout was itself a global reference, so returning a
cached layout and storing it in a node's state each created another one.

PreparedTextLayout now holds its reference in a shared_ptr, so copies share
it, and TextLayoutManager looks FabricUIManager up once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@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 28, 2026
@facebook-github-tools facebook-github-tools Bot added the Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. label Oct 28, 2026
@javache

javache commented Oct 28, 2026

Copy link
Copy Markdown
Contributor

Great find! Let's split the FabricUIManager off from the PreparedTextLayout changes here. I've got some ideas around the FabricUIManager one (it should really be an alias_ref being returned from the context container)

@github-actions

Copy link
Copy Markdown

Visual regression

3 visual difference(s) at c6d6fd048570. Visual differences need maintainer approval. Review the report, then apply visual-change-approved.

ios button/default-styling/default: Changed, 38223 px (1.533%)

diff

Expected and Captured
Expected Captured
expected captured

ios scroll-view/scroll-to/end: Changed, 273004 px (37.729%)

diff

Expected and Captured
Expected Captured
expected captured

ios scroll-view/scroll-to/start: Changed, 280136 px (38.714%)

diff

Expected and Captured
Expected Captured
expected captured

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. Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants