Skip to content

Commit ed502b4

Browse files
committed
feat: include tsconfig paths in resolution
1 parent 4d2be2b commit ed502b4

6 files changed

Lines changed: 308 additions & 44 deletions

File tree

packages/metro/src/resolver.ts

Lines changed: 0 additions & 43 deletions
This file was deleted.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import type { HarnessResolver, MetroResolver } from './types';
2+
3+
export const createHarnessResolver = (
4+
resolvers: HarnessResolver[]
5+
): MetroResolver => {
6+
return (context, moduleName, platform) => {
7+
for (const resolver of resolvers) {
8+
const result = resolver(context, moduleName, platform);
9+
if (result != null) {
10+
return result;
11+
}
12+
}
13+
14+
return context.resolveRequest(context, moduleName, platform);
15+
};
16+
};
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import type { MetroConfig } from '@react-native/metro-config';
2+
import type { Config as HarnessConfig } from '@react-native-harness/config';
3+
import { createHarnessResolver } from './composite-resolver';
4+
import { createTsConfigResolver } from './tsconfig-resolver';
5+
import type { HarnessResolver, MetroResolver } from './types';
6+
7+
export const createHarnessEntryPointResolver = (
8+
harnessConfig: HarnessConfig
9+
): HarnessResolver => {
10+
// Can be relative to the project root or absolute, need to normalize it
11+
const resolvedEntryPointPath = require.resolve(harnessConfig.entryPoint, {
12+
paths: [process.cwd()],
13+
});
14+
15+
return (_context, moduleName, _platform) => {
16+
if (moduleName === resolvedEntryPointPath) {
17+
return {
18+
type: 'sourceFile',
19+
filePath: require.resolve('@react-native-harness/runtime/entry-point'),
20+
};
21+
}
22+
23+
if (moduleName === harnessConfig.entryPoint) {
24+
return {
25+
type: 'sourceFile',
26+
filePath: require.resolve('@react-native-harness/runtime/entry-point'),
27+
};
28+
}
29+
30+
if (typeof moduleName === 'string') {
31+
try {
32+
const resolvedModuleName = require.resolve(moduleName, {
33+
paths: [process.cwd()],
34+
});
35+
if (resolvedModuleName === resolvedEntryPointPath) {
36+
return {
37+
type: 'sourceFile',
38+
filePath: require.resolve(
39+
'@react-native-harness/runtime/entry-point'
40+
),
41+
};
42+
}
43+
} catch {
44+
// Ignore and fall through
45+
}
46+
}
47+
48+
return null;
49+
};
50+
};
51+
52+
export const createJestGlobalsResolver = (): HarnessResolver => {
53+
return (_context, moduleName, _platform) => {
54+
// Intercept @jest/globals imports and redirect to mock module
55+
if (moduleName === '@jest/globals') {
56+
return {
57+
type: 'sourceFile',
58+
filePath: require.resolve('./jest-globals-mock'),
59+
};
60+
}
61+
62+
return null;
63+
};
64+
};
65+
66+
export const getHarnessResolver = (
67+
metroConfig: MetroConfig,
68+
harnessConfig: HarnessConfig
69+
): MetroResolver => {
70+
const resolvers: HarnessResolver[] = [
71+
createHarnessEntryPointResolver(harnessConfig),
72+
createJestGlobalsResolver(),
73+
createTsConfigResolver(process.cwd()),
74+
].filter((resolver): resolver is HarnessResolver => !!resolver);
75+
76+
return createHarnessResolver(resolvers);
77+
};
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
import path from 'path';
2+
import fs from 'fs';
3+
import type { Resolution, CustomResolutionContext } from 'metro-resolver';
4+
import type { HarnessResolver } from './types';
5+
6+
// This resolver is based on the Expo's implementation.
7+
// https://github.com/expo/expo/blob/main/packages/%40expo/cli/src/start/server/metro/withMetroMultiPlatform.ts
8+
// The reason to have it in Harness is that Expo doesn't set the resolveRequest function in the context.
9+
// In order for tsconfig's paths to work, we need to recreate this logic ourselves.
10+
11+
export type TsConfigPaths = {
12+
paths: Record<string, string[]>;
13+
baseUrl: string;
14+
hasBaseUrl: boolean;
15+
}
16+
17+
/**
18+
* Load tsconfig.json or jsconfig.json and extract path mappings
19+
*/
20+
export const loadTsConfigPaths = (
21+
projectRoot: string
22+
): TsConfigPaths | null => {
23+
const configFiles = ['tsconfig.json', 'jsconfig.json'];
24+
25+
for (const configFile of configFiles) {
26+
const configPath = path.join(projectRoot, configFile);
27+
28+
if (!fs.existsSync(configPath)) continue;
29+
30+
try {
31+
const content = fs.readFileSync(configPath, 'utf8');
32+
// Strip comments without touching string literals
33+
const jsonContent = stripJsonComments(content);
34+
const config = JSON.parse(jsonContent);
35+
36+
const compilerOptions = config.compilerOptions || {};
37+
const paths = compilerOptions.paths || {};
38+
const baseUrl = compilerOptions.baseUrl;
39+
40+
if (Object.keys(paths).length > 0 || baseUrl) {
41+
return {
42+
paths,
43+
baseUrl: baseUrl ? path.resolve(projectRoot, baseUrl) : projectRoot,
44+
hasBaseUrl: !!baseUrl,
45+
};
46+
}
47+
} catch (error) {
48+
console.warn(`Failed to parse ${configFile}:`, error);
49+
}
50+
}
51+
52+
return null;
53+
};
54+
55+
const stripJsonComments = (input: string): string => {
56+
let result = '';
57+
let inString = false;
58+
let stringChar = '';
59+
let isEscaped = false;
60+
let inLineComment = false;
61+
let inBlockComment = false;
62+
63+
for (let i = 0; i < input.length; i += 1) {
64+
const char = input[i];
65+
const nextChar = input[i + 1];
66+
67+
if (inLineComment) {
68+
if (char === '\n') {
69+
inLineComment = false;
70+
result += char;
71+
}
72+
continue;
73+
}
74+
75+
if (inBlockComment) {
76+
if (char === '*' && nextChar === '/') {
77+
inBlockComment = false;
78+
i += 1;
79+
}
80+
continue;
81+
}
82+
83+
if (inString) {
84+
result += char;
85+
if (!isEscaped && char === stringChar) {
86+
inString = false;
87+
stringChar = '';
88+
}
89+
isEscaped = !isEscaped && char === '\\';
90+
continue;
91+
}
92+
93+
if (char === '"' || char === "'") {
94+
inString = true;
95+
stringChar = char;
96+
result += char;
97+
isEscaped = false;
98+
continue;
99+
}
100+
101+
if (char === '/' && nextChar === '/') {
102+
inLineComment = true;
103+
i += 1;
104+
continue;
105+
}
106+
107+
if (char === '/' && nextChar === '*') {
108+
inBlockComment = true;
109+
i += 1;
110+
continue;
111+
}
112+
113+
result += char;
114+
}
115+
116+
return result;
117+
};
118+
119+
/**
120+
* Match module name against tsconfig path pattern (supports wildcards)
121+
*/
122+
const matchPattern = (
123+
pattern: string,
124+
moduleName: string
125+
): { matched: boolean; captured: string } => {
126+
const escapedPattern = pattern
127+
.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
128+
.replace(/\*/g, '(.*)');
129+
130+
const regex = new RegExp(`^${escapedPattern}$`);
131+
const match = moduleName.match(regex);
132+
133+
return {
134+
matched: !!match,
135+
captured: match?.[1] || '',
136+
};
137+
};
138+
139+
/**
140+
* Resolve module using tsconfig path mappings
141+
* Use this directly in your custom resolver
142+
*/
143+
export const resolveWithTsConfigPaths = (
144+
tsConfig: TsConfigPaths,
145+
context: CustomResolutionContext,
146+
moduleName: string,
147+
platform: string | null
148+
): Resolution | null => {
149+
const { paths, baseUrl, hasBaseUrl } = tsConfig;
150+
const resolveRequest = context.resolveRequest;
151+
152+
if (!resolveRequest) {
153+
return null;
154+
}
155+
156+
// Try path mappings first
157+
for (const [pattern, targets] of Object.entries(paths)) {
158+
const { matched, captured } = matchPattern(pattern, moduleName);
159+
if (!matched) continue;
160+
161+
// Try each target
162+
for (const target of targets) {
163+
const resolvedTarget = target.replace('*', captured);
164+
const absolutePath = path.resolve(baseUrl, resolvedTarget);
165+
166+
try {
167+
return resolveRequest(context, absolutePath, platform);
168+
} catch {
169+
continue;
170+
}
171+
}
172+
}
173+
174+
// Try baseUrl for non-relative imports
175+
if (hasBaseUrl && !moduleName.startsWith('.') && !moduleName.startsWith('/')) {
176+
const absolutePath = path.resolve(baseUrl, moduleName);
177+
try {
178+
return resolveRequest(context, absolutePath, platform);
179+
} catch {
180+
// Fall through
181+
}
182+
}
183+
184+
return null;
185+
};
186+
187+
export const createTsConfigResolver = (
188+
projectRoot: string
189+
): HarnessResolver => {
190+
const tsConfig = loadTsConfigPaths(projectRoot);
191+
192+
return (context, moduleName, platform) => {
193+
if (!tsConfig) {
194+
return null;
195+
}
196+
197+
if (!context.resolveRequest) {
198+
return null;
199+
}
200+
201+
const resolved = resolveWithTsConfigPaths(
202+
tsConfig,
203+
context,
204+
moduleName,
205+
platform
206+
);
207+
208+
return resolved ?? null;
209+
};
210+
};
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import type { CustomResolutionContext, Resolution } from 'metro-resolver';
2+
3+
export type HarnessResolver = (context: CustomResolutionContext, moduleName: string, platform: string | null) => Resolution | null;
4+
export type MetroResolver = (context: CustomResolutionContext, moduleName: string, platform: string | null) => Resolution;

packages/metro/src/withRnHarness.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { MetroConfig } from 'metro-config';
22
import { getConfig } from '@react-native-harness/config';
3-
import { getHarnessResolver } from './resolver';
3+
import { getHarnessResolver } from './resolvers/resolver';
44
import { getHarnessManifest } from './manifest';
55
import { getHarnessBabelTransformerPath } from './babel-transformer';
66
import { getHarnessCacheStores } from './metro-cache';

0 commit comments

Comments
 (0)