Skip to content

Commit 23b8954

Browse files
committed
fix(tsconfig): detect cycles when walking project references
A pair of project references that point at each other (a → b → a) previously caused infinite recursion when `references: "auto"` recursively loaded the graph, blowing the test thread's stack. Add a `visited` set threaded through `load_references`. Each tsconfig inserts its own path before walking its references. When a candidate reference's loaded path is already in the chain, the cycle edge is cut: the reference is still attached so its own `paths` are honored, but its nested references are not walked. Includes a `references-cycle` fixture (a ↔ b) and a `cyclic_references` test that asserts resolution succeeds from both sides without stack overflow. Direct self-reference (A → A) detection via the existing equality check inside the cache callback is preserved.
1 parent e84cffb commit 23b8954

6 files changed

Lines changed: 73 additions & 4 deletions

File tree

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

src/lib.rs

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1495,7 +1495,8 @@ impl<Fs: FileSystem + Send + Sync> ResolverGeneric<Fs> {
14951495
.collect();
14961496
}
14971497
}
1498-
self.load_references(&mut tsconfig).await?;
1498+
let mut visited = FxHashSet::default();
1499+
self.load_references(&mut tsconfig, &mut visited).await?;
14991500

15001501
Ok(tsconfig)
15011502
})
@@ -1509,20 +1510,28 @@ impl<Fs: FileSystem + Send + Sync> ResolverGeneric<Fs> {
15091510
// honored — matching `tsc`'s "nearest tsconfig wins" behavior and
15101511
// `enhanced-resolve`'s recursive `references: "auto"` walk.
15111512
//
1512-
// The caller is expected to provide a DAG; cycle detection is intentionally
1513-
// not implemented here.
1513+
// `visited` carries the canonical paths of tsconfigs already being loaded
1514+
// along the current chain. When a reference points back into the chain,
1515+
// the cycle edge is cut: the reference is still attached with its own
1516+
// `paths` honored, but its nested references are not walked.
15141517
fn load_references<'a>(
15151518
&'a self,
15161519
tsconfig: &'a mut TsConfig,
1520+
visited: &'a mut FxHashSet<PathBuf>,
15171521
) -> BoxFuture<'a, Result<(), ResolveError>> {
15181522
Box::pin(async move {
15191523
if tsconfig.references.is_empty() {
15201524
return Ok(());
15211525
}
15221526
let directory = tsconfig.directory().to_path_buf();
15231527
let current_path = tsconfig.path.clone();
1528+
visited.insert(current_path.clone());
15241529
for reference in &mut tsconfig.references {
15251530
let reference_tsconfig_path = directory.normalize_with(&reference.path);
1531+
// Reborrow so the closure below can capture `&mut FxHashSet` across
1532+
// multiple iterations of this loop. Without the reborrow, the first
1533+
// iteration would consume the original `&mut visited` binding.
1534+
let visited: &mut FxHashSet<PathBuf> = &mut *visited;
15261535
let reference_tsconfig = self
15271536
.cache
15281537
.tsconfig(
@@ -1536,13 +1545,21 @@ impl<Fs: FileSystem + Send + Sync> ResolverGeneric<Fs> {
15361545
reference_tsconfig.path.clone(),
15371546
));
15381547
}
1548+
// Cut the cycle: if this reference is already part of the
1549+
// ongoing load chain, return it without walking its own
1550+
// references. Its `paths` are still attached to the parent.
1551+
if visited.contains(&reference_tsconfig.path) {
1552+
return Ok(reference_tsconfig);
1553+
}
15391554
// Apply `extends` so the reference inherits its base config's
15401555
// `baseUrl`/`paths` before its own references are walked.
15411556
let directory = self.cache.value(reference_tsconfig.directory());
15421557
self
15431558
.merge_tsconfig_extends(&mut reference_tsconfig, &directory)
15441559
.await?;
1545-
self.load_references(&mut reference_tsconfig).await?;
1560+
self
1561+
.load_references(&mut reference_tsconfig, visited)
1562+
.await?;
15461563
Ok(reference_tsconfig)
15471564
}
15481565
},

src/tests/tsconfig_project_references.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,3 +261,33 @@ async fn references_with_extends() {
261261
.map(|p| p.full_path());
262262
assert_eq!(resolved_path, Ok(f.join("app/aliased/index.ts")));
263263
}
264+
265+
// A pair of project references that form a cycle (a → b → a) must not
266+
// cause infinite recursion / stack overflow when `references: "auto"`
267+
// recursively walks the graph. Each project's own `paths` should still
268+
// be honored from within its own directory.
269+
#[tokio::test]
270+
async fn cyclic_references() {
271+
let f = super::fixture_root().join("tsconfig/cases/references-cycle");
272+
273+
let resolver = Resolver::new(ResolveOptions {
274+
extensions: vec![".ts".into()],
275+
tsconfig: Some(TsconfigOptions {
276+
config_file: f.join("a"),
277+
references: TsconfigReferences::Auto,
278+
}),
279+
..ResolveOptions::default()
280+
});
281+
282+
let resolved_path = resolver
283+
.resolve(&f.join("a"), "@a/index")
284+
.await
285+
.map(|p| p.full_path());
286+
assert_eq!(resolved_path, Ok(f.join("a/src/index.ts")));
287+
288+
let resolved_path = resolver
289+
.resolve(&f.join("b"), "@b/index")
290+
.await
291+
.map(|p| p.full_path());
292+
assert_eq!(resolved_path, Ok(f.join("b/src/index.ts")));
293+
}

0 commit comments

Comments
 (0)