Skip to content

Commit 28e11ff

Browse files
committed
fix(tsconfig): honor transitive project references for paths resolution
When the entry tsconfig declares `references: [B]` and B declares `references: [C]`, a file inside C should resolve via C's own `paths`, matching `tsc`'s "nearest tsconfig wins" semantics and webpack's recursive `references: "auto"` walk. Previously, rspack-resolver loaded only the directly-listed references (one level deep) and `TsConfig::resolve` only iterated those direct references. As a result, requests from a transitively-referenced project's directory would silently fall back to the entry tsconfig's `paths` and resolve incorrectly. Changes: - `load_tsconfig` extracts `load_references` which recursively loads nested project references. Existing self-reference detection (A → A) is preserved; cycle detection across multiple levels is intentionally out of scope for this change. - `TsConfig::resolve` recursively descends into nested references via `find_reference_paths`, returning the nearest reference whose `base_path` contains the requested path before falling back to the current tsconfig.
1 parent 4574c84 commit 28e11ff

9 files changed

Lines changed: 144 additions & 28 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const from = "app";
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"compilerOptions": {
3+
"baseUrl": "./",
4+
"paths": {
5+
"@/*": ["./aliased/*"]
6+
}
7+
},
8+
"references": [
9+
{ "path": "../project_b" }
10+
]
11+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const from = "project_b";
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"compilerOptions": {
3+
"composite": true,
4+
"baseUrl": "./src",
5+
"paths": {
6+
"@/*": ["./aliased/*"]
7+
}
8+
},
9+
"references": [
10+
{ "path": "../project_c" }
11+
]
12+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const from = "project_c";
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"compilerOptions": {
3+
"composite": true,
4+
"baseUrl": "./",
5+
"paths": {
6+
"@/*": ["./aliased/*"]
7+
}
8+
}
9+
}

src/lib.rs

Lines changed: 48 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1521,31 +1521,7 @@ impl<Fs: FileSystem + Send + Sync> ResolverGeneric<Fs> {
15211521
.collect();
15221522
}
15231523
}
1524-
if !tsconfig.references.is_empty() {
1525-
let directory = tsconfig.directory().to_path_buf();
1526-
for reference in &mut tsconfig.references {
1527-
let reference_tsconfig_path = directory.normalize_with(&reference.path);
1528-
let reference_tsconfig = self
1529-
.cache
1530-
.tsconfig(
1531-
/* root */ true,
1532-
&reference_tsconfig_path,
1533-
|reference_tsconfig| async {
1534-
if reference_tsconfig.path == tsconfig.path {
1535-
return Err(ResolveError::TsconfigSelfReference(
1536-
reference_tsconfig.path.clone(),
1537-
));
1538-
}
1539-
Ok(reference_tsconfig)
1540-
},
1541-
)
1542-
.await?;
1543-
tsconfig
1544-
.file_dependencies
1545-
.extend(reference_tsconfig.file_dependencies.iter().cloned());
1546-
reference.tsconfig.replace(reference_tsconfig);
1547-
}
1548-
}
1524+
self.load_references(&mut tsconfig).await?;
15491525

15501526
Ok(tsconfig)
15511527
})
@@ -1554,6 +1530,53 @@ impl<Fs: FileSystem + Send + Sync> ResolverGeneric<Fs> {
15541530
Box::pin(fut)
15551531
}
15561532

