ITADN

[Android] Removing borderWidth from a rounded overflow:hidden View stops all its children from being drawn (NaN reaches the padding-box clip)

#57780OpenSatyamBansal 创建于 28 天前
Needs: Author FeedbackNeeds: Repro
S
SatyamBansalcommented
**Reproducer:** https://github.com/react/react-native/pull/57781 — `RNTesterPlayground.js` edit (RNTester → Playground, Android). Not for merge. ## Description On Android, a `View` with `borderRadius` and `overflow: 'hidden'` **stops drawing all of its children** once its `borderWidth` prop is *removed* — as opposed to being set to `0`. The view's own background keeps painting, so the result is a correctly sized, correctly rounded, completely empty box. The children are still mounted and laid out — the accessibility tree reports them with the correct labels and frames — they simply are not rendered. Setting `borderWidth` again restores them immediately. This shows up in the very common "selectable card" pattern, where a selected style adds `borderWidth: 1` and the unselected style omits it: ```jsx style={[styles.card, isSelected && styles.cardSelected]} ``` Selecting a different card makes the previously selected one render as an empty shell. ### Root cause It looks like a `null`-vs-`NaN` contract mismatch between the ViewManager and the border-inset storage: 1. **`ReactViewManager.kt`** declares the borderWidth prop group with `defaultFloat = Float.NaN`, so when `borderWidth` disappears from a style, prop diffing invokes the setter with `NaN`: ```kotlin @ReactPropGroup(names = [ViewProps.BORDER_WIDTH, ...], defaultFloat = Float.NaN) public open fun setBorderWidth(view: ReactViewGroup, index: Int, width: Float) { BackgroundStyleApplicator.setBorderWidth(view, LogicalEdge.values()[index], width) } ``` Note `width: Float` is a primitive and can never be `null` here. 2. **`BackgroundStyleApplicator.setBorderWidth`** documents `null` as the removal signal (`@param width The border width in DIPs, or null to remove`) but receives `NaN` from the caller above. It stores it as a present value, and allocates `BorderInsets` on that very call: ```kotlin composite.borderInsets = composite.borderInsets ?: BorderInsets() composite.borderInsets?.setBorderWidth(edge, width) // width == NaN ``` 3. **`BorderInsets.resolve()`** is an elvis chain ending in `?: 0f`. Elvis only fires on `null`, so the `NaN` passes straight through and `resolve()` returns `RectF(NaN, NaN, NaN, NaN)`: ```kotlin edgeInsets[LogicalEdge.START.ordinal] ?: edgeInsets[LogicalEdge.LEFT.ordinal] ?: edgeInsets[LogicalEdge.ALL.ordinal] ?: 0f ``` Still unguarded on `v0.86.2` and on `main` at the time of filing. 4. **`BackgroundStyleApplicator.clipToPaddingBoxWithAntiAliasing`** subtracts those insets from `composite.bounds`, so every edge of the padding box becomes `NaN`: ```kotlin paddingBoxRect.left = composite.bounds.left + (computedBorderInsets?.left?.dpToPx() ?: 0f) ``` The `?: 0f` guard here cannot help — `computedBorderInsets` is non-null, only its fields are `NaN`. Because `borderRadius` is set, `hasRoundedBorders()` is true and that rect is converted into a `Path` and installed with `canvas.clipPath(paddingBoxPath)`. A path built from `NaN` coordinates clips everything away. 5. **`ReactViewGroup.dispatchDraw`** only applies that clip when overflow is not visible, which is why `overflow: 'hidden'` is a required ingredient: ```kotlin override fun dispatchDraw(canvas: Canvas) { if (_overflow != Overflow.VISIBLE || getTag(R.id.filter) != null) { clipToPaddingBox(this, canvas) } super.dispatchDraw(canvas) } ``` The background survives because background drawables paint during `View.draw()`, before `dispatchDraw()` installs the poisoned clip. Hence "shell renders, children vanish". Incidentally, `getPaddingBoxRect()` has the same `NaN` insets but calls `.toInt()`, and `NaN.toInt()` is `0` in Kotlin, so that path degrades harmlessly. Only the float/`Path` path propagates the `NaN`. ### Why iOS is unaffected `RCTView` uses a *negative* sentinel for "unset" and clamps it before use: ```objc _borderWidth = -1; // RCTView.m const CGFloat borderWidth = MAX(0, _borderWidth); // RCTView.m ``` `-1` is neutralised by one `MAX`. `NaN` propagates through every arithmetic operation it touches. ## Steps to reproduce 1. Run the reproducer below on Android with the New Architecture enabled. 2. In group **A**, tap "Row B". 3. **"Row A" keeps its grey rounded background but its text disappears.** 4. In group **B** — identical except that the base style sets `borderWidth: 0` — perform the same tap. It behaves correctly. Group B is the important control. `borderWidth: 0` and "no `borderWidth` at all" produce an **identical** 1px geometry change, so this is not a layout or measurement problem — only the *removed prop* triggers it. That is what points at the `NaN` default rather than the border itself. Also worth noting: the broken state is sticky. Scrolling the row off screen and back does not restore the text; only re-adding a `borderWidth` does. ## Reproducer Fresh `npx @react-native-community/cli init` app, RN 0.86.0, only this `App.tsx` changed. No third-party libraries, no patches to React Native, no feature-flag overrides. Verified in a **release** build to rule out dev-only behaviour. ```tsx import React, { useState } from 'react'; import { Pressable, SafeAreaView, StatusBar, Text, View } from 'react-native'; const ROWS = ['Row A', 'Row B', 'Row C']; /** * keepBorderWidth=false -> the unselected style has NO borderWidth, so * deselecting REMOVES the prop. The row keeps its background but all of its * children stop being drawn. * * keepBorderWidth=true -> the unselected style sets borderWidth: 0. Identical * 1px geometry change, but the prop is never removed. Renders correctly. */ function Group({ title, keepBorderWidth, }: { title: string; keepBorderWidth: boolean; }) { const [selected, setSelected] = useState(0); return ( <View style={{ marginBottom: 24 }}> <Text style={{ fontSize: 13, marginBottom: 8, color: '#000' }}> {title} </Text> {ROWS.map((label, i) => ( <Pressable key={label} onPress={() => setSelected(i)} style={[ { backgroundColor: '#e6e8ee', borderRadius: 8, // ingredient 1: rounded -> clip is built as a Path overflow: 'hidden', // ingredient 2: installs the child clip minHeight: 48, justifyContent: 'center', paddingHorizontal: 12, marginBottom: 8, }, keepBorderWidth ? { borderWidth: 0, borderColor: 'transparent' } : null, // ingredient 3: borderWidth appears on select, disappears on deselect i === selected ? { borderWidth: 1, borderColor: '#3b5afb' } : null, ]} > <Text style={{ fontSize: 16, color: '#111' }}>{label}</Text> </Pressable> ))} </View> ); } export default function App() { return ( <SafeAreaView style={{ flex: 1, backgroundColor: '#fff' }}> <StatusBar barStyle="dark-content" /> <View style={{ padding: 16 }}> <Text style={{ fontSize: 16, fontWeight: '600', marginBottom: 16, color: '#000', }} > Android: removing borderWidth hides children </Text> <Group keepBorderWidth={false} title="A) BROKEN - unselected style has no borderWidth" /> <Group keepBorderWidth title="B) OK - unselected style has borderWidth: 0" /> </View> </SafeAreaView> ); } ``` ## Observed vs expected | Variant | `overflow: hidden` | unselected `borderWidth` | Result | | --- | --- | --- | --- | | A | yes | absent (setter receives `NaN`) | **children not drawn** | | B | yes | `0` explicit | correct | | — | yes | `1` constant, only colour toggles | correct | | — | no | absent (`NaN`) | correct | Expected: removing `borderWidth` behaves the same as setting it to `0`. Observed: removing it makes every child of the view invisible while the background continues to paint. ## React Native Version 0.86.0 — the relevant source is unchanged on `v0.86.2` and on `main`. ## Affected Platforms Runtime — Android. iOS is not affected. ## Output of `npx react-native info` ```text npmPackages: "@react-native-community/cli": installed: 20.1.0, wanted: 20.1.0 react: installed: 19.2.3, wanted: 19.2.3 react-native: installed: 0.86.0, wanted: 0.86.0 Android: hermesEnabled: true newArchEnabled: true Java: 21.0.10 Android Studio: 2025.3 ``` Device: Pixel 8, Android 17 (SDK 37), Hermes, New Architecture, release build. ## Stacktrace or Logs No crash and no logs — this is a silent rendering failure. `NaN` flows through the geometry without raising anything. ## Suggested fix Treat `NaN` as "unset" in `BorderInsets`, either by filtering per edge in `resolve()`: ```kotlin edgeInsets[LogicalEdge.START.ordinal]?.takeUnless { it.isNaN() } ?: edgeInsets[LogicalEdge.LEFT.ordinal]?.takeUnless { it.isNaN() } ?: ... ?: 0f ``` or by normalising at the entry point so the stored value matches the documented `null` contract: ```kotlin public fun setBorderWidth(view: View, edge: LogicalEdge, width: Float?) { val normalised = width?.takeUnless { it.isNaN() } ... } ``` The second is probably preferable, since it makes the storage consistent with `setBorderWidth`'s own documented contract and fixes every consumer of `BorderInsets` at once rather than just the clip path.
2 条评论