From 1c7b34fab873b2e6056fb58cd671795c5571dad3 Mon Sep 17 00:00:00 2001 From: jonathanmos <48201295+jonathanmos@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:22:44 +0300 Subject: [PATCH 01/10] Render placeholder wireframe for SVG views with missing entry data SvgViewMapper returned an empty wireframe list when an SVG's hash or resource entry data was unavailable, making the element fully invisible in the replay instead of showing at least its bounding box. --- .../sessionreplay/mappers/SvgViewMapper.kt | 32 +++++++------------ .../mappers/SvgViewMapperTest.kt | 10 +++--- 2 files changed, 18 insertions(+), 24 deletions(-) diff --git a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/mappers/SvgViewMapper.kt b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/mappers/SvgViewMapper.kt index 7ae53ae2f..edd88fd47 100644 --- a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/mappers/SvgViewMapper.kt +++ b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/mappers/SvgViewMapper.kt @@ -60,20 +60,22 @@ internal open class SvgViewMapper( val wireframes = mutableListOf() if (view is DdPrivacyView) { - val hash = view.attributes?.get("hash") ?: return listOf( - MobileSegment.Wireframe.ShapeWireframe( - resolveViewId(view), - viewGlobalBounds.x, - viewGlobalBounds.y, - viewGlobalBounds.width, - viewGlobalBounds.height, - shapeStyle = shapeStyle, - border = border - ) + val containerWireframe = MobileSegment.Wireframe.ShapeWireframe( + resolveViewId(view), + viewGlobalBounds.x, + viewGlobalBounds.y, + viewGlobalBounds.width, + viewGlobalBounds.height, + shapeStyle = shapeStyle, + border = border ) + + val hash = view.attributes?.get("hash") ?: return listOf(containerWireframe) val width = view.attributes?.get("width") val height = view.attributes?.get("height") + wireframes.add(containerWireframe) + var entryData = internalCallback.getEntryData(hash) ?: return wireframes @@ -96,16 +98,6 @@ internal open class SvgViewMapper( entryData = entryStr.toByteArray(Charsets.UTF_8); } - wireframes.add(MobileSegment.Wireframe.ShapeWireframe( - resolveViewId(view), - viewGlobalBounds.x, - viewGlobalBounds.y, - viewGlobalBounds.width, - viewGlobalBounds.height, - shapeStyle = shapeStyle, - border = border - )) - val imageWireframeId = viewIdentifierResolver.resolveChildUniqueIdentifier(view, "svg") ?: return wireframes val imgWireframe = MobileSegment.Wireframe.ImageWireframe( imageWireframeId, diff --git a/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/mappers/SvgViewMapperTest.kt b/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/mappers/SvgViewMapperTest.kt index 97b01be5e..f37c3bc50 100644 --- a/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/mappers/SvgViewMapperTest.kt +++ b/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/mappers/SvgViewMapperTest.kt @@ -148,7 +148,7 @@ internal class SvgViewMapperTest { } @Test - fun `M return empty list W map() { DdPrivacyView with hash but no entry data }`() { + fun `M return ShapeWireframe W map() { DdPrivacyView with hash but no entry data }`() { // Given val hash = "missing-entry-hash" whenever(mockDdPrivacyView.attributes).thenReturn(mapOf("hash" to hash)) @@ -163,11 +163,12 @@ internal class SvgViewMapperTest { ) // Then - assertThat(result).isEmpty() + assertThat(result).hasSize(1) + assertThat(result[0]).isInstanceOf(MobileSegment.Wireframe.ShapeWireframe::class.java) } @Test - fun `M return empty list W map() { DdPrivacyView with hash but no child view }`() { + fun `M return ShapeWireframe W map() { DdPrivacyView with hash but no child view }`() { // Given val hash = "no-child-hash" val svgBytes = "".toByteArray(Charsets.UTF_8) @@ -184,6 +185,7 @@ internal class SvgViewMapperTest { ) // Then - assertThat(result).isEmpty() + assertThat(result).hasSize(1) + assertThat(result[0]).isInstanceOf(MobileSegment.Wireframe.ShapeWireframe::class.java) } } From 08ed22cbb2694efba07463ae1f6104c350eb8955 Mon Sep 17 00:00:00 2001 From: jonathanmos <48201295+jonathanmos@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:23:42 +0300 Subject: [PATCH 02/10] Call setApiTypes before buildSvgMap so the SVG scan actually runs buildSvgMap() is a no-op until setApiTypes() has been called (it guards on this.t). Both the plugin's pre() hook and the generate-sr-assets CLI called buildSvgMap() first, so the project-wide SVG scan silently produced an empty map on every real build. --- .../react-native-babel-plugin/src/cli/generate-sr-assets.ts | 3 +++ packages/react-native-babel-plugin/src/index.ts | 1 + 2 files changed, 4 insertions(+) diff --git a/packages/react-native-babel-plugin/src/cli/generate-sr-assets.ts b/packages/react-native-babel-plugin/src/cli/generate-sr-assets.ts index c6cffe00e..66f4c2c38 100644 --- a/packages/react-native-babel-plugin/src/cli/generate-sr-assets.ts +++ b/packages/react-native-babel-plugin/src/cli/generate-sr-assets.ts @@ -6,6 +6,7 @@ */ import { transformSync } from '@babel/core'; +import * as babelTypes from '@babel/types'; import glob from 'fast-glob'; import fs from 'fs'; import path from 'path'; @@ -370,6 +371,8 @@ function generateSessionReplayAssets() { const errors: Array<{ file: string; error: string }> = []; const reactNativeSVG = new ReactNativeSVG(rootDir, assetsPath, true); + reactNativeSVG.setApiTypes(babelTypes); + reactNativeSVG.buildSvgMap(); for (const file of files) { try { diff --git a/packages/react-native-babel-plugin/src/index.ts b/packages/react-native-babel-plugin/src/index.ts index 6c41836c5..03827d8d1 100644 --- a/packages/react-native-babel-plugin/src/index.ts +++ b/packages/react-native-babel-plugin/src/index.ts @@ -60,6 +60,7 @@ export default declare( assetsPath, options.__internal_saveSvgMapToDisk || false ); + reactNativeSVG.setApiTypes(api.types); reactNativeSVG.buildSvgMap(); } reactNativeSVG?.setApiTypes(api.types); From 4f558ec2704b7576b3df0ac40e12662d03b99640 Mon Sep 17 00:00:00 2001 From: jonathanmos <48201295+jonathanmos@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:24:31 +0300 Subject: [PATCH 03/10] Fix barrel re-export naming in buildSvgMap's SVG scan For `export { default as Logo } from './icon.svg'`, the scan keyed the map entry on spec.local.name ('default') instead of spec.exported ('Logo') -- the name consumers actually import and render as . Aliased re-exports were therefore never matched. --- .../src/libraries/react-native-svg/index.ts | 7 +- .../test/react-native-svg.test.ts | 75 +++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/packages/react-native-babel-plugin/src/libraries/react-native-svg/index.ts b/packages/react-native-babel-plugin/src/libraries/react-native-svg/index.ts index 1d3fed1ab..d9e248d30 100644 --- a/packages/react-native-babel-plugin/src/libraries/react-native-svg/index.ts +++ b/packages/react-native-babel-plugin/src/libraries/react-native-svg/index.ts @@ -169,9 +169,14 @@ export class ReactNativeSVG { ); for (const spec of path.node.specifiers) { if (spec.type === 'ExportSpecifier') { + // spec.exported is the name consumers import under + // ('default' would be wrong for `export { default as Logo }`) + const exported = spec.exported; const name = getNodeName( this.t, - spec.local.name + this.t.isStringLiteral(exported) + ? exported.value + : exported.name ); if (name) { this.localSvgMap[name] = { 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 178f450b4..38e10165e 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 @@ -1146,3 +1146,78 @@ describe('SessionReplayView.Privacy SVG Wrapper', () => { expect(svgContent).not.toContain('fill={color}'); }); }); + +describe('ReactNativeSVG.buildSvgMap', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dd-buildsvgmap-')); + fs.writeFileSync( + path.join(tmpDir, 'icon.svg'), + '' + ); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should populate localSvgMap from a default import of an SVG file', () => { + fs.writeFileSync( + path.join(tmpDir, 'Component.tsx'), + `import Logo from './icon.svg';\nexport default function C() { return ; }` + ); + + const instance = new ReactNativeSVG(tmpDir, tmpDir, false); + instance.setApiTypes(t); + instance.buildSvgMap(); + + expect(instance.localSvgMap['Logo']).toBeDefined(); + expect(instance.localSvgMap['Logo'].path).toBe( + path.join(tmpDir, 'icon.svg') + ); + }); + + it('should populate localSvgMap from a named import of an SVG file', () => { + fs.writeFileSync( + path.join(tmpDir, 'Component.tsx'), + `import { ReactComponent as StarIcon } from './icon.svg';\nexport default function C() { return ; }` + ); + + const instance = new ReactNativeSVG(tmpDir, tmpDir, false); + instance.setApiTypes(t); + instance.buildSvgMap(); + + expect(instance.localSvgMap['StarIcon']).toBeDefined(); + }); + + it('should populate localSvgMap with the exported name for aliased re-exports', () => { + fs.writeFileSync( + path.join(tmpDir, 'icons.ts'), + `export { default as Logo } from './icon.svg';` + ); + + const instance = new ReactNativeSVG(tmpDir, tmpDir, false); + instance.setApiTypes(t); + instance.buildSvgMap(); + + expect(instance.localSvgMap['Logo']).toBeDefined(); + expect(instance.localSvgMap['Logo'].path).toBe( + path.join(tmpDir, 'icon.svg') + ); + expect(instance.localSvgMap['default']).toBeUndefined(); + }); + + it('should populate localSvgMap with the exported name for non-aliased re-exports', () => { + fs.writeFileSync( + path.join(tmpDir, 'icons.ts'), + `export { StarIcon } from './icon.svg';` + ); + + const instance = new ReactNativeSVG(tmpDir, tmpDir, false); + instance.setApiTypes(t); + instance.buildSvgMap(); + + expect(instance.localSvgMap['StarIcon']).toBeDefined(); + }); +}); From 44bfa5ca0184ad39c3ce3fc79eab00aae85c0f3c Mon Sep 17 00:00:00 2001 From: jonathanmos <48201295+jonathanmos@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:25:12 +0300 Subject: [PATCH 04/10] Reuse the ReactNativeSVG instance across files in pre() pre() assigned options.__internal_reactNativeSVG directly with no fallback, so in a real build (no injected instance) every file discarded the previous scan and rebuilt/rescanned from scratch. --- .../react-native-babel-plugin/src/index.ts | 5 ++++- .../test/react-native-svg.test.ts | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/react-native-babel-plugin/src/index.ts b/packages/react-native-babel-plugin/src/index.ts index 03827d8d1..cbeaa79fb 100644 --- a/packages/react-native-babel-plugin/src/index.ts +++ b/packages/react-native-babel-plugin/src/index.ts @@ -53,7 +53,10 @@ export default declare( assetsPath = getAssetsPath(); } - reactNativeSVG = options.__internal_reactNativeSVG; + // Reuse the instance across files instead of rebuilding (and + // rescanning) it per file. + reactNativeSVG = + options.__internal_reactNativeSVG ?? reactNativeSVG; if (!reactNativeSVG && assetsPath) { reactNativeSVG = new ReactNativeSVG( process.cwd(), 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 38e10165e..1f80703d0 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 @@ -1220,4 +1220,23 @@ describe('ReactNativeSVG.buildSvgMap', () => { expect(instance.localSvgMap['StarIcon']).toBeDefined(); }); + + it('should not overwrite localSvgMap when buildSvgMap is called a second time on a fresh instance', () => { + fs.writeFileSync( + path.join(tmpDir, 'icons.ts'), + `export { StarIcon } from './icon.svg';` + ); + + const instance = new ReactNativeSVG(tmpDir, tmpDir, false); + instance.setApiTypes(t); + instance.buildSvgMap(); + const mapAfterFirstCall = { ...instance.localSvgMap }; + + // Simulates what pre() used to do: create a brand-new instance per file + const freshInstance = new ReactNativeSVG(tmpDir, tmpDir, false); + freshInstance.setApiTypes(t); + freshInstance.buildSvgMap(); + + expect(freshInstance.localSvgMap).toEqual(mapAfterFirstCall); + }); }); From eaeb48f1877d03937ac048c5a6c73fd3908b0efb Mon Sep 17 00:00:00 2001 From: jonathanmos <48201295+jonathanmos@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:28:55 +0300 Subject: [PATCH 05/10] Scope local SVG resolution to the importing file's actual bindings HandlerResolver/LocalSvgHandler matched purely on JSX tag name against a project-wide map, with no check that the current file actually imported that name from an SVG. A plain component in one file could get wrapped as an unrelated SVG also named Icon imported or re-exported elsewhere in the project. resolveSvgImport now resolves each usage site through the JSX tag's real scope binding, tracing it to its source module (direct .svg import or barrel re-export via svgFileMap) before treating it as SVG. --- .../react-native-babel-plugin/src/index.ts | 8 +- .../handlers/HandlerResolver.ts | 33 +- .../handlers/LocalSvgHandler.ts | 42 +- .../handlers/UriSvgHandler.ts | 2 +- .../src/libraries/react-native-svg/index.ts | 347 +++++++++------ .../test/react-native-svg.test.ts | 401 ++++++++++++++++-- 6 files changed, 618 insertions(+), 215 deletions(-) diff --git a/packages/react-native-babel-plugin/src/index.ts b/packages/react-native-babel-plugin/src/index.ts index cbeaa79fb..c9d29d89f 100644 --- a/packages/react-native-babel-plugin/src/index.ts +++ b/packages/react-native-babel-plugin/src/index.ts @@ -145,7 +145,13 @@ export default declare( options ); - pluginState.reactNativeSVG?.processItem(path, name); + if (state.filename) { + pluginState.reactNativeSVG?.processItem( + path, + name, + state.filename + ); + } } } }; diff --git a/packages/react-native-babel-plugin/src/libraries/react-native-svg/handlers/HandlerResolver.ts b/packages/react-native-babel-plugin/src/libraries/react-native-svg/handlers/HandlerResolver.ts index dff5a54ff..6aff31736 100644 --- a/packages/react-native-babel-plugin/src/libraries/react-native-svg/handlers/HandlerResolver.ts +++ b/packages/react-native-babel-plugin/src/libraries/react-native-svg/handlers/HandlerResolver.ts @@ -16,45 +16,40 @@ type Dependencies = { t: typeof Babel.types; path: Babel.NodePath; name: string; - localSvgMap: Record; + // Already scoped to the current file's actual import bindings (see + // ReactNativeSVG.resolveSvgImport) — null if this tag isn't a local SVG import. + svgPath: string | null; + readSvgContent: (path: string) => string; }; export class HandlerResolver { private static registry: Record; private static dependencies: Dependencies | null = null; - /** - * Registers handler factories for supported JSX element types and stores shared dependencies. - * This method must be called before invoking `create()`, as it initializes the internal registry - * with handler constructors that are parameterized with the provided Babel context and configuration. - * - * @param dependencies - Shared Babel-related dependencies and contextual information, - * including `types`, the current JSX `path`, tag `name`, and the `localSvgMap`. - */ static configure(dependencies: Dependencies) { this.dependencies = dependencies; - const { t, path, name, localSvgMap } = dependencies; + const { t, path, name, svgPath, readSvgContent } = dependencies; HandlerResolver.registry = { RNSvgHandler: () => new RNSvgHandler(t, path, name), // UriSvgHandler: () => new UriSvgHandler(t, path, name), LocalSvgHandler: () => - new LocalSvgHandler(t, path, name, localSvgMap) + new LocalSvgHandler( + t, + path, + name, + svgPath as string, + readSvgContent + ) }; } - /** - * Resolves and returns the appropriate handler instance based on the JSX tag name. - * @throws Error if `configure()` has not been called prior to invocation. - * - * @returns The resolved handler instance or `null` if no match exists. - */ static create() { if (!this.dependencies) { throw new Error('HandlerResolver must be configured before use.'); } - const { name, localSvgMap } = this.dependencies; + const { name, svgPath } = this.dependencies; switch (name) { case 'Svg': { @@ -66,7 +61,7 @@ export class HandlerResolver { // } default: { - return localSvgMap[name] + return svgPath ? HandlerResolver.registry.LocalSvgHandler() : null; } diff --git a/packages/react-native-babel-plugin/src/libraries/react-native-svg/handlers/LocalSvgHandler.ts b/packages/react-native-babel-plugin/src/libraries/react-native-svg/handlers/LocalSvgHandler.ts index 0504e0a10..0499e72a9 100644 --- a/packages/react-native-babel-plugin/src/libraries/react-native-svg/handlers/LocalSvgHandler.ts +++ b/packages/react-native-babel-plugin/src/libraries/react-native-svg/handlers/LocalSvgHandler.ts @@ -5,7 +5,6 @@ */ import type * as Babel from '@babel/core'; -import fs from 'fs'; import { getNodeName } from '../../../utils'; import { handleSvgDimensions } from '../processing/attributes'; @@ -13,40 +12,29 @@ import { handleSvgDimensions } from '../processing/attributes'; import type { SvgHandler } from './SvgHandler'; /** - * Internal handler that inlines locally imported SVG components into - * JSX output during Babel transformation. - * - * The `LocalSvgHandler` resolves SVG imports from disk, caches their raw - * contents, and extracts relevant dimension attributes (e.g., width, height) - * from the JSX element for use in the generated SVG markup. + * Inlines locally imported SVG components into JSX output during Babel + * transformation. */ export class LocalSvgHandler implements SvgHandler { constructor( private types: typeof Babel.types, private path: Babel.NodePath, private name: string, - private localSvgMap: Record + private svgPath: string, + private readSvgContent: (path: string) => string ) { // no-op } - /** - * Retrieves and returns the contents of a local SVG file corresponding to the JSXElement tag name. - * If the file hasn't been read yet, it reads the SVG content from disk and caches it in `localSvgMap`. - * Also extracts and stores width/height dimensions from the JSX attributes into the `dimensions` object. - * - * @param dimensions - Object to collect extracted width/height info. - * @returns Raw SVG string content from the local file, or undefined if the tag is not found in `localSvgMap`. - */ transformSvgNode(dimensions: Record) { - if (!this.localSvgMap[this.name]) { + if (!this.svgPath) { return undefined; } - const { path, content } = this.localSvgMap[this.name]; + const content = this.readSvgContent(this.svgPath); if (!content) { - this.localSvgMap[this.name].content = fs.readFileSync(path, 'utf8'); + return undefined; } this.processAttributes( @@ -56,22 +44,9 @@ export class LocalSvgHandler implements SvgHandler { dimensions ); - return this.localSvgMap[this.name].content; + return content; } - /** - * Processes the attributes of a JSXElement to extract relevant SVG metadata. - * Specifically identifies and handles dimension-related attributes (e.g., width, height), - * storing them into the provided `dimensions` object. Ignores spread attributes. - * - * @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 - The JSXElement whose attributes will be processed. - * @param dimensions - Object to collect extracted width/height info. - */ private processAttributes( t: typeof Babel.types, rootElementPath: Babel.NodePath | null, @@ -90,7 +65,6 @@ export class LocalSvgHandler implements SvgHandler { continue; } - // Handle SVG dimensions handleSvgDimensions( t, rootElementPath, diff --git a/packages/react-native-babel-plugin/src/libraries/react-native-svg/handlers/UriSvgHandler.ts b/packages/react-native-babel-plugin/src/libraries/react-native-svg/handlers/UriSvgHandler.ts index 89caccf03..55ffb3b58 100644 --- a/packages/react-native-babel-plugin/src/libraries/react-native-svg/handlers/UriSvgHandler.ts +++ b/packages/react-native-babel-plugin/src/libraries/react-native-svg/handlers/UriSvgHandler.ts @@ -32,7 +32,7 @@ export class UriSvgHandler implements SvgHandler { * Also extracts and stores width/height dimensions from the JSX attributes into the `dimensions` object. * * @param dimensions - Object to collect extracted width/height info. - * @returns Raw SVG string content from the local file, or undefined if the tag is not found in `localSvgMap`. + * @returns The `uri` attribute value, or undefined if the element has none. */ transformSvgNode(dimensions: Record) { const uri = this.processAttributes( diff --git a/packages/react-native-babel-plugin/src/libraries/react-native-svg/index.ts b/packages/react-native-babel-plugin/src/libraries/react-native-svg/index.ts index d9e248d30..2845631f3 100644 --- a/packages/react-native-babel-plugin/src/libraries/react-native-svg/index.ts +++ b/packages/react-native-babel-plugin/src/libraries/react-native-svg/index.ts @@ -25,22 +25,23 @@ type SvgOffset = { length: number; }; -/** - * Internal processor responsible for detecting, transforming, and wrapping - * React Native SVG components for use with Session Replay. - * - * This class scans the project for `.svg` imports, builds a mapping between - * JSX identifiers and SVG files, and transforms JSX SVG nodes into - * optimized, web-compatible SVG markup. Each transformed element is then - * wrapped in a `SessionReplayView.Privacy` component with metadata used by - * the native Session Replay layer. - */ +// A raw re-export edge found while scanning a file: `svgPath` is terminal +// (points at an .svg); `fromFile`/`fromName` is a barrel hop still to resolve. +type SvgExportEdge = + | { svgPath: string } + | { fromFile: string; fromName: string }; + export class ReactNativeSVG { svgMap: Record = {}; svgOffset: Record = {}; - localSvgMap: Record = {}; + // Resolved SVG re-exports, keyed by the barrel file's absolute path, then + // by the name it exposes. Direct `.svg` imports are resolved live in + // resolveSvgImport and are not stored here. + svgFileMap: Record> = {}; + + private svgContentCache: Record = {}; t: typeof Babel.types | null = null; @@ -55,37 +56,21 @@ export class ReactNativeSVG { } /** - * Scans all source files in the project to detect `.svg` imports and builds a mapping - * of JSX identifiers to their corresponding SVG file paths. This is done by parsing each - * file's AST and collecting `import` or `export` declarations that reference `.svg` files. - * - * The collected mappings are stored in `localSvgMap`, keyed by the local/imported variable - * names (e.g., `Logo`, `IconSearch`), with their values pointing to the resolved file path. - * - * This method ignores files in `node_modules`, `lib`, and `dist`, as well as `.d.ts`, test, - * and config files. - * - * If `saveSvgMapToDisk` is false, it will first attempt to load the mapping from a previously - * saved `svg-map.json` file for better performance. If the file doesn't exist or can't be read, - * it falls back to scanning the codebase. - * - * If `saveSvgMapToDisk` is true, the mapping will be saved to a JSON file in the assets directory - * after scanning. + * Scans the project for `.svg` barrel re-exports and populates svgFileMap, + * following `export ... from` chains down to their terminal `.svg` file. */ buildSvgMap() { if (!this.t) { return; } - // If not saving to disk, try to load from existing svg-map.json first if (!this.saveSvgMapToDisk) { - // Resolve to package root: from lib/commonjs/libraries/react-native-svg -> package root - const packageRoot = pathN.resolve(__dirname, '../../../..'); + const packageRoot = resolvePackageRoot(__dirname); const svgMapPath = pathN.join(packageRoot, 'svg-map.json'); try { if (fs.existsSync(svgMapPath)) { const mapContent = fs.readFileSync(svgMapPath, 'utf8'); - this.localSvgMap = JSON.parse(mapContent); + this.svgFileMap = JSON.parse(mapContent); return; } } catch (err) { @@ -113,6 +98,8 @@ export class ReactNativeSVG { } ); + const rawEdges: Record> = {}; + for (const file of files) { try { const code = fs.readFileSync(file, 'utf8'); @@ -132,62 +119,61 @@ export class ReactNativeSVG { }); traverse(ast, { - ImportDeclaration: path => { + ExportNamedDeclaration: path => { if (!this.t) { return; } - const source = path.node.source.value; - if (!source.endsWith('.svg')) { + const source = path.node.source?.value; + if (!source) { return; } - const resolved = pathN.resolve( - pathN.dirname(file), - source - ); - for (const spec of path.node.specifiers) { - const name = getNodeName(this.t, spec.local.name); - if (name) { - this.localSvgMap[name] = { - path: resolved - }; - } - } - }, - ExportNamedDeclaration: path => { - if (!this.t) { - return; - } - const source = path.node.source?.value; - if (!source?.endsWith('.svg')) { + const resolvedSourceFile = source.endsWith('.svg') + ? pathN.resolve(pathN.dirname(file), source) + : this.resolveModuleFile( + pathN.dirname(file), + source + ); + + if (!resolvedSourceFile) { return; } - const resolved = pathN.resolve( - pathN.dirname(file), - source - ); for (const spec of path.node.specifiers) { - if (spec.type === 'ExportSpecifier') { - // spec.exported is the name consumers import under - // ('default' would be wrong for `export { default as Logo }`) - const exported = spec.exported; - const name = getNodeName( - this.t, - this.t.isStringLiteral(exported) - ? exported.value - : exported.name - ); - if (name) { - this.localSvgMap[name] = { - path: resolved - }; - } - } else { + if (spec.type !== 'ExportSpecifier') { console.warn( `[buildSvgMap]: Unhandled export specifier type: ${spec.type}` ); + continue; + } + + // spec.exported is the name consumers import under + // ('default' would be wrong for aliased re-exports) + const exported = spec.exported; + const exportedName = getNodeName( + this.t, + this.t.isStringLiteral(exported) + ? exported.value + : exported.name + ); + const localName = getNodeName( + this.t, + spec.local.name + ); + + if (!exportedName || !localName) { + continue; } + + rawEdges[file] ??= {}; + rawEdges[file][exportedName] = source.endsWith( + '.svg' + ) + ? { svgPath: resolvedSourceFile } + : { + fromFile: resolvedSourceFile, + fromName: localName + }; } } }); @@ -196,15 +182,44 @@ export class ReactNativeSVG { } } - // Save the mapping to disk if requested + const resolveEdge = ( + file: string, + name: string, + seen: Set = new Set() + ): string | null => { + const key = `${file}#${name}`; + if (seen.has(key)) { + return null; + } + seen.add(key); + + const edge = rawEdges[file]?.[name]; + if (!edge) { + return null; + } + if ('svgPath' in edge) { + return edge.svgPath; + } + return resolveEdge(edge.fromFile, edge.fromName, seen); + }; + + for (const file of Object.keys(rawEdges)) { + for (const name of Object.keys(rawEdges[file])) { + const svgPath = resolveEdge(file, name); + if (svgPath) { + this.svgFileMap[file] ??= {}; + this.svgFileMap[file][name] = svgPath; + } + } + } + if (this.saveSvgMapToDisk) { try { - // Resolve to package root: from lib/commonjs/libraries/react-native-svg -> package root - const packageRoot = pathN.resolve(__dirname, '../../../..'); + const packageRoot = resolvePackageRoot(__dirname); const svgMapPath = pathN.join(packageRoot, 'svg-map.json'); fs.writeFileSync( svgMapPath, - JSON.stringify(this.localSvgMap, null, 2), + JSON.stringify(this.svgFileMap, null, 2), 'utf8' ); } catch (err) { @@ -216,21 +231,120 @@ export class ReactNativeSVG { } } + private resolveModuleFile(fromDir: string, source: string): string | null { + const base = pathN.resolve(fromDir, source); + const candidates = [ + base, + `${base}.ts`, + `${base}.tsx`, + `${base}.js`, + `${base}.jsx`, + pathN.join(base, 'index.ts'), + pathN.join(base, 'index.tsx'), + pathN.join(base, 'index.js'), + pathN.join(base, 'index.jsx') + ]; + + for (const candidate of candidates) { + if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) { + return candidate; + } + } + + return null; + } + + /** + * Resolves the `.svg` path (if any) a JSX tag actually refers to in the + * current file, via its real import binding rather than a name-only + * lookup — this is what prevents cross-file name collisions. + */ + resolveSvgImport( + path: Babel.NodePath, + name: string, + currentFile: string + ): string | null { + if (!this.t) { + return null; + } + + const binding = path.scope.getBinding(name); + if (!binding) { + return null; + } + + const bindingNode = binding.path.node; + const isDefaultSpecifier = this.t.isImportDefaultSpecifier(bindingNode); + const isNamedSpecifier = this.t.isImportSpecifier(bindingNode); + const isNamespaceSpecifier = this.t.isImportNamespaceSpecifier( + bindingNode + ); + + if (!isDefaultSpecifier && !isNamedSpecifier && !isNamespaceSpecifier) { + return null; + } + + const importDeclaration = binding.path.parentPath?.node; + if ( + !importDeclaration || + !this.t.isImportDeclaration(importDeclaration) + ) { + return null; + } + + const source = importDeclaration.source.value; + + if (source.endsWith('.svg')) { + return pathN.resolve(pathN.dirname(currentFile), source); + } + + // A namespace import of a barrel (`import * as Icons from './icons'`) + // doesn't map to a single re-exported name. + if (isNamespaceSpecifier) { + return null; + } + + let importedName: string | null = null; + if (isDefaultSpecifier) { + importedName = 'default'; + } else if (this.t.isImportSpecifier(bindingNode)) { + const imported = bindingNode.imported; + importedName = this.t.isStringLiteral(imported) + ? imported.value + : imported.name; + } + + if (!importedName) { + return null; + } + + const resolvedSourceFile = this.resolveModuleFile( + pathN.dirname(currentFile), + source + ); + if (!resolvedSourceFile) { + return null; + } + + return this.svgFileMap[resolvedSourceFile]?.[importedName] ?? null; + } + + getSvgContent(svgPath: string): string { + if (!this.svgContentCache[svgPath]) { + this.svgContentCache[svgPath] = fs.readFileSync(svgPath, 'utf8'); + } + return this.svgContentCache[svgPath]; + } + /** - * Processes a JSXElement representing an SVG-based component and transforms it into - * a web-compliant SVG string with normalized attributes and extracted dimensions. - * The resulting SVG content and its metadata (e.g., width/height) are stored in `svgMap`, - * keyed by a generated UUID for later reference. - * - * Internally, the appropriate handler is selected based on the tag name and used to - * perform the transformation. - * - * @param path - Babel NodePath pointing to the JSXElement to process. - * @param name - JSX tag name (e.g., 'Svg', 'Logo') used to resolve the appropriate handler. - * @returns An object containing the original SVG string and its optimized version, - * or `undefined` if no transformation could be performed. + * Transforms a JSXElement representing an SVG-based component into + * optimized, web-compatible SVG markup, wrapped for Session Replay. */ - processItem(path: Babel.NodePath, name: string) { + processItem( + path: Babel.NodePath, + name: string, + currentFile: string + ) { if (!this.t) { return; } @@ -242,11 +356,14 @@ export class ReactNativeSVG { return; } + const svgPath = this.resolveSvgImport(path, name, currentFile); + HandlerResolver.configure({ t: this.t, path, name, - localSvgMap: this.localSvgMap + svgPath, + readSvgContent: this.getSvgContent.bind(this) }); const handler = HandlerResolver.create(); @@ -304,37 +421,6 @@ export class ReactNativeSVG { } } - /** - * Wraps a JSX element with a `SessionReplayView.Privacy` component - * and injects metadata attributes used by Session Replay. - * - * The resulting element is transformed into: - * ```tsx - * - * {originalElement} - * - * ``` - * - * This transformation ensures the element is identifiable on the native side - * while preserving its layout and interaction behavior. - * - * @param t - Babel types helper used to build and manipulate AST nodes. - * @param path - The current JSXElement node path being transformed. - * @param id - The unique native identifier assigned to the element. - * @param hash - A content hash used to reference the corresponding resource. - * @param dimensions - Optional width and height metadata to include in the attributes. - * @returns A new `JSXElement` AST node wrapped in `SessionReplayView.Privacy`. - */ private wrapElementForSessionReplay( t: typeof Babel.types, path: Babel.NodePath, @@ -376,12 +462,10 @@ export class ReactNativeSVG { const attributesNode = [ t.jsxAttribute(jsxIdentifier('nativeID'), stringLiteral(id)), - // https://reactnative.dev/docs/view#collapsable t.jsxAttribute( t.jsxIdentifier('collapsable'), t.jsxExpressionContainer(t.booleanLiteral(false)) ), - // https://reactnative.dev/docs/view#pointerevents t.jsxAttribute( t.jsxIdentifier('pointerEvents'), t.stringLiteral('box-none') @@ -412,17 +496,6 @@ export class ReactNativeSVG { return viewWrapper; } - /** - * Ensures that the `SessionReplayView` import from - * `@datadog/mobile-react-native-session-replay` exists in the file. - * - * If the import is not already present, this method injects a new - * `import { SessionReplayView } from '@datadog/mobile-react-native-session-replay'` - * declaration at the top of the program. - * - * @param t - Babel types helper used to create and check AST nodes. - * @param path - The current JSXElement node path from which to locate the program root. - */ private ensureSessionReplayImport( t: typeof Babel.types, path: Babel.NodePath @@ -458,3 +531,7 @@ export class ReactNativeSVG { } } } + +function resolvePackageRoot(startDir: string): string { + return pathN.resolve(startDir, '../../../..'); +} 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 1f80703d0..9fbe89916 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 @@ -1147,6 +1147,39 @@ describe('SessionReplayView.Privacy SVG Wrapper', () => { }); }); +/** + * Parses `code` and returns the NodePath of the first JSXElement whose tag matches + * `tagName`, with real scope/bindings built. Used to exercise `resolveSvgImport`. + */ +function getJsxElementPath(code: string, tagName: string) { + const ast = parser.parse(code, { + sourceType: 'module', + plugins: ['jsx', 'typescript'] + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let result: any; + + traverse(ast, { + JSXElement(nodePath) { + const opening = nodePath.node.openingElement; + if ( + !result && + t.isJSXIdentifier(opening.name) && + opening.name.name === tagName + ) { + result = nodePath; + } + } + }); + + if (!result) { + throw new Error(`<${tagName}/> not found in test fixture`); + } + + return result; +} + describe('ReactNativeSVG.buildSvgMap', () => { let tmpDir: string; @@ -1162,9 +1195,10 @@ describe('ReactNativeSVG.buildSvgMap', () => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); - it('should populate localSvgMap from a default import of an SVG file', () => { + it('should not populate svgFileMap for a direct import of an SVG file', () => { + const srcFile = path.join(tmpDir, 'Component.tsx'); fs.writeFileSync( - path.join(tmpDir, 'Component.tsx'), + srcFile, `import Logo from './icon.svg';\nexport default function C() { return ; }` ); @@ -1172,71 +1206,388 @@ describe('ReactNativeSVG.buildSvgMap', () => { instance.setApiTypes(t); instance.buildSvgMap(); - expect(instance.localSvgMap['Logo']).toBeDefined(); - expect(instance.localSvgMap['Logo'].path).toBe( + expect(instance.svgFileMap[srcFile]).toBeUndefined(); + }); + + it('should populate svgFileMap with the exported name for aliased re-exports', () => { + const barrelFile = path.join(tmpDir, 'icons.ts'); + fs.writeFileSync( + barrelFile, + `export { default as Logo } from './icon.svg';` + ); + + const instance = new ReactNativeSVG(tmpDir, tmpDir, false); + instance.setApiTypes(t); + instance.buildSvgMap(); + + expect(instance.svgFileMap[barrelFile]?.['Logo']).toBe( + path.join(tmpDir, 'icon.svg') + ); + expect(instance.svgFileMap[barrelFile]?.['default']).toBeUndefined(); + }); + + it('should populate svgFileMap with the exported name for non-aliased re-exports', () => { + const barrelFile = path.join(tmpDir, 'icons.ts'); + fs.writeFileSync(barrelFile, `export { StarIcon } from './icon.svg';`); + + const instance = new ReactNativeSVG(tmpDir, tmpDir, false); + instance.setApiTypes(t); + instance.buildSvgMap(); + + expect(instance.svgFileMap[barrelFile]?.['StarIcon']).toBe( + path.join(tmpDir, 'icon.svg') + ); + }); + + it('should resolve multi-hop barrel re-export chains to the terminal svg path', () => { + const innerBarrel = path.join(tmpDir, 'inner.ts'); + const outerBarrel = path.join(tmpDir, 'outer.ts'); + fs.writeFileSync(innerBarrel, `export { StarIcon } from './icon.svg';`); + fs.writeFileSync(outerBarrel, `export { StarIcon } from './inner';`); + + const instance = new ReactNativeSVG(tmpDir, tmpDir, false); + instance.setApiTypes(t); + instance.buildSvgMap(); + + expect(instance.svgFileMap[outerBarrel]?.['StarIcon']).toBe( + path.join(tmpDir, 'icon.svg') + ); + }); + + it('should not overwrite svgFileMap when buildSvgMap is called a second time on a fresh instance', () => { + const barrelFile = path.join(tmpDir, 'icons.ts'); + fs.writeFileSync(barrelFile, `export { StarIcon } from './icon.svg';`); + + const instance = new ReactNativeSVG(tmpDir, tmpDir, false); + instance.setApiTypes(t); + instance.buildSvgMap(); + const mapAfterFirstCall = JSON.parse( + JSON.stringify(instance.svgFileMap) + ); + + const freshInstance = new ReactNativeSVG(tmpDir, tmpDir, false); + freshInstance.setApiTypes(t); + freshInstance.buildSvgMap(); + + expect(freshInstance.svgFileMap).toEqual(mapAfterFirstCall); + }); +}); + +describe('ReactNativeSVG.resolveSvgImport', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dd-resolvesvg-')); + fs.writeFileSync( + path.join(tmpDir, 'icon.svg'), + '' + ); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should resolve a direct default import of an SVG file', () => { + const currentFile = path.join(tmpDir, 'Component.tsx'); + const jsxPath = getJsxElementPath( + `import Logo from './icon.svg';\nfunction C() { return ; }`, + 'Logo' + ); + + const instance = new ReactNativeSVG(tmpDir, tmpDir, false); + instance.setApiTypes(t); + + expect(instance.resolveSvgImport(jsxPath, 'Logo', currentFile)).toBe( path.join(tmpDir, 'icon.svg') ); }); - it('should populate localSvgMap from a named import of an SVG file', () => { + it('should resolve a direct namespace import of an SVG file', () => { + const currentFile = path.join(tmpDir, 'Component.tsx'); + const jsxPath = getJsxElementPath( + `import * as Logo from './icon.svg';\nfunction C() { return ; }`, + 'Logo' + ); + + const instance = new ReactNativeSVG(tmpDir, tmpDir, false); + instance.setApiTypes(t); + + expect(instance.resolveSvgImport(jsxPath, 'Logo', currentFile)).toBe( + path.join(tmpDir, 'icon.svg') + ); + }); + + it('should resolve a direct aliased named import of an SVG file', () => { + const currentFile = path.join(tmpDir, 'Component.tsx'); + const jsxPath = getJsxElementPath( + `import { ReactComponent as StarIcon } from './icon.svg';\nfunction C() { return ; }`, + 'StarIcon' + ); + + const instance = new ReactNativeSVG(tmpDir, tmpDir, false); + instance.setApiTypes(t); + + expect( + instance.resolveSvgImport(jsxPath, 'StarIcon', currentFile) + ).toBe(path.join(tmpDir, 'icon.svg')); + }); + + it('should NOT resolve a namespace import of a barrel to any of its re-exports', () => { + const barrelFile = path.join(tmpDir, 'icons.ts'); fs.writeFileSync( - path.join(tmpDir, 'Component.tsx'), - `import { ReactComponent as StarIcon } from './icon.svg';\nexport default function C() { return ; }` + barrelFile, + `export { default as Logo } from './icon.svg';` + ); + + const currentFile = path.join(tmpDir, 'Component.tsx'); + const jsxPath = getJsxElementPath( + `import * as Icons from './icons';\nfunction C() { return ; }`, + 'Icons' ); const instance = new ReactNativeSVG(tmpDir, tmpDir, false); instance.setApiTypes(t); instance.buildSvgMap(); - expect(instance.localSvgMap['StarIcon']).toBeDefined(); + expect( + instance.resolveSvgImport(jsxPath, 'Icons', currentFile) + ).toBeNull(); }); - it('should populate localSvgMap with the exported name for aliased re-exports', () => { + it('should resolve a barrel-imported SVG from a different file', () => { + const barrelFile = path.join(tmpDir, 'icons.ts'); fs.writeFileSync( - path.join(tmpDir, 'icons.ts'), + barrelFile, `export { default as Logo } from './icon.svg';` ); + const currentFile = path.join(tmpDir, 'Component.tsx'); + const jsxPath = getJsxElementPath( + `import { Logo } from './icons';\nfunction C() { return ; }`, + 'Logo' + ); + const instance = new ReactNativeSVG(tmpDir, tmpDir, false); instance.setApiTypes(t); instance.buildSvgMap(); - expect(instance.localSvgMap['Logo']).toBeDefined(); - expect(instance.localSvgMap['Logo'].path).toBe( + expect(instance.resolveSvgImport(jsxPath, 'Logo', currentFile)).toBe( path.join(tmpDir, 'icon.svg') ); - expect(instance.localSvgMap['default']).toBeUndefined(); }); - it('should populate localSvgMap with the exported name for non-aliased re-exports', () => { + it('should resolve an aliased barrel import (import { Logo as Icon })', () => { + const barrelFile = path.join(tmpDir, 'icons.ts'); fs.writeFileSync( - path.join(tmpDir, 'icons.ts'), - `export { StarIcon } from './icon.svg';` + barrelFile, + `export { default as Logo } from './icon.svg';` + ); + + const currentFile = path.join(tmpDir, 'Component.tsx'); + const jsxPath = getJsxElementPath( + `import { Logo as Icon } from './icons';\nfunction C() { return ; }`, + 'Icon' + ); + + const instance = new ReactNativeSVG(tmpDir, tmpDir, false); + instance.setApiTypes(t); + instance.buildSvgMap(); + + expect(instance.resolveSvgImport(jsxPath, 'Icon', currentFile)).toBe( + path.join(tmpDir, 'icon.svg') + ); + }); + + // Core regression case for the cross-file collision bug: a same-named + // component that is NOT an SVG import must never be treated as one. + it('should NOT resolve a locally declared component that merely shares a name with an SVG import elsewhere', () => { + const barrelFile = path.join(tmpDir, 'icons.ts'); + fs.writeFileSync( + barrelFile, + `export { default as Icon } from './icon.svg';` + ); + + const currentFile = path.join(tmpDir, 'Unrelated.tsx'); + const jsxPath = getJsxElementPath( + `function Icon() { return null; }\nfunction C() { return ; }`, + 'Icon' ); const instance = new ReactNativeSVG(tmpDir, tmpDir, false); instance.setApiTypes(t); instance.buildSvgMap(); - expect(instance.localSvgMap['StarIcon']).toBeDefined(); + expect( + instance.resolveSvgImport(jsxPath, 'Icon', currentFile) + ).toBeNull(); }); - it('should not overwrite localSvgMap when buildSvgMap is called a second time on a fresh instance', () => { + it('should NOT resolve a name imported from an unrelated (non-barrel) module', () => { fs.writeFileSync( path.join(tmpDir, 'icons.ts'), - `export { StarIcon } from './icon.svg';` + `export { default as Icon } from './icon.svg';` + ); + fs.writeFileSync( + path.join(tmpDir, 'VectorIcon.tsx'), + `export default function Icon() { return null; }` + ); + + const currentFile = path.join(tmpDir, 'Unrelated.tsx'); + const jsxPath = getJsxElementPath( + `import Icon from './VectorIcon';\nfunction C() { return ; }`, + 'Icon' ); const instance = new ReactNativeSVG(tmpDir, tmpDir, false); instance.setApiTypes(t); instance.buildSvgMap(); - const mapAfterFirstCall = { ...instance.localSvgMap }; - // Simulates what pre() used to do: create a brand-new instance per file - const freshInstance = new ReactNativeSVG(tmpDir, tmpDir, false); - freshInstance.setApiTypes(t); - freshInstance.buildSvgMap(); + expect( + instance.resolveSvgImport(jsxPath, 'Icon', currentFile) + ).toBeNull(); + }); + + it('should resolve a barrel that is a directory with an index file', () => { + const barrelDir = path.join(tmpDir, 'icons'); + fs.mkdirSync(barrelDir); + fs.writeFileSync( + path.join(barrelDir, 'index.ts'), + `export { default as Logo } from '../icon.svg';` + ); + + const currentFile = path.join(tmpDir, 'Component.tsx'); + const jsxPath = getJsxElementPath( + `import { Logo } from './icons';\nfunction C() { return ; }`, + 'Logo' + ); + + const instance = new ReactNativeSVG(tmpDir, tmpDir, false); + instance.setApiTypes(t); + instance.buildSvgMap(); + + expect(instance.resolveSvgImport(jsxPath, 'Logo', currentFile)).toBe( + path.join(tmpDir, 'icon.svg') + ); + }); + + it('should return null (not throw) when the import source cannot be resolved to a file on disk', () => { + const currentFile = path.join(tmpDir, 'Component.tsx'); + const jsxPath = getJsxElementPath( + `import Icon from 'some-package-that-does-not-exist';\nfunction C() { return ; }`, + 'Icon' + ); + + const instance = new ReactNativeSVG(tmpDir, tmpDir, false); + instance.setApiTypes(t); + + expect(() => + instance.resolveSvgImport(jsxPath, 'Icon', currentFile) + ).not.toThrow(); + expect( + instance.resolveSvgImport(jsxPath, 'Icon', currentFile) + ).toBeNull(); + }); +}); + +describe('SVG resolution is scoped per file (end-to-end)', () => { + let projectDir: string; + let assetsDir: string; + + beforeEach(() => { + projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dd-svg-e2e-')); + assetsDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'dd-svg-e2e-assets-') + ); + fs.writeFileSync( + path.join(projectDir, 'icon.svg'), + '' + ); + }); + + afterEach(() => { + fs.rmSync(projectDir, { recursive: true, force: true }); + fs.rmSync(assetsDir, { recursive: true, force: true }); + }); + + function transformProjectFile( + relativeFilename: string, + code: string, + reactNativeSVG: ReactNativeSVG + ): string | undefined { + return transform(code, { + filename: path.join(projectDir, relativeFilename), + presets: ['@babel/preset-react', '@babel/preset-typescript'], + plugins: [ + [ + plugin, + { + sessionReplay: { svgTracking: true }, + __internal_reactNativeSVG: reactNativeSVG + } + ] + ], + configFile: false + })?.code as string | undefined; + } + + it('wraps a directly-imported local SVG', () => { + const reactNativeSVG = new ReactNativeSVG(projectDir, assetsDir, false); + reactNativeSVG.setApiTypes(t); + + const output = transformProjectFile( + 'Component.tsx', + `import Logo from './icon.svg';\nfunction C() { return ; }`, + reactNativeSVG + ); + + expect(output).toContain('SessionReplayView.Privacy'); + }); + + it('wraps an SVG imported through a barrel re-export from a different file', () => { + fs.writeFileSync( + path.join(projectDir, 'icons.ts'), + `export { default as Logo } from './icon.svg';` + ); + + const reactNativeSVG = new ReactNativeSVG(projectDir, assetsDir, false); + reactNativeSVG.setApiTypes(t); + reactNativeSVG.buildSvgMap(); + + const output = transformProjectFile( + 'Component.tsx', + `import { Logo } from './icons';\nfunction C() { return ; }`, + reactNativeSVG + ); + + expect(output).toContain('SessionReplayView.Privacy'); + }); + + it('does not wrap an unrelated component that shares a name with an SVG imported elsewhere in the project', () => { + fs.writeFileSync( + path.join(projectDir, 'icons.ts'), + `export { default as Icon } from './icon.svg';` + ); + + const reactNativeSVG = new ReactNativeSVG(projectDir, assetsDir, false); + reactNativeSVG.setApiTypes(t); + reactNativeSVG.buildSvgMap(); + + const outputA = transformProjectFile( + 'FileA.tsx', + `import { Icon } from './icons';\nfunction A() { return ; }`, + reactNativeSVG + ); + expect(outputA).toContain('SessionReplayView.Privacy'); + + const inputB = `function Icon() { return null; }\nfunction B() { return ; }`; + const outputB = transformProjectFile( + 'FileB.tsx', + inputB, + reactNativeSVG + ); - expect(freshInstance.localSvgMap).toEqual(mapAfterFirstCall); + expect(outputB).not.toContain('SessionReplayView.Privacy'); + expect(outputB).toContain('function Icon()'); }); }); From 5fead67b870eb89af91bd018b8ea95808096d35b Mon Sep 17 00:00:00 2001 From: jonathanmos <48201295+jonathanmos@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:29:24 +0300 Subject: [PATCH 06/10] Always attempt SVG processing regardless of missing filename Gating processItem() on state.filename being truthy skipped all SVG handling -- including the tag path, which never needed a filename -- whenever filename was falsy. resolveSvgImport already fails closed on its own when given an empty currentFile. --- packages/react-native-babel-plugin/src/index.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/react-native-babel-plugin/src/index.ts b/packages/react-native-babel-plugin/src/index.ts index c9d29d89f..0492ce43b 100644 --- a/packages/react-native-babel-plugin/src/index.ts +++ b/packages/react-native-babel-plugin/src/index.ts @@ -145,13 +145,11 @@ export default declare( options ); - if (state.filename) { - pluginState.reactNativeSVG?.processItem( - path, - name, - state.filename - ); - } + pluginState.reactNativeSVG?.processItem( + path, + name, + state.filename ?? '' + ); } } }; From d5d866fba85c3a1519d0979db1aecb769fe84248 Mon Sep 17 00:00:00 2001 From: jonathanmos <48201295+jonathanmos@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:29:51 +0300 Subject: [PATCH 07/10] Fix svg-map.json package-root resolution for non-built execution The package-root computation was a hardcoded ../../../.. offset from __dirname, correct only when running from the built lib/commonjs/... output. Under ts-jest or ts-node it resolved one directory too high, into the shared monorepo directory instead of this package's own root. resolvePackageRoot now walks up to find this package's actual package.json. --- .../src/libraries/react-native-svg/index.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/packages/react-native-babel-plugin/src/libraries/react-native-svg/index.ts b/packages/react-native-babel-plugin/src/libraries/react-native-svg/index.ts index 2845631f3..aa2949a48 100644 --- a/packages/react-native-babel-plugin/src/libraries/react-native-svg/index.ts +++ b/packages/react-native-babel-plugin/src/libraries/react-native-svg/index.ts @@ -532,6 +532,36 @@ export class ReactNativeSVG { } } +const PACKAGE_NAME = '@datadog/mobile-react-native-babel-plugin'; + +// __dirname's depth relative to the package root differs between the built +// lib/commonjs/... output and running straight from src/... (e.g. ts-jest), +// so a hardcoded relative offset would be wrong in one of the two. Walk up +// to the actual package.json instead. function resolvePackageRoot(startDir: string): string { + let currentDir = startDir; + + for (let i = 0; i < 10; i++) { + const packageJsonPath = pathN.join(currentDir, 'package.json'); + try { + if (fs.existsSync(packageJsonPath)) { + const pkg = JSON.parse( + fs.readFileSync(packageJsonPath, 'utf8') + ); + if (pkg.name === PACKAGE_NAME) { + return currentDir; + } + } + } catch { + // Malformed package.json — keep walking up. + } + + const parentDir = pathN.dirname(currentDir); + if (parentDir === currentDir) { + break; + } + currentDir = parentDir; + } + return pathN.resolve(startDir, '../../../..'); } From c40a79f25a8d707a96435b6888173c6fe54f53e8 Mon Sep 17 00:00:00 2001 From: jonathanmos <48201295+jonathanmos@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:31:19 +0300 Subject: [PATCH 08/10] Add regression tests for the real pre()/CLI construction paths Every existing test injected __internal_reactNativeSVG pre-built with setApiTypes() already called, bypassing pre()'s own instance construction entirely -- meaning the setApiTypes/buildSvgMap ordering fix could regress without any test catching it. Adds a test that lets pre() build its own instance, and a test that runs the actual generateSessionReplayAssets() CLI entrypoint end-to-end. --- .../src/cli/generate-sr-assets.ts | 2 +- .../test/generate-sr-assets.test.ts | 95 ++++++++++++++++++- .../test/react-native-svg.test.ts | 47 +++++++++ 3 files changed, 142 insertions(+), 2 deletions(-) diff --git a/packages/react-native-babel-plugin/src/cli/generate-sr-assets.ts b/packages/react-native-babel-plugin/src/cli/generate-sr-assets.ts index 66f4c2c38..76bfbcdaa 100644 --- a/packages/react-native-babel-plugin/src/cli/generate-sr-assets.ts +++ b/packages/react-native-babel-plugin/src/cli/generate-sr-assets.ts @@ -297,7 +297,7 @@ function mergeSvgAssets(assetsDir: string) { * --followSymlinks Follow symbolic links during directory traversal. * Default: false (symlinks are ignored). */ -function generateSessionReplayAssets() { +export function generateSessionReplayAssets() { const cliOptions = parseCliArgs(); const { verbose } = cliOptions; diff --git a/packages/react-native-babel-plugin/test/generate-sr-assets.test.ts b/packages/react-native-babel-plugin/test/generate-sr-assets.test.ts index 973ff0579..6c86b9a5d 100644 --- a/packages/react-native-babel-plugin/test/generate-sr-assets.test.ts +++ b/packages/react-native-babel-plugin/test/generate-sr-assets.test.ts @@ -4,11 +4,22 @@ * Copyright 2016-Present Datadog, Inc. */ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + import { parseCliArgs, DEFAULT_IGNORE_PATTERNS, - normalizeIgnorePattern + normalizeIgnorePattern, + generateSessionReplayAssets } from '../src/cli/generate-sr-assets'; +import * as assetsFs from '../src/libraries/react-native-svg/processing/fs'; + +jest.mock('../src/libraries/react-native-svg/processing/fs', () => ({ + ...jest.requireActual('../src/libraries/react-native-svg/processing/fs'), + getAssetsPath: jest.fn() +})); describe('generate-sr-assets CLI', () => { describe('parseCliArgs', () => { @@ -389,3 +400,85 @@ describe('generate-sr-assets CLI', () => { }); }); }); + +describe('generateSessionReplayAssets', () => { + // getAssetsPath is mocked (rather than chdir-ing into the tmp project) so + // cwd stays the real repo root and Babel can still resolve + // @babel/preset-react/@babel/preset-typescript by name. + let projectDir: string; + let assetsDir: string; + let originalArgv: string[]; + let consoleInfoSpy: jest.SpyInstance; + let consoleWarnSpy: jest.SpyInstance; + let writeFileSyncSpy: jest.SpyInstance; + + // saveSvgMapToDisk writes svg-map.json to this package's real root, a + // location every other test file's buildSvgMap() also reads as a cache. + // Redirect just that one path into this test's own sandbox. + const realSvgMapPath = path.join( + path.resolve(__dirname, '..'), + 'svg-map.json' + ); + const realWriteFileSync = fs.writeFileSync; + + beforeEach(() => { + projectDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'dd-generate-sr-assets-') + ); + assetsDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'dd-generate-sr-assets-out-') + ); + fs.writeFileSync( + path.join(projectDir, 'icon.svg'), + '' + ); + fs.writeFileSync( + path.join(projectDir, 'Component.tsx'), + "import Logo from './icon.svg';\nfunction C() { return ; }" + ); + + (assetsFs.getAssetsPath as jest.Mock).mockReturnValue(assetsDir); + + originalArgv = process.argv; + process.argv = [...originalArgv, '--path', projectDir]; + + consoleInfoSpy = jest + .spyOn(console, 'info') + .mockImplementation(() => undefined); + consoleWarnSpy = jest + .spyOn(console, 'warn') + .mockImplementation(() => undefined); + + writeFileSyncSpy = jest + .spyOn(fs, 'writeFileSync') + .mockImplementation((file, data, options) => { + const target = + file === realSvgMapPath + ? path.join(assetsDir, 'svg-map.json') + : file; + return realWriteFileSync(target, data, options); + }); + }); + + afterEach(() => { + process.argv = originalArgv; + writeFileSyncSpy.mockRestore(); + fs.rmSync(projectDir, { recursive: true, force: true }); + fs.rmSync(assetsDir, { recursive: true, force: true }); + consoleInfoSpy.mockRestore(); + consoleWarnSpy.mockRestore(); + }); + + it('scans the project, wraps the SVG import, and writes a populated assets index', () => { + generateSessionReplayAssets(); + + const indexPath = path.join(assetsDir, 'assets.json'); + + expect(fs.existsSync(indexPath)).toBe(true); + + const index = JSON.parse(fs.readFileSync(indexPath, 'utf8')); + expect(Object.keys(index).length).toBeGreaterThan(0); + + expect(fs.existsSync(path.join(assetsDir, 'assets.bin'))).toBe(true); + }); +}); 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 9fbe89916..1100de4c0 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 @@ -1591,3 +1591,50 @@ describe('SVG resolution is scoped per file (end-to-end)', () => { expect(outputB).toContain('function Icon()'); }); }); + +// Every other test in this file supplies __internal_reactNativeSVG pre-built +// with setApiTypes() already called, bypassing pre()'s own instance-construction +// branch entirely. This exercises that real path. +describe('Babel plugin: pre() constructs its own ReactNativeSVG instance', () => { + let projectDir: string; + let originalCwd: string; + let originalPluginDev: string | undefined; + + beforeEach(() => { + projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dd-pre-hook-')); + fs.writeFileSync( + path.join(projectDir, 'icon.svg'), + '' + ); + + originalCwd = process.cwd(); + originalPluginDev = process.env.pluginDev; + process.env.pluginDev = 'true'; + process.chdir(projectDir); + }); + + afterEach(() => { + process.chdir(originalCwd); + if (originalPluginDev === undefined) { + delete process.env.pluginDev; + } else { + process.env.pluginDev = originalPluginDev; + } + fs.rmSync(projectDir, { recursive: true, force: true }); + }); + + it('wraps a directly-imported local SVG without an injected ReactNativeSVG instance', () => { + const output = transform( + `import Logo from './icon.svg';\nfunction C() { return ; }`, + { + filename: path.join(projectDir, 'Component.tsx'), + // No node_modules under projectDir to resolve presets from. + parserOpts: { plugins: ['jsx', 'typescript'] }, + plugins: [[plugin, { sessionReplay: { svgTracking: true } }]], + configFile: false + } + )?.code as string | undefined; + + expect(output).toContain('SessionReplayView.Privacy'); + }); +}); From ae922b2dd5748aeaf62671c36b9d0b0e4f9386f5 Mon Sep 17 00:00:00 2001 From: jonathanmos <48201295+jonathanmos@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:33:39 +0300 Subject: [PATCH 09/10] Harden tests against a real local svg-map.json cache file A stray svg-map.json at this package's real root (e.g. left behind by a local datadog-generate-sr-assets run) would make every test in this file silently read stale cached data instead of scanning its own tmp fixtures. Hide that one path from fs.existsSync unconditionally. --- .../test/react-native-svg.test.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) 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 1100de4c0..044ebd8b5 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 @@ -18,6 +18,30 @@ import plugin from '../src/index'; import { RNSvgHandler } from '../src/libraries/react-native-svg/handlers/RNSvgHandler'; import { ReactNativeSVG } from '../src/libraries/react-native-svg'; +// buildSvgMap() reads /svg-map.json as a cache whenever +// saveSvgMapToDisk is false. If a real file exists there locally (e.g. from +// running datadog-generate-sr-assets), every test in this file would +// silently read stale data instead of scanning its own tmp fixtures. Hide it +// unconditionally so results don't depend on local machine state. +const REAL_SVG_MAP_PATH = path.join( + path.resolve(__dirname, '..'), + 'svg-map.json' +); +const realFsExistsSync = fs.existsSync; + +beforeEach(() => { + jest.spyOn(fs, 'existsSync').mockImplementation(p => { + if (p === REAL_SVG_MAP_PATH) { + return false; + } + return realFsExistsSync(p); + }); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + /** * Helper function to test SVG transformation */ From ac44218792ccfdba5daff8b5741504a9ed5b3525 Mon Sep 17 00:00:00 2001 From: jonathanmos <48201295+jonathanmos@users.noreply.github.com> Date: Sun, 26 Jul 2026 15:24:07 +0300 Subject: [PATCH 10/10] Describe SVG test cases by behavior instead of internal fix numbers References like "Fix 2, 3, 4, 6" only made sense during the session that introduced them and mean nothing to a future reader. Replaced with descriptions of what each case actually verifies. --- .../src/scenario/SessionReplay/component/Svg.tsx | 13 ++++++++----- .../SessionReplay/component/assets/icons.ts | 6 +++--- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/benchmarks/src/scenario/SessionReplay/component/Svg.tsx b/benchmarks/src/scenario/SessionReplay/component/Svg.tsx index ae86f9950..ecd05a8cb 100644 --- a/benchmarks/src/scenario/SessionReplay/component/Svg.tsx +++ b/benchmarks/src/scenario/SessionReplay/component/Svg.tsx @@ -321,7 +321,7 @@ function DynamicOpacityCircle({ fadeLevel }: { fadeLevel: number }) { // ───────────────────────────────────────────────────────────── // GROUP F — File imports -// LocalSvgHandler reads the SVG file from disk (Fix 2, 3, 4). +// LocalSvgHandler resolves and reads the imported SVG file from disk. // ───────────────────────────────────────────────────────────── /** F1: Default import of a local .svg file */ @@ -329,8 +329,11 @@ function LocalStarImport() { return ; } -/** F2: Named import from barrel file (export { default as HeartIcon }) - * Tests Fix 6: ExportNamedDeclaration uses spec.exported.name, not spec.local.name */ +/** + * F2: Named import from a barrel file (export { default as HeartIcon } from './heart.svg'). + * The barrel scan must key this by the exported name ('HeartIcon'), not the source + * module's local name ('default') — otherwise this import is never resolved. + */ function BarrelHeartImport() { return ; } @@ -430,7 +433,7 @@ export default function SvgTestCases() { All cases in Groups A–D should appear in replay.{'\n'} Group E: known limitation — absent from replay entirely (see comment).{'\n'} - Group F: appears after buildSvgMap fixes.{'\n'} + Group F: direct and barrel-re-exported .svg imports should appear in replay.{'\n'} Group G: privacy overrides — verify masking behavior in replay.{'\n'} Group I: I1 shows circle only (checkmark removed), I2 shows circle + checkmark. @@ -510,7 +513,7 @@ export default function SvgTestCases() { -
+
diff --git a/benchmarks/src/scenario/SessionReplay/component/assets/icons.ts b/benchmarks/src/scenario/SessionReplay/component/assets/icons.ts index 5662e288f..1f2f97258 100644 --- a/benchmarks/src/scenario/SessionReplay/component/assets/icons.ts +++ b/benchmarks/src/scenario/SessionReplay/component/assets/icons.ts @@ -4,8 +4,8 @@ * Copyright 2016-Present Datadog, Inc. */ -// Barrel file used to test Fix 6: ExportNamedDeclaration must use spec.exported.name -// not spec.local.name, otherwise 'export { default as HeartIcon }' stores 'default' -// as the key in localSvgMap instead of 'HeartIcon', and is never found. +// Barrel file used to test aliased re-exports: the SVG scan must key this +// entry by the exported name ('HeartIcon'), not the source module's local +// name ('default') — otherwise 'export { default as HeartIcon }' is never found. export { default as HeartIcon } from './heart.svg'; export { default as ShieldIcon } from './shield.svg';