Skip to content

Instantly share code, notes, and snippets.

@wodin
Last active January 20, 2026 05:20
Show Gist options
  • Select an option

  • Save wodin/6a19d204383aeceac645b7e709890424 to your computer and use it in GitHub Desktop.

Select an option

Save wodin/6a19d204383aeceac645b7e709890424 to your computer and use it in GitHub Desktop.
Technical explanation of ClipPath combining fix for react-native-svg PR

The ClipPath Combining Fix: A Technical Story

The Problem

SVG's <clipPath> element defines a clipping region. When a clipPath has multiple children (shapes like <rect>, <circle>, <path>, etc.), the SVG spec is clear:

"The silhouettes of the child elements are logically OR'd together" — SVG 2 Spec

This means if you have:

<clipPath id="myClip">
  <rect x="0" y="0" width="50" height="100"/>
  <rect x="30" y="0" width="50" height="100"/>
</clipPath>

The clipping region should be the union of both rectangles—anything inside either rect is visible.

The old implementation was broken. It treated clipPath children with simple path addition, which:

  1. Didn't correctly union overlapping shapes
  2. Ignored each child's individual clip-rule property (evenodd vs nonzero)
  3. Applied a single clipRule from the element being clipped, not from the clipPath children

The Solution: Two Rendering Strategies

Both Android and iOS commits implement the same architectural pattern—a dual-strategy approach:

Strategy 1: Fast Path (Simple Cases)

Used when:

  • Single child (no combining needed), OR
  • Multiple non-overlapping children with uniform clipRule

This path uses direct path clipping—no bitmap allocation, maximum performance.

Strategy 2: Mask Path (Complex Cases)

Used when:

  • Children overlap, OR
  • Children have mixed clipRules (some evenodd, some nonzero)

This path renders each child to a grayscale bitmap mask, where:

  • White = inside clip region
  • Black = outside clip region

Each child is drawn with white using its own fill rule. The natural source-over blending of overlapping white fills achieves the UNION operation the spec requires.

Why two strategies? Bitmap allocation is expensive—memory and CPU. For the common case of single-child clipPaths or non-overlapping shapes, we can use fast path clipping with zero overhead. The mask path is only engaged when semantically necessary.


Android Implementation Details

File: ClipPathView.java (+230 lines)

Constants & Configuration:

private static final int STRATEGY_UNKNOWN = 0;
private static final int STRATEGY_FAST = 1;
private static final int STRATEGY_MASK = 2;
private static final int MAX_OVERLAP_CHECK_CHILDREN = 100;
private static final float MAX_LOGICAL_DIMENSION = 100000f;
private static final int MAX_BITMAP_DIMENSION = 2048;

Key decisions:

  • MAX_OVERLAP_CHECK_CHILDREN = 100: The overlap check is O(n²). For >100 children, we assume overlap to avoid stalling the UI thread.
  • MAX_BITMAP_DIMENSION = 2048: Limits mask bitmap to ~4MB (grayscale). Balances quality vs memory on low-end devices.
  • MAX_LOGICAL_DIMENSION = 100000: Rejects malformed SVGs that would cause integer overflow or OOM.

Core Methods:

  1. getUniformClipRule() — Checks if all children have the same clipRule. Returns the rule if uniform, or CLIP_RULE_MIXED (-1) if not.

  2. canUseFastPath() — Decision point:

    boolean isSimple = (childCount == 1) && !(getChildAt(0) instanceof GroupView);
    boolean hasOverlap = !isSimple && hasOverlappingChildren();
    boolean isUniform = getUniformClipRule() != VirtualView.CLIP_RULE_MIXED;
    return (isSimple || !hasOverlap) && isUniform;
  3. hasOverlappingChildren() — O(n²) pairwise bounding box intersection check:

    for (int i = 0; i < validCount; i++) {
      for (int j = i + 1; j < validCount; j++) {
        if (RectF.intersects(bounds[i], bounds[j])) {
          return true;
        }
      }
    }
  4. createMask() — The heart of the mask strategy:

    • Calculates combined bounds of all children
    • Creates Bitmap.Config.ALPHA_8 (grayscale) bitmap
    • Scales for screen density, capped at 2048px
    • Renders each child with its own Path.FillType (EVEN_ODD or WINDING)
    • Caches the result

