From 5fe1ddabfe1b1363651660a1ddf26692a03b375c Mon Sep 17 00:00:00 2001 From: jonathanmos <48201295+jonathanmos@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:33:51 +0300 Subject: [PATCH 1/2] RUM-12185: Support aliased import paths for local SVG discovery --- benchmarks/babel.config.js | 9 + benchmarks/package.json | 1 + .../scenario/SessionReplay/component/Svg.tsx | 21 ++ .../react-native-babel-plugin/package.json | 2 + .../src/libraries/react-native-svg/index.ts | 30 +- .../react-native-svg/pathAliasResolver.ts | 291 +++++++++++++++++ .../test/react-native-svg.test.ts | 300 ++++++++++++++++++ yarn.lock | 76 ++++- 8 files changed, 715 insertions(+), 15 deletions(-) create mode 100644 packages/react-native-babel-plugin/src/libraries/react-native-svg/pathAliasResolver.ts diff --git a/benchmarks/babel.config.js b/benchmarks/babel.config.js index d79333729..d81f42512 100644 --- a/benchmarks/babel.config.js +++ b/benchmarks/babel.config.js @@ -1,6 +1,15 @@ module.exports = { presets: ['module:@react-native/babel-preset'], plugins: [ + // Used by Group H in the SVG test screen to verify aliased imports + // (e.g. '@assets/star.svg') resolve both at runtime (via this plugin + // rewriting the import) and in buildSvgMap's static scan (RUM-12185). + ['module-resolver', { + root: ['./src'], + alias: { + '@assets': './src/scenario/SessionReplay/component/assets' + } + }], ['@datadog/mobile-react-native-babel-plugin', { sessionReplay: { svgTracking: true diff --git a/benchmarks/package.json b/benchmarks/package.json index 0487960da..472a97e89 100644 --- a/benchmarks/package.json +++ b/benchmarks/package.json @@ -52,6 +52,7 @@ "@react-native/typescript-config": "0.78.2", "@types/jest": "29.5.13", "@types/react-test-renderer": "19.0.0", + "babel-plugin-module-resolver": "5.0.2", "eslint": "8.19.0", "jest": "29.6.3", "prettier": "2.8.8", diff --git a/benchmarks/src/scenario/SessionReplay/component/Svg.tsx b/benchmarks/src/scenario/SessionReplay/component/Svg.tsx index ae86f9950..20a4d7c2b 100644 --- a/benchmarks/src/scenario/SessionReplay/component/Svg.tsx +++ b/benchmarks/src/scenario/SessionReplay/component/Svg.tsx @@ -23,6 +23,9 @@ import { import StarSvg from './assets/star.svg'; import { HeartIcon, ShieldIcon } from './assets/icons'; +// Aliased via the 'module-resolver' babel plugin (see benchmarks/babel.config.js) — +// tests that buildSvgMap resolves aliased local SVG imports (RUM-12185). +import AliasedStarSvg from '@assets/star.svg'; // Module-level const used in Case D1 to test findIdentifierInScope const BADGE_SIZE = 72; @@ -340,6 +343,17 @@ function BarrelShieldImport() { return ; } +// ───────────────────────────────────────────────────────────── +// GROUP H — Aliased import (RUM-12185) +// Same star.svg as F1, but imported via the '@assets' alias configured +// through babel-plugin-module-resolver in babel.config.js. +// ───────────────────────────────────────────────────────────── + +/** H1: Default import of a local .svg file via an aliased path */ +function AliasedStarImport() { + return ; +} + // ───────────────────────────────────────────────────────────── // GROUP I — Unsupported nested elements // AnimatedPath isn't a recognized SVG tag, so it's now spliced out of the tree @@ -432,6 +446,7 @@ export default function SvgTestCases() { Group E: known limitation — absent from replay entirely (see comment).{'\n'} Group F: appears after buildSvgMap fixes.{'\n'} Group G: privacy overrides — verify masking behavior in replay.{'\n'} + Group H: aliased import — same star as F1, resolved via '@assets' alias.{'\n'} Group I: I1 shows circle only (checkmark removed), I2 shows circle + checkmark. @@ -522,6 +537,12 @@ export default function SvgTestCases() { +
+ + + +
+ {/* ─── GROUP G — Privacy interaction ─── */} {/* These cases test whether the native SDK's view-level privacy mechanism diff --git a/packages/react-native-babel-plugin/package.json b/packages/react-native-babel-plugin/package.json index 1fe528098..7e5e191e6 100644 --- a/packages/react-native-babel-plugin/package.json +++ b/packages/react-native-babel-plugin/package.json @@ -50,6 +50,7 @@ "@babel/types": "^7.27.7", "fast-glob": "^3.3.3", "svgo": "^4.0.2", + "tsconfig-paths": "^4.2.0", "uuid": "^8.3.2" }, "devDependencies": { @@ -60,6 +61,7 @@ "@swc/core": "^1.13.21", "@swc/jest": "^0.2.38", "@types/jest": "^30.0.0", + "babel-plugin-module-resolver": "^5.0.2", "jest": "^29.7.0", "react-native-builder-bob": "0.26.0", "tsc-alias": "^1.8.16", 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 72ead6ae7..d4113401e 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 @@ -18,6 +18,7 @@ import { v4 as uuidv4 } from 'uuid'; import { getNodeName } from '../../utils'; import { HandlerResolver } from './handlers/HandlerResolver'; +import { PathAliasResolver } from './pathAliasResolver'; import { writeAssetToDisk } from './processing/fs'; // Used when the caller (e.g. the plugin's own pre() hook) doesn't have a more @@ -47,13 +48,17 @@ export class ReactNativeSVG { t: typeof Babel.types | null = null; + private pathAliasResolver: PathAliasResolver; + constructor( private rootDir: string, private assetsPath: string, private saveSvgMapToDisk: boolean = false, private scanIgnorePatterns: string[] = DEFAULT_SCAN_IGNORE_PATTERNS, private followSymlinks: boolean = false - ) {} + ) { + this.pathAliasResolver = new PathAliasResolver(rootDir); + } setApiTypes(t: typeof Babel.types) { this.t = t; @@ -100,7 +105,11 @@ export class ReactNativeSVG { } } - // TODO: Support aliased paths (RUM-12185) + // Drop any alias config cached from a previous buildSvgMap() run -- + // otherwise edits to tsconfig.json/babel.config.js made since then + // would be invisible to a reused instance. + this.pathAliasResolver.reset(); + const files = glob.sync('**/*.{js,jsx,ts,tsx}', { cwd: this.rootDir, absolute: true, @@ -136,10 +145,7 @@ export class ReactNativeSVG { return; } - const resolved = pathN.resolve( - pathN.dirname(file), - source - ); + const resolved = this.resolveImportSource(file, source); for (const spec of path.node.specifiers) { const name = getNodeName(this.t, spec.local.name); if (name) { @@ -158,10 +164,7 @@ export class ReactNativeSVG { return; } - const resolved = pathN.resolve( - pathN.dirname(file), - source - ); + const resolved = this.resolveImportSource(file, source); for (const spec of path.node.specifiers) { if (spec.type === 'ExportSpecifier') { // spec.exported is the name consumers import under @@ -211,6 +214,13 @@ export class ReactNativeSVG { } } + private resolveImportSource(file: string, source: string): string { + return ( + this.pathAliasResolver.resolve(source, file) ?? + pathN.resolve(pathN.dirname(file), source) + ); + } + /** * Processes a JSXElement representing an SVG-based component and transforms it into * a web-compliant SVG string with normalized attributes and extracted dimensions. diff --git a/packages/react-native-babel-plugin/src/libraries/react-native-svg/pathAliasResolver.ts b/packages/react-native-babel-plugin/src/libraries/react-native-svg/pathAliasResolver.ts new file mode 100644 index 000000000..0bc823eb1 --- /dev/null +++ b/packages/react-native-babel-plugin/src/libraries/react-native-svg/pathAliasResolver.ts @@ -0,0 +1,291 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import * as babelCore from '@babel/core'; +import pathN from 'path'; +import { createMatchPath, loadConfig } from 'tsconfig-paths'; +import type { MatchPath } from 'tsconfig-paths'; + +// @babel/core's type declarations describe `options.plugins` as the input +// `PluginItem[]` shape, but `loadPartialConfig()` actually resolves each +// entry to a `ConfigItem` (undocumented in @types/babel__core) exposing +// `.file.resolved` and `.options`. +type ResolvedConfigItem = { + file?: { resolved: string }; + options?: unknown; + value?: unknown; +}; + +type ModuleResolverBinding = { + resolvePath: ( + sourcePath: string, + currentFile: string, + opts: unknown + ) => string | null; + options: unknown; +}; + +type ModuleResolverModule = { + default?: unknown; + resolvePath?: ModuleResolverBinding['resolvePath']; +}; + +function isRelativePath(value: string): boolean { + return /^\.?\.\//.test(value); +} + +/** + * Resolves non-relative import specifiers (e.g. `@components/Logo`) against a + * project's `babel-plugin-module-resolver` config and/or its + * `tsconfig.json`/`jsconfig.json` `paths` mapping, so aliased local SVG + * imports can be found on disk the same way they resolve at runtime. + * + * Callers should still fall back to plain relative resolution when this + * returns `null` -- that covers projects that don't use any aliasing. + */ +export class PathAliasResolver { + private rootDir: string; + + private moduleResolverBindings = new Map< + string, + ModuleResolverBinding | null + >(); + + private tsMatchPath: MatchPath | null | undefined; + + private resultCache = new Map(); + + constructor(rootDir: string) { + this.rootDir = rootDir; + } + + /** Drops all cached config/results -- call before reusing this resolver + * for a fresh scan, since a stale cache would otherwise outlive edits to + * tsconfig.json/Babel config made after it was first computed. */ + reset(): void { + this.moduleResolverBindings.clear(); + this.tsMatchPath = undefined; + this.resultCache.clear(); + } + + resolve(importSource: string, currentFile: string): string | null { + if (importSource[0] === '.' || pathN.isAbsolute(importSource)) { + return null; + } + + const cacheKey = `${currentFile}\0${importSource}`; + const cached = this.resultCache.get(cacheKey); + if (cached !== undefined) { + return cached; + } + + const resolved = + this.resolveWithModuleResolver(importSource, currentFile) ?? + this.resolveWithTsconfigPaths(importSource); + this.resultCache.set(cacheKey, resolved); + return resolved; + } + + private resolveWithModuleResolver( + importSource: string, + currentFile: string + ): string | null { + const binding = this.getModuleResolverBinding(currentFile); + if (!binding) { + return null; + } + + try { + // Delegate to the project's own installed babel-plugin-module-resolver + // instead of re-implementing its alias/root matching -- this keeps + // regex-keyed aliases, function-valued aliases, and glob roots working + // exactly as they would at real build time. + const resolved = binding.resolvePath( + importSource, + currentFile, + binding.options + ); + if (!resolved || !isRelativePath(resolved)) { + return null; + } + + return pathN.resolve(pathN.dirname(currentFile), resolved); + } catch (err) { + console.warn( + '[PathAliasResolver]: babel-plugin-module-resolver failed to resolve an aliased import, falling back to relative resolution', + err + ); + return null; + } + } + + private resolveWithTsconfigPaths(importSource: string): string | null { + const matchPath = this.getTsMatchPath(); + if (!matchPath) { + return null; + } + + return matchPath(importSource) ?? null; + } + + private getTsMatchPath(): MatchPath | null { + if (this.tsMatchPath !== undefined) { + return this.tsMatchPath; + } + + try { + const config = loadConfig(this.rootDir); + if (config.resultType === 'success') { + this.tsMatchPath = createMatchPath( + config.absoluteBaseUrl, + config.paths + ); + } else { + this.tsMatchPath = null; + } + } catch (err) { + console.warn( + '[PathAliasResolver]: Failed to load tsconfig.json/jsconfig.json paths, aliased SVG imports may not resolve', + err + ); + this.tsMatchPath = null; + } + + return this.tsMatchPath; + } + + private getModuleResolverBinding( + currentFile: string + ): ModuleResolverBinding | null { + if (this.moduleResolverBindings.has(currentFile)) { + return this.moduleResolverBindings.get(currentFile) ?? null; + } + + try { + const partialConfig = babelCore.loadPartialConfig({ + cwd: this.rootDir, + filename: currentFile + }); + + const plugins = ((partialConfig?.options.plugins ?? + []) as unknown) as ResolvedConfigItem[]; + // Compare with normalized (forward-slash) separators -- `file.resolved` + // uses the OS-native separator, which is a backslash on Windows. + let pluginItem = plugins.find(plugin => + plugin.file?.resolved + .replace(/\\/g, '/') + .includes('/babel-plugin-module-resolver/') + ); + + let resolvedPluginPath = pluginItem?.file?.resolved; + let moduleResolverModule: ModuleResolverModule | undefined; + + // Babel omits `file.resolved` when a config passes the plugin + // function directly (for example `require('...')`). Resolve the + // project-visible module and compare its exported function by + // identity so this valid config form is detected too. + if (!pluginItem) { + const bindFunctionPlugin = ( + modulePath: string, + candidateModule: ModuleResolverModule + ): boolean => { + const candidatePluginItem = plugins.find( + plugin => + plugin.value === candidateModule.default || + plugin.value === candidateModule + ); + if (!candidatePluginItem) { + return false; + } + + resolvedPluginPath = modulePath; + moduleResolverModule = candidateModule; + pluginItem = candidatePluginItem; + return true; + }; + + try { + const projectModulePath = require.resolve( + 'babel-plugin-module-resolver', + { + paths: [pathN.dirname(currentFile), this.rootDir] + } + ); + // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require, import/no-dynamic-require + const projectModule = require(projectModulePath) as ModuleResolverModule; + bindFunctionPlugin(projectModulePath, projectModule); + } catch (err) { + // The config may have required an explicit module path + // outside rootDir, so check already-loaded modules below. + } + + if (!pluginItem) { + for (const cachedModule of Object.values(require.cache)) { + const modulePath = cachedModule?.filename; + if ( + !cachedModule || + !modulePath + ?.replace(/\\/g, '/') + .includes('/babel-plugin-module-resolver/') + ) { + continue; + } + + if ( + bindFunctionPlugin( + modulePath, + cachedModule.exports as ModuleResolverModule + ) + ) { + break; + } + } + } + } + + const options = pluginItem?.options; + if ( + !pluginItem || + !resolvedPluginPath || + !options || + typeof options !== 'object' + ) { + this.moduleResolverBindings.set(currentFile, null); + return null; + } + + // Require the project's own installed copy (via its resolved path, + // rather than a bundled copy of ours) so behavior matches whatever + // version is actually driving the project's real bundling. The + // path is only known at runtime, so a dynamic require is required. + // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require, import/no-dynamic-require + moduleResolverModule ??= require(resolvedPluginPath) as ModuleResolverModule; + + // babel-plugin-module-resolver defaults `cwd` to `process.cwd()` + // when unset, which at real build time is the project root -- but + // isn't necessarily true for this out-of-band scan (e.g. tests, + // or a CLI run from elsewhere), so pin it to rootDir explicitly. + const optionsWithCwd = + 'cwd' in options ? options : { ...options, cwd: this.rootDir }; + + const binding = moduleResolverModule.resolvePath + ? { + resolvePath: moduleResolverModule.resolvePath, + options: optionsWithCwd + } + : null; + this.moduleResolverBindings.set(currentFile, binding); + return binding; + } catch (err) { + console.warn( + '[PathAliasResolver]: Failed to load babel-plugin-module-resolver config, aliased SVG imports may not resolve', + err + ); + this.moduleResolverBindings.set(currentFile, null); + return null; + } + } +} 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 a1d6a0b31..c50f3f7b4 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 @@ -1262,3 +1262,303 @@ describe('ReactNativeSVG.buildSvgMap', () => { expect(scopedInstance.localSvgMap['StarIcon']).toBeUndefined(); }); }); + +describe('ReactNativeSVG.buildSvgMap with aliased paths', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'dd-buildsvgmap-alias-') + ); + fs.mkdirSync(path.join(tmpDir, 'src', 'components'), { + recursive: true + }); + fs.writeFileSync( + path.join(tmpDir, 'src', 'components', 'icon.svg'), + '' + ); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should resolve an aliased import using tsconfig.json baseUrl/paths', () => { + fs.writeFileSync( + path.join(tmpDir, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { + baseUrl: '.', + paths: { '@components/*': ['src/components/*'] } + } + }) + ); + fs.writeFileSync( + path.join(tmpDir, 'Component.tsx'), + `import Logo from '@components/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, 'src', 'components', 'icon.svg') + ); + }); + + it('should resolve an aliased import using jsconfig.json baseUrl/paths', () => { + fs.writeFileSync( + path.join(tmpDir, 'jsconfig.json'), + JSON.stringify({ + compilerOptions: { + baseUrl: '.', + paths: { '@components/*': ['src/components/*'] } + } + }) + ); + fs.writeFileSync( + path.join(tmpDir, 'Component.jsx'), + `import Logo from '@components/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, 'src', 'components', 'icon.svg') + ); + }); + + it('should resolve an aliased import using a tsconfig.json that extends a base config', () => { + fs.writeFileSync( + path.join(tmpDir, 'base.tsconfig.json'), + JSON.stringify({ + compilerOptions: { + baseUrl: '.', + paths: { '@components/*': ['src/components/*'] } + } + }) + ); + fs.writeFileSync( + path.join(tmpDir, 'tsconfig.json'), + JSON.stringify({ extends: './base.tsconfig.json' }) + ); + fs.writeFileSync( + path.join(tmpDir, 'Component.tsx'), + `import Logo from '@components/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, 'src', 'components', 'icon.svg') + ); + }); + + it('should resolve an aliased import using babel-plugin-module-resolver config', () => { + const moduleResolverPath = require.resolve( + 'babel-plugin-module-resolver' + ); + fs.writeFileSync( + path.join(tmpDir, 'babel.config.js'), + `module.exports = { + plugins: [ + [${JSON.stringify(moduleResolverPath)}, { + root: ['./src'], + alias: { '@components': './src/components' } + }] + ] + };` + ); + fs.writeFileSync( + path.join(tmpDir, 'Component.tsx'), + `import Logo from '@components/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, 'src', 'components', 'icon.svg') + ); + }); + + it('should resolve an aliased import using a .babelrc config', () => { + const moduleResolverPath = require.resolve( + 'babel-plugin-module-resolver' + ); + fs.writeFileSync( + path.join(tmpDir, '.babelrc'), + JSON.stringify({ + plugins: [ + [ + moduleResolverPath, + { + alias: { + '@components': './src/components' + } + } + ] + ] + }) + ); + fs.writeFileSync( + path.join(tmpDir, 'Component.tsx'), + `import Logo from '@components/icon.svg';\nexport default function C() { return ; }` + ); + + const instance = new ReactNativeSVG(tmpDir, tmpDir, false); + instance.setApiTypes(t); + instance.buildSvgMap(); + + expect(instance.localSvgMap['Logo'].path).toBe( + path.join(tmpDir, 'src', 'components', 'icon.svg') + ); + }); + + it('should resolve an aliased import when module-resolver is passed as a function', () => { + const moduleResolverPath = require.resolve( + 'babel-plugin-module-resolver' + ); + fs.writeFileSync( + path.join(tmpDir, 'babel.config.js'), + `const moduleResolver = require(${JSON.stringify( + moduleResolverPath + )}); + module.exports = { + plugins: [ + [moduleResolver, { + alias: { '@components': './src/components' } + }] + ] + };` + ); + fs.writeFileSync( + path.join(tmpDir, 'Component.tsx'), + `import Logo from '@components/icon.svg';\nexport default function C() { return ; }` + ); + + const instance = new ReactNativeSVG(tmpDir, tmpDir, false); + instance.setApiTypes(t); + instance.buildSvgMap(); + + expect(instance.localSvgMap['Logo'].path).toBe( + path.join(tmpDir, 'src', 'components', 'icon.svg') + ); + }); + + it('should prefer babel-plugin-module-resolver over tsconfig.json when both are configured', () => { + fs.mkdirSync(path.join(tmpDir, 'alt-components')); + fs.writeFileSync( + path.join(tmpDir, 'alt-components', 'icon.svg'), + '' + ); + fs.writeFileSync( + path.join(tmpDir, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { + baseUrl: '.', + paths: { '@components/*': ['alt-components/*'] } + } + }) + ); + const moduleResolverPath = require.resolve( + 'babel-plugin-module-resolver' + ); + fs.writeFileSync( + path.join(tmpDir, 'babel.config.js'), + `module.exports = { + plugins: [ + [${JSON.stringify(moduleResolverPath)}, { + alias: { '@components': './src/components' } + }] + ] + };` + ); + fs.writeFileSync( + path.join(tmpDir, 'Component.tsx'), + `import Logo from '@components/icon.svg';\nexport default function C() { return ; }` + ); + + const instance = new ReactNativeSVG(tmpDir, tmpDir, false); + instance.setApiTypes(t); + instance.buildSvgMap(); + + expect(instance.localSvgMap['Logo'].path).toBe( + path.join(tmpDir, 'src', 'components', 'icon.svg') + ); + }); + + it('should leave non-relative imports unresolved (falling back to the previous behavior) when no alias config is present', () => { + fs.writeFileSync( + path.join(tmpDir, 'Component.tsx'), + `import Logo from '@components/icon.svg';\nexport default function C() { return ; }` + ); + + const instance = new ReactNativeSVG(tmpDir, tmpDir, false); + instance.setApiTypes(t); + expect(() => instance.buildSvgMap()).not.toThrow(); + + expect(instance.localSvgMap['Logo']).toBeDefined(); + expect(instance.localSvgMap['Logo'].path).toBe( + path.resolve(tmpDir, '@components/icon.svg') + ); + }); + + it('should fall back to unresolved relative resolution when an alias is configured but does not match a file on disk', () => { + fs.writeFileSync( + path.join(tmpDir, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { + baseUrl: '.', + paths: { '@missing/*': ['src/does-not-exist/*'] } + } + }) + ); + fs.writeFileSync( + path.join(tmpDir, 'Component.tsx'), + `import Logo from '@missing/icon.svg';\nexport default function C() { return ; }` + ); + + const instance = new ReactNativeSVG(tmpDir, tmpDir, false); + instance.setApiTypes(t); + expect(() => instance.buildSvgMap()).not.toThrow(); + + expect(instance.localSvgMap['Logo'].path).toBe( + path.resolve(tmpDir, '@missing/icon.svg') + ); + }); + + it('should still resolve unaliased relative imports normally when alias config is present', () => { + fs.writeFileSync( + path.join(tmpDir, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { + baseUrl: '.', + paths: { '@components/*': ['src/components/*'] } + } + }) + ); + fs.writeFileSync( + path.join(tmpDir, 'Component.tsx'), + `import Logo from './src/components/icon.svg';\nexport default function C() { return ; }` + ); + + const instance = new ReactNativeSVG(tmpDir, tmpDir, false); + instance.setApiTypes(t); + instance.buildSvgMap(); + + expect(instance.localSvgMap['Logo'].path).toBe( + path.join(tmpDir, 'src', 'components', 'icon.svg') + ); + }); +}); diff --git a/yarn.lock b/yarn.lock index 4451f7b88..ce5bd3417 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2039,11 +2039,13 @@ __metadata: "@swc/core": ^1.13.21 "@swc/jest": ^0.2.38 "@types/jest": ^30.0.0 + babel-plugin-module-resolver: ^5.0.2 fast-glob: ^3.3.3 jest: ^29.7.0 react-native-builder-bob: 0.26.0 svgo: ^4.0.2 tsc-alias: ^1.8.16 + tsconfig-paths: ^4.2.0 typescript: 5.9.3 uuid: ^8.3.2 peerDependencies: @@ -6592,6 +6594,19 @@ __metadata: languageName: node linkType: hard +"babel-plugin-module-resolver@npm:5.0.2": + version: 5.0.2 + resolution: "babel-plugin-module-resolver@npm:5.0.2" + dependencies: + find-babel-config: ^2.1.1 + glob: ^9.3.3 + pkg-up: ^3.1.0 + reselect: ^4.1.7 + resolve: ^1.22.8 + checksum: f1d198acbbbd0b76c9c0c4aacbf9f1ef90f8d36b3d5209d9e7a75cadee2113a73711550ebddeb9464d143b71df19adc75e165dff99ada2614d7ea333affe3b5a + languageName: node + linkType: hard + "babel-plugin-module-resolver@npm:^4.0.0": version: 4.1.0 resolution: "babel-plugin-module-resolver@npm:4.1.0" @@ -6605,6 +6620,19 @@ __metadata: languageName: node linkType: hard +"babel-plugin-module-resolver@npm:^5.0.2": + version: 5.0.3 + resolution: "babel-plugin-module-resolver@npm:5.0.3" + dependencies: + find-babel-config: ^2.1.1 + glob: ^9.3.3 + pkg-up: ^3.1.0 + reselect: ^4.1.7 + resolve: ^1.22.8 + checksum: a03a614d8a6d7eedb2f7a764503e28e41824d983c4100203c180523d4dbda8fd12d2679b8e1c769c0426595d581e818696fffb10bacf20bdb487a46acdebece3 + languageName: node + linkType: hard + "babel-plugin-polyfill-corejs2@npm:^0.4.10, babel-plugin-polyfill-corejs2@npm:^0.4.15": version: 0.4.17 resolution: "babel-plugin-polyfill-corejs2@npm:0.4.17" @@ -6800,6 +6828,7 @@ __metadata: "@react-navigation/native-stack": 7.3.12 "@types/jest": 29.5.13 "@types/react-test-renderer": 19.0.0 + babel-plugin-module-resolver: 5.0.2 eslint: 8.19.0 jest: 29.6.3 prettier: 2.8.8 @@ -9680,6 +9709,15 @@ __metadata: languageName: node linkType: hard +"find-babel-config@npm:^2.1.1": + version: 2.1.2 + resolution: "find-babel-config@npm:2.1.2" + dependencies: + json5: ^2.2.3 + checksum: 268f29cb38ee086b0f953c89f762dcea30b5b0e14abee2b39516410c00b49baa6821f598bd50346c93584e5625c5740f5c8b7e34993f568787a068f84dacc8c2 + languageName: node + linkType: hard + "find-cache-dir@npm:^2.0.0": version: 2.1.0 resolution: "find-cache-dir@npm:2.1.0" @@ -10261,6 +10299,18 @@ __metadata: languageName: node linkType: hard +"glob@npm:^9.3.3": + version: 9.3.5 + resolution: "glob@npm:9.3.5" + dependencies: + fs.realpath: ^1.0.0 + minimatch: ^8.0.2 + minipass: ^4.2.4 + path-scurry: ^1.6.1 + checksum: 94b093adbc591bc36b582f77927d1fb0dbf3ccc231828512b017601408be98d1fe798fc8c0b19c6f2d1a7660339c3502ce698de475e9d938ccbb69b47b647c84 + languageName: node + linkType: hard + "global-agent@npm:^3.0.0": version: 3.0.0 resolution: "global-agent@npm:3.0.0" @@ -13555,6 +13605,15 @@ __metadata: languageName: node linkType: hard +"minimatch@npm:^8.0.2": + version: 8.0.7 + resolution: "minimatch@npm:8.0.7" + dependencies: + brace-expansion: ^2.0.1 + checksum: edaefeb16297f4f3969287913adb04c12c5683f2bd8610c6d6bfd5aa5b98bbbfd6013a2d0bb24df62e8add9c265128df1bfdbb61bb043ef4aa86b449fc2a9c76 + languageName: node + linkType: hard + "minimist-options@npm:4.1.0": version: 4.1.0 resolution: "minimist-options@npm:4.1.0" @@ -13633,6 +13692,13 @@ __metadata: languageName: node linkType: hard +"minipass@npm:^4.2.4": + version: 4.2.8 + resolution: "minipass@npm:4.2.8" + checksum: 7f4914d5295a9a30807cae5227a37a926e6d910c03f315930fde52332cf0575dfbc20295318f91f0baf0e6bb11a6f668e30cde8027dea7a11b9d159867a3c830 + languageName: node + linkType: hard + "minipass@npm:^5.0.0 || ^6.0.2 || ^7.0.0, minipass@npm:^7.0.2, minipass@npm:^7.0.3, minipass@npm:^7.0.4, minipass@npm:^7.1.2": version: 7.1.2 resolution: "minipass@npm:7.1.2" @@ -14894,7 +14960,7 @@ __metadata: languageName: node linkType: hard -"path-scurry@npm:^1.11.1": +"path-scurry@npm:^1.11.1, path-scurry@npm:^1.6.1": version: 1.11.1 resolution: "path-scurry@npm:1.11.1" dependencies: @@ -16284,7 +16350,7 @@ __metadata: languageName: node linkType: hard -"reselect@npm:^4.0.0": +"reselect@npm:^4.0.0, reselect@npm:^4.1.7": version: 4.1.8 resolution: "reselect@npm:4.1.8" checksum: a4ac87cedab198769a29be92bc221c32da76cfdad6911eda67b4d3e7136dca86208c3b210e31632eae31ebd2cded18596f0dd230d3ccc9e978df22f233b5583e @@ -16335,7 +16401,7 @@ __metadata: languageName: node linkType: hard -"resolve@npm:^1.10.0, resolve@npm:^1.13.1, resolve@npm:^1.18.1, resolve@npm:^1.20.0, resolve@npm:^1.22.11, resolve@npm:^1.22.4": +"resolve@npm:^1.10.0, resolve@npm:^1.13.1, resolve@npm:^1.18.1, resolve@npm:^1.20.0, resolve@npm:^1.22.11, resolve@npm:^1.22.4, resolve@npm:^1.22.8": version: 1.22.12 resolution: "resolve@npm:1.22.12" dependencies: @@ -16362,7 +16428,7 @@ __metadata: languageName: node linkType: hard -"resolve@patch:resolve@^1.10.0#~builtin, resolve@patch:resolve@^1.13.1#~builtin, resolve@patch:resolve@^1.18.1#~builtin, resolve@patch:resolve@^1.20.0#~builtin, resolve@patch:resolve@^1.22.11#~builtin, resolve@patch:resolve@^1.22.4#~builtin": +"resolve@patch:resolve@^1.10.0#~builtin, resolve@patch:resolve@^1.13.1#~builtin, resolve@patch:resolve@^1.18.1#~builtin, resolve@patch:resolve@^1.20.0#~builtin, resolve@patch:resolve@^1.22.11#~builtin, resolve@patch:resolve@^1.22.4#~builtin, resolve@patch:resolve@^1.22.8#~builtin": version: 1.22.12 resolution: "resolve@patch:resolve@npm%3A1.22.12#~builtin::version=1.22.12&hash=c3c19d" dependencies: @@ -17805,7 +17871,7 @@ __metadata: languageName: node linkType: hard -"tsconfig-paths@npm:^4.1.2": +"tsconfig-paths@npm:^4.1.2, tsconfig-paths@npm:^4.2.0": version: 4.2.0 resolution: "tsconfig-paths@npm:4.2.0" dependencies: From a1af24b2b13ac0f1ca4fc5f1d99a7548370eab7f Mon Sep 17 00:00:00 2001 From: Jonathan Moskovich Date: Mon, 31 Aug 2026 12:01:38 +0300 Subject: [PATCH 2/2] RUM-12185: Support absolute and Metro-aliased paths in SVG import resolution --- LICENSE-3rdparty.csv | 2 + benchmarks/babel.config.js | 20 +- benchmarks/ios/Podfile.lock | 114 +++--- benchmarks/metro.config.js | 26 +- .../scenario/SessionReplay/component/Svg.tsx | 42 ++- .../react-native-babel-plugin/package.json | 2 +- .../src/libraries/react-native-svg/index.ts | 222 ++++++++++-- .../react-native-svg/pathAliasResolver.ts | 118 +++++- .../test/react-native-svg.test.ts | 340 ++++++++++++++++++ yarn.lock | 15 +- 10 files changed, 792 insertions(+), 109 deletions(-) diff --git a/LICENSE-3rdparty.csv b/LICENSE-3rdparty.csv index 3baff5a5f..0d925df8c 100644 --- a/LICENSE-3rdparty.csv +++ b/LICENSE-3rdparty.csv @@ -3,6 +3,7 @@ prod,big-integer,Unlicense,"Free and unencumbered software released into the pub prod,lodash.isequal,CC0,"Copyright jQuery Foundation and other contributors " prod,react-native,MIT,"Copyright (c) Facebook, Inc. and its affiliates." dev,@apollo/client,MIT,"Copyright (c) 2022 Apollo Graph, Inc. (Formerly Meteor Development Group, Inc.)" +dev,babel-plugin-module-resolver,MIT,"Copyright (c) 2015 Tommy Leunen (tommyleunen.com)" dev,@babel/plugin-transform-runtime,MIT,"Copyright (c) 2014-present Sebastian McKenzie and other contributors" dev,@testing-library/react-native,MIT,"Copyright (c) 2018 Callstack and Rally Health" dev,@types/jest,MIT,"Copyrights are respective of each contributor listed at the beginning of each definition file." @@ -37,4 +38,5 @@ prod,@openfeature/web-sdk,Apache-2.0,"Copyright (c) The OpenFeature Authors" prod,chokidar,MIT,"Copyright (c) 2012 Paul Miller (https://paulmillr.com), Elan Shanker" prod,fast-glob,MIT,"Copyright (c) Denis Malinochkin" prod,svgo,MIT,"Copyright (c) Kir Belevich" +prod,tsconfig-paths,MIT,"Copyright (c) 2016 Jonas Kello" prod,uuid,MIT,"Copyright (c) 2010-2020 Robert Kieffer and other contributors" diff --git a/benchmarks/babel.config.js b/benchmarks/babel.config.js index d81f42512..267288b75 100644 --- a/benchmarks/babel.config.js +++ b/benchmarks/babel.config.js @@ -1,3 +1,5 @@ +const path = require('path'); + module.exports = { presets: ['module:@react-native/babel-preset'], plugins: [ @@ -7,7 +9,23 @@ module.exports = { ['module-resolver', { root: ['./src'], alias: { - '@assets': './src/scenario/SessionReplay/component/assets' + '@assets': './src/scenario/SessionReplay/component/assets', + // H2/H3 alias into @react-native/debugger-frontend (a real, + // non-workspace npm dependency -- NOT a yarn-workspace symlink, + // so its file path genuinely contains 'node_modules'). This matters + // because the Babel plugin explicitly skips any file under + // node_modules (index.ts), so RNSvgHandler never independently + // wraps these icons' own tag the way it does for in-project + // assets/*.svg -- localSvgMap/pathAliasResolver is the ONLY thing + // that can make these show up wrapped in Session Replay, which is + // what actually exercises the aliasing fix (RUM-12185). + // + // H2: alias maps straight to a file -- the specifier itself carries + // no '.svg' extension, exercising buildSvgMap's extensionless-alias path. + '@heart-logo': './node_modules/@react-native/debugger-frontend/dist/third-party/front_end/Images/checkmark.svg', + // H3: alias substitute is an absolute path rather than one relative to + // the importing file, exercising buildSvgMap's absolute-alias path. + '@absoluteAssets': path.resolve(__dirname, 'node_modules/@react-native/debugger-frontend/dist/third-party/front_end/Images') } }], ['@datadog/mobile-react-native-babel-plugin', { diff --git a/benchmarks/ios/Podfile.lock b/benchmarks/ios/Podfile.lock index 9a32882d8..fa0605350 100644 --- a/benchmarks/ios/Podfile.lock +++ b/benchmarks/ios/Podfile.lock @@ -2140,9 +2140,9 @@ SPEC CHECKSUMS: DatadogInternal: e5a4652ee266986e29c6b8491aff7f78dc3b731d DatadogLogs: 52792add5827042f619771a23656e2bfb6b0f4e7 DatadogRUM: b78d608e013f39c5679ed31ad97dad323c6113e8 - DatadogSDKReactNative: ac7f15bcb1b7b805d7e80ad931f8f28fcf6d0985 - DatadogSDKReactNativeSessionReplay: cb9dc63d83543f4460c275e6c2661f79d2108dd5 - DatadogSDKReactNativeWebView: e7118febd9ac7f9ff981cfb9719722de5e664020 + DatadogSDKReactNative: 384b09933cbccd45e616d773fa950c390a223cf4 + DatadogSDKReactNativeSessionReplay: 614404cade1bacec7ad305e4bcfb3b277529767e + DatadogSDKReactNativeWebView: 46c89e61223339b5c750bac0815c8b5373974788 DatadogSessionReplay: 932b2077b1a5367de1450479d72093621b9fd618 DatadogTrace: 5ef84e254e2e1d337bc52e0cdf16396ca102aeb1 DatadogWebViewTracking: e0470157f55a83680a549d79ba2985e0d17465db @@ -2155,74 +2155,74 @@ SPEC CHECKSUMS: KSCrash: 8c4464fd5da7de520f2ce4a00fdf63f169a80f18 OpenTelemetry-Swift-Api: 3be9043f6288eb4ac3cbe7548b5bd45fb4ed5849 OpenTelemetry-Swift-Sdk: d9fa7bc839350f5a81467a5e877f727a8a77712d - RCT-Folly: 36fe2295e44b10d831836cc0d1daec5f8abcf809 + RCT-Folly: e78785aa9ba2ed998ea4151e314036f6c49e6d82 RCTDeprecation: be794de7dc6ed8f9f7fbf525f86e7651b8b68746 RCTRequired: a83787b092ec554c2eb6019ff3f5b8d125472b3b RCTTypeSafety: 48ad3c858926b1c46f46a81a58822b476e178e2c React: 3b5754191f1b65f1dbc52fbea7959c3d2d9e39c9 React-callinvoker: 6beeaf4c7db11b6cc953fac45f2c76e3fb125013 - React-Core: 88e817c42de035378cc71e009193b9a044d3f595 - React-CoreModules: dcf764d71efb4f75d38fcae8d4513b6729f49360 - React-cxxreact: 8cdcc937c5fbc406fe843a381102fd69440ca78a + React-Core: 8a10ac9de53373a3ecb5dfcbcf56df1d3dad0861 + React-CoreModules: af6999b35c7c01b0e12b59d27f3e054e13da43b1 + React-cxxreact: 833f00155ce8c2fda17f6d286f8eaeff2ececc69 React-debug: 440175830c448e7e53e61ebb8d8468c3256b645e - React-defaultsnativemodule: 4824bcd7b96ee2d75c28b1ca21f58976867f5535 - React-domnativemodule: a421118b475618961cf282e8ea85347cc9bb453c - React-Fabric: 6ac7de06009eb96b609a770b17abba6e460b5f45 - React-FabricComponents: e3bc2680a5a9a4917ff0c8d7f390688c30ef753c - React-FabricImage: 8bad558dec7478077974caa96acc79692d6b71f5 + React-defaultsnativemodule: a970effe18fe50bdbbb7115c3297f873b666d0d4 + React-domnativemodule: 45f886342a724e61531b18fba1859bb6782e5d62 + React-Fabric: 69f1881f2177a8512304a64157943548ab6df0cf + React-FabricComponents: f54111c8e2439fc273ab07483e3a7054ca1e75af + React-FabricImage: 9ad2619dfe8c386d79e8aaa87da6e8f018ab9592 React-featureflags: b9cf9b35baca1c7f20c06a104ffc325a02752faa - React-featureflagsnativemodule: dc93d81da9f41f7132e24455ec8b4b60802fd5b0 - React-graphics: aaa5a38bea15d7b895b210d95d554af45a07002a - React-hermes: 08ad9fb832d1b9faef391be17309aa6a69fad23b - React-idlecallbacksnativemodule: aacea33ef6c511a9781f9286cc7cdf93f39bba14 - React-ImageManager: c596c3b658c9c14607f9183ed0f635c8dd77987c - React-jserrorhandler: 987609b2f16b7d79d63fcd621bf0110dd7400b35 - React-jsi: afa286d7e0c102c2478dc420d4f8935e13c973fc - React-jsiexecutor: 08f5b512b4db9e2f147416d60a0a797576b9cfef - React-jsinspector: 5a94bcae66e3637711c4d96a00038ab9ec935bf5 - React-jsinspectortracing: a12589a0adbb2703cbc4380dabe9a58800810923 - React-jsitracing: 0b1a403d7757cec66b7dd8b308d04db85eef75f3 - React-logger: 304814ae37503c8eb54359851cc55bd4f936b39c - React-Mapbuffer: b588d1ca18d2ce626f868f04ab12d8b1f004f12c - React-microtasksnativemodule: 11831d070aa47755bb5739069eb04ec621fec548 - react-native-config: 3367df9c1f25bb96197007ec531c7087ed4554c3 - react-native-safe-area-context: 9b169299f9dc95f1d7fe1dd266fde53bd899cd0c - react-native-slider: 27263d134d55db948a4706f1e47d0ec88fb354dd - react-native-webview: be9957759cb73cb64f2ed5359e32a85f1f5bdff8 - React-NativeModulesApple: 79a4404ac301b40bec3b367879c5e9a9ce81683c - React-perflogger: 0ea25c109dba33d47dec36b2634bf7ea67c1a555 - React-performancetimeline: f74480de6efbcd8541c34317c0baedb433f27296 + React-featureflagsnativemodule: 7f1bc76d1d2c5bede5e753b8d188dbde7c59b12f + React-graphics: 069e0d0b31ed1e80feb023ad4f7e97f00e84f7b9 + React-hermes: 63df5ac5a944889c8758a6213b39ed825863adb7 + React-idlecallbacksnativemodule: 4c700bd7c0012adf904929075a79418b828b5ffc + React-ImageManager: 5d1ba8a7bae44ebba43fc93da64937c713d42941 + React-jserrorhandler: 0defd58f8bb797cdd0a820f733bf42d8bee708ce + React-jsi: 99d6207ec802ad73473a0dad3c9ad48cd98463f6 + React-jsiexecutor: 8c8097b4ba7e7f480582d6e6238b01be5dcc01c0 + React-jsinspector: ea148ec45bc7ff830e443383ea715f9780c15934 + React-jsinspectortracing: 46bb2841982f01e7b63eaab98140fa1de5b2a1db + React-jsitracing: c1063fc2233960d1c8322291e74bca51d25c10d7 + React-logger: 763728cf4eebc9c5dc9bfc3649e22295784f69f3 + React-Mapbuffer: 63278529b5cf531a7eaf8fc71244fabb062ca90c + React-microtasksnativemodule: 6a39463c32ce831c4c2aa8469273114d894b6be9 + react-native-config: 644074ab88db883fcfaa584f03520ec29589d7df + react-native-safe-area-context: afcc2e2b3e78ae8ef90d81e658aacee34ebc27ea + react-native-slider: 310d3f89edd6ca8344a974bfe83a29a3fbb60e5a + react-native-webview: 80ef603d1df42e24fdde765686fbb9b8a6ecd554 + React-NativeModulesApple: fd0545efbb7f936f78edd15a6564a72d2c34bb32 + React-perflogger: 5f8fa36a8e168fb355efe72099efe77213bc2ac6 + React-performancetimeline: 8c0ecfa1ae459cc5678a65f95ac3bf85644d6feb React-RCTActionSheet: 2ef95837e89b9b154f13cd8401f9054fc3076aff - React-RCTAnimation: 33d960d7f58a81779eea6dea47ad0364c67e1517 - React-RCTAppDelegate: 85c13403fd6f6b6cc630428d52bd8bd76a670dc9 - React-RCTBlob: 74c986a02d951931d2f6ed0e07ed5a7eb385bfc0 - React-RCTFabric: 384a8fea4f22fc0f21299d771971862883ba630a - React-RCTFBReactNativeSpec: eb1c3ec5149f76133593a516ff9d5efe32ebcecd - React-RCTImage: 2c58b5ddeb3c65e52f942bbe13ff9c59bd649b09 - React-RCTLinking: b6b14f8a3e62c02fc627ac4f3fb0c7bd941f907c - React-RCTNetwork: 1d050f2466c1541b339587d46f78d5eee218d626 - React-RCTSettings: 8148f6be0ccc0cfe6e313417ebf8a479caaa2146 - React-RCTText: 64114531ad1359e4e02a4a8af60df606dbbabc25 - React-RCTVibration: f4859417a7dd859b6bf18b1aba897e52beb72ef6 + React-RCTAnimation: 46abefd5acfda7e6629f9e153646deecc70babd2 + React-RCTAppDelegate: 7e58e0299e304cceee3f7019fa77bc6990f66b22 + React-RCTBlob: f68c63a801ef1d27e83c4011e3b083cc86a200d7 + React-RCTFabric: c59f41d0c4edbaac8baa232731ca09925ae4dda7 + React-RCTFBReactNativeSpec: 3240b9b8d792aa4be0fb85c9898fc183125ba8de + React-RCTImage: 34e0bba1507e55f1c614bd759eb91d9be48c8c5b + React-RCTLinking: a0b6c9f4871c18b0b81ea952f43e752718bd5f1d + React-RCTNetwork: bdafd661ac2b20d23b779e45bf7ac3e4c8bd1b60 + React-RCTSettings: 98aa5163796f43789314787b584a84eba47787a9 + React-RCTText: 424a274fc9015b29de89cf3cbcdf4dd85dd69f83 + React-RCTVibration: 92d9875a955b0adb34b4b773528fdbbbc5addd6c React-rendererconsistency: 5ac4164ec18cfdd76ed5f864dbfdc56a5a948bc9 - React-rendererdebug: 3dc1d97bbee0c0c13191e501a96ed9325bbd920e + React-rendererdebug: 710dbd7990e355852c786aa6bc7753f6028f357a React-rncore: 0bace3b991d8843bb5b57c5f2301ec6e9c94718b - React-RuntimeApple: 1e1e0a0c6086bc8c3b07e8f1a2f6ca99b50419a0 - React-RuntimeCore: d39322c59bef2a4b343fda663d20649f29f57fcc + React-RuntimeApple: 701ec44a8b5d863ee9b6a2b2447b6a26bb6805a1 + React-RuntimeCore: a82767065b9a936b05e209dc6987bc1ea9eb5d2d React-runtimeexecutor: 876dfc1d8daa819dfd039c40f78f277c5a3e66a6 - React-RuntimeHermes: 44f5f2baf039f249b31ea4f3e224484fd1731e0e - React-runtimescheduler: 3b3c5b50743bb8743ca49b9e5a70c2c385f156e1 + React-RuntimeHermes: e7a051fd91cab8849df56ac917022ef6064ad621 + React-runtimescheduler: c544141f2124ee3d5f3d5bf0d69f4029a61a68b0 React-timing: 1ee3572c398f5579c9df5bf76aacddf5683ff74e - React-utils: 0cfb7c7fb37d4e5f31cc18ffc7426be0ae6bf907 - ReactAppDependencyProvider: b48473fe434569ff8f6cb6ed4421217ebcbda878 - ReactCodegen: 653a0d8532d8c7dab50c391392044d98e20c9f79 - ReactCommon: 547db015202a80a5b3e7e041586ea54c4a087180 - RNCPicker: ffbd7b9fc7c1341929e61dbef6219f7860f57418 - RNScreens: 0f01bbed9bd8045a8d58e4b46993c28c7f498f3c - RNSVG: 082ae2874b288a96c95326c4f6029793f7009d4b + React-utils: 18703928768cb37e70cf2efff09def12d74a399e + ReactAppDependencyProvider: 4893bde33952f997a323eb1a1ee87a72764018ff + ReactCodegen: da30aff1cea9b5993dcbc33bf1ef47a463c55194 + ReactCommon: 865ebe76504a95e115b6229dd00a31e56d2d4bfe + RNCPicker: cfb51a08c6e10357d9a65832e791825b0747b483 + RNScreens: 790123c4a28783d80a342ce42e8c7381bed62db1 + RNSVG: 8ad5c94593e5e4a6e133e6934e2188e3c688d27a SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 Yoga: e14bad835e12b6c7e2260fc320bd00e0f4b45add PODFILE CHECKSUM: d0574c1c0410627561bf4b1b55d0f1754283e022 -COCOAPODS: 1.16.2 +COCOAPODS: 1.17.0 diff --git a/benchmarks/metro.config.js b/benchmarks/metro.config.js index 80b2747c5..21a754986 100644 --- a/benchmarks/metro.config.js +++ b/benchmarks/metro.config.js @@ -47,10 +47,28 @@ const config = { ) ) ), - extraNodeModules: modules.reduce((acc, name) => { - acc[name] = path.join(__dirname, 'node_modules', name); - return acc; - }, {}) + extraNodeModules: { + ...modules.reduce((acc, name) => { + acc[name] = path.join(__dirname, 'node_modules', name); + return acc; + }, {}), + // H4: alias configured directly in metro.config.js (rather than + // babel-plugin-module-resolver/tsconfig.json), exercising + // buildSvgMap's resolver.extraNodeModules alias path. Unscoped + // (no leading '@') so it matches for any subpath depth -- Metro's + // own parsing only splits a scoped key at a *second* slash, so + // e.g. '@metroAssets/star.svg' wouldn't match a key of just + // '@metroAssets' (see pathAliasResolver.ts). + // + // Points into @react-native/debugger-frontend (a real npm + // dependency, not a workspace symlink) for the same reason as + // the babel-plugin-module-resolver aliases above -- see + // babel.config.js. + metroAssets: path.join( + __dirname, + 'node_modules/@react-native/debugger-frontend/dist/third-party/front_end/Images' + ) + } }, }; diff --git a/benchmarks/src/scenario/SessionReplay/component/Svg.tsx b/benchmarks/src/scenario/SessionReplay/component/Svg.tsx index 20a4d7c2b..3e44c50c8 100644 --- a/benchmarks/src/scenario/SessionReplay/component/Svg.tsx +++ b/benchmarks/src/scenario/SessionReplay/component/Svg.tsx @@ -26,6 +26,17 @@ import { HeartIcon, ShieldIcon } from './assets/icons'; // Aliased via the 'module-resolver' babel plugin (see benchmarks/babel.config.js) — // tests that buildSvgMap resolves aliased local SVG imports (RUM-12185). import AliasedStarSvg from '@assets/star.svg'; +// H2/H3/H4 alias into @react-native/debugger-frontend -- a real npm +// dependency (not a workspace symlink), so the Babel plugin's node_modules +// exclusion applies to it, meaning localSvgMap/pathAliasResolver is the +// only thing that can make these show up wrapped in Session Replay (see +// babel.config.js for why this matters). +// H2: alias resolves straight to a file -- specifier has no '.svg' extension. +import CheckmarkLogo from '@heart-logo'; +// H3: alias substitute is an absolute filesystem path. +import AbsoluteAliasedLock from '@absoluteAssets/lock.svg'; +// H4: alias configured in metro.config.js's resolver.extraNodeModules. +import MetroAliasedGear from 'metroAssets/gear-filled.svg'; // Module-level const used in Case D1 to test findIdentifierInScope const BADGE_SIZE = 72; @@ -354,6 +365,24 @@ function AliasedStarImport() { return ; } +/** H2: Alias substitute maps straight to a file, so the specifier itself + * ('@heart-logo') carries no '.svg' extension at all. */ +function AliasedExtensionlessImport() { + return ; +} + +/** H3: Alias substitute is an absolute filesystem path rather than one + * relative to the importing file. */ +function AbsoluteAliasImport() { + return ; +} + +/** H4: Alias configured in metro.config.js's resolver.extraNodeModules, + * rather than via babel-plugin-module-resolver or tsconfig.json. */ +function MetroExtraNodeModulesImport() { + return ; +} + // ───────────────────────────────────────────────────────────── // GROUP I — Unsupported nested elements // AnimatedPath isn't a recognized SVG tag, so it's now spliced out of the tree @@ -446,7 +475,7 @@ export default function SvgTestCases() { Group E: known limitation — absent from replay entirely (see comment).{'\n'} Group F: appears after buildSvgMap fixes.{'\n'} Group G: privacy overrides — verify masking behavior in replay.{'\n'} - Group H: aliased import — same star as F1, resolved via '@assets' alias.{'\n'} + Group H: aliased imports — resolved via module-resolver, absolute-path, and metro.config.js aliases.{'\n'} Group I: I1 shows circle only (checkmark removed), I2 shows circle + checkmark. @@ -538,9 +567,18 @@ export default function SvgTestCases() {
- + + + + + + + + + +
{/* ─── GROUP G — Privacy interaction ─── */} diff --git a/packages/react-native-babel-plugin/package.json b/packages/react-native-babel-plugin/package.json index 7e5e191e6..4e1398d45 100644 --- a/packages/react-native-babel-plugin/package.json +++ b/packages/react-native-babel-plugin/package.json @@ -61,7 +61,7 @@ "@swc/core": "^1.13.21", "@swc/jest": "^0.2.38", "@types/jest": "^30.0.0", - "babel-plugin-module-resolver": "^5.0.2", + "babel-plugin-module-resolver": "5.0.2", "jest": "^29.7.0", "react-native-builder-bob": "0.26.0", "tsc-alias": "^1.8.16", 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 d4113401e..3517a4db5 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 @@ -33,6 +33,17 @@ const DEFAULT_SCAN_IGNORE_PATTERNS = [ '**/*.config.js' ]; +// Mirrors the early-exit check in PathAliasResolver.resolve() -- a relative +// or absolute specifier can never resolve through it (it guarantees `null` +// for both), so there's no point deferring one into pendingBareSources at +// all. Relative component imports rendered as JSX (`import Foo from +// './Foo'; `) are the single most common React import pattern, so +// skipping this eagerly avoids a closure allocation and a second-pass +// iteration for the overwhelming majority of a typical project's imports. +function isBareSpecifier(source: string): boolean { + return source[0] !== '.' && !pathN.isAbsolute(source); +} + /** * Internal processor responsible for detecting, transforming, and wrapping * React Native SVG components for use with Session Replay. @@ -117,6 +128,27 @@ export class ReactNativeSVG { followSymbolicLinks: this.followSymlinks }); + // An extensionless aliased import (e.g. `@logo` -> a .svg with no + // extension in the specifier) can only be told apart from an + // ordinary bare import (e.g. 'react') by actually attempting alias + // resolution, which is expensive per file (it can trigger + // @babel/core's loadPartialConfig()). Running that for every bare + // import in every file -- most of which are never SVGs -- would + // regress badly on large codebases. So this defers alias resolution + // for non-'.svg'-suffixed sources to a second pass, run only for + // import/export names that are provably rendered as JSX somewhere + // in the project (checked project-wide, not per file, since a + // barrel re-export and its JSX usage can live in different files -- + // see the barrel-export tests). An import never rendered as JSX + // could never be looked up via localSvgMap anyway. + const pendingBareSources: Array<{ + file: string; + source: string; + candidateNames: string[]; + populate: (resolved: string) => void; + }> = []; + const usedJsxNames = new Set(); + for (const file of files) { try { const code = fs.readFileSync(file, 'utf8'); @@ -136,57 +168,119 @@ export class ReactNativeSVG { }); traverse(ast, { + JSXOpeningElement: path => { + if (!this.t) { + return; + } + const name = getNodeName(this.t, path.node.name); + if (name) { + usedJsxNames.add(name); + } + }, ImportDeclaration: path => { if (!this.t) { return; } const source = path.node.source.value; - if (!source.endsWith('.svg')) { + + if (source.endsWith('.svg')) { + const resolved = this.resolveImportSource( + file, + source + ); + for (const spec of path.node.specifiers) { + const name = getNodeName( + this.t, + spec.local.name + ); + if (name) { + this.localSvgMap[name] = { + path: resolved + }; + } + } + return; + } + + if (!isBareSpecifier(source)) { return; } - const resolved = this.resolveImportSource(file, source); + const candidateNames: string[] = []; for (const spec of path.node.specifiers) { const name = getNodeName(this.t, spec.local.name); if (name) { - this.localSvgMap[name] = { - path: resolved - }; + candidateNames.push(name); } } + if (!candidateNames.length) { + return; + } + + pendingBareSources.push({ + file, + source, + candidateNames, + populate: resolved => { + for (const name of candidateNames) { + this.localSvgMap[name] = { + path: resolved + }; + } + } + }); }, ExportNamedDeclaration: path => { if (!this.t) { return; } const source = path.node.source?.value; - if (!source?.endsWith('.svg')) { + if (!source) { return; } - const resolved = this.resolveImportSource(file, source); + if (source.endsWith('.svg')) { + const resolved = this.resolveImportSource( + file, + source + ); + this.populateExportedSvgNames(path, resolved); + return; + } + + if (!isBareSpecifier(source)) { + return; + } + + const candidateNames: string[] = []; 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 { - console.warn( - `[buildSvgMap]: Unhandled export specifier type: ${spec.type}` - ); + if (spec.type !== 'ExportSpecifier') { + continue; + } + // 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) { + candidateNames.push(name); } } + if (!candidateNames.length) { + return; + } + + pendingBareSources.push({ + file, + source, + candidateNames, + populate: resolved => + this.populateExportedSvgNames(path, resolved) + }); } }); } catch (err) { @@ -194,6 +288,27 @@ export class ReactNativeSVG { } } + // Second pass: only now attempt the (potentially expensive) alias + // resolution, and only for sources with at least one candidate name + // that's actually used as JSX somewhere in the project. + for (const pending of pendingBareSources) { + if ( + !pending.candidateNames.some(name => usedJsxNames.has(name)) + ) { + continue; + } + + const resolved = this.resolveSvgImportSource( + pending.file, + pending.source + ); + if (!resolved) { + continue; + } + + pending.populate(resolved); + } + // Save the mapping to disk if requested if (this.saveSvgMapToDisk) { try { @@ -214,6 +329,41 @@ export class ReactNativeSVG { } } + /** Populates `localSvgMap` for each `ExportSpecifier` on an + * `export { ... } from '...svg'` declaration once its source has been + * resolved to a real `.svg` path. */ + private populateExportedSvgNames( + path: Babel.NodePath, + resolved: string + ): void { + if (!this.t) { + return; + } + + 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 { + console.warn( + `[buildSvgMap]: Unhandled export specifier type: ${spec.type}` + ); + } + } + } + private resolveImportSource(file: string, source: string): string { return ( this.pathAliasResolver.resolve(source, file) ?? @@ -221,6 +371,26 @@ export class ReactNativeSVG { ); } + /** + * Returns the resolved `.svg` file path for an import/export source, or + * `null` if it isn't an SVG import. A source that already ends in `.svg` + * is resolved directly; otherwise it may still be an aliased specifier + * (e.g. `alias: { '@logo': './src/assets/logo.svg' }` used as + * `import Logo from '@logo'`) whose specifier itself carries no + * extension, so alias resolution is attempted before giving up. + */ + private resolveSvgImportSource( + file: string, + source: string + ): string | null { + if (source.endsWith('.svg')) { + return this.resolveImportSource(file, source); + } + + const aliased = this.pathAliasResolver.resolve(source, file); + return aliased?.endsWith('.svg') ? aliased : null; + } + /** * Processes a JSXElement representing an SVG-based component and transforms it into * a web-compliant SVG string with normalized attributes and extracted dimensions. diff --git a/packages/react-native-babel-plugin/src/libraries/react-native-svg/pathAliasResolver.ts b/packages/react-native-babel-plugin/src/libraries/react-native-svg/pathAliasResolver.ts index 0bc823eb1..cb05f5e5c 100644 --- a/packages/react-native-babel-plugin/src/libraries/react-native-svg/pathAliasResolver.ts +++ b/packages/react-native-babel-plugin/src/libraries/react-native-svg/pathAliasResolver.ts @@ -5,6 +5,7 @@ */ import * as babelCore from '@babel/core'; +import fs from 'fs'; import pathN from 'path'; import { createMatchPath, loadConfig } from 'tsconfig-paths'; import type { MatchPath } from 'tsconfig-paths'; @@ -37,10 +38,44 @@ function isRelativePath(value: string): boolean { return /^\.?\.\//.test(value); } +/** + * Splits a bare import specifier into the "package name" Metro's own + * resolver (`metro-resolver`'s `parseBareSpecifier`) would use to look it up + * in `resolver.extraNodeModules`, and the remaining subpath. Scoped-looking + * specifiers (starting with `@`) only split after their *second* path + * segment -- `@scope/pkg/sub` maps package name `@scope/pkg`, but `@scope/sub` + * (only one slash) has no further segment to split on, so the whole + * specifier is the package name, exactly mirroring Metro's own behavior. + */ +function parseExtraNodeModulesSpecifier(specifier: string): { + packageName: string; + subpath: string; +} { + const firstSlash = specifier.indexOf('/'); + if (specifier[0] === '@' && firstSlash !== -1) { + const secondSlash = specifier.indexOf('/', firstSlash + 1); + if (secondSlash === -1) { + return { packageName: specifier, subpath: '' }; + } + return { + packageName: specifier.slice(0, secondSlash), + subpath: specifier.slice(secondSlash) + }; + } + if (firstSlash === -1) { + return { packageName: specifier, subpath: '' }; + } + return { + packageName: specifier.slice(0, firstSlash), + subpath: specifier.slice(firstSlash) + }; +} + /** * Resolves non-relative import specifiers (e.g. `@components/Logo`) against a - * project's `babel-plugin-module-resolver` config and/or its - * `tsconfig.json`/`jsconfig.json` `paths` mapping, so aliased local SVG + * project's `babel-plugin-module-resolver` config, its + * `tsconfig.json`/`jsconfig.json` `paths` mapping, and/or its + * `metro.config.js` `resolver.extraNodeModules` map, so aliased local SVG * imports can be found on disk the same way they resolve at runtime. * * Callers should still fall back to plain relative resolution when this @@ -56,6 +91,8 @@ export class PathAliasResolver { private tsMatchPath: MatchPath | null | undefined; + private metroExtraNodeModules: Record | null | undefined; + private resultCache = new Map(); constructor(rootDir: string) { @@ -68,6 +105,7 @@ export class PathAliasResolver { reset(): void { this.moduleResolverBindings.clear(); this.tsMatchPath = undefined; + this.metroExtraNodeModules = undefined; this.resultCache.clear(); } @@ -84,7 +122,8 @@ export class PathAliasResolver { const resolved = this.resolveWithModuleResolver(importSource, currentFile) ?? - this.resolveWithTsconfigPaths(importSource); + this.resolveWithTsconfigPaths(importSource) ?? + this.resolveWithMetroExtraNodeModules(importSource); this.resultCache.set(cacheKey, resolved); return resolved; } @@ -108,7 +147,18 @@ export class PathAliasResolver { currentFile, binding.options ); - if (!resolved || !isRelativePath(resolved)) { + if (!resolved) { + return null; + } + + // An `alias` entry can map to an absolute path directly (e.g. + // `alias: { '@app': path.resolve(__dirname, 'src') }`), not just a + // path relative to the importing file -- return it as-is. + if (pathN.isAbsolute(resolved)) { + return resolved; + } + + if (!isRelativePath(resolved)) { return null; } @@ -131,6 +181,66 @@ export class PathAliasResolver { return matchPath(importSource) ?? null; } + private resolveWithMetroExtraNodeModules( + importSource: string + ): string | null { + const extraNodeModules = this.getMetroExtraNodeModules(); + if (!extraNodeModules) { + return null; + } + + const { packageName, subpath } = parseExtraNodeModulesSpecifier( + importSource + ); + const target = extraNodeModules[packageName]; + if (!target) { + return null; + } + + return subpath ? pathN.join(target, subpath) : target; + } + + private getMetroExtraNodeModules(): Record | null { + if (this.metroExtraNodeModules !== undefined) { + return this.metroExtraNodeModules; + } + + try { + const configPath = ['metro.config.js', 'metro.config.cjs'] + .map(name => pathN.join(this.rootDir, name)) + .find(candidate => fs.existsSync(candidate)); + + if (!configPath) { + this.metroExtraNodeModules = null; + return null; + } + + // Unlike loadPartialConfig()/loadConfig() below (which read their + // config files fresh from disk each call), require() caches by + // resolved filename -- drop any cached entry first so an edit to + // metro.config.js made since this was last required is picked up + // after reset(), instead of silently reusing a stale module. + delete require.cache[require.resolve(configPath)]; + // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require, import/no-dynamic-require + const config = require(configPath) as { + resolver?: { extraNodeModules?: unknown }; + }; + const extraNodeModules = config?.resolver?.extraNodeModules; + this.metroExtraNodeModules = + extraNodeModules && typeof extraNodeModules === 'object' + ? (extraNodeModules as Record) + : null; + } catch (err) { + console.warn( + '[PathAliasResolver]: Failed to load metro.config.js, aliased SVG imports may not resolve', + err + ); + this.metroExtraNodeModules = null; + } + + return this.metroExtraNodeModules; + } + private getTsMatchPath(): MatchPath | null { if (this.tsMatchPath !== undefined) { return this.tsMatchPath; 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 c50f3f7b4..7595f2cd3 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 @@ -17,6 +17,7 @@ import path from 'path'; import plugin from '../src/index'; import { RNSvgHandler } from '../src/libraries/react-native-svg/handlers/RNSvgHandler'; import { ReactNativeSVG } from '../src/libraries/react-native-svg'; +import { PathAliasResolver } from '../src/libraries/react-native-svg/pathAliasResolver'; /** * Helper function to test SVG transformation @@ -1561,4 +1562,343 @@ describe('ReactNativeSVG.buildSvgMap with aliased paths', () => { path.join(tmpDir, 'src', 'components', 'icon.svg') ); }); + + it('should resolve an aliased import whose specifier has no .svg extension (alias points directly at the file)', () => { + const moduleResolverPath = require.resolve( + 'babel-plugin-module-resolver' + ); + fs.writeFileSync( + path.join(tmpDir, 'babel.config.js'), + `module.exports = { + plugins: [ + [${JSON.stringify(moduleResolverPath)}, { + alias: { '@logo': './src/components/icon.svg' } + }] + ] + };` + ); + fs.writeFileSync( + path.join(tmpDir, 'Component.tsx'), + `import Logo from '@logo';\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, 'src', 'components', 'icon.svg') + ); + }); + + it('should resolve an extensionless aliased re-export even when the JSX usage is in a different file (performance guard checks JSX usage project-wide, not per file)', () => { + const moduleResolverPath = require.resolve( + 'babel-plugin-module-resolver' + ); + fs.writeFileSync( + path.join(tmpDir, 'babel.config.js'), + `module.exports = { + plugins: [ + [${JSON.stringify(moduleResolverPath)}, { + alias: { '@logo': './src/components/icon.svg' } + }] + ] + };` + ); + // The extensionless-aliased import lives in a barrel file with no + // JSX at all -- only Screen.tsx (a separate file) ever renders it. + fs.writeFileSync( + path.join(tmpDir, 'icons.ts'), + `export { default as Logo } from '@logo';` + ); + fs.writeFileSync( + path.join(tmpDir, 'Screen.tsx'), + `import { Logo } from './icons';\nexport default function Screen() { 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, 'src', 'components', 'icon.svg') + ); + }); + + it('should NOT populate localSvgMap for an extensionless aliased import that is never rendered as JSX anywhere in the project (performance guard skips alias resolution)', () => { + const moduleResolverPath = require.resolve( + 'babel-plugin-module-resolver' + ); + fs.writeFileSync( + path.join(tmpDir, 'babel.config.js'), + `module.exports = { + plugins: [ + [${JSON.stringify(moduleResolverPath)}, { + alias: { '@logo': './src/components/icon.svg' } + }] + ] + };` + ); + fs.writeFileSync( + path.join(tmpDir, 'Component.tsx'), + `import UnusedLogo from '@logo';\nexport default function C() { return null; }` + ); + + const instance = new ReactNativeSVG(tmpDir, tmpDir, false); + instance.setApiTypes(t); + instance.buildSvgMap(); + + expect(instance.localSvgMap['UnusedLogo']).toBeUndefined(); + }); + + it('should never attempt alias resolution for a relative or absolute import, even when its name is rendered as JSX (they can never resolve via PathAliasResolver, so deferring them would be pure overhead)', () => { + const resolveSpy = jest.spyOn( + PathAliasResolver.prototype, + 'resolve' + ); + + fs.writeFileSync( + path.join(tmpDir, 'Icon.tsx'), + `export default function Icon() { return null; }` + ); + fs.writeFileSync( + path.join(tmpDir, 'Component.tsx'), + `import Icon from './Icon';\nexport default function C() { return ; }` + ); + + const instance = new ReactNativeSVG(tmpDir, tmpDir, false); + instance.setApiTypes(t); + instance.buildSvgMap(); + + expect(resolveSpy).not.toHaveBeenCalled(); + expect(instance.localSvgMap['Icon']).toBeUndefined(); + + resolveSpy.mockRestore(); + }); + + it('should resolve an alias that maps directly to an absolute path', () => { + const moduleResolverPath = require.resolve( + 'babel-plugin-module-resolver' + ); + const absoluteIconPath = path.join( + tmpDir, + 'src', + 'components', + 'icon.svg' + ); + fs.writeFileSync( + path.join(tmpDir, 'babel.config.js'), + `module.exports = { + plugins: [ + [${JSON.stringify(moduleResolverPath)}, { + alias: { '@logo': ${JSON.stringify( + absoluteIconPath + )} } + }] + ] + };` + ); + fs.writeFileSync( + path.join(tmpDir, 'Component.tsx'), + `import Logo from '@logo';\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(absoluteIconPath); + }); + + it('should resolve an aliased import using metro.config.js resolver.extraNodeModules', () => { + fs.writeFileSync( + path.join(tmpDir, 'metro.config.js'), + `module.exports = { + resolver: { + extraNodeModules: { + assets: ${JSON.stringify( + path.join(tmpDir, 'src', 'components') + )} + } + } + };` + ); + fs.writeFileSync( + path.join(tmpDir, 'Component.tsx'), + `import Logo from 'assets/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, 'src', 'components', 'icon.svg') + ); + }); + + it('should resolve a scoped extraNodeModules alias only when the key matches the full specifier (single subpath segment), mirroring Metro\'s own parsing', () => { + fs.writeFileSync( + path.join(tmpDir, 'metro.config.js'), + `module.exports = { + resolver: { + extraNodeModules: { + '@assets/icon.svg': ${JSON.stringify( + path.join(tmpDir, 'src', 'components', 'icon.svg') + )} + } + } + };` + ); + fs.writeFileSync( + path.join(tmpDir, 'Component.tsx'), + `import Logo from '@assets/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, 'src', 'components', 'icon.svg') + ); + }); + + it('should not match a scoped extraNodeModules alias against a one-slash specifier when the key is only the scope segment, mirroring Metro\'s own parsing', () => { + fs.writeFileSync( + path.join(tmpDir, 'metro.config.js'), + `module.exports = { + resolver: { + extraNodeModules: { + '@assets': ${JSON.stringify( + path.join(tmpDir, 'src', 'components') + )} + } + } + };` + ); + fs.writeFileSync( + path.join(tmpDir, 'Component.tsx'), + `import Logo from '@assets/icon.svg';\nexport default function C() { return ; }` + ); + + const instance = new ReactNativeSVG(tmpDir, tmpDir, false); + instance.setApiTypes(t); + instance.buildSvgMap(); + + // No extraNodeModules key matches the full '@assets/icon.svg' specifier + // (per Metro's own parsing, '@assets' alone isn't a match), so this + // falls back to unresolved relative resolution like any other + // unmatched alias -- same fallback as the plain non-relative-import case. + expect(instance.localSvgMap['Logo'].path).toBe( + path.resolve(tmpDir, '@assets/icon.svg') + ); + }); + + it('should prefer babel-plugin-module-resolver/tsconfig.json over metro.config.js when both are configured', () => { + const moduleResolverPath = require.resolve( + 'babel-plugin-module-resolver' + ); + fs.writeFileSync( + path.join(tmpDir, 'babel.config.js'), + `module.exports = { + plugins: [ + [${JSON.stringify(moduleResolverPath)}, { + alias: { '@shared': './src/components' } + }] + ] + };` + ); + fs.mkdirSync(path.join(tmpDir, 'other'), { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, 'other', 'icon.svg'), + '' + ); + fs.writeFileSync( + path.join(tmpDir, 'metro.config.js'), + `module.exports = { + resolver: { + extraNodeModules: { + '@shared': ${JSON.stringify( + path.join(tmpDir, 'other') + )} + } + } + };` + ); + fs.writeFileSync( + path.join(tmpDir, 'Component.tsx'), + `import Logo from '@shared/icon.svg';\nexport default function C() { return ; }` + ); + + const instance = new ReactNativeSVG(tmpDir, tmpDir, false); + instance.setApiTypes(t); + instance.buildSvgMap(); + + expect(instance.localSvgMap['Logo'].path).toBe( + path.join(tmpDir, 'src', 'components', 'icon.svg') + ); + }); + + it('should pick up an edit to metro.config.js resolver.extraNodeModules after reset(), rather than reusing a require()-cached module', () => { + fs.mkdirSync(path.join(tmpDir, 'other'), { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, 'other', 'icon.svg'), + '' + ); + fs.writeFileSync( + path.join(tmpDir, 'metro.config.js'), + `module.exports = { + resolver: { + extraNodeModules: { + assets: ${JSON.stringify( + path.join(tmpDir, 'src', 'components') + )} + } + } + };` + ); + fs.writeFileSync( + path.join(tmpDir, 'Component.tsx'), + `import Logo from 'assets/icon.svg';\nexport default function C() { return ; }` + ); + + const instance = new ReactNativeSVG(tmpDir, tmpDir, false); + instance.setApiTypes(t); + instance.buildSvgMap(); + + expect(instance.localSvgMap['Logo'].path).toBe( + path.join(tmpDir, 'src', 'components', 'icon.svg') + ); + + // Edit metro.config.js in place (same path -- require()'s module + // cache is keyed by resolved filename, so this only gets picked up + // if the cache entry is dropped before re-requiring). jest.resetModules() + // clears Jest's own sandboxed module registry, which doesn't otherwise + // track this source's own require.cache manipulation the same way + // plain Node does -- without it, this test would pass regardless of + // whether the source actually clears require.cache itself. + jest.resetModules(); + fs.writeFileSync( + path.join(tmpDir, 'metro.config.js'), + `module.exports = { + resolver: { + extraNodeModules: { + assets: ${JSON.stringify(path.join(tmpDir, 'other'))} + } + } + };` + ); + + instance.buildSvgMap(); + + expect(instance.localSvgMap['Logo'].path).toBe( + path.join(tmpDir, 'other', 'icon.svg') + ); + }); }); diff --git a/yarn.lock b/yarn.lock index ce5bd3417..55992dd31 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2039,7 +2039,7 @@ __metadata: "@swc/core": ^1.13.21 "@swc/jest": ^0.2.38 "@types/jest": ^30.0.0 - babel-plugin-module-resolver: ^5.0.2 + babel-plugin-module-resolver: 5.0.2 fast-glob: ^3.3.3 jest: ^29.7.0 react-native-builder-bob: 0.26.0 @@ -6620,19 +6620,6 @@ __metadata: languageName: node linkType: hard -"babel-plugin-module-resolver@npm:^5.0.2": - version: 5.0.3 - resolution: "babel-plugin-module-resolver@npm:5.0.3" - dependencies: - find-babel-config: ^2.1.1 - glob: ^9.3.3 - pkg-up: ^3.1.0 - reselect: ^4.1.7 - resolve: ^1.22.8 - checksum: a03a614d8a6d7eedb2f7a764503e28e41824d983c4100203c180523d4dbda8fd12d2679b8e1c769c0426595d581e818696fffb10bacf20bdb487a46acdebece3 - languageName: node - linkType: hard - "babel-plugin-polyfill-corejs2@npm:^0.4.10, babel-plugin-polyfill-corejs2@npm:^0.4.15": version: 0.4.17 resolution: "babel-plugin-polyfill-corejs2@npm:0.4.17"