Skip to content

fix: add null safety guard to Brush.getVal() on Android#2940

Open
developerdanx wants to merge 1 commit into
software-mansion:mainfrom
developerdanx:fix/android-brush-null-pointer-exception
Open

fix: add null safety guard to Brush.getVal() on Android#2940
developerdanx wants to merge 1 commit into
software-mansion:mainfrom
developerdanx:fix/android-brush-null-pointer-exception

Conversation

@developerdanx

Copy link
Copy Markdown

Summary

Adds a null check in Brush.getVal() to prevent a NullPointerException crash on Android when gradient coordinate props (x1, y1, x2, y2) have not yet been initialized by the JS thread.

Problem

When the React Native JS thread is slow to deliver gradient coordinate properties to the native UI thread, LinearGradientView.saveDefinition() creates a Brush with null SVGLength entries in mPoints. When the native renderer then calls setupPaint()getVal(), it passes these null values to PropHelper.fromRelative(), causing an immediate NullPointerException crash.

This affects all SVG components using <LinearGradient> or <RadialGradient> on Android. iOS is unaffected due to its different rendering pipeline.

Crash stack trace

java.lang.NullPointerException: Attempt to read from field 'int com.horcrux.svg.SVGLength.unit' on a null object reference
    at com.horcrux.svg.Brush.getVal(Brush.java:108)
    at com.horcrux.svg.Brush.setupPaint(Brush.java:181)

Production Impact

This was discovered in production at Factorial, where a large OTA bundle (caused by a temporary EXPO_NO_BUNDLE_SPLITTING=1 workaround) slowed JS thread execution enough to expose this race condition. The result was ~17,000 crash events across ~8,000 Android users on core screens that render SVGs with <LinearGradient>.

The crash did not occur on native APK builds because pre-compiled Hermes bytecode executes fast enough to beat the UI draw cycle. It only manifested on OTA updates where the JS bundle loads more slowly.

Fix

A simple null guard in getVal() that returns 0 as a safe default when SVGLength is null. This allows the gradient to render gracefully (with default coordinates) rather than crashing, and the correct coordinates are applied once the JS thread delivers them.

private double getVal(SVGLength length, double relative, float scale, float textSize) {
    if (length == null) {
      return 0;
    }
    return PropHelper.fromRelative(
        length, relative, 0,
        mUseObjectBoundingBox && length.unit == SVGLength.UnitType.NUMBER ? relative : scale,
        textSize);
}

Testing

This is a race condition between the JS thread and the native UI thread, which makes it inherently difficult to reproduce in a deterministic test environment.

What we did:

  • Applied this fix as a pnpm patch in our production app at Factorial
  • Deployed an OTA update under the same conditions that triggered the crash (large monolithic bundle with EXPO_NO_BUNDLE_SPLITTING=1)
  • Confirmed the crash stopped entirely after the patch (~17K crash events → 0)

What we didn't do:

  • There are no Android unit tests in this repo today, so we did not add one. The getVal() method is a good candidate for a plain JUnit test (no Android framework dependencies), and we're happy to add test infrastructure in this PR if maintainers prefer it.
  • The existing E2E visual regression suite cannot reproduce this issue, since it requires a specific JS thread timing delay that doesn't occur in normal test conditions.

When the JS thread is slow to send gradient coordinate props (x1, y1,
x2, y2) to the native UI thread, the SVGLength values in the mPoints
array can be null when setupPaint() is called. This causes a
NullPointerException crash in Brush.getVal().

This was discovered in production at Factorial, where a large OTA
bundle slowed JS execution enough to expose the race condition,
causing ~17K crash events across ~8K Android users on screens
rendering SVGs with LinearGradient.

The fix adds a null check that returns 0 as a safe default, allowing
the gradient to render gracefully once coordinates arrive.
andrlut added a commit to andrlut/rpgtasks that referenced this pull request May 3, 2026
…store Manrope on iOS/web (#70)

Hero kept native-crashing on Android even with ErrorBoundary in place.
A native crash from react-native-svg's C++/Java side bypasses the JS
boundary — there's no way to catch it in JS. The user reported the
app still force-closes after PR #69 OTA'd.

Pivot: on Android, never construct any react-native-svg components.
Render HexChartFallback (pure-RN Views/Text) directly. Same data, same
look-and-feel for the dim cards / pips, just without the radar polygon.

iOS and web continue to render the proper HexChart with Manrope on
SvgText labels. Both platforms still wear the ErrorBoundary so any
JS-level throw drops in inline.

Restored fontFamily="Manrope_800ExtraBold" on the SvgText labels per
user request — the rest of the app already uses Manrope and the
inconsistency was visible.

Re-enable the SVG path on Android once react-native-svg ships the
Brush.getVal null guard (PR software-mansion/react-native-svg#2940)
and we resolve native typeface registration for SVG text.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant