From f10d310082eecef04624b92c54c752154db10ec6 Mon Sep 17 00:00:00 2001 From: Andrew Datsenko Date: Tue, 5 May 2026 10:18:41 -0700 Subject: [PATCH] Introduce StatefulSpan and MutableSpannableLayout for safe span state isolation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: `PreparedLayoutTextView` can receive shared `PreparedLayout` instances from a C++ LRU cache. Multiple views rendering identical text share the same `Layout` → `Spannable` → span objects. Stateless spans are fine to share, but stateful spans like `SpoilerEffectSpan` (particles, dismiss state) would have one view's tap-dismiss corrupt all other views sharing that layout. Introduces a `StatefulSpan` marker interface whose presence tells `PreparedLayoutTextView` to clone the spannable with fresh span instances. The Layout is reused via a delegating subclass (`MutableSpannableLayout`) that passes the cloned Spannable to `Layout`'s protected constructor and delegates all line metrics to the original. The mutable Spannable can be useful for spans which affect display, but do not alter existing layout calculations. No StaticLayout rebuild needed. Performance: Only views with stateful spans pay the cost. `getSpans()` is O(spans), `SpannableString` copy is O(text+spans). Views without `StatefulSpan` are completely unchanged. Changelog: [internal] Reviewed By: alanleedev Differential Revision: D97415850 --- .../views/text/MutableSpannableLayout.kt | 107 ++++++++++++++++++ .../views/text/PreparedLayoutTextView.kt | 29 ++++- .../views/text/internal/span/StatefulSpan.kt | 21 ++++ 3 files changed, 153 insertions(+), 4 deletions(-) create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/MutableSpannableLayout.kt create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/StatefulSpan.kt diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/MutableSpannableLayout.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/MutableSpannableLayout.kt new file mode 100644 index 000000000000..c4e16d8718bc --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/MutableSpannableLayout.kt @@ -0,0 +1,107 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.views.text + +import android.text.Layout +import android.text.SpannableString +import android.text.Spanned +import com.facebook.react.common.annotations.UnstableReactNativeAPI +import com.facebook.react.views.text.internal.span.StatefulSpan + +/** + * A delegating [Layout] subclass that clones the spannable text from [delegate] and replaces all + * [StatefulSpan] instances with fresh clones. This gives each [PreparedLayoutTextView] independent + * mutable span state (e.g. particle animation, dismiss state) even when the underlying [Layout] is + * shared from a cache. + * + * The mutable [Spannable] can be useful for spans which affect display, but do not alter existing + * layout calculations. + * + * Line metrics are delegated to [delegate] so no expensive [StaticLayout] rebuild is needed. + * [Layout.getText] is final and returns `mText` set by the protected constructor, so the cloned + * [SpannableString] is passed there directly. + */ +internal class MutableSpannableLayout +private constructor( + private val delegate: Layout, + clonedText: SpannableString, +) : + Layout( + clonedText, + delegate.paint, + delegate.width, + delegate.alignment, + delegate.spacingMultiplier, + delegate.spacingAdd, + ) { + + companion object { + /** Returns a [MutableSpannableLayout] if [layout] contains stateful spans, else null. */ + @OptIn(UnstableReactNativeAPI::class) + fun createIfNeeded(layout: Layout): MutableSpannableLayout? { + val spanned = layout.text as? Spanned ?: return null + val statefulSpans = spanned.getSpans(0, spanned.length, StatefulSpan::class.java) + if (statefulSpans.isEmpty()) { + return null + } + + val cloned = SpannableString(spanned) + for (oldSpan in statefulSpans) { + val start = cloned.getSpanStart(oldSpan) + val end = cloned.getSpanEnd(oldSpan) + val flags = cloned.getSpanFlags(oldSpan) + cloned.removeSpan(oldSpan) + cloned.setSpan(oldSpan.clone(), start, end, flags) + } + return MutableSpannableLayout(layout, cloned) + } + } + + // --- 10 abstract methods — delegate to original --- + + override fun getLineCount(): Int = delegate.lineCount + + override fun getLineTop(line: Int): Int = delegate.getLineTop(line) + + override fun getLineDescent(line: Int): Int = delegate.getLineDescent(line) + + override fun getLineStart(line: Int): Int = delegate.getLineStart(line) + + override fun getLineContainsTab(line: Int): Boolean = delegate.getLineContainsTab(line) + + override fun getLineDirections(line: Int): Directions = delegate.getLineDirections(line) + + override fun getTopPadding(): Int = delegate.topPadding + + override fun getBottomPadding(): Int = delegate.bottomPadding + + override fun getEllipsisStart(line: Int): Int = delegate.getEllipsisStart(line) + + override fun getEllipsisCount(line: Int): Int = delegate.getEllipsisCount(line) + + override fun getParagraphDirection(line: Int): Int = delegate.getParagraphDirection(line) + + // --- Non-abstract overrides for performance/correctness --- + // StaticLayout overrides these with optimized implementations. Delegating + // ensures we get the original's fast paths rather than Layout's base + // implementations that recompute from scratch. + + override fun getEllipsizedWidth(): Int = delegate.ellipsizedWidth + + override fun getLineMax(line: Int): Float = delegate.getLineMax(line) + + override fun getLineWidth(line: Int): Float = delegate.getLineWidth(line) + + override fun getLineLeft(line: Int): Float = delegate.getLineLeft(line) + + override fun getLineRight(line: Int): Float = delegate.getLineRight(line) + + // Only called by the framework on API 33+ + @android.annotation.SuppressLint("NewApi") + override fun isFallbackLineSpacingEnabled(): Boolean = delegate.isFallbackLineSpacingEnabled +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt index 1b4e659a2a85..e3a0c122e4a9 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt @@ -48,10 +48,14 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re var preparedLayout: PreparedLayout? = null set(value) { if (field != value) { + val effectiveValue = value?.maybeProxyStatefulSpans() val lastSelection = selection if (lastSelection != null) { - if (value != null && field?.layout?.text.toString() == value.layout.text.toString()) { - value.layout.getSelectionPath( + if ( + effectiveValue != null && + field?.layout?.text.toString() == effectiveValue.layout.text.toString() + ) { + effectiveValue.layout.getSelectionPath( lastSelection.start, lastSelection.end, lastSelection.path, @@ -61,9 +65,10 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re } } - clickableSpans = value?.layout?.text?.let { filterClickableSpans(it) } ?: emptyList() + clickableSpans = + effectiveValue?.layout?.text?.let { filterClickableSpans(it) } ?: emptyList() - field = value + field = effectiveValue invalidate() } } @@ -393,5 +398,21 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re return spans } + + /** + * If the layout contains [StatefulSpan]s, returns a new [PreparedLayout] whose spannable has + * independent clones of those spans. Otherwise returns the receiver unchanged. + */ + private fun PreparedLayout.maybeProxyStatefulSpans(): PreparedLayout { + val proxyLayout = MutableSpannableLayout.createIfNeeded(layout) ?: return this + return PreparedLayout( + proxyLayout, + maximumNumberOfLines, + verticalOffset, + reactTags, + textBreakStrategy, + justificationMode, + ) + } } } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/StatefulSpan.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/StatefulSpan.kt new file mode 100644 index 000000000000..cc8f09284efa --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/StatefulSpan.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.views.text.internal.span + +import com.facebook.react.common.annotations.UnstableReactNativeAPI + +/** + * Marker interface for spans that hold per-view mutable state (e.g. animation particles, dismiss + * flags). When a [PreparedLayout] contains stateful spans, [PreparedLayoutTextView] clones the + * spannable so that each view gets independent state even when layouts are shared from a cache. + */ +@UnstableReactNativeAPI +public interface StatefulSpan { + /** Returns a fresh instance with the same configuration but independent mutable state. */ + public fun clone(): StatefulSpan +}