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 d79333729..267288b75 100644
--- a/benchmarks/babel.config.js
+++ b/benchmarks/babel.config.js
@@ -1,6 +1,33 @@
+const path = require('path');
+
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',
+ // 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', {
sessionReplay: {
svgTracking: true
diff --git a/benchmarks/ios/Podfile.lock b/benchmarks/ios/Podfile.lock
index 577819ad4..0feab3e10 100644
--- a/benchmarks/ios/Podfile.lock
+++ b/benchmarks/ios/Podfile.lock
@@ -2161,68 +2161,68 @@ SPEC CHECKSUMS:
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/package.json b/benchmarks/package.json
index d208e29fd..ace8ebfd2 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..3e44c50c8 100644
--- a/benchmarks/src/scenario/SessionReplay/component/Svg.tsx
+++ b/benchmarks/src/scenario/SessionReplay/component/Svg.tsx
@@ -23,6 +23,20 @@ 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';
+// 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;
@@ -340,6 +354,35 @@ 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 ;
+}
+
+/** 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
@@ -432,6 +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 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.
@@ -522,6 +566,21 @@ 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 19e4ca920..75e5e183e 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.1.0",
+ "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/index.ts b/packages/react-native-babel-plugin/src/index.ts
index 54ff86bcd..b58b4fdf4 100644
--- a/packages/react-native-babel-plugin/src/index.ts
+++ b/packages/react-native-babel-plugin/src/index.ts
@@ -153,7 +153,22 @@ export default declare(
options
);
- pluginState.reactNativeSVG?.processItem(path, name);
+ // `currentFile` only matters for disambiguating a
+ // local/aliased SVG lookup (see `getLocalSvgEntry`) --
+ // the built-in `` tag (`RNSvgHandler`) needs no
+ // file context at all, so `processItem` must still run
+ // when `state.filename` is unset (e.g. an in-memory
+ // `transform()` call with no `filename` option) rather
+ // than skipping SVG processing for the whole file. An
+ // empty string can never match a real file's
+ // `nodeKey`, so this just falls through to the flat,
+ // name-only `localSvgMap` lookup -- the same behavior
+ // this file had before `currentFile` existed.
+ pluginState.reactNativeSVG?.processItem(
+ path,
+ name,
+ state.filename ?? ''
+ );
}
}
};
diff --git a/packages/react-native-babel-plugin/src/libraries/react-native-svg/buildSvgMap.ts b/packages/react-native-babel-plugin/src/libraries/react-native-svg/buildSvgMap.ts
new file mode 100644
index 000000000..910daeae1
--- /dev/null
+++ b/packages/react-native-babel-plugin/src/libraries/react-native-svg/buildSvgMap.ts
@@ -0,0 +1,683 @@
+/*
+ * 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 type * as Babel from '@babel/core';
+import * as parser from '@babel/parser';
+import traverse from '@babel/traverse';
+import glob from 'fast-glob';
+import fs from 'fs';
+import pathN from 'path';
+
+import { getNodeName } from '../../utils';
+
+import { exportNodeKey, nodeKey, splitNodeKey } from './nodeKeys';
+import type { PathAliasResolver } from './pathAliasResolver';
+import {
+ isBareSpecifier,
+ resolveModuleFileFromBase,
+ resolveRelativeModuleFile
+} from './pathAliasResolver';
+import { getExportedName, getLocalName } from './specifierNames';
+import type { SvgBindingMap } from './svgBindingMap';
+
+/** Resolves an import/export source that already ends in `.svg`. Returns
+ * `null` when `source` is a bare specifier (e.g. a typo'd or unconfigured
+ * alias) that no alias mechanism could resolve -- treating it as relative
+ * to the importing file's directory in that case would fabricate a path
+ * that's guaranteed not to exist on disk. */
+function resolveImportSource(
+ pathAliasResolver: PathAliasResolver,
+ file: string,
+ source: string
+): string | null {
+ const aliasResolved = pathAliasResolver.resolve(source, file);
+ if (aliasResolved) {
+ return aliasResolved;
+ }
+ if (isBareSpecifier(source)) {
+ return null;
+ }
+ return pathN.resolve(pathN.dirname(file), source);
+}
+
+/** 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. */
+function resolveSvgImportSource(
+ pathAliasResolver: PathAliasResolver,
+ file: string,
+ source: string
+): string | null {
+ if (source.endsWith('.svg')) {
+ return resolveImportSource(pathAliasResolver, file, source);
+ }
+
+ const aliased = pathAliasResolver.resolve(source, file);
+ return aliased?.endsWith('.svg') ? aliased : null;
+}
+
+/** Records a `bindingEdges` entry for each specifier of a relative
+ * `import ... from './somewhere'` -- mapping the LOCAL name in `file` to
+ * the EXPORT-table entry it's bound to in the target file (an import
+ * always reaches into what its source module exports, never that
+ * module's own local scope), so a JSX usage of the local name can be
+ * traced through to whatever ultimately defines it (a bare/aliased SVG
+ * import, another barrel file, etc). Namespace imports (`import * as X`)
+ * aren't tracked: JSX would reference them via a member expression
+ * (` `), which isn't a plain name this graph models. */
+function recordRelativeImportEdges(
+ t: typeof Babel.types,
+ file: string,
+ source: string,
+ specifiers: Babel.types.ImportDeclaration['specifiers'],
+ bindingEdges: Map
+): void {
+ const targetFile = resolveRelativeModuleFile(file, source);
+ if (!targetFile) {
+ return;
+ }
+
+ for (const spec of specifiers) {
+ const localName = getLocalName(t, spec.local);
+ if (!localName) {
+ continue;
+ }
+
+ let importedName: string | null = null;
+ if (t.isImportDefaultSpecifier(spec)) {
+ importedName = 'default';
+ } else if (t.isImportSpecifier(spec)) {
+ importedName = getNodeName(
+ t,
+ t.isStringLiteral(spec.imported)
+ ? spec.imported.value
+ : spec.imported.name
+ );
+ }
+
+ if (!importedName) {
+ continue;
+ }
+
+ bindingEdges.set(
+ nodeKey(file, localName),
+ exportNodeKey(targetFile, importedName)
+ );
+ }
+}
+
+/** Shared specifier-walking logic behind `recordRelativeReexportEdges` and
+ * `recordLocalReexportEdges` below -- both record one `bindingEdges` entry
+ * per `ExportSpecifier`, mapping `file`'s own EXPORT-table entry
+ * (`exportNodeKey(file, exportedName)`) to a target key built from the
+ * specifier's local name. Only how that target key is built differs
+ * between the two callers (another file's own export-table entry vs. a
+ * same-file local binding), so that's the one thing left as a
+ * parameter. */
+function recordExportSpecifierEdges(
+ t: typeof Babel.types,
+ file: string,
+ specifiers: Babel.types.ExportNamedDeclaration['specifiers'],
+ bindingEdges: Map,
+ targetKeyFor: (localName: string) => string
+): void {
+ for (const spec of specifiers) {
+ if (spec.type !== 'ExportSpecifier') {
+ continue;
+ }
+
+ const exportedName = getExportedName(t, spec.exported);
+ const localName = getLocalName(t, spec.local);
+ if (!exportedName || !localName) {
+ continue;
+ }
+
+ bindingEdges.set(
+ exportNodeKey(file, exportedName),
+ targetKeyFor(localName)
+ );
+ }
+}
+
+/** Records a `bindingEdges` entry for each specifier of a relative
+ * `export { ... } from './somewhere'` re-export -- mapping `file`'s own
+ * EXPORT-table entry to the EXPORT-table entry it re-exports from the
+ * target file (both ends are export-table entries: this declaration
+ * doesn't reference or create any local binding in either file), the
+ * mirror image of `recordRelativeImportEdges` above for barrel files. */
+function recordRelativeReexportEdges(
+ t: typeof Babel.types,
+ file: string,
+ source: string,
+ specifiers: Babel.types.ExportNamedDeclaration['specifiers'],
+ bindingEdges: Map
+): void {
+ const targetFile = resolveRelativeModuleFile(file, source);
+ if (!targetFile) {
+ return;
+ }
+
+ recordExportSpecifierEdges(t, file, specifiers, bindingEdges, localName =>
+ exportNodeKey(targetFile, localName)
+ );
+}
+
+/** Records a same-file `bindingEdges` entry for each specifier of a local
+ * re-export with no `from` source (e.g. `import Something from '@alias';
+ * export { Something as LocalComponent };`) -- maps `file`'s own
+ * EXPORT-table entry `LocalComponent` to its LOCAL binding `Something`
+ * (this form, unlike the one above, really does reference an existing
+ * local variable in the same file: there's no `from` module to reach
+ * into). The within-file counterpart of `recordRelativeReexportEdges`
+ * above, since this rebind never crosses a module boundary for
+ * `resolveRelativeModuleFile` to resolve.
+ *
+ * Note: not a self-loop even when the exported name equals the local name
+ * (e.g. `export { Logo };`, no rename) -- the source is `file`'s
+ * EXPORT-table entry, the target is `file`'s LOCAL scope, two distinct
+ * kinds of node that happen to share the same text, exactly the sort of
+ * same-name-different-kind pair `exportNodeKey`/`nodeKey` exist to keep
+ * apart. */
+function recordLocalReexportEdges(
+ t: typeof Babel.types,
+ file: string,
+ specifiers: Babel.types.ExportNamedDeclaration['specifiers'],
+ bindingEdges: Map
+): void {
+ recordExportSpecifierEdges(t, file, specifiers, bindingEdges, localName =>
+ nodeKey(file, localName)
+ );
+}
+
+export type ScanOptions = {
+ t: typeof Babel.types;
+ rootDir: string;
+ scanIgnorePatterns: string[];
+ followSymlinks: boolean;
+ pathAliasResolver: PathAliasResolver;
+ svgBindingMap: SvgBindingMap;
+};
+
+/**
+ * Scans every source file under `rootDir` to detect `.svg` imports and
+ * builds a mapping from JSX identifiers to their corresponding SVG file
+ * paths, writing the result into `svgBindingMap`. Parses each file's AST
+ * and collects `import`/`export` declarations that reference `.svg`
+ * files, tracing re-export chains (relative, local, wildcard, and
+ * bare/aliased) so a name rendered as JSX in one file can still resolve
+ * to a bare/aliased SVG import declared in a completely different file.
+ *
+ * Files matching `scanIgnorePatterns` are skipped.
+ */
+export function scanProjectForSvgs({
+ t,
+ rootDir,
+ scanIgnorePatterns,
+ followSymlinks,
+ pathAliasResolver,
+ svgBindingMap
+}: ScanOptions): void {
+ const files = glob.sync('**/*.{js,jsx,ts,tsx}', {
+ cwd: rootDir,
+ absolute: true,
+ ignore: scanIgnorePatterns,
+ followSymbolicLinks: 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.
+ type PendingBareSource = {
+ file: string;
+ source: string;
+ candidateNames: string[];
+ // Whether `candidateNames` live in `file`'s local scope (a bare
+ // `import`) or its export table (a bare re-export) -- decides
+ // which of `nodeKey`/`exportNodeKey` the reachability check
+ // below must test, since JSX usage of a re-exported name is
+ // impossible (JSX can only ever reference a local binding).
+ nameKind: 'local' | 'export';
+ // Takes the subset of `candidateNames` actually proven
+ // reachable (not all of them) -- a multi-specifier import/export
+ // can have some names that are genuinely JSX-rendered and others
+ // that are merely co-imported, and only the former should ever
+ // be written to `localSvgMap`.
+ populate: (
+ resolved: string,
+ reachableNames: ReadonlySet
+ ) => void;
+ };
+ const pendingBareSources: PendingBareSource[] = [];
+ // How the two `pendingBareSources.push` call sites below differ is
+ // entirely in how `candidateNames` gets collected (import vs.
+ // export specifiers) and what `populate` does with a resolved
+ // path -- both genuinely distinct per caller. This just centralizes
+ // the "skip if nothing to defer, otherwise defer it" wrapper they'd
+ // otherwise duplicate around that.
+ const deferBareSource = (entry: PendingBareSource): void => {
+ if (!entry.candidateNames.length) {
+ return;
+ }
+ pendingBareSources.push(entry);
+ };
+ // JSX usages, kept per-file (not a flat name set) -- a name rendered
+ // as JSX in one file must never be able to justify resolving an
+ // unrelated bare import of the same name in a different file (e.g. a
+ // real ` ` from a UI library colliding with an aliased SVG
+ // import also named `Button` elsewhere).
+ const usedJsxNodes = new Set();
+ // Relative import/re-export bindings, so a name rendered as JSX can
+ // still be traced through a barrel file to the bare/aliased source
+ // that actually defines it, without falling back to matching on the
+ // name alone project-wide.
+ const bindingEdges = new Map();
+ // `export * from './x'` re-exports every named export of its target
+ // under the SAME name (never renamed), so unlike a named re-export
+ // we can't record a concrete per-name edge until we know which
+ // names might actually matter project-wide -- resolved to concrete
+ // pass-through edges once the scan below has collected that set.
+ const wildcardReexports: Array<{
+ file: string;
+ targetFile: string;
+ }> = [];
+ // A direct `import X from './x.svg'` is always a real SVG, so
+ // unlike the bare/aliased case above it never needs a reachability
+ // check -- but the actual `setLocalSvgMapEntry` write is still
+ // deferred until after `rootsReaching` (below) exists, so a file
+ // that consumes this same import through a re-export chain gets
+ // its own precise `localSvgMapByBinding` entry too, not just the
+ // flat map.
+ const pendingDirectSvgImports: Array<{
+ file: string;
+ name: string;
+ resolved: string;
+ }> = [];
+ // Same deferral, for a direct `export { X } from './x.svg'`.
+ const pendingDirectSvgExports: Array<{
+ file: string;
+ path: Babel.NodePath;
+ resolved: string;
+ }> = [];
+
+ for (const file of files) {
+ try {
+ const code = fs.readFileSync(file, 'utf8');
+ if (!code) {
+ continue;
+ }
+
+ const ast = parser.parse(code, {
+ sourceType: 'module',
+ plugins: [
+ 'jsx',
+ 'typescript',
+ 'exportDefaultFrom',
+ 'classProperties',
+ 'dynamicImport'
+ ]
+ });
+
+ traverse(ast, {
+ JSXOpeningElement: path => {
+ const name = getNodeName(t, path.node.name);
+ if (name) {
+ usedJsxNodes.add(nodeKey(file, name));
+ }
+ },
+ ImportDeclaration: path => {
+ const source = path.node.source.value;
+
+ if (source.endsWith('.svg')) {
+ const resolved = resolveImportSource(
+ pathAliasResolver,
+ file,
+ source
+ );
+ if (resolved) {
+ for (const spec of path.node.specifiers) {
+ const name = getLocalName(t, spec.local);
+ if (name) {
+ pendingDirectSvgImports.push({
+ file,
+ name,
+ resolved
+ });
+ }
+ }
+ }
+ return;
+ }
+
+ if (!isBareSpecifier(source)) {
+ recordRelativeImportEdges(
+ t,
+ file,
+ source,
+ path.node.specifiers,
+ bindingEdges
+ );
+ return;
+ }
+
+ const candidateNames: string[] = [];
+ for (const spec of path.node.specifiers) {
+ const name = getLocalName(t, spec.local);
+ if (name) {
+ candidateNames.push(name);
+ }
+ }
+ deferBareSource({
+ file,
+ source,
+ candidateNames,
+ nameKind: 'local',
+ populate: (resolved, reachableNames) => {
+ for (const name of candidateNames) {
+ if (reachableNames.has(name)) {
+ svgBindingMap.setLocalSvgMapEntry(
+ file,
+ name,
+ resolved,
+ rootsReaching
+ );
+ }
+ }
+ }
+ });
+ },
+ ExportAllDeclaration: path => {
+ // This parser config doesn't enable
+ // `exportNamespaceFrom`, so `export * as Name from
+ // './x'` (a single namespace binding, not a
+ // per-name pass-through) never reaches here as an
+ // `ExportAllDeclaration` -- every node visited by
+ // this handler is the plain `export * from './x'`
+ // form, which re-exports every name unchanged.
+ const source = path.node.source.value;
+ let targetFile: string | null;
+ if (isBareSpecifier(source)) {
+ // An alias can resolve as far as a bare
+ // directory (e.g. `'@icons'` -> `./icons`, with
+ // no specific file) -- still needs the same
+ // extension/index resolution a relative
+ // specifier gets, just starting from wherever
+ // the alias landed instead of `dirname(file)`.
+ const aliasResolved = pathAliasResolver.resolve(
+ source,
+ file
+ );
+ targetFile = aliasResolved
+ ? resolveModuleFileFromBase(aliasResolved)
+ : null;
+ } else {
+ targetFile = resolveRelativeModuleFile(file, source);
+ }
+ if (!targetFile) {
+ return;
+ }
+ wildcardReexports.push({ file, targetFile });
+ },
+ ExportDefaultDeclaration: path => {
+ const declaration = path.node.declaration;
+ // Only `export default SomeLocalName;` re-exports an
+ // existing binding under the name `'default'` -- a
+ // new declaration (`export default function() {}`)
+ // has no local binding for the graph to link to.
+ if (!t.isIdentifier(declaration)) {
+ return;
+ }
+ bindingEdges.set(
+ exportNodeKey(file, 'default'),
+ nodeKey(file, declaration.name)
+ );
+ },
+ ExportNamedDeclaration: path => {
+ const source = path.node.source?.value;
+ if (!source) {
+ recordLocalReexportEdges(
+ t,
+ file,
+ path.node.specifiers,
+ bindingEdges
+ );
+ return;
+ }
+
+ if (source.endsWith('.svg')) {
+ const resolved = resolveImportSource(
+ pathAliasResolver,
+ file,
+ source
+ );
+ if (resolved) {
+ pendingDirectSvgExports.push({
+ file,
+ path,
+ resolved
+ });
+ }
+ return;
+ }
+
+ if (!isBareSpecifier(source)) {
+ recordRelativeReexportEdges(
+ t,
+ file,
+ source,
+ path.node.specifiers,
+ bindingEdges
+ );
+ return;
+ }
+
+ const candidateNames: string[] = [];
+ for (const spec of path.node.specifiers) {
+ if (spec.type !== 'ExportSpecifier') {
+ continue;
+ }
+ const name = getExportedName(t, spec.exported);
+ if (name) {
+ candidateNames.push(name);
+ }
+ }
+ deferBareSource({
+ file,
+ source,
+ candidateNames,
+ nameKind: 'export',
+ populate: (resolved, reachableNames) =>
+ svgBindingMap.populateExportedSvgNames(
+ t,
+ file,
+ path,
+ resolved,
+ reachableNames,
+ rootsReaching
+ )
+ });
+ }
+ });
+ } catch (err) {
+ console.error(`[buildSvgMap]: \n File: ${file}\n`, err);
+ }
+ }
+
+ // Group wildcard re-exports by their own file -- unlike the other
+ // four re-export mechanisms (each a single deterministic edge), a
+ // file can have more than one `export * from` statement, and each
+ // re-exports whatever name is actually being looked for rather
+ // than a fixed, precomputable set of names. Fanning these out
+ // during the walk below (instead of pre-flattening them into
+ // `bindingEdges`, which can only hold one target per key) means a
+ // second wildcard's real target is never starved by a first
+ // wildcard that merely happened to claim the same key first.
+ const wildcardTargetsByFile = new Map();
+ for (const { file, targetFile } of wildcardReexports) {
+ const targets = wildcardTargetsByFile.get(file);
+ if (targets) {
+ targets.push(targetFile);
+ } else {
+ wildcardTargetsByFile.set(file, [targetFile]);
+ }
+ }
+
+ // Walk outward from every real JSX usage, through the relative
+ // import/re-export edges recorded above (plus any wildcard barrels
+ // a node's file re-exports from), to find every (file, name) a JSX
+ // render could actually be bound to -- a barrel file may re-export
+ // a bare/aliased source under a name only ever rendered in a
+ // different file, so this has to follow the real binding chain
+ // rather than matching on the name in isolation.
+ //
+ // Walked per-root (rather than one merged flood-fill from every
+ // root at once) so `rootsReaching` can record exactly which JSX
+ // usage(s) each visited node is reachable from. That's what lets
+ // `setLocalSvgMapEntry`/`setFlatSvgMapEntry` (on `svgBindingMap`)
+ // write a precise `localSvgMapByBinding` entry for every file that
+ // legitimately consumes a resolved SVG through a chain -- not just
+ // the file holding the original import/export declaration -- so a
+ // same-named unrelated binding in some OTHER, unconnected file can
+ // never inherit it via the flat map fallback. `.pop()` makes each
+ // per-root traversal LIFO, which doesn't matter here since it's a
+ // flood-fill into a visited set, not a shortest-path search. Most
+ // JSX usages (a plain ``, ``, ...) have no outgoing
+ // `bindingEdges` entry at all, so their per-root walk is O(1);
+ // only usages that actually trace through a chain do more work.
+ const reachable = new Set();
+ const rootsReaching = new Map>();
+ for (const root of usedJsxNodes) {
+ const visited = new Set();
+ const queue: string[] = [root];
+ while (queue.length) {
+ const current = queue.pop();
+ if (current === undefined || visited.has(current)) {
+ continue;
+ }
+ visited.add(current);
+ reachable.add(current);
+
+ let consumers = rootsReaching.get(current);
+ if (!consumers) {
+ consumers = new Set();
+ rootsReaching.set(current, consumers);
+ }
+ consumers.add(root);
+
+ const directEdge = bindingEdges.get(current);
+ if (directEdge) {
+ queue.push(directEdge);
+ }
+
+ // A wildcard (`export * from './x'`) forwards from `file`'s
+ // own EXPORT table into `targetFile`'s -- it never touches
+ // local scope, so only fan out from an export-kind node
+ // here. Most popped nodes are local-kind (every JSX-usage
+ // root is), so check the kind byte directly and skip
+ // splitting the key at all for those -- `splitNodeKey`
+ // isn't needed unless this node could possibly have a
+ // wildcard target.
+ if (current[0] === 'E') {
+ const [, currentFile, currentName] = splitNodeKey(current);
+ const wildcardTargets = wildcardTargetsByFile.get(currentFile);
+ if (wildcardTargets) {
+ for (const targetFile of wildcardTargets) {
+ queue.push(exportNodeKey(targetFile, currentName));
+ }
+ }
+ }
+ }
+ }
+
+ // Only writable now that `rootsReaching` exists (see above) --
+ // deferred from the first pass so a file that consumes one of
+ // these direct, always-a-real-SVG imports/exports through a
+ // re-export chain gets its own precise `localSvgMapByBinding`
+ // entry too, not just the flat map.
+ for (const entry of pendingDirectSvgImports) {
+ try {
+ svgBindingMap.setLocalSvgMapEntry(
+ entry.file,
+ entry.name,
+ entry.resolved,
+ rootsReaching
+ );
+ } catch (err) {
+ console.error(`[buildSvgMap]: \n File: ${entry.file}\n`, err);
+ }
+ }
+ for (const entry of pendingDirectSvgExports) {
+ try {
+ svgBindingMap.populateExportedSvgNames(
+ t,
+ entry.file,
+ entry.path,
+ entry.resolved,
+ null,
+ rootsReaching
+ );
+ } catch (err) {
+ console.error(`[buildSvgMap]: \n File: ${entry.file}\n`, err);
+ }
+ }
+
+ // Second pass: only now attempt the (potentially expensive) alias
+ // resolution, and only for sources with at least one candidate name
+ // that's actually reachable from a real JSX usage via that binding
+ // chain (not just textually matching some unrelated usage elsewhere).
+ // Wrapped in a try/catch per entry -- mirroring the first pass's
+ // per-file isolation -- so a single misconfigured alias (e.g. a
+ // malformed tsconfig.json `paths` pattern) can't abort SVG detection
+ // for the rest of the project.
+ for (const pending of pendingBareSources) {
+ try {
+ const keyFn =
+ pending.nameKind === 'export' ? exportNodeKey : nodeKey;
+ // Most bare imports/re-exports in a real project are never
+ // rendered as JSX under any of their candidate names -- a
+ // cheap, allocation-free `.some()` check lets that common
+ // case skip straight past without paying for a `.filter()`
+ // array and a `Set` that would just get discarded.
+ if (
+ !pending.candidateNames.some(name =>
+ reachable.has(keyFn(pending.file, name))
+ )
+ ) {
+ continue;
+ }
+ const reachableNames = new Set(
+ pending.candidateNames.filter(name =>
+ reachable.has(keyFn(pending.file, name))
+ )
+ );
+
+ const resolved = resolveSvgImportSource(
+ pathAliasResolver,
+ pending.file,
+ pending.source
+ );
+ if (!resolved) {
+ continue;
+ }
+
+ pending.populate(resolved, reachableNames);
+ } catch (err) {
+ console.error(`[buildSvgMap]: \n File: ${pending.file}\n`, err);
+ }
+ }
+}
diff --git a/packages/react-native-babel-plugin/src/libraries/react-native-svg/handlers/HandlerResolver.ts b/packages/react-native-babel-plugin/src/libraries/react-native-svg/handlers/HandlerResolver.ts
index dff5a54ff..ba647f396 100644
--- a/packages/react-native-babel-plugin/src/libraries/react-native-svg/handlers/HandlerResolver.ts
+++ b/packages/react-native-babel-plugin/src/libraries/react-native-svg/handlers/HandlerResolver.ts
@@ -16,7 +16,11 @@ type Dependencies = {
t: typeof Babel.types;
path: Babel.NodePath;
name: string;
- localSvgMap: Record;
+ // The already-resolved SVG map entry for this specific JSX usage (see
+ // `ReactNativeSVG.getLocalSvgEntry`), not the whole map -- the caller
+ // has already disambiguated `name` against the file currently being
+ // transformed, so this class doesn't need to know about that at all.
+ localSvgEntry: { path: string; content?: string } | undefined;
};
export class HandlerResolver {
@@ -29,17 +33,18 @@ export class HandlerResolver {
* with handler constructors that are parameterized with the provided Babel context and configuration.
*
* @param dependencies - Shared Babel-related dependencies and contextual information,
- * including `types`, the current JSX `path`, tag `name`, and the `localSvgMap`.
+ * including `types`, the current JSX `path`, tag `name`, and the
+ * resolved `localSvgEntry` (if any) for this usage.
*/
static configure(dependencies: Dependencies) {
this.dependencies = dependencies;
- const { t, path, name, localSvgMap } = dependencies;
+ const { t, path, name, localSvgEntry } = dependencies;
HandlerResolver.registry = {
RNSvgHandler: () => new RNSvgHandler(t, path, name),
// UriSvgHandler: () => new UriSvgHandler(t, path, name),
LocalSvgHandler: () =>
- new LocalSvgHandler(t, path, name, localSvgMap)
+ new LocalSvgHandler(t, path, name, localSvgEntry)
};
}
@@ -54,7 +59,7 @@ export class HandlerResolver {
throw new Error('HandlerResolver must be configured before use.');
}
- const { name, localSvgMap } = this.dependencies;
+ const { name, localSvgEntry } = this.dependencies;
switch (name) {
case 'Svg': {
@@ -66,7 +71,7 @@ export class HandlerResolver {
// }
default: {
- return localSvgMap[name]
+ return localSvgEntry
? HandlerResolver.registry.LocalSvgHandler()
: null;
}
diff --git a/packages/react-native-babel-plugin/src/libraries/react-native-svg/handlers/LocalSvgHandler.ts b/packages/react-native-babel-plugin/src/libraries/react-native-svg/handlers/LocalSvgHandler.ts
index 0504e0a10..31f4c1721 100644
--- a/packages/react-native-babel-plugin/src/libraries/react-native-svg/handlers/LocalSvgHandler.ts
+++ b/packages/react-native-babel-plugin/src/libraries/react-native-svg/handlers/LocalSvgHandler.ts
@@ -25,28 +25,30 @@ export class LocalSvgHandler implements SvgHandler {
private types: typeof Babel.types,
private path: Babel.NodePath,
private name: string,
- private localSvgMap: Record
+ private svgEntry: { path: string; content?: string } | undefined
) {
// no-op
}
/**
* Retrieves and returns the contents of a local SVG file corresponding to the JSXElement tag name.
- * If the file hasn't been read yet, it reads the SVG content from disk and caches it in `localSvgMap`.
+ * If the file hasn't been read yet, it reads the SVG content from disk and caches it on `svgEntry`
+ * (the same object instance `ReactNativeSVG` stores in its maps, so the cached content is visible
+ * to any later lookup of this same entry too).
* Also extracts and stores width/height dimensions from the JSX attributes into the `dimensions` object.
*
* @param dimensions - Object to collect extracted width/height info.
- * @returns Raw SVG string content from the local file, or undefined if the tag is not found in `localSvgMap`.
+ * @returns Raw SVG string content from the local file, or undefined if no entry was resolved for this usage.
*/
transformSvgNode(dimensions: Record) {
- if (!this.localSvgMap[this.name]) {
+ if (!this.svgEntry) {
return undefined;
}
- const { path, content } = this.localSvgMap[this.name];
+ const { path, content } = this.svgEntry;
if (!content) {
- this.localSvgMap[this.name].content = fs.readFileSync(path, 'utf8');
+ this.svgEntry.content = fs.readFileSync(path, 'utf8');
}
this.processAttributes(
@@ -56,7 +58,7 @@ export class LocalSvgHandler implements SvgHandler {
dimensions
);
- return this.localSvgMap[this.name].content;
+ return this.svgEntry.content;
}
/**
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..fa57de520 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
@@ -5,20 +5,19 @@
*/
import type * as Babel from '@babel/core';
-import * as parser from '@babel/parser';
-import traverse from '@babel/traverse';
import { jsxIdentifier, stringLiteral } from '@babel/types';
import { createHash } from 'crypto';
-import glob from 'fast-glob';
-import fs from 'fs';
import pathN from 'path';
import { optimize } from 'svgo';
import { v4 as uuidv4 } from 'uuid';
import { getNodeName } from '../../utils';
+import { scanProjectForSvgs } from './buildSvgMap';
import { HandlerResolver } from './handlers/HandlerResolver';
+import { PathAliasResolver } from './pathAliasResolver';
import { writeAssetToDisk } from './processing/fs';
+import { SvgBindingMap } from './svgBindingMap';
// 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
@@ -36,178 +35,87 @@ const DEFAULT_SCAN_IGNORE_PATTERNS = [
* Internal processor responsible for detecting, transforming, and wrapping
* React Native SVG components for use with Session Replay.
*
- * This class scans the project for `.svg` imports, builds a mapping between
- * JSX identifiers and SVG files, and transforms JSX SVG nodes into
- * optimized, web-compatible SVG markup. Each transformed element is then
- * wrapped in a `SessionReplayView.Privacy` component with metadata used by
- * the native Session Replay layer.
+ * This class scans the project for `.svg` imports (delegated to
+ * `buildSvgMap.ts`, which populates a `SvgBindingMap`), and transforms JSX
+ * SVG nodes into optimized, web-compatible SVG markup. Each transformed
+ * element is then wrapped in a `SessionReplayView.Privacy` component with
+ * metadata used by the native Session Replay layer.
*/
export class ReactNativeSVG {
- localSvgMap: Record = {};
+ private svgBindingMap = new SvgBindingMap();
+
+ // Kept as a public property (rather than a getter-only re-export) for
+ // backward compatibility: it's the tested public surface (assertions
+ // read `instance.localSvgMap[...]` directly) and the disk-cache
+ // format. Delegates to `svgBindingMap` so the two-map, per-binding
+ // disambiguation design (see `SvgBindingMap`) stays encapsulated
+ // there rather than leaking into this class.
+ get localSvgMap(): Record {
+ return this.svgBindingMap.localSvgMap;
+ }
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;
}
/**
- * Scans all source files in the project to detect `.svg` imports and builds a mapping
- * of JSX identifiers to their corresponding SVG file paths. This is done by parsing each
- * file's AST and collecting `import` or `export` declarations that reference `.svg` files.
+ * Builds (or loads a cached) mapping of JSX identifiers to their
+ * corresponding SVG file paths, storing the result in `svgBindingMap`.
*
- * 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.
+ * If `saveSvgMapToDisk` is false, first attempts to load the mapping
+ * from a previously saved `svg-map.json` for better performance; if
+ * that file doesn't exist or can't be read, falls back to scanning
+ * the codebase (see `scanProjectForSvgs` in `buildSvgMap.ts`).
*
- * 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,
- * it falls back to scanning the codebase.
- *
- * If `saveSvgMapToDisk` is true, the mapping will be saved to a JSON file in the assets directory
- * after scanning.
+ * If `saveSvgMapToDisk` is true, the mapping is saved to that JSON
+ * file after scanning.
*/
buildSvgMap() {
if (!this.t) {
return;
}
- // If not saving to disk, try to load from existing svg-map.json first
- if (!this.saveSvgMapToDisk) {
- // Resolve to package root: from lib/commonjs/libraries/react-native-svg -> package root
- const packageRoot = pathN.resolve(__dirname, '../../../..');
- const svgMapPath = pathN.join(packageRoot, 'svg-map.json');
- try {
- if (fs.existsSync(svgMapPath)) {
- const mapContent = fs.readFileSync(svgMapPath, 'utf8');
- this.localSvgMap = JSON.parse(mapContent);
- return;
- }
- } catch (err) {
- console.warn(
- '[buildSvgMap]: Failed to load SVG map from disk, falling back to codebase scan',
- err
- );
- }
+ // Resolve to package root: from lib/commonjs/libraries/react-native-svg -> package root
+ const packageRoot = pathN.resolve(__dirname, '../../../..');
+ const svgMapPath = pathN.join(packageRoot, 'svg-map.json');
+
+ if (
+ !this.saveSvgMapToDisk &&
+ this.svgBindingMap.loadFromDisk(svgMapPath)
+ ) {
+ return;
}
- // TODO: Support aliased paths (RUM-12185)
- const files = glob.sync('**/*.{js,jsx,ts,tsx}', {
- cwd: this.rootDir,
- absolute: true,
- ignore: this.scanIgnorePatterns,
- followSymbolicLinks: this.followSymlinks
+ // 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();
+
+ scanProjectForSvgs({
+ t: this.t,
+ rootDir: this.rootDir,
+ scanIgnorePatterns: this.scanIgnorePatterns,
+ followSymlinks: this.followSymlinks,
+ pathAliasResolver: this.pathAliasResolver,
+ svgBindingMap: this.svgBindingMap
});
- for (const file of files) {
- try {
- const code = fs.readFileSync(file, 'utf8');
- if (!code) {
- continue;
- }
-
- const ast = parser.parse(code, {
- sourceType: 'module',
- plugins: [
- 'jsx',
- 'typescript',
- 'exportDefaultFrom',
- 'classProperties',
- 'dynamicImport'
- ]
- });
-
- traverse(ast, {
- ImportDeclaration: path => {
- if (!this.t) {
- return;
- }
- const source = path.node.source.value;
- if (!source.endsWith('.svg')) {
- return;
- }
-
- const resolved = pathN.resolve(
- pathN.dirname(file),
- source
- );
- for (const spec of path.node.specifiers) {
- const name = getNodeName(this.t, spec.local.name);
- if (name) {
- this.localSvgMap[name] = {
- path: resolved
- };
- }
- }
- },
- ExportNamedDeclaration: path => {
- if (!this.t) {
- return;
- }
- const source = path.node.source?.value;
- if (!source?.endsWith('.svg')) {
- return;
- }
-
- const resolved = pathN.resolve(
- pathN.dirname(file),
- source
- );
- for (const spec of path.node.specifiers) {
- if (spec.type === 'ExportSpecifier') {
- // spec.exported is the name consumers import under
- // ('default' would be wrong for `export { default as Logo }`)
- const exported = spec.exported;
- const name = getNodeName(
- this.t,
- this.t.isStringLiteral(exported)
- ? exported.value
- : exported.name
- );
- if (name) {
- this.localSvgMap[name] = {
- path: resolved
- };
- }
- } else {
- console.warn(
- `[buildSvgMap]: Unhandled export specifier type: ${spec.type}`
- );
- }
- }
- }
- });
- } catch (err) {
- console.error(`[buildSvgMap]: \n File: ${file}\n`, err);
- }
- }
-
- // Save the mapping to disk if requested
if (this.saveSvgMapToDisk) {
- try {
- // Resolve to package root: from lib/commonjs/libraries/react-native-svg -> package root
- const packageRoot = pathN.resolve(__dirname, '../../../..');
- const svgMapPath = pathN.join(packageRoot, 'svg-map.json');
- fs.writeFileSync(
- svgMapPath,
- JSON.stringify(this.localSvgMap, null, 2),
- 'utf8'
- );
- } catch (err) {
- console.error(
- '[buildSvgMap]: Failed to save SVG map to disk',
- err
- );
- }
+ this.svgBindingMap.saveToDisk(svgMapPath);
}
}
@@ -220,10 +128,18 @@ export class ReactNativeSVG {
*
* @param path - Babel NodePath pointing to the JSXElement to process.
* @param name - JSX tag name (e.g., 'Svg', 'Logo') used to resolve the appropriate handler.
+ * @param currentFile - Absolute path of the file currently being
+ * transformed (e.g. Babel's `state.filename`), used to disambiguate
+ * `name` against the per-binding map when two different files alias
+ * a different SVG under the same local name.
* @returns An object containing the original SVG string and its optimized version,
* or `undefined` if no transformation could be performed.
*/
- processItem(path: Babel.NodePath, name: string) {
+ processItem(
+ path: Babel.NodePath,
+ name: string,
+ currentFile: string
+ ) {
if (!this.t) {
return;
}
@@ -235,11 +151,23 @@ export class ReactNativeSVG {
return;
}
+ // Every `SvgBindingMap` write puts `name` into `localSvgMap`
+ // and its per-binding map together (or into `localSvgMap`
+ // alone), so a miss on this flat, allocation-free check
+ // proves `name` can't be in the per-binding map either --
+ // skips the per-binding lookup for the overwhelming majority
+ // of JSX elements (`View`, `Text`, ...) that are never a
+ // local SVG import at all.
+ const localSvgEntry =
+ name in this.localSvgMap
+ ? this.svgBindingMap.getLocalSvgEntry(currentFile, name)
+ : undefined;
+
HandlerResolver.configure({
t: this.t,
path,
name,
- localSvgMap: this.localSvgMap
+ localSvgEntry
});
const handler = HandlerResolver.create();
diff --git a/packages/react-native-babel-plugin/src/libraries/react-native-svg/nodeKeys.ts b/packages/react-native-babel-plugin/src/libraries/react-native-svg/nodeKeys.ts
new file mode 100644
index 000000000..db2b1a9eb
--- /dev/null
+++ b/packages/react-native-babel-plugin/src/libraries/react-native-svg/nodeKeys.ts
@@ -0,0 +1,43 @@
+/*
+ * 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.
+ */
+
+// The binding graph (built in index.ts's buildSvgMap()) has two distinct
+// kinds of node that can share the same (file, name) text without being
+// the same thing: a LOCAL node is "the binding named `name` in `file`'s
+// own scope" (what a JSX identifier always refers to, and what a plain
+// `import`'s local specifier creates); an EXPORT node is "the entry named
+// `name` in `file`'s export table" (what an `import {name} from 'file'` in
+// ANOTHER file reaches into, and what a re-export declaration's exported
+// name refers to). A file can legally have an unrelated local import and a
+// re-export both using the same text -- `import Icon from 'ui-lib'; export
+// { Icon } from './svgBarrel';` -- so conflating the two into one key
+// space would let a JSX usage of the real local `Icon` incorrectly walk
+// into the re-exported barrel's `Icon`. Keys are tagged with a
+// one-character kind prefix so the two spaces can never collide even when
+// `file`+`name` are identical.
+//
+// Lives in its own module (rather than as private helpers in index.ts) so
+// other modules can build/split the same kind of key without creating a
+// circular import back into index.ts. The main consumer is
+// `ReactNativeSVG.getLocalSvgEntry` in index.ts itself, which uses `nodeKey`
+// to disambiguate a JSX identifier's local binding at consumption time.
+export function nodeKey(file: string, name: string): string {
+ return `L\0${file}\0${name}`;
+}
+
+export function exportNodeKey(file: string, name: string): string {
+ return `E\0${file}\0${name}`;
+}
+
+/** Inverse of `nodeKey`/`exportNodeKey` -- the key is always exactly
+ * `\0\0` and file paths never contain a NUL byte, so a
+ * plain split unambiguously recovers all three parts. */
+export function splitNodeKey(
+ key: string
+): [kind: 'local' | 'export', file: string, name: string] {
+ const [kindTag, file, name] = key.split('\0');
+ return [kindTag === 'E' ? 'export' : 'local', file, name];
+}
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..488553cad
--- /dev/null
+++ b/packages/react-native-babel-plugin/src/libraries/react-native-svg/pathAliasResolver.ts
@@ -0,0 +1,655 @@
+/*
+ * 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 fs from 'fs';
+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);
+}
+
+// Lives here (rather than as a private helper in index.ts, which also uses
+// it) since `resolve()`'s own early-exit below needs the exact same
+// relative/absolute check `isBareSpecifier` encodes -- keeping one copy
+// means the two can't quietly drift apart. index.ts imports it from here
+// instead of defining its own; that direction avoids a circular import,
+// since index.ts already depends on this module.
+export function isBareSpecifier(source: string): boolean {
+ return source[0] !== '.' && !pathN.isAbsolute(source);
+}
+
+const RELATIVE_MODULE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx'];
+
+// Metro resolves a platform-specific variant (e.g. `Icon.ios.tsx`) before
+// the plain form, and some RN components only ever exist in platform-split
+// files with no plain fallback -- tried without knowing which platform a
+// real build would target, so all of them are candidates here.
+const RELATIVE_MODULE_PLATFORM_SUFFIXES = ['ios', 'android', 'native', 'web'];
+
+/** Resolves a `base` path (not yet known to be a real file -- it may be
+ * missing its extension, or be a directory expecting an `index` file) to a
+ * real file on disk, trying common extensions, platform-specific variants,
+ * and index files -- mirrors enough of Node/Metro resolution to trace
+ * re-export chains without pulling in a full resolver for what's normally a
+ * single lookup. Used both for a relative specifier resolved to an absolute
+ * `base` directly, and for an alias/tsconfig/Metro resolution that only
+ * gets as far as a bare directory or extensionless path. */
+export function resolveModuleFileFromBase(base: string): string | null {
+ const withPlatformSuffixes = (prefix: string) =>
+ RELATIVE_MODULE_PLATFORM_SUFFIXES.flatMap(platform =>
+ RELATIVE_MODULE_EXTENSIONS.map(ext => `${prefix}.${platform}${ext}`)
+ );
+ const candidates = [
+ base,
+ ...withPlatformSuffixes(base),
+ ...RELATIVE_MODULE_EXTENSIONS.map(ext => `${base}${ext}`),
+ ...withPlatformSuffixes(pathN.join(base, 'index')),
+ ...RELATIVE_MODULE_EXTENSIONS.map(ext =>
+ pathN.join(base, `index${ext}`)
+ )
+ ];
+ for (const candidate of candidates) {
+ try {
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
+ return candidate;
+ }
+ } catch (err) {
+ // Treat an unreadable candidate the same as a missing one.
+ }
+ }
+ return null;
+}
+
+/** Resolves a relative import specifier (e.g. `./icons`) to a real file on
+ * disk -- see `resolveModuleFileFromBase` for how. */
+export function resolveRelativeModuleFile(
+ fromFile: string,
+ specifier: string
+): string | null {
+ return resolveModuleFileFromBase(
+ pathN.resolve(pathN.dirname(fromFile), specifier)
+ );
+}
+
+// Tried, in order, when a bare module-resolver alias target has no
+// extension of its own -- `.svg` first, since resolving an aliased SVG
+// import is this whole resolver's reason for existing.
+const MODULE_SPECIFIER_FALLBACK_EXTENSIONS = [
+ '.svg',
+ '.ts',
+ '.tsx',
+ '.js',
+ '.jsx'
+];
+
+/**
+ * Walks upward from `startDir` (inclusive) to the filesystem root looking
+ * for one of `fileNames`, mirroring how `tsconfig-paths` itself locates
+ * `tsconfig.json`/`jsconfig.json`. `rootDir` is the CLI/plugin's *scan*
+ * root (e.g. `--path ./src`), which is very often a subdirectory of the
+ * actual project root where `metro.config.js` lives -- a single lookup
+ * inside `rootDir` alone would miss it.
+ *
+ * `cache` is keyed by every directory visited on the way to the answer
+ * (not just `startDir`), with path compression: once any directory's
+ * answer is known -- a resolved config path, or `null` if none exists
+ * anywhere above it -- every directory the walk passed through to reach
+ * it is backfilled with that same answer. A project with many directories
+ * that all share one project-root config then only ever pays for the walk
+ * once, not once per directory that happens to need it.
+ */
+function findConfigUpward(
+ startDir: string,
+ fileNames: string[],
+ cache: Map
+): string | null {
+ const visited: string[] = [];
+ let dir = startDir;
+ // eslint-disable-next-line no-constant-condition
+ while (true) {
+ const cached = cache.get(dir);
+ if (cached !== undefined) {
+ for (const visitedDir of visited) {
+ cache.set(visitedDir, cached);
+ }
+ return cached;
+ }
+ visited.push(dir);
+
+ for (const name of fileNames) {
+ const candidate = pathN.join(dir, name);
+ if (fs.existsSync(candidate)) {
+ for (const visitedDir of visited) {
+ cache.set(visitedDir, candidate);
+ }
+ return candidate;
+ }
+ }
+
+ const parent = pathN.dirname(dir);
+ if (parent === dir) {
+ for (const visitedDir of visited) {
+ cache.set(visitedDir, null);
+ }
+ return null;
+ }
+ dir = parent;
+ }
+}
+
+/**
+ * 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, 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
+ * returns `null` -- that covers projects that don't use any aliasing.
+ */
+export class PathAliasResolver {
+ private rootDir: string;
+
+ private moduleResolverBindings = new Map<
+ string,
+ ModuleResolverBinding | null
+ >();
+
+ // Two-layer caches: the first layer (below) maps every directory a
+ // lookup started from to the config file it resolves to (with the path
+ // compression `findConfigUpward` performs) -- a monorepo can have
+ // multiple tsconfig.json/metro.config.js files, so each directory needs
+ // its own nearest config rather than sharing whichever one resolved
+ // first for the whole instance. The second layer maps each resolved
+ // config PATH to its parsed value, so directories that share one
+ // config (by far the common case) only pay to load/parse it once.
+ private tsconfigPathByDir = new Map();
+
+ private tsMatchPathByConfigPath = new Map();
+
+ private metroConfigPathByDir = new Map();
+
+ private metroExtraNodeModulesByConfigPath = new Map<
+ string,
+ Record | null
+ >();
+
+ 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.tsconfigPathByDir.clear();
+ this.tsMatchPathByConfigPath.clear();
+ this.metroConfigPathByDir.clear();
+ this.metroExtraNodeModulesByConfigPath.clear();
+ this.resultCache.clear();
+ }
+
+ resolve(importSource: string, currentFile: string): string | null {
+ if (!isBareSpecifier(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, currentFile) ??
+ this.resolveWithMetroExtraNodeModules(importSource, currentFile);
+ 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) {
+ 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)) {
+ // `resolved` is a bare specifier (e.g. `alias: { '@ui':
+ // 'my-ui-library' }`, the pattern babel-plugin-module-resolver's
+ // own README documents) rather than a relative/absolute
+ // path -- resolve it through Node's own module resolution
+ // instead of discarding a legitimately-aliased target.
+ const resolveOpts = {
+ paths: [pathN.dirname(currentFile), this.rootDir]
+ };
+ try {
+ return require.resolve(resolved, resolveOpts);
+ } catch (packageResolveErr) {
+ // `require.resolve` only tries Node's own default
+ // extensions (.js/.json/.node) for an extensionless
+ // specifier -- an aliased target pointing at a
+ // bundler-resolved file like `.svg` (the case this
+ // whole resolver exists for) needs its extension
+ // guessed explicitly, the same way `resolveRelativeModuleFile`
+ // does for relative specifiers.
+ for (const ext of MODULE_SPECIFIER_FALLBACK_EXTENSIONS) {
+ try {
+ return require.resolve(
+ `${resolved}${ext}`,
+ resolveOpts
+ );
+ } catch (extResolveErr) {
+ // Try the next candidate extension.
+ }
+ }
+ 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,
+ currentFile: string
+ ): string | null {
+ const matchPath = this.getTsMatchPath(currentFile);
+ if (!matchPath) {
+ return null;
+ }
+
+ try {
+ // `matchPath` can still throw at match time (not just when this
+ // config was first loaded) -- e.g. a malformed/conflicting
+ // `paths` pattern -- so guard it the same way
+ // `resolveWithModuleResolver` guards its own third-party call.
+ return matchPath(importSource) ?? null;
+ } catch (err) {
+ console.warn(
+ '[PathAliasResolver]: tsconfig-paths failed to resolve an aliased import, falling back to relative resolution',
+ err
+ );
+ return null;
+ }
+ }
+
+ private resolveWithMetroExtraNodeModules(
+ importSource: string,
+ currentFile: string
+ ): string | null {
+ const extraNodeModules = this.getMetroExtraNodeModules(currentFile);
+ if (!extraNodeModules) {
+ return null;
+ }
+
+ const { packageName, subpath } = parseExtraNodeModulesSpecifier(
+ importSource
+ );
+ // A plain indexed lookup would also return inherited
+ // `Object.prototype` members for a specifier like `constructor/foo`
+ // (`extraNodeModules.constructor` is the `Object` function, not
+ // `undefined`) -- guard against both that and a non-string config
+ // value before treating it as a path.
+ if (
+ !Object.prototype.hasOwnProperty.call(extraNodeModules, packageName)
+ ) {
+ return null;
+ }
+ const target = extraNodeModules[packageName];
+ if (typeof target !== 'string') {
+ return null;
+ }
+
+ return subpath ? pathN.join(target, subpath) : target;
+ }
+
+ private getMetroExtraNodeModules(
+ currentFile: string
+ ): Record | null {
+ // Walk upward from the FILE's own directory, not `rootDir` -- a
+ // monorepo scanned from one shared root can have multiple
+ // metro.config.js files, each governing its own subpackage, and
+ // caching a single instance-wide result would apply whichever one
+ // resolved first to every file, regardless of which subpackage it's
+ // actually in. `findConfigUpward`'s path compression means this
+ // walk only runs once per distinct config discovered, not once per
+ // directory -- the common single-project case (one config shared
+ // by every directory) still resolves in O(1) after the first call.
+ const dir = pathN.dirname(currentFile);
+ const configPath = findConfigUpward(
+ dir,
+ ['metro.config.js', 'metro.config.cjs'],
+ this.metroConfigPathByDir
+ );
+ if (!configPath) {
+ return null;
+ }
+
+ const cached = this.metroExtraNodeModulesByConfigPath.get(configPath);
+ if (cached !== undefined) {
+ return cached;
+ }
+
+ let extraNodeModulesResult: Record | null = null;
+ try {
+ // 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;
+ extraNodeModulesResult =
+ 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.metroExtraNodeModulesByConfigPath.set(
+ configPath,
+ extraNodeModulesResult
+ );
+ return extraNodeModulesResult;
+ }
+
+ private getTsMatchPath(currentFile: string): MatchPath | null {
+ // Same reasoning as `getMetroExtraNodeModules` above -- find (and
+ // cache, with path compression) the nearest tsconfig.json/
+ // jsconfig.json from the file's own directory, so each subpackage
+ // in a monorepo gets its own config; but only load/parse each
+ // distinct config path once, not once per directory that happens
+ // to share it.
+ const dir = pathN.dirname(currentFile);
+ const configPath = findConfigUpward(
+ dir,
+ ['tsconfig.json', 'jsconfig.json'],
+ this.tsconfigPathByDir
+ );
+ if (!configPath) {
+ return null;
+ }
+
+ const cached = this.tsMatchPathByConfigPath.get(configPath);
+ if (cached !== undefined) {
+ return cached;
+ }
+
+ let matchPathResult: MatchPath | null = null;
+ try {
+ // Passing the config's own directory (rather than `dir`) means
+ // `loadConfig` finds it on its first check instead of repeating
+ // the upward walk `findConfigUpward` already just did.
+ const config = loadConfig(pathN.dirname(configPath));
+ if (config.resultType === 'success') {
+ matchPathResult = createMatchPath(
+ config.absoluteBaseUrl,
+ config.paths
+ );
+ }
+ } catch (err) {
+ console.warn(
+ '[PathAliasResolver]: Failed to load tsconfig.json/jsconfig.json paths, aliased SVG imports may not resolve',
+ err
+ );
+ }
+
+ this.tsMatchPathByConfigPath.set(configPath, matchPathResult);
+ return matchPathResult;
+ }
+
+ private getModuleResolverBinding(
+ currentFile: string
+ ): ModuleResolverBinding | null {
+ if (this.moduleResolverBindings.has(currentFile)) {
+ return this.moduleResolverBindings.get(currentFile) ?? null;
+ }
+
+ try {
+ // `rootDir` is the CLI/plugin's *scan* root (e.g. `--path
+ // ./src`), which is often a subdirectory of the real project
+ // root where babel.config.js actually lives. Babel's default
+ // rootMode ('root') only checks `cwd` itself; 'upward-optional'
+ // walks up to find it instead, falling back to `cwd` (rather
+ // than throwing) when no config file exists anywhere above it.
+ const partialConfig = babelCore.loadPartialConfig({
+ cwd: this.rootDir,
+ filename: currentFile,
+ rootMode: 'upward-optional'
+ });
+
+ 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 explicitly. Use the
+ // directory `loadPartialConfig` actually resolved `root` to
+ // (available whenever a config file was found, even via the
+ // `upward-optional` walk above) rather than `this.rootDir` --
+ // otherwise a relative alias value written naturally relative to
+ // the discovered config's own directory would resolve against
+ // the (possibly different, narrower) scan root instead.
+ const configRoot = partialConfig?.options.root ?? this.rootDir;
+ const optionsWithCwd =
+ 'cwd' in options ? options : { ...options, cwd: configRoot };
+
+ 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/src/libraries/react-native-svg/specifierNames.ts b/packages/react-native-babel-plugin/src/libraries/react-native-svg/specifierNames.ts
new file mode 100644
index 000000000..72a4a2447
--- /dev/null
+++ b/packages/react-native-babel-plugin/src/libraries/react-native-svg/specifierNames.ts
@@ -0,0 +1,35 @@
+/*
+ * 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 type * as Babel from '@babel/core';
+
+import { getNodeName } from '../../utils';
+
+/** An `ExportSpecifier`'s `exported` field is the name consumers actually
+ * import under ('default' would be wrong for `export { default as Logo }`)
+ * -- extracted into one helper since every export-specifier-walking branch
+ * that needs it (in both `buildSvgMap.ts` and `svgBindingMap.ts`) shares
+ * this. */
+export function getExportedName(
+ t: typeof Babel.types,
+ exported: Babel.types.Identifier | Babel.types.StringLiteral
+): string | null {
+ return getNodeName(
+ t,
+ t.isStringLiteral(exported) ? exported.value : exported.name
+ );
+}
+
+/** A specifier's `local` side (import or export) is always an `Identifier`
+ * -- never a `StringLiteral` -- across the specifier types this file deals
+ * with, so unlike `getExportedName` there's no literal-vs-identifier
+ * branching needed here. */
+export function getLocalName(
+ t: typeof Babel.types,
+ local: Babel.types.Identifier
+): string | null {
+ return getNodeName(t, local.name);
+}
diff --git a/packages/react-native-babel-plugin/src/libraries/react-native-svg/svgBindingMap.ts b/packages/react-native-babel-plugin/src/libraries/react-native-svg/svgBindingMap.ts
new file mode 100644
index 000000000..7ee6c2a19
--- /dev/null
+++ b/packages/react-native-babel-plugin/src/libraries/react-native-svg/svgBindingMap.ts
@@ -0,0 +1,266 @@
+/*
+ * 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 type * as Babel from '@babel/core';
+import fs from 'fs';
+
+import { exportNodeKey, nodeKey } from './nodeKeys';
+import { getExportedName } from './specifierNames';
+
+export type SvgEntry = { path: string; content?: string };
+
+// The svg-map.json disk-cache format, wrapping both the flat map and its
+// per-binding counterpart. Bumped whenever that shape changes; shared
+// between the save and load paths so the two can't desync on the number.
+const CURRENT_SVG_MAP_VERSION = 2;
+
+/**
+ * The name -> resolved-SVG-path mapping `buildSvgMap.ts`'s project scan
+ * populates, and `ReactNativeSVG.processItem` consults at real transform
+ * time. Encapsulates the two-map (flat + per-binding) design and the
+ * disk-cache format in one place, so the scanner and the transform-time
+ * lookup don't each need to know how the disambiguation actually works.
+ */
+export class SvgBindingMap {
+ // A null prototype -- rather than a plain `{}` -- so a source-controlled
+ // key that collides with an inherited name (`__proto__`, `constructor`,
+ // `toString`, ...) is stored as an ordinary own property instead of
+ // reaching through to Object.prototype's accessor/method of the same
+ // name (which for `__proto__` specifically would reassign this object's
+ // own prototype rather than add a property).
+ localSvgMap: Record = Object.create(null);
+
+ // `localSvgMap` above is kept flat/name-only for backward compatibility
+ // (its public shape, and the disk-cache format), but that means two
+ // DIFFERENT files each legitimately aliasing a DIFFERENT SVG under the
+ // same local name can still overwrite each other there -- whichever
+ // file's scan happens to run last silently wins. This second index is
+ // keyed by `nodeKey(file, name)` (never a bare name, so it can't
+ // collide with `Object.prototype` either) and is consulted first at
+ // real transform time via `getLocalSvgEntry()`, so the two files above
+ // each still resolve to their own correct entry. Each entry here is
+ // the SAME object reference stored in `localSvgMap`, so lazily caching
+ // `.content` (in LocalSvgHandler) updates both consistently.
+ private localSvgMapByBinding: Record = {};
+
+ // Whether `localSvgMapByBinding` holds ANY per-binding data at all for
+ // this instance (fresh scan or loaded disk cache) -- not per-name, the
+ // whole map. Once true, `getLocalSvgEntry` stops falling back to the
+ // flat, name-only `localSvgMap` on a miss: with a fresh scan's full
+ // `rootsReaching` propagation, every file that legitimately consumes a
+ // resolved SVG (directly or through a re-export chain) gets its OWN
+ // `localSvgMapByBinding` entry, so a miss means `name` genuinely isn't
+ // reachable from THIS file -- falling back to the flat map at that
+ // point would risk silently inheriting an entry that belongs to some
+ // OTHER, unconnected file merely sharing the same text. The flat map
+ // stays a safe fallback only while this is false, i.e. a legacy
+ // (pre-versioned) disk cache with no per-binding data was loaded --
+ // the same, historical, non-regressive behavior as before per-binding
+ // disambiguation existed.
+ private hasBindingData = false;
+
+ /** Loads a previously-saved `svg-map.json` from `svgMapPath`, if one
+ * exists, replacing both maps wholesale. Returns `true` when a cache
+ * was found and loaded (however old its format), `false` when there
+ * was nothing to load -- the caller should fall back to a fresh
+ * project scan in that case. Never throws: a corrupt/unreadable cache
+ * is logged and treated the same as "nothing to load". */
+ loadFromDisk(svgMapPath: string): boolean {
+ try {
+ if (!fs.existsSync(svgMapPath)) {
+ return false;
+ }
+ const mapContent = fs.readFileSync(svgMapPath, 'utf8');
+ const parsed = JSON.parse(mapContent);
+ // Versioned wrapper (current format, carrying both the flat
+ // map and its per-binding counterpart) vs. a pre-existing
+ // cache file written before `localSvgMapByBinding` existed,
+ // which was just the flat map itself with no wrapper at all
+ // -- still loadable, just without per-binding disambiguation
+ // until the cache is regenerated.
+ const flat =
+ parsed?.version === CURRENT_SVG_MAP_VERSION
+ ? parsed.flat
+ : parsed;
+ const byBinding =
+ parsed?.version === CURRENT_SVG_MAP_VERSION
+ ? parsed.byBinding
+ : null;
+ // `JSON.parse` always returns a plain (Object.prototype)
+ // object -- re-home it onto a null prototype so a
+ // `__proto__`/`constructor`-named entry persisted from a
+ // previous scan can't reintroduce the same hazard here.
+ this.localSvgMap = Object.assign(Object.create(null), flat);
+ this.localSvgMapByBinding = byBinding ?? {};
+ this.hasBindingData =
+ Object.keys(this.localSvgMapByBinding).length > 0;
+ return true;
+ } catch (err) {
+ console.warn(
+ '[buildSvgMap]: Failed to load SVG map from disk, falling back to codebase scan',
+ err
+ );
+ return false;
+ }
+ }
+
+ /** Writes both maps to `svgMapPath`, wrapped with the current format
+ * version. Never throws: a failed write is logged, not fatal to the
+ * caller's scan that just produced this data. */
+ saveToDisk(svgMapPath: string): void {
+ try {
+ fs.writeFileSync(
+ svgMapPath,
+ JSON.stringify(
+ {
+ version: CURRENT_SVG_MAP_VERSION,
+ flat: this.localSvgMap,
+ byBinding: this.localSvgMapByBinding
+ },
+ null,
+ 2
+ ),
+ 'utf8'
+ );
+ } catch (err) {
+ console.error('[buildSvgMap]: Failed to save SVG map to disk', err);
+ }
+ }
+
+ /** Writes one `localSvgMap` entry, and its `localSvgMapByBinding`
+ * counterpart(s) -- keyed by `nodeKey(file, name)` for `file`'s own
+ * local scope (a direct `import` specifier, never a re-export -- see
+ * `setFlatSvgMapEntry` for that case), AND, via `rootsReaching`, for
+ * every OTHER file whose own JSX usage of some name legitimately
+ * traces -- through the binding-edge chain `buildSvgMap.ts` builds --
+ * to this exact `(file, name)`. All written entries share the SAME
+ * object reference, so lazily caching `.content` (in LocalSvgHandler)
+ * via any of them keeps the rest in sync. */
+ setLocalSvgMapEntry(
+ file: string,
+ name: string,
+ resolvedPath: string,
+ rootsReaching: ReadonlyMap>
+ ): void {
+ const entry = { path: resolvedPath };
+ this.localSvgMap[name] = entry;
+ const key = nodeKey(file, name);
+ this.localSvgMapByBinding[key] = entry;
+ this.hasBindingData = true;
+ const consumers = rootsReaching.get(key);
+ if (consumers) {
+ for (const consumerRoot of consumers) {
+ this.localSvgMapByBinding[consumerRoot] = entry;
+ }
+ }
+ }
+
+ /** Writes the flat `localSvgMap` entry, plus (via `rootsReaching`) a
+ * `localSvgMapByBinding` entry for every file whose own JSX usage
+ * traces to this exported name -- but never a `nodeKey(file, ...)`
+ * entry for `file` itself, since a re-export declaration forwards a
+ * name through `file`'s export table without ever introducing a
+ * same-named LOCAL binding in `file`'s own scope. Writing that name
+ * into `file`'s own local `nodeKey` space would be flat-out wrong,
+ * not just imprecise, if `file` also happens to have an unrelated
+ * LOCAL import bound to that same name -- `nodeKey(file, name)` would
+ * then wrongly resolve a JSX usage of the real local import to this
+ * re-export's target instead. A consuming file with no traced chain
+ * of its own (absent from `rootsReaching`) gets no entry here at all
+ * -- see `getLocalSvgEntry`/`hasBindingData` for why that's now
+ * correct rather than a gap: once any fresh per-binding data exists,
+ * a miss means genuinely unreachable, not merely undocumented. */
+ setFlatSvgMapEntry(
+ file: string,
+ name: string,
+ resolvedPath: string,
+ rootsReaching: ReadonlyMap>
+ ): void {
+ const entry = { path: resolvedPath };
+ this.localSvgMap[name] = entry;
+ const consumers = rootsReaching.get(exportNodeKey(file, name));
+ if (consumers) {
+ for (const consumerRoot of consumers) {
+ this.localSvgMapByBinding[consumerRoot] = entry;
+ this.hasBindingData = true;
+ }
+ }
+ }
+
+ /** Resolves the entry a real JSX usage of `name` while transforming
+ * `currentFile` should use -- prefers the precise per-binding entry
+ * (disambiguated by file) over the flat, name-only map, since two
+ * different files can each legitimately alias a different SVG under
+ * the same local name.
+ *
+ * Once `hasBindingData` is true, a `byBinding` miss returns `undefined`
+ * directly rather than falling through to the flat map: a fresh
+ * scan's `rootsReaching` propagation (see `buildSvgMap.ts`) already
+ * gives every file that legitimately consumes a resolved SVG --
+ * directly or through a re-export chain -- its OWN `byBinding` entry,
+ * so a miss at this point means `name` genuinely isn't reachable from
+ * `currentFile` at all. Falling back to the flat map there would risk
+ * silently inheriting an entry that belongs to some OTHER, unconnected
+ * file that merely happens to share the same text (e.g. a real
+ * ` ` from a UI library colliding with an aliased SVG also
+ * named `Icon` in an unrelated file). The flat map is only consulted
+ * when `hasBindingData` is false, i.e. a legacy (pre-versioned) disk
+ * cache with no per-binding data was loaded -- the same, historical,
+ * non-regressive behavior as before per-binding disambiguation
+ * existed. */
+ getLocalSvgEntry(currentFile: string, name: string): SvgEntry | undefined {
+ const byBindingHit = this.localSvgMapByBinding[
+ nodeKey(currentFile, name)
+ ];
+ if (byBindingHit) {
+ return byBindingHit;
+ }
+ return this.hasBindingData ? undefined : this.localSvgMap[name];
+ }
+
+ /** Populates `localSvgMap` (and, via `rootsReaching`,
+ * `localSvgMapByBinding`) for each `ExportSpecifier` on an `export {
+ * ... } from '...svg'` declaration, declared in `file`, once its
+ * source has been resolved to a real `.svg` path. `nameFilter`
+ * restricts which exported names actually get written: `null` means
+ * "all of them" (the always-a-real-`.svg`-extension call site, where
+ * every specifier is legitimately an SVG regardless of JSX usage); a
+ * `Set` means "only these" (the bare/aliased-source call site, where
+ * only the specific names proven reachable from a real JSX usage
+ * should be populated -- writing the rest would risk a same-named,
+ * unrelated component elsewhere being misidentified as this SVG).
+ * Both call sites run only after `rootsReaching` has been computed
+ * (see `buildSvgMap.ts`), even though the `nameFilter: null` case is
+ * conceptually eager -- deferred so its `setFlatSvgMapEntry` calls
+ * can still attribute a precise `localSvgMapByBinding` entry to
+ * whichever file(s) actually consume it. */
+ populateExportedSvgNames(
+ t: typeof Babel.types,
+ file: string,
+ path: Babel.NodePath,
+ resolved: string,
+ nameFilter: ReadonlySet | null,
+ rootsReaching: ReadonlyMap>
+ ): void {
+ for (const spec of path.node.specifiers) {
+ if (spec.type === 'ExportSpecifier') {
+ const name = getExportedName(t, spec.exported);
+ if (name && (!nameFilter || nameFilter.has(name))) {
+ this.setFlatSvgMapEntry(
+ file,
+ name,
+ resolved,
+ rootsReaching
+ );
+ }
+ } else {
+ console.warn(
+ `[buildSvgMap]: Unhandled export specifier type: ${spec.type}`
+ );
+ }
+ }
+ }
+}
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..fc179688e 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
@@ -16,6 +16,7 @@ import path from 'path';
import plugin from '../src/index';
import { RNSvgHandler } from '../src/libraries/react-native-svg/handlers/RNSvgHandler';
+import { PathAliasResolver } from '../src/libraries/react-native-svg/pathAliasResolver';
import { ReactNativeSVG } from '../src/libraries/react-native-svg';
/**
@@ -1261,4 +1262,1662 @@ describe('ReactNativeSVG.buildSvgMap', () => {
scopedInstance.buildSvgMap();
expect(scopedInstance.localSvgMap['StarIcon']).toBeUndefined();
});
+
+ it("should store a __proto__-named import as an ordinary own property, not reassign localSvgMap's own prototype", () => {
+ // Read as a dynamic key rather than a literal `'__proto__'` string
+ // both to mirror how `localSvgMap` is actually indexed in
+ // production (always by a runtime-derived name) and to avoid
+ // triggering ESLint's `no-proto` rule, which exists for the
+ // opposite reason this test does -- accidental use of the special
+ // accessor -- not for deliberately verifying it's been neutralized.
+ const protoKey = '__proto__';
+ fs.writeFileSync(
+ path.join(tmpDir, 'Component.tsx'),
+ `import ${protoKey} from './icon.svg';\nexport default function C() { return <${protoKey} />; }`
+ );
+
+ const instance = new ReactNativeSVG(tmpDir, tmpDir, false);
+ instance.setApiTypes(t);
+ instance.buildSvgMap();
+
+ expect(Object.getPrototypeOf(instance.localSvgMap)).toBeNull();
+ expect(
+ Object.prototype.hasOwnProperty.call(instance.localSvgMap, protoKey)
+ ).toBe(true);
+ expect(instance.localSvgMap[protoKey].path).toBe(
+ path.join(tmpDir, 'icon.svg')
+ );
+ // A totally unrelated lookup must still behave like a normal missing
+ // key, not like a reassigned prototype leaking through.
+ expect(instance.localSvgMap['SomethingElse']).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 each of two monorepo subpackages using its OWN nearest tsconfig.json, not whichever one happened to resolve first for the whole scan', () => {
+ fs.mkdirSync(path.join(tmpDir, 'packages', 'appA', 'src'), {
+ recursive: true
+ });
+ fs.mkdirSync(path.join(tmpDir, 'packages', 'appB', 'assets'), {
+ recursive: true
+ });
+ fs.writeFileSync(
+ path.join(tmpDir, 'packages', 'appA', 'src', 'icon.svg'),
+ ' '
+ );
+ fs.writeFileSync(
+ path.join(tmpDir, 'packages', 'appB', 'assets', 'icon.svg'),
+ ' '
+ );
+ fs.writeFileSync(
+ path.join(tmpDir, 'packages', 'appA', 'tsconfig.json'),
+ JSON.stringify({
+ compilerOptions: {
+ baseUrl: '.',
+ paths: { '@a/*': ['src/*'] }
+ }
+ })
+ );
+ fs.writeFileSync(
+ path.join(tmpDir, 'packages', 'appB', 'tsconfig.json'),
+ JSON.stringify({
+ compilerOptions: {
+ baseUrl: '.',
+ paths: { '@b/*': ['assets/*'] }
+ }
+ })
+ );
+ fs.writeFileSync(
+ path.join(tmpDir, 'packages', 'appA', 'Component.tsx'),
+ `import IconA from '@a/icon.svg';\nexport default function A() { return ; }`
+ );
+ fs.writeFileSync(
+ path.join(tmpDir, 'packages', 'appB', 'Component.tsx'),
+ `import IconB from '@b/icon.svg';\nexport default function B() { return ; }`
+ );
+
+ const instance = new ReactNativeSVG(tmpDir, tmpDir, false);
+ instance.setApiTypes(t);
+ instance.buildSvgMap();
+
+ expect(instance.localSvgMap['IconA']).toBeDefined();
+ expect(instance.localSvgMap['IconA'].path).toBe(
+ path.join(tmpDir, 'packages', 'appA', 'src', 'icon.svg')
+ );
+ expect(instance.localSvgMap['IconB']).toBeDefined();
+ expect(instance.localSvgMap['IconB'].path).toBe(
+ path.join(tmpDir, 'packages', 'appB', 'assets', '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 a babel-plugin-module-resolver alias from babel.config.js at the project root even when the scan root (e.g. --path ./src) is a subdirectory of it', () => {
+ const moduleResolverPath = require.resolve(
+ 'babel-plugin-module-resolver'
+ );
+ // babel.config.js lives at the project root, one level above the
+ // scan root passed to ReactNativeSVG below. Its alias target is
+ // written naturally relative to the project root (where the config
+ // file itself lives) -- PathAliasResolver pins module-resolver's
+ // `cwd` to whatever directory `loadPartialConfig` actually resolved
+ // as the config root, not to its own (possibly narrower) rootDir.
+ fs.writeFileSync(
+ path.join(tmpDir, 'babel.config.js'),
+ `module.exports = {
+ plugins: [
+ [${JSON.stringify(moduleResolverPath)}, {
+ alias: { '@components': './src/components' }
+ }]
+ ]
+ };`
+ );
+ fs.writeFileSync(
+ path.join(tmpDir, 'src', 'Component.tsx'),
+ `import Logo from '@components/icon.svg';\nexport default function C() { return ; }`
+ );
+
+ const scanRoot = path.join(tmpDir, 'src');
+ const instance = new ReactNativeSVG(scanRoot, scanRoot, 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 babel-plugin-module-resolver alias that maps to a real npm package name (rather than a relative/local path), a pattern the library's own README documents", () => {
+ const moduleResolverPath = require.resolve(
+ 'babel-plugin-module-resolver'
+ );
+ // A real installed "package" the alias points at, so the resolved
+ // value (`fake-icon-lib/icon.svg`) is neither absolute nor
+ // `./`/`../`-relative -- it can only be found via Node's own module
+ // resolution, not by treating it as a filesystem path directly.
+ fs.mkdirSync(path.join(tmpDir, 'node_modules', 'fake-icon-lib'), {
+ recursive: true
+ });
+ fs.writeFileSync(
+ path.join(tmpDir, 'node_modules', 'fake-icon-lib', 'icon.svg'),
+ ' '
+ );
+ fs.writeFileSync(
+ path.join(tmpDir, 'babel.config.js'),
+ `module.exports = {
+ plugins: [
+ [${JSON.stringify(moduleResolverPath)}, {
+ alias: { '@icons': 'fake-icon-lib' }
+ }]
+ ]
+ };`
+ );
+ fs.writeFileSync(
+ path.join(tmpDir, 'Component.tsx'),
+ `import Logo from '@icons/icon.svg';\nexport default function C() { return ; }`
+ );
+
+ const instance = new ReactNativeSVG(tmpDir, tmpDir, false);
+ instance.setApiTypes(t);
+ instance.buildSvgMap();
+
+ expect(instance.localSvgMap['Logo']).toBeDefined();
+ // `require.resolve` returns the canonicalized (symlink-resolved)
+ // path -- e.g. macOS's /tmp -> /private/tmp -- unlike the other
+ // resolvers here which just join path strings without touching the
+ // filesystem, so compare against the realpath rather than the raw
+ // `tmpDir` string.
+ expect(instance.localSvgMap['Logo'].path).toBe(
+ fs.realpathSync(
+ path.join(tmpDir, 'node_modules', 'fake-icon-lib', 'icon.svg')
+ )
+ );
+ });
+
+ it('should resolve a babel-plugin-module-resolver alias to a real npm package .svg file even when the specifier itself omits the extension', () => {
+ const moduleResolverPath = require.resolve(
+ 'babel-plugin-module-resolver'
+ );
+ // The specifier ('@icons/icon', no '.svg') means the alias
+ // substitution also lands on an extensionless bare specifier
+ // ('fake-icon-lib/icon') -- `require.resolve` alone won't find
+ // `icon.svg` for that (it only tries .js/.json/.node by default),
+ // so this needs the extension-guessing fallback.
+ fs.mkdirSync(path.join(tmpDir, 'node_modules', 'fake-icon-lib'), {
+ recursive: true
+ });
+ fs.writeFileSync(
+ path.join(tmpDir, 'node_modules', 'fake-icon-lib', 'icon.svg'),
+ ' '
+ );
+ fs.writeFileSync(
+ path.join(tmpDir, 'babel.config.js'),
+ `module.exports = {
+ plugins: [
+ [${JSON.stringify(moduleResolverPath)}, {
+ alias: { '@icons': 'fake-icon-lib' }
+ }]
+ ]
+ };`
+ );
+ fs.writeFileSync(
+ path.join(tmpDir, 'Component.tsx'),
+ `import Logo from '@icons/icon';\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(
+ fs.realpathSync(
+ path.join(tmpDir, 'node_modules', 'fake-icon-lib', '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 NOT populate localSvgMap for a bare, .svg-suffixed import that no alias mechanism can resolve, rather than fabricate a path relative to the importing file (which would be guaranteed not to exist on disk)', () => {
+ 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']).toBeUndefined();
+ });
+
+ it('should NOT populate localSvgMap when a configured alias does not match a file on disk, rather than fabricate a nonexistent path', () => {
+ 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']).toBeUndefined();
+ });
+
+ 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')
+ );
+ });
+
+ 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 trace an aliased import through a default re-export (`import X from "@alias"; export default X;`) to a JSX usage in a different 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, 'icons.ts'),
+ `import Logo from '@logo';\nexport default Logo;`
+ );
+ fs.writeFileSync(
+ path.join(tmpDir, 'Screen.tsx'),
+ `import Icon 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 trace an aliased import through a sourceless local re-export (`import X from "@alias"; export { X as Y };`) to a JSX usage in a different 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, 'icons.ts'),
+ `import Logo from '@logo';\nexport { Logo as AppLogo };`
+ );
+ fs.writeFileSync(
+ path.join(tmpDir, 'Screen.tsx'),
+ `import { AppLogo } 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 trace an aliased import through a sourceless local re-export that does NOT rename (`import X from "@alias"; export { X };`) to a JSX usage in a different 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' }
+ }]
+ ]
+ };`
+ );
+ // No rename here -- the exported name and the local binding name
+ // are the same text ('Logo'), which is the common case for a
+ // sourceless re-export and the one the fix specifically targets.
+ fs.writeFileSync(
+ path.join(tmpDir, 'icons.ts'),
+ `import Logo from '@logo';\nexport { 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 trace an aliased import through an `export * from` wildcard barrel whose target is itself a NON-renaming sourceless local re-export', () => {
+ 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' }
+ }]
+ ]
+ };`
+ );
+ // barrel.ts's own export is a sourceless, non-renaming local
+ // re-export of an aliased import -- the wildcard fan-out (an
+ // export node) has to hand off into barrel.ts's LOCAL scope (via
+ // recordLocalReexportEdges), not stay entirely within the export
+ // namespace the way the other wildcard tests do.
+ fs.writeFileSync(
+ path.join(tmpDir, 'barrel.ts'),
+ `import Logo from '@logo';\nexport { Logo };`
+ );
+ fs.writeFileSync(
+ path.join(tmpDir, 'icons.ts'),
+ `export * from './barrel';`
+ );
+ 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 trace an aliased import through an `export * from` wildcard barrel to a JSX usage in a different 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' }
+ }]
+ ]
+ };`
+ );
+ // icons/Icon1.ts named-re-exports the aliased SVG import; icons.ts
+ // wildcard-re-exports everything from Icon1.ts under the same name.
+ fs.mkdirSync(path.join(tmpDir, 'icons'), { recursive: true });
+ fs.writeFileSync(
+ path.join(tmpDir, 'icons', 'Icon1.ts'),
+ `export { default as Logo } from '@logo';`
+ );
+ fs.writeFileSync(
+ path.join(tmpDir, 'icons.ts'),
+ `export * from './icons/Icon1';`
+ );
+ 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 trace through the correct one of TWO `export * from` wildcards on the same barrel file, not just the first one visited', () => {
+ 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' }
+ }]
+ ]
+ };`
+ );
+ // icons.ts wildcard-re-exports from BOTH IconsA (which exports
+ // nothing named Logo) and IconsB (which does, via the aliased SVG
+ // import). The wildcard for IconsA is declared first, so a naive
+ // "first wildcard claims the name" implementation would wrongly
+ // bind Logo to IconsA's (nonexistent) export and miss IconsB's.
+ fs.mkdirSync(path.join(tmpDir, 'icons'), { recursive: true });
+ fs.writeFileSync(
+ path.join(tmpDir, 'icons', 'IconsA.ts'),
+ `export const Unrelated = 1;`
+ );
+ fs.writeFileSync(
+ path.join(tmpDir, 'icons', 'IconsB.ts'),
+ `export { default as Logo } from '@logo';`
+ );
+ fs.writeFileSync(
+ path.join(tmpDir, 'icons.ts'),
+ `export * from './icons/IconsA';\nexport * from './icons/IconsB';`
+ );
+ 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 trace an aliased import through an `export * from` wildcard whose OWN source is itself a bare/aliased specifier (not just a relative path)', () => {
+ 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',
+ '@icons': './icons'
+ }
+ }]
+ ]
+ };`
+ );
+ fs.mkdirSync(path.join(tmpDir, 'icons'), { recursive: true });
+ fs.writeFileSync(
+ path.join(tmpDir, 'icons', 'index.ts'),
+ `export { default as Logo } from '@logo';`
+ );
+ // The wildcard barrel's own source ('@icons') is a bare/aliased
+ // specifier, not a relative path.
+ fs.writeFileSync(
+ path.join(tmpDir, 'barrel.ts'),
+ `export * from '@icons';`
+ );
+ fs.writeFileSync(
+ path.join(tmpDir, 'Screen.tsx'),
+ `import { Logo } from './barrel';\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 trace an aliased import through a re-export barrel whose target only exists as platform-specific files (e.g. Icon.ios.tsx/Icon.android.tsx, no plain Icon.tsx)', () => {
+ 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' }
+ }]
+ ]
+ };`
+ );
+ // Icon.tsx re-exports the aliased SVG import, but only ever exists
+ // as platform-split files -- no plain Icon.tsx fallback.
+ fs.writeFileSync(
+ path.join(tmpDir, 'Icon.ios.tsx'),
+ `export { default as Logo } from '@logo';`
+ );
+ fs.writeFileSync(
+ path.join(tmpDir, 'Icon.android.tsx'),
+ `export { default as Logo } from '@logo';`
+ );
+ fs.writeFileSync(
+ path.join(tmpDir, 'Screen.tsx'),
+ `import { Logo } from './Icon';\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 prefer a platform-specific re-export target (Icon.ios.tsx) over a plain fallback (Icon.tsx) when both exist, matching real Metro resolution order', () => {
+ const moduleResolverPath = require.resolve(
+ 'babel-plugin-module-resolver'
+ );
+ fs.writeFileSync(
+ path.join(tmpDir, 'babel.config.js'),
+ `module.exports = {
+ plugins: [
+ [${JSON.stringify(moduleResolverPath)}, {
+ alias: {
+ '@logoIos': './src/components/icon-ios.svg',
+ '@logoPlain': './src/components/icon-plain.svg'
+ }
+ }]
+ ]
+ };`
+ );
+ // Both a plain fallback AND a platform-specific variant exist --
+ // Metro (and this resolver) must pick the platform-specific one.
+ fs.writeFileSync(
+ path.join(tmpDir, 'Icon.tsx'),
+ `export { default as Logo } from '@logoPlain';`
+ );
+ fs.writeFileSync(
+ path.join(tmpDir, 'Icon.ios.tsx'),
+ `export { default as Logo } from '@logoIos';`
+ );
+ fs.writeFileSync(
+ path.join(tmpDir, 'Screen.tsx'),
+ `import { Logo } from './Icon';\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-ios.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 NOT resolve an aliased import in one file just because an unrelated, identically-named component is rendered as JSX in a different file (the JSX-usage guard is per-binding, not a project-wide name match)', () => {
+ const moduleResolverPath = require.resolve(
+ 'babel-plugin-module-resolver'
+ );
+ fs.writeFileSync(
+ path.join(tmpDir, 'babel.config.js'),
+ `module.exports = {
+ plugins: [
+ [${JSON.stringify(moduleResolverPath)}, {
+ alias: { '@icons/button': './src/components/icon.svg' }
+ }]
+ ]
+ };`
+ );
+ // A real, non-SVG "Button" is imported from an unrelated package and
+ // rendered as JSX here -- this must not justify resolving the
+ // unrelated aliased "Button" SVG import in IconButton.tsx below,
+ // which is never itself rendered as JSX anywhere.
+ fs.writeFileSync(
+ path.join(tmpDir, 'Screen.tsx'),
+ `import { Button } from 'some-ui-library';\nexport default function Screen() { return ; }`
+ );
+ fs.writeFileSync(
+ path.join(tmpDir, 'IconButton.tsx'),
+ `import Button from '@icons/button';\nexport default Button;`
+ );
+
+ const instance = new ReactNativeSVG(tmpDir, tmpDir, false);
+ instance.setApiTypes(t);
+ instance.buildSvgMap();
+
+ expect(instance.localSvgMap['Button']).toBeUndefined();
+ });
+
+ it("should NOT let a same-file re-export's exported name leak into an unrelated LOCAL import that merely happens to share the same text", () => {
+ 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, 'svgBarrel.ts'),
+ `export { default as Icon } from '@logo';`
+ );
+ // Screen.tsx has an unrelated LOCAL "Icon" import (a real, non-SVG
+ // component) AND separately re-exports a DIFFERENT "Icon" from the
+ // SVG barrel above under the exact same name -- valid, non-
+ // conflicting ES module syntax, since an `export { X } from
+ // './y'` clause never references the importing file's own local
+ // scope. The JSX usage below refers only to the local import.
+ fs.writeFileSync(
+ path.join(tmpDir, 'Screen.tsx'),
+ `import Icon from 'some-ui-library';\nexport { Icon } from './svgBarrel';\nexport default function Screen() { return ; }`
+ );
+
+ const instance = new ReactNativeSVG(tmpDir, tmpDir, false);
+ instance.setApiTypes(t);
+ instance.buildSvgMap();
+
+ expect(instance.localSvgMap['Icon']).toBeUndefined();
+ });
+
+ it('should only populate localSvgMap for the specific co-imported name proven JSX-reachable, not every name on the same aliased import statement', () => {
+ const moduleResolverPath = require.resolve(
+ 'babel-plugin-module-resolver'
+ );
+ fs.writeFileSync(
+ path.join(tmpDir, 'babel.config.js'),
+ `module.exports = {
+ plugins: [
+ [${JSON.stringify(moduleResolverPath)}, {
+ alias: { '@icons': './src/components/icon.svg' }
+ }]
+ ]
+ };`
+ );
+ // A single, extensionless aliased (bare) import statement co-imports
+ // two names -- both resolve to the SAME aliased .svg file, since
+ // alias resolution operates on the whole import source, not per
+ // specifier. Only RealIcon is ever rendered as JSX through this
+ // import; UnrelatedName is merely co-imported alongside it.
+ fs.writeFileSync(
+ path.join(tmpDir, 'Icons.tsx'),
+ `import { RealIcon, UnrelatedName } from '@icons';\nexport default function C() { return ; }`
+ );
+ // A completely unrelated, real (non-SVG) component happens to share
+ // the co-imported-but-unused name and IS rendered as JSX elsewhere.
+ fs.writeFileSync(
+ path.join(tmpDir, 'Screen.tsx'),
+ `import { UnrelatedName } from 'some-real-ui-lib';\nexport default function Screen() { return ; }`
+ );
+
+ const instance = new ReactNativeSVG(tmpDir, tmpDir, false);
+ instance.setApiTypes(t);
+ instance.buildSvgMap();
+
+ expect(instance.localSvgMap['RealIcon']).toBeDefined();
+ expect(instance.localSvgMap['UnrelatedName']).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 not let one aliased import whose resolution throws abort SVG detection for the rest of the project', () => {
+ 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' }
+ }]
+ ]
+ };`
+ );
+ // @broken is never configured, but the important part is that
+ // resolving it throws (simulating a third-party resolver -- e.g.
+ // tsconfig-paths' matchPath -- misbehaving on a malformed config)
+ // rather than returning null.
+ fs.writeFileSync(
+ path.join(tmpDir, 'Broken.tsx'),
+ `import Broken from '@broken';\nexport default function B() { return ; }`
+ );
+ fs.writeFileSync(
+ path.join(tmpDir, 'Component.tsx'),
+ `import Logo from '@logo';\nexport default function C() { return ; }`
+ );
+
+ const originalResolve = PathAliasResolver.prototype.resolve;
+ const resolveSpy = jest
+ .spyOn(PathAliasResolver.prototype, 'resolve')
+ .mockImplementation(function (
+ this: PathAliasResolver,
+ source,
+ file
+ ) {
+ if (source === '@broken') {
+ throw new Error('simulated third-party resolver failure');
+ }
+ return originalResolve.call(this, source, file);
+ } as typeof PathAliasResolver.prototype.resolve);
+
+ const instance = new ReactNativeSVG(tmpDir, tmpDir, false);
+ instance.setApiTypes(t);
+
+ expect(() => instance.buildSvgMap()).not.toThrow();
+ expect(instance.localSvgMap['Broken']).toBeUndefined();
+ expect(instance.localSvgMap['Logo']).toBeDefined();
+ expect(instance.localSvgMap['Logo'].path).toBe(
+ path.join(tmpDir, 'src', 'components', 'icon.svg')
+ );
+
+ 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 metro.config.js extraNodeModules alias from the project root even when the scan root (e.g. --path ./src) is a subdirectory of it', () => {
+ // metro.config.js lives at the project root, one level above the
+ // scan root passed to ReactNativeSVG below -- a single lookup
+ // inside the scan root alone would miss it.
+ 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, 'src', 'Component.tsx'),
+ `import Logo from 'assets/icon.svg';\nexport default function C() { return ; }`
+ );
+
+ const scanRoot = path.join(tmpDir, 'src');
+ const instance = new ReactNativeSVG(scanRoot, scanRoot, 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
+ // stays unresolved like any other bare specifier no alias mechanism
+ // can find a real file for -- not populated in localSvgMap at all.
+ expect(instance.localSvgMap['Logo']).toBeUndefined();
+ });
+
+ it('should not crash the whole scan when a bare import specifier matches an inherited Object.prototype member (e.g. "constructor/whatever") against a configured extraNodeModules map', () => {
+ 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 Foo from 'constructor/whatever';\nexport default function C() { return ; }`
+ );
+
+ const instance = new ReactNativeSVG(tmpDir, tmpDir, false);
+ instance.setApiTypes(t);
+
+ expect(() => instance.buildSvgMap()).not.toThrow();
+ expect(instance.localSvgMap['Foo']).toBeUndefined();
+ });
+
+ 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')
+ );
+ });
+});
+
+describe('ReactNativeSVG end-to-end: same local name aliased to different SVGs across files', () => {
+ it('should embed the correct, distinct SVG content for each file when two unrelated files each alias a DIFFERENT SVG under the same local name', () => {
+ const tmpDir = fs.mkdtempSync(
+ path.join(os.tmpdir(), 'dd-svg-collision-')
+ );
+ const assetsDir = fs.mkdtempSync(
+ path.join(os.tmpdir(), 'dd-svg-collision-assets-')
+ );
+ const moduleResolverPath = require.resolve(
+ 'babel-plugin-module-resolver'
+ );
+
+ fs.writeFileSync(
+ path.join(tmpDir, 'circle.svg'),
+ ' '
+ );
+ fs.writeFileSync(
+ path.join(tmpDir, 'square.svg'),
+ ' '
+ );
+ fs.writeFileSync(
+ path.join(tmpDir, 'babel.config.js'),
+ `module.exports = {
+ plugins: [
+ [${JSON.stringify(moduleResolverPath)}, {
+ alias: {
+ '@circle-icon': './circle.svg',
+ '@square-icon': './square.svg'
+ }
+ }]
+ ]
+ };`
+ );
+
+ const fileACode =
+ "import Icon from '@circle-icon';\nexport default function A() { return ; }";
+ const fileBCode =
+ "import Icon from '@square-icon';\nexport default function B() { return ; }";
+ const fileAPath = path.join(tmpDir, 'FileA.tsx');
+ const fileBPath = path.join(tmpDir, 'FileB.tsx');
+ fs.writeFileSync(fileAPath, fileACode);
+ fs.writeFileSync(fileBPath, fileBCode);
+
+ // One shared ReactNativeSVG instance and one buildSvgMap() scan --
+ // exactly how a real build reuses it across every file compiled in
+ // the same session (see src/index.ts's `pre()` hook).
+ const instance = new ReactNativeSVG(tmpDir, assetsDir);
+ instance.setApiTypes(t);
+ instance.buildSvgMap();
+
+ const transformOpts = {
+ presets: ['@babel/preset-react', '@babel/preset-typescript'],
+ plugins: [
+ [
+ plugin,
+ {
+ sessionReplay: { svgTracking: true },
+ __internal_reactNativeSVG: instance
+ }
+ ]
+ ],
+ configFile: false
+ };
+
+ const outputA = transform(fileACode, {
+ ...transformOpts,
+ filename: fileAPath
+ })?.code as string;
+ const outputB = transform(fileBCode, {
+ ...transformOpts,
+ filename: fileBPath
+ })?.code as string;
+
+ const hashA = outputA.match(/hash:\s*["']([0-9a-f]{32})["']/i)?.[1];
+ const hashB = outputB.match(/hash:\s*["']([0-9a-f]{32})["']/i)?.[1];
+ expect(hashA).toBeTruthy();
+ expect(hashB).toBeTruthy();
+
+ const contentA = fs.readFileSync(
+ path.join(assetsDir, `${hashA}.svg`),
+ 'utf8'
+ );
+ const contentB = fs.readFileSync(
+ path.join(assetsDir, `${hashB}.svg`),
+ 'utf8'
+ );
+
+ // FileA aliased Icon to circle.svg -- must never end up with
+ // FileB's square, and vice versa. svgo's optimizer converts the
+ // into an equivalent as part of its default preset,
+ // so check for the shape actually surviving optimization (a
+ // ``) landing on the right file, rather than assuming the
+ // original tag names remain verbatim in the optimized output.
+ expect(contentA).toContain('circle');
+ expect(contentB).not.toContain('circle');
+ expect(contentA).not.toBe(contentB);
+ });
+
+ it('should resolve a local import correctly even when the same file also re-exports an unrelated SVG under the identical name (local scope and export table are separate namespaces)', () => {
+ const tmpDir = fs.mkdtempSync(
+ path.join(os.tmpdir(), 'dd-svg-collision-')
+ );
+ const assetsDir = fs.mkdtempSync(
+ path.join(os.tmpdir(), 'dd-svg-collision-assets-')
+ );
+ const moduleResolverPath = require.resolve(
+ 'babel-plugin-module-resolver'
+ );
+
+ fs.writeFileSync(
+ path.join(tmpDir, 'circle.svg'),
+ ' '
+ );
+ fs.writeFileSync(
+ path.join(tmpDir, 'square.svg'),
+ ' '
+ );
+ fs.writeFileSync(
+ path.join(tmpDir, 'babel.config.js'),
+ `module.exports = {
+ plugins: [
+ [${JSON.stringify(moduleResolverPath)}, {
+ alias: {
+ '@circle-icon': './circle.svg',
+ '@square-icon': './square.svg'
+ }
+ }]
+ ]
+ };`
+ );
+
+ // SameFile both LOCALLY imports Icon (from circle.svg) and
+ // separately re-exports an unrelated Icon (from square.svg) under
+ // its own export table -- two different namespaces that happen to
+ // share a name. Its own JSX unambiguously refers to the local
+ // import; the re-export only matters to another file that imports
+ // {Icon} from SameFile, never to SameFile's own scope.
+ const sameFileCode =
+ "import Icon from '@circle-icon';\nexport { default as Icon } from '@square-icon';\nexport default function SameFile() { return ; }";
+ // OtherFile is what makes the re-export actually reachable (and
+ // therefore populated at all) -- without a cross-file consumer,
+ // the performance guard would skip resolving '@square-icon'
+ // entirely.
+ const otherFileCode =
+ "import { Icon } from './SameFile';\nexport default function OtherFile() { return ; }";
+ const sameFilePath = path.join(tmpDir, 'SameFile.tsx');
+ const otherFilePath = path.join(tmpDir, 'OtherFile.tsx');
+ fs.writeFileSync(sameFilePath, sameFileCode);
+ fs.writeFileSync(otherFilePath, otherFileCode);
+
+ const instance = new ReactNativeSVG(tmpDir, assetsDir);
+ instance.setApiTypes(t);
+ instance.buildSvgMap();
+
+ const outputSameFile = transform(sameFileCode, {
+ presets: ['@babel/preset-react', '@babel/preset-typescript'],
+ plugins: [
+ [
+ plugin,
+ {
+ sessionReplay: { svgTracking: true },
+ __internal_reactNativeSVG: instance
+ }
+ ]
+ ],
+ configFile: false,
+ filename: sameFilePath
+ })?.code as string;
+
+ const hash = outputSameFile.match(
+ /hash:\s*["']([0-9a-f]{32})["']/i
+ )?.[1];
+ expect(hash).toBeTruthy();
+
+ const content = fs.readFileSync(
+ path.join(assetsDir, `${hash}.svg`),
+ 'utf8'
+ );
+
+ // SameFile's own ` ` must resolve to its LOCAL import
+ // (circle.svg), never to the unrelated re-export (square.svg) that
+ // merely happens to share the same exported name.
+ expect(content).toContain('circle');
+ });
+
+ it("should NOT let a completely unrelated file's real, non-SVG component be misidentified as an SVG just because some OTHER, disconnected file legitimately aliases a DIFFERENT component under the same local name", () => {
+ const tmpDir = fs.mkdtempSync(
+ path.join(os.tmpdir(), 'dd-svg-collision-')
+ );
+ const assetsDir = fs.mkdtempSync(
+ path.join(os.tmpdir(), 'dd-svg-collision-assets-')
+ );
+
+ fs.writeFileSync(
+ path.join(tmpDir, 'circle.svg'),
+ ' '
+ );
+
+ // FileB legitimately aliases (a direct relative import here, but
+ // the same risk applies to any resolution mechanism) a real SVG
+ // under the name "Icon", and renders it -- this is what proves
+ // the name reachable and gets it written to the flat
+ // `localSvgMap`.
+ const fileBCode =
+ "import Icon from './circle.svg';\nexport default function B() { return ; }";
+ // FileA has NO relationship to FileB whatsoever -- its own "Icon"
+ // is a real, unrelated component from some other library. It
+ // just happens to share the exact same local name.
+ const fileACode =
+ "import Icon from 'some-real-ui-lib';\nexport default function A() { return ; }";
+ const fileBPath = path.join(tmpDir, 'FileB.tsx');
+ const fileAPath = path.join(tmpDir, 'FileA.tsx');
+ fs.writeFileSync(fileBPath, fileBCode);
+ fs.writeFileSync(fileAPath, fileACode);
+
+ const instance = new ReactNativeSVG(tmpDir, assetsDir);
+ instance.setApiTypes(t);
+ instance.buildSvgMap();
+
+ // The flat map still carries FileB's entry (backward-compatible,
+ // documented residual imprecision) -- the bug is specifically
+ // whether FileA's own JSX inherits it.
+ expect(instance.localSvgMap['Icon']).toBeDefined();
+
+ const outputFileA = transform(fileACode, {
+ presets: ['@babel/preset-react', '@babel/preset-typescript'],
+ plugins: [
+ [
+ plugin,
+ {
+ sessionReplay: { svgTracking: true },
+ __internal_reactNativeSVG: instance
+ }
+ ]
+ ],
+ configFile: false,
+ filename: fileAPath
+ })?.code as string;
+
+ // FileA's unrelated ` ` must be left completely untouched --
+ // never wrapped in SessionReplayView.Privacy, never given FileB's
+ // circle.svg content, since it has no traceable relationship to
+ // FileB at all.
+ expect(outputFileA).not.toContain('SessionReplayView');
+ expect(outputFileA).not.toMatch(/hash:\s*["'][0-9a-f]{32}["']/i);
+ });
});
diff --git a/yarn.lock b/yarn.lock
index 6f59da441..7c476e841 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2015,11 +2015,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.1.0
tsc-alias: ^1.8.16
+ tsconfig-paths: ^4.2.0
typescript: 5.9.3
uuid: ^8.3.2
peerDependencies:
@@ -6533,6 +6535,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"
@@ -6748,6 +6763,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
@@ -9641,6 +9657,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"
@@ -10213,6 +10238,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"
@@ -13519,6 +13556,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"
@@ -13621,6 +13667,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"
@@ -14874,7 +14927,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:
@@ -16264,7 +16317,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
@@ -16315,7 +16368,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:
@@ -16342,7 +16395,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:
@@ -17780,7 +17833,7 @@ __metadata:
languageName: node
linkType: hard
-"tsconfig-paths@npm:4.2.0":
+"tsconfig-paths@npm:4.2.0, tsconfig-paths@npm:^4.2.0":
version: 4.2.0
resolution: "tsconfig-paths@npm:4.2.0"
dependencies: