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: