From 61cfad782ba38be588595c41feede13eff828879 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C3=ABl=20Nison?= Date: Thu, 8 Oct 2026 20:40:58 +0200 Subject: [PATCH 1/2] fix(lazy install): keep production focused installs production After `yarn workspaces focus --production`, the first command run from that workspace started a full install. The lazy install check included devDependencies when computing the workspaces the current one needs, so workspaces only reachable through devDependencies looked missing. The install state now records whether the install was a production one. If it was, the check only follows production dependencies. --- packages/zpm/src/install.rs | 12 +++++++ packages/zpm/src/project.rs | 7 +++- .../sources/features/lazyInstalls.test.ts | 33 +++++++++++++++++++ 3 files changed, 51 insertions(+), 1 deletion(-) diff --git a/packages/zpm/src/install.rs b/packages/zpm/src/install.rs index 9d3bb8da..ec32d4b8 100644 --- a/packages/zpm/src/install.rs +++ b/packages/zpm/src/install.rs @@ -879,6 +879,12 @@ impl InstallOpResult { pub struct InstallState { pub last_installed_at: u128, pub installed_workspaces: Option>, + + /// Whether the installed workspaces were installed without their + /// devDependencies (`yarn workspaces focus --production`); their + /// coverage then only includes production dependencies. + pub production: bool, + pub install_config_hash: Option, pub content_flags: BTreeMap, pub resolution_tree: ResolutionTree, @@ -918,6 +924,7 @@ impl Default for InstallState { Self { last_installed_at: 0, installed_workspaces: Some(BTreeSet::new()), + production: false, install_config_hash: None, content_flags: BTreeMap::new(), resolution_tree: ResolutionTree::default(), @@ -945,6 +952,7 @@ pub struct Install { pub install_state: InstallState, pub roots: BTreeSet, pub installed_workspaces: Option>, + pub production: bool, pub resolved_islands: Vec, pub skip_build: bool, pub skip_link_step: bool, @@ -1135,6 +1143,9 @@ impl Install { self.install_state.installed_workspaces = self.installed_workspaces.clone(); + self.install_state.production + = self.production; + self.install_state.install_config_hash = Some(project.install_config_hash()); @@ -1253,6 +1264,7 @@ impl<'a> InstallManager<'a> { } pub fn with_context(mut self, context: InstallContext<'a>) -> Self { + self.result.production = context.prune_dev_dependencies; self.context = context; self } diff --git a/packages/zpm/src/project.rs b/packages/zpm/src/project.rs index 35dc9d23..80c4b411 100644 --- a/packages/zpm/src/project.rs +++ b/packages/zpm/src/project.rs @@ -1479,9 +1479,14 @@ impl Project { self.local_cache_path().fs_exists() }; + // After a production install, the user explicitly asked to leave + // devDependencies out; don't undo that by extending the install + let include_dev_dependencies + = !self.install_state.as_ref().is_some_and(|install_state| install_state.production); + let required_workspaces = self.try_workspace_by_rel_path(&self.package_cwd)? - .map(|workspace| self.workspace_dependency_closure([workspace.name.clone()], true)) + .map(|workspace| self.workspace_dependency_closure([workspace.name.clone()], include_dev_dependencies)) .transpose()?; if cache_exists { diff --git a/tests/acceptance-tests/pkg-tests-specs/sources/features/lazyInstalls.test.ts b/tests/acceptance-tests/pkg-tests-specs/sources/features/lazyInstalls.test.ts index 5523e270..28da793b 100644 --- a/tests/acceptance-tests/pkg-tests-specs/sources/features/lazyInstalls.test.ts +++ b/tests/acceptance-tests/pkg-tests-specs/sources/features/lazyInstalls.test.ts @@ -200,6 +200,39 @@ describe(`Features`, () => { }), ); + test( + `it should not run install after a production focused install`, + makeTemporaryEnv({}, async ({path, run}) => { + await setupMonorepo(path); + + // A devDependency on another workspace: production installs leave it out + await xfs.writeJsonPromise(ppath.join(path, `packages/foo/package.json` as PortablePath), { + name: `foo`, + dependencies: { + [`no-deps`]: `1.0.0`, + }, + devDependencies: { + [`bar`]: `workspace:*`, + }, + }); + + await run(`install`); + await run(`workspaces`, `focus`, `foo`, `--production`, {cwd: ppath.join(path, `packages/foo` as PortablePath)}); + + const installStatePath = ppath.join(path, `.yarn/ignore/install` as PortablePath); + const stateBefore = await xfs.statPromise(installStatePath); + + await new Promise(resolve => setTimeout(resolve, 10)); + + await run(`node`, `-e`, `require('no-deps')`, { + cwd: ppath.join(path, `packages/foo` as PortablePath), + }); + + const stateAfter = await xfs.statPromise(installStatePath); + expect(stateAfter.mtimeMs).toEqual(stateBefore.mtimeMs); + }), + ); + test( `it should extend a focused install when the active workspace is missing and the lockfile is fresh`, makeTemporaryEnv({}, async ({path, run, source}) => { From a8f1f5758adcfce72c9f5c5398ea8248078c4e65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C3=ABl=20Nison?= Date: Fri, 9 Oct 2026 10:34:41 +0200 Subject: [PATCH 2/2] fix(lazy install): stay in production mode when extending the install When a command outside the focused workspaces extended a production install, the lazy install followed devDependencies and ran without pruning them, putting them back in the tree and clearing the flag. Both now follow the production setting of the previous install. --- packages/zpm/src/project.rs | 8 ++++-- .../sources/features/lazyInstalls.test.ts | 28 +++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/packages/zpm/src/project.rs b/packages/zpm/src/project.rs index 80c4b411..ac90fc03 100644 --- a/packages/zpm/src/project.rs +++ b/packages/zpm/src/project.rs @@ -1481,8 +1481,10 @@ impl Project { // After a production install, the user explicitly asked to leave // devDependencies out; don't undo that by extending the install + let production + = self.install_state.as_ref().is_some_and(|install_state| install_state.production); let include_dev_dependencies - = !self.install_state.as_ref().is_some_and(|install_state| install_state.production); + = !production; let required_workspaces = self.try_workspace_by_rel_path(&self.package_cwd)? @@ -1526,7 +1528,7 @@ impl Project { .chain(required_workspaces.iter()) .cloned(); - Some(self.workspace_dependency_closure(roots, true)?) + Some(self.workspace_dependency_closure(roots, include_dev_dependencies)?) } else { None } @@ -1540,7 +1542,7 @@ impl Project { check_checksums: false, check_resolutions: false, enforced_resolutions: BTreeMap::new(), - prune_dev_dependencies: false, + prune_dev_dependencies: production, refresh_lockfile: false, silent_or_error: true, mode: None, diff --git a/tests/acceptance-tests/pkg-tests-specs/sources/features/lazyInstalls.test.ts b/tests/acceptance-tests/pkg-tests-specs/sources/features/lazyInstalls.test.ts index 28da793b..a316c2ea 100644 --- a/tests/acceptance-tests/pkg-tests-specs/sources/features/lazyInstalls.test.ts +++ b/tests/acceptance-tests/pkg-tests-specs/sources/features/lazyInstalls.test.ts @@ -233,6 +233,34 @@ describe(`Features`, () => { }), ); + test( + `it should keep devDependencies out when a production focused install gets extended`, + makeTemporaryEnv({}, async ({path, run}) => { + await setupMonorepo(path); + + await xfs.writeJsonPromise(ppath.join(path, `packages/foo/package.json` as PortablePath), { + name: `foo`, + dependencies: { + [`no-deps`]: `1.0.0`, + }, + devDependencies: { + [`one-fixed-dep`]: `1.0.0`, + }, + }); + + await run(`install`); + await run(`workspaces`, `focus`, `foo`, `--production`, {cwd: ppath.join(path, `packages/foo` as PortablePath)}); + + // A command from another workspace extends the focused install + await run(`node`, `-e`, `require('no-deps')`, { + cwd: ppath.join(path, `packages/bar` as PortablePath), + }); + + const pnp = await xfs.readFilePromise(ppath.join(path, `.pnp.cjs` as PortablePath), `utf8`); + expect(pnp).not.toContain(`one-fixed-dep`); + }), + ); + test( `it should extend a focused install when the active workspace is missing and the lockfile is fresh`, makeTemporaryEnv({}, async ({path, run, source}) => {