diff --git a/packages/metro/src/resolver.ts b/packages/metro/src/resolver.ts deleted file mode 100644 index 6352fb8f..00000000 --- a/packages/metro/src/resolver.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { MetroConfig } from '@react-native/metro-config'; -import type { Config as HarnessConfig } from '@react-native-harness/config'; - -type CustomResolver = NonNullable< - NonNullable['resolveRequest'] ->; - -export const getHarnessResolver = ( - metroConfig: MetroConfig, - harnessConfig: HarnessConfig -): CustomResolver => { - // Can be relative to the project root or absolute, need to normalize it - const resolvedEntryPointPath = require.resolve(harnessConfig.entryPoint, { - paths: [process.cwd()], - }); - - return (context, moduleName, platform) => { - const existingResolver = - metroConfig.resolver?.resolveRequest ?? context.resolveRequest; - const resolvedModule = existingResolver(context, moduleName, platform); - - // Replace the entry point with Harness - if ( - resolvedModule.type === 'sourceFile' && - resolvedModule.filePath === resolvedEntryPointPath - ) { - return { - type: 'sourceFile', - filePath: require.resolve('@react-native-harness/runtime/entry-point'), - }; - } - - // Intercept @jest/globals imports and redirect to mock module - if (moduleName === '@jest/globals') { - return { - type: 'sourceFile', - filePath: require.resolve('./jest-globals-mock'), - }; - } - - return resolvedModule; - }; -}; diff --git a/packages/metro/src/resolvers/composite-resolver.ts b/packages/metro/src/resolvers/composite-resolver.ts new file mode 100644 index 00000000..079613ff --- /dev/null +++ b/packages/metro/src/resolvers/composite-resolver.ts @@ -0,0 +1,16 @@ +import type { HarnessResolver, MetroResolver } from './types'; + +export const createHarnessResolver = ( + resolvers: HarnessResolver[] +): MetroResolver => { + return (context, moduleName, platform) => { + for (const resolver of resolvers) { + const result = resolver(context, moduleName, platform); + if (result != null) { + return result; + } + } + + return context.resolveRequest(context, moduleName, platform); + }; +}; diff --git a/packages/metro/src/resolvers/resolver.ts b/packages/metro/src/resolvers/resolver.ts new file mode 100644 index 00000000..b7f1fa9c --- /dev/null +++ b/packages/metro/src/resolvers/resolver.ts @@ -0,0 +1,77 @@ +import type { MetroConfig } from '@react-native/metro-config'; +import type { Config as HarnessConfig } from '@react-native-harness/config'; +import { createHarnessResolver } from './composite-resolver'; +import { createTsConfigResolver } from './tsconfig-resolver'; +import type { HarnessResolver, MetroResolver } from './types'; + +export const createHarnessEntryPointResolver = ( + harnessConfig: HarnessConfig +): HarnessResolver => { + // Can be relative to the project root or absolute, need to normalize it + const resolvedEntryPointPath = require.resolve(harnessConfig.entryPoint, { + paths: [process.cwd()], + }); + + return (_context, moduleName, _platform) => { + if (moduleName === resolvedEntryPointPath) { + return { + type: 'sourceFile', + filePath: require.resolve('@react-native-harness/runtime/entry-point'), + }; + } + + if (moduleName === harnessConfig.entryPoint) { + return { + type: 'sourceFile', + filePath: require.resolve('@react-native-harness/runtime/entry-point'), + }; + } + + if (typeof moduleName === 'string') { + try { + const resolvedModuleName = require.resolve(moduleName, { + paths: [process.cwd()], + }); + if (resolvedModuleName === resolvedEntryPointPath) { + return { + type: 'sourceFile', + filePath: require.resolve( + '@react-native-harness/runtime/entry-point' + ), + }; + } + } catch { + // Ignore and fall through + } + } + + return null; + }; +}; + +export const createJestGlobalsResolver = (): HarnessResolver => { + return (_context, moduleName, _platform) => { + // Intercept @jest/globals imports and redirect to mock module + if (moduleName === '@jest/globals') { + return { + type: 'sourceFile', + filePath: require.resolve('./jest-globals-mock'), + }; + } + + return null; + }; +}; + +export const getHarnessResolver = ( + metroConfig: MetroConfig, + harnessConfig: HarnessConfig +): MetroResolver => { + const resolvers: HarnessResolver[] = [ + createHarnessEntryPointResolver(harnessConfig), + createJestGlobalsResolver(), + createTsConfigResolver(process.cwd()), + ].filter((resolver): resolver is HarnessResolver => !!resolver); + + return createHarnessResolver(resolvers); +}; diff --git a/packages/metro/src/resolvers/tsconfig-resolver.ts b/packages/metro/src/resolvers/tsconfig-resolver.ts new file mode 100644 index 00000000..16ec3094 --- /dev/null +++ b/packages/metro/src/resolvers/tsconfig-resolver.ts @@ -0,0 +1,210 @@ +import path from 'path'; +import fs from 'fs'; +import type { Resolution, CustomResolutionContext } from 'metro-resolver'; +import type { HarnessResolver } from './types'; + +// This resolver is based on the Expo's implementation. +// https://github.com/expo/expo/blob/main/packages/%40expo/cli/src/start/server/metro/withMetroMultiPlatform.ts +// The reason to have it in Harness is that Expo doesn't set the resolveRequest function in the context. +// In order for tsconfig's paths to work, we need to recreate this logic ourselves. + +export type TsConfigPaths = { + paths: Record; + baseUrl: string; + hasBaseUrl: boolean; +} + +/** + * Load tsconfig.json or jsconfig.json and extract path mappings + */ +export const loadTsConfigPaths = ( + projectRoot: string +): TsConfigPaths | null => { + const configFiles = ['tsconfig.json', 'jsconfig.json']; + + for (const configFile of configFiles) { + const configPath = path.join(projectRoot, configFile); + + if (!fs.existsSync(configPath)) continue; + + try { + const content = fs.readFileSync(configPath, 'utf8'); + // Strip comments without touching string literals + const jsonContent = stripJsonComments(content); + const config = JSON.parse(jsonContent); + + const compilerOptions = config.compilerOptions || {}; + const paths = compilerOptions.paths || {}; + const baseUrl = compilerOptions.baseUrl; + + if (Object.keys(paths).length > 0 || baseUrl) { + return { + paths, + baseUrl: baseUrl ? path.resolve(projectRoot, baseUrl) : projectRoot, + hasBaseUrl: !!baseUrl, + }; + } + } catch (error) { + console.warn(`Failed to parse ${configFile}:`, error); + } + } + + return null; +}; + +const stripJsonComments = (input: string): string => { + let result = ''; + let inString = false; + let stringChar = ''; + let isEscaped = false; + let inLineComment = false; + let inBlockComment = false; + + for (let i = 0; i < input.length; i += 1) { + const char = input[i]; + const nextChar = input[i + 1]; + + if (inLineComment) { + if (char === '\n') { + inLineComment = false; + result += char; + } + continue; + } + + if (inBlockComment) { + if (char === '*' && nextChar === '/') { + inBlockComment = false; + i += 1; + } + continue; + } + + if (inString) { + result += char; + if (!isEscaped && char === stringChar) { + inString = false; + stringChar = ''; + } + isEscaped = !isEscaped && char === '\\'; + continue; + } + + if (char === '"' || char === "'") { + inString = true; + stringChar = char; + result += char; + isEscaped = false; + continue; + } + + if (char === '/' && nextChar === '/') { + inLineComment = true; + i += 1; + continue; + } + + if (char === '/' && nextChar === '*') { + inBlockComment = true; + i += 1; + continue; + } + + result += char; + } + + return result; +}; + +/** + * Match module name against tsconfig path pattern (supports wildcards) + */ +const matchPattern = ( + pattern: string, + moduleName: string +): { matched: boolean; captured: string } => { + const escapedPattern = pattern + .replace(/[.+?^${}()|[\]\\]/g, '\\$&') + .replace(/\*/g, '(.*)'); + + const regex = new RegExp(`^${escapedPattern}$`); + const match = moduleName.match(regex); + + return { + matched: !!match, + captured: match?.[1] || '', + }; +}; + +/** + * Resolve module using tsconfig path mappings + * Use this directly in your custom resolver + */ +export const resolveWithTsConfigPaths = ( + tsConfig: TsConfigPaths, + context: CustomResolutionContext, + moduleName: string, + platform: string | null +): Resolution | null => { + const { paths, baseUrl, hasBaseUrl } = tsConfig; + const resolveRequest = context.resolveRequest; + + if (!resolveRequest) { + return null; + } + + // Try path mappings first + for (const [pattern, targets] of Object.entries(paths)) { + const { matched, captured } = matchPattern(pattern, moduleName); + if (!matched) continue; + + // Try each target + for (const target of targets) { + const resolvedTarget = target.replace('*', captured); + const absolutePath = path.resolve(baseUrl, resolvedTarget); + + try { + return resolveRequest(context, absolutePath, platform); + } catch { + continue; + } + } + } + + // Try baseUrl for non-relative imports + if (hasBaseUrl && !moduleName.startsWith('.') && !moduleName.startsWith('/')) { + const absolutePath = path.resolve(baseUrl, moduleName); + try { + return resolveRequest(context, absolutePath, platform); + } catch { + // Fall through + } + } + + return null; +}; + +export const createTsConfigResolver = ( + projectRoot: string +): HarnessResolver => { + const tsConfig = loadTsConfigPaths(projectRoot); + + return (context, moduleName, platform) => { + if (!tsConfig) { + return null; + } + + if (!context.resolveRequest) { + return null; + } + + const resolved = resolveWithTsConfigPaths( + tsConfig, + context, + moduleName, + platform + ); + + return resolved ?? null; + }; +}; diff --git a/packages/metro/src/resolvers/types.ts b/packages/metro/src/resolvers/types.ts new file mode 100644 index 00000000..6244f47d --- /dev/null +++ b/packages/metro/src/resolvers/types.ts @@ -0,0 +1,4 @@ +import type { CustomResolutionContext, Resolution } from 'metro-resolver'; + +export type HarnessResolver = (context: CustomResolutionContext, moduleName: string, platform: string | null) => Resolution | null; +export type MetroResolver = (context: CustomResolutionContext, moduleName: string, platform: string | null) => Resolution; \ No newline at end of file diff --git a/packages/metro/src/withRnHarness.ts b/packages/metro/src/withRnHarness.ts index 557e5cf1..24d9b406 100644 --- a/packages/metro/src/withRnHarness.ts +++ b/packages/metro/src/withRnHarness.ts @@ -1,6 +1,6 @@ import type { MetroConfig } from 'metro-config'; import { getConfig } from '@react-native-harness/config'; -import { getHarnessResolver } from './resolver'; +import { getHarnessResolver } from './resolvers/resolver'; import { getHarnessManifest } from './manifest'; import { getHarnessBabelTransformerPath } from './babel-transformer'; import { getHarnessCacheStores } from './metro-cache';