From 77fa1704837e598c442e7ed7b8fe871c69265db7 Mon Sep 17 00:00:00 2001 From: kwf Date: Mon, 19 Oct 2026 15:38:02 +0000 Subject: [PATCH 01/26] fix(mobile): keep wrapped markdown text inside its bubble The transcript's markdown measure builds its StaticLayout with a single TextPaint, while the display TextView resolves per-run font fallback (emoji, combining marks). A run the fallback widens can wrap into one more line than the measure saw, so the second line paints below the bubble. Re-measure the height at the hugged width minus a small safety margin, and let the display views feed a laid-out height back through the measurement store when the drawn text outgrows the Yoga frame. --- patches/react-native-enriched-markdown.patch | 235 +++++++++++++++++++ pnpm-lock.yaml | 9 +- pnpm-workspace.yaml | 1 + 3 files changed, 241 insertions(+), 4 deletions(-) create mode 100644 patches/react-native-enriched-markdown.patch diff --git a/patches/react-native-enriched-markdown.patch b/patches/react-native-enriched-markdown.patch new file mode 100644 index 00000000000..2c23a41dbdd --- /dev/null +++ b/patches/react-native-enriched-markdown.patch @@ -0,0 +1,235 @@ +diff --git a/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt b/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt +index b6b38b4cc1a37eacf53f48f940608ed7ead26f93..ef75ddf445d282a078236c0fe2f1d05f5d465158 100644 +--- a/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt ++++ b/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt +@@ -628,6 +628,26 @@ class EnrichedMarkdown( + b: Int, + ) { + layoutSegments() ++ correctUnderMeasuredHeight(b - t) ++ } ++ ++ // The shadow-node measure builds its StaticLayout with a single TextPaint, ++ // while the display TextView resolves per-run font fallback (e.g. combining ++ // marks). A run that falls back can wrap into more lines than the measure ++ // saw, so the laid-out children overflow the Yoga-assigned height and the ++ // text paints outside its parent. When the laid-out children are taller than ++ // the assigned frame, re-measure: invalidate the shadow-node cache and bump ++ // the height-recalculation counter so Yoga adopts the real height. ++ private fun correctUnderMeasuredHeight(assignedHeight: Int) { ++ if (assignedHeight <= 0) return ++ val childrenHeight = computeSegmentsTotalHeight() ++ if (childrenHeight <= assignedHeight) return ++ MeasurementStore.invalidate(id) ++ val wrapper = stateWrapper ?: return ++ val state = Arguments.createMap() ++ state.putInt("forceHeightRecalculationCounter", ++forceHeightRecalculationCounter) ++ wrapper.updateState(state) ++ requestLayout() + } + + fun onImageLayoutChanged() { +diff --git a/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownText.kt b/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownText.kt +index f3bb9122d27d80746b9b8ed07ca5247f080a25d0..7c7fde155fb8109357c45d1516157229449596f8 100644 +--- a/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownText.kt ++++ b/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownText.kt +@@ -511,6 +511,21 @@ class EnrichedMarkdownText + ) { + applyContentBoxInset(widthMeasureSpec) + super.onMeasure(widthMeasureSpec, heightMeasureSpec) ++ correctMeasuredHeight() ++ } ++ ++ // The TextView's own span-aware layout (per-run font fallback for combining ++ // marks) can wrap lines the single-paint StaticLayout in MeasurementStore ++ // cannot reproduce, so the Yoga-assigned height can be shorter than the ++ // drawn text and the text overflows its parent. After the first real ++ // layout pass, feed the laid-out height back through the measurement store ++ // so Yoga re-measures with the authoritative value. ++ private fun correctMeasuredHeight() { ++ val laidOutHeight = layout?.height?.toFloat() ?: return ++ val assignedHeight = measuredHeight.toFloat() ++ if (laidOutHeight <= assignedHeight) return ++ if (layoutManager.invalidateLayout(laidOutHeight)) return ++ layoutManager.invalidateLayout() + } + + /** +diff --git a/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownTextLayoutManager.kt b/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownTextLayoutManager.kt +index 3ba2159a7c6389b0202bbbd886f1b88f8aa6d9e5..99e7c7ed1c58241843b99a440bb2f91870b3b969 100644 +--- a/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownTextLayoutManager.kt ++++ b/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownTextLayoutManager.kt +@@ -7,15 +7,30 @@ class EnrichedMarkdownTextLayoutManager( + ) { + private var forceHeightRecalculationCounter = 0 + +- fun invalidateLayout() { ++ fun invalidateLayout(): Boolean { + val text = view.text + val paint = view.paint +- val heightChanged = MeasurementStore.store(view.id, text, paint, view.trailingMarginBottomPx()) +- if (!heightChanged) return +- val stateWrapper = view.stateWrapper ?: return ++ // The TextView's own layout height is authoritative: its span-aware ++ // ParagraphStyle layout (per-run font fallback for combining marks) can ++ // wrap lines a single-paint StaticLayout cannot reproduce. `layout` is ++ // null until the first traversal; the -1 default then keeps the ++ // StaticLayout estimate and the next pass corrects it. ++ val laidOutHeight = view.layout?.height?.toFloat() ?: -1f ++ return invalidateLayout(laidOutHeight) ++ } ++ ++ /** Re-stores the measurement with an explicit height override in px. Returns true when the height changed. */ ++ fun invalidateLayout(heightOverridePx: Float): Boolean { ++ val text = view.text ++ val paint = view.paint ++ val heightChanged = ++ MeasurementStore.store(view.id, text, paint, view.trailingMarginBottomPx(), heightOverridePx) ++ if (!heightChanged) return false ++ val stateWrapper = view.stateWrapper ?: return false + val state = Arguments.createMap() + state.putInt("forceHeightRecalculationCounter", ++forceHeightRecalculationCounter) + stateWrapper.updateState(state) ++ return true + } + + fun releaseMeasurementStore() { +diff --git a/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt b/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt +index 7bb48f44c5415f7c6b914934db90705825bfea13..f51c55d40b95615eaaf0fe431b9c9d02cb72b2e4 100644 +--- a/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt ++++ b/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt +@@ -50,6 +50,14 @@ import kotlin.math.ceil + object MeasurementStore { + private const val TAG = "MeasurementStore" + ++ /** ++ * Extra px subtracted from a hugged width before re-measuring the height. ++ * The display TextView resolves per-run font fallback (emoji, combining ++ * marks) that a single TextPaint understates by a sub-pixel or two, which ++ * can flip a line at the exact hug width. ++ */ ++ private const val HUG_WIDTH_SAFETY_PX = 2 ++ + private data class PaintParams( + val typeface: Typeface, + val fontSize: Float, +@@ -113,6 +121,7 @@ object MeasurementStore { + spannable: CharSequence?, + paint: TextPaint, + trailingMarginBottomPx: Float = 0f, ++ heightOverridePx: Float = -1f, + ): Boolean { + val cached = data[id] + val width = cached?.cachedWidth ?: 0f +@@ -120,7 +129,18 @@ object MeasurementStore { + val existingHash = cached?.markdownHash ?: 0 + val paintParams = PaintParams(paint.typeface ?: Typeface.DEFAULT, paint.textSize) + +- var newSize = measure(width, spannable, paint, id) ++ var newSize = ++ if (heightOverridePx >= 0f) { ++ // The display TextView's own span-aware layout (per-run font fallback for ++ // combining marks) can wrap lines the single-paint StaticLayout here ++ // cannot reproduce, so trust the view's laid-out height when it exists. ++ YogaMeasureOutput.make( ++ if (width > 0f) PixelUtil.toDIPFromPixel(width) else YogaMeasureOutput.getWidth(measure(width, spannable, paint, id)), ++ PixelUtil.toDIPFromPixel(heightOverridePx), ++ ) ++ } else { ++ measure(width, spannable, paint, id) ++ } + if (trailingMarginBottomPx > 0f) { + newSize = + YogaMeasureOutput.make( +@@ -543,9 +563,26 @@ object MeasurementStore { + segment.styledText.replaceMathSpansWithPlaceholders(context) + + val layout = createStaticLayout(segment.styledText, fontSize, widthPx, id) +- totalHeightPx += layout.height ++ var segmentHeightPx = layout.height.toFloat() + + val segmentMaxLineWidth = (0 until layout.lineCount).maxOfOrNull { layout.getLineWidth(it) } ?: 0f ++ ++ // Mirror the single-span measure(): the shadow node may be clamped ++ // below widthPx (maxWidth style), and the display TextView re-wraps ++ // at the clamped width, so re-measure the height at the hugged ++ // width when it is narrower. The display's per-run font fallback ++ // (emoji, combining marks) can add a sub-pixel or two of advance ++ // over this single TextPaint, which flips a line at the exact hug ++ // width, so re-measure a hair narrower than the reported line width. ++ val huggedWidth = ceil(segmentMaxLineWidth).toInt() ++ val huggedMeasureWidth = maxOf(1, huggedWidth - HUG_WIDTH_SAFETY_PX) ++ if (huggedMeasureWidth < widthPx) { ++ val huggedLayout = createStaticLayout(segment.styledText, fontSize, huggedMeasureWidth, id) ++ segmentHeightPx = huggedLayout.height.toFloat() ++ } ++ ++ totalHeightPx += segmentHeightPx ++ + maxContentWidthPx = maxOf(maxContentWidthPx, ceil(segmentMaxLineWidth)) + + if (includeBottomMargin) { +@@ -737,17 +774,62 @@ object MeasurementStore { + + val layout = builder.build() + val visibleLineCount = visibleLineCount(layout, viewId) +- val measuredHeight = truncatedHeight(layout, visibleLineCount, content) ++ var measuredHeight = truncatedHeight(layout, visibleLineCount, content) + + // Calculate actual content width (widest visible line) + val measuredWidth = (0 until visibleLineCount).maxOfOrNull { layout.getLineWidth(it) } ?: 0f + ++ // Yoga may clamp this node's width below maxWidth (maxWidth style, a ++ // percentage cap on an ancestor). When it does, the display TextView ++ // re-wraps at the clamped width and can grow taller than the height ++ // measured here, so the text overflows its parent. Re-measure at the ++ // hugged width so the reported height matches the width Yoga receives. ++ // The display's per-run font fallback (emoji, combining marks) can add a ++ // sub-pixel or two of advance over this single TextPaint, which flips a ++ // line at the exact hug width, so re-measure a hair narrower than the ++ // reported line width. ++ if (measuredWidth > 0f) { ++ val huggedWidth = ceil(measuredWidth).toInt() ++ val huggedMeasureWidth = maxOf(1, huggedWidth - HUG_WIDTH_SAFETY_PX) ++ if (huggedMeasureWidth < safeWidth) { ++ val huggedLayout = buildLayout(text = content, paint = paint, widthPx = huggedMeasureWidth, viewId = viewId) ++ val huggedLineCount = visibleLineCount(huggedLayout, viewId) ++ measuredHeight = truncatedHeight(huggedLayout, huggedLineCount, content) ++ } ++ } ++ + return YogaMeasureOutput.make( + PixelUtil.toDIPFromPixel(ceil(measuredWidth)), + PixelUtil.toDIPFromPixel(measuredHeight), + ) + } + ++ private fun buildLayout( ++ text: CharSequence, ++ paint: TextPaint, ++ widthPx: Int, ++ viewId: Int?, ++ ): StaticLayout { ++ prepareImageSpansForMeasurement(text, widthPx) ++ val builder = ++ StaticLayout.Builder ++ .obtain(text, 0, text.length, paint, widthPx) ++ .setIncludePad(false) ++ .setLineSpacing(0f, 1f) ++ ++ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { ++ @Suppress("WrongConstant") ++ builder.setBreakStrategy(resolveBreakStrategy(viewId)) ++ } ++ ++ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { ++ builder.setUseLineSpacingFromFallbacks(true) ++ } ++ ++ applyLineLimit(builder, viewId) ++ return builder.build() ++ } ++ + // Applies the per-view numberOfLines / ellipsizeMode clamp to a builder, matching + // RN's TextLayoutManager.buildLayout: only when a line limit is set. + private fun applyLineLimit( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 474ae9e9a68..39aa9838f0b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -188,6 +188,7 @@ patchedDependencies: expo-task-manager@57.0.21: 7b144f9d84c386dfc1bc93db75d62887c20db3f652bad1f5c01b01ebb531315c expo-widgets@57.0.22: 1c69b613e94332d7166d8c9255b5fd297f56412de480f0b51e8440b768283e81 react-native-appsflyer@7.0.2-v2: 390902fe7a6476a9911a4f2c41e89c8b9035104468c266647d4498d5a913ab73 + react-native-enriched-markdown@1.1.0: 5aaff2d2ce8a4b833a4e5805787bbf00b42ff59e0ce497f488de6c43630c6865 react-native@0.86.3: 113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9 importers: @@ -535,7 +536,7 @@ importers: version: 1.5.2(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@sentry/react-native': specifier: ~8.29.0 - version: 8.29.0(@babel/core@7.29.7)(@expo/config-types@57.0.2)(@expo/config@57.0.9(typescript@7.0.2))(@expo/env@2.4.3)(bufferutil@4.1.0)(dotenv@17.4.2)(expo-router@57.0.24)(expo@57.0.26)(metro-config@0.84.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(metro-resolver@0.84.5)(open@11.0.0)(promise@8.3.0)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(rollup@4.62.3)(typescript@7.0.2)(utf-8-validate@6.0.6)(webpack@5.105.4(esbuild@0.28.2)) + version: 8.29.0(@babel/core@7.29.7)(@expo/config-types@57.0.2)(@expo/config@57.0.9(typescript@7.0.2))(@expo/env@2.4.3)(bufferutil@4.1.0)(dotenv@17.4.2)(expo-router@57.0.24)(expo@57.0.26)(metro-config@0.84.5)(metro-resolver@0.84.5)(open@11.0.0)(promise@8.3.0)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(rollup@4.62.3)(typescript@7.0.2)(utf-8-validate@6.0.6)(webpack@5.105.4(esbuild@0.28.2)) '@shopify/flash-list': specifier: 2.3.3 version: 2.3.3(@babel/runtime@7.29.7)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -748,7 +749,7 @@ importers: version: 3.0.7(@expo/metro-config@57.0.12(bufferutil@4.1.0)(expo@57.0.26)(typescript@7.0.2)(utf-8-validate@6.0.6))(lightningcss@1.30.1)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-enriched-markdown: specifier: 1.1.0 - version: 1.1.0(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 1.1.0(patch_hash=5aaff2d2ce8a4b833a4e5805787bbf00b42ff59e0ce497f488de6c43630c6865)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-gesture-handler: specifier: 2.32.0 version: 2.32.0(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -27438,7 +27439,7 @@ snapshots: '@sentry/conventions': 0.16.0 '@sentry/core': 10.69.0 - '@sentry/react-native@8.29.0(@babel/core@7.29.7)(@expo/config-types@57.0.2)(@expo/config@57.0.9(typescript@7.0.2))(@expo/env@2.4.3)(bufferutil@4.1.0)(dotenv@17.4.2)(expo-router@57.0.24)(expo@57.0.26)(metro-config@0.84.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(metro-resolver@0.84.5)(open@11.0.0)(promise@8.3.0)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(rollup@4.62.3)(typescript@7.0.2)(utf-8-validate@6.0.6)(webpack@5.105.4(esbuild@0.28.2))': + '@sentry/react-native@8.29.0(@babel/core@7.29.7)(@expo/config-types@57.0.2)(@expo/config@57.0.9(typescript@7.0.2))(@expo/env@2.4.3)(bufferutil@4.1.0)(dotenv@17.4.2)(expo-router@57.0.24)(expo@57.0.26)(metro-config@0.84.5)(metro-resolver@0.84.5)(open@11.0.0)(promise@8.3.0)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(rollup@4.62.3)(typescript@7.0.2)(utf-8-validate@6.0.6)(webpack@5.105.4(esbuild@0.28.2))': dependencies: '@expo/config-plugins': 57.0.9(typescript@7.0.2) '@sentry/browser': 10.75.2 @@ -38010,7 +38011,7 @@ snapshots: use-latest-callback: 0.2.6(react@19.2.6) optional: true - react-native-enriched-markdown@1.1.0(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-enriched-markdown@1.1.0(patch_hash=5aaff2d2ce8a4b833a4e5805787bbf00b42ff59e0ce497f488de6c43630c6865)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 react-native: 0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 9b43b1ecbe7..4f62ac95862 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -279,6 +279,7 @@ patchedDependencies: # A finished first entry made a cold start re-enqueue with REPLACE, which # cancelled the glanceable refresh run in progress. expo-background-task@57.0.21: patches/expo-background-task@57.0.21.patch + react-native-enriched-markdown@1.1.0: patches/react-native-enriched-markdown.patch publicHoistPattern: - '@types/*' - '*import-in-the-middle*' From 19665a50963ee9273a135322a98eee47e7d32282 Mon Sep 17 00:00:00 2001 From: kwf Date: Mon, 19 Oct 2026 15:55:26 +0000 Subject: [PATCH 02/26] fix(mobile): re-measure wrapped markdown height at the hugged width Replace the layout-pass backstop with a narrower correction in the measure pass: when the hugged width is narrower than the measured width, re-measure the height there minus a 2 px safety margin. The display view resolves per-run font fallback that the single TextPaint understates, which can flip a line at the exact hug width. --- patches/react-native-enriched-markdown.patch | 130 +------------------ pnpm-lock.yaml | 6 +- 2 files changed, 6 insertions(+), 130 deletions(-) diff --git a/patches/react-native-enriched-markdown.patch b/patches/react-native-enriched-markdown.patch index 2c23a41dbdd..ad3901438d3 100644 --- a/patches/react-native-enriched-markdown.patch +++ b/patches/react-native-enriched-markdown.patch @@ -1,101 +1,5 @@ -diff --git a/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt b/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt -index b6b38b4cc1a37eacf53f48f940608ed7ead26f93..ef75ddf445d282a078236c0fe2f1d05f5d465158 100644 ---- a/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt -+++ b/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt -@@ -628,6 +628,26 @@ class EnrichedMarkdown( - b: Int, - ) { - layoutSegments() -+ correctUnderMeasuredHeight(b - t) -+ } -+ -+ // The shadow-node measure builds its StaticLayout with a single TextPaint, -+ // while the display TextView resolves per-run font fallback (e.g. combining -+ // marks). A run that falls back can wrap into more lines than the measure -+ // saw, so the laid-out children overflow the Yoga-assigned height and the -+ // text paints outside its parent. When the laid-out children are taller than -+ // the assigned frame, re-measure: invalidate the shadow-node cache and bump -+ // the height-recalculation counter so Yoga adopts the real height. -+ private fun correctUnderMeasuredHeight(assignedHeight: Int) { -+ if (assignedHeight <= 0) return -+ val childrenHeight = computeSegmentsTotalHeight() -+ if (childrenHeight <= assignedHeight) return -+ MeasurementStore.invalidate(id) -+ val wrapper = stateWrapper ?: return -+ val state = Arguments.createMap() -+ state.putInt("forceHeightRecalculationCounter", ++forceHeightRecalculationCounter) -+ wrapper.updateState(state) -+ requestLayout() - } - - fun onImageLayoutChanged() { -diff --git a/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownText.kt b/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownText.kt -index f3bb9122d27d80746b9b8ed07ca5247f080a25d0..7c7fde155fb8109357c45d1516157229449596f8 100644 ---- a/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownText.kt -+++ b/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownText.kt -@@ -511,6 +511,21 @@ class EnrichedMarkdownText - ) { - applyContentBoxInset(widthMeasureSpec) - super.onMeasure(widthMeasureSpec, heightMeasureSpec) -+ correctMeasuredHeight() -+ } -+ -+ // The TextView's own span-aware layout (per-run font fallback for combining -+ // marks) can wrap lines the single-paint StaticLayout in MeasurementStore -+ // cannot reproduce, so the Yoga-assigned height can be shorter than the -+ // drawn text and the text overflows its parent. After the first real -+ // layout pass, feed the laid-out height back through the measurement store -+ // so Yoga re-measures with the authoritative value. -+ private fun correctMeasuredHeight() { -+ val laidOutHeight = layout?.height?.toFloat() ?: return -+ val assignedHeight = measuredHeight.toFloat() -+ if (laidOutHeight <= assignedHeight) return -+ if (layoutManager.invalidateLayout(laidOutHeight)) return -+ layoutManager.invalidateLayout() - } - - /** -diff --git a/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownTextLayoutManager.kt b/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownTextLayoutManager.kt -index 3ba2159a7c6389b0202bbbd886f1b88f8aa6d9e5..99e7c7ed1c58241843b99a440bb2f91870b3b969 100644 ---- a/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownTextLayoutManager.kt -+++ b/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownTextLayoutManager.kt -@@ -7,15 +7,30 @@ class EnrichedMarkdownTextLayoutManager( - ) { - private var forceHeightRecalculationCounter = 0 - -- fun invalidateLayout() { -+ fun invalidateLayout(): Boolean { - val text = view.text - val paint = view.paint -- val heightChanged = MeasurementStore.store(view.id, text, paint, view.trailingMarginBottomPx()) -- if (!heightChanged) return -- val stateWrapper = view.stateWrapper ?: return -+ // The TextView's own layout height is authoritative: its span-aware -+ // ParagraphStyle layout (per-run font fallback for combining marks) can -+ // wrap lines a single-paint StaticLayout cannot reproduce. `layout` is -+ // null until the first traversal; the -1 default then keeps the -+ // StaticLayout estimate and the next pass corrects it. -+ val laidOutHeight = view.layout?.height?.toFloat() ?: -1f -+ return invalidateLayout(laidOutHeight) -+ } -+ -+ /** Re-stores the measurement with an explicit height override in px. Returns true when the height changed. */ -+ fun invalidateLayout(heightOverridePx: Float): Boolean { -+ val text = view.text -+ val paint = view.paint -+ val heightChanged = -+ MeasurementStore.store(view.id, text, paint, view.trailingMarginBottomPx(), heightOverridePx) -+ if (!heightChanged) return false -+ val stateWrapper = view.stateWrapper ?: return false - val state = Arguments.createMap() - state.putInt("forceHeightRecalculationCounter", ++forceHeightRecalculationCounter) - stateWrapper.updateState(state) -+ return true - } - - fun releaseMeasurementStore() { diff --git a/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt b/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt -index 7bb48f44c5415f7c6b914934db90705825bfea13..f51c55d40b95615eaaf0fe431b9c9d02cb72b2e4 100644 +index 7bb48f44c5415f7c6b914934db90705825bfea13..92a8cd39e4ed013f91a02b33467e0a86d10c7e4b 100644 --- a/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt +++ b/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt @@ -50,6 +50,14 @@ import kotlin.math.ceil @@ -113,35 +17,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..f51c55d40b95615eaaf0fe431b9c9d02 private data class PaintParams( val typeface: Typeface, val fontSize: Float, -@@ -113,6 +121,7 @@ object MeasurementStore { - spannable: CharSequence?, - paint: TextPaint, - trailingMarginBottomPx: Float = 0f, -+ heightOverridePx: Float = -1f, - ): Boolean { - val cached = data[id] - val width = cached?.cachedWidth ?: 0f -@@ -120,7 +129,18 @@ object MeasurementStore { - val existingHash = cached?.markdownHash ?: 0 - val paintParams = PaintParams(paint.typeface ?: Typeface.DEFAULT, paint.textSize) - -- var newSize = measure(width, spannable, paint, id) -+ var newSize = -+ if (heightOverridePx >= 0f) { -+ // The display TextView's own span-aware layout (per-run font fallback for -+ // combining marks) can wrap lines the single-paint StaticLayout here -+ // cannot reproduce, so trust the view's laid-out height when it exists. -+ YogaMeasureOutput.make( -+ if (width > 0f) PixelUtil.toDIPFromPixel(width) else YogaMeasureOutput.getWidth(measure(width, spannable, paint, id)), -+ PixelUtil.toDIPFromPixel(heightOverridePx), -+ ) -+ } else { -+ measure(width, spannable, paint, id) -+ } - if (trailingMarginBottomPx > 0f) { - newSize = - YogaMeasureOutput.make( -@@ -543,9 +563,26 @@ object MeasurementStore { +@@ -543,9 +551,26 @@ object MeasurementStore { segment.styledText.replaceMathSpansWithPlaceholders(context) val layout = createStaticLayout(segment.styledText, fontSize, widthPx, id) @@ -169,7 +45,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..f51c55d40b95615eaaf0fe431b9c9d02 maxContentWidthPx = maxOf(maxContentWidthPx, ceil(segmentMaxLineWidth)) if (includeBottomMargin) { -@@ -737,17 +774,62 @@ object MeasurementStore { +@@ -737,17 +762,62 @@ object MeasurementStore { val layout = builder.build() val visibleLineCount = visibleLineCount(layout, viewId) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 39aa9838f0b..252c7beae20 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -188,7 +188,7 @@ patchedDependencies: expo-task-manager@57.0.21: 7b144f9d84c386dfc1bc93db75d62887c20db3f652bad1f5c01b01ebb531315c expo-widgets@57.0.22: 1c69b613e94332d7166d8c9255b5fd297f56412de480f0b51e8440b768283e81 react-native-appsflyer@7.0.2-v2: 390902fe7a6476a9911a4f2c41e89c8b9035104468c266647d4498d5a913ab73 - react-native-enriched-markdown@1.1.0: 5aaff2d2ce8a4b833a4e5805787bbf00b42ff59e0ce497f488de6c43630c6865 + react-native-enriched-markdown@1.1.0: 26aab82432a083f56b0537dcfe3cbef8456df039f437595cd7729b6d7f2f933f react-native@0.86.3: 113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9 importers: @@ -749,7 +749,7 @@ importers: version: 3.0.7(@expo/metro-config@57.0.12(bufferutil@4.1.0)(expo@57.0.26)(typescript@7.0.2)(utf-8-validate@6.0.6))(lightningcss@1.30.1)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-enriched-markdown: specifier: 1.1.0 - version: 1.1.0(patch_hash=5aaff2d2ce8a4b833a4e5805787bbf00b42ff59e0ce497f488de6c43630c6865)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 1.1.0(patch_hash=26aab82432a083f56b0537dcfe3cbef8456df039f437595cd7729b6d7f2f933f)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-gesture-handler: specifier: 2.32.0 version: 2.32.0(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -38011,7 +38011,7 @@ snapshots: use-latest-callback: 0.2.6(react@19.2.6) optional: true - react-native-enriched-markdown@1.1.0(patch_hash=5aaff2d2ce8a4b833a4e5805787bbf00b42ff59e0ce497f488de6c43630c6865)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-enriched-markdown@1.1.0(patch_hash=26aab82432a083f56b0537dcfe3cbef8456df039f437595cd7729b6d7f2f933f)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 react-native: 0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) From 0b4b3acb653ed58e6bfcf5203e306848c288b81c Mon Sep 17 00:00:00 2001 From: kwf Date: Mon, 19 Oct 2026 17:18:44 +0000 Subject: [PATCH 03/26] fix(mobile): only re-measure markdown height when the width is clamped Gate the hugged-width re-measure on the node actually being assigned a narrower width than it was measured at, so unclamped single-line text keeps the exact height it measured. --- patches/react-native-enriched-markdown.patch | 38 ++++++++++---------- pnpm-lock.yaml | 6 ++-- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/patches/react-native-enriched-markdown.patch b/patches/react-native-enriched-markdown.patch index ad3901438d3..a7a408e98a3 100644 --- a/patches/react-native-enriched-markdown.patch +++ b/patches/react-native-enriched-markdown.patch @@ -1,5 +1,5 @@ diff --git a/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt b/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt -index 7bb48f44c5415f7c6b914934db90705825bfea13..92a8cd39e4ed013f91a02b33467e0a86d10c7e4b 100644 +index 7bb48f44c5415f7c6b914934db90705825bfea13..8d01f327475950f4635b6af5f98d12e6beb718d2 100644 --- a/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt +++ b/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt @@ -50,6 +50,14 @@ import kotlin.math.ceil @@ -17,7 +17,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..92a8cd39e4ed013f91a02b33467e0a86 private data class PaintParams( val typeface: Typeface, val fontSize: Float, -@@ -543,9 +551,26 @@ object MeasurementStore { +@@ -543,9 +551,27 @@ object MeasurementStore { segment.styledText.replaceMathSpansWithPlaceholders(context) val layout = createStaticLayout(segment.styledText, fontSize, widthPx, id) @@ -28,14 +28,15 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..92a8cd39e4ed013f91a02b33467e0a86 + + // Mirror the single-span measure(): the shadow node may be clamped + // below widthPx (maxWidth style), and the display TextView re-wraps -+ // at the clamped width, so re-measure the height at the hugged -+ // width when it is narrower. The display's per-run font fallback -+ // (emoji, combining marks) can add a sub-pixel or two of advance -+ // over this single TextPaint, which flips a line at the exact hug -+ // width, so re-measure a hair narrower than the reported line width. ++ // at the clamped width. Only that clamp can change the line count, ++ // so re-measure the height there (a hair narrower than the reported ++ // line width) and leave unclamped text on the widthPx height. ++ // The display's per-run font fallback (emoji, combining marks) can ++ // add a sub-pixel or two of advance over this single TextPaint, ++ // which flips a line at the exact hug width. + val huggedWidth = ceil(segmentMaxLineWidth).toInt() -+ val huggedMeasureWidth = maxOf(1, huggedWidth - HUG_WIDTH_SAFETY_PX) -+ if (huggedMeasureWidth < widthPx) { ++ if (huggedWidth < widthPx) { ++ val huggedMeasureWidth = maxOf(1, huggedWidth - HUG_WIDTH_SAFETY_PX) + val huggedLayout = createStaticLayout(segment.styledText, fontSize, huggedMeasureWidth, id) + segmentHeightPx = huggedLayout.height.toFloat() + } @@ -45,7 +46,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..92a8cd39e4ed013f91a02b33467e0a86 maxContentWidthPx = maxOf(maxContentWidthPx, ceil(segmentMaxLineWidth)) if (includeBottomMargin) { -@@ -737,17 +762,62 @@ object MeasurementStore { +@@ -737,17 +763,61 @@ object MeasurementStore { val layout = builder.build() val visibleLineCount = visibleLineCount(layout, viewId) @@ -56,18 +57,17 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..92a8cd39e4ed013f91a02b33467e0a86 val measuredWidth = (0 until visibleLineCount).maxOfOrNull { layout.getLineWidth(it) } ?: 0f + // Yoga may clamp this node's width below maxWidth (maxWidth style, a -+ // percentage cap on an ancestor). When it does, the display TextView -+ // re-wraps at the clamped width and can grow taller than the height -+ // measured here, so the text overflows its parent. Re-measure at the -+ // hugged width so the reported height matches the width Yoga receives. -+ // The display's per-run font fallback (emoji, combining marks) can add a ++ // percentage cap on an ancestor). Only that clamp can change the line ++ // count: the display TextView re-wraps at the clamped width, which can be ++ // taller than the height measured here, so re-measure at that width. The ++ // display's per-run font fallback (emoji, combining marks) can add a + // sub-pixel or two of advance over this single TextPaint, which flips a -+ // line at the exact hug width, so re-measure a hair narrower than the -+ // reported line width. ++ // line at the exact hugged width, so measure a hair narrower than the ++ // reported line width. Unclamped text keeps the height measured above. + if (measuredWidth > 0f) { + val huggedWidth = ceil(measuredWidth).toInt() -+ val huggedMeasureWidth = maxOf(1, huggedWidth - HUG_WIDTH_SAFETY_PX) -+ if (huggedMeasureWidth < safeWidth) { ++ if (huggedWidth < safeWidth) { ++ val huggedMeasureWidth = maxOf(1, huggedWidth - HUG_WIDTH_SAFETY_PX) + val huggedLayout = buildLayout(text = content, paint = paint, widthPx = huggedMeasureWidth, viewId = viewId) + val huggedLineCount = visibleLineCount(huggedLayout, viewId) + measuredHeight = truncatedHeight(huggedLayout, huggedLineCount, content) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 252c7beae20..78aea6f7a87 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -188,7 +188,7 @@ patchedDependencies: expo-task-manager@57.0.21: 7b144f9d84c386dfc1bc93db75d62887c20db3f652bad1f5c01b01ebb531315c expo-widgets@57.0.22: 1c69b613e94332d7166d8c9255b5fd297f56412de480f0b51e8440b768283e81 react-native-appsflyer@7.0.2-v2: 390902fe7a6476a9911a4f2c41e89c8b9035104468c266647d4498d5a913ab73 - react-native-enriched-markdown@1.1.0: 26aab82432a083f56b0537dcfe3cbef8456df039f437595cd7729b6d7f2f933f + react-native-enriched-markdown@1.1.0: 643f1ae1b3c59a41d9225f0b05659bce61a9ca4f435e0a54d63af8de32010774 react-native@0.86.3: 113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9 importers: @@ -749,7 +749,7 @@ importers: version: 3.0.7(@expo/metro-config@57.0.12(bufferutil@4.1.0)(expo@57.0.26)(typescript@7.0.2)(utf-8-validate@6.0.6))(lightningcss@1.30.1)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-enriched-markdown: specifier: 1.1.0 - version: 1.1.0(patch_hash=26aab82432a083f56b0537dcfe3cbef8456df039f437595cd7729b6d7f2f933f)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 1.1.0(patch_hash=643f1ae1b3c59a41d9225f0b05659bce61a9ca4f435e0a54d63af8de32010774)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-gesture-handler: specifier: 2.32.0 version: 2.32.0(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -38011,7 +38011,7 @@ snapshots: use-latest-callback: 0.2.6(react@19.2.6) optional: true - react-native-enriched-markdown@1.1.0(patch_hash=26aab82432a083f56b0537dcfe3cbef8456df039f437595cd7729b6d7f2f933f)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-enriched-markdown@1.1.0(patch_hash=643f1ae1b3c59a41d9225f0b05659bce61a9ca4f435e0a54d63af8de32010774)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 react-native: 0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) From 2ed57cad2eeeed39acd3f76be2c4d079bcb84bc4 Mon Sep 17 00:00:00 2001 From: kwf Date: Mon, 19 Oct 2026 17:33:40 +0000 Subject: [PATCH 04/26] fix(mobile): re-measure markdown height at the width the display view gets The shadow-node contract hands the display view either the full available width (assigned exactly) or the widest line. Re-measure the height a hair narrower than that width only when the widest line comes within the margin of it, since that is when the display's per-run fallback advance can wrap a line the measurement fitted. --- patches/react-native-enriched-markdown.patch | 207 ++++++++++++++++--- pnpm-lock.yaml | 6 +- 2 files changed, 179 insertions(+), 34 deletions(-) diff --git a/patches/react-native-enriched-markdown.patch b/patches/react-native-enriched-markdown.patch index a7a408e98a3..7ac48d10583 100644 --- a/patches/react-native-enriched-markdown.patch +++ b/patches/react-native-enriched-markdown.patch @@ -1,5 +1,5 @@ diff --git a/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt b/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt -index 7bb48f44c5415f7c6b914934db90705825bfea13..8d01f327475950f4635b6af5f98d12e6beb718d2 100644 +index 7bb48f44c5415f7c6b914934db90705825bfea13..39df59647764c7348ec75131b78183bcbc93bc06 100644 --- a/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt +++ b/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt @@ -50,6 +50,14 @@ import kotlin.math.ceil @@ -17,7 +17,88 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..8d01f327475950f4635b6af5f98d12e6 private data class PaintParams( val typeface: Typeface, val fontSize: Float, -@@ -543,9 +551,27 @@ object MeasurementStore { +@@ -120,7 +128,9 @@ object MeasurementStore { + val existingHash = cached?.markdownHash ?: 0 + val paintParams = PaintParams(paint.typeface ?: Typeface.DEFAULT, paint.textSize) + +- var newSize = measure(width, spannable, paint, id) ++ // The display view's width is the width last measured for it, so treat it ++ // as assigned exactly when recomputing the height here. ++ var newSize = measure(width, true, spannable, paint, id) + if (trailingMarginBottomPx > 0f) { + newSize = + YogaMeasureOutput.make( +@@ -177,7 +187,8 @@ object MeasurementStore { + } + } + +- val size = getMeasureByIdInternal(context, id, width, props, splitTableSegments) ++ val exactWidth = widthMode === YogaMeasureMode.EXACTLY ++ val size = getMeasureByIdInternal(context, id, width, exactWidth, props, splitTableSegments) + + // Honor the Yoga width contract. When the parent assigns an exact width + // (flex child, alignItems: stretch, explicit width / width: "100%"), fill it +@@ -273,6 +284,7 @@ object MeasurementStore { + context: Context, + id: Int?, + width: Float, ++ exactWidth: Boolean, + props: ReadableMap?, + splitTableSegments: Boolean, + ): Long { +@@ -282,21 +294,21 @@ object MeasurementStore { + + // Split measurement always goes through the full measure path (no spannable caching) + if (splitTableSegments) { +- return measureAndCacheSplit(context, id, width, props, allowFontScaling, fontScale, maxFontSizeMultiplier) ++ return measureAndCacheSplit(context, id, width, exactWidth, props, allowFontScaling, fontScale, maxFontSizeMultiplier) + } + +- val safeId = id ?: return measureAndCache(context, null, width, props, allowFontScaling, fontScale, maxFontSizeMultiplier) +- val cached = data[safeId] ?: return measureAndCache(context, safeId, width, props, allowFontScaling, fontScale, maxFontSizeMultiplier) ++ val safeId = id ?: return measureAndCache(context, null, width, exactWidth, props, allowFontScaling, fontScale, maxFontSizeMultiplier) ++ val cached = data[safeId] ?: return measureAndCache(context, safeId, width, exactWidth, props, allowFontScaling, fontScale, maxFontSizeMultiplier) + + val currentHash = computePropsHash(props, allowFontScaling, fontScale, maxFontSizeMultiplier) + + if (cached.markdownHash != currentHash) { +- return measureAndCache(context, safeId, width, props, allowFontScaling, fontScale, maxFontSizeMultiplier) ++ return measureAndCache(context, safeId, width, exactWidth, props, allowFontScaling, fontScale, maxFontSizeMultiplier) + } + + // Width changed - re-measure with cached spannable + if (cached.cachedWidth != width) { +- val newSize = measure(width, cached.spannable, cached.paintParams, safeId) ++ val newSize = measure(width, exactWidth, cached.spannable, cached.paintParams, safeId) + data[safeId] = cached.copy(cachedWidth = width, cachedSize = newSize) + return newSize + } +@@ -368,6 +380,7 @@ object MeasurementStore { + context: Context, + id: Int?, + width: Float, ++ exactWidth: Boolean, + props: ReadableMap?, + allowFontScaling: Boolean, + fontScale: Float, +@@ -408,7 +421,7 @@ object MeasurementStore { + ) + spannable?.replaceMathSpansWithPlaceholders(context) + val textToMeasure = spannable ?: markdown +- val (size, _) = measureWithLayout(width, textToMeasure, measurePaint, id) ++ val (size, _) = measureWithLayout(width, exactWidth, textToMeasure, measurePaint, id) + + // 3. Calculate Margin + val allowTrailingMargin = props.getBooleanOrDefault("allowTrailingMargin", false) +@@ -435,6 +448,7 @@ object MeasurementStore { + context: Context, + id: Int?, + width: Float, ++ exactWidth: Boolean, + props: ReadableMap?, + allowFontScaling: Boolean, + fontScale: Float, +@@ -543,9 +557,29 @@ object MeasurementStore { segment.styledText.replaceMathSpansWithPlaceholders(context) val layout = createStaticLayout(segment.styledText, fontSize, widthPx, id) @@ -26,18 +107,20 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..8d01f327475950f4635b6af5f98d12e6 val segmentMaxLineWidth = (0 until layout.lineCount).maxOfOrNull { layout.getLineWidth(it) } ?: 0f + -+ // Mirror the single-span measure(): the shadow node may be clamped -+ // below widthPx (maxWidth style), and the display TextView re-wraps -+ // at the clamped width. Only that clamp can change the line count, -+ // so re-measure the height there (a hair narrower than the reported -+ // line width) and leave unclamped text on the widthPx height. -+ // The display's per-run font fallback (emoji, combining marks) can -+ // add a sub-pixel or two of advance over this single TextPaint, -+ // which flips a line at the exact hug width. -+ val huggedWidth = ceil(segmentMaxLineWidth).toInt() -+ if (huggedWidth < widthPx) { -+ val huggedMeasureWidth = maxOf(1, huggedWidth - HUG_WIDTH_SAFETY_PX) -+ val huggedLayout = createStaticLayout(segment.styledText, fontSize, huggedMeasureWidth, id) ++ // The display view wraps at the width Yoga assigns this node, which ++ // the shadow-node contract reports as the full available width when ++ // the parent assigns one exactly, and as the widest line otherwise. ++ // Its per-run font fallback (emoji, combining marks) can add a ++ // sub-pixel or two of advance over this single TextPaint, so a line ++ // measured as fitting at that width can wrap in the display view. ++ // Re-measure the height a hair narrower than the display width ++ // whenever the widest line comes within that hair of it; text that ++ // sits comfortably inside the width keeps the height measured above. ++ val displayWidthPx = ++ if (exactWidth) widthPx else minOf(widthPx, ceil(segmentMaxLineWidth).toInt()) ++ if (segmentMaxLineWidth > displayWidthPx - HUG_WIDTH_SAFETY_PX) { ++ val huggedWidth = maxOf(1, displayWidthPx - HUG_WIDTH_SAFETY_PX) ++ val huggedLayout = createStaticLayout(segment.styledText, fontSize, huggedWidth, id) + segmentHeightPx = huggedLayout.height.toFloat() + } + @@ -46,7 +129,38 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..8d01f327475950f4635b6af5f98d12e6 maxContentWidthPx = maxOf(maxContentWidthPx, ceil(segmentMaxLineWidth)) if (includeBottomMargin) { -@@ -737,17 +763,61 @@ object MeasurementStore { +@@ -610,7 +644,7 @@ object MeasurementStore { + result + } catch (e: Exception) { + Log.w(TAG, "Split measurement failed, falling back", e) +- measureAndCache(context, id, width, props, allowFontScaling, fontScale, maxFontSizeMultiplier) ++ measureAndCache(context, id, width, exactWidth, props, allowFontScaling, fontScale, maxFontSizeMultiplier) + } + } + +@@ -698,6 +732,7 @@ object MeasurementStore { + + private fun measure( + maxWidth: Float, ++ exactWidth: Boolean, + text: CharSequence?, + paintParams: PaintParams, + viewId: Int?, +@@ -705,11 +740,12 @@ object MeasurementStore { + measurePaint.reset() + measurePaint.typeface = paintParams.typeface + measurePaint.textSize = paintParams.fontSize +- return measure(maxWidth, text, measurePaint, viewId) ++ return measure(maxWidth, exactWidth, text, measurePaint, viewId) + } + + private fun measure( + maxWidth: Float, ++ exactWidth: Boolean, + text: CharSequence?, + paint: TextPaint, + viewId: Int?, +@@ -737,17 +773,58 @@ object MeasurementStore { val layout = builder.build() val visibleLineCount = visibleLineCount(layout, viewId) @@ -56,22 +170,19 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..8d01f327475950f4635b6af5f98d12e6 // Calculate actual content width (widest visible line) val measuredWidth = (0 until visibleLineCount).maxOfOrNull { layout.getLineWidth(it) } ?: 0f -+ // Yoga may clamp this node's width below maxWidth (maxWidth style, a -+ // percentage cap on an ancestor). Only that clamp can change the line -+ // count: the display TextView re-wraps at the clamped width, which can be -+ // taller than the height measured here, so re-measure at that width. The -+ // display's per-run font fallback (emoji, combining marks) can add a -+ // sub-pixel or two of advance over this single TextPaint, which flips a -+ // line at the exact hugged width, so measure a hair narrower than the -+ // reported line width. Unclamped text keeps the height measured above. -+ if (measuredWidth > 0f) { -+ val huggedWidth = ceil(measuredWidth).toInt() -+ if (huggedWidth < safeWidth) { -+ val huggedMeasureWidth = maxOf(1, huggedWidth - HUG_WIDTH_SAFETY_PX) -+ val huggedLayout = buildLayout(text = content, paint = paint, widthPx = huggedMeasureWidth, viewId = viewId) -+ val huggedLineCount = visibleLineCount(huggedLayout, viewId) -+ measuredHeight = truncatedHeight(huggedLayout, huggedLineCount, content) -+ } ++ // The display view wraps at the width Yoga assigns this node: the full ++ // available width when the parent assigns one exactly, the widest line ++ // otherwise. Its per-run font fallback (emoji, combining marks) can add a ++ // sub-pixel or two of advance over this single TextPaint, so a line ++ // measured as fitting at that width can wrap in the display view. When the ++ // widest line comes within that hair of the assigned width, re-measure the ++ // height a hair narrower; text comfortably inside keeps the height above. ++ val displayWidthPx = if (exactWidth) safeWidth else minOf(safeWidth, ceil(measuredWidth).toInt()) ++ if (measuredWidth > displayWidthPx - HUG_WIDTH_SAFETY_PX) { ++ val huggedMeasureWidth = maxOf(1, displayWidthPx - HUG_WIDTH_SAFETY_PX) ++ val huggedLayout = buildLayout(text = content, paint = paint, widthPx = huggedMeasureWidth, viewId = viewId) ++ val huggedLineCount = visibleLineCount(huggedLayout, viewId) ++ measuredHeight = truncatedHeight(huggedLayout, huggedLineCount, content) + } + return YogaMeasureOutput.make( @@ -109,3 +220,37 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..8d01f327475950f4635b6af5f98d12e6 // Applies the per-view numberOfLines / ellipsizeMode clamp to a builder, matching // RN's TextLayoutManager.buildLayout: only when a line limit is set. private fun applyLineLimit( +@@ -816,6 +893,7 @@ object MeasurementStore { + */ + private fun measureWithLayout( + maxWidth: Float, ++ exactWidth: Boolean, + text: CharSequence?, + paint: TextPaint, + viewId: Int?, +@@ -847,10 +925,24 @@ object MeasurementStore { + (0 until visibleLineCount) + .maxOfOrNull { layout.getLineWidth(it) } ?: 0f + ++ // Same contract as measure(): the display view wraps at the width Yoga ++ // assigns (the full available width when assigned exactly, the widest line ++ // otherwise) and its per-run font fallback can add a sub-pixel of advance ++ // over this paint, so a line that fits at that width can wrap. Re-measure ++ // the height a hair narrower when the widest line is that close to it. ++ val displayWidthPx = if (exactWidth) widthPx else minOf(widthPx, ceil(maxLineWidth).toInt()) ++ val height = ++ if (maxLineWidth > displayWidthPx - HUG_WIDTH_SAFETY_PX) { ++ val huggedLayout = buildLayout(content, paint, maxOf(1, displayWidthPx - HUG_WIDTH_SAFETY_PX), viewId) ++ truncatedHeight(huggedLayout, visibleLineCount(huggedLayout, viewId), content) ++ } else { ++ truncatedHeight(layout, visibleLineCount, content) ++ } ++ + val size = + YogaMeasureOutput.make( + PixelUtil.toDIPFromPixel(ceil(maxLineWidth)), +- PixelUtil.toDIPFromPixel(truncatedHeight(layout, visibleLineCount, content)), ++ PixelUtil.toDIPFromPixel(height), + ) + + return size to layout diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 78aea6f7a87..ff33c8a4400 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -188,7 +188,7 @@ patchedDependencies: expo-task-manager@57.0.21: 7b144f9d84c386dfc1bc93db75d62887c20db3f652bad1f5c01b01ebb531315c expo-widgets@57.0.22: 1c69b613e94332d7166d8c9255b5fd297f56412de480f0b51e8440b768283e81 react-native-appsflyer@7.0.2-v2: 390902fe7a6476a9911a4f2c41e89c8b9035104468c266647d4498d5a913ab73 - react-native-enriched-markdown@1.1.0: 643f1ae1b3c59a41d9225f0b05659bce61a9ca4f435e0a54d63af8de32010774 + react-native-enriched-markdown@1.1.0: 899e4011289b4657b642139330edc6ab9e7a419066a2c1bfd2af94b05f2b4ac3 react-native@0.86.3: 113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9 importers: @@ -749,7 +749,7 @@ importers: version: 3.0.7(@expo/metro-config@57.0.12(bufferutil@4.1.0)(expo@57.0.26)(typescript@7.0.2)(utf-8-validate@6.0.6))(lightningcss@1.30.1)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-enriched-markdown: specifier: 1.1.0 - version: 1.1.0(patch_hash=643f1ae1b3c59a41d9225f0b05659bce61a9ca4f435e0a54d63af8de32010774)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 1.1.0(patch_hash=899e4011289b4657b642139330edc6ab9e7a419066a2c1bfd2af94b05f2b4ac3)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-gesture-handler: specifier: 2.32.0 version: 2.32.0(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -38011,7 +38011,7 @@ snapshots: use-latest-callback: 0.2.6(react@19.2.6) optional: true - react-native-enriched-markdown@1.1.0(patch_hash=643f1ae1b3c59a41d9225f0b05659bce61a9ca4f435e0a54d63af8de32010774)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-enriched-markdown@1.1.0(patch_hash=899e4011289b4657b642139330edc6ab9e7a419066a2c1bfd2af94b05f2b4ac3)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 react-native: 0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) From d16ad45fdd6f068817abc2d19a6771404cc4c870 Mon Sep 17 00:00:00 2001 From: kwf Date: Mon, 19 Oct 2026 18:39:00 +0000 Subject: [PATCH 05/26] chore: retrigger review after a rate-limited run From 96a5504611730e2974c060b01df4a5ca344ede1f Mon Sep 17 00:00:00 2001 From: kwf Date: Mon, 19 Oct 2026 19:52:11 +0000 Subject: [PATCH 06/26] fix(mobile): state the hugged and assigned width cases separately A hugged width is the widest line itself, so it is always re-measured; an assigned width only when the widest line fills it. The previous condition hid that in a predicate that was always true for hugged text. --- patches/react-native-enriched-markdown.patch | 47 +++++++++++--------- pnpm-lock.yaml | 6 +-- 2 files changed, 29 insertions(+), 24 deletions(-) diff --git a/patches/react-native-enriched-markdown.patch b/patches/react-native-enriched-markdown.patch index 7ac48d10583..1c2a0f50e11 100644 --- a/patches/react-native-enriched-markdown.patch +++ b/patches/react-native-enriched-markdown.patch @@ -1,5 +1,5 @@ diff --git a/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt b/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt -index 7bb48f44c5415f7c6b914934db90705825bfea13..39df59647764c7348ec75131b78183bcbc93bc06 100644 +index 7bb48f44c5415f7c6b914934db90705825bfea13..d45ec94d81403f35a930598c3ddb1ddd6091251a 100644 --- a/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt +++ b/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt @@ -50,6 +50,14 @@ import kotlin.math.ceil @@ -107,18 +107,18 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..39df59647764c7348ec75131b78183bc val segmentMaxLineWidth = (0 until layout.lineCount).maxOfOrNull { layout.getLineWidth(it) } ?: 0f + -+ // The display view wraps at the width Yoga assigns this node, which -+ // the shadow-node contract reports as the full available width when -+ // the parent assigns one exactly, and as the widest line otherwise. -+ // Its per-run font fallback (emoji, combining marks) can add a -+ // sub-pixel or two of advance over this single TextPaint, so a line -+ // measured as fitting at that width can wrap in the display view. -+ // Re-measure the height a hair narrower than the display width -+ // whenever the widest line comes within that hair of it; text that -+ // sits comfortably inside the width keeps the height measured above. ++ // The display view wraps at the width Yoga assigns this node: the ++ // full available width when the parent assigns one exactly, and the ++ // widest line otherwise. Its per-run font fallback (emoji, ++ // combining marks) can add a sub-pixel or two of advance over this ++ // single TextPaint, so a line measured as fitting at that width can ++ // still wrap. A hugged width is the widest line itself, so it is ++ // always re-measured; an assigned width is only re-measured when the ++ // widest line fills it, where the same flip is possible. + val displayWidthPx = + if (exactWidth) widthPx else minOf(widthPx, ceil(segmentMaxLineWidth).toInt()) -+ if (segmentMaxLineWidth > displayWidthPx - HUG_WIDTH_SAFETY_PX) { ++ val fillsAssignedWidth = segmentMaxLineWidth > displayWidthPx - HUG_WIDTH_SAFETY_PX ++ if (!exactWidth || fillsAssignedWidth) { + val huggedWidth = maxOf(1, displayWidthPx - HUG_WIDTH_SAFETY_PX) + val huggedLayout = createStaticLayout(segment.styledText, fontSize, huggedWidth, id) + segmentHeightPx = huggedLayout.height.toFloat() @@ -160,7 +160,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..39df59647764c7348ec75131b78183bc text: CharSequence?, paint: TextPaint, viewId: Int?, -@@ -737,17 +773,58 @@ object MeasurementStore { +@@ -737,17 +773,61 @@ object MeasurementStore { val layout = builder.build() val visibleLineCount = visibleLineCount(layout, viewId) @@ -174,11 +174,14 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..39df59647764c7348ec75131b78183bc + // available width when the parent assigns one exactly, the widest line + // otherwise. Its per-run font fallback (emoji, combining marks) can add a + // sub-pixel or two of advance over this single TextPaint, so a line -+ // measured as fitting at that width can wrap in the display view. When the -+ // widest line comes within that hair of the assigned width, re-measure the -+ // height a hair narrower; text comfortably inside keeps the height above. ++ // measured as fitting at that width can wrap in the display view. A hugged ++ // width is the widest line itself, so it is always re-measured; an assigned ++ // width is only re-measured when the widest line fills it, where the same ++ // flip is possible. Text comfortably inside an assigned width keeps the ++ // height measured above. + val displayWidthPx = if (exactWidth) safeWidth else minOf(safeWidth, ceil(measuredWidth).toInt()) -+ if (measuredWidth > displayWidthPx - HUG_WIDTH_SAFETY_PX) { ++ val fillsAssignedWidth = measuredWidth > displayWidthPx - HUG_WIDTH_SAFETY_PX ++ if (!exactWidth || fillsAssignedWidth) { + val huggedMeasureWidth = maxOf(1, displayWidthPx - HUG_WIDTH_SAFETY_PX) + val huggedLayout = buildLayout(text = content, paint = paint, widthPx = huggedMeasureWidth, viewId = viewId) + val huggedLineCount = visibleLineCount(huggedLayout, viewId) @@ -220,7 +223,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..39df59647764c7348ec75131b78183bc // Applies the per-view numberOfLines / ellipsizeMode clamp to a builder, matching // RN's TextLayoutManager.buildLayout: only when a line limit is set. private fun applyLineLimit( -@@ -816,6 +893,7 @@ object MeasurementStore { +@@ -816,6 +896,7 @@ object MeasurementStore { */ private fun measureWithLayout( maxWidth: Float, @@ -228,18 +231,20 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..39df59647764c7348ec75131b78183bc text: CharSequence?, paint: TextPaint, viewId: Int?, -@@ -847,10 +925,24 @@ object MeasurementStore { +@@ -847,10 +928,26 @@ object MeasurementStore { (0 until visibleLineCount) .maxOfOrNull { layout.getLineWidth(it) } ?: 0f + // Same contract as measure(): the display view wraps at the width Yoga + // assigns (the full available width when assigned exactly, the widest line + // otherwise) and its per-run font fallback can add a sub-pixel of advance -+ // over this paint, so a line that fits at that width can wrap. Re-measure -+ // the height a hair narrower when the widest line is that close to it. ++ // over this paint, so a line that fits at that width can wrap. A hugged ++ // width is the widest line itself, so it is always re-measured; an assigned ++ // width only when the widest line fills it. + val displayWidthPx = if (exactWidth) widthPx else minOf(widthPx, ceil(maxLineWidth).toInt()) ++ val fillsAssignedWidth = maxLineWidth > displayWidthPx - HUG_WIDTH_SAFETY_PX + val height = -+ if (maxLineWidth > displayWidthPx - HUG_WIDTH_SAFETY_PX) { ++ if (!exactWidth || fillsAssignedWidth) { + val huggedLayout = buildLayout(content, paint, maxOf(1, displayWidthPx - HUG_WIDTH_SAFETY_PX), viewId) + truncatedHeight(huggedLayout, visibleLineCount(huggedLayout, viewId), content) + } else { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff33c8a4400..31cb5e92231 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -188,7 +188,7 @@ patchedDependencies: expo-task-manager@57.0.21: 7b144f9d84c386dfc1bc93db75d62887c20db3f652bad1f5c01b01ebb531315c expo-widgets@57.0.22: 1c69b613e94332d7166d8c9255b5fd297f56412de480f0b51e8440b768283e81 react-native-appsflyer@7.0.2-v2: 390902fe7a6476a9911a4f2c41e89c8b9035104468c266647d4498d5a913ab73 - react-native-enriched-markdown@1.1.0: 899e4011289b4657b642139330edc6ab9e7a419066a2c1bfd2af94b05f2b4ac3 + react-native-enriched-markdown@1.1.0: 02a258b4316e30b59546bed5cfd68ab0542e979773524a08d99bf70212cb0705 react-native@0.86.3: 113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9 importers: @@ -749,7 +749,7 @@ importers: version: 3.0.7(@expo/metro-config@57.0.12(bufferutil@4.1.0)(expo@57.0.26)(typescript@7.0.2)(utf-8-validate@6.0.6))(lightningcss@1.30.1)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-enriched-markdown: specifier: 1.1.0 - version: 1.1.0(patch_hash=899e4011289b4657b642139330edc6ab9e7a419066a2c1bfd2af94b05f2b4ac3)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 1.1.0(patch_hash=02a258b4316e30b59546bed5cfd68ab0542e979773524a08d99bf70212cb0705)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-gesture-handler: specifier: 2.32.0 version: 2.32.0(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -38011,7 +38011,7 @@ snapshots: use-latest-callback: 0.2.6(react@19.2.6) optional: true - react-native-enriched-markdown@1.1.0(patch_hash=899e4011289b4657b642139330edc6ab9e7a419066a2c1bfd2af94b05f2b4ac3)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-enriched-markdown@1.1.0(patch_hash=02a258b4316e30b59546bed5cfd68ab0542e979773524a08d99bf70212cb0705)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 react-native: 0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) From 23ed93886c1c68b674ab92fde1b0f1c7be7a63ac Mon Sep 17 00:00:00 2001 From: kwf Date: Mon, 19 Oct 2026 21:46:37 +0000 Subject: [PATCH 07/26] chore: request a review of the final head From ba83117b5417ec6506bf94dcd4b54465de4bbc66 Mon Sep 17 00:00:00 2001 From: kwf Date: Mon, 19 Oct 2026 22:02:16 +0000 Subject: [PATCH 08/26] docs: record the widen alternative in the patch constant The widening direction was measured on device and still wrapped, so the patch keeps the reserve re-measure; the constant now says so. --- patches/react-native-enriched-markdown.patch | 37 +++++++++++--------- pnpm-lock.yaml | 6 ++-- 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/patches/react-native-enriched-markdown.patch b/patches/react-native-enriched-markdown.patch index 1c2a0f50e11..dab484270a8 100644 --- a/patches/react-native-enriched-markdown.patch +++ b/patches/react-native-enriched-markdown.patch @@ -1,8 +1,8 @@ diff --git a/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt b/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt -index 7bb48f44c5415f7c6b914934db90705825bfea13..d45ec94d81403f35a930598c3ddb1ddd6091251a 100644 +index 7bb48f44c5415f7c6b914934db90705825bfea13..93b5f767f528568e6bbc5e537b37e8b755491f23 100644 --- a/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt +++ b/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt -@@ -50,6 +50,14 @@ import kotlin.math.ceil +@@ -50,6 +50,19 @@ import kotlin.math.ceil object MeasurementStore { private const val TAG = "MeasurementStore" @@ -11,13 +11,18 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..d45ec94d81403f35a930598c3ddb1ddd + * The display TextView resolves per-run font fallback (emoji, combining + * marks) that a single TextPaint understates by a sub-pixel or two, which + * can flip a line at the exact hug width. ++ * ++ * The opposite trade — reporting a width widened by this margin so the ++ * display gets slack without reserving a line — was measured on device and ++ * still wrapped: the display view's advance exceeded the widened width, so ++ * reserving is the only direction that keeps the text inside its bubble. + */ + private const val HUG_WIDTH_SAFETY_PX = 2 + private data class PaintParams( val typeface: Typeface, val fontSize: Float, -@@ -120,7 +128,9 @@ object MeasurementStore { +@@ -120,7 +133,9 @@ object MeasurementStore { val existingHash = cached?.markdownHash ?: 0 val paintParams = PaintParams(paint.typeface ?: Typeface.DEFAULT, paint.textSize) @@ -28,7 +33,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..d45ec94d81403f35a930598c3ddb1ddd if (trailingMarginBottomPx > 0f) { newSize = YogaMeasureOutput.make( -@@ -177,7 +187,8 @@ object MeasurementStore { +@@ -177,7 +192,8 @@ object MeasurementStore { } } @@ -38,7 +43,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..d45ec94d81403f35a930598c3ddb1ddd // Honor the Yoga width contract. When the parent assigns an exact width // (flex child, alignItems: stretch, explicit width / width: "100%"), fill it -@@ -273,6 +284,7 @@ object MeasurementStore { +@@ -273,6 +289,7 @@ object MeasurementStore { context: Context, id: Int?, width: Float, @@ -46,7 +51,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..d45ec94d81403f35a930598c3ddb1ddd props: ReadableMap?, splitTableSegments: Boolean, ): Long { -@@ -282,21 +294,21 @@ object MeasurementStore { +@@ -282,21 +299,21 @@ object MeasurementStore { // Split measurement always goes through the full measure path (no spannable caching) if (splitTableSegments) { @@ -73,7 +78,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..d45ec94d81403f35a930598c3ddb1ddd data[safeId] = cached.copy(cachedWidth = width, cachedSize = newSize) return newSize } -@@ -368,6 +380,7 @@ object MeasurementStore { +@@ -368,6 +385,7 @@ object MeasurementStore { context: Context, id: Int?, width: Float, @@ -81,7 +86,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..d45ec94d81403f35a930598c3ddb1ddd props: ReadableMap?, allowFontScaling: Boolean, fontScale: Float, -@@ -408,7 +421,7 @@ object MeasurementStore { +@@ -408,7 +426,7 @@ object MeasurementStore { ) spannable?.replaceMathSpansWithPlaceholders(context) val textToMeasure = spannable ?: markdown @@ -90,7 +95,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..d45ec94d81403f35a930598c3ddb1ddd // 3. Calculate Margin val allowTrailingMargin = props.getBooleanOrDefault("allowTrailingMargin", false) -@@ -435,6 +448,7 @@ object MeasurementStore { +@@ -435,6 +453,7 @@ object MeasurementStore { context: Context, id: Int?, width: Float, @@ -98,7 +103,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..d45ec94d81403f35a930598c3ddb1ddd props: ReadableMap?, allowFontScaling: Boolean, fontScale: Float, -@@ -543,9 +557,29 @@ object MeasurementStore { +@@ -543,9 +562,29 @@ object MeasurementStore { segment.styledText.replaceMathSpansWithPlaceholders(context) val layout = createStaticLayout(segment.styledText, fontSize, widthPx, id) @@ -129,7 +134,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..d45ec94d81403f35a930598c3ddb1ddd maxContentWidthPx = maxOf(maxContentWidthPx, ceil(segmentMaxLineWidth)) if (includeBottomMargin) { -@@ -610,7 +644,7 @@ object MeasurementStore { +@@ -610,7 +649,7 @@ object MeasurementStore { result } catch (e: Exception) { Log.w(TAG, "Split measurement failed, falling back", e) @@ -138,7 +143,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..d45ec94d81403f35a930598c3ddb1ddd } } -@@ -698,6 +732,7 @@ object MeasurementStore { +@@ -698,6 +737,7 @@ object MeasurementStore { private fun measure( maxWidth: Float, @@ -146,7 +151,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..d45ec94d81403f35a930598c3ddb1ddd text: CharSequence?, paintParams: PaintParams, viewId: Int?, -@@ -705,11 +740,12 @@ object MeasurementStore { +@@ -705,11 +745,12 @@ object MeasurementStore { measurePaint.reset() measurePaint.typeface = paintParams.typeface measurePaint.textSize = paintParams.fontSize @@ -160,7 +165,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..d45ec94d81403f35a930598c3ddb1ddd text: CharSequence?, paint: TextPaint, viewId: Int?, -@@ -737,17 +773,61 @@ object MeasurementStore { +@@ -737,17 +778,61 @@ object MeasurementStore { val layout = builder.build() val visibleLineCount = visibleLineCount(layout, viewId) @@ -223,7 +228,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..d45ec94d81403f35a930598c3ddb1ddd // Applies the per-view numberOfLines / ellipsizeMode clamp to a builder, matching // RN's TextLayoutManager.buildLayout: only when a line limit is set. private fun applyLineLimit( -@@ -816,6 +896,7 @@ object MeasurementStore { +@@ -816,6 +901,7 @@ object MeasurementStore { */ private fun measureWithLayout( maxWidth: Float, @@ -231,7 +236,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..d45ec94d81403f35a930598c3ddb1ddd text: CharSequence?, paint: TextPaint, viewId: Int?, -@@ -847,10 +928,26 @@ object MeasurementStore { +@@ -847,10 +933,26 @@ object MeasurementStore { (0 until visibleLineCount) .maxOfOrNull { layout.getLineWidth(it) } ?: 0f diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 31cb5e92231..5d22dd406a3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -188,7 +188,7 @@ patchedDependencies: expo-task-manager@57.0.21: 7b144f9d84c386dfc1bc93db75d62887c20db3f652bad1f5c01b01ebb531315c expo-widgets@57.0.22: 1c69b613e94332d7166d8c9255b5fd297f56412de480f0b51e8440b768283e81 react-native-appsflyer@7.0.2-v2: 390902fe7a6476a9911a4f2c41e89c8b9035104468c266647d4498d5a913ab73 - react-native-enriched-markdown@1.1.0: 02a258b4316e30b59546bed5cfd68ab0542e979773524a08d99bf70212cb0705 + react-native-enriched-markdown@1.1.0: 2e8c6d3e99e2b4726bf50f5ff81f07475a1afd1da43071b9d40a74bad4da6a7f react-native@0.86.3: 113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9 importers: @@ -749,7 +749,7 @@ importers: version: 3.0.7(@expo/metro-config@57.0.12(bufferutil@4.1.0)(expo@57.0.26)(typescript@7.0.2)(utf-8-validate@6.0.6))(lightningcss@1.30.1)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-enriched-markdown: specifier: 1.1.0 - version: 1.1.0(patch_hash=02a258b4316e30b59546bed5cfd68ab0542e979773524a08d99bf70212cb0705)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 1.1.0(patch_hash=2e8c6d3e99e2b4726bf50f5ff81f07475a1afd1da43071b9d40a74bad4da6a7f)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-gesture-handler: specifier: 2.32.0 version: 2.32.0(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -38011,7 +38011,7 @@ snapshots: use-latest-callback: 0.2.6(react@19.2.6) optional: true - react-native-enriched-markdown@1.1.0(patch_hash=02a258b4316e30b59546bed5cfd68ab0542e979773524a08d99bf70212cb0705)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-enriched-markdown@1.1.0(patch_hash=2e8c6d3e99e2b4726bf50f5ff81f07475a1afd1da43071b9d40a74bad4da6a7f)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 react-native: 0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) From b2b5e023bf05e75c52cc76ab53aa253a97dd75cc Mon Sep 17 00:00:00 2001 From: kwf Date: Mon, 19 Oct 2026 23:49:20 +0000 Subject: [PATCH 09/26] chore: refresh review on the current head From f04ec8130114410792ef0c908f52177bb252d896 Mon Sep 17 00:00:00 2001 From: kwf Date: Mon, 19 Oct 2026 23:58:15 +0000 Subject: [PATCH 10/26] chore: retry the review trigger From a414607522d94f1a332a08cd2d83ecd0aa7a8482 Mon Sep 17 00:00:00 2001 From: kwf Date: Tue, 20 Oct 2026 00:21:30 +0000 Subject: [PATCH 11/26] docs: clarify that the height comes from the assigned-width layout --- patches/react-native-enriched-markdown.patch | 20 +++++++++++--------- pnpm-lock.yaml | 6 +++--- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/patches/react-native-enriched-markdown.patch b/patches/react-native-enriched-markdown.patch index dab484270a8..28a4068c93b 100644 --- a/patches/react-native-enriched-markdown.patch +++ b/patches/react-native-enriched-markdown.patch @@ -1,5 +1,5 @@ diff --git a/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt b/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt -index 7bb48f44c5415f7c6b914934db90705825bfea13..93b5f767f528568e6bbc5e537b37e8b755491f23 100644 +index 7bb48f44c5415f7c6b914934db90705825bfea13..7eb3a71c3278e7af6f99a01c62592cf2b49f1095 100644 --- a/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt +++ b/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt @@ -50,6 +50,19 @@ import kotlin.math.ceil @@ -103,7 +103,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..93b5f767f528568e6bbc5e537b37e8b7 props: ReadableMap?, allowFontScaling: Boolean, fontScale: Float, -@@ -543,9 +562,29 @@ object MeasurementStore { +@@ -543,9 +562,31 @@ object MeasurementStore { segment.styledText.replaceMathSpansWithPlaceholders(context) val layout = createStaticLayout(segment.styledText, fontSize, widthPx, id) @@ -119,7 +119,9 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..93b5f767f528568e6bbc5e537b37e8b7 + // single TextPaint, so a line measured as fitting at that width can + // still wrap. A hugged width is the widest line itself, so it is + // always re-measured; an assigned width is only re-measured when the -+ // widest line fills it, where the same flip is possible. ++ // widest line fills it, where the same flip is possible. The height ++ // is always taken from the layout at the assigned width, never from ++ // the wider measurement layout above. + val displayWidthPx = + if (exactWidth) widthPx else minOf(widthPx, ceil(segmentMaxLineWidth).toInt()) + val fillsAssignedWidth = segmentMaxLineWidth > displayWidthPx - HUG_WIDTH_SAFETY_PX @@ -134,7 +136,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..93b5f767f528568e6bbc5e537b37e8b7 maxContentWidthPx = maxOf(maxContentWidthPx, ceil(segmentMaxLineWidth)) if (includeBottomMargin) { -@@ -610,7 +649,7 @@ object MeasurementStore { +@@ -610,7 +651,7 @@ object MeasurementStore { result } catch (e: Exception) { Log.w(TAG, "Split measurement failed, falling back", e) @@ -143,7 +145,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..93b5f767f528568e6bbc5e537b37e8b7 } } -@@ -698,6 +737,7 @@ object MeasurementStore { +@@ -698,6 +739,7 @@ object MeasurementStore { private fun measure( maxWidth: Float, @@ -151,7 +153,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..93b5f767f528568e6bbc5e537b37e8b7 text: CharSequence?, paintParams: PaintParams, viewId: Int?, -@@ -705,11 +745,12 @@ object MeasurementStore { +@@ -705,11 +747,12 @@ object MeasurementStore { measurePaint.reset() measurePaint.typeface = paintParams.typeface measurePaint.textSize = paintParams.fontSize @@ -165,7 +167,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..93b5f767f528568e6bbc5e537b37e8b7 text: CharSequence?, paint: TextPaint, viewId: Int?, -@@ -737,17 +778,61 @@ object MeasurementStore { +@@ -737,17 +780,61 @@ object MeasurementStore { val layout = builder.build() val visibleLineCount = visibleLineCount(layout, viewId) @@ -228,7 +230,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..93b5f767f528568e6bbc5e537b37e8b7 // Applies the per-view numberOfLines / ellipsizeMode clamp to a builder, matching // RN's TextLayoutManager.buildLayout: only when a line limit is set. private fun applyLineLimit( -@@ -816,6 +901,7 @@ object MeasurementStore { +@@ -816,6 +903,7 @@ object MeasurementStore { */ private fun measureWithLayout( maxWidth: Float, @@ -236,7 +238,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..93b5f767f528568e6bbc5e537b37e8b7 text: CharSequence?, paint: TextPaint, viewId: Int?, -@@ -847,10 +933,26 @@ object MeasurementStore { +@@ -847,10 +935,26 @@ object MeasurementStore { (0 until visibleLineCount) .maxOfOrNull { layout.getLineWidth(it) } ?: 0f diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5d22dd406a3..1009f9741a9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -188,7 +188,7 @@ patchedDependencies: expo-task-manager@57.0.21: 7b144f9d84c386dfc1bc93db75d62887c20db3f652bad1f5c01b01ebb531315c expo-widgets@57.0.22: 1c69b613e94332d7166d8c9255b5fd297f56412de480f0b51e8440b768283e81 react-native-appsflyer@7.0.2-v2: 390902fe7a6476a9911a4f2c41e89c8b9035104468c266647d4498d5a913ab73 - react-native-enriched-markdown@1.1.0: 2e8c6d3e99e2b4726bf50f5ff81f07475a1afd1da43071b9d40a74bad4da6a7f + react-native-enriched-markdown@1.1.0: 9464bd32d8c37e1be7148ae24a731c1abbef30b4f3aa842e6d4c56f72cbe0909 react-native@0.86.3: 113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9 importers: @@ -749,7 +749,7 @@ importers: version: 3.0.7(@expo/metro-config@57.0.12(bufferutil@4.1.0)(expo@57.0.26)(typescript@7.0.2)(utf-8-validate@6.0.6))(lightningcss@1.30.1)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-enriched-markdown: specifier: 1.1.0 - version: 1.1.0(patch_hash=2e8c6d3e99e2b4726bf50f5ff81f07475a1afd1da43071b9d40a74bad4da6a7f)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 1.1.0(patch_hash=9464bd32d8c37e1be7148ae24a731c1abbef30b4f3aa842e6d4c56f72cbe0909)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-gesture-handler: specifier: 2.32.0 version: 2.32.0(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -38011,7 +38011,7 @@ snapshots: use-latest-callback: 0.2.6(react@19.2.6) optional: true - react-native-enriched-markdown@1.1.0(patch_hash=2e8c6d3e99e2b4726bf50f5ff81f07475a1afd1da43071b9d40a74bad4da6a7f)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-enriched-markdown@1.1.0(patch_hash=9464bd32d8c37e1be7148ae24a731c1abbef30b4f3aa842e6d4c56f72cbe0909)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 react-native: 0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) From be9622e87e90f2ada1df69eefbd173b9f66e5e30 Mon Sep 17 00:00:00 2001 From: kwf Date: Tue, 20 Oct 2026 01:35:26 +0000 Subject: [PATCH 12/26] chore: nudge the reviewer From e8093cf3b4b7ae762da8699bb313b2cdd92d1176 Mon Sep 17 00:00:00 2001 From: kwf Date: Tue, 20 Oct 2026 01:50:33 +0000 Subject: [PATCH 13/26] chore: retry the review after the integration auth failure From 6bce717cad7966f0192b150602bb1d9007fa63eb Mon Sep 17 00:00:00 2001 From: kwf Date: Tue, 20 Oct 2026 02:30:07 +0000 Subject: [PATCH 14/26] chore: retry the review once more From d86dedfd357e37a814984c044d215a01949d7f86 Mon Sep 17 00:00:00 2001 From: kwf Date: Tue, 20 Oct 2026 03:15:57 +0000 Subject: [PATCH 15/26] fix(mobile): adopt the taller height only when the hug actually wraps Gate the reserved height on the narrower layout wrapping into more lines than the measurement layout; text that fits at the assigned width keeps the height measured there, so a single-line message never reserves a second line it does not draw. --- patches/react-native-enriched-markdown.patch | 61 +++++++++++--------- pnpm-lock.yaml | 6 +- 2 files changed, 37 insertions(+), 30 deletions(-) diff --git a/patches/react-native-enriched-markdown.patch b/patches/react-native-enriched-markdown.patch index 28a4068c93b..7cad265c5b7 100644 --- a/patches/react-native-enriched-markdown.patch +++ b/patches/react-native-enriched-markdown.patch @@ -1,5 +1,5 @@ diff --git a/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt b/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt -index 7bb48f44c5415f7c6b914934db90705825bfea13..7eb3a71c3278e7af6f99a01c62592cf2b49f1095 100644 +index 7bb48f44c5415f7c6b914934db90705825bfea13..7a8093d2cc99029c0fd1d83af71b612d76bc3f16 100644 --- a/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt +++ b/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt @@ -50,6 +50,19 @@ import kotlin.math.ceil @@ -103,7 +103,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..7eb3a71c3278e7af6f99a01c62592cf2 props: ReadableMap?, allowFontScaling: Boolean, fontScale: Float, -@@ -543,9 +562,31 @@ object MeasurementStore { +@@ -543,9 +562,32 @@ object MeasurementStore { segment.styledText.replaceMathSpansWithPlaceholders(context) val layout = createStaticLayout(segment.styledText, fontSize, widthPx, id) @@ -117,18 +117,19 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..7eb3a71c3278e7af6f99a01c62592cf2 + // widest line otherwise. Its per-run font fallback (emoji, + // combining marks) can add a sub-pixel or two of advance over this + // single TextPaint, so a line measured as fitting at that width can -+ // still wrap. A hugged width is the widest line itself, so it is -+ // always re-measured; an assigned width is only re-measured when the -+ // widest line fills it, where the same flip is possible. The height -+ // is always taken from the layout at the assigned width, never from -+ // the wider measurement layout above. ++ // still wrap. When the widest line fills the assigned width, ++ // re-measure at a hair narrower and adopt the taller height only if ++ // that layout actually wraps into more lines — text that fits keeps ++ // the height measured above. + val displayWidthPx = + if (exactWidth) widthPx else minOf(widthPx, ceil(segmentMaxLineWidth).toInt()) + val fillsAssignedWidth = segmentMaxLineWidth > displayWidthPx - HUG_WIDTH_SAFETY_PX -+ if (!exactWidth || fillsAssignedWidth) { ++ if (fillsAssignedWidth) { + val huggedWidth = maxOf(1, displayWidthPx - HUG_WIDTH_SAFETY_PX) + val huggedLayout = createStaticLayout(segment.styledText, fontSize, huggedWidth, id) -+ segmentHeightPx = huggedLayout.height.toFloat() ++ if (huggedLayout.lineCount > layout.lineCount) { ++ segmentHeightPx = huggedLayout.height.toFloat() ++ } + } + + totalHeightPx += segmentHeightPx @@ -136,7 +137,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..7eb3a71c3278e7af6f99a01c62592cf2 maxContentWidthPx = maxOf(maxContentWidthPx, ceil(segmentMaxLineWidth)) if (includeBottomMargin) { -@@ -610,7 +651,7 @@ object MeasurementStore { +@@ -610,7 +652,7 @@ object MeasurementStore { result } catch (e: Exception) { Log.w(TAG, "Split measurement failed, falling back", e) @@ -145,7 +146,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..7eb3a71c3278e7af6f99a01c62592cf2 } } -@@ -698,6 +739,7 @@ object MeasurementStore { +@@ -698,6 +740,7 @@ object MeasurementStore { private fun measure( maxWidth: Float, @@ -153,7 +154,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..7eb3a71c3278e7af6f99a01c62592cf2 text: CharSequence?, paintParams: PaintParams, viewId: Int?, -@@ -705,11 +747,12 @@ object MeasurementStore { +@@ -705,11 +748,12 @@ object MeasurementStore { measurePaint.reset() measurePaint.typeface = paintParams.typeface measurePaint.textSize = paintParams.fontSize @@ -167,7 +168,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..7eb3a71c3278e7af6f99a01c62592cf2 text: CharSequence?, paint: TextPaint, viewId: Int?, -@@ -737,17 +780,61 @@ object MeasurementStore { +@@ -737,17 +781,62 @@ object MeasurementStore { val layout = builder.build() val visibleLineCount = visibleLineCount(layout, viewId) @@ -181,18 +182,19 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..7eb3a71c3278e7af6f99a01c62592cf2 + // available width when the parent assigns one exactly, the widest line + // otherwise. Its per-run font fallback (emoji, combining marks) can add a + // sub-pixel or two of advance over this single TextPaint, so a line -+ // measured as fitting at that width can wrap in the display view. A hugged -+ // width is the widest line itself, so it is always re-measured; an assigned -+ // width is only re-measured when the widest line fills it, where the same -+ // flip is possible. Text comfortably inside an assigned width keeps the -+ // height measured above. ++ // measured as fitting at that width can wrap in the display view. When the ++ // widest line fills the assigned width, re-measure at a hair narrower and ++ // adopt the taller height only if that layout actually wraps into more ++ // lines — text that fits keeps the height measured above. + val displayWidthPx = if (exactWidth) safeWidth else minOf(safeWidth, ceil(measuredWidth).toInt()) + val fillsAssignedWidth = measuredWidth > displayWidthPx - HUG_WIDTH_SAFETY_PX -+ if (!exactWidth || fillsAssignedWidth) { ++ if (fillsAssignedWidth) { + val huggedMeasureWidth = maxOf(1, displayWidthPx - HUG_WIDTH_SAFETY_PX) + val huggedLayout = buildLayout(text = content, paint = paint, widthPx = huggedMeasureWidth, viewId = viewId) + val huggedLineCount = visibleLineCount(huggedLayout, viewId) -+ measuredHeight = truncatedHeight(huggedLayout, huggedLineCount, content) ++ if (huggedLineCount > visibleLineCount) { ++ measuredHeight = truncatedHeight(huggedLayout, huggedLineCount, content) ++ } + } + return YogaMeasureOutput.make( @@ -230,7 +232,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..7eb3a71c3278e7af6f99a01c62592cf2 // Applies the per-view numberOfLines / ellipsizeMode clamp to a builder, matching // RN's TextLayoutManager.buildLayout: only when a line limit is set. private fun applyLineLimit( -@@ -816,6 +903,7 @@ object MeasurementStore { +@@ -816,6 +905,7 @@ object MeasurementStore { */ private fun measureWithLayout( maxWidth: Float, @@ -238,22 +240,27 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..7eb3a71c3278e7af6f99a01c62592cf2 text: CharSequence?, paint: TextPaint, viewId: Int?, -@@ -847,10 +935,26 @@ object MeasurementStore { +@@ -847,10 +937,31 @@ object MeasurementStore { (0 until visibleLineCount) .maxOfOrNull { layout.getLineWidth(it) } ?: 0f + // Same contract as measure(): the display view wraps at the width Yoga + // assigns (the full available width when assigned exactly, the widest line + // otherwise) and its per-run font fallback can add a sub-pixel of advance -+ // over this paint, so a line that fits at that width can wrap. A hugged -+ // width is the widest line itself, so it is always re-measured; an assigned -+ // width only when the widest line fills it. ++ // over this paint, so a line that fits at that width can wrap. When the ++ // widest line fills the assigned width, re-measure at a hair narrower and ++ // adopt the taller height only if that layout actually wraps into more ++ // lines; text that fits keeps the height measured above. + val displayWidthPx = if (exactWidth) widthPx else minOf(widthPx, ceil(maxLineWidth).toInt()) + val fillsAssignedWidth = maxLineWidth > displayWidthPx - HUG_WIDTH_SAFETY_PX + val height = -+ if (!exactWidth || fillsAssignedWidth) { ++ if (fillsAssignedWidth) { + val huggedLayout = buildLayout(content, paint, maxOf(1, displayWidthPx - HUG_WIDTH_SAFETY_PX), viewId) -+ truncatedHeight(huggedLayout, visibleLineCount(huggedLayout, viewId), content) ++ if (visibleLineCount(huggedLayout, viewId) > visibleLineCount) { ++ truncatedHeight(huggedLayout, visibleLineCount(huggedLayout, viewId), content) ++ } else { ++ truncatedHeight(layout, visibleLineCount, content) ++ } + } else { + truncatedHeight(layout, visibleLineCount, content) + } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1009f9741a9..f5c85a5a71a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -188,7 +188,7 @@ patchedDependencies: expo-task-manager@57.0.21: 7b144f9d84c386dfc1bc93db75d62887c20db3f652bad1f5c01b01ebb531315c expo-widgets@57.0.22: 1c69b613e94332d7166d8c9255b5fd297f56412de480f0b51e8440b768283e81 react-native-appsflyer@7.0.2-v2: 390902fe7a6476a9911a4f2c41e89c8b9035104468c266647d4498d5a913ab73 - react-native-enriched-markdown@1.1.0: 9464bd32d8c37e1be7148ae24a731c1abbef30b4f3aa842e6d4c56f72cbe0909 + react-native-enriched-markdown@1.1.0: 54f355263e454d17a016b7ebfbda63a59ac5f81f4d3a9844b19522b91a09a5ff react-native@0.86.3: 113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9 importers: @@ -749,7 +749,7 @@ importers: version: 3.0.7(@expo/metro-config@57.0.12(bufferutil@4.1.0)(expo@57.0.26)(typescript@7.0.2)(utf-8-validate@6.0.6))(lightningcss@1.30.1)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-enriched-markdown: specifier: 1.1.0 - version: 1.1.0(patch_hash=9464bd32d8c37e1be7148ae24a731c1abbef30b4f3aa842e6d4c56f72cbe0909)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 1.1.0(patch_hash=54f355263e454d17a016b7ebfbda63a59ac5f81f4d3a9844b19522b91a09a5ff)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-gesture-handler: specifier: 2.32.0 version: 2.32.0(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -38011,7 +38011,7 @@ snapshots: use-latest-callback: 0.2.6(react@19.2.6) optional: true - react-native-enriched-markdown@1.1.0(patch_hash=9464bd32d8c37e1be7148ae24a731c1abbef30b4f3aa842e6d4c56f72cbe0909)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-enriched-markdown@1.1.0(patch_hash=54f355263e454d17a016b7ebfbda63a59ac5f81f4d3a9844b19522b91a09a5ff)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 react-native: 0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) From 67e6581d680172bf0807e0411e561097bbc2b0a9 Mon Sep 17 00:00:00 2001 From: kwf Date: Tue, 20 Oct 2026 04:39:47 +0000 Subject: [PATCH 16/26] docs: note the emulator verification of the hug-branch gate --- patches/react-native-enriched-markdown.patch | 13 ++++++++----- pnpm-lock.yaml | 6 +++--- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/patches/react-native-enriched-markdown.patch b/patches/react-native-enriched-markdown.patch index 7cad265c5b7..dfeb70f6b4f 100644 --- a/patches/react-native-enriched-markdown.patch +++ b/patches/react-native-enriched-markdown.patch @@ -1,5 +1,5 @@ diff --git a/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt b/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt -index 7bb48f44c5415f7c6b914934db90705825bfea13..7a8093d2cc99029c0fd1d83af71b612d76bc3f16 100644 +index 7bb48f44c5415f7c6b914934db90705825bfea13..460a87791a2c97d6bcb7d731b7b918c0984a4573 100644 --- a/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt +++ b/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt @@ -50,6 +50,19 @@ import kotlin.math.ceil @@ -168,7 +168,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..7a8093d2cc99029c0fd1d83af71b612d text: CharSequence?, paint: TextPaint, viewId: Int?, -@@ -737,17 +781,62 @@ object MeasurementStore { +@@ -737,17 +781,65 @@ object MeasurementStore { val layout = builder.build() val visibleLineCount = visibleLineCount(layout, viewId) @@ -185,7 +185,10 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..7a8093d2cc99029c0fd1d83af71b612d + // measured as fitting at that width can wrap in the display view. When the + // widest line fills the assigned width, re-measure at a hair narrower and + // adopt the taller height only if that layout actually wraps into more -+ // lines — text that fits keeps the height measured above. ++ // lines — text that fits keeps the height measured above. Verified on an ++ // ar-SA emulator: a wrapped combining-mark message gets the taller height, ++ // and single-line multi-word messages keep the one-line height because the ++ // narrower layout does not wrap them. + val displayWidthPx = if (exactWidth) safeWidth else minOf(safeWidth, ceil(measuredWidth).toInt()) + val fillsAssignedWidth = measuredWidth > displayWidthPx - HUG_WIDTH_SAFETY_PX + if (fillsAssignedWidth) { @@ -232,7 +235,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..7a8093d2cc99029c0fd1d83af71b612d // Applies the per-view numberOfLines / ellipsizeMode clamp to a builder, matching // RN's TextLayoutManager.buildLayout: only when a line limit is set. private fun applyLineLimit( -@@ -816,6 +905,7 @@ object MeasurementStore { +@@ -816,6 +908,7 @@ object MeasurementStore { */ private fun measureWithLayout( maxWidth: Float, @@ -240,7 +243,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..7a8093d2cc99029c0fd1d83af71b612d text: CharSequence?, paint: TextPaint, viewId: Int?, -@@ -847,10 +937,31 @@ object MeasurementStore { +@@ -847,10 +940,31 @@ object MeasurementStore { (0 until visibleLineCount) .maxOfOrNull { layout.getLineWidth(it) } ?: 0f diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f5c85a5a71a..13155783674 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -188,7 +188,7 @@ patchedDependencies: expo-task-manager@57.0.21: 7b144f9d84c386dfc1bc93db75d62887c20db3f652bad1f5c01b01ebb531315c expo-widgets@57.0.22: 1c69b613e94332d7166d8c9255b5fd297f56412de480f0b51e8440b768283e81 react-native-appsflyer@7.0.2-v2: 390902fe7a6476a9911a4f2c41e89c8b9035104468c266647d4498d5a913ab73 - react-native-enriched-markdown@1.1.0: 54f355263e454d17a016b7ebfbda63a59ac5f81f4d3a9844b19522b91a09a5ff + react-native-enriched-markdown@1.1.0: 544e0a4a4cc8f69463ae1bad448158c3f68811772b95422dbf52da68e0a0264a react-native@0.86.3: 113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9 importers: @@ -749,7 +749,7 @@ importers: version: 3.0.7(@expo/metro-config@57.0.12(bufferutil@4.1.0)(expo@57.0.26)(typescript@7.0.2)(utf-8-validate@6.0.6))(lightningcss@1.30.1)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-enriched-markdown: specifier: 1.1.0 - version: 1.1.0(patch_hash=54f355263e454d17a016b7ebfbda63a59ac5f81f4d3a9844b19522b91a09a5ff)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 1.1.0(patch_hash=544e0a4a4cc8f69463ae1bad448158c3f68811772b95422dbf52da68e0a0264a)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-gesture-handler: specifier: 2.32.0 version: 2.32.0(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -38011,7 +38011,7 @@ snapshots: use-latest-callback: 0.2.6(react@19.2.6) optional: true - react-native-enriched-markdown@1.1.0(patch_hash=54f355263e454d17a016b7ebfbda63a59ac5f81f4d3a9844b19522b91a09a5ff)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-enriched-markdown@1.1.0(patch_hash=544e0a4a4cc8f69463ae1bad448158c3f68811772b95422dbf52da68e0a0264a)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 react-native: 0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) From db01ff61441aeac55578e4cecd5e4521469a1fc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <4062236+iscekic@users.noreply.github.com> Date: Tue, 20 Oct 2026 04:58:27 +0000 Subject: [PATCH 17/26] Revert "docs: note the emulator verification of the hug-branch gate" This reverts commit 67e6581d680172bf0807e0411e561097bbc2b0a9. --- patches/react-native-enriched-markdown.patch | 13 +++++-------- pnpm-lock.yaml | 6 +++--- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/patches/react-native-enriched-markdown.patch b/patches/react-native-enriched-markdown.patch index dfeb70f6b4f..7cad265c5b7 100644 --- a/patches/react-native-enriched-markdown.patch +++ b/patches/react-native-enriched-markdown.patch @@ -1,5 +1,5 @@ diff --git a/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt b/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt -index 7bb48f44c5415f7c6b914934db90705825bfea13..460a87791a2c97d6bcb7d731b7b918c0984a4573 100644 +index 7bb48f44c5415f7c6b914934db90705825bfea13..7a8093d2cc99029c0fd1d83af71b612d76bc3f16 100644 --- a/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt +++ b/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt @@ -50,6 +50,19 @@ import kotlin.math.ceil @@ -168,7 +168,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..460a87791a2c97d6bcb7d731b7b918c0 text: CharSequence?, paint: TextPaint, viewId: Int?, -@@ -737,17 +781,65 @@ object MeasurementStore { +@@ -737,17 +781,62 @@ object MeasurementStore { val layout = builder.build() val visibleLineCount = visibleLineCount(layout, viewId) @@ -185,10 +185,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..460a87791a2c97d6bcb7d731b7b918c0 + // measured as fitting at that width can wrap in the display view. When the + // widest line fills the assigned width, re-measure at a hair narrower and + // adopt the taller height only if that layout actually wraps into more -+ // lines — text that fits keeps the height measured above. Verified on an -+ // ar-SA emulator: a wrapped combining-mark message gets the taller height, -+ // and single-line multi-word messages keep the one-line height because the -+ // narrower layout does not wrap them. ++ // lines — text that fits keeps the height measured above. + val displayWidthPx = if (exactWidth) safeWidth else minOf(safeWidth, ceil(measuredWidth).toInt()) + val fillsAssignedWidth = measuredWidth > displayWidthPx - HUG_WIDTH_SAFETY_PX + if (fillsAssignedWidth) { @@ -235,7 +232,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..460a87791a2c97d6bcb7d731b7b918c0 // Applies the per-view numberOfLines / ellipsizeMode clamp to a builder, matching // RN's TextLayoutManager.buildLayout: only when a line limit is set. private fun applyLineLimit( -@@ -816,6 +908,7 @@ object MeasurementStore { +@@ -816,6 +905,7 @@ object MeasurementStore { */ private fun measureWithLayout( maxWidth: Float, @@ -243,7 +240,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..460a87791a2c97d6bcb7d731b7b918c0 text: CharSequence?, paint: TextPaint, viewId: Int?, -@@ -847,10 +940,31 @@ object MeasurementStore { +@@ -847,10 +937,31 @@ object MeasurementStore { (0 until visibleLineCount) .maxOfOrNull { layout.getLineWidth(it) } ?: 0f diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 13155783674..f5c85a5a71a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -188,7 +188,7 @@ patchedDependencies: expo-task-manager@57.0.21: 7b144f9d84c386dfc1bc93db75d62887c20db3f652bad1f5c01b01ebb531315c expo-widgets@57.0.22: 1c69b613e94332d7166d8c9255b5fd297f56412de480f0b51e8440b768283e81 react-native-appsflyer@7.0.2-v2: 390902fe7a6476a9911a4f2c41e89c8b9035104468c266647d4498d5a913ab73 - react-native-enriched-markdown@1.1.0: 544e0a4a4cc8f69463ae1bad448158c3f68811772b95422dbf52da68e0a0264a + react-native-enriched-markdown@1.1.0: 54f355263e454d17a016b7ebfbda63a59ac5f81f4d3a9844b19522b91a09a5ff react-native@0.86.3: 113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9 importers: @@ -749,7 +749,7 @@ importers: version: 3.0.7(@expo/metro-config@57.0.12(bufferutil@4.1.0)(expo@57.0.26)(typescript@7.0.2)(utf-8-validate@6.0.6))(lightningcss@1.30.1)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-enriched-markdown: specifier: 1.1.0 - version: 1.1.0(patch_hash=544e0a4a4cc8f69463ae1bad448158c3f68811772b95422dbf52da68e0a0264a)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 1.1.0(patch_hash=54f355263e454d17a016b7ebfbda63a59ac5f81f4d3a9844b19522b91a09a5ff)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-gesture-handler: specifier: 2.32.0 version: 2.32.0(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -38011,7 +38011,7 @@ snapshots: use-latest-callback: 0.2.6(react@19.2.6) optional: true - react-native-enriched-markdown@1.1.0(patch_hash=544e0a4a4cc8f69463ae1bad448158c3f68811772b95422dbf52da68e0a0264a)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-enriched-markdown@1.1.0(patch_hash=54f355263e454d17a016b7ebfbda63a59ac5f81f4d3a9844b19522b91a09a5ff)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 react-native: 0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) From ff42859074e3e5f18927baaae491e205ea83e9fb Mon Sep 17 00:00:00 2001 From: kwf Date: Tue, 20 Oct 2026 05:23:54 +0000 Subject: [PATCH 18/26] chore: retrigger the review on the reverted head From d7a3b7801635c2aff5f694b1c7af57444567afdd Mon Sep 17 00:00:00 2001 From: kwf Date: Tue, 20 Oct 2026 05:34:34 +0000 Subject: [PATCH 19/26] docs: note the unobserved wrap at the reserved width --- patches/react-native-enriched-markdown.patch | 34 +++++++++++--------- pnpm-lock.yaml | 6 ++-- 2 files changed, 21 insertions(+), 19 deletions(-) diff --git a/patches/react-native-enriched-markdown.patch b/patches/react-native-enriched-markdown.patch index 7cad265c5b7..a8e95c37006 100644 --- a/patches/react-native-enriched-markdown.patch +++ b/patches/react-native-enriched-markdown.patch @@ -1,8 +1,8 @@ diff --git a/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt b/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt -index 7bb48f44c5415f7c6b914934db90705825bfea13..7a8093d2cc99029c0fd1d83af71b612d76bc3f16 100644 +index 7bb48f44c5415f7c6b914934db90705825bfea13..8abb1a944e1c7cf4cbd5cee92dd571177c0e3ef6 100644 --- a/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt +++ b/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt -@@ -50,6 +50,19 @@ import kotlin.math.ceil +@@ -50,6 +50,21 @@ import kotlin.math.ceil object MeasurementStore { private const val TAG = "MeasurementStore" @@ -16,13 +16,15 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..7a8093d2cc99029c0fd1d83af71b612d + * display gets slack without reserving a line — was measured on device and + * still wrapped: the display view's advance exceeded the widened width, so + * reserving is the only direction that keeps the text inside its bubble. ++ * The margin is small enough that a message wrapping at the reserved width ++ * but not at the reported width has never been observed on the emulator. + */ + private const val HUG_WIDTH_SAFETY_PX = 2 + private data class PaintParams( val typeface: Typeface, val fontSize: Float, -@@ -120,7 +133,9 @@ object MeasurementStore { +@@ -120,7 +135,9 @@ object MeasurementStore { val existingHash = cached?.markdownHash ?: 0 val paintParams = PaintParams(paint.typeface ?: Typeface.DEFAULT, paint.textSize) @@ -33,7 +35,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..7a8093d2cc99029c0fd1d83af71b612d if (trailingMarginBottomPx > 0f) { newSize = YogaMeasureOutput.make( -@@ -177,7 +192,8 @@ object MeasurementStore { +@@ -177,7 +194,8 @@ object MeasurementStore { } } @@ -43,7 +45,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..7a8093d2cc99029c0fd1d83af71b612d // Honor the Yoga width contract. When the parent assigns an exact width // (flex child, alignItems: stretch, explicit width / width: "100%"), fill it -@@ -273,6 +289,7 @@ object MeasurementStore { +@@ -273,6 +291,7 @@ object MeasurementStore { context: Context, id: Int?, width: Float, @@ -51,7 +53,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..7a8093d2cc99029c0fd1d83af71b612d props: ReadableMap?, splitTableSegments: Boolean, ): Long { -@@ -282,21 +299,21 @@ object MeasurementStore { +@@ -282,21 +301,21 @@ object MeasurementStore { // Split measurement always goes through the full measure path (no spannable caching) if (splitTableSegments) { @@ -78,7 +80,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..7a8093d2cc99029c0fd1d83af71b612d data[safeId] = cached.copy(cachedWidth = width, cachedSize = newSize) return newSize } -@@ -368,6 +385,7 @@ object MeasurementStore { +@@ -368,6 +387,7 @@ object MeasurementStore { context: Context, id: Int?, width: Float, @@ -86,7 +88,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..7a8093d2cc99029c0fd1d83af71b612d props: ReadableMap?, allowFontScaling: Boolean, fontScale: Float, -@@ -408,7 +426,7 @@ object MeasurementStore { +@@ -408,7 +428,7 @@ object MeasurementStore { ) spannable?.replaceMathSpansWithPlaceholders(context) val textToMeasure = spannable ?: markdown @@ -95,7 +97,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..7a8093d2cc99029c0fd1d83af71b612d // 3. Calculate Margin val allowTrailingMargin = props.getBooleanOrDefault("allowTrailingMargin", false) -@@ -435,6 +453,7 @@ object MeasurementStore { +@@ -435,6 +455,7 @@ object MeasurementStore { context: Context, id: Int?, width: Float, @@ -103,7 +105,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..7a8093d2cc99029c0fd1d83af71b612d props: ReadableMap?, allowFontScaling: Boolean, fontScale: Float, -@@ -543,9 +562,32 @@ object MeasurementStore { +@@ -543,9 +564,32 @@ object MeasurementStore { segment.styledText.replaceMathSpansWithPlaceholders(context) val layout = createStaticLayout(segment.styledText, fontSize, widthPx, id) @@ -137,7 +139,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..7a8093d2cc99029c0fd1d83af71b612d maxContentWidthPx = maxOf(maxContentWidthPx, ceil(segmentMaxLineWidth)) if (includeBottomMargin) { -@@ -610,7 +652,7 @@ object MeasurementStore { +@@ -610,7 +654,7 @@ object MeasurementStore { result } catch (e: Exception) { Log.w(TAG, "Split measurement failed, falling back", e) @@ -146,7 +148,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..7a8093d2cc99029c0fd1d83af71b612d } } -@@ -698,6 +740,7 @@ object MeasurementStore { +@@ -698,6 +742,7 @@ object MeasurementStore { private fun measure( maxWidth: Float, @@ -154,7 +156,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..7a8093d2cc99029c0fd1d83af71b612d text: CharSequence?, paintParams: PaintParams, viewId: Int?, -@@ -705,11 +748,12 @@ object MeasurementStore { +@@ -705,11 +750,12 @@ object MeasurementStore { measurePaint.reset() measurePaint.typeface = paintParams.typeface measurePaint.textSize = paintParams.fontSize @@ -168,7 +170,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..7a8093d2cc99029c0fd1d83af71b612d text: CharSequence?, paint: TextPaint, viewId: Int?, -@@ -737,17 +781,62 @@ object MeasurementStore { +@@ -737,17 +783,62 @@ object MeasurementStore { val layout = builder.build() val visibleLineCount = visibleLineCount(layout, viewId) @@ -232,7 +234,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..7a8093d2cc99029c0fd1d83af71b612d // Applies the per-view numberOfLines / ellipsizeMode clamp to a builder, matching // RN's TextLayoutManager.buildLayout: only when a line limit is set. private fun applyLineLimit( -@@ -816,6 +905,7 @@ object MeasurementStore { +@@ -816,6 +907,7 @@ object MeasurementStore { */ private fun measureWithLayout( maxWidth: Float, @@ -240,7 +242,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..7a8093d2cc99029c0fd1d83af71b612d text: CharSequence?, paint: TextPaint, viewId: Int?, -@@ -847,10 +937,31 @@ object MeasurementStore { +@@ -847,10 +939,31 @@ object MeasurementStore { (0 until visibleLineCount) .maxOfOrNull { layout.getLineWidth(it) } ?: 0f diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f5c85a5a71a..c53e8ae7e5c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -188,7 +188,7 @@ patchedDependencies: expo-task-manager@57.0.21: 7b144f9d84c386dfc1bc93db75d62887c20db3f652bad1f5c01b01ebb531315c expo-widgets@57.0.22: 1c69b613e94332d7166d8c9255b5fd297f56412de480f0b51e8440b768283e81 react-native-appsflyer@7.0.2-v2: 390902fe7a6476a9911a4f2c41e89c8b9035104468c266647d4498d5a913ab73 - react-native-enriched-markdown@1.1.0: 54f355263e454d17a016b7ebfbda63a59ac5f81f4d3a9844b19522b91a09a5ff + react-native-enriched-markdown@1.1.0: 0e464035a18c94c3418693994fd816ead6c288c9c5064b2b71a56e2f95f8e230 react-native@0.86.3: 113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9 importers: @@ -749,7 +749,7 @@ importers: version: 3.0.7(@expo/metro-config@57.0.12(bufferutil@4.1.0)(expo@57.0.26)(typescript@7.0.2)(utf-8-validate@6.0.6))(lightningcss@1.30.1)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-enriched-markdown: specifier: 1.1.0 - version: 1.1.0(patch_hash=54f355263e454d17a016b7ebfbda63a59ac5f81f4d3a9844b19522b91a09a5ff)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 1.1.0(patch_hash=0e464035a18c94c3418693994fd816ead6c288c9c5064b2b71a56e2f95f8e230)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-gesture-handler: specifier: 2.32.0 version: 2.32.0(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -38011,7 +38011,7 @@ snapshots: use-latest-callback: 0.2.6(react@19.2.6) optional: true - react-native-enriched-markdown@1.1.0(patch_hash=54f355263e454d17a016b7ebfbda63a59ac5f81f4d3a9844b19522b91a09a5ff)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-enriched-markdown@1.1.0(patch_hash=0e464035a18c94c3418693994fd816ead6c288c9c5064b2b71a56e2f95f8e230)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 react-native: 0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) From 1d8d4f54296603b535b08a4aa85f63e8546dca20 Mon Sep 17 00:00:00 2001 From: kwf Date: Tue, 20 Oct 2026 07:05:43 +0000 Subject: [PATCH 20/26] refactor(mobile): rename the reserve margin to say what it guards HUG_WIDTH_SAFETY_PX becomes LINE_FLIP_MARGIN_PX: the margin covers the sub-pixel of per-run font fallback advance that can flip a line at the width the display view wraps at. --- patches/react-native-enriched-markdown.patch | 16 ++++++++-------- pnpm-lock.yaml | 6 +++--- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/patches/react-native-enriched-markdown.patch b/patches/react-native-enriched-markdown.patch index a8e95c37006..d0463215535 100644 --- a/patches/react-native-enriched-markdown.patch +++ b/patches/react-native-enriched-markdown.patch @@ -1,5 +1,5 @@ diff --git a/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt b/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt -index 7bb48f44c5415f7c6b914934db90705825bfea13..8abb1a944e1c7cf4cbd5cee92dd571177c0e3ef6 100644 +index 7bb48f44c5415f7c6b914934db90705825bfea13..fa206afdc825e292b5394a2b89c7781a0bddd08e 100644 --- a/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt +++ b/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt @@ -50,6 +50,21 @@ import kotlin.math.ceil @@ -19,7 +19,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..8abb1a944e1c7cf4cbd5cee92dd57117 + * The margin is small enough that a message wrapping at the reserved width + * but not at the reported width has never been observed on the emulator. + */ -+ private const val HUG_WIDTH_SAFETY_PX = 2 ++ private const val LINE_FLIP_MARGIN_PX = 2 + private data class PaintParams( val typeface: Typeface, @@ -125,9 +125,9 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..8abb1a944e1c7cf4cbd5cee92dd57117 + // the height measured above. + val displayWidthPx = + if (exactWidth) widthPx else minOf(widthPx, ceil(segmentMaxLineWidth).toInt()) -+ val fillsAssignedWidth = segmentMaxLineWidth > displayWidthPx - HUG_WIDTH_SAFETY_PX ++ val fillsAssignedWidth = segmentMaxLineWidth > displayWidthPx - LINE_FLIP_MARGIN_PX + if (fillsAssignedWidth) { -+ val huggedWidth = maxOf(1, displayWidthPx - HUG_WIDTH_SAFETY_PX) ++ val huggedWidth = maxOf(1, displayWidthPx - LINE_FLIP_MARGIN_PX) + val huggedLayout = createStaticLayout(segment.styledText, fontSize, huggedWidth, id) + if (huggedLayout.lineCount > layout.lineCount) { + segmentHeightPx = huggedLayout.height.toFloat() @@ -189,9 +189,9 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..8abb1a944e1c7cf4cbd5cee92dd57117 + // adopt the taller height only if that layout actually wraps into more + // lines — text that fits keeps the height measured above. + val displayWidthPx = if (exactWidth) safeWidth else minOf(safeWidth, ceil(measuredWidth).toInt()) -+ val fillsAssignedWidth = measuredWidth > displayWidthPx - HUG_WIDTH_SAFETY_PX ++ val fillsAssignedWidth = measuredWidth > displayWidthPx - LINE_FLIP_MARGIN_PX + if (fillsAssignedWidth) { -+ val huggedMeasureWidth = maxOf(1, displayWidthPx - HUG_WIDTH_SAFETY_PX) ++ val huggedMeasureWidth = maxOf(1, displayWidthPx - LINE_FLIP_MARGIN_PX) + val huggedLayout = buildLayout(text = content, paint = paint, widthPx = huggedMeasureWidth, viewId = viewId) + val huggedLineCount = visibleLineCount(huggedLayout, viewId) + if (huggedLineCount > visibleLineCount) { @@ -254,10 +254,10 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..8abb1a944e1c7cf4cbd5cee92dd57117 + // adopt the taller height only if that layout actually wraps into more + // lines; text that fits keeps the height measured above. + val displayWidthPx = if (exactWidth) widthPx else minOf(widthPx, ceil(maxLineWidth).toInt()) -+ val fillsAssignedWidth = maxLineWidth > displayWidthPx - HUG_WIDTH_SAFETY_PX ++ val fillsAssignedWidth = maxLineWidth > displayWidthPx - LINE_FLIP_MARGIN_PX + val height = + if (fillsAssignedWidth) { -+ val huggedLayout = buildLayout(content, paint, maxOf(1, displayWidthPx - HUG_WIDTH_SAFETY_PX), viewId) ++ val huggedLayout = buildLayout(content, paint, maxOf(1, displayWidthPx - LINE_FLIP_MARGIN_PX), viewId) + if (visibleLineCount(huggedLayout, viewId) > visibleLineCount) { + truncatedHeight(huggedLayout, visibleLineCount(huggedLayout, viewId), content) + } else { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c53e8ae7e5c..0d1ba97dfe9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -188,7 +188,7 @@ patchedDependencies: expo-task-manager@57.0.21: 7b144f9d84c386dfc1bc93db75d62887c20db3f652bad1f5c01b01ebb531315c expo-widgets@57.0.22: 1c69b613e94332d7166d8c9255b5fd297f56412de480f0b51e8440b768283e81 react-native-appsflyer@7.0.2-v2: 390902fe7a6476a9911a4f2c41e89c8b9035104468c266647d4498d5a913ab73 - react-native-enriched-markdown@1.1.0: 0e464035a18c94c3418693994fd816ead6c288c9c5064b2b71a56e2f95f8e230 + react-native-enriched-markdown@1.1.0: 82d992a195e3ae3eaa43b23210307808fb4aedf9af936ec782078eaf3ec7d10d react-native@0.86.3: 113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9 importers: @@ -749,7 +749,7 @@ importers: version: 3.0.7(@expo/metro-config@57.0.12(bufferutil@4.1.0)(expo@57.0.26)(typescript@7.0.2)(utf-8-validate@6.0.6))(lightningcss@1.30.1)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-enriched-markdown: specifier: 1.1.0 - version: 1.1.0(patch_hash=0e464035a18c94c3418693994fd816ead6c288c9c5064b2b71a56e2f95f8e230)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 1.1.0(patch_hash=82d992a195e3ae3eaa43b23210307808fb4aedf9af936ec782078eaf3ec7d10d)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-gesture-handler: specifier: 2.32.0 version: 2.32.0(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -38011,7 +38011,7 @@ snapshots: use-latest-callback: 0.2.6(react@19.2.6) optional: true - react-native-enriched-markdown@1.1.0(patch_hash=0e464035a18c94c3418693994fd816ead6c288c9c5064b2b71a56e2f95f8e230)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-enriched-markdown@1.1.0(patch_hash=82d992a195e3ae3eaa43b23210307808fb4aedf9af936ec782078eaf3ec7d10d)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 react-native: 0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) From d916296154698b941908c87f234ae13de391bf79 Mon Sep 17 00:00:00 2001 From: kwf Date: Tue, 20 Oct 2026 08:46:37 +0000 Subject: [PATCH 21/26] fix(mobile): reserve on height, not line count Compare the laid-out heights instead of line counts: a narrower wrap can be taller with the same line count when tall glyphs grow the line box, and a same-count reserve is what made the single-line case look like it gained a phantom line. --- patches/react-native-enriched-markdown.patch | 21 +++++++++----------- pnpm-lock.yaml | 6 +++--- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/patches/react-native-enriched-markdown.patch b/patches/react-native-enriched-markdown.patch index d0463215535..4ab96826e3e 100644 --- a/patches/react-native-enriched-markdown.patch +++ b/patches/react-native-enriched-markdown.patch @@ -1,5 +1,5 @@ diff --git a/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt b/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt -index 7bb48f44c5415f7c6b914934db90705825bfea13..fa206afdc825e292b5394a2b89c7781a0bddd08e 100644 +index 7bb48f44c5415f7c6b914934db90705825bfea13..43521ec309e0992f4c0c7ee00c993f182923b183 100644 --- a/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt +++ b/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt @@ -50,6 +50,21 @@ import kotlin.math.ceil @@ -121,7 +121,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..fa206afdc825e292b5394a2b89c7781a + // single TextPaint, so a line measured as fitting at that width can + // still wrap. When the widest line fills the assigned width, + // re-measure at a hair narrower and adopt the taller height only if -+ // that layout actually wraps into more lines — text that fits keeps ++ // that layout actually comes out taller — text that fits keeps + // the height measured above. + val displayWidthPx = + if (exactWidth) widthPx else minOf(widthPx, ceil(segmentMaxLineWidth).toInt()) @@ -129,7 +129,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..fa206afdc825e292b5394a2b89c7781a + if (fillsAssignedWidth) { + val huggedWidth = maxOf(1, displayWidthPx - LINE_FLIP_MARGIN_PX) + val huggedLayout = createStaticLayout(segment.styledText, fontSize, huggedWidth, id) -+ if (huggedLayout.lineCount > layout.lineCount) { ++ if (huggedLayout.height > layout.height) { + segmentHeightPx = huggedLayout.height.toFloat() + } + } @@ -193,9 +193,9 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..fa206afdc825e292b5394a2b89c7781a + if (fillsAssignedWidth) { + val huggedMeasureWidth = maxOf(1, displayWidthPx - LINE_FLIP_MARGIN_PX) + val huggedLayout = buildLayout(text = content, paint = paint, widthPx = huggedMeasureWidth, viewId = viewId) -+ val huggedLineCount = visibleLineCount(huggedLayout, viewId) -+ if (huggedLineCount > visibleLineCount) { -+ measuredHeight = truncatedHeight(huggedLayout, huggedLineCount, content) ++ val huggedHeight = truncatedHeight(huggedLayout, visibleLineCount(huggedLayout, viewId), content) ++ if (huggedHeight > measuredHeight) { ++ measuredHeight = huggedHeight + } + } + @@ -242,7 +242,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..fa206afdc825e292b5394a2b89c7781a text: CharSequence?, paint: TextPaint, viewId: Int?, -@@ -847,10 +939,31 @@ object MeasurementStore { +@@ -847,10 +939,28 @@ object MeasurementStore { (0 until visibleLineCount) .maxOfOrNull { layout.getLineWidth(it) } ?: 0f @@ -258,11 +258,8 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..fa206afdc825e292b5394a2b89c7781a + val height = + if (fillsAssignedWidth) { + val huggedLayout = buildLayout(content, paint, maxOf(1, displayWidthPx - LINE_FLIP_MARGIN_PX), viewId) -+ if (visibleLineCount(huggedLayout, viewId) > visibleLineCount) { -+ truncatedHeight(huggedLayout, visibleLineCount(huggedLayout, viewId), content) -+ } else { -+ truncatedHeight(layout, visibleLineCount, content) -+ } ++ val huggedHeight = truncatedHeight(huggedLayout, visibleLineCount(huggedLayout, viewId), content) ++ maxOf(huggedHeight, truncatedHeight(layout, visibleLineCount, content)) + } else { + truncatedHeight(layout, visibleLineCount, content) + } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0d1ba97dfe9..638187e322d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -188,7 +188,7 @@ patchedDependencies: expo-task-manager@57.0.21: 7b144f9d84c386dfc1bc93db75d62887c20db3f652bad1f5c01b01ebb531315c expo-widgets@57.0.22: 1c69b613e94332d7166d8c9255b5fd297f56412de480f0b51e8440b768283e81 react-native-appsflyer@7.0.2-v2: 390902fe7a6476a9911a4f2c41e89c8b9035104468c266647d4498d5a913ab73 - react-native-enriched-markdown@1.1.0: 82d992a195e3ae3eaa43b23210307808fb4aedf9af936ec782078eaf3ec7d10d + react-native-enriched-markdown@1.1.0: 38d76c6d16dbbf6e0ab71c0a34bbbbb780ba6f561bd6b0d89fbd6c1a6a7a6cb2 react-native@0.86.3: 113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9 importers: @@ -749,7 +749,7 @@ importers: version: 3.0.7(@expo/metro-config@57.0.12(bufferutil@4.1.0)(expo@57.0.26)(typescript@7.0.2)(utf-8-validate@6.0.6))(lightningcss@1.30.1)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-enriched-markdown: specifier: 1.1.0 - version: 1.1.0(patch_hash=82d992a195e3ae3eaa43b23210307808fb4aedf9af936ec782078eaf3ec7d10d)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 1.1.0(patch_hash=38d76c6d16dbbf6e0ab71c0a34bbbbb780ba6f561bd6b0d89fbd6c1a6a7a6cb2)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-gesture-handler: specifier: 2.32.0 version: 2.32.0(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -38011,7 +38011,7 @@ snapshots: use-latest-callback: 0.2.6(react@19.2.6) optional: true - react-native-enriched-markdown@1.1.0(patch_hash=82d992a195e3ae3eaa43b23210307808fb4aedf9af936ec782078eaf3ec7d10d)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-enriched-markdown@1.1.0(patch_hash=38d76c6d16dbbf6e0ab71c0a34bbbbb780ba6f561bd6b0d89fbd6c1a6a7a6cb2)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 react-native: 0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) From b7a29d2e7c5eff8990081281e42243b575868e56 Mon Sep 17 00:00:00 2001 From: kwf Date: Tue, 20 Oct 2026 10:44:26 +0000 Subject: [PATCH 22/26] fix(mobile): compare the reserve against the assigned-width layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reserve height is now compared with a layout built at the width the display view actually wraps at, so a hug whose narrower layout does not gain a line keeps the height measured at the available width — the phantom-line case the review named cannot occur, and text that does wrap still reserves. --- patches/react-native-enriched-markdown.patch | 45 +++++++++++--------- pnpm-lock.yaml | 6 +-- 2 files changed, 29 insertions(+), 22 deletions(-) diff --git a/patches/react-native-enriched-markdown.patch b/patches/react-native-enriched-markdown.patch index 4ab96826e3e..bca539b61c9 100644 --- a/patches/react-native-enriched-markdown.patch +++ b/patches/react-native-enriched-markdown.patch @@ -1,5 +1,5 @@ diff --git a/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt b/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt -index 7bb48f44c5415f7c6b914934db90705825bfea13..43521ec309e0992f4c0c7ee00c993f182923b183 100644 +index 7bb48f44c5415f7c6b914934db90705825bfea13..a09ee900b6b51456ea9b468daca49c0f91ab9c7b 100644 --- a/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt +++ b/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt @@ -50,6 +50,21 @@ import kotlin.math.ceil @@ -105,7 +105,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..43521ec309e0992f4c0c7ee00c993f18 props: ReadableMap?, allowFontScaling: Boolean, fontScale: Float, -@@ -543,9 +564,32 @@ object MeasurementStore { +@@ -543,9 +564,36 @@ object MeasurementStore { segment.styledText.replaceMathSpansWithPlaceholders(context) val layout = createStaticLayout(segment.styledText, fontSize, widthPx, id) @@ -121,16 +121,20 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..43521ec309e0992f4c0c7ee00c993f18 + // single TextPaint, so a line measured as fitting at that width can + // still wrap. When the widest line fills the assigned width, + // re-measure at a hair narrower and adopt the taller height only if -+ // that layout actually comes out taller — text that fits keeps -+ // the height measured above. ++ // that layout actually comes out taller than a layout at the ++ // assigned width — text that fits there keeps the height measured ++ // at the available width above. + val displayWidthPx = + if (exactWidth) widthPx else minOf(widthPx, ceil(segmentMaxLineWidth).toInt()) + val fillsAssignedWidth = segmentMaxLineWidth > displayWidthPx - LINE_FLIP_MARGIN_PX + if (fillsAssignedWidth) { + val huggedWidth = maxOf(1, displayWidthPx - LINE_FLIP_MARGIN_PX) + val huggedLayout = createStaticLayout(segment.styledText, fontSize, huggedWidth, id) -+ if (huggedLayout.height > layout.height) { ++ val displayLayout = createStaticLayout(segment.styledText, fontSize, displayWidthPx, id) ++ if (huggedLayout.height > displayLayout.height) { + segmentHeightPx = huggedLayout.height.toFloat() ++ } else { ++ segmentHeightPx = displayLayout.height.toFloat() + } + } + @@ -139,7 +143,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..43521ec309e0992f4c0c7ee00c993f18 maxContentWidthPx = maxOf(maxContentWidthPx, ceil(segmentMaxLineWidth)) if (includeBottomMargin) { -@@ -610,7 +654,7 @@ object MeasurementStore { +@@ -610,7 +658,7 @@ object MeasurementStore { result } catch (e: Exception) { Log.w(TAG, "Split measurement failed, falling back", e) @@ -148,7 +152,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..43521ec309e0992f4c0c7ee00c993f18 } } -@@ -698,6 +742,7 @@ object MeasurementStore { +@@ -698,6 +746,7 @@ object MeasurementStore { private fun measure( maxWidth: Float, @@ -156,7 +160,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..43521ec309e0992f4c0c7ee00c993f18 text: CharSequence?, paintParams: PaintParams, viewId: Int?, -@@ -705,11 +750,12 @@ object MeasurementStore { +@@ -705,11 +754,12 @@ object MeasurementStore { measurePaint.reset() measurePaint.typeface = paintParams.typeface measurePaint.textSize = paintParams.fontSize @@ -170,7 +174,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..43521ec309e0992f4c0c7ee00c993f18 text: CharSequence?, paint: TextPaint, viewId: Int?, -@@ -737,17 +783,62 @@ object MeasurementStore { +@@ -737,17 +787,63 @@ object MeasurementStore { val layout = builder.build() val visibleLineCount = visibleLineCount(layout, viewId) @@ -186,17 +190,18 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..43521ec309e0992f4c0c7ee00c993f18 + // sub-pixel or two of advance over this single TextPaint, so a line + // measured as fitting at that width can wrap in the display view. When the + // widest line fills the assigned width, re-measure at a hair narrower and -+ // adopt the taller height only if that layout actually wraps into more -+ // lines — text that fits keeps the height measured above. ++ // adopt the taller height only if that layout actually comes out taller ++ // than a layout at the assigned width — text that fits there keeps the ++ // height measured at the available width above. + val displayWidthPx = if (exactWidth) safeWidth else minOf(safeWidth, ceil(measuredWidth).toInt()) + val fillsAssignedWidth = measuredWidth > displayWidthPx - LINE_FLIP_MARGIN_PX + if (fillsAssignedWidth) { + val huggedMeasureWidth = maxOf(1, displayWidthPx - LINE_FLIP_MARGIN_PX) + val huggedLayout = buildLayout(text = content, paint = paint, widthPx = huggedMeasureWidth, viewId = viewId) + val huggedHeight = truncatedHeight(huggedLayout, visibleLineCount(huggedLayout, viewId), content) -+ if (huggedHeight > measuredHeight) { -+ measuredHeight = huggedHeight -+ } ++ val displayLayout = buildLayout(text = content, paint = paint, widthPx = displayWidthPx, viewId = viewId) ++ val displayHeight = truncatedHeight(displayLayout, visibleLineCount(displayLayout, viewId), content) ++ measuredHeight = maxOf(measuredHeight, maxOf(huggedHeight, displayHeight)) + } + return YogaMeasureOutput.make( @@ -234,7 +239,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..43521ec309e0992f4c0c7ee00c993f18 // Applies the per-view numberOfLines / ellipsizeMode clamp to a builder, matching // RN's TextLayoutManager.buildLayout: only when a line limit is set. private fun applyLineLimit( -@@ -816,6 +907,7 @@ object MeasurementStore { +@@ -816,6 +912,7 @@ object MeasurementStore { */ private fun measureWithLayout( maxWidth: Float, @@ -242,7 +247,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..43521ec309e0992f4c0c7ee00c993f18 text: CharSequence?, paint: TextPaint, viewId: Int?, -@@ -847,10 +939,28 @@ object MeasurementStore { +@@ -847,10 +944,30 @@ object MeasurementStore { (0 until visibleLineCount) .maxOfOrNull { layout.getLineWidth(it) } ?: 0f @@ -251,15 +256,17 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..43521ec309e0992f4c0c7ee00c993f18 + // otherwise) and its per-run font fallback can add a sub-pixel of advance + // over this paint, so a line that fits at that width can wrap. When the + // widest line fills the assigned width, re-measure at a hair narrower and -+ // adopt the taller height only if that layout actually wraps into more -+ // lines; text that fits keeps the height measured above. ++ // adopt the taller height only if that layout actually comes out taller ++ // than a layout at the assigned width. + val displayWidthPx = if (exactWidth) widthPx else minOf(widthPx, ceil(maxLineWidth).toInt()) + val fillsAssignedWidth = maxLineWidth > displayWidthPx - LINE_FLIP_MARGIN_PX + val height = + if (fillsAssignedWidth) { + val huggedLayout = buildLayout(content, paint, maxOf(1, displayWidthPx - LINE_FLIP_MARGIN_PX), viewId) + val huggedHeight = truncatedHeight(huggedLayout, visibleLineCount(huggedLayout, viewId), content) -+ maxOf(huggedHeight, truncatedHeight(layout, visibleLineCount, content)) ++ val displayLayout = buildLayout(content, paint, displayWidthPx, viewId) ++ val displayHeight = truncatedHeight(displayLayout, visibleLineCount(displayLayout, viewId), content) ++ maxOf(huggedHeight, displayHeight) + } else { + truncatedHeight(layout, visibleLineCount, content) + } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 638187e322d..446c5ffac6f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -188,7 +188,7 @@ patchedDependencies: expo-task-manager@57.0.21: 7b144f9d84c386dfc1bc93db75d62887c20db3f652bad1f5c01b01ebb531315c expo-widgets@57.0.22: 1c69b613e94332d7166d8c9255b5fd297f56412de480f0b51e8440b768283e81 react-native-appsflyer@7.0.2-v2: 390902fe7a6476a9911a4f2c41e89c8b9035104468c266647d4498d5a913ab73 - react-native-enriched-markdown@1.1.0: 38d76c6d16dbbf6e0ab71c0a34bbbbb780ba6f561bd6b0d89fbd6c1a6a7a6cb2 + react-native-enriched-markdown@1.1.0: 86989e82ad545e8580d28d1e85c2b97ae9fa502c261bf7de2cb85f55d8e2eb51 react-native@0.86.3: 113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9 importers: @@ -749,7 +749,7 @@ importers: version: 3.0.7(@expo/metro-config@57.0.12(bufferutil@4.1.0)(expo@57.0.26)(typescript@7.0.2)(utf-8-validate@6.0.6))(lightningcss@1.30.1)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-enriched-markdown: specifier: 1.1.0 - version: 1.1.0(patch_hash=38d76c6d16dbbf6e0ab71c0a34bbbbb780ba6f561bd6b0d89fbd6c1a6a7a6cb2)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 1.1.0(patch_hash=86989e82ad545e8580d28d1e85c2b97ae9fa502c261bf7de2cb85f55d8e2eb51)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-gesture-handler: specifier: 2.32.0 version: 2.32.0(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -38011,7 +38011,7 @@ snapshots: use-latest-callback: 0.2.6(react@19.2.6) optional: true - react-native-enriched-markdown@1.1.0(patch_hash=38d76c6d16dbbf6e0ab71c0a34bbbbb780ba6f561bd6b0d89fbd6c1a6a7a6cb2)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-enriched-markdown@1.1.0(patch_hash=86989e82ad545e8580d28d1e85c2b97ae9fa502c261bf7de2cb85f55d8e2eb51)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 react-native: 0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) From d186aa33128c798eae942074c3d99052ac4acf38 Mon Sep 17 00:00:00 2001 From: kwf Date: Tue, 20 Oct 2026 12:03:25 +0000 Subject: [PATCH 23/26] chore: request a fresh review of the resolved threads From 6d5144dc9cd4975aeaba629c166aed47d67f619e Mon Sep 17 00:00:00 2001 From: kwf Date: Tue, 20 Oct 2026 12:31:53 +0000 Subject: [PATCH 24/26] fix(mobile): widen hugged markdown width by the font fallback margin The display TextView resolves per-run font fallback (emoji, combining marks) that the single-TextPaint measure understates, so a line that fit at the hugged width could wrap there. Report the hugged width plus that margin so the display has the room; the height stays the one measured at the available width, so no line is reserved that the text does not use. --- patches/react-native-enriched-markdown.patch | 145 +++++++------------ pnpm-lock.yaml | 6 +- 2 files changed, 56 insertions(+), 95 deletions(-) diff --git a/patches/react-native-enriched-markdown.patch b/patches/react-native-enriched-markdown.patch index bca539b61c9..b319f74bda5 100644 --- a/patches/react-native-enriched-markdown.patch +++ b/patches/react-native-enriched-markdown.patch @@ -1,30 +1,22 @@ diff --git a/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt b/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt -index 7bb48f44c5415f7c6b914934db90705825bfea13..a09ee900b6b51456ea9b468daca49c0f91ab9c7b 100644 +index 7bb48f44c5415f7c6b914934db90705825bfea13..4e878cb36c7299f536b085b184ce35ef943c7fee 100644 --- a/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt +++ b/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt -@@ -50,6 +50,21 @@ import kotlin.math.ceil +@@ -50,6 +50,13 @@ import kotlin.math.ceil object MeasurementStore { private const val TAG = "MeasurementStore" + /** -+ * Extra px subtracted from a hugged width before re-measuring the height. -+ * The display TextView resolves per-run font fallback (emoji, combining -+ * marks) that a single TextPaint understates by a sub-pixel or two, which -+ * can flip a line at the exact hug width. -+ * -+ * The opposite trade — reporting a width widened by this margin so the -+ * display gets slack without reserving a line — was measured on device and -+ * still wrapped: the display view's advance exceeded the widened width, so -+ * reserving is the only direction that keeps the text inside its bubble. -+ * The margin is small enough that a message wrapping at the reserved width -+ * but not at the reported width has never been observed on the emulator. ++ * Slack added to a hugged width. The display TextView resolves per-run font ++ * fallback (emoji, combining marks) that a single TextPaint understates, so ++ * a line measured as fitting at the hug width can still wrap there. + */ -+ private const val LINE_FLIP_MARGIN_PX = 2 ++ private const val WIDEN_SLACK_PX = 8 + private data class PaintParams( val typeface: Typeface, val fontSize: Float, -@@ -120,7 +135,9 @@ object MeasurementStore { +@@ -120,7 +127,9 @@ object MeasurementStore { val existingHash = cached?.markdownHash ?: 0 val paintParams = PaintParams(paint.typeface ?: Typeface.DEFAULT, paint.textSize) @@ -35,7 +27,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..a09ee900b6b51456ea9b468daca49c0f if (trailingMarginBottomPx > 0f) { newSize = YogaMeasureOutput.make( -@@ -177,7 +194,8 @@ object MeasurementStore { +@@ -177,7 +186,8 @@ object MeasurementStore { } } @@ -45,7 +37,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..a09ee900b6b51456ea9b468daca49c0f // Honor the Yoga width contract. When the parent assigns an exact width // (flex child, alignItems: stretch, explicit width / width: "100%"), fill it -@@ -273,6 +291,7 @@ object MeasurementStore { +@@ -273,6 +283,7 @@ object MeasurementStore { context: Context, id: Int?, width: Float, @@ -53,7 +45,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..a09ee900b6b51456ea9b468daca49c0f props: ReadableMap?, splitTableSegments: Boolean, ): Long { -@@ -282,21 +301,21 @@ object MeasurementStore { +@@ -282,21 +293,21 @@ object MeasurementStore { // Split measurement always goes through the full measure path (no spannable caching) if (splitTableSegments) { @@ -80,7 +72,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..a09ee900b6b51456ea9b468daca49c0f data[safeId] = cached.copy(cachedWidth = width, cachedSize = newSize) return newSize } -@@ -368,6 +387,7 @@ object MeasurementStore { +@@ -368,6 +379,7 @@ object MeasurementStore { context: Context, id: Int?, width: Float, @@ -88,7 +80,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..a09ee900b6b51456ea9b468daca49c0f props: ReadableMap?, allowFontScaling: Boolean, fontScale: Float, -@@ -408,7 +428,7 @@ object MeasurementStore { +@@ -408,7 +420,7 @@ object MeasurementStore { ) spannable?.replaceMathSpansWithPlaceholders(context) val textToMeasure = spannable ?: markdown @@ -97,7 +89,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..a09ee900b6b51456ea9b468daca49c0f // 3. Calculate Margin val allowTrailingMargin = props.getBooleanOrDefault("allowTrailingMargin", false) -@@ -435,6 +455,7 @@ object MeasurementStore { +@@ -435,6 +447,7 @@ object MeasurementStore { context: Context, id: Int?, width: Float, @@ -105,7 +97,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..a09ee900b6b51456ea9b468daca49c0f props: ReadableMap?, allowFontScaling: Boolean, fontScale: Float, -@@ -543,9 +564,36 @@ object MeasurementStore { +@@ -543,10 +556,28 @@ object MeasurementStore { segment.styledText.replaceMathSpansWithPlaceholders(context) val layout = createStaticLayout(segment.styledText, fontSize, widthPx, id) @@ -113,37 +105,30 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..a09ee900b6b51456ea9b468daca49c0f + var segmentHeightPx = layout.height.toFloat() val segmentMaxLineWidth = (0 until layout.lineCount).maxOfOrNull { layout.getLineWidth(it) } ?: 0f +- maxContentWidthPx = maxOf(maxContentWidthPx, ceil(segmentMaxLineWidth)) + -+ // The display view wraps at the width Yoga assigns this node: the -+ // full available width when the parent assigns one exactly, and the -+ // widest line otherwise. Its per-run font fallback (emoji, -+ // combining marks) can add a sub-pixel or two of advance over this -+ // single TextPaint, so a line measured as fitting at that width can -+ // still wrap. When the widest line fills the assigned width, -+ // re-measure at a hair narrower and adopt the taller height only if -+ // that layout actually comes out taller than a layout at the -+ // assigned width — text that fits there keeps the height measured -+ // at the available width above. -+ val displayWidthPx = -+ if (exactWidth) widthPx else minOf(widthPx, ceil(segmentMaxLineWidth).toInt()) -+ val fillsAssignedWidth = segmentMaxLineWidth > displayWidthPx - LINE_FLIP_MARGIN_PX -+ if (fillsAssignedWidth) { -+ val huggedWidth = maxOf(1, displayWidthPx - LINE_FLIP_MARGIN_PX) -+ val huggedLayout = createStaticLayout(segment.styledText, fontSize, huggedWidth, id) -+ val displayLayout = createStaticLayout(segment.styledText, fontSize, displayWidthPx, id) -+ if (huggedLayout.height > displayLayout.height) { -+ segmentHeightPx = huggedLayout.height.toFloat() ++ // The display view wraps at the width this node reports: the full ++ // available width when the parent assigns one exactly, and the ++ // reported content width otherwise. Its per-run font fallback ++ // (emoji, combining marks) can add a couple of px of advance over ++ // this single TextPaint, so a line that fits here can still wrap ++ // there. Report the hugged width widened by that margin so the ++ // display has the room; the height stays the one measured at the ++ // available width. ++ val contentWidthPx = ++ if (exactWidth) { ++ widthPx + } else { -+ segmentHeightPx = displayLayout.height.toFloat() ++ minOf(widthPx, ceil(segmentMaxLineWidth).toInt() + WIDEN_SLACK_PX) + } -+ } + + totalHeightPx += segmentHeightPx + - maxContentWidthPx = maxOf(maxContentWidthPx, ceil(segmentMaxLineWidth)) ++ maxContentWidthPx = maxOf(maxContentWidthPx, contentWidthPx.toFloat()) if (includeBottomMargin) { -@@ -610,7 +658,7 @@ object MeasurementStore { + totalHeightPx += segment.lastElementMarginBottom +@@ -610,7 +641,7 @@ object MeasurementStore { result } catch (e: Exception) { Log.w(TAG, "Split measurement failed, falling back", e) @@ -152,7 +137,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..a09ee900b6b51456ea9b468daca49c0f } } -@@ -698,6 +746,7 @@ object MeasurementStore { +@@ -698,6 +729,7 @@ object MeasurementStore { private fun measure( maxWidth: Float, @@ -160,7 +145,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..a09ee900b6b51456ea9b468daca49c0f text: CharSequence?, paintParams: PaintParams, viewId: Int?, -@@ -705,11 +754,12 @@ object MeasurementStore { +@@ -705,11 +737,12 @@ object MeasurementStore { measurePaint.reset() measurePaint.typeface = paintParams.typeface measurePaint.textSize = paintParams.fontSize @@ -174,7 +159,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..a09ee900b6b51456ea9b468daca49c0f text: CharSequence?, paint: TextPaint, viewId: Int?, -@@ -737,17 +787,63 @@ object MeasurementStore { +@@ -737,17 +770,53 @@ object MeasurementStore { val layout = builder.build() val visibleLineCount = visibleLineCount(layout, viewId) @@ -184,28 +169,19 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..a09ee900b6b51456ea9b468daca49c0f // Calculate actual content width (widest visible line) val measuredWidth = (0 until visibleLineCount).maxOfOrNull { layout.getLineWidth(it) } ?: 0f -+ // The display view wraps at the width Yoga assigns this node: the full -+ // available width when the parent assigns one exactly, the widest line ++ // The display view wraps at the width this node reports: the full available ++ // width when the parent assigns one exactly, the reported content width + // otherwise. Its per-run font fallback (emoji, combining marks) can add a -+ // sub-pixel or two of advance over this single TextPaint, so a line -+ // measured as fitting at that width can wrap in the display view. When the -+ // widest line fills the assigned width, re-measure at a hair narrower and -+ // adopt the taller height only if that layout actually comes out taller -+ // than a layout at the assigned width — text that fits there keeps the -+ // height measured at the available width above. -+ val displayWidthPx = if (exactWidth) safeWidth else minOf(safeWidth, ceil(measuredWidth).toInt()) -+ val fillsAssignedWidth = measuredWidth > displayWidthPx - LINE_FLIP_MARGIN_PX -+ if (fillsAssignedWidth) { -+ val huggedMeasureWidth = maxOf(1, displayWidthPx - LINE_FLIP_MARGIN_PX) -+ val huggedLayout = buildLayout(text = content, paint = paint, widthPx = huggedMeasureWidth, viewId = viewId) -+ val huggedHeight = truncatedHeight(huggedLayout, visibleLineCount(huggedLayout, viewId), content) -+ val displayLayout = buildLayout(text = content, paint = paint, widthPx = displayWidthPx, viewId = viewId) -+ val displayHeight = truncatedHeight(displayLayout, visibleLineCount(displayLayout, viewId), content) -+ measuredHeight = maxOf(measuredHeight, maxOf(huggedHeight, displayHeight)) -+ } ++ // couple of px of advance over this single TextPaint, so a line that fits ++ // here can still wrap there. Report the hugged width widened by that ++ // margin so the display has the room; the height stays the one measured at ++ // the available width. ++ val contentWidthPx = ++ if (exactWidth) safeWidth else minOf(safeWidth, ceil(measuredWidth).toInt() + WIDEN_SLACK_PX) + return YogaMeasureOutput.make( - PixelUtil.toDIPFromPixel(ceil(measuredWidth)), +- PixelUtil.toDIPFromPixel(ceil(measuredWidth)), ++ PixelUtil.toDIPFromPixel(if (exactWidth) ceil(measuredWidth) else contentWidthPx.toFloat()), PixelUtil.toDIPFromPixel(measuredHeight), ) } @@ -239,7 +215,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..a09ee900b6b51456ea9b468daca49c0f // Applies the per-view numberOfLines / ellipsizeMode clamp to a builder, matching // RN's TextLayoutManager.buildLayout: only when a line limit is set. private fun applyLineLimit( -@@ -816,6 +912,7 @@ object MeasurementStore { +@@ -816,6 +885,7 @@ object MeasurementStore { */ private fun measureWithLayout( maxWidth: Float, @@ -247,35 +223,20 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..a09ee900b6b51456ea9b468daca49c0f text: CharSequence?, paint: TextPaint, viewId: Int?, -@@ -847,10 +944,30 @@ object MeasurementStore { +@@ -847,9 +917,15 @@ object MeasurementStore { (0 until visibleLineCount) .maxOfOrNull { layout.getLineWidth(it) } ?: 0f -+ // Same contract as measure(): the display view wraps at the width Yoga -+ // assigns (the full available width when assigned exactly, the widest line -+ // otherwise) and its per-run font fallback can add a sub-pixel of advance -+ // over this paint, so a line that fits at that width can wrap. When the -+ // widest line fills the assigned width, re-measure at a hair narrower and -+ // adopt the taller height only if that layout actually comes out taller -+ // than a layout at the assigned width. -+ val displayWidthPx = if (exactWidth) widthPx else minOf(widthPx, ceil(maxLineWidth).toInt()) -+ val fillsAssignedWidth = maxLineWidth > displayWidthPx - LINE_FLIP_MARGIN_PX -+ val height = -+ if (fillsAssignedWidth) { -+ val huggedLayout = buildLayout(content, paint, maxOf(1, displayWidthPx - LINE_FLIP_MARGIN_PX), viewId) -+ val huggedHeight = truncatedHeight(huggedLayout, visibleLineCount(huggedLayout, viewId), content) -+ val displayLayout = buildLayout(content, paint, displayWidthPx, viewId) -+ val displayHeight = truncatedHeight(displayLayout, visibleLineCount(displayLayout, viewId), content) -+ maxOf(huggedHeight, displayHeight) -+ } else { -+ truncatedHeight(layout, visibleLineCount, content) -+ } ++ // Same contract as measure(): the display view wraps at the width this ++ // node reports, so report the hugged width widened by the fallback margin; ++ // the height stays the one measured at the available width. ++ val contentWidthPx = ++ if (exactWidth) widthPx else minOf(widthPx, ceil(maxLineWidth).toInt() + WIDEN_SLACK_PX) + val size = YogaMeasureOutput.make( - PixelUtil.toDIPFromPixel(ceil(maxLineWidth)), -- PixelUtil.toDIPFromPixel(truncatedHeight(layout, visibleLineCount, content)), -+ PixelUtil.toDIPFromPixel(height), +- PixelUtil.toDIPFromPixel(ceil(maxLineWidth)), ++ PixelUtil.toDIPFromPixel(if (exactWidth) ceil(maxLineWidth) else contentWidthPx.toFloat()), + PixelUtil.toDIPFromPixel(truncatedHeight(layout, visibleLineCount, content)), ) - return size to layout diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 446c5ffac6f..2330aaf61cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -188,7 +188,7 @@ patchedDependencies: expo-task-manager@57.0.21: 7b144f9d84c386dfc1bc93db75d62887c20db3f652bad1f5c01b01ebb531315c expo-widgets@57.0.22: 1c69b613e94332d7166d8c9255b5fd297f56412de480f0b51e8440b768283e81 react-native-appsflyer@7.0.2-v2: 390902fe7a6476a9911a4f2c41e89c8b9035104468c266647d4498d5a913ab73 - react-native-enriched-markdown@1.1.0: 86989e82ad545e8580d28d1e85c2b97ae9fa502c261bf7de2cb85f55d8e2eb51 + react-native-enriched-markdown@1.1.0: 775159db861840f4b011df88a1b3a4326972593a352af5a35ee7f597009fea70 react-native@0.86.3: 113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9 importers: @@ -749,7 +749,7 @@ importers: version: 3.0.7(@expo/metro-config@57.0.12(bufferutil@4.1.0)(expo@57.0.26)(typescript@7.0.2)(utf-8-validate@6.0.6))(lightningcss@1.30.1)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-enriched-markdown: specifier: 1.1.0 - version: 1.1.0(patch_hash=86989e82ad545e8580d28d1e85c2b97ae9fa502c261bf7de2cb85f55d8e2eb51)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 1.1.0(patch_hash=775159db861840f4b011df88a1b3a4326972593a352af5a35ee7f597009fea70)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-gesture-handler: specifier: 2.32.0 version: 2.32.0(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -38011,7 +38011,7 @@ snapshots: use-latest-callback: 0.2.6(react@19.2.6) optional: true - react-native-enriched-markdown@1.1.0(patch_hash=86989e82ad545e8580d28d1e85c2b97ae9fa502c261bf7de2cb85f55d8e2eb51)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-enriched-markdown@1.1.0(patch_hash=775159db861840f4b011df88a1b3a4326972593a352af5a35ee7f597009fea70)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 react-native: 0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) From f972983b2c4a8b00638220eb369480e2c714a74e Mon Sep 17 00:00:00 2001 From: kwf Date: Tue, 20 Oct 2026 12:48:58 +0000 Subject: [PATCH 25/26] fix(mobile): reserve the height when there is no room to widen When the widest line already fills the available width, the widened hug would be clamped back to that width and the display view would still wrap, so keep the reported width and re-measure the height a hair narrower there, adopting the taller height only if it comes out taller. --- patches/react-native-enriched-markdown.patch | 68 +++++++++++++++----- pnpm-lock.yaml | 6 +- 2 files changed, 55 insertions(+), 19 deletions(-) diff --git a/patches/react-native-enriched-markdown.patch b/patches/react-native-enriched-markdown.patch index b319f74bda5..8ac8c78147c 100644 --- a/patches/react-native-enriched-markdown.patch +++ b/patches/react-native-enriched-markdown.patch @@ -1,5 +1,5 @@ diff --git a/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt b/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt -index 7bb48f44c5415f7c6b914934db90705825bfea13..4e878cb36c7299f536b085b184ce35ef943c7fee 100644 +index 7bb48f44c5415f7c6b914934db90705825bfea13..c0b47bba637493aa4caa30b7ef5824934fcb3801 100644 --- a/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt +++ b/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt @@ -50,6 +50,13 @@ import kotlin.math.ceil @@ -97,7 +97,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..4e878cb36c7299f536b085b184ce35ef props: ReadableMap?, allowFontScaling: Boolean, fontScale: Float, -@@ -543,10 +556,28 @@ object MeasurementStore { +@@ -543,10 +556,39 @@ object MeasurementStore { segment.styledText.replaceMathSpansWithPlaceholders(context) val layout = createStaticLayout(segment.styledText, fontSize, widthPx, id) @@ -113,14 +113,25 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..4e878cb36c7299f536b085b184ce35ef + // (emoji, combining marks) can add a couple of px of advance over + // this single TextPaint, so a line that fits here can still wrap + // there. Report the hugged width widened by that margin so the -+ // display has the room; the height stays the one measured at the -+ // available width. ++ // display has the room. When there is no room to widen (the line ++ // already fills the available width), keep the reported width and ++ // re-measure the height a hair narrower instead, adopting the ++ // taller height only if that layout actually comes out taller. + val contentWidthPx = + if (exactWidth) { + widthPx + } else { + minOf(widthPx, ceil(segmentMaxLineWidth).toInt() + WIDEN_SLACK_PX) + } ++ if (!exactWidth && contentWidthPx == widthPx && ++ ceil(segmentMaxLineWidth).toInt() > widthPx - WIDEN_SLACK_PX ++ ) { ++ val reserveLayout = ++ createStaticLayout(segment.styledText, fontSize, widthPx - WIDEN_SLACK_PX, id) ++ if (reserveLayout.height > layout.height) { ++ segmentHeightPx = reserveLayout.height.toFloat() ++ } ++ } + + totalHeightPx += segmentHeightPx + @@ -128,7 +139,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..4e878cb36c7299f536b085b184ce35ef if (includeBottomMargin) { totalHeightPx += segment.lastElementMarginBottom -@@ -610,7 +641,7 @@ object MeasurementStore { +@@ -610,7 +652,7 @@ object MeasurementStore { result } catch (e: Exception) { Log.w(TAG, "Split measurement failed, falling back", e) @@ -137,7 +148,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..4e878cb36c7299f536b085b184ce35ef } } -@@ -698,6 +729,7 @@ object MeasurementStore { +@@ -698,6 +740,7 @@ object MeasurementStore { private fun measure( maxWidth: Float, @@ -145,7 +156,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..4e878cb36c7299f536b085b184ce35ef text: CharSequence?, paintParams: PaintParams, viewId: Int?, -@@ -705,11 +737,12 @@ object MeasurementStore { +@@ -705,11 +748,12 @@ object MeasurementStore { measurePaint.reset() measurePaint.typeface = paintParams.typeface measurePaint.textSize = paintParams.fontSize @@ -159,7 +170,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..4e878cb36c7299f536b085b184ce35ef text: CharSequence?, paint: TextPaint, viewId: Int?, -@@ -737,17 +770,53 @@ object MeasurementStore { +@@ -737,17 +781,64 @@ object MeasurementStore { val layout = builder.build() val visibleLineCount = visibleLineCount(layout, viewId) @@ -174,10 +185,21 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..4e878cb36c7299f536b085b184ce35ef + // otherwise. Its per-run font fallback (emoji, combining marks) can add a + // couple of px of advance over this single TextPaint, so a line that fits + // here can still wrap there. Report the hugged width widened by that -+ // margin so the display has the room; the height stays the one measured at -+ // the available width. ++ // margin so the display has the room. When there is no room to widen (the ++ // line already fills the available width), keep the reported width and ++ // re-measure the height a hair narrower instead, adopting the taller ++ // height only if that layout actually comes out taller. + val contentWidthPx = + if (exactWidth) safeWidth else minOf(safeWidth, ceil(measuredWidth).toInt() + WIDEN_SLACK_PX) ++ if (!exactWidth && contentWidthPx == safeWidth && ++ ceil(measuredWidth).toInt() > safeWidth - WIDEN_SLACK_PX ++ ) { ++ val reserveLayout = buildLayout(text = content, paint = paint, widthPx = safeWidth - WIDEN_SLACK_PX, viewId = viewId) ++ val reserveHeight = truncatedHeight(reserveLayout, visibleLineCount(reserveLayout, viewId), content) ++ if (reserveHeight > measuredHeight) { ++ measuredHeight = reserveHeight ++ } ++ } + return YogaMeasureOutput.make( - PixelUtil.toDIPFromPixel(ceil(measuredWidth)), @@ -215,7 +237,7 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..4e878cb36c7299f536b085b184ce35ef // Applies the per-view numberOfLines / ellipsizeMode clamp to a builder, matching // RN's TextLayoutManager.buildLayout: only when a line limit is set. private fun applyLineLimit( -@@ -816,6 +885,7 @@ object MeasurementStore { +@@ -816,6 +907,7 @@ object MeasurementStore { */ private fun measureWithLayout( maxWidth: Float, @@ -223,20 +245,34 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..4e878cb36c7299f536b085b184ce35ef text: CharSequence?, paint: TextPaint, viewId: Int?, -@@ -847,9 +917,15 @@ object MeasurementStore { +@@ -847,10 +939,28 @@ object MeasurementStore { (0 until visibleLineCount) .maxOfOrNull { layout.getLineWidth(it) } ?: 0f -+ // Same contract as measure(): the display view wraps at the width this -+ // node reports, so report the hugged width widened by the fallback margin; -+ // the height stays the one measured at the available width. ++ // Same contract as measure(): report the hugged width widened by the ++ // fallback margin so the display has the room. When there is no room to ++ // widen (the line already fills the available width), keep the reported ++ // width and re-measure the height a hair narrower instead, adopting the ++ // taller height only if that layout actually comes out taller. + val contentWidthPx = + if (exactWidth) widthPx else minOf(widthPx, ceil(maxLineWidth).toInt() + WIDEN_SLACK_PX) ++ var height = truncatedHeight(layout, visibleLineCount, content) ++ if (!exactWidth && contentWidthPx == widthPx && ++ ceil(maxLineWidth).toInt() > widthPx - WIDEN_SLACK_PX ++ ) { ++ val reserveLayout = buildLayout(content, paint, widthPx - WIDEN_SLACK_PX, viewId) ++ val reserveHeight = truncatedHeight(reserveLayout, visibleLineCount(reserveLayout, viewId), content) ++ if (reserveHeight > height) { ++ height = reserveHeight ++ } ++ } + val size = YogaMeasureOutput.make( - PixelUtil.toDIPFromPixel(ceil(maxLineWidth)), +- PixelUtil.toDIPFromPixel(truncatedHeight(layout, visibleLineCount, content)), + PixelUtil.toDIPFromPixel(if (exactWidth) ceil(maxLineWidth) else contentWidthPx.toFloat()), - PixelUtil.toDIPFromPixel(truncatedHeight(layout, visibleLineCount, content)), ++ PixelUtil.toDIPFromPixel(height), ) + return size to layout diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2330aaf61cb..f06b4fe7a11 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -188,7 +188,7 @@ patchedDependencies: expo-task-manager@57.0.21: 7b144f9d84c386dfc1bc93db75d62887c20db3f652bad1f5c01b01ebb531315c expo-widgets@57.0.22: 1c69b613e94332d7166d8c9255b5fd297f56412de480f0b51e8440b768283e81 react-native-appsflyer@7.0.2-v2: 390902fe7a6476a9911a4f2c41e89c8b9035104468c266647d4498d5a913ab73 - react-native-enriched-markdown@1.1.0: 775159db861840f4b011df88a1b3a4326972593a352af5a35ee7f597009fea70 + react-native-enriched-markdown@1.1.0: 4a63eb4016ba84ef89387227d1f9c7e5d333a1ebf2f7d210cb629b7d6993cdcf react-native@0.86.3: 113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9 importers: @@ -749,7 +749,7 @@ importers: version: 3.0.7(@expo/metro-config@57.0.12(bufferutil@4.1.0)(expo@57.0.26)(typescript@7.0.2)(utf-8-validate@6.0.6))(lightningcss@1.30.1)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-enriched-markdown: specifier: 1.1.0 - version: 1.1.0(patch_hash=775159db861840f4b011df88a1b3a4326972593a352af5a35ee7f597009fea70)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 1.1.0(patch_hash=4a63eb4016ba84ef89387227d1f9c7e5d333a1ebf2f7d210cb629b7d6993cdcf)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-gesture-handler: specifier: 2.32.0 version: 2.32.0(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -38011,7 +38011,7 @@ snapshots: use-latest-callback: 0.2.6(react@19.2.6) optional: true - react-native-enriched-markdown@1.1.0(patch_hash=775159db861840f4b011df88a1b3a4326972593a352af5a35ee7f597009fea70)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-enriched-markdown@1.1.0(patch_hash=4a63eb4016ba84ef89387227d1f9c7e5d333a1ebf2f7d210cb629b7d6993cdcf)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 react-native: 0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) From fb79812f3f1606771fb235c24c4f170a2e663178 Mon Sep 17 00:00:00 2001 From: kwf Date: Tue, 20 Oct 2026 13:08:44 +0000 Subject: [PATCH 26/26] fix(mobile): reserve for exact-width text and bound the reserve width The reserve now runs whenever the widest line fills the width the display view gets, including text assigned an exact width, and the reserve width is bounded below at 1 px so a tiny early measure cannot inflate the height or break StaticLayout's contract. --- patches/react-native-enriched-markdown.patch | 26 ++++++++++---------- pnpm-lock.yaml | 6 ++--- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/patches/react-native-enriched-markdown.patch b/patches/react-native-enriched-markdown.patch index 8ac8c78147c..b0f2511530e 100644 --- a/patches/react-native-enriched-markdown.patch +++ b/patches/react-native-enriched-markdown.patch @@ -1,5 +1,5 @@ diff --git a/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt b/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt -index 7bb48f44c5415f7c6b914934db90705825bfea13..c0b47bba637493aa4caa30b7ef5824934fcb3801 100644 +index 7bb48f44c5415f7c6b914934db90705825bfea13..efb2fcd825fe021a613d3f91d8a64d37e0c750ce 100644 --- a/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt +++ b/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt @@ -50,6 +50,13 @@ import kotlin.math.ceil @@ -123,11 +123,11 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..c0b47bba637493aa4caa30b7ef582493 + } else { + minOf(widthPx, ceil(segmentMaxLineWidth).toInt() + WIDEN_SLACK_PX) + } -+ if (!exactWidth && contentWidthPx == widthPx && -+ ceil(segmentMaxLineWidth).toInt() > widthPx - WIDEN_SLACK_PX -+ ) { ++ val fillsReportedWidth = segmentMaxLineWidth > contentWidthPx - WIDEN_SLACK_PX ++ val reserveWidth = maxOf(1, contentWidthPx - WIDEN_SLACK_PX) ++ if (fillsReportedWidth) { + val reserveLayout = -+ createStaticLayout(segment.styledText, fontSize, widthPx - WIDEN_SLACK_PX, id) ++ createStaticLayout(segment.styledText, fontSize, reserveWidth, id) + if (reserveLayout.height > layout.height) { + segmentHeightPx = reserveLayout.height.toFloat() + } @@ -191,10 +191,10 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..c0b47bba637493aa4caa30b7ef582493 + // height only if that layout actually comes out taller. + val contentWidthPx = + if (exactWidth) safeWidth else minOf(safeWidth, ceil(measuredWidth).toInt() + WIDEN_SLACK_PX) -+ if (!exactWidth && contentWidthPx == safeWidth && -+ ceil(measuredWidth).toInt() > safeWidth - WIDEN_SLACK_PX -+ ) { -+ val reserveLayout = buildLayout(text = content, paint = paint, widthPx = safeWidth - WIDEN_SLACK_PX, viewId = viewId) ++ val fillsReportedWidth = measuredWidth > contentWidthPx - WIDEN_SLACK_PX ++ val reserveWidth = maxOf(1, contentWidthPx - WIDEN_SLACK_PX) ++ if (fillsReportedWidth) { ++ val reserveLayout = buildLayout(text = content, paint = paint, widthPx = reserveWidth, viewId = viewId) + val reserveHeight = truncatedHeight(reserveLayout, visibleLineCount(reserveLayout, viewId), content) + if (reserveHeight > measuredHeight) { + measuredHeight = reserveHeight @@ -256,11 +256,11 @@ index 7bb48f44c5415f7c6b914934db90705825bfea13..c0b47bba637493aa4caa30b7ef582493 + // taller height only if that layout actually comes out taller. + val contentWidthPx = + if (exactWidth) widthPx else minOf(widthPx, ceil(maxLineWidth).toInt() + WIDEN_SLACK_PX) ++ val fillsReportedWidth = maxLineWidth > contentWidthPx - WIDEN_SLACK_PX ++ val reserveWidth = maxOf(1, contentWidthPx - WIDEN_SLACK_PX) + var height = truncatedHeight(layout, visibleLineCount, content) -+ if (!exactWidth && contentWidthPx == widthPx && -+ ceil(maxLineWidth).toInt() > widthPx - WIDEN_SLACK_PX -+ ) { -+ val reserveLayout = buildLayout(content, paint, widthPx - WIDEN_SLACK_PX, viewId) ++ if (fillsReportedWidth) { ++ val reserveLayout = buildLayout(content, paint, reserveWidth, viewId) + val reserveHeight = truncatedHeight(reserveLayout, visibleLineCount(reserveLayout, viewId), content) + if (reserveHeight > height) { + height = reserveHeight diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f06b4fe7a11..6f17e4e872d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -188,7 +188,7 @@ patchedDependencies: expo-task-manager@57.0.21: 7b144f9d84c386dfc1bc93db75d62887c20db3f652bad1f5c01b01ebb531315c expo-widgets@57.0.22: 1c69b613e94332d7166d8c9255b5fd297f56412de480f0b51e8440b768283e81 react-native-appsflyer@7.0.2-v2: 390902fe7a6476a9911a4f2c41e89c8b9035104468c266647d4498d5a913ab73 - react-native-enriched-markdown@1.1.0: 4a63eb4016ba84ef89387227d1f9c7e5d333a1ebf2f7d210cb629b7d6993cdcf + react-native-enriched-markdown@1.1.0: a28e477145c6be0da2af78d959581939d2e7dbf4873bfdbe702250523619a813 react-native@0.86.3: 113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9 importers: @@ -749,7 +749,7 @@ importers: version: 3.0.7(@expo/metro-config@57.0.12(bufferutil@4.1.0)(expo@57.0.26)(typescript@7.0.2)(utf-8-validate@6.0.6))(lightningcss@1.30.1)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-enriched-markdown: specifier: 1.1.0 - version: 1.1.0(patch_hash=4a63eb4016ba84ef89387227d1f9c7e5d333a1ebf2f7d210cb629b7d6993cdcf)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 1.1.0(patch_hash=a28e477145c6be0da2af78d959581939d2e7dbf4873bfdbe702250523619a813)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-gesture-handler: specifier: 2.32.0 version: 2.32.0(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -38011,7 +38011,7 @@ snapshots: use-latest-callback: 0.2.6(react@19.2.6) optional: true - react-native-enriched-markdown@1.1.0(patch_hash=4a63eb4016ba84ef89387227d1f9c7e5d333a1ebf2f7d210cb629b7d6993cdcf)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-enriched-markdown@1.1.0(patch_hash=a28e477145c6be0da2af78d959581939d2e7dbf4873bfdbe702250523619a813)(react-native@0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 react-native: 0.86.3(patch_hash=113932fc75a9e6f352c2a0668f6aa2be25e81326b7b96cc47f8420776a1a92f9)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)