From b9c9bd1b03ac6be9fae20ff766ea6040b1aa4ec0 Mon Sep 17 00:00:00 2001 From: Eric Hartzog Date: Thu, 4 Jun 2026 18:38:39 -0700 Subject: [PATCH] Fix stale text measurement after display density change Summary: Changelog: [General][Fixed] - Fix text measurements being incorrectly reused across pixel density changes Differential Revision: D107594582 --- .../textlayoutmanager/TextMeasureCache.h | 23 ++++++-- .../textlayoutmanager/TextLayoutManager.cpp | 6 ++- .../textlayoutmanager/TextLayoutManager.mm | 3 +- .../tests/TextLayoutManagerTest.cpp | 53 +++++++++++++++++++ .../api-snapshots/ReactAndroidDebugCxx.api | 2 + .../api-snapshots/ReactAndroidNewarchCxx.api | 2 + .../api-snapshots/ReactAndroidReleaseCxx.api | 2 + .../api-snapshots/ReactAppleDebugCxx.api | 2 + .../api-snapshots/ReactAppleNewarchCxx.api | 2 + .../api-snapshots/ReactAppleReleaseCxx.api | 2 + .../api-snapshots/ReactCommonDebugCxx.api | 2 + .../api-snapshots/ReactCommonNewarchCxx.api | 2 + .../api-snapshots/ReactCommonReleaseCxx.api | 2 + 13 files changed, 96 insertions(+), 7 deletions(-) diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/TextMeasureCache.h b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/TextMeasureCache.h index cb3a6000c30..f062132f274 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/TextMeasureCache.h +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/TextMeasureCache.h @@ -66,6 +66,10 @@ class TextMeasureCacheKey final { AttributedString attributedString{}; ParagraphAttributes paragraphAttributes{}; LayoutConstraints layoutConstraints{}; + // The measured size depends on the pixel scale factor because layout metrics + // are rounded to the pixel grid. Two otherwise-identical measures at different + // densities are not interchangeable, so the scale factor is part of the key. + Float pointScaleFactor{}; }; // The Key type that is used for Line Measure Cache. @@ -87,6 +91,9 @@ class PreparedTextCacheKey final { AttributedString attributedString{}; ParagraphAttributes paragraphAttributes{}; LayoutConstraints layoutConstraints{}; + // A prepared layout is rounded to the pixel grid, so it is only reusable at + // the pixel scale factor it was laid out at. + Float pointScaleFactor{}; }; /* @@ -252,7 +259,8 @@ inline size_t attributedStringHashDisplayWise(const AttributedString &attributed inline bool operator==(const TextMeasureCacheKey &lhs, const TextMeasureCacheKey &rhs) { return areAttributedStringsEquivalentLayoutWise(lhs.attributedString, rhs.attributedString) && - lhs.paragraphAttributes == rhs.paragraphAttributes && lhs.layoutConstraints == rhs.layoutConstraints; + lhs.paragraphAttributes == rhs.paragraphAttributes && lhs.layoutConstraints == rhs.layoutConstraints && + floatEquality(lhs.pointScaleFactor, rhs.pointScaleFactor); } inline bool operator==(const LineMeasureCacheKey &lhs, const LineMeasureCacheKey &rhs) @@ -264,7 +272,8 @@ inline bool operator==(const LineMeasureCacheKey &lhs, const LineMeasureCacheKey inline bool operator==(const PreparedTextCacheKey &lhs, const PreparedTextCacheKey &rhs) { return areAttributedStringsEquivalentDisplayWise(lhs.attributedString, rhs.attributedString) && - lhs.paragraphAttributes == rhs.paragraphAttributes && lhs.layoutConstraints == rhs.layoutConstraints; + lhs.paragraphAttributes == rhs.paragraphAttributes && lhs.layoutConstraints == rhs.layoutConstraints && + floatEquality(lhs.pointScaleFactor, rhs.pointScaleFactor); } } // namespace facebook::react @@ -276,7 +285,10 @@ struct hash { size_t operator()(const facebook::react::TextMeasureCacheKey &key) const { return facebook::react::hash_combine( - attributedStringHashLayoutWise(key.attributedString), key.paragraphAttributes, key.layoutConstraints); + attributedStringHashLayoutWise(key.attributedString), + key.paragraphAttributes, + key.layoutConstraints, + key.pointScaleFactor); } }; @@ -294,7 +306,10 @@ struct hash { size_t operator()(const facebook::react::PreparedTextCacheKey &key) const { return facebook::react::hash_combine( - attributedStringHashDisplayWise(key.attributedString), key.paragraphAttributes, key.layoutConstraints); + attributedStringHashDisplayWise(key.attributedString), + key.paragraphAttributes, + key.layoutConstraints, + key.pointScaleFactor); } }; diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/android/react/renderer/textlayoutmanager/TextLayoutManager.cpp b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/android/react/renderer/textlayoutmanager/TextLayoutManager.cpp index ce6ecb15373..99886999293 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/android/react/renderer/textlayoutmanager/TextLayoutManager.cpp +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/android/react/renderer/textlayoutmanager/TextLayoutManager.cpp @@ -193,7 +193,8 @@ TextMeasurement TextLayoutManager::measure( : textMeasureCache_.get( {.attributedString = attributedString, .paragraphAttributes = paragraphAttributes, - .layoutConstraints = layoutConstraints}, + .layoutConstraints = layoutConstraints, + .pointScaleFactor = layoutContext.pointScaleFactor}, std::move(measureText)); measurement.size = layoutConstraints.clamp(measurement.size); @@ -315,7 +316,8 @@ TextLayoutManager::PreparedTextLayout TextLayoutManager::prepareLayout( const auto [key, preparedText] = preparedTextCache_.getWithKey( {.attributedString = attributedString, .paragraphAttributes = paragraphAttributes, - .layoutConstraints = layoutConstraints}, + .layoutConstraints = layoutConstraints, + .pointScaleFactor = layoutContext.pointScaleFactor}, [&]() { const auto& fabricUIManager = contextContainer_->at>("FabricUIManager"); diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/TextLayoutManager.mm b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/TextLayoutManager.mm index cddafb5a6ef..82cad8fd2ee 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/TextLayoutManager.mm +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/TextLayoutManager.mm @@ -43,7 +43,8 @@ measurement = textMeasureCache_.get( {.attributedString = attributedString, .paragraphAttributes = paragraphAttributes, - .layoutConstraints = layoutConstraints}, + .layoutConstraints = layoutConstraints, + .pointScaleFactor = layoutContext.pointScaleFactor}, [&]() { auto telemetry = TransactionTelemetry::threadLocalTelemetry(); if (telemetry) { diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/tests/TextLayoutManagerTest.cpp b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/tests/TextLayoutManagerTest.cpp index 8d1152bcc9a..bd0a053f64b 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/tests/TextLayoutManagerTest.cpp +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/tests/TextLayoutManagerTest.cpp @@ -44,3 +44,56 @@ TEST(TextLayoutManagerTest, maxFontSizeMultiplierAffectsLayoutCacheHash) { EXPECT_NE( textAttributesHashLayoutWise(lhs), textAttributesHashLayoutWise(rhs)); } + +// Measurements are rounded to the pixel grid, so a measurement cached at one +// pixel scale factor must not satisfy a lookup at another. Keys that differ +// only by pointScaleFactor must compare unequal. +TEST(TextLayoutManagerTest, pointScaleFactorAffectsTextMeasureCacheEquality) { + TextMeasureCacheKey lhs; + TextMeasureCacheKey rhs; + + lhs.pointScaleFactor = 2.0; + rhs.pointScaleFactor = 1.6; + EXPECT_FALSE(lhs == rhs); + + rhs.pointScaleFactor = 2.0; + EXPECT_TRUE(lhs == rhs); +} + +TEST(TextLayoutManagerTest, pointScaleFactorAffectsTextMeasureCacheHash) { + TextMeasureCacheKey lhs; + TextMeasureCacheKey rhs; + + lhs.pointScaleFactor = 2.0; + rhs.pointScaleFactor = 1.6; + + EXPECT_NE( + std::hash{}(lhs), + std::hash{}(rhs)); +} + +// Same invariant for the prepared-text cache: a prepared layout is pixel-grid +// rounded and is only reusable at the pixel scale factor it was prepared at. +TEST(TextLayoutManagerTest, pointScaleFactorAffectsPreparedTextCacheEquality) { + PreparedTextCacheKey lhs; + PreparedTextCacheKey rhs; + + lhs.pointScaleFactor = 2.0; + rhs.pointScaleFactor = 1.6; + EXPECT_FALSE(lhs == rhs); + + rhs.pointScaleFactor = 2.0; + EXPECT_TRUE(lhs == rhs); +} + +TEST(TextLayoutManagerTest, pointScaleFactorAffectsPreparedTextCacheHash) { + PreparedTextCacheKey lhs; + PreparedTextCacheKey rhs; + + lhs.pointScaleFactor = 2.0; + rhs.pointScaleFactor = 1.6; + + EXPECT_NE( + std::hash{}(lhs), + std::hash{}(rhs)); +} diff --git a/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api b/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api index 23da100989c..e54e4bb6acb 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api @@ -4129,6 +4129,7 @@ class facebook::react::PointerHoverTracker { class facebook::react::PreparedTextCacheKey { public facebook::react::AttributedString attributedString; + public facebook::react::Float pointScaleFactor; public facebook::react::LayoutConstraints layoutConstraints; public facebook::react::ParagraphAttributes paragraphAttributes; } @@ -5064,6 +5065,7 @@ class facebook::react::TextLayoutManager { class facebook::react::TextMeasureCacheKey { public facebook::react::AttributedString attributedString; + public facebook::react::Float pointScaleFactor; public facebook::react::LayoutConstraints layoutConstraints; public facebook::react::ParagraphAttributes paragraphAttributes; } diff --git a/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api b/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api index 12885e3d63c..fbaf6d503d9 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api @@ -3973,6 +3973,7 @@ class facebook::react::PointerHoverTracker { class facebook::react::PreparedTextCacheKey { public facebook::react::AttributedString attributedString; + public facebook::react::Float pointScaleFactor; public facebook::react::LayoutConstraints layoutConstraints; public facebook::react::ParagraphAttributes paragraphAttributes; } @@ -4890,6 +4891,7 @@ class facebook::react::TextLayoutManager { class facebook::react::TextMeasureCacheKey { public facebook::react::AttributedString attributedString; + public facebook::react::Float pointScaleFactor; public facebook::react::LayoutConstraints layoutConstraints; public facebook::react::ParagraphAttributes paragraphAttributes; } diff --git a/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api b/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api index c5c78b10c55..cce1cbacb78 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api @@ -4126,6 +4126,7 @@ class facebook::react::PointerHoverTracker { class facebook::react::PreparedTextCacheKey { public facebook::react::AttributedString attributedString; + public facebook::react::Float pointScaleFactor; public facebook::react::LayoutConstraints layoutConstraints; public facebook::react::ParagraphAttributes paragraphAttributes; } @@ -5055,6 +5056,7 @@ class facebook::react::TextLayoutManager { class facebook::react::TextMeasureCacheKey { public facebook::react::AttributedString attributedString; + public facebook::react::Float pointScaleFactor; public facebook::react::LayoutConstraints layoutConstraints; public facebook::react::ParagraphAttributes paragraphAttributes; } diff --git a/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api index 3dfdc05855d..2a078f3c440 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api @@ -6316,6 +6316,7 @@ class facebook::react::PointerHoverTracker { class facebook::react::PreparedTextCacheKey { public facebook::react::AttributedString attributedString; + public facebook::react::Float pointScaleFactor; public facebook::react::LayoutConstraints layoutConstraints; public facebook::react::ParagraphAttributes paragraphAttributes; } @@ -7283,6 +7284,7 @@ class facebook::react::TextLayoutManager { class facebook::react::TextMeasureCacheKey { public facebook::react::AttributedString attributedString; + public facebook::react::Float pointScaleFactor; public facebook::react::LayoutConstraints layoutConstraints; public facebook::react::ParagraphAttributes paragraphAttributes; } diff --git a/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api index e49d4cdde96..c2aa6cdfc4e 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api @@ -6188,6 +6188,7 @@ class facebook::react::PointerHoverTracker { class facebook::react::PreparedTextCacheKey { public facebook::react::AttributedString attributedString; + public facebook::react::Float pointScaleFactor; public facebook::react::LayoutConstraints layoutConstraints; public facebook::react::ParagraphAttributes paragraphAttributes; } @@ -7137,6 +7138,7 @@ class facebook::react::TextLayoutManager { class facebook::react::TextMeasureCacheKey { public facebook::react::AttributedString attributedString; + public facebook::react::Float pointScaleFactor; public facebook::react::LayoutConstraints layoutConstraints; public facebook::react::ParagraphAttributes paragraphAttributes; } diff --git a/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api index b89188d3b83..040d1db716a 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api @@ -6313,6 +6313,7 @@ class facebook::react::PointerHoverTracker { class facebook::react::PreparedTextCacheKey { public facebook::react::AttributedString attributedString; + public facebook::react::Float pointScaleFactor; public facebook::react::LayoutConstraints layoutConstraints; public facebook::react::ParagraphAttributes paragraphAttributes; } @@ -7274,6 +7275,7 @@ class facebook::react::TextLayoutManager { class facebook::react::TextMeasureCacheKey { public facebook::react::AttributedString attributedString; + public facebook::react::Float pointScaleFactor; public facebook::react::LayoutConstraints layoutConstraints; public facebook::react::ParagraphAttributes paragraphAttributes; } diff --git a/scripts/cxx-api/api-snapshots/ReactCommonDebugCxx.api b/scripts/cxx-api/api-snapshots/ReactCommonDebugCxx.api index 6d18214f9b4..d4113b38f16 100644 --- a/scripts/cxx-api/api-snapshots/ReactCommonDebugCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactCommonDebugCxx.api @@ -2732,6 +2732,7 @@ class facebook::react::PointerHoverTracker { } class facebook::react::PreparedTextCacheKey { + public Float pointScaleFactor; public facebook::react::AttributedString attributedString; public facebook::react::LayoutConstraints layoutConstraints; public facebook::react::ParagraphAttributes paragraphAttributes; @@ -3554,6 +3555,7 @@ class facebook::react::TextInputState { } class facebook::react::TextMeasureCacheKey { + public Float pointScaleFactor; public facebook::react::AttributedString attributedString; public facebook::react::LayoutConstraints layoutConstraints; public facebook::react::ParagraphAttributes paragraphAttributes; diff --git a/scripts/cxx-api/api-snapshots/ReactCommonNewarchCxx.api b/scripts/cxx-api/api-snapshots/ReactCommonNewarchCxx.api index 2d462c2c4ae..6908e99301b 100644 --- a/scripts/cxx-api/api-snapshots/ReactCommonNewarchCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactCommonNewarchCxx.api @@ -2616,6 +2616,7 @@ class facebook::react::PointerHoverTracker { } class facebook::react::PreparedTextCacheKey { + public Float pointScaleFactor; public facebook::react::AttributedString attributedString; public facebook::react::LayoutConstraints layoutConstraints; public facebook::react::ParagraphAttributes paragraphAttributes; @@ -3420,6 +3421,7 @@ class facebook::react::TextInputState { } class facebook::react::TextMeasureCacheKey { + public Float pointScaleFactor; public facebook::react::AttributedString attributedString; public facebook::react::LayoutConstraints layoutConstraints; public facebook::react::ParagraphAttributes paragraphAttributes; diff --git a/scripts/cxx-api/api-snapshots/ReactCommonReleaseCxx.api b/scripts/cxx-api/api-snapshots/ReactCommonReleaseCxx.api index 5d4384fa835..d64d649e6a7 100644 --- a/scripts/cxx-api/api-snapshots/ReactCommonReleaseCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactCommonReleaseCxx.api @@ -2729,6 +2729,7 @@ class facebook::react::PointerHoverTracker { } class facebook::react::PreparedTextCacheKey { + public Float pointScaleFactor; public facebook::react::AttributedString attributedString; public facebook::react::LayoutConstraints layoutConstraints; public facebook::react::ParagraphAttributes paragraphAttributes; @@ -3545,6 +3546,7 @@ class facebook::react::TextInputState { } class facebook::react::TextMeasureCacheKey { + public Float pointScaleFactor; public facebook::react::AttributedString attributedString; public facebook::react::LayoutConstraints layoutConstraints; public facebook::react::ParagraphAttributes paragraphAttributes;