Skip to content

Commit 45c4dca

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

6 files changed

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