diff --git a/benchmarks/src/scenario/SessionReplay/component/Svg.tsx b/benchmarks/src/scenario/SessionReplay/component/Svg.tsx index 581e894dd..ae86f9950 100644 --- a/benchmarks/src/scenario/SessionReplay/component/Svg.tsx +++ b/benchmarks/src/scenario/SessionReplay/component/Svg.tsx @@ -27,11 +27,9 @@ import { HeartIcon, ShieldIcon } from './assets/icons'; // Module-level const used in Case D1 to test findIdentifierInScope const BADGE_SIZE = 72; -// Used in Group I to reproduce the customer's `AnimatedPath` case without pulling in -// react-native-reanimated. `Animated.createAnimatedComponent` is core React Native and -// produces the exact same shape that breaks the Babel plugin: a capitalized custom -// component whose first-letter-lowercased tag name ('AnimatedPath' -> 'animatedPath') -// is not in svgElements. +// Used in Group I. `Animated.createAnimatedComponent` is core React Native and needs no +// extra dependency to produce a capitalized custom component whose first-letter-lowercased +// tag name ('AnimatedPath' -> 'animatedPath') is not in svgElements. const AnimatedPath = Animated.createAnimatedComponent(Path); // ───────────────────────────────────────────────────────────── @@ -266,12 +264,16 @@ function ViewBoxOnlyIcon() { } // ───────────────────────────────────────────────────────────── -// GROUP E — Dynamic props -// Non-resolvable props are stripped before svgo runs (Fix 1). -// Shape is captured; dynamic attribute is absent in replay. +// GROUP E — Dynamic props (known limitation, deferred to a follow-up PR) +// A property value that can't be resolved at build time (e.g. fill={color}) is left as-is +// rather than stripped or guessed at — stripping/altering properties can change an SVG's +// bounds in ways that are hard to reason about. Left in place, it makes the generated markup +// invalid, svgo fails to parse it, and the whole is skipped — same graceful-failure path +// as before any of this work, just no longer reachable for unsupported *tags* (see Group I). +// A follow-up PR will pass these runtime values through the native view to fill in natively. // ───────────────────────────────────────────────────────────── -/** E1: Dynamic fill on root element — fill dropped, container captured */ +/** E1: Dynamic fill on root element — EXPECTED: absent from replay entirely */ function DynamicRootFill({ accentColor }: { accentColor: string }) { return ( @@ -280,7 +282,7 @@ function DynamicRootFill({ accentColor }: { accentColor: string }) { ); } -/** E2: Dynamic fill on child — child fill dropped, shape still captured */ +/** E2: Dynamic fill on child — EXPECTED: absent from replay entirely */ function DynamicChildFill({ iconColor }: { iconColor: string }) { return ( @@ -290,7 +292,7 @@ function DynamicChildFill({ iconColor }: { iconColor: string }) { ); } -/** E3: Dynamic stroke and strokeWidth — both dropped, base shape preserved */ +/** E3: Dynamic stroke and strokeWidth — EXPECTED: absent from replay entirely */ function DynamicStrokeRect({ borderColor, borderWidth }: { borderColor: string; borderWidth: number }) { return ( @@ -308,7 +310,7 @@ function DynamicStrokeRect({ borderColor, borderWidth }: { borderColor: string; ); } -/** E4: Mix of static fill + dynamic opacity — fill kept, opacity dropped */ +/** E4: Mix of static fill + dynamic opacity — EXPECTED: absent from replay entirely */ function DynamicOpacityCircle({ fadeLevel }: { fadeLevel: number }) { return ( @@ -339,7 +341,7 @@ function BarrelShieldImport() { } // ───────────────────────────────────────────────────────────── -// GROUP I — Unsupported nested elements (customer repro) +// GROUP I — Unsupported nested elements // AnimatedPath isn't a recognized SVG tag, so it's now spliced out of the tree // instead of breaking generation for the whole parent (see RNSvgHandler). // ───────────────────────────────────────────────────────────── @@ -427,7 +429,7 @@ export default function SvgTestCases() { Session Replay SVG Test Screen All cases in Groups A–D should appear in replay.{'\n'} - Group E: shape appears, dynamic attr is absent.{'\n'} + Group E: known limitation — absent from replay entirely (see comment).{'\n'} Group F: appears after buildSvgMap fixes.{'\n'} Group G: privacy overrides — verify masking behavior in replay.{'\n'} Group I: I1 shows circle only (checkmark removed), I2 shows circle + checkmark. @@ -493,17 +495,17 @@ export default function SvgTestCases() { -
- +
+ - + - + - +
@@ -561,8 +563,8 @@ export default function SvgTestCases() {
{/* - G4: imagePrivacy = MASK_NON_BUNDLED_ONLY, matching the customer's session - setting. SVGs come from assets.bin (bundled), so EXPECTED: VISIBLE. + G4: imagePrivacy = MASK_NON_BUNDLED_ONLY. SVGs come from assets.bin + (bundled), so EXPECTED: VISIBLE. */}
-
+
@@ -601,7 +603,10 @@ export default function SvgTestCases() { from the Babel transform by design (index.ts:81,128). No DdPrivacyView wrapper is ever created around them.{'\n\n'} • SvgUri is listed in svgSupportedNames but its handler is commented out — - <SvgUri uri="..." /> is silently skipped. + <SvgUri uri="..." /> is silently skipped.{'\n\n'} + • A property value that can't be resolved at build time (e.g. fill={color}) + makes the whole <Svg> skipped, not just that attribute — see Group E. + A follow-up PR will fill these in on the native side instead.
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..25f97aed6 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 @@ -9,7 +9,7 @@ import generate from '@babel/generator'; import { jsxAttribute, jSXIdentifier, stringLiteral } from '@babel/types'; import { getNodeName } from '../../../utils'; -import { svgElements, svgSupportedNames, xmlNamespace } from '../constants'; +import { svgSupportedNames, xmlNamespace } from '../constants'; import { buildTransformStringAttribute, handleArrayAttributes, @@ -18,6 +18,7 @@ import { handleRNSpecificAttributes, handleSeparateTransformAttributes, handleSvgDimensions, + isSupportedSvgElement, validateAttribute } from '../processing/attributes'; import { convertAttributeCasing } from '../utils'; @@ -56,6 +57,10 @@ export class RNSvgHandler implements SvgHandler { const clone = this.types.cloneNode(this.path.node, true); + if (!this.isElementSupported(this.types, clone)) { + return undefined; + } + this.transformElement(this.types, this.path, clone, dimensions); this.setNamespace(this.types, clone); @@ -84,97 +89,132 @@ export class RNSvgHandler implements SvgHandler { } /** - * Transforms an individual JSXElement by: - * - Converting tag casing to be web-compatible. - * - Processing and sanitizing attributes. - * - Recursively handling children. - * - * @param t - Babel types helper. - * @param el - JSXElement node to transform. - * @param dimensions - Optional object to collect extracted width/height info. + * Pure check, run before any mutation — callers never infer support from whether a + * transform happened to succeed. Only the element's own tag is checked here: unresolvable + * property *values* are handled separately (see `processAttributes`) and don't affect + * whether the element itself is kept. + */ + private isElementSupported( + t: typeof Babel.types, + el: Babel.types.JSXElement + ): boolean { + const openingNode = el.openingElement.name; + + // A member expression (), namespaced name, or any other non-identifier tag + // name form is never in svgElements (a plain-string allowlist), so it's unsupported — + // but it would otherwise skip this check entirely and pass through unconverted, + // reopening the exact "one bad tag corrupts the whole SVG" failure this check exists + // to prevent. + if (!t.isJSXIdentifier(openingNode)) { + console.warn( + `RNSvgHandler[isElementSupported]: Removing element with an unsupported tag name form: "${getNodeName( + t, + openingNode + )}"` + ); + return false; + } + + const elementName = convertAttributeCasing(openingNode.name); + + if (!isSupportedSvgElement(elementName)) { + console.warn( + `RNSvgHandler[isElementSupported]: Removing unsupported element: "${elementName}"` + ); + return false; + } + + if (el.closingElement) { + const closingNode = el.closingElement.name; + + // Same treatment as the opening tag: return false (element removed by the caller) + // rather than throw. A throw here wouldn't clean up the malformed node first, and + // the mismatched tag left behind can make Babel's own code generation fail later — + // for the *whole file*, not just this element. Removing it keeps the tree valid. + if (!t.isJSXIdentifier(closingNode)) { + console.warn( + `RNSvgHandler[isElementSupported]: Removing element with an unsupported closing tag name form: "${getNodeName( + t, + closingNode + )}"` + ); + return false; + } + + const closingElementName = convertAttributeCasing(closingNode.name); + + // `elementName` (the opening tag) is already confirmed supported above, so a + // mismatch check here also subsumes "closing tag is unsupported": the only way + // closingElementName can equal elementName is if it's supported too. Both tags can + // individually be supported elements yet still not match each other (e.g. + // ...) — a real parser never produces this, but a malformed AST from + // AST manipulation upstream could. Left unchecked, this would generate invalid + // markup with the same whole-file blast radius as the other checks above. + if (closingElementName !== elementName) { + console.warn( + `RNSvgHandler[isElementSupported]: Removing element with mismatched closing tag: "${elementName}" vs "${closingElementName}"` + ); + return false; + } + } + + return true; + } + + /** + * Assumes `isElementSupported(el)` already returned `true`. */ private transformElement( t: typeof Babel.types, rootElementPath: Babel.NodePath | null, el: Babel.types.JSXElement, dimensions: Record - ) { + ): void { const openingNode = el.openingElement.name; - const isJSXIdentifierOpen = t.isJSXIdentifier(openingNode); - // Fix casing for openingElement - if (isJSXIdentifierOpen) { + if (t.isJSXIdentifier(openingNode)) { openingNode.name = convertAttributeCasing(openingNode.name); - if (!svgElements.has(openingNode.name)) { - console.warn( - `RNSvgHandler[transformElement]: Skipping unsupported element: "${openingNode.name}"` - ); - return; // Skip unsupported elements instead of crashing - } } const closingNode = el.closingElement?.name; - const isJSXIdentifierClose = t.isJSXIdentifier(closingNode); - // Fix casing for closingElement - if (isJSXIdentifierClose) { + if (t.isJSXIdentifier(closingNode)) { closingNode.name = convertAttributeCasing(closingNode.name); - - if (!svgElements.has(closingNode.name)) { - throw new Error( - `RNSvgHandler[transformElement]: Failed to transform element: "${closingNode.name}" is not supported` - ); - } } this.processAttributes(t, rootElementPath, el, dimensions); } - /** - * Recursively traverses the children of a JSXElement and applies `transformElement` - * to each child that is itself a JSXElement. - * - * @param t - Babel types helper. - * @param rootElementPath - The path of the root JSX element containing the SVG. - * Used to locate lexical scopes (component or program) for resolving variable references. - * May be `null` if no traversal context is available. - * @param jsxElement - Parent JSXElement whose children will be transformed. - * @param dimensions - Optional object to propagate width/height info through child elements. - */ private traverseAndTransformChildren( t: typeof Babel.types, rootElementPath: Babel.NodePath | null, jsxElement: Babel.types.JSXElement, dimensions: Record = {} ) { - for (const child of jsxElement.children) { - if (t.isJSXElement(child)) { - this.transformElement(t, rootElementPath, child, dimensions); + 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)) { + continue; + } + + if (!this.isElementSupported(t, child)) { + children.splice(i, 1); + continue; } + + this.transformElement(t, rootElementPath, child, dimensions); } } - /** - * Processes and transforms all attributes of a given JSXElement: - * - Removes invalid or unsupported attributes. - * - Normalizes attribute casing and naming. - * - Consolidates transform-related attributes into a single `transform` string. - * - Extracts dimensions and stores them in the provided `dimensions` object. - * - Recursively applies transformations to child elements. - * - * @param t - Babel types helper. - * @param rootElementPath - The path of the root JSX element containing the SVG. - * Used to locate lexical scopes (component or program) for resolving variable references. - * May be `null` if no traversal context is available. - * @param jsxElement - JSXElement whose attributes are to be processed. - * @param dimensions - Optional object to collect extracted width/height info. - */ private processAttributes( t: typeof Babel.types, rootElementPath: Babel.NodePath | null, jsxElement: Babel.types.JSXElement, dimensions: Record = {} - ) { + ): void { const el = jsxElement.openingElement; const name = getNodeName(t, el); const transformsArray: { name: string; value: string | number }[] = []; @@ -192,7 +232,6 @@ export class RNSvgHandler implements SvgHandler { continue; } - // Handle RN style attribute & non-supported attributes const rnAttributesHandled = handleRNSpecificAttributes( t, attr, @@ -205,7 +244,6 @@ export class RNSvgHandler implements SvgHandler { continue; } - // Validate whether the attribute is valid const { attrName, isInvalidAttribute } = validateAttribute( attr.name.name ); @@ -215,10 +253,6 @@ export class RNSvgHandler implements SvgHandler { continue; } - /* If we reach this point we know we have a valid attribute name */ - - // Handle SVG dimensions - const { resolved: dimensionsHandled, remove: removeDimension @@ -233,18 +267,16 @@ export class RNSvgHandler implements SvgHandler { ); if (dimensionsHandled) { - // If dimension is invalid or if it's a variable that was not initialized in the file - // We remove the attribute and assign a value in the native layer where we have access to wireframe's dimensions + // Unresolved variable dimensions are filled in natively, where wireframe + // sizing is available. if (removeDimension) { el.attributes.splice(index, 1); } continue; } - // Set the formatted attibute name to our cloned element attr.name.name = attrName; - // Handle array attributes const arrayAttributesHandled = handleArrayAttributes( t, attr, @@ -255,7 +287,6 @@ export class RNSvgHandler implements SvgHandler { continue; } - // Handle separate transform attributes const separateTransformAttributesHandled = handleSeparateTransformAttributes( t, attr, @@ -268,7 +299,6 @@ export class RNSvgHandler implements SvgHandler { continue; } - // Handle joined transform attributes const joinedTransformAttributesHandled = handleJoinedTransformAttributes( t, attr, @@ -287,10 +317,7 @@ export class RNSvgHandler implements SvgHandler { } } - // Create & Set a new transform attribute based on the element's transform attributes buildTransformStringAttribute(el, transformsArray); - - // Goes through an elements children and transforms its properties this.traverseAndTransformChildren(t, null, jsxElement, dimensions); } } 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..ddb0199e8 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 @@ -19,7 +19,8 @@ import { rnSvgArrayAttributeValues, rnSvgTransformAttributeValues, svgAttributesCC, - svgAttributesKC + svgAttributesKC, + svgElements } from '../constants'; import { convertStyleObjToCssObj, kebabCase } from '../utils'; @@ -93,6 +94,10 @@ export function handleRNSpecificAttributes( return false; } +export function isSupportedSvgElement(name: string): boolean { + return svgElements.has(name); +} + /** * Validates and normalizes an attribute name for use in web SVG: * - Converts camelCase to kebab-case if needed. @@ -325,7 +330,10 @@ 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. Attributes whose value can't be resolved at + * build time (e.g. `fill={color}`) are left untouched today — the plugin doesn't attempt to + * guess or drop them. Resolving these on the native side, once the runtime value is known, is + * planned as a follow-up and not yet implemented. * * @param t - Babel types helper. * @param attr - JSX attribute node. @@ -333,10 +341,11 @@ export function handleJoinedTransformAttributes( export function handleRegularAttributes( t: typeof Babel.types, attr: Babel.types.JSXAttribute -) { +): void { const result = getJSXAttributeData(t, attr); - if (result.value) { + // !== null, not truthiness: 0/'' (e.g. opacity={0}) are valid resolved values. + if (result.value !== null) { attr.value = t.stringLiteral(result.value.toString()); } } 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..178f450b4 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 @@ -7,6 +7,7 @@ /* eslint quotes: ["off"] */ import { transform } from '@babel/core'; import * as parser from '@babel/parser'; +import type { NodePath } from '@babel/traverse'; import traverse from '@babel/traverse'; import * as t from '@babel/types'; import fs from 'fs'; @@ -735,23 +736,132 @@ 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 element whose tag name is a member expression (e.g. ), rather than pass it through unconverted', () => { + const input = ` + function Icon({ color }) { + return ( + + + + + ); + } + `; + const output = transformSvg(input); + + expect(output).not.toContain('Icons.Path'); + expect(output).toContain( + '' + ); + }); + + it('should remove an unsupported element with dynamic/unresolvable attributes without throwing', () => { + 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 leave an unresolvable property value untouched rather than strip it or drop the element, deferring to native-side fill-in', () => { + // Property-level resolution is intentionally out of scope for this fix (see PR + // review discussion) — dropping properties/tags based on resolvability can affect + // an SVG's bounds in ways that are hard to reason about. This fix only drops + // unsupported *tags*; a follow-up PR will pass unresolvable runtime property + // values through the native view to be filled in on the native side instead. + const input = ` + function Icon({ color }) { + return ( + + + + ); + } + `; + const output = transformSvg(input); + + expect(output).toContain('fill={color}'); + }); + + it('should drop only the unsupported child inside a group, leaving the group and its other siblings intact', () => { + const input = ` + function Icon() { + return ; + } + `; + const output = transformSvg(input); + + expect(output).toContain(''); + expect(output).toContain( + '' + ); + expect(output).not.toContain('unsupportedElement'); + }); + + it('should keep a polygon whose `points` use nested coordinate-pair arrays with signed numbers', () => { + const input = + ''; + const output = transformSvg(input); + + expect(output).toContain( + '' + ); + }); + it('should handle malformed transform array gracefully', () => { const input = ''; @@ -760,6 +870,123 @@ describe('React Native SVG Processing - RNSvgHandler', () => { `""` ); }); + + it('should return undefined from transformSvgNode when the root tag itself is unsupported, without mutating the node', () => { + // Direct unit test of RNSvgHandler.transformSvgNode's own guard — the real plugin + // never reaches this via today (HandlerResolver disables that handler + // first), but the guard exists so the root element is checked the same way as any + // child before anything is mutated. + const ast = parser.parse( + '', + { + sourceType: 'module', + plugins: ['jsx', 'typescript'] + } + ); + + let result: string | undefined; + let openingName: string | undefined; + traverse(ast, { + JSXElement(nodePath) { + const handler = new RNSvgHandler(t, nodePath, 'SvgUri'); + result = handler.transformSvgNode({}); + const name = nodePath.node.openingElement.name; + openingName = t.isJSXIdentifier(name) + ? name.name + : undefined; + } + }); + + expect(result).toBeUndefined(); + expect(openingName).toBe('SvgUri'); + }); + + it('should remove (not throw for) an element whose closing tag has a mismatched name form, so a malformed node never lingers in the tree', () => { + // A real parser never produces an opening/closing tag pair with different node + // types — this can only happen via direct AST manipulation, which is exactly what + // this test does. It matters because throwing here (instead of returning false) + // would leave the malformed node in the tree unrepaired; Babel's own code + // generation can then fail on it later, corrupting the *whole file's* output, not + // just this element. Returning false lets the caller splice it out cleanly. + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(); + + const ast = parser.parse( + '', + { sourceType: 'module', plugins: ['jsx', 'typescript'] } + ); + + let svgPath: NodePath | undefined; + traverse(ast, { + JSXElement(elPath) { + const name = elPath.node.openingElement.name; + if (t.isJSXIdentifier(name) && name.name === 'Svg') { + svgPath = elPath; + } + if ( + t.isJSXIdentifier(name) && + name.name === 'Circle' && + elPath.node.closingElement + ) { + elPath.node.closingElement.name = t.jsxMemberExpression( + t.jsxIdentifier('Icons'), + t.jsxIdentifier('Circle') + ); + } + } + }); + + const handler = new RNSvgHandler(t, svgPath!, 'Svg'); + + expect(() => handler.transformSvgNode({})).not.toThrow(); + expect(handler.transformSvgNode({})).toBe( + '' + ); + + warnSpy.mockRestore(); + }); + + it('should remove an element whose opening and closing tags are each individually supported but name different elements (e.g. ...)', () => { + // Both tags pass the individual "is this a supported element" check, but they + // don't match each other. A real parser never produces this — mismatched + // opening/closing tag names are a parse error — so this is exercised via direct + // AST manipulation, simulating a malformed tree from an upstream transform. + // Left unchecked, this would still generate invalid markup with the same + // whole-file blast radius the other checks in this function guard against. + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(); + + const ast = parser.parse( + '', + { sourceType: 'module', plugins: ['jsx', 'typescript'] } + ); + + let svgPath: NodePath | undefined; + traverse(ast, { + JSXElement(elPath) { + const name = elPath.node.openingElement.name; + if (t.isJSXIdentifier(name) && name.name === 'Svg') { + svgPath = elPath; + } + if ( + t.isJSXIdentifier(name) && + name.name === 'Circle' && + elPath.node.closingElement + ) { + elPath.node.closingElement.name = t.jsxIdentifier( + 'Rect' + ); + } + } + }); + + const handler = new RNSvgHandler(t, svgPath!, 'Svg'); + + expect(() => handler.transformSvgNode({})).not.toThrow(); + expect(handler.transformSvgNode({})).toBe( + '' + ); + + warnSpy.mockRestore(); + }); }); }); @@ -810,6 +1037,16 @@ describe('SessionReplayView.Privacy SVG Wrapper', () => { expect(output).toMatchSnapshot(); }); + it('should leave SvgUri untouched, since its handler is disabled in HandlerResolver, rather than throw or produce a broken wrapper', () => { + const input = + ''; + const output = transformWithSvgTracking(input); + + expect(output).not.toContain('SessionReplayView.Privacy'); + expect(output).toContain('SvgUri'); + expect(output).toContain('https://example.com/x.svg'); + }); + it('should wrap an SVG without explicit dimensions', () => { const input = ''; @@ -826,4 +1063,86 @@ describe('SessionReplayView.Privacy SVG Wrapper', () => { expect(output).not.toContain('flexShrink'); expect(output).not.toContain('style'); }); + + it('should skip wrapping the SVG entirely when a supported child has an unresolvable property, rather than strip the property or the element', () => { + // Property-level resolution is deferred to a follow-up PR (native-side fill-in). Until + // then, an unresolvable property on an otherwise-supported tag is left in place, svgo + // fails to parse the resulting invalid markup, and this SVG is skipped — same + // (pre-existing) graceful-failure path as before this fix, just no longer reachable + // for unsupported *tags* like AnimatedPath, which are now removed before svgo ever runs. + const input = ` + function Icon({ color }) { + return ( + + + + ); + } + `; + const output = transformWithSvgTracking(input); + + expect(output).not.toContain('SessionReplayView.Privacy'); + expect(output).toContain('fill: color'); + }); + + it('should skip wrapping the SVG entirely when an unsupported tag is reachable only through a JSXExpressionContainer, e.g. conditional rendering', () => { + // traverseAndTransformChildren only inspects direct JSXElement entries of + // `jsxElement.children`. An unsupported tag reached through a JSXExpressionContainer + // (conditional `{cond && }`, `{items.map(...)}`) or a JSXFragment child is a + // different node type at that position, so it's skipped entirely rather than removed + // — same deferred-limitation category as unresolvable properties above (see PR + // discussion). This test locks in that current, known-limitation behavior so a partial + // fix for one wrapper shape doesn't silently leave the others untested. + const input = ` + function Icon({ showCheckmark, color }) { + return ( + + + {showCheckmark && } + + ); + } + `; + const output = transformWithSvgTracking(input); + + expect(output).not.toContain('SessionReplayView.Privacy'); + }); + + it('should wrap the SVG and write a clean asset when an unsupported tag is removed, going through the real svgo/asset-writing pipeline', () => { + // The mirror case of the test above, and the actual customer repro: unlike an + // unresolvable property, an unsupported *tag* (AnimatedPath) is removed before the + // string is ever handed to svgo, so this one — unlike the property case — must still + // get wrapped. This exercises the real pipeline (svgo, asset file on disk), not just + // RNSvgHandler's own output string. + const assetDir = path.join(os.tmpdir(), 'dd-svg-test-assets'); + const input = ` + function Icon({ color, opacity }) { + return ( + + + + + ); + } + `; + const output = transformWithSvgTracking(input); + + 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).toContain( + '' + ); + expect(svgContent).not.toContain('animatedPath'); + expect(svgContent).not.toContain('fill={color}'); + }); });