Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -369,7 +370,15 @@ function generateSessionReplayAssets() {
let processedCount = 0;
const errors: Array<{ file: string; error: string }> = [];

const reactNativeSVG = new ReactNativeSVG(rootDir, assetsPath, true);
const reactNativeSVG = new ReactNativeSVG(
rootDir,
assetsPath,
true,
ignorePatterns,
cliOptions.followSymlinks
);
reactNativeSVG.setApiTypes(babelTypes);
reactNativeSVG.buildSvgMap();

for (const file of files) {
try {
Expand Down
16 changes: 14 additions & 2 deletions packages/react-native-babel-plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,22 +44,34 @@ export default declare(
let assetsPath: string | null = null;

return {
pre() {
pre(file) {
if (!options.sessionReplay.svgTracking) {
return;
}

// Skip for web builds — the expensive buildSvgMap() scan below
// would otherwise run even though Program.enter skips all SVG
// handling for web anyway.
const platform = (file.opts?.caller as any)?.platform;
if (platform === 'web') {
return;
}

if (!assetsPath) {
assetsPath = getAssetsPath();
}

reactNativeSVG = options.__internal_reactNativeSVG;
// Reuse the instance across files instead of rebuilding (and
// rescanning) it per file.
reactNativeSVG =
options.__internal_reactNativeSVG ?? reactNativeSVG;
Comment thread
jonathanmos marked this conversation as resolved.
if (!reactNativeSVG && assetsPath) {
reactNativeSVG = new ReactNativeSVG(
process.cwd(),
assetsPath,
options.__internal_saveSvgMapToDisk || false
);
reactNativeSVG.setApiTypes(api.types);
reactNativeSVG.buildSvgMap();
}
reactNativeSVG?.setApiTypes(api.types);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ export class RNSvgHandler implements SvgHandler {
/**
* Processes a JSXElement representing an SVG node and transforms it into
* a web compliant SVG string with updated attributes and dimensions.
* Stores the transformed SVG string and dimensions in `svgMap`, keyed by a UUID.
*
* @param dimensions - Object to collect extracted width/height info.
* @returns Transformed SVG JSX string, or undefined if the tag is not supported.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,17 @@ import { getNodeName } from '../../utils';
import { HandlerResolver } from './handlers/HandlerResolver';
import { writeAssetToDisk } from './processing/fs';

type SvgOffset = {
start: number;
length: number;
};
// Used when the caller (e.g. the plugin's own pre() hook) doesn't have a more
// specific set of patterns to pass in -- the generate-sr-assets CLI passes its
// own (larger, user-configurable) ignore list instead of relying on this.
const DEFAULT_SCAN_IGNORE_PATTERNS = [
'**/node_modules/**',
'**/lib/**',
'**/dist/**',
'**/*.d.ts',
'**/*.test.*',
'**/*.config.js'
];

/**
* Internal processor responsible for detecting, transforming, and wrapping
Expand All @@ -36,18 +43,16 @@ type SvgOffset = {
* the native Session Replay layer.
*/
export class ReactNativeSVG {
svgMap: Record<string, { file: string; [key: string]: string }> = {};

svgOffset: Record<string, SvgOffset> = {};

localSvgMap: Record<string, { path: string; content?: string }> = {};

t: typeof Babel.types | null = null;

constructor(
private rootDir: string,
private assetsPath: string,
private saveSvgMapToDisk: boolean = false
private saveSvgMapToDisk: boolean = false,
private scanIgnorePatterns: string[] = DEFAULT_SCAN_IGNORE_PATTERNS,
private followSymlinks: boolean = false
) {}

setApiTypes(t: typeof Babel.types) {
Expand All @@ -62,8 +67,7 @@ export class ReactNativeSVG {
* 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.
* Files matching `scanIgnorePatterns` (defaulted in the constructor) are skipped.
*
* 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,
Expand Down Expand Up @@ -97,21 +101,12 @@ export class ReactNativeSVG {
}

// TODO: Support aliased paths (RUM-12185)
const files = glob.sync(
['**/*.{js,jsx,ts,tsx}', '**/*.{js,jsx,ts,tsx}'],
{
cwd: this.rootDir,
absolute: true,
ignore: [
'**/node_modules/**',
'**/lib/**',
'**/dist/**',
'**/*.d.ts',
'**/*.test.*',
'**/*.config.js'
]
}
);
const files = glob.sync('**/*.{js,jsx,ts,tsx}', {
cwd: this.rootDir,
absolute: true,
ignore: this.scanIgnorePatterns,
followSymbolicLinks: this.followSymlinks
});

for (const file of files) {
try {
Expand Down Expand Up @@ -169,9 +164,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] = {
Expand Down Expand Up @@ -214,8 +214,6 @@ export class ReactNativeSVG {
/**
* 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.
Expand Down Expand Up @@ -279,11 +277,6 @@ export class ReactNativeSVG {
__wrappedForSR: true
};

this.svgMap[id] = {
file: optimized,
...dimensions
};

writeAssetToDisk(this.assetsPath, id, hash, optimized);

return { original: output, optimized };
Expand Down
89 changes: 89 additions & 0 deletions packages/react-native-babel-plugin/test/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,12 @@
*/

import { transform } from '@babel/core';
import fs from 'fs';
import os from 'os';
import path from 'path';

import plugin from '../src/index';
import { ReactNativeSVG } from '../src/libraries/react-native-svg';
import type { PluginOptions } from '../src/types';

function transformCode(code: string, pluginOptions?: Partial<PluginOptions>) {
Expand Down Expand Up @@ -81,6 +85,91 @@ describe('Babel plugin: web platform', () => {
expect(output).not.toContain('__DD_RN_BABEL_PLUGIN_ENABLED__');
expect(output).not.toContain('@datadog/mobile-react-native');
});

// pre() runs before Program.enter's own platform check, so buildSvgMap()'s
// project-wide scan must skip web itself rather than relying on that later
// check -- otherwise every web build would pay for a scan whose results
// Program.enter/JSXElement would immediately discard anyway.
describe('buildSvgMap scanning', () => {
let projectDir: string;
let originalCwd: string;
let originalPluginDev: string | undefined;
let buildSvgMapSpy: jest.SpyInstance;

beforeEach(() => {
projectDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'dd-plugin-web-platform-')
);
originalCwd = process.cwd();
originalPluginDev = process.env.pluginDev;
process.env.pluginDev = 'true';
process.chdir(projectDir);

buildSvgMapSpy = jest
.spyOn(ReactNativeSVG.prototype, 'buildSvgMap')
.mockImplementation(() => undefined);
});

afterEach(() => {
buildSvgMapSpy.mockRestore();
process.chdir(originalCwd);
if (originalPluginDev === undefined) {
delete process.env.pluginDev;
} else {
process.env.pluginDev = originalPluginDev;
}
fs.rmSync(projectDir, { recursive: true, force: true });
});

function transformSvg(caller: { name: string; platform: string }) {
transform('<Svg><Circle cx="1" cy="1" r="1" /></Svg>', {
filename: path.join(projectDir, 'file.tsx'),
// No node_modules under projectDir to resolve presets from.
parserOpts: { plugins: ['jsx'] },
plugins: [[plugin, { sessionReplay: { svgTracking: true } }]],
configFile: false,
caller
});
}

it('should not scan for SVGs on web platform builds even with svgTracking enabled', () => {
transformSvg({ name: 'metro', platform: 'web' });

expect(buildSvgMapSpy).not.toHaveBeenCalled();
});

it('should still scan for SVGs on non-web platform builds with svgTracking enabled', () => {
transformSvg({ name: 'metro', platform: 'ios' });

expect(buildSvgMapSpy).toHaveBeenCalledTimes(1);
});

// pre() reuses the ReactNativeSVG instance across files in the same
// worker instead of rebuilding (and rescanning) it per file.
it('should only scan once across multiple files processed by the same plugin instance', () => {
const pluginConfig: [typeof plugin, unknown] = [
plugin,
{ sessionReplay: { svgTracking: true } }
];

transform('<Svg><Circle cx="1" cy="1" r="1" /></Svg>', {
filename: path.join(projectDir, 'FileA.tsx'),
parserOpts: { plugins: ['jsx'] },
plugins: [pluginConfig],
configFile: false,
caller: { name: 'metro', platform: 'ios' }
});
transform('<Svg><Circle cx="1" cy="1" r="1" /></Svg>', {
filename: path.join(projectDir, 'FileB.tsx'),
parserOpts: { plugins: ['jsx'] },
plugins: [pluginConfig],
configFile: false,
caller: { name: 'metro', platform: 'ios' }
});

expect(buildSvgMapSpy).toHaveBeenCalledTimes(1);
});
});
});

describe('Babel plugin: wrap interaction handlers for RUM', () => {
Expand Down
Loading