Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions fixtures/tsconfig/cases/references-cycle/a/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const from = "a";
10 changes: 10 additions & 0 deletions fixtures/tsconfig/cases/references-cycle/a/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"baseUrl": "./",
"paths": {
"@a/*": ["src/*"]
}
},
"references": [{ "path": "../b" }]
}
1 change: 1 addition & 0 deletions fixtures/tsconfig/cases/references-cycle/b/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const from = "b";
10 changes: 10 additions & 0 deletions fixtures/tsconfig/cases/references-cycle/b/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"baseUrl": "./",
"paths": {
"@b/*": ["src/*"]
}
},
"references": [{ "path": "../a" }]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const from = "app";
9 changes: 9 additions & 0 deletions fixtures/tsconfig/cases/references-extends/app/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"compilerOptions": {
"baseUrl": "./",
"paths": {
"@/*": ["./aliased/*"]
}
},
"references": [{ "path": "../project_b" }]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const from = "project_b via extends";
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"baseUrl": "./src"
}
}
7 changes: 7 additions & 0 deletions fixtures/tsconfig/cases/references-extends/tsconfig.base.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./aliased/*"]
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const from = "app";
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"compilerOptions": {
"baseUrl": "./",
"paths": {
"@/*": ["./aliased/*"]
}
},
"references": [{ "path": "../project_b" }]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const from = "project_b";
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"baseUrl": "./src",
"paths": {
"@/*": ["./aliased/*"]
}
},
"references": [{ "path": "../project_c" }]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const from = "project_c";
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"compilerOptions": {
"composite": true,
"baseUrl": "./",
"paths": {
"@/*": ["./aliased/*"]
}
}
}
166 changes: 112 additions & 54 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1475,35 +1475,9 @@ impl<Fs: FileSystem + Send + Sync> ResolverGeneric<Fs> {
tracing::trace!(tsconfig = ?tsconfig, "load_tsconfig");

// Extend tsconfig
if let Some(extends) = &tsconfig.extends {
let extended_tsconfig_paths = match extends {
ExtendsField::Single(s) => {
vec![
self
.get_extended_tsconfig_path(&directory, &tsconfig, s)
.await?,
]
}
ExtendsField::Multiple(specifiers) => {
try_join_all(
specifiers
.iter()
.map(|s| self.get_extended_tsconfig_path(&directory, &tsconfig, s)),
)
.await?
}
};
for extended_tsconfig_path in extended_tsconfig_paths {
let extended_tsconfig = self
.load_tsconfig(
/* root */ false,
&extended_tsconfig_path,
&TsconfigReferences::Disabled,
)
.await?;
tsconfig.extend_tsconfig(&extended_tsconfig);
}
}
self
.merge_tsconfig_extends(&mut tsconfig, &directory)
.await?;

// Load project references
match references {
Expand All @@ -1521,31 +1495,8 @@ impl<Fs: FileSystem + Send + Sync> ResolverGeneric<Fs> {
.collect();
}
}
if !tsconfig.references.is_empty() {
let directory = tsconfig.directory().to_path_buf();
for reference in &mut tsconfig.references {
let reference_tsconfig_path = directory.normalize_with(&reference.path);
let reference_tsconfig = self
.cache
.tsconfig(
/* root */ true,
&reference_tsconfig_path,
|reference_tsconfig| async {
if reference_tsconfig.path == tsconfig.path {
return Err(ResolveError::TsconfigSelfReference(
reference_tsconfig.path.clone(),
));
}
Ok(reference_tsconfig)
},
)
.await?;
tsconfig
.file_dependencies
.extend(reference_tsconfig.file_dependencies.iter().cloned());
reference.tsconfig.replace(reference_tsconfig);
}
}
let mut visited = FxHashSet::default();
self.load_references(&mut tsconfig, &mut visited).await?;

Ok(tsconfig)
})
Expand All @@ -1554,6 +1505,113 @@ impl<Fs: FileSystem + Send + Sync> ResolverGeneric<Fs> {
Box::pin(fut)
}

// Walks `tsconfig.references` and loads each referenced tsconfig, recursing
// into nested references so that transitive `paths` (e.g. A → B → C) are
// honored — matching `tsc`'s "nearest tsconfig wins" behavior and
// `enhanced-resolve`'s recursive `references: "auto"` walk.
//
// `visited` carries the canonical paths of tsconfigs already being loaded
// along the current chain. When a reference points back into the chain,
// the cycle edge is cut: the reference is still attached with its own
// `paths` honored, but its nested references are not walked.
fn load_references<'a>(
&'a self,
tsconfig: &'a mut TsConfig,
visited: &'a mut FxHashSet<PathBuf>,
) -> BoxFuture<'a, Result<(), ResolveError>> {
Box::pin(async move {
if tsconfig.references.is_empty() {
return Ok(());
}
let directory = tsconfig.directory().to_path_buf();
let current_path = tsconfig.path.clone();
visited.insert(current_path.clone());
for reference in &mut tsconfig.references {
let reference_tsconfig_path = directory.normalize_with(&reference.path);
// Reborrow so the closure below can capture `&mut FxHashSet` across
// multiple iterations of this loop. Without the reborrow, the first
// iteration would consume the original `&mut visited` binding.
let visited: &mut FxHashSet<PathBuf> = &mut *visited;
let reference_tsconfig = self
.cache
.tsconfig(
/* root */ true,
&reference_tsconfig_path,
|mut reference_tsconfig| {
let current_path = current_path.clone();
async move {
if reference_tsconfig.path == current_path {
return Err(ResolveError::TsconfigSelfReference(
reference_tsconfig.path.clone(),
));
}
// Cut the cycle: if this reference is already part of the
// ongoing load chain, return it without walking its own
// references. Its `paths` are still attached to the parent.
if visited.contains(&reference_tsconfig.path) {
return Ok(reference_tsconfig);
}
// Apply `extends` so the reference inherits its base config's
// `baseUrl`/`paths` before its own references are walked.
let directory = self.cache.value(reference_tsconfig.directory());
self
.merge_tsconfig_extends(&mut reference_tsconfig, &directory)
.await?;
self
.load_references(&mut reference_tsconfig, visited)
.await?;
Ok(reference_tsconfig)
}
},
)
.await?;
tsconfig
.file_dependencies
.extend(reference_tsconfig.file_dependencies.iter().cloned());
reference.tsconfig.replace(reference_tsconfig);
}
Ok(())
})
}