1533+
// Walks `tsconfig.references` and loads each referenced tsconfig, recursing
1534+
// into nested references so that transitive `paths` (e.g. A → B → C) are
1535+
// honored — matching `tsc`'s "nearest tsconfig wins" behavior and
1536+
// `enhanced-resolve`'s recursive `references: "auto"` walk.
1537+
//
1538+
// The caller is expected to provide a DAG; cycle detection is intentionally
1539+
// not implemented here.
1540+
fn load_references<'a>(
1541+
&'a self,
1542+
tsconfig: &'a mut TsConfig,
1543+
) -> BoxFuture<'a, Result<(), ResolveError>> {
1544+
Box::pin(async move {
1545+
if tsconfig.references.is_empty() {
1546+
return Ok(());
1547+
}
1548+
let directory = tsconfig.directory().to_path_buf();
1549+
let current_path = tsconfig.path.clone();
1550+
for reference in &mut tsconfig.references {
1551+
let reference_tsconfig_path = directory.normalize_with(&reference.path);
1552+
let reference_tsconfig = self
1553+
.cache
1554+
.tsconfig(
1555+
/* root */ true,
1556+
&reference_tsconfig_path,
1557+
|mut reference_tsconfig| {
1558+
let current_path = current_path.clone();
1559+
async move {
1560+
if reference_tsconfig.path == current_path {
1561+
return Err(ResolveError::TsconfigSelfReference(
1562+
reference_tsconfig.path.clone(),
1563+
));
1564+
}
1565+
self.load_references(&mut reference_tsconfig).await?;
1566+
Ok(reference_tsconfig)
1567+
}
1568+
},
1569+
)
1570+
.await?;
1571+
tsconfig
1572+
.file_dependencies
1573+
.extend(reference_tsconfig.file_dependencies.iter().cloned());
1574+
reference.tsconfig.replace(reference_tsconfig);
1575+
}
1576+
Ok(())
1577+
})
1578+
}
1579+
15571580
async fn get_extended_tsconfig_path(
15581581
&self,
15591582
directory: &CachedPath,

src/tests/tsconfig_project_references.rs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,3 +184,47 @@ async fn self_reference() {
184184
);
185185
}
186186
}
187+
188+
// Transitive project references: A → B → C.
189+
// When the entry tsconfig (A) declares `references: [B]` and B declares
190+
// `references: [C]`, a file inside C must resolve via C's own `paths`
191+
// (matching tsc's "nearest tsconfig wins" behavior and webpack's
192+
// recursive `references: "auto"` walk).
193+
#[tokio::test]
194+
async fn transitive_references() {
195+
let f = super::fixture_root().join("tsconfig/cases/references-transitive");
196+
197+
let resolver = Resolver::new(ResolveOptions {
198+
tsconfig: Some(TsconfigOptions {
199+
config_file: f.join("app"),
200+
references: TsconfigReferences::Auto,
201+
}),
202+
..ResolveOptions::default()
203+
});
204+
205+
let cases = [
206+
// Direct: file in app uses app's paths.
207+
(f.join("app"), "@/index.ts", f.join("app/aliased/index.ts")),
208+
// One level: file in project_b uses project_b's paths (baseUrl ./src).
209+
(
210+
f.join("project_b/src"),
211+
"@/index.ts",
212+
f.join("project_b/src/aliased/index.ts"),
213+
),
214+
// Two levels: file in project_c (referenced by project_b which is
215+
// referenced by app) uses project_c's paths.
216+
(
217+
f.join("project_c"),
218+
"@/index.ts",
219+
f.join("project_c/aliased/index.ts"),
220+
),
221+
];
222+
223+
for (path, request, expected) in cases {
224+
let resolved_path = resolver
225+
.resolve(&path, request)
226+
.await
227+
.map(|p| p.full_path());
228+
assert_eq!(resolved_path, Ok(expected), "{request} from {path:?}");
229+
}
230+
}

src/tsconfig.rs

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -170,17 +170,31 @@ impl TsConfig {
170170
}
171171

172172
pub fn resolve(&self, path: &Path, specifier: &str) -> Vec<PathBuf> {
173+
if let Some(matched) = self.find_reference_paths(path, specifier) {
174+
return matched;
175+
}
176+
self.resolve_path_alias(specifier)
177+
}
178+
179+
// Walks `references` recursively, returning the nearest reference whose
180+
// `base_path` contains `path`. Used to honor transitive project references
181+
// (A → B → C): a file inside C should resolve via C's own `paths` even
182+
// when the entry tsconfig is A and only B is listed directly in A's
183+
// references. Matches `tsc`'s "nearest tsconfig wins" semantics.
184+
fn find_reference_paths(&self, path: &Path, specifier: &str) -> Option<Vec<PathBuf>> {
173185
for tsconfig in self
174186
.references
175187
.iter()
176188
.filter_map(|reference| reference.tsconfig.as_ref())
177189
{
190+
if let Some(nested) = tsconfig.find_reference_paths(path, specifier) {
191+
return Some(nested);
192+
}
178193
if path.starts_with(tsconfig.base_path()) {
179-
return tsconfig.resolve_path_alias(specifier);
194+
return Some(tsconfig.resolve_path_alias(specifier));
180195
}
181196
}
182-
183-
self.resolve_path_alias(specifier)
197+
None
184198
}
185199

186200
// Copied from parcel

0 commit comments

Comments
 (0)