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:
- Didn't correctly union overlapping shapes
- Ignored each child's individual
clip-ruleproperty (evenodd vs nonzero) - Applied a single
clipRulefrom the element being clipped, not from the clipPath children
Both Android and iOS commits implement the same architectural pattern—a dual-strategy approach:
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.
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.
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:
-
getUniformClipRule()— Checks if all children have the same clipRule. Returns the rule if uniform, orCLIP_RULE_MIXED (-1)if not. -
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;
-
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; } } }
-
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 strategyclearCache()— 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.
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.
The fast-path clipping logic was updated to:
- Get clipRule from
ClipPathView.getUniformClipRule()instead of from the element being clipped - Use
Path.Op.UNIONonly for nonzero rule (evenodd needs simple addPath to preserve winding) - 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
}Null safety fixes for path operations:
if (p2 != null) {
if (transform != null) {
p2.transform(transform);
}
path.op(p2, pop);
}Very similar architecture to Android, with Objective-C++ idioms:
Strategy enum:
typedef enum {
RNSVGClipPathStrategyUnknown,
RNSVGClipPathStrategyFast,
RNSVGClipPathStrategyMask
} RNSVGClipPathStrategy;Key Methods:
-
hasOverlappingChildren:— Uses C++std::vector<CGRect>for efficient bounds collection with early-exit on first overlap found. -
getUniformClipRule:context:— ReturnsYESif uniform, outputs the rule via pointer. -
createMask:bounds:— Creates grayscale bitmap using CoreGraphics:CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceGray(); CGContextRef bitmapContext = CGBitmapContextCreate( NULL, width, height, 8, width, colorSpace, kCGImageAlphaNone);
Renders each child with
CGContextFillPathorCGContextEOFillPathbased on clipRule. -
canUseFastPath:clipRule:— Same logic as Android, caches result. -
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];
#endifNew instance variables:
CGImageRef _cachedClipMask;
CGRect _cachedClipMaskBounds;The clip: method was refactored from a monolithic function into three parts:
-
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]; }
-
applyFastPathClipping:clipRule:— UsesCGContextCliporCGContextEOClip -
applyMaskClipping:— UsesCGContextClipToMask
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:
deallocreleases both_cachedClipPathand_cachedClipMaskprepareForRecycle(Fabric) resets everything including mask bounds
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.
Before:
clipRule?: WithDefault<Int32, 0>; // 0 = evenoddAfter:
clipRule?: WithDefault<Int32, 1>; // 1 = nonzeroWhy? 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.
Before the fix, these tests failed on both platforms:
clip-basicclip-evenodd-unionclip-path-shapesclip-ruleissue-1520mask-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.
| 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 |
Previous attempts to fix this (v7.0.0 era) used Path.op(UNION) unconditionally on Android, which:
- Worked for nonzero clipRule but broke evenodd (UNION flattens path structure)
- 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.