async fn merge_tsconfig_extends(
&self,
tsconfig: &mut TsConfig,
directory: &CachedPath,
) -> Result<(), ResolveError> {
let Some(extends) = &tsconfig.extends else {
return Ok(());
};
let extended_tsconfig_paths = match extends {
ExtendsField::Single(s) => {
vec![
self
.get_extended_tsconfig_path(directory, tsconfig, s)
.await?,
]
}
ExtendsField::Multiple(specifiers) => {
try_join_all(
specifiers
.iter()
.map(|s| self.get_extended_tsconfig_path(directory, tsconfig, s)),
)
.await?
}
};
for extended_tsconfig_path in extended_tsconfig_paths {
let extended_tsconfig = self
.load_tsconfig(
/* root */ false,
&extended_tsconfig_path,
&TsconfigReferences::Disabled,
)
.await?;
tsconfig.extend_tsconfig(&extended_tsconfig);
}
Ok(())
}

async fn get_extended_tsconfig_path(
&self,
directory: &CachedPath,
Expand Down
107 changes: 107 additions & 0 deletions src/tests/tsconfig_project_references.rs
Original file line number Diff line number Diff line change
Expand Up @@ -184,3 +184,110 @@ async fn self_reference() {
);
}
}

// Transitive project references: A → B → C.
// When the entry tsconfig (A) declares `references: [B]` and B declares
// `references: [C]`, a file inside C must resolve via C's own `paths`
// (matching tsc's "nearest tsconfig wins" behavior and webpack's
// recursive `references: "auto"` walk).
#[tokio::test]
async fn transitive_references() {
let f = super::fixture_root().join("tsconfig/cases/references-transitive");

let resolver = Resolver::new(ResolveOptions {
tsconfig: Some(TsconfigOptions {
config_file: f.join("app"),
references: TsconfigReferences::Auto,
}),
..ResolveOptions::default()
});

let cases = [
// Direct: file in app uses app's paths.
(f.join("app"), "@/index.ts", f.join("app/aliased/index.ts")),
// One level: file in project_b uses project_b's paths (baseUrl ./src).
(
f.join("project_b/src"),
"@/index.ts",
f.join("project_b/src/aliased/index.ts"),
),
// Two levels: file in project_c (referenced by project_b which is
// referenced by app) uses project_c's paths.
(
f.join("project_c"),
"@/index.ts",
f.join("project_c/aliased/index.ts"),
),
];

for (path, request, expected) in cases {
let resolved_path = resolver
.resolve(&path, request)
.await
.map(|p| p.full_path());
assert_eq!(resolved_path, Ok(expected), "{request} from {path:?}");
}
}

// When a project reference uses `extends` to inherit its `baseUrl`/`paths`
// from a shared base config, those fields must be merged before the
// reference is consulted for resolution. Without merging `extends` on
// referenced configs, a request from inside `project_b` would see no
// alias candidates and fail to resolve.
#[tokio::test]
async fn references_with_extends() {
let f = super::fixture_root().join("tsconfig/cases/references-extends");

let resolver = Resolver::new(ResolveOptions {
tsconfig: Some(TsconfigOptions {
config_file: f.join("app"),
references: TsconfigReferences::Auto,
}),
..ResolveOptions::default()
});

// From project_b's directory, the inherited `paths` from
// ../tsconfig.base.json must apply (baseUrl ./src).
let resolved_path = resolver
.resolve(&f.join("project_b/src"), "@/index.ts")
.await
.map(|p| p.full_path());
assert_eq!(resolved_path, Ok(f.join("project_b/src/aliased/index.ts")));

// The entry tsconfig still uses its own `paths`.
let resolved_path = resolver
.resolve(&f.join("app"), "@/index.ts")
.await
.map(|p| p.full_path());
assert_eq!(resolved_path, Ok(f.join("app/aliased/index.ts")));
}

// A pair of project references that form a cycle (a → b → a) must not
// cause infinite recursion / stack overflow when `references: "auto"`
// recursively walks the graph. Each project's own `paths` should still
// be honored from within its own directory.
#[tokio::test]
async fn cyclic_references() {
let f = super::fixture_root().join("tsconfig/cases/references-cycle");

let resolver = Resolver::new(ResolveOptions {
extensions: vec![".ts".into()],
tsconfig: Some(TsconfigOptions {
config_file: f.join("a"),
references: TsconfigReferences::Auto,
}),
..ResolveOptions::default()
});

let resolved_path = resolver
.resolve(&f.join("a"), "@a/index")
.await
.map(|p| p.full_path());
assert_eq!(resolved_path, Ok(f.join("a/src/index.ts")));

let resolved_path = resolver
.resolve(&f.join("b"), "@b/index")
.await
.map(|p| p.full_path());
assert_eq!(resolved_path, Ok(f.join("b/src/index.ts")));
}
Loading
Loading