[Android] Removing borderWidth from a rounded overflow:hidden View stops all its children from being drawn (NaN reaches the padding-box clip)
Needs: Author FeedbackNeeds: Repro
**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 条评论