Skip to content

Cache resolved custom fonts on iOS Fabric - #59153

Open
hryhoriiK97 wants to merge 1 commit into
react:mainfrom
hryhoriiK97:ios-fabric-font-cache
Open

hryhoriiK97 wants to merge 1 commit into
react:mainfrom
hryhoriiK97:ios-fabric-font-cache

Conversation

@hryhoriiK97

@hryhoriiK97 hryhoriiK97 commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Summary:

RCTFontWithFontProperties (ReactCommon/react/renderer/textlayoutmanager/platform/ios/.../RCTFontUtils.mm) resolves the UIFont for every text fragment when a paragraph's NSAttributedString is built. The system font is cached, but a custom fontFamily is not.

Current behavior. For a custom family, every call enumerates the font database with +[UIFont fontNamesForFamilyName:], then instantiates every face in the family and reads its traits and weight to pick the closest one. That runs once per fragment per attributed-string build, so a list of 1000 labels in a custom font resolves the same font 1000 times on the layout thread, and again on the main thread whenever the attributed-string cache (256 entries) has evicted the paragraph before it is drawn.

With this change. The resolved font is cached per family, effective size, weight and style, in an NSCache (thread-safe, so the layout and main threads share it) that is cleared on kCTFontManagerRegisteredFontsChangedNotification, so a font registered at runtime does not stay cached as unknown. The lookup is an extra else if arm ahead of the existing resolution code, which is unchanged; a miss runs it and stores the result, including the fallback to the system font for an unknown family. Font variants and variation settings are applied after the lookup, as before, so they are not part of the key. The legacy React/Views/RCTFont.mm caches its lookups the same way.

Why. Custom fonts are the common case in production apps, and this cost scales with the number of text fragments on screen rather than with the number of distinct fonts, which is usually a handful.

Changelog:

[IOS] [CHANGED] - Cache custom font family resolution for Fabric text, so a font family is resolved once per family, size, weight and style instead of once per text fragment

Test Plan:

  • node scripts/clang-format.js --check on the changed file passes.
  • RNTester built with xcodebuild in Release for a physical iPhone 15 Pro; the Text examples render as before.
  • Screenshots of a 1000-label list and of a set of edge cases (right-aligned text with an inline view, centered multi-line text, empty text with an explicit size, truncation across nested colours, RTL with an inline view, clipped fixed height, two-line tail ellipsis) are byte-identical with and without this change.
  • Measurement. RNTester Playground mounting 1000 unique single-line and wrapping labels in one commit, timed from Fabric's transaction telemetry (TransactionTelemetry), a timer around the attributed-string conversion inside measurement (where fonts are resolved), a run-loop observer after the CoreAnimation commit (mount end to display) and a timer around -[RCTParagraphTextView drawRect:]. Two builds of the same tree differing only in this file. The button presses were driven by Argent on the physical iPhone, so both builds ran the identical sequence (5 mounts and unmounts per font). iPhone 15 Pro, Release, medians of 5 mounts:
1000 labels, fontFamily: 'Avenir', fontWeight: '500' before this PR change
layout (shadow thread, incl. text measurement) 54.1 ms 35.5 ms −34%
text measurement alone 49.4 ms 31.4 ms −36%
of which attributed-string conversion (where fonts are resolved) 25.9 ms 7.6 ms −71%
drawRect total (main thread) 57.1 ms 45.5 ms −20%
mount end to display (main thread) 115.2 ms 96.8 ms −16%

The same scenario with the system font is unchanged within noise (47.6 ms and 45.7 ms layout), as expected since the system font was already cached. The draw-side gain comes from paragraphs whose attributed string had been evicted from the 256-entry cache before drawing; a list with fewer than 256 distinct strings would see only the layout-side gain.

  • Memory. The cache has one entry per distinct family, effective size, weight and style, so it grows with the fonts an app uses, not with the amount of text. Each entry is about 0.7 KB: a 48-byte key and a 640-byte UIFont object, measured with malloc_size. iPhone 15 Pro, Release, one binary with the cache switched on and off by a launch argument (not part of this PR), 3 launches with the cache and 2 without:
Scenario Cache entries Cache memory
1000 labels, one custom font (the scenario above) 1 < 1 KB
1000 labels, 1000 different font sizes, mounted and unmounted 4 times 1,000 ~0.7 MB
5000 labels, each a different family, weight, style and size 5,875 ~4 MB

In the first scenario the app's total memory (phys_footprint) is the same with and without the cache (106 and 108 MB with the labels mounted). In the other two, the difference is within a few MB, close to the run-to-run variation of the same build. Repeating the 1000 sizes reused the existing entries instead of adding new ones. NSCache evicts entries under memory pressure; none were evicted in these runs.

🤖 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 16, 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 16, 2026
@hryhoriiK97
hryhoriiK97 force-pushed the ios-fabric-font-cache branch from 8f3f830 to 5cc89ee Compare October 17, 2026 11:57
@hryhoriiK97 hryhoriiK97 changed the title Cache custom font family resolution on iOS Fabric Cache resolved custom fonts on iOS Fabric Oct 17, 2026
Resolving a custom `fontFamily` in `RCTFontWithFontProperties` enumerates the
family's faces and instantiates every one of them to pick the closest weight
and style, and it ran once per text fragment per attributed string build.
Cache the resolved font per family, size, weight and style, cleared when
fonts are registered at runtime, as the legacy `RCTFont.mm` already does for
its own lookups. Variants and variation settings are applied after the cache
as before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@hryhoriiK97
hryhoriiK97 force-pushed the ios-fabric-font-cache branch from 5cc89ee to f9c4305 Compare October 17, 2026 11:58

@cipolleschi cipolleschi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for improving this.

@meta-codesync

meta-codesync Bot commented Oct 20, 2026

Copy link
Copy Markdown

@cipolleschi has imported this pull request. If you are a Meta employee, you can view this in D126316025.

@cipolleschi

Copy link
Copy Markdown
Contributor

@hryhoriiK97: @javache asked what's the size of the cache and what's the impact on memory. Can you help running some tests and attach these information to the Test Plan or Summary, please?

@hryhoriiK97

Copy link
Copy Markdown
Contributor Author

@hryhoriiK97: @javache asked what's the size of the cache and what's the impact on memory. Can you help running some tests and attach these information to the Test Plan or Summary, please?

@cipolleschi @javache sure! I tested it on an iPhone 15 Pro (Release build), with the cache turned on and off:

Scenario Cache entries Cache memory
1000 labels, one custom font (this PR's case) 1 < 1 KB
1000 labels, 1000 different font sizes 1,000 ~0.7 MB
5000 labels, each a different family, weight, style and size 5,875 ~4 MB

The cache keeps one font for each family, size, weight and style the app uses. Each one takes about 0.7 KB, so the cache grows with the number of different fonts, not with the amount of text. In the first case, the app uses the same amount of memory with or without the cache. There's no fixed limit, but NSCache removes entries when the system is low on memory (it didn't remove any in my tests). I can add a countLimit if you want a hard limit.

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