Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -92,13 +92,17 @@ export class RNSvgHandler implements SvgHandler {
* @param t - Babel types helper.
* @param el - JSXElement node to transform.
* @param dimensions - Optional object to collect extracted width/height info.
* @returns `true` if the element is supported and was transformed in place. `false` if
* the element is not a recognized SVG tag (e.g. a custom wrapper component like
* `Animated.createAnimatedComponent(Path)`) and must be removed by the caller — see
* `handleRegularAttributes` for why leaving it in place isn't an option.
*/
private transformElement(
t: typeof Babel.types,
rootElementPath: Babel.NodePath<Babel.types.JSXElement> | null,
el: Babel.types.JSXElement,
dimensions: Record<string, string>
) {
): boolean {
const openingNode = el.openingElement.name;
const isJSXIdentifierOpen = t.isJSXIdentifier(openingNode);

Expand All @@ -107,9 +111,9 @@ export class RNSvgHandler implements SvgHandler {
openingNode.name = convertAttributeCasing(openingNode.name);
if (!svgElements.has(openingNode.name)) {
console.warn(
`RNSvgHandler[transformElement]: Skipping unsupported element: "${openingNode.name}"`
`RNSvgHandler[transformElement]: Removing unsupported element: "${openingNode.name}"`
);
return; // Skip unsupported elements instead of crashing
return false; // Signal the caller to remove this element from the tree
}
}

Expand All @@ -128,11 +132,12 @@ export class RNSvgHandler implements SvgHandler {
}

this.processAttributes(t, rootElementPath, el, dimensions);
return true;
}

/**
* Recursively traverses the children of a JSXElement and applies `transformElement`
* to each child that is itself a JSXElement.
* to each child that is itself a JSXElement, splicing out any child reported unsupported.
*
* @param t - Babel types helper.
* @param rootElementPath - The path of the root JSX element containing the SVG.
Expand All @@ -147,9 +152,21 @@ export class RNSvgHandler implements SvgHandler {
jsxElement: Babel.types.JSXElement,
dimensions: Record<string, string> = {}
) {
for (const child of jsxElement.children) {
const children = jsxElement.children;

// Iterate in reverse so splicing doesn't shift the indices of unvisited entries.
for (let i = children.length - 1; i >= 0; i--) {
const child = children[i];
if (t.isJSXElement(child)) {
this.transformElement(t, rootElementPath, child, dimensions);
const isSupported = this.transformElement(
t,
rootElementPath,
child,
dimensions
);
if (!isSupported) {
children.splice(i, 1);
}
}
}
}
Expand Down Expand Up @@ -281,7 +298,9 @@ export class RNSvgHandler implements SvgHandler {
continue;
}

handleRegularAttributes(t, attr);
if (handleRegularAttributes(t, attr)) {
el.attributes.splice(index, 1);
}
} catch (error) {
console.error('ReactNativeSVG[processAttributes]: ', error);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -325,20 +325,31 @@ export function handleJoinedTransformAttributes(

/**
* Converts a standard JSX attribute (e.g., `stroke`, `fill`, `opacity`) to a string literal
* if it holds a valid value.
* if it holds a statically resolvable value.
*
* @param t - Babel types helper.
* @param attr - JSX attribute node.
* @returns `true` if the attribute should be removed from the element — i.e. its value is a
* JSX expression that could not be statically resolved and would produce invalid SVG XML
* (e.g. `fill={color}` where `color` is a prop or variable). `false` if the attribute was
* successfully converted or required no change.
*/
export function handleRegularAttributes(
t: typeof Babel.types,
attr: Babel.types.JSXAttribute
) {
): boolean {
const result = getJSXAttributeData(t, attr);

if (result.value) {
// Use !== null rather than truthiness: 0, '', and false are all valid resolved
// values (e.g. opacity={0}) and must not be treated as unresolved.
if (result.value !== null) {
attr.value = t.stringLiteral(result.value.toString());
return false;
}

// Unresolved JSX expression containers (e.g. fill={color}) would otherwise serialize
// into invalid SVG XML and make svgo throw — remove them instead.
return t.isJSXExpressionContainer(attr.value);
}

/**
Expand Down
116 changes: 112 additions & 4 deletions packages/react-native-babel-plugin/test/react-native-svg.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -735,23 +735,71 @@ describe('React Native SVG Processing - RNSvgHandler', () => {
});

describe('Error Handling', () => {
it('should warn for unsupported element names but still include them in output', () => {
it('should warn for unsupported element names and remove them from output', () => {
const warnSpy = jest.spyOn(console, 'warn').mockImplementation();

const input = '<Svg><UnsupportedElement x="10" y="10" /></Svg>';
const output = transformSvg(input);

// Unsupported elements are converted to lowercase and included
// Unsupported elements are now removed entirely rather than left in place.
expect(output).toMatchInlineSnapshot(
`"<svg xmlns="http://www.w3.org/2000/svg"><unsupportedElement x="10" y="10" /></svg>"`
`"<svg xmlns="http://www.w3.org/2000/svg"></svg>"`
);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('Skipping unsupported element')
expect.stringContaining('Removing unsupported element')
);

warnSpy.mockRestore();
});

it('should remove an unsupported element but keep its supported siblings', () => {
const input =
'<Svg><Circle cx="50" cy="50" r="40" fill="#27ae60" /><UnsupportedElement x="10" y="10" /></Svg>';
const output = transformSvg(input);

expect(output).toContain(
'<circle cx="50" cy="50" r="40" fill="#27ae60" />'
);
expect(output).not.toContain('unsupportedElement');
});

it('should remove an unsupported element with dynamic/unresolvable attributes without throwing', () => {
// Mirrors the customer repro: a custom component (e.g. AnimatedPath from
// Animated.createAnimatedComponent) with JSX expression container attributes
// that cannot be statically resolved (fill={color}, style={{opacity}}).
const input = `
function Icon({ color, opacity }) {
return (
<Svg width="64" height="64" viewBox="0 0 64 64">
<Circle cx="32" cy="32" r="28" fill="#27ae60" />
<AnimatedPath
d="M18 32 L28 42 L46 20"
fill={color}
style={{ opacity }}
/>
</Svg>
);
}
`;

expect(() => transformSvg(input)).not.toThrow();

const output = transformSvg(input);
expect(output).toContain(
'<circle cx="32" cy="32" r="28" fill="#27ae60" />'
);
expect(output).not.toContain('animatedPath');
expect(output).not.toContain('fill={color}');
});

it('should preserve resolvable falsy values (0) instead of treating them as unresolved', () => {
const input =
'<Svg><Circle cx="50" cy="50" r="40" opacity={0} /></Svg>';
const output = transformSvg(input);

expect(output).toContain('opacity="0"');
});

it('should handle malformed transform array gracefully', () => {
const input =
'<Svg><Rect x="10" y="10" width="50" height="50" transform={[null, undefined, { invalid: true }]} /></Svg>';
Expand Down Expand Up @@ -826,4 +874,64 @@ describe('SessionReplayView.Privacy SVG Wrapper', () => {
expect(output).not.toContain('flexShrink');
expect(output).not.toContain('style');
});

it('should capture an SVG whose child element has a non-resolvable prop, dropping only that attr from the asset', () => {
const assetDir = path.join(os.tmpdir(), 'dd-svg-test-assets');
const input = `
function Icon({ color }) {
return (
<Svg width="80" height="80" viewBox="0 0 100 100">
<Circle cx={50} cy={50} r={40} fill={color} />
</Svg>
);
}
`;
const output = transformWithSvgTracking(input);

// The wrapper must be present — the element was NOT silently dropped
expect(output).toContain('SessionReplayView.Privacy');
// The wrapper carries a hash attribute — the SVG passed through svgo without error
expect(output).toContain('hash:');

// The written SVG asset must not contain the unresolvable expression.
// (Reading the file proves svgo successfully processed the content.)
const match = output!.match(/hash:\s*["']([0-9a-f]{32})["']/i);
expect(match?.[1]).toBeTruthy();
const svgContent = fs.readFileSync(
path.join(assetDir, `${match![1]}.svg`),
'utf8'
);
expect(svgContent).not.toContain('fill={color}');
// Numeric literals on child elements are resolved and kept
expect(svgContent).toContain('cx="50"');
expect(svgContent).toContain('cy="50"');
expect(svgContent).toContain('r="40"');
});

it('should capture an SVG where all child props are non-resolvable, producing a shape-only SVG', () => {
const assetDir = path.join(os.tmpdir(), 'dd-svg-test-assets');
const input = `
function Icon({ d, fill, strokeWidth }) {
return (
<Svg width="24" height="24" viewBox="0 0 24 24">
<Path d={d} fill={fill} strokeWidth={strokeWidth} />
</Svg>
);
}
`;
const output = transformWithSvgTracking(input);

// Still wrapped — the SVG container is captured even if all child attrs are dynamic
expect(output).toContain('SessionReplayView.Privacy');

const match = output!.match(/hash:\s*["']([0-9a-f]{32})["']/i);
expect(match?.[1]).toBeTruthy();
const svgContent = fs.readFileSync(
path.join(assetDir, `${match![1]}.svg`),
'utf8'
);
expect(svgContent).not.toContain('fill={fill}');
expect(svgContent).not.toContain('d={d}');
expect(svgContent).not.toContain('strokeWidth={strokeWidth}');
});
});