Reproducer: #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:
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:
-
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:
@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.
-
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:
composite.borderInsets = composite.borderInsets ?: BorderInsets()
composite.borderInsets?.setBorderWidth(edge, width) // width == NaN
-
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):
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.
-
BackgroundStyleApplicator.clipToPaddingBoxWithAntiAliasing subtracts those insets from composite.bounds, so every edge of the padding box becomes NaN:
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.
-
ReactViewGroup.dispatchDraw only applies that clip when overflow is not visible, which is why overflow: 'hidden' is a required ingredient:
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:
_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
- Run the reproducer below on Android with the New Architecture enabled.
- In group A, tap "Row B".
- "Row A" keeps its grey rounded background but its text disappears.
- 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.
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
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():
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:
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.
Reproducer: #57781 —
RNTesterPlayground.jsedit (RNTester → Playground, Android). Not for merge.Description
On Android, a
ViewwithborderRadiusandoverflow: 'hidden'stops drawing all of its children once itsborderWidthprop is removed — as opposed to being set to0. 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
borderWidthagain restores them immediately.This shows up in the very common "selectable card" pattern, where a selected style adds
borderWidth: 1and the unselected style omits it:Selecting a different card makes the previously selected one render as an empty shell.
Root cause
It looks like a
null-vs-NaNcontract mismatch between the ViewManager and the border-inset storage:ReactViewManager.ktdeclares the borderWidth prop group withdefaultFloat = Float.NaN, so whenborderWidthdisappears from a style, prop diffing invokes the setter withNaN:Note
width: Floatis a primitive and can never benullhere.BackgroundStyleApplicator.setBorderWidthdocumentsnullas the removal signal (@param width The border width in DIPs, or null to remove) but receivesNaNfrom the caller above. It stores it as a present value, and allocatesBorderInsetson that very call:BorderInsets.resolve()is an elvis chain ending in?: 0f. Elvis only fires onnull, so theNaNpasses straight through andresolve()returnsRectF(NaN, NaN, NaN, NaN):Still unguarded on
v0.86.2and onmainat the time of filing.BackgroundStyleApplicator.clipToPaddingBoxWithAntiAliasingsubtracts those insets fromcomposite.bounds, so every edge of the padding box becomesNaN:The
?: 0fguard here cannot help —computedBorderInsetsis non-null, only its fields areNaN. BecauseborderRadiusis set,hasRoundedBorders()is true and that rect is converted into aPathand installed withcanvas.clipPath(paddingBoxPath). A path built fromNaNcoordinates clips everything away.ReactViewGroup.dispatchDrawonly applies that clip when overflow is not visible, which is whyoverflow: 'hidden'is a required ingredient:The background survives because background drawables paint during
View.draw(), beforedispatchDraw()installs the poisoned clip. Hence "shell renders, children vanish".Incidentally,
getPaddingBoxRect()has the sameNaNinsets but calls.toInt(), andNaN.toInt()is0in Kotlin, so that path degrades harmlessly. Only the float/Pathpath propagates theNaN.Why iOS is unaffected
RCTViewuses a negative sentinel for "unset" and clamps it before use:-1is neutralised by oneMAX.NaNpropagates through every arithmetic operation it touches.Steps to reproduce
borderWidth: 0— perform the same tap. It behaves correctly.Group B is the important control.
borderWidth: 0and "noborderWidthat 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 theNaNdefault 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
borderWidthdoes.Reproducer
Fresh
npx @react-native-community/cli initapp, RN 0.86.0, only thisApp.tsxchanged. No third-party libraries, no patches to React Native, no feature-flag overrides. Verified in a release build to rule out dev-only behaviour.Observed vs expected
overflow: hiddenborderWidthNaN)0explicit1constant, only colour togglesNaN)Expected: removing
borderWidthbehaves the same as setting it to0.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.2and onmain.Affected Platforms
Runtime — Android. iOS is not affected.
Output of
npx react-native infoDevice: 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.
NaNflows through the geometry without raising anything.Suggested fix
Treat
NaNas "unset" inBorderInsets, either by filtering per edge inresolve():or by normalising at the entry point so the stored value matches the documented
nullcontract:The second is probably preferable, since it makes the storage consistent with
setBorderWidth's own documented contract and fixes every consumer ofBorderInsetsat once rather than just the clip path.