Skip to content

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

Description

@SatyamBansal

Reproducer: #57781RNTesterPlayground.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:

  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:

    @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:

    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):

    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:

    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:

    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

  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.

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Needs: AttentionIssues where the author has responded to feedback.Needs: ReproThis issue could be improved with a clear list of steps to reproduce the issue.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions