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..ddac8e735 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'; @@ -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 { diff --git a/packages/react-native-babel-plugin/src/index.ts b/packages/react-native-babel-plugin/src/index.ts index 6c41836c5..54ff86bcd 100644 --- a/packages/react-native-babel-plugin/src/index.ts +++ b/packages/react-native-babel-plugin/src/index.ts @@ -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; if (!reactNativeSVG && assetsPath) { reactNativeSVG = new ReactNativeSVG( process.cwd(), assetsPath, options.__internal_saveSvgMapToDisk || false ); + reactNativeSVG.setApiTypes(api.types); reactNativeSVG.buildSvgMap(); } reactNativeSVG?.setApiTypes(api.types); diff --git a/packages/react-native-babel-plugin/src/libraries/react-native-svg/handlers/RNSvgHandler.ts b/packages/react-native-babel-plugin/src/libraries/react-native-svg/handlers/RNSvgHandler.ts index 25f97aed6..065624143 100644 --- a/packages/react-native-babel-plugin/src/libraries/react-native-svg/handlers/RNSvgHandler.ts +++ b/packages/react-native-babel-plugin/src/libraries/react-native-svg/handlers/RNSvgHandler.ts @@ -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. 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..72ead6ae7 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 @@ -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 @@ -36,10 +43,6 @@ type SvgOffset = { * the native Session Replay layer. */ export class ReactNativeSVG { - svgMap: Record = {}; - - svgOffset: Record = {}; - localSvgMap: Record = {}; t: typeof Babel.types | null = null; @@ -47,7 +50,9 @@ export class ReactNativeSVG { 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) { @@ -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, @@ -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 { @@ -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] = { @@ -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. @@ -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 }; diff --git a/packages/react-native-babel-plugin/test/plugin.test.ts b/packages/react-native-babel-plugin/test/plugin.test.ts index 4f496d142..c4cb123ba 100644 --- a/packages/react-native-babel-plugin/test/plugin.test.ts +++ b/packages/react-native-babel-plugin/test/plugin.test.ts @@ -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) { @@ -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('', { + 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('', { + filename: path.join(projectDir, 'FileA.tsx'), + parserOpts: { plugins: ['jsx'] }, + plugins: [pluginConfig], + configFile: false, + caller: { name: 'metro', platform: 'ios' } + }); + transform('', { + 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', () => { 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..a1d6a0b31 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,119 @@ 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(); + }); + + it('should produce the same localSvgMap across instances for the same source tree', () => { + 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 }; + + const freshInstance = new ReactNativeSVG(tmpDir, tmpDir, false); + freshInstance.setApiTypes(t); + freshInstance.buildSvgMap(); + + expect(freshInstance.localSvgMap).toEqual(mapAfterFirstCall); + }); + + // generate-sr-assets passes its own (larger, user-configurable) ignore + // list here instead of relying on the hardcoded default -- otherwise it + // would scan directories the CLI was explicitly told to skip. + it('should respect a custom scanIgnorePatterns list instead of the hardcoded default', () => { + fs.mkdirSync(path.join(tmpDir, 'vendor')); + fs.writeFileSync( + path.join(tmpDir, 'vendor', 'icons.ts'), + `export { StarIcon } from '../icon.svg';` + ); + + const defaultInstance = new ReactNativeSVG(tmpDir, tmpDir, false); + defaultInstance.setApiTypes(t); + defaultInstance.buildSvgMap(); + expect(defaultInstance.localSvgMap['StarIcon']).toBeDefined(); + + const scopedInstance = new ReactNativeSVG(tmpDir, tmpDir, false, [ + '**/vendor/**' + ]); + scopedInstance.setApiTypes(t); + scopedInstance.buildSvgMap(); + expect(scopedInstance.localSvgMap['StarIcon']).toBeUndefined(); + }); +});