Skip to content

Commit aba7091

Browse files
authored
fix(env): invalidate stale shim cache when project source changes (#1951)
## Summary - Revalidates shim resolve cache entries against the current effective project Node.js version source. - Shares project version source resolution between env config resolution and shim cache validation to keep fallback behavior consistent. - Adds coverage for stale fallback cache invalidation and invalid-source fallback cases. Fixes #1948
1 parent 1137bbe commit aba7091

2 files changed

Lines changed: 287 additions & 81 deletions

File tree

crates/vite_global_cli/src/commands/env/config.rs

Lines changed: 226 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
88
use serde::{Deserialize, Serialize};
99
use vite_js_runtime::{
10-
NodeProvider, VersionSource, normalize_version, read_package_json, resolve_node_version,
10+
NodeProvider, VersionSource, is_valid_version, normalize_version, read_package_json,
11+
resolve_node_version,
1112
};
1213
use vite_path::{AbsolutePath, AbsolutePathBuf};
1314

@@ -228,91 +229,123 @@ pub async fn resolve_version(cwd: &AbsolutePath) -> Result<VersionResolution, Er
228229
resolve_version_from_files(cwd).await
229230
}
230231

231-
/// Resolve Node.js version from project files only (skipping session overrides).
232-
///
233-
/// This is used by `vp env use` without arguments to revert to file-based resolution.
234-
pub async fn resolve_version_from_files(cwd: &AbsolutePath) -> Result<VersionResolution, Error> {
235-
let provider = NodeProvider::new();
232+
pub(crate) struct ProjectVersionSource {
233+
pub version: String,
234+
pub source: String,
235+
pub source_path: AbsolutePathBuf,
236+
pub project_root: AbsolutePathBuf,
237+
}
236238

237-
// Use shared version resolution with directory walking
238-
let resolution = resolve_node_version(cwd, true)
239+
/// Resolve the effective project-file Node.js version source.
240+
///
241+
/// `warn_invalid` controls whether invalid version specs print user-facing
242+
/// warnings. Use `true` for env commands, and `false` for shim cache validation
243+
/// so wrapped tool output stays quiet.
244+
pub(crate) async fn resolve_project_version_source(
245+
cwd: &AbsolutePath,
246+
warn_invalid: bool,
247+
) -> Result<Option<ProjectVersionSource>, Error> {
248+
let Some(resolution) = resolve_node_version(cwd, true)
239249
.await
240-
.map_err(|e| Error::ConfigError(e.to_string().into()))?;
250+
.map_err(|e| Error::ConfigError(e.to_string().into()))?
251+
else {
252+
return Ok(None);
253+
};
241254

242-
if let Some(resolution) = resolution {
243-
// Validate version before attempting resolution
244-
// If invalid, warning is printed by normalize_version and we fall through to defaults
245-
if let Some(validated) =
246-
normalize_version(&resolution.version.clone().into(), &resolution.source.to_string())
255+
if let Some(version) =
256+
validate_version_spec(&resolution.version, &resolution.source.to_string(), warn_invalid)
257+
{
258+
if let (Some(source_path), Some(project_root)) =
259+
(resolution.source_path, resolution.project_root)
247260
{
248-
// Detect if the original version spec was a range (not exact)
249-
// This includes partial versions (20, 20.18), semver ranges (^20.0.0), LTS aliases, and "latest"
250-
let is_range = NodeProvider::is_version_alias(&validated)
251-
|| !NodeProvider::is_exact_version(&validated);
252-
253-
let resolved = resolve_version_string(&validated, &provider).await?;
254-
return Ok(VersionResolution {
255-
version: resolved,
261+
return Ok(Some(ProjectVersionSource {
262+
version,
256263
source: resolution.source.to_string(),
257-
source_path: resolution.source_path,
258-
project_root: resolution.project_root,
259-
is_range,
260-
});
264+
source_path,
265+
project_root,
266+
}));
261267
}
268+
return Ok(None);
269+
}
262270

263-
// Invalid version from a project source - try lower-priority sources in the same directory.
264-
// This mirrors the fallback logic in download_runtime_for_project().
265-
// - NodeVersionFile: try devEngines.runtime, then engines.node
266-
// - DevEnginesRuntime: try engines.node
267-
if matches!(
268-
resolution.source,
269-
VersionSource::NodeVersionFile | VersionSource::DevEnginesRuntime
270-
) {
271-
if let Some(project_root) = &resolution.project_root {
272-
let package_json_path = project_root.join("package.json");
273-
if let Ok(Some(pkg)) = read_package_json(&package_json_path).await {
274-
// Try devEngines.runtime (only when falling back from .node-version)
275-
if matches!(resolution.source, VersionSource::NodeVersionFile) {
276-
if let Some(dev_engines) = pkg
277-
.dev_engines_runtime("node")
278-
.and_then(|r| r.version.clone())
279-
.and_then(|v| normalize_version(&v, "devEngines.runtime"))
280-
{
281-
let resolved = resolve_version_string(&dev_engines, &provider).await?;
282-
let is_range = NodeProvider::is_lts_alias(&dev_engines)
283-
|| !NodeProvider::is_exact_version(&dev_engines);
284-
return Ok(VersionResolution {
285-
version: resolved,
286-
source: "devEngines.runtime".into(),
287-
source_path: Some(package_json_path),
288-
project_root: Some(project_root.clone()),
289-
is_range,
290-
});
291-
}
292-
}
293-
294-
// Try engines.node
295-
if let Some(engines_node) = pkg
296-
.engines
297-
.as_ref()
298-
.and_then(|e| e.node.clone())
299-
.and_then(|v| normalize_version(&v, "engines.node"))
300-
{
301-
let resolved = resolve_version_string(&engines_node, &provider).await?;
302-
let is_range = NodeProvider::is_lts_alias(&engines_node)
303-
|| !NodeProvider::is_exact_version(&engines_node);
304-
return Ok(VersionResolution {
305-
version: resolved,
306-
source: "engines.node".into(),
307-
source_path: Some(package_json_path),
308-
project_root: Some(project_root.clone()),
309-
is_range,
310-
});
311-
}
312-
}
313-
}
314-
}
315-
// Invalid version and no valid package.json sources - fall through to user default or LTS
271+
// Invalid version from a project source: try lower-priority sources in the same directory.
272+
// This mirrors the fallback logic in download_runtime_for_project().
273+
if !matches!(
274+
resolution.source,
275+
VersionSource::NodeVersionFile | VersionSource::DevEnginesRuntime
276+
) {
277+
return Ok(None);
278+
}
279+
280+
let Some(project_root) = resolution.project_root else {
281+
return Ok(None);
282+
};
283+
let package_json_path = project_root.join("package.json");
284+
let Ok(Some(pkg)) = read_package_json(&package_json_path).await else {
285+
return Ok(None);
286+
};
287+
288+
if matches!(resolution.source, VersionSource::NodeVersionFile)
289+
&& let Some(version) = pkg
290+
.dev_engines_runtime("node")
291+
.and_then(|r| r.version.clone())
292+
.and_then(|v| validate_version_spec(&v, "devEngines.runtime", warn_invalid))
293+
{
294+
return Ok(Some(ProjectVersionSource {
295+
version,
296+
source: "devEngines.runtime".into(),
297+
source_path: package_json_path,
298+
project_root,
299+
}));
300+
}
301+
302+
if let Some(version) = pkg
303+
.engines
304+
.as_ref()
305+
.and_then(|e| e.node.clone())
306+
.and_then(|v| validate_version_spec(&v, "engines.node", warn_invalid))
307+
{
308+
return Ok(Some(ProjectVersionSource {
309+
version,
310+
source: "engines.node".into(),
311+
source_path: package_json_path,
312+
project_root,
313+
}));
314+
}
315+
316+
Ok(None)
317+
}
318+
319+
fn validate_version_spec(
320+
version: &vite_str::Str,
321+
source: &str,
322+
warn_invalid: bool,
323+
) -> Option<String> {
324+
if warn_invalid {
325+
normalize_version(version, source).map(|v| v.to_string())
326+
} else {
327+
let trimmed = version.trim();
328+
is_valid_version(trimmed).then(|| trimmed.to_string())
329+
}
330+
}
331+
332+
/// Resolve Node.js version from project files only (skipping session overrides).
333+
///
334+
/// This is used by `vp env use` without arguments to revert to file-based resolution.
335+
pub async fn resolve_version_from_files(cwd: &AbsolutePath) -> Result<VersionResolution, Error> {
336+
let provider = NodeProvider::new();
337+
338+
if let Some(project_source) = resolve_project_version_source(cwd, true).await? {
339+
let is_range = NodeProvider::is_version_alias(&project_source.version)
340+
|| !NodeProvider::is_exact_version(&project_source.version);
341+
let resolved = resolve_version_string(&project_source.version, &provider).await?;
342+
return Ok(VersionResolution {
343+
version: resolved,
344+
source: project_source.source,
345+
source_path: Some(project_source.source_path),
346+
project_root: Some(project_source.project_root),
347+
is_range,
348+
});
316349
}
317350

318351
// CLI-specific: Check user default from config
@@ -779,6 +812,120 @@ mod tests {
779812
);
780813
}
781814

815+
#[tokio::test]
816+
async fn test_project_source_detects_new_dev_engines() {
817+
let temp_dir = TempDir::new().unwrap();
818+
let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();
819+
820+
assert!(resolve_project_version_source(&temp_path, false).await.unwrap().is_none());
821+
822+
tokio::fs::write(
823+
temp_path.join("package.json"),
824+
r#"{"devEngines":{"runtime":{"name":"node","version":"22.22.0"}}}"#,
825+
)
826+
.await
827+
.unwrap();
828+
829+
let source = resolve_project_version_source(&temp_path, false).await.unwrap().unwrap();
830+
assert_eq!(source.version, "22.22.0");
831+
assert_eq!(source.source, "devEngines.runtime");
832+
assert_eq!(source.source_path, temp_path.join("package.json"));
833+
}
834+
835+
#[tokio::test]
836+
async fn test_project_source_prefers_nearer_dev_engines_over_parent_node_version() {
837+
let temp_dir = TempDir::new().unwrap();
838+
let parent = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();
839+
let child = parent.join("child");
840+
tokio::fs::create_dir(&child).await.unwrap();
841+
tokio::fs::write(parent.join(".node-version"), "24.18.0").await.unwrap();
842+
tokio::fs::write(
843+
child.join("package.json"),
844+
r#"{"devEngines":{"runtime":{"name":"node","version":"22.22.0"}}}"#,
845+
)
846+
.await
847+
.unwrap();
848+
849+
let source = resolve_project_version_source(&child, false).await.unwrap().unwrap();
850+
assert_eq!(source.version, "22.22.0");
851+
assert_eq!(source.source, "devEngines.runtime");
852+
assert_eq!(source.source_path, child.join("package.json"));
853+
}
854+
855+
#[tokio::test]
856+
async fn test_project_source_falls_back_from_invalid_node_version_to_dev_engines() {
857+
let temp_dir = TempDir::new().unwrap();
858+
let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();
859+
tokio::fs::write(temp_path.join(".node-version"), "not-a-version").await.unwrap();
860+
tokio::fs::write(
861+
temp_path.join("package.json"),
862+
r#"{"devEngines":{"runtime":{"name":"node","version":"22.22.0"}}}"#,
863+
)
864+
.await
865+
.unwrap();
866+
867+
let source = resolve_project_version_source(&temp_path, false).await.unwrap().unwrap();
868+
assert_eq!(source.version, "22.22.0");
869+
assert_eq!(source.source, "devEngines.runtime");
870+
}
871+
872+
#[tokio::test]
873+
async fn test_project_source_falls_back_from_invalid_dev_engines_to_engines_node() {
874+
let temp_dir = TempDir::new().unwrap();
875+
let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();
876+
tokio::fs::write(
877+
temp_path.join("package.json"),
878+
r#"{"devEngines":{"runtime":{"name":"node","version":"not-a-version"}},"engines":{"node":"22.22.0"}}"#,
879+
)
880+
.await
881+
.unwrap();
882+
883+
let source = resolve_project_version_source(&temp_path, false).await.unwrap().unwrap();
884+
assert_eq!(source.version, "22.22.0");
885+
assert_eq!(source.source, "engines.node");
886+
}
887+
888+
#[tokio::test]
889+
async fn test_project_source_ignores_empty_engines_node_and_keeps_walking() {
890+
let temp_dir = TempDir::new().unwrap();
891+
let parent = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();
892+
let child = parent.join("child");
893+
tokio::fs::create_dir(&child).await.unwrap();
894+
tokio::fs::write(parent.join(".node-version"), "24.18.0").await.unwrap();
895+
tokio::fs::write(child.join("package.json"), r#"{"engines":{"node":""}}"#).await.unwrap();
896+
897+
let source = resolve_project_version_source(&child, false).await.unwrap().unwrap();
898+
assert_eq!(source.version, "24.18.0");
899+
assert_eq!(source.source, ".node-version");
900+
assert_eq!(source.source_path, parent.join(".node-version"));
901+
}
902+
903+
#[tokio::test]
904+
async fn test_project_source_stops_at_invalid_package_source() {
905+
let temp_dir = TempDir::new().unwrap();
906+
let parent = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();
907+
let child = parent.join("child");
908+
tokio::fs::create_dir(&child).await.unwrap();
909+
tokio::fs::write(parent.join(".node-version"), "24.18.0").await.unwrap();
910+
tokio::fs::write(
911+
child.join("package.json"),
912+
r#"{"devEngines":{"runtime":{"name":"node","version":"not-a-version"}}}"#,
913+
)
914+
.await
915+
.unwrap();
916+
917+
assert!(resolve_project_version_source(&child, false).await.unwrap().is_none());
918+
}
919+
920+
#[tokio::test]
921+
async fn test_project_source_returns_none_for_invalid_project_source() {
922+
let temp_dir = TempDir::new().unwrap();
923+
let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();
924+
tokio::fs::write(temp_path.join(".node-version"), "not-a-version").await.unwrap();
925+
926+
assert!(resolve_project_version_source(&temp_path, false).await.unwrap().is_none());
927+
}
928+
782929
#[tokio::test]
783930
async fn test_resolve_version_latest_alias_in_node_version() {
784931
let temp_dir = TempDir::new().unwrap();

0 commit comments

Comments
 (0)