Skip to content

Commit c0e40ff

Browse files
authored
feat: include tsconfig paths in resolution (#66)
This pull request adds a new resolver to the resolution chain that looks up dependencies based on the paths defined in the tsconfig file. This behavior is consistent with Expo's internal resolver, so if you use path aliases in your Expo app, you’ll be able to use them with Harness as well.
1 parent 4d2be2b commit c0e40ff

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)