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';
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..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
@@ -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';
@@ -296,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;
@@ -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..0492ce43b 100644
--- a/packages/react-native-babel-plugin/src/index.ts
+++ b/packages/react-native-babel-plugin/src/index.ts
@@ -53,13 +53,17 @@ 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(),
assetsPath,
options.__internal_saveSvgMapToDisk || false
);
+ reactNativeSVG.setApiTypes(api.types);
reactNativeSVG.buildSvgMap();
}
reactNativeSVG?.setApiTypes(api.types);
@@ -141,7 +145,11 @@ export default declare(
options
);
- pluginState.reactNativeSVG?.processItem(path, name);
+ 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 1d3fed1ab..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
@@ -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,57 +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') {
- const name = getNodeName(
- this.t,
- spec.local.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
+ };
}
}
});
@@ -191,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) {
@@ -211,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;
+ }
+
/**
- * 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.
+ * 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.
*/
- processItem(path: Babel.NodePath, name: string) {
+ 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];
+ }
+
+ /**
+ * Transforms a JSXElement representing an SVG-based component into
+ * optimized, web-compatible SVG markup, wrapped for Session Replay.
+ */
+ processItem(
+ path: Babel.NodePath,
+ name: string,
+ currentFile: string
+ ) {
if (!this.t) {
return;
}
@@ -237,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();
@@ -299,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,
@@ -371,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')
@@ -407,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
@@ -453,3 +531,37 @@ 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, '../../../..');
+}
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 178f450b4..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
*/
@@ -1146,3 +1170,495 @@ describe('SessionReplayView.Privacy SVG Wrapper', () => {
expect(svgContent).not.toContain('fill={color}');
});
});
+
+/**
+ * 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;
+
+ 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 not populate svgFileMap for a direct import of an SVG file', () => {
+ const srcFile = path.join(tmpDir, 'Component.tsx');
+ fs.writeFileSync(
+ srcFile,
+ `import Logo from './icon.svg';\nexport default function C() { return ; }`
+ );
+
+ const instance = new ReactNativeSVG(tmpDir, tmpDir, false);
+ instance.setApiTypes(t);
+ instance.buildSvgMap();
+
+ 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 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(
+ 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.resolveSvgImport(jsxPath, 'Icons', currentFile)
+ ).toBeNull();
+ });
+
+ it('should resolve a barrel-imported SVG from a different file', () => {
+ const barrelFile = path.join(tmpDir, 'icons.ts');
+ fs.writeFileSync(
+ 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.resolveSvgImport(jsxPath, 'Logo', currentFile)).toBe(
+ path.join(tmpDir, 'icon.svg')
+ );
+ });
+
+ it('should resolve an aliased barrel import (import { Logo as Icon })', () => {
+ const barrelFile = path.join(tmpDir, 'icons.ts');
+ fs.writeFileSync(
+ 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.resolveSvgImport(jsxPath, 'Icon', currentFile)
+ ).toBeNull();
+ });
+
+ it('should NOT resolve a name imported from an unrelated (non-barrel) module', () => {
+ fs.writeFileSync(
+ path.join(tmpDir, 'icons.ts'),
+ `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();
+
+ 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(outputB).not.toContain('SessionReplayView.Privacy');
+ 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');
+ });
+});
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)
}
}