Skip to content

fix(text): resolve app-bundled font families by passing a DirectWrite font collection - #16339

Open
Collin Schneide (FaithfulAudio) wants to merge 3 commits into
microsoft:mainfrom
FacilitronWorks:fix/app-bundled-font-collection
Open

fix(text): resolve app-bundled font families by passing a DirectWrite font collection#16339
Collin Schneide (FaithfulAudio) wants to merge 3 commits into
microsoft:mainfrom
FacilitronWorks:fix/app-bundled-font-collection

Conversation

@FaithfulAudio

@FaithfulAudio Collin Schneide (FaithfulAudio) commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Resolve app-bundled fonts in Fabric text layout

Fixes #16306. Fixes #16308. (Both issues turn out to have this single root cause — see below; both of
their filed diagnoses were wrong, and I am the person who filed them.)

Problem

On the new architecture, no app-bundled font family ever resolves. fontFamily: 'Ionicons',
'Material Icons', 'FontAwesome' and friends all fall back to Segoe UI and render glyph 0
(.notdef), whether the app ships the TTF in Assets\Fonts\ or registers it through the
windows.sharedFonts package-manifest extension. Only fonts installed system-wide work.

Root cause

WindowsTextLayoutManager::GetTextLayout passes nullptr as the font collection to
CreateTextFormat (WindowsTextLayoutManager.cpp L119):

nullptr, // Font collection (nullptr sets it to use the system font collection).

nullptr means "resolve against the system font collection only". An app's own font files are not in
it, so the family is never found, DirectWrite falls back, and every icon codepoint maps to glyph 0.

That is the whole bug. It also explains why the two issues I filed looked like two different bugs and
were both misdiagnosed:

Fix

Add Microsoft::ReactNative::DWriteAppFontCollection() to DWriteHelpers: on first use, build a
DirectWrite collection from the system font set merged with every *.ttf / *.otf under the app
directory's Assets\ and Assets\Fonts\, and pass it at the CreateTextFormat call site instead of
nullptr.

Properties that matter for review:

  • Fails closed. Returns nullptr on any failure and when the app bundles no fonts, and
    nullptr is exactly the current behaviour — so an app that ships no fonts, or a machine where the
    collection cannot be built, behaves precisely as it does today.
  • Built once, via a function-local static const (thread-safe magic static). Bundled assets
    cannot change during the process lifetime.
  • System families keep working, because the system font set is added to the builder first.
  • Per-file failures are skipped, so one malformed font cannot break font resolution for the app.
  • Only the one CreateTextFormat call needed changing. The per-fragment
    SetFontFamilyName at L274 resolves against the collection the layout already inherited from the
    text format, so it picks the app collection up for free.

Validation

What was actually measured. I wrote a standalone DirectWrite probe that replays this exact call
sequence — CreateTextFormat (including the empty localeName the real code passes) →
CreateTextLayoutDraw with a recording IDWriteTextRenderer that reports the resolved face and
glyph indices — against the real stock TTFs. Built with VS 2022 (x64) and run on Windows 11
10.0.26200. Probe sources and raw output are attachable; here is the substance.

Every stock react-native-vector-icons@10.3.0 font, unmodified:

file Analyze supported AddFontFile real family name (name table) codepoint drawn with a collection containing it with nullptr (today)
MaterialIcons.ttf 1 hr=0 accepted Material Icons U+E000 glyph 40 Segoe UI, glyph 0
MaterialCommunityIcons.ttf 1 hr=0 accepted Material Design Icons U+F0001 glyph 1 Segoe UI, glyph 0
Ionicons.ttf 1 hr=0 accepted Ionicons U+EA01 glyph 1 Segoe UI, glyph 0
FontAwesome.ttf 1 hr=0 accepted FontAwesome U+F000 glyph 13 Segoe UI, glyph 0
Feather.ttf 1 hr=0 accepted Feather U+F100 glyph 4 Segoe UI, glyph 0
Octicons.ttf 1 hr=0 accepted Octicons U+F10B glyph 4 Segoe UI, glyph 0
EvilIcons.ttf 1 hr=0 accepted EvilIcons U+F100 glyph 4 Segoe UI, glyph 0
AntDesign.ttf 1 hr=0 accepted anticon U+E600 glyph 2 Segoe UI, glyph 0

The codepoint in each row is taken from the face's own IDWriteFontFace1::GetUnicodeRanges, so it is
guaranteed to be one the font claims to map.

Family-string variants, holding the collection and the font constant and changing only the requested
string (MaterialIcons.ttf, drawing U+E7FD):

requested fontFamily FindFamilyName draw result
Material Icons exists Material Icons, glyph 782
material icons exists Material Icons, glyph 782
MATERIAL ICONS exists Material Icons, glyph 782
MaterialIcons not found Segoe UI, glyph 0
" Material Icons" (leading space) not found Segoe UI, glyph 0
"Material Icons " (trailing space) not found Segoe UI, glyph 0
Material Icons (doubled interior space) not found Segoe UI, glyph 0

Interior spaces are fine; case does not matter; leading/trailing/duplicated whitespace is not
normalised
by DirectWrite and RNW does not trim the string either. Worth knowing, but not fixed
here — a separate, arguable question.

The API sequence this patch uses (IDWriteFactory5::CreateFontSetBuilderGetSystemFontSet
AddFontSetAddFontFileCreateFontSetCreateFontCollectionFromFontSet → query for
IDWriteFontCollection) is the same sequence the probe compiled and ran successfully, so the API
usage is exercised rather than merely plausible.

Honest limits — nothing in RNW was compiled or run:

  • The patch itself is not compiled and not run. yarn lint, yarn format:verify,
    clang-format, typecheck and the build are all unrun. The probe is separate standalone code, not
    this patch.
  • Verified only mechanically: authored against the exact raw.githubusercontent.com bytes at
    c69cf55f67f9b03f467502dac1007ac2d9ebe209; git apply --check against a pristine scratch copy with
    core.autocrlf falseexit 0; real git apply → exit 0 with 0 CRLF sequences in all three
    files; git apply --check against a CRLF-converted working copy with core.autocrlf true (what a
    normal checkout produces, since .gitattributes declares *.cpp text eol=crlf) → exit 0.
    Longest added line is 107 columns, within the 120-column ColumnLimit — hand-counted, not
    clang-format-verified.
  • I did not reproduce the original windows.sharedFonts state. The probe machine has none of
    these fonts installed, so its nullptr rows fail for the simple reason that the font is absent
    from the system collection. I therefore cannot claim to have measured the exact configuration in
    DirectWrite text layout cannot resolve font families whose names contain spaces — 'Material Icons' renders tofu, same font renamed 'MaterialIcons' renders #16306/Registered icon-font TTFs render blank glyphs until table checksums/table directory are recomputed — silent failure in the DWrite font path #16308, only to have shown that neither spaces nor checksums are the mechanism and that
    nullptr cannot resolve a font that is not installed.
  • The Assets\ / Assets\Fonts\ convention is a judgement call, not a measurement. It matches
    where RNW app templates put font assets and where our production app puts them, but if maintainers
    would rather this be an explicit API (an app-settable collection, or something aligned with
    Implement IProvideFontInfo to unify font loading #15750's IProvideFontInfo direction) than a directory convention, that is a reasonable objection
    and I will rework it.
  • Startup cost is unmeasured. GetSystemFontSet plus building a merged collection happens once,
    on the first text layout. I have not profiled it.
  • AppDirectory() uses a MAX_PATH buffer and returns empty (→ nullptr → today's behaviour) if the
    module path is longer.
  • Out of scope: the editable text of a TextInput goes through RichEdit, not DirectWrite text
    layout, so app-bundled fonts there are unaffected by this change. The placeholder is fixed,
    because CreatePlaceholderLayout routes through WindowsTextLayoutManager::GetTextLayout. The
    nullptr in ScrollViewComponentView.cpp L474 is fine as-is — it requests the system font
    Segoe Fluent Icons.

Prior art

Our production app (Facilitron FIT, RNW 0.83.2) ships a near-identical patch as a yarn patch and it
resolves app-bundled font families without the windows.sharedFonts manifest extension — which
matters because that extension can be rejected during Store package acceptance. This PR is that patch
generalised and cleaned up for upstream. Related: #15316, #15750, #3463.

Change file

change/react-native-windows-fix-app-bundled-fonts.json, "type": "prerelease" (correct for main,
whose vnext/package.json version is 0.0.0-canary.1057; the repo's beachball transform downgrades
prerelease to patch automatically on released branches).

Microsoft Reviewers: Open in CodeFlow

… font collection

WindowsTextLayoutManager::GetTextLayout passed nullptr as the font collection to
CreateTextFormat, which restricts resolution to the system collection. Every
app-bundled font family therefore failed to resolve and every codepoint fell back
to Segoe UI glyph 0 (.notdef) - icon fonts render as blank or tofu.

Measured with a standalone DirectWrite probe replaying this exact call sequence
against the eight stock react-native-vector-icons TTFs: with a collection that
contains the font each draws a real glyph index (40, 1, 1, 13, 4, 4, 4, 2); with
nullptr every one resolves to Segoe UI glyph 0.

Adds DWriteAppFontCollection() to DWriteHelpers - the system font set merged with
every *.ttf/*.otf under the app's Assets\ and Assets\Fonts\, built once via a
magic static, failing closed to nullptr so behaviour is unchanged for apps that
bundle no fonts - and passes it at the CreateTextFormat call site. Per-fragment
SetFontFamilyName inherits the layout's collection, so only that one call site
needed changing.

Fixes microsoft#16306. Fixes microsoft#16308 (same root cause - the checksum and space-in-name
diagnoses in those issues were both refuted by the probe).
@FaithfulAudio
Collin Schneide (FaithfulAudio) requested a review from a team as a code owner July 26, 2026 08:32
@azure-pipelines

Copy link
Copy Markdown
Contributor
Azure Pipelines:
There may be pipelines that require an authorized user to comment /azp run to run.

Copilot AI 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.

Pull request overview

This PR fixes Fabric text layout on Windows failing to resolve app-bundled font families by ensuring DirectWrite text formats are created with a font collection that includes both the system font set and fonts shipped with the app under Assets\ and Assets\Fonts\.

Changes:

  • Introduces Microsoft::ReactNative::DWriteAppFontCollection() to build (once) a merged DirectWrite font collection from the system font set plus app-bundled *.ttf/*.otf files.
  • Updates Fabric text layout creation (WindowsTextLayoutManager::GetTextLayout) to pass the merged collection to CreateTextFormat instead of nullptr (system-only).
  • Adds a change file documenting the behavioral fix for the react-native-windows package.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated no comments.

File Description
vnext/Microsoft.ReactNative/Fabric/platform/react/renderer/textlayoutmanager/WindowsTextLayoutManager.cpp Uses the new app+system DirectWrite font collection when creating text formats so bundled font families resolve.
vnext/Microsoft.ReactNative/Fabric/DWriteHelpers.h Declares DWriteAppFontCollection() helper for reuse.
vnext/Microsoft.ReactNative/Fabric/DWriteHelpers.cpp Implements one-time creation of a merged DirectWrite font collection by enumerating bundled font files.
change/react-native-windows-fix-app-bundled-fonts.json Records the fix in the repo’s change tracking.

@acoates-ms

Copy link
Copy Markdown
Contributor

/azp run

@azure-pipelines

Copy link
Copy Markdown
Contributor
Azure Pipelines:
Successfully started running 1 pipeline(s).

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

Performance Test Results

Branch: fix/app-bundled-font-collection
Commit: 5611f151
Time: 2026-08-04T00:01:08.296Z
Tests: 161/161 passed

✅ Passed

161 scenario(s) across 28 suite(s) — no regressions

SectionList

Scenario Mean Median StdDev Renders vs Baseline
SectionList mount 2.10ms 2.00ms ±0.74ms 1 -60.0%
SectionList unmount 0.10ms 0.00ms ±0.32ms 0 +0.0%
SectionList rerender 6.40ms 6.00ms ±1.58ms 2 -42.9%
SectionList with-3-sections-15-items 2.90ms 3.00ms ±0.57ms 1 -45.5%
SectionList with-5-sections-50-items 3.70ms 3.00ms ±1.57ms 1 -50.0%
SectionList with-10-sections-200-items 2.70ms 2.50ms ±0.82ms 1 -54.5%
SectionList with-20-sections-200-items 3.50ms 3.00ms ±1.58ms 1 -40.0%
SectionList with-section-separator 1.10ms 1.00ms ±0.32ms 1 -50.0%
SectionList with-item-separator 1.10ms 1.00ms ±0.32ms 1 -50.0%
SectionList with-header-footer 1.00ms 1.00ms ±0.00ms 1 -50.0%
SectionList with-section-footer 1.50ms 1.00ms ±1.58ms 1 -50.0%
SectionList with-sticky-section-headers 1.10ms 1.00ms ±0.32ms 1 -50.0%
SectionList with-empty-list 0.20ms 0.00ms ±0.42ms 1 -100.0%
SectionList with-50-sections-1000-items 1.00ms 1.00ms ±0.00ms 1 -50.0%

FlatList

Scenario Mean Median StdDev Renders vs Baseline
FlatList mount 2.20ms 2.00ms ±0.63ms 1 -50.0%
FlatList unmount 0.00ms 0.00ms ±0.00ms 0 +0.0%
FlatList rerender 5.60ms 5.00ms ±1.51ms 2 -44.4%
FlatList with-10-items 2.80ms 3.00ms ±0.42ms 1 -25.0%
FlatList with-100-items 2.80ms 3.00ms ±0.63ms 1 -40.0%
FlatList with-500-items 2.50ms 2.50ms ±0.53ms 1 -37.5%
FlatList with-1000-items 2.80ms 2.50ms ±1.62ms 1 -37.5%
FlatList horizontal 2.10ms 2.00ms ±0.57ms 1 -60.0%
FlatList with-separator 1.50ms 1.00ms ±0.97ms 1 -50.0%
FlatList with-header-footer 1.00ms 1.00ms ±0.00ms 1 -50.0%
FlatList with-empty-list 0.10ms 0.00ms ±0.32ms 1 -100.0%
FlatList with-get-item-layout 0.70ms 1.00ms ±0.48ms 1 +0.0%
FlatList inverted 1.40ms 1.00ms ±1.26ms 1 -33.3%
FlatList with-num-columns 1.30ms 1.00ms ±0.48ms 1 -66.7%

TouchableOpacity

Scenario Mean Median StdDev Renders vs Baseline
TouchableOpacity mount 0.60ms 1.00ms ±0.52ms 1 +0.0%
TouchableOpacity unmount 0.00ms 0.00ms ±0.00ms 0 +0.0%
TouchableOpacity rerender 1.00ms 1.00ms ±1.49ms 2 +0.0%
TouchableOpacity custom-active-opacity 0.40ms 0.00ms ±0.52ms 1 -100.0%
TouchableOpacity disabled 0.40ms 0.00ms ±0.52ms 1 -100.0%
TouchableOpacity with-all-handlers 0.40ms 0.00ms ±0.52ms 1 -100.0%
TouchableOpacity with-hit-slop 0.40ms 0.00ms ±0.52ms 1 -100.0%
TouchableOpacity with-delay 0.30ms 0.00ms ±0.48ms 1 -100.0%
TouchableOpacity nested 0.80ms 1.00ms ±0.42ms 1 +0.0%
TouchableOpacity multiple-10 3.87ms 3.00ms ±2.10ms 1 -50.0%
TouchableOpacity multiple-50 13.80ms 13.00ms ±2.57ms 1 -55.2%
TouchableOpacity multiple-100 11.20ms 11.00ms ±2.04ms 1 -78.0%

ScrollView

Scenario Mean Median StdDev Renders vs Baseline
ScrollView mount 0.20ms 0.00ms ±0.42ms 1 +0.0%
ScrollView unmount 0.00ms 0.00ms ±0.00ms 0 +0.0%
ScrollView rerender 0.30ms 0.00ms ±0.48ms 2 -100.0%
ScrollView children-20 1.73ms 2.00ms ±0.46ms 1 -50.0%
ScrollView children-100 10.13ms 10.00ms ±1.51ms 1 -37.5%
ScrollView horizontal 1.80ms 2.00ms ±0.42ms 1 -50.0%
ScrollView sticky-headers 1.90ms 2.00ms ±1.20ms 1 -33.3%
ScrollView scroll-indicators 0.30ms 0.00ms ±0.48ms 1 -100.0%
ScrollView nested 0.80ms 1.00ms ±0.42ms 1 +0.0%
ScrollView content-container-style 0.40ms 0.00ms ±0.52ms 1 -100.0%
ScrollView children-500 12.87ms 12.00ms ±2.64ms 1 -36.8%

TouchableHighlight

Scenario Mean Median StdDev Renders vs Baseline
TouchableHighlight mount 0.20ms 0.00ms ±0.42ms 1 -100.0%
TouchableHighlight unmount 0.00ms 0.00ms ±0.00ms 0 +0.0%
TouchableHighlight rerender 0.30ms 0.00ms ±0.48ms 2 -100.0%
TouchableHighlight custom-underlay-color 0.30ms 0.00ms ±0.48ms 1 +0.0%
TouchableHighlight custom-active-opacity 0.20ms 0.00ms ±0.42ms 1 +0.0%
TouchableHighlight disabled 0.20ms 0.00ms ±0.42ms 1 +0.0%
TouchableHighlight with-all-handlers 0.10ms 0.00ms ±0.32ms 1 +0.0%
TouchableHighlight with-hit-slop 0.20ms 0.00ms ±0.42ms 1 +0.0%
TouchableHighlight nested-touchables 0.40ms 0.00ms ±0.52ms 1 -100.0%
TouchableHighlight multiple-touchables-10 1.20ms 1.00ms ±0.42ms 1 -66.7%
TouchableHighlight multiple-touchables-50 7.20ms 7.00ms ±1.23ms 1 -44.0%
TouchableHighlight multiple-touchables-100 13.00ms 12.50ms ±1.76ms 1 -44.4%

Pressable

Scenario Mean Median StdDev Renders vs Baseline
Pressable mount 0.20ms 0.00ms ±0.42ms 1 +0.0%
Pressable unmount 0.00ms 0.00ms ±0.00ms 0 +0.0%
Pressable rerender 0.30ms 0.00ms ±0.48ms 2 -100.0%
Pressable with-all-handlers 0.10ms 0.00ms ±0.32ms 1 +0.0%
Pressable with-style-function 0.10ms 0.00ms ±0.32ms 1 +0.0%
Pressable disabled 0.10ms 0.00ms ±0.32ms 1 +0.0%
Pressable with-hit-slop 0.10ms 0.00ms ±0.32ms 1 +0.0%
Pressable nested 0.40ms 0.00ms ±0.52ms 1 -100.0%
Pressable multiple-10 1.47ms 1.00ms ±0.52ms 1 -66.7%
Pressable multiple-50 7.87ms 8.00ms ±1.19ms 1 -42.9%
Pressable multiple-100 9.13ms 8.00ms ±5.55ms 1 -33.3%

Modal

Scenario Mean Median StdDev Renders vs Baseline
Modal mount 0.20ms 0.00ms ±0.42ms 1 +0.0%
Modal unmount 0.00ms 0.00ms ±0.00ms 0 +0.0%
Modal rerender 0.40ms 0.00ms ±0.52ms 2 +0.0%
Modal slide-animation 0.10ms 0.00ms ±0.32ms 1 +0.0%
Modal fade-animation 0.20ms 0.00ms ±0.42ms 1 +0.0%
Modal transparent 0.20ms 0.00ms ±0.42ms 1 +0.0%
Modal with-callbacks 0.10ms 0.00ms ±0.32ms 1 +0.0%
Modal rich-content 1.00ms 1.00ms ±0.00ms 1 -50.0%
Modal with-accessibility 0.20ms 0.00ms ±0.42ms 1 +0.0%

Image

Scenario Mean Median StdDev Renders vs Baseline
Image mount 0.10ms 0.00ms ±0.32ms 1 +0.0%
Image unmount 0.00ms 0.00ms ±0.00ms 0 +0.0%
Image rerender 0.00ms 0.00ms ±0.00ms 2 +0.0%
Image with-resize-mode 0.00ms 0.00ms ±0.00ms 1 +0.0%
Image with-border-radius 0.00ms 0.00ms ±0.00ms 1 +0.0%
Image with-tint-color 0.10ms 0.00ms ±0.32ms 1 +0.0%
Image with-blur-radius 0.10ms 0.00ms ±0.32ms 1 +0.0%
Image with-accessibility 0.00ms 0.00ms ±0.00ms 1 +0.0%
Image multiple-10 0.47ms 0.00ms ±0.52ms 1 -100.0%
Image multiple-50 2.40ms 2.00ms ±0.51ms 1 -33.3%
Image multiple-100 4.53ms 5.00ms ±1.06ms 1 -37.5%

ActivityIndicator

Scenario Mean Median StdDev Renders vs Baseline
ActivityIndicator mount 0.10ms 0.00ms ±0.32ms 1 +0.0%
ActivityIndicator unmount 0.10ms 0.00ms ±0.32ms 0 +0.0%
ActivityIndicator rerender 0.00ms 0.00ms ±0.00ms 2 +0.0%
ActivityIndicator size-large 0.10ms 0.00ms ±0.32ms 1 +0.0%
ActivityIndicator size-small 0.00ms 0.00ms ±0.00ms 1 +0.0%
ActivityIndicator with-color 0.00ms 0.00ms ±0.00ms 1 +0.0%
ActivityIndicator not-animating 0.00ms 0.00ms ±0.00ms 1 +0.0%
ActivityIndicator with-accessibility 0.10ms 0.00ms ±0.32ms 1 +0.0%
ActivityIndicator multiple-10 0.53ms 1.00ms ±0.52ms 1 +0.0%
ActivityIndicator multiple-50 2.67ms 2.00ms ±1.40ms 1 -50.0%
ActivityIndicator multiple-100 5.73ms 4.00ms ±3.45ms 1 -42.9%

Switch

Scenario Mean Median StdDev Renders vs Baseline
Switch mount 0.10ms 0.00ms ±0.32ms 1 +0.0%
Switch unmount 0.00ms 0.00ms ±0.00ms 0 +0.0%
Switch rerender 0.00ms 0.00ms ±0.00ms 2 -100.0%
Switch value-true 0.10ms 0.00ms ±0.32ms 1 +0.0%
Switch disabled 0.10ms 0.00ms ±0.32ms 1 +0.0%
Switch custom-colors 0.10ms 0.00ms ±0.32ms 1 +0.0%
Switch on-value-change 0.10ms 0.00ms ±0.32ms 1 +0.0%
Switch with-accessibility 0.00ms 0.00ms ±0.00ms 1 +0.0%
Switch multiple-10 0.87ms 1.00ms ±0.35ms 1 -50.0%
Switch multiple-50 5.00ms 4.00ms ±1.69ms 1 -55.6%
Switch multiple-100 10.33ms 10.00ms ±1.88ms 1 -37.5%

Button

Scenario Mean Median StdDev Renders vs Baseline
Button mount 0.10ms 0.00ms ±0.32ms 1 -100.0%
Button unmount 0.10ms 0.00ms ±0.32ms 0 +0.0%
Button rerender 0.50ms 0.50ms ±0.53ms 2 -50.0%
Button disabled 0.40ms 0.00ms ±0.52ms 1 -100.0%
Button with-color 0.30ms 0.00ms ±0.48ms 1 -100.0%
Button with-accessibility 0.30ms 0.00ms ±0.48ms 1 -100.0%
Button multiple-10 2.93ms 3.00ms ±1.10ms 1 -50.0%
Button multiple-50 12.07ms 13.00ms ±4.43ms 1 -51.9%
Button multiple-100 9.00ms 8.00ms ±1.85ms 1 -57.9%

TextInput

Scenario Mean Median StdDev Renders vs Baseline
TextInput mount 0.10ms 0.00ms ±0.32ms 1 +0.0%
TextInput unmount 0.00ms 0.00ms ±0.00ms 0 +0.0%
TextInput rerender 0.10ms 0.00ms ±0.32ms 2 +0.0%
TextInput multiline 0.00ms 0.00ms ±0.00ms 1 +0.0%
TextInput with-value 0.10ms 0.00ms ±0.32ms 1 +0.0%
TextInput styled 0.10ms 0.00ms ±0.32ms 1 +0.0%
TextInput multiple-100 4.60ms 4.00ms ±1.18ms 1 -42.9%

View

Scenario Mean Median StdDev Renders vs Baseline
View mount 0.10ms 0.00ms ±0.32ms 1 +0.0%
View unmount 0.00ms 0.00ms ±0.00ms 0 +0.0%
View rerender 0.20ms 0.00ms ±0.42ms 2 +0.0%
View nested-50 2.20ms 2.00ms ±0.77ms 1 -33.3%
View nested-100 4.33ms 4.00ms ±1.05ms 1 -42.9%
View shadow 0.10ms 0.00ms ±0.32ms 1 +0.0%
View border-radius 0.00ms 0.00ms ±0.00ms 1 +0.0%
View nested-500 10.60ms 6.00ms ±8.02ms 1 -40.0%

Text

Scenario Mean Median StdDev Renders vs Baseline
Text mount 0.10ms 0.00ms ±0.32ms 1 +0.0%
Text unmount 0.10ms 0.00ms ±0.32ms 0 +0.0%
Text rerender 0.10ms 0.00ms ±0.32ms 2 +0.0%
Text long-1000 0.10ms 0.00ms ±0.32ms 1 +0.0%
Text nested 0.10ms 0.00ms ±0.32ms 1 +0.0%
Text styled 0.10ms 0.00ms ±0.32ms 1 +0.0%
Text multiple-100 5.07ms 5.00ms ±1.44ms 1 -28.6%

SectionList.native-perf-test.ts

Scenario Mean Median StdDev Renders vs Baseline
SectionList native mount 2.79ms 2.61ms ±0.47ms 1 -59.8%

FlatList.native-perf-test.ts

Scenario Mean Median StdDev Renders vs Baseline
FlatList native mount 2.94ms 2.69ms ±0.74ms 1 -70.9%

TouchableHighlight.native-perf-test.ts

Scenario Mean Median StdDev Renders vs Baseline
TouchableHighlight native mount 0.92ms 0.76ms ±0.37ms 1 -63.4%

TouchableOpacity.native-perf-test.ts

Scenario Mean Median StdDev Renders vs Baseline
TouchableOpacity native mount 0.97ms 0.96ms ±0.13ms 1 -69.5%

Pressable.native-perf-test.ts

Scenario Mean Median StdDev Renders vs Baseline
Pressable native mount 0.86ms 0.82ms ±0.12ms 1 -67.5%

ScrollView.native-perf-test.ts

Scenario Mean Median StdDev Renders vs Baseline
ScrollView native mount 2.03ms 1.96ms ±0.30ms 1 -51.6%

ActivityIndicator.native-perf-test.ts

Scenario Mean Median StdDev Renders vs Baseline
ActivityIndicator native mount 0.72ms 0.69ms ±0.07ms 1 -72.2%

TextInput.native-perf-test.ts

Scenario Mean Median StdDev Renders vs Baseline
TextInput native mount 1.38ms 1.15ms ±0.62ms 1 -71.9%

Switch.native-perf-test.ts

Scenario Mean Median StdDev Renders vs Baseline
Switch native mount 0.61ms 0.61ms ±0.03ms 1 -64.6%

Button.native-perf-test.ts

Scenario Mean Median StdDev Renders vs Baseline
Button native mount 1.00ms 0.99ms ±0.13ms 1 -62.1%

Modal.native-perf-test.ts

Scenario Mean Median StdDev Renders vs Baseline
Modal native mount 0.72ms 0.57ms ±0.54ms 1 -53.5%

Image.native-perf-test.ts

Scenario Mean Median StdDev Renders vs Baseline
Image native mount 1.07ms 0.97ms ±0.25ms 1 -57.3%

View.native-perf-test.ts

Scenario Mean Median StdDev Renders vs Baseline
View native mount 0.55ms 0.53ms ±0.08ms 1 -62.6%

Text.native-perf-test.ts

Scenario Mean Median StdDev Renders vs Baseline
Text native mount 0.68ms 0.67ms ±0.06ms 1 -61.6%

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.

Upon further reflection, the DWriteAppFontCollection is likely accessed at least on two threads in the first use. Can we cache list of fonts so that we can at least avoid doing the file searches the 2nd time?

@microsoft-github-policy-service microsoft-github-policy-service Bot added the Needs: Author Feedback The issue/PR needs activity from its author (label drives bot activity) label Aug 4, 2026
… hot path

acoates-ms asked whether the font list can be cached so the file searches are
not repeated, noting DWriteAppFontCollection is likely reached from more than
one thread on first use.

The directory enumeration already runs exactly once: s_appFontCollection is a
function-local static with a dynamic initializer, so it is initialized a single
time and concurrent first callers wait for that initialization rather than
racing or repeating it ([stmt.dcl]/4). No call after the first touches the file
system. That guarantee was load-bearing but only implied by the old comment, so
it is now stated explicitly - including the thread-safety, which was the part
worth being able to read off the page.

What the old signature *did* cost on every call: GetTextLayout() invokes this
once per text measure, and returning winrt::com_ptr by value put an
AddRef/Release pair on that path for a pointer whose lifetime is already static
and process-long. The accessor now returns a non-owning raw pointer, so the
per-measure path has no refcount traffic at all; the static keeps the only
reference. The call site drops its .get() accordingly.

Not changed, and worth calling out in case it is the concern behind the
question: the first call still does the enumeration inline, so whichever thread
arrives first pays that cost and any thread arriving during it blocks. Moving
that work off the measure path entirely (eager construction at instance setup)
is a larger change and would be a behavioral one - happy to do it if that is
what you would prefer here.
Copilot AI review requested due to automatic review settings August 4, 2026 17:53
@microsoft-github-policy-service microsoft-github-policy-service Bot removed the Needs: Author Feedback The issue/PR needs activity from its author (label drives bot activity) label Aug 4, 2026
Collin Schneide (FaithfulAudio) added a commit to FacilitronWorks/react-native-windows that referenced this pull request Aug 4, 2026
… hot path

Twin of the same change on main (microsoft#16339), kept byte-identical so the two
branches cannot drift.

The directory enumeration already ran exactly once: s_appFontCollection is a
function-local static with a dynamic initializer, so it is initialized a single
time and concurrent first callers wait for that initialization rather than
racing or repeating it ([stmt.dcl]/4). That guarantee is now stated explicitly
instead of merely implied.

What the old signature did cost on every call: GetTextLayout() invokes this once
per text measure, and returning winrt::com_ptr by value put an AddRef/Release
pair on that path for a pointer whose lifetime is already static and
process-long. The accessor now returns a non-owning raw pointer; the call site
drops its .get().
@FaithfulAudio

Copy link
Copy Markdown
Contributor Author

Thanks — pushed, though with one correction to the premise, so please sanity-check my reasoning here.

The file searches already happen exactly once. s_appFontCollection is a function-local static with a dynamic initializer, so it is initialized a single time and concurrent first callers wait for that initialization rather than racing or repeating it ([stmt.dcl]/4). No call after the first touches the file system, so there was no second enumeration to cache away. That guarantee was load-bearing but only implied by the old comment — it now says so explicitly, including the thread-safety, since that was the part worth being able to read off the page.

There was a real per-call cost, just not that one. GetTextLayout() calls this on every text measure, and the accessor returned winrt::com_ptr by value — an AddRef/Release pair on that path for a pointer whose lifetime is already static and process-long. It now returns a non-owning raw pointer, so the per-measure path has no refcount traffic at all; the static keeps the only reference and the call site drops its .get().

What I did not change, in case it is the concern behind your question. The first call still does the enumeration inline, so whichever thread arrives first pays it, and a thread arriving during that window blocks. Caching wouldn't help that either — it is a first-use cost, not a repeat cost. Moving it off the measure path entirely (eager construction at instance setup) is a larger and behavioral change, so I would rather you tell me you want it than assume. If the two-thread observation was about that stall rather than repeated I/O, say so and I will do it that way.

The 0.83-stable twin (#16344) has the identical change; I diffed the three files to confirm they are byte-identical across the two branches.

Copilot AI 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.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.

Suppressed comments (1)

vnext/Microsoft.ReactNative/Fabric/DWriteHelpers.cpp:35

  • Executable paths longer than MAX_PATH make this helper return an empty directory, so bundled fonts still fail to resolve for otherwise valid long-path installations. GetModuleFileNameW supports a larger buffer; use a dynamically sized buffer (or the maximum long-path size) instead of treating truncation as “no app fonts.”
  wchar_t modulePath[MAX_PATH]{};
  const DWORD length = ::GetModuleFileNameW(nullptr, modulePath, MAX_PATH);
  if (length == 0 || length >= MAX_PATH) {

@acoates-ms

Copy link
Copy Markdown
Contributor

/azp run

@azure-pipelines

Copy link
Copy Markdown
Contributor
Azure Pipelines:
Successfully started running 1 pipeline(s).

@vmoroz

Copy link
Copy Markdown
Member

Collin Schneide (@FaithfulAudio) , thank you for your PR!
Sorry, our pipelines are currently affected by recent changes in the security settings where we are not allowed to access directly npmjs.com and nuget.org feeds anymore.
I am working on fixing it - see PR #16350.
It may take a few days until we restore our capability to run PR and CI validations and to release the packages.
+Andrew Coates (@acoates-ms) FYI

…ad race

Two changes, one per reading of the review comment.

1. The list of bundled font files is now its own cached static
   (AppFontFilePaths): the directory searches run exactly once per process and
   every consumer - including any future path that rebuilds a collection -
   reads the cached list and never touches the file system again.
   CreateAppFontCollection() contains no enumeration by construction.

2. The genuine first-use thread race was in DWriteFactory() itself: the
   existing `if (!s_dwriteFactory) { assign }` lazy-init is a data race when
   two threads make first use concurrently - and DWriteAppFontCollection() is
   reachable from more than one thread on first use, which makes that race
   live rather than theoretical. Converted to a function-local static with a
   dynamic initializer (thread-safe by [stmt.dcl]/4; /Zc:threadSafeInit is on
   by default and nothing in the RNW build disables it), so concurrent first
   callers wait for one initialization instead of racing it.

Also removes the stray `#pragma once` this .cpp carried.
Copilot AI review requested due to automatic review settings August 5, 2026 19:50
@FaithfulAudio

Copy link
Copy Markdown
Contributor Author

Done — and digging into it, I think your instinct was pointing at something realer than my earlier reply gave it credit for.

The font-file list is now cached in its own right. AppFontFilePaths() is a separately-initialized static: the directory searches run exactly once per process, and every consumer — including any future path that rebuilds a collection (retry, refresh, whatever comes later) — reads the cached list and can never re-run the searches. CreateAppFontCollection() contains no enumeration by construction.

The genuine first-use thread race was one function up. DWriteFactory()'s existing if (!s_dwriteFactory) { …assign… } lazy-init is a data race when two threads make first use concurrently — and DWriteAppFontCollection() is reachable from more than one thread on first use, exactly as you said, which makes that race live rather than theoretical. Converted it to a function-local static with a dynamic initializer, so concurrent first callers wait for a single initialization instead of racing it. (Thread-safe statics are in effect: /Zc:threadSafeInit is on by default and nothing in the RNW build disables it — I checked the props/targets tree before relying on that.)

Also removed the stray #pragma once this .cpp carried.

The 0.83 twin (#16344) has the identical change — files verified byte-identical across the two branches.

Vladimir Morozov (@vmoroz) thanks for the heads-up on the feed lockdown — no urgency on our side; these will keep. Good luck with #16350.

Copilot AI 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.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.

Suppressed comments (1)

vnext/Microsoft.ReactNative/Fabric/DWriteHelpers.cpp:124

  • The system set is added before bundled files, but IDWriteFontSetBuilder keeps the first-added family when names collide. As a result, an installed font with the same family name wins and the app’s bundled version (potentially with different icon glyph mappings) is ignored. Add bundled files first, then append the system set so bundled assets take precedence while all other system families remain available.
    winrt::com_ptr<::IDWriteFontSet> systemFontSet;
    winrt::check_hresult(factory5->GetSystemFontSet(systemFontSet.put()));
    winrt::check_hresult(builder->AddFontSet(systemFontSet.get()));

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

Labels

None yet

Projects

None yet

4 participants