diff --git a/packages/react-native-babel-plugin/src/libraries/react-native-svg/handlers/RNSvgHandler.ts b/packages/react-native-babel-plugin/src/libraries/react-native-svg/handlers/RNSvgHandler.ts index 659fec676..15d07232c 100644 --- a/packages/react-native-babel-plugin/src/libraries/react-native-svg/handlers/RNSvgHandler.ts +++ b/packages/react-native-babel-plugin/src/libraries/react-native-svg/handlers/RNSvgHandler.ts @@ -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 | null, el: Babel.types.JSXElement, dimensions: Record - ) { + ): boolean { const openingNode = el.openingElement.name; const isJSXIdentifierOpen = t.isJSXIdentifier(openingNode); @@ -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 } } @@ -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. @@ -147,9 +152,21 @@ export class RNSvgHandler implements SvgHandler { jsxElement: Babel.types.JSXElement, dimensions: Record = {} ) { - 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); + } } } } @@ -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); } diff --git a/packages/react-native-babel-plugin/src/libraries/react-native-svg/processing/attributes.ts b/packages/react-native-babel-plugin/src/libraries/react-native-svg/processing/attributes.ts index 7a413be11..0aa854e97 100644 --- a/packages/react-native-babel-plugin/src/libraries/react-native-svg/processing/attributes.ts +++ b/packages/react-native-babel-plugin/src/libraries/react-native-svg/processing/attributes.ts @@ -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); } /** diff --git a/packages/react-native-babel-plugin/test/react-native-svg.test.ts b/packages/react-native-babel-plugin/test/react-native-svg.test.ts index 5427e9d76..a8f3a70ef 100644 --- a/packages/react-native-babel-plugin/test/react-native-svg.test.ts +++ b/packages/react-native-babel-plugin/test/react-native-svg.test.ts @@ -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 = ''; 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( - `""` + `""` ); 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 = + ''; + const output = transformSvg(input); + + expect(output).toContain( + '' + ); + 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 ( + + + + + ); + } + `; + + expect(() => transformSvg(input)).not.toThrow(); + + const output = transformSvg(input); + expect(output).toContain( + '' + ); + 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 = + ''; + const output = transformSvg(input); + + expect(output).toContain('opacity="0"'); + }); + it('should handle malformed transform array gracefully', () => { const input = ''; @@ -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 ( + + + + ); + } + `; + 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 ( + + + + ); + } + `; + 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}'); + }); });