Skip to content

Commit ca83ef3

Browse files
andrewdacenkometa-codesync[bot]
authored andcommitted
Introduce StatefulSpan and MutableSpannableLayout for safe span state isolation (#56688)
Summary: Pull Request resolved: #56688 `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 fbshipit-source-id: fd570daf839a299d2d12617a9fc2dbd57f1e4049
1 parent 882be91 commit ca83ef3

3 files changed

Lines changed: 153 additions & 4 deletions

File tree

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
package com.facebook.react.views.text
9+
10+
import android.text.Layout
11+
import android.text.SpannableString
12+
import android.text.Spanned
13+
import com.facebook.react.common.annotations.UnstableReactNativeAPI
14+
import com.facebook.react.views.text.internal.span.StatefulSpan
15+
16+
/**
17+
* A delegating [Layout] subclass that clones the spannable text from [delegate] and replaces all
18+
* [StatefulSpan] instances with fresh clones. This gives each [PreparedLayoutTextView] independent
19+
* mutable span state (e.g. particle animation, dismiss state) even when the underlying [Layout] is
20+
* shared from a cache.
21+
*
22+
* The mutable [Spannable] can be useful for spans which affect display, but do not alter existing
23+
* layout calculations.
24+
*
25+
* Line metrics are delegated to [delegate] so no expensive [StaticLayout] rebuild is needed.
26+
* [Layout.getText] is final and returns `mText` set by the protected constructor, so the cloned
27+
* [SpannableString] is passed there directly.
28+
*/
29+
internal class MutableSpannableLayout
30+
private constructor(
31+
private val delegate: Layout,
32+
clonedText: SpannableString,
33+
) :
34+
Layout(
35+
clonedText,
36+
delegate.paint,
37+
delegate.width,
38+
delegate.alignment,
39+
delegate.spacingMultiplier,
40+
delegate.spacingAdd,
41+
) {
42+
43+
companion object {
44+
/** Returns a [MutableSpannableLayout] if [layout] contains stateful spans, else null. */
45+
@OptIn(UnstableReactNativeAPI::class)
46+
fun createIfNeeded(layout: Layout): MutableSpannableLayout? {
47+
val spanned = layout.text as? Spanned ?: return null
48+
val statefulSpans = spanned.getSpans(0, spanned.length, StatefulSpan::class.java)
49+
if (statefulSpans.isEmpty()) {
50+
return null
51+
}
52+
53+
val cloned = SpannableString(spanned)
54+
for (oldSpan in statefulSpans) {
55+
val start = cloned.getSpanStart(oldSpan)
56+
val end = cloned.getSpanEnd(oldSpan)
57+
val flags = cloned.getSpanFlags(oldSpan)
58+
cloned.removeSpan(oldSpan)
59+
cloned.setSpan(oldSpan.clone(), start, end, flags)
60+
}
61+
return MutableSpannableLayout(layout, cloned)
62+
}
63+
}
64+
65+
// --- 10 abstract methods — delegate to original ---
66+
67+
override fun getLineCount(): Int = delegate.lineCount
68+
69+
override fun getLineTop(line: Int): Int = delegate.getLineTop(line)
70+
71+
override fun getLineDescent(line: Int): Int = delegate.getLineDescent(line)
72+
73+
override fun getLineStart(line: Int): Int = delegate.getLineStart(line)
74+
75+
override fun getLineContainsTab(line: Int): Boolean = delegate.getLineContainsTab(line)
76+
77+
override fun getLineDirections(line: Int): Directions = delegate.getLineDirections(line)
78+
79+
override fun getTopPadding(): Int = delegate.topPadding
80+
81+
override fun getBottomPadding(): Int = delegate.bottomPadding
82+
83+
override fun getEllipsisStart(line: Int): Int = delegate.getEllipsisStart(line)
84+
85+
override fun getEllipsisCount(line: Int): Int = delegate.getEllipsisCount(line)
86+
87+
override fun getParagraphDirection(line: Int): Int = delegate.getParagraphDirection(line)
88+
89+
// --- Non-abstract overrides for performance/correctness ---
90+
// StaticLayout overrides these with optimized implementations. Delegating
91+
// ensures we get the original's fast paths rather than Layout's base
92+
// implementations that recompute from scratch.
93+
94+
override fun getEllipsizedWidth(): Int = delegate.ellipsizedWidth
95+
96+
override fun getLineMax(line: Int): Float = delegate.getLineMax(line)
97+
98+
override fun getLineWidth(line: Int): Float = delegate.getLineWidth(line)
99+
100+
override fun getLineLeft(line: Int): Float = delegate.getLineLeft(line)
101+
102+
override fun getLineRight(line: Int): Float = delegate.getLineRight(line)
103+
104+
// Only called by the framework on API 33+
105+
@android.annotation.SuppressLint("NewApi")
106+
override fun isFallbackLineSpacingEnabled(): Boolean = delegate.isFallbackLineSpacingEnabled
107+
}

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,14 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re
4848
var preparedLayout: PreparedLayout? = null
4949
set(value) {
5050
if (field != value) {
51+
val effectiveValue = value?.maybeProxyStatefulSpans()
5152
val lastSelection = selection
5253
if (lastSelection != null) {
53-
if (value != null && field?.layout?.text.toString() == value.layout.text.toString()) {
54-
value.layout.getSelectionPath(
54+
if (
55+
effectiveValue != null &&
56+
field?.layout?.text.toString() == effectiveValue.layout.text.toString()
57+
) {
58+
effectiveValue.layout.getSelectionPath(
5559
lastSelection.start,
5660
lastSelection.end,
5761
lastSelection.path,
@@ -61,9 +65,10 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re
6165
}
6266
}
6367

64-
clickableSpans = value?.layout?.text?.let { filterClickableSpans(it) } ?: emptyList()
68+
clickableSpans =
69+
effectiveValue?.layout?.text?.let { filterClickableSpans(it) } ?: emptyList()
6570

66-
field = value
71+
field = effectiveValue
6772
invalidate()
6873
}
6974
}
@@ -393,5 +398,21 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re
393398

394399
return spans
395400
}
401+
402+
/**
403+
* If the layout contains [StatefulSpan]s, returns a new [PreparedLayout] whose spannable has
404+
* independent clones of those spans. Otherwise returns the receiver unchanged.
405+
*/
406+
private fun PreparedLayout.maybeProxyStatefulSpans(): PreparedLayout {
407+
val proxyLayout = MutableSpannableLayout.createIfNeeded(layout) ?: return this
408+
return PreparedLayout(
409+
proxyLayout,
410+
maximumNumberOfLines,
411+
verticalOffset,
412+
reactTags,
413+
textBreakStrategy,
414+
justificationMode,
415+
)
416+
}
396417
}
397418
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
package com.facebook.react.views.text.internal.span
9+
10+
import com.facebook.react.common.annotations.UnstableReactNativeAPI
11+
12+
/**
13+
* Marker interface for spans that hold per-view mutable state (e.g. animation particles, dismiss
14+
* flags). When a [PreparedLayout] contains stateful spans, [PreparedLayoutTextView] clones the
15+
* spannable so that each view gets independent state even when layouts are shared from a cache.
16+
*/
17+
@UnstableReactNativeAPI
18+
public interface StatefulSpan {
19+
/** Returns a fresh instance with the same configuration but independent mutable state. */
20+
public fun clone(): StatefulSpan
21+
}

0 commit comments

Comments
 (0)