Memory Safety:

try {
  mask = Bitmap.createBitmap(width, height, Bitmap.Config.ALPHA_8);
} catch (OutOfMemoryError e) {
  FLog.e(ReactConstants.TAG, "...out of memory");
  return null;
}

Lifecycle Management:

  • invalidate() — Recycles cached bitmap, resets strategy
  • clearCache() — Nulls reference without recycling (bitmap may be in use)
  • onDetachedFromWindow() — Safely recycles bitmap

Why clearCache() doesn't recycle? During rendering, RenderableView may still hold a reference to the bitmap. Recycling it would cause a crash. Instead, we null the reference and let GC handle it. onDetachedFromWindow() can safely recycle because no render is in progress.

File: RenderableView.java (+38 lines)

The render path now checks if clipPath needs mask rendering:

if (mClipPath != null) {
  clipPathView = (ClipPathView) root.getDefinedClipPath(mClipPath);
  if (clipPathView != null && !clipPathView.canUseFastPath()) {
    useClipMask = true;
  }
}

When useClipMask is true, after rendering the element:

Paint dstInPaint = new Paint();
dstInPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_IN));
canvas.saveLayer(null, dstInPaint);

Bitmap clipMask = clipPathView.createMask(canvas, paint, bounds);
if (clipMask != null) {
  canvas.drawBitmap(clipMask, null, bounds, null);
}
canvas.restore();

PorterDuff.Mode.DST_IN = Keep destination pixels where source (mask) is opaque. This is how the mask actually clips the rendered content.

File: VirtualView.java (+30 lines, refactored)

The fast-path clipping logic was updated to:

  1. Get clipRule from ClipPathView.getUniformClipRule() instead of from the element being clipped
  2. Use Path.Op.UNION only for nonzero rule (evenodd needs simple addPath to preserve winding)
  3. Skip path-based clipping entirely if mask will be used
void clip(Canvas canvas, Paint paint) {
  if (mClipPath != null) {
    ClipPathView clipNode = (ClipPathView) getSvgView().getDefinedClipPath(mClipPath);
    if (clipNode != null && !clipNode.canUseFastPath()) {
      return;  // Mask clipping handled in render()
    }
  }
  // ... fast path clipping
}

File: GroupView.java (+10 lines)

Null safety fixes for path operations:

if (p2 != null) {
  if (transform != null) {
    p2.transform(transform);
  }
  path.op(p2, pop);
}

iOS Implementation Details

File: RNSVGClipPath.mm (+256 lines)

Very similar architecture to Android, with Objective-C++ idioms:

Strategy enum:

typedef enum {
  RNSVGClipPathStrategyUnknown,
  RNSVGClipPathStrategyFast,
  RNSVGClipPathStrategyMask
} RNSVGClipPathStrategy;

Key Methods:

  1. hasOverlappingChildren: — Uses C++ std::vector<CGRect> for efficient bounds collection with early-exit on first overlap found.

  2. getUniformClipRule:context: — Returns YES if uniform, outputs the rule via pointer.

  3. createMask:bounds: — Creates grayscale bitmap using CoreGraphics:

    CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceGray();
    CGContextRef bitmapContext = CGBitmapContextCreate(
      NULL, width, height, 8, width, colorSpace, kCGImageAlphaNone);

    Renders each child with CGContextFillPath or CGContextEOFillPath based on clipRule.

  4. canUseFastPath:clipRule: — Same logic as Android, caches result.

  5. containsPoint:inPath:context: — New method for hit testing that respects the correct clipRule.

Platform differences:

#if TARGET_OS_OSX
  CGFloat screenScale = [[NSScreen mainScreen] backingScaleFactor];
#else
  CGFloat screenScale = [[UIScreen mainScreen] scale];
#endif

File: RNSVGNode.mm (+80 lines)

New instance variables:

CGImageRef _cachedClipMask;
CGRect _cachedClipMaskBounds;

The clip: method was refactored from a monolithic function into three parts:

  1. clip: — Entry point, decides strategy:

    RNSVGCGFCRule clipRule;
    BOOL canUseFastPath = [_clipNode canUseFastPath:context clipRule:&clipRule];
    
    if (canUseFastPath) {
      [self applyFastPathClipping:context clipRule:clipRule];
    } else {
      [self applyMaskClipping:context];
    }
  2. applyFastPathClipping:clipRule: — Uses CGContextClip or CGContextEOClip

  3. applyMaskClipping: — Uses CGContextClipToMask

Cache invalidation:

if (_clipNode.dirty) {
  CGPathRelease(_cachedClipPath);
  _cachedClipPath = nil;
  CGImageRelease(_cachedClipMask);
  _cachedClipMask = nil;
}

The dirty flag propagates up from ClipPath children when they change, ensuring cached masks are invalidated.

Memory management:

  • dealloc releases both _cachedClipPath and _cachedClipMask
  • prepareForRecycle (Fabric) resets everything including mask bounds

File: RNSVGGroup.mm and RNSVGRenderable.mm

Hit testing was simplified to use the new containsPoint:inPath:context: method:

// Before: Complex branching based on isSimpleClipPath
// After: Single path through ClipPath
if (![clipNode containsPoint:transformed inPath:clipPath context:UIGraphicsGetCurrentContext()]) {
  return nil;
}

Why centralize hit testing in ClipPath? The old code duplicated clipRule logic in both Group and Renderable. Now ClipPath owns the knowledge of how its children combine, making hit testing consistent with rendering.


The clipRule Default Fix

Files: 20 TypeScript specs + 20 Java delegates

Before:

clipRule?: WithDefault<Int32, 0>;  // 0 = evenodd

After:

clipRule?: WithDefault<Int32, 1>;  // 1 = nonzero

Why? The SVG spec says:

"The initial value for clip-rule is nonzero"

The library was incorrectly defaulting to evenodd. This affected the Fabric codegen output—when no clipRule is specified, the generated Java delegates pass the default value.

Note: Testing showed these generated defaults don't actually affect Android (it sets clipRule explicitly elsewhere), but they were corrected for spec compliance and to avoid future bugs.


The failedCases.json Changes

Before the fix, these tests failed on both platforms:

  • clip-basic
  • clip-evenodd-union
  • clip-path-shapes
  • clip-rule
  • issue-1520
  • mask-edge-cases

After Android fix:

"android": {
  "paper": ["2", "clip-path-shapes", "mask-edge-cases"],
  "fabric": ["2", "clip-path-shapes", "mask-edge-cases"]
}

After Apple fix:

"ios": {
  "paper": ["clip-evenodd-union", "clip-path-shapes", "mask-edge-cases"],
  "fabric": ["clip-evenodd-union", "clip-path-shapes", "mask-edge-cases"]
}

Most clipPath tests now pass. Remaining failures are unrelated issues or edge cases requiring further investigation.


Key Design Decisions Summary

Decision Rationale
Dual-strategy (fast/mask) Performance: avoid bitmap for simple cases
O(n²) overlap check Acceptable for typical clipPaths (<20 children)
MAX_OVERLAP_CHECK_CHILDREN = 100 Prevent UI thread stall for pathological cases
MAX_BITMAP_DIMENSION = 2048 Balance quality vs memory (~4MB cap)
Grayscale bitmap Sufficient for binary clip mask, minimal memory
Per-child clipRule in mask Spec-compliant: each child can have different fill rule
Cache masks in consuming node Avoid re-rendering when clipPath unchanged
dirty flag for invalidation Automatic cache busting when clipPath mutates
Default clipRule = nonzero (1) SVG spec compliance

Historical Context

Previous attempts to fix this (v7.0.0 era) used Path.op(UNION) unconditionally on Android, which:

  1. Worked for nonzero clipRule but broke evenodd (UNION flattens path structure)
  2. Didn't handle per-child clipRules

Our fix handles both cases correctly by only using UNION when semantically safe, and falling back to bitmap compositing for complex cases.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment