From 03c502c1a06dab2173276408927ffdb69ff05a1b Mon Sep 17 00:00:00 2001 From: Zoltan Kochan Date: Fri, 20 Feb 2026 14:00:25 +0100 Subject: [PATCH] fix: detect overrides and other lockfile-affecting setting changes in optimisticRepeatInstall (#10654) * fix: detect overrides and other lockfile-affecting setting changes in optimisticRepeatInstall When optimisticRepeatInstall was enabled, changing overrides, packageExtensions, ignoredOptionalDependencies, patchedDependencies, or peersSuffixMaxLength would not trigger a reinstall because these settings were not tracked in the workspace state file. * refactor: extract WORKSPACE_STATE_SETTING_KEYS to prevent type/runtime drift The settings key list in createWorkspaceState's pick() call must stay in sync with the WorkspaceStateSettings type. Extract a shared const array so both the type and runtime pick are derived from a single source, preventing the class of bug fixed in the previous commit. --- ...fix-optimistic-repeat-install-overrides.md | 6 + deps/status/test/checkDepsStatus.test.ts | 157 ++++++++++++++++++ workspace/state/src/createWorkspaceState.ts | 22 +-- workspace/state/src/index.ts | 2 +- workspace/state/src/types.ts | 45 ++--- .../state/test/createWorkspaceState.test.ts | 39 +++++ 6 files changed, 231 insertions(+), 40 deletions(-) create mode 100644 .changeset/fix-optimistic-repeat-install-overrides.md diff --git a/.changeset/fix-optimistic-repeat-install-overrides.md b/.changeset/fix-optimistic-repeat-install-overrides.md new file mode 100644 index 0000000000..c5a60ba688 --- /dev/null +++ b/.changeset/fix-optimistic-repeat-install-overrides.md @@ -0,0 +1,6 @@ +--- +"@pnpm/workspace.state": patch +"pnpm": patch +--- + +Fixed `optimisticRepeatInstall` skipping install when `overrides`, `packageExtensions`, `ignoredOptionalDependencies`, `patchedDependencies`, or `peersSuffixMaxLength` changed. diff --git a/deps/status/test/checkDepsStatus.test.ts b/deps/status/test/checkDepsStatus.test.ts index be79ddf06d..c36bf51ede 100644 --- a/deps/status/test/checkDepsStatus.test.ts +++ b/deps/status/test/checkDepsStatus.test.ts @@ -41,6 +41,163 @@ const lockfileFs = await import('@pnpm/lockfile.fs') const fsUtils = await import('../lib/safeStat.js') const statManifestFileUtils = await import('../lib/statManifestFile.js') +describe('checkDepsStatus - settings change detection', () => { + beforeEach(() => { + jest.resetModules() + jest.clearAllMocks() + }) + + it('returns upToDate: false when overrides have changed', async () => { + const lastValidatedTimestamp = Date.now() - 10_000 + const mockWorkspaceState: WorkspaceState = { + lastValidatedTimestamp, + pnpmfiles: [], + settings: { + excludeLinksFromLockfile: false, + linkWorkspacePackages: true, + preferWorkspacePackages: true, + overrides: { foo: '1.0.0' }, + }, + projects: {}, + filteredInstall: false, + } + + jest.mocked(loadWorkspaceState).mockReturnValue(mockWorkspaceState) + + const opts: CheckDepsStatusOptions = { + rootProjectManifest: {}, + rootProjectManifestDir: '/project', + pnpmfile: [], + ...mockWorkspaceState.settings, + overrides: { foo: '2.0.0' }, + } + const result = await checkDepsStatus(opts) + + expect(result.upToDate).toBe(false) + expect(result.issue).toBe('The value of the overrides setting has changed') + }) + + it('returns upToDate: false when packageExtensions have changed', async () => { + const lastValidatedTimestamp = Date.now() - 10_000 + const mockWorkspaceState: WorkspaceState = { + lastValidatedTimestamp, + pnpmfiles: [], + settings: { + excludeLinksFromLockfile: false, + linkWorkspacePackages: true, + preferWorkspacePackages: true, + packageExtensions: { foo: { dependencies: { bar: '1.0.0' } } }, + }, + projects: {}, + filteredInstall: false, + } + + jest.mocked(loadWorkspaceState).mockReturnValue(mockWorkspaceState) + + const opts: CheckDepsStatusOptions = { + rootProjectManifest: {}, + rootProjectManifestDir: '/project', + pnpmfile: [], + ...mockWorkspaceState.settings, + packageExtensions: { foo: { dependencies: { bar: '2.0.0' } } }, + } + const result = await checkDepsStatus(opts) + + expect(result.upToDate).toBe(false) + expect(result.issue).toBe('The value of the packageExtensions setting has changed') + }) + + it('returns upToDate: false when ignoredOptionalDependencies have changed', async () => { + const lastValidatedTimestamp = Date.now() - 10_000 + const mockWorkspaceState: WorkspaceState = { + lastValidatedTimestamp, + pnpmfiles: [], + settings: { + excludeLinksFromLockfile: false, + linkWorkspacePackages: true, + preferWorkspacePackages: true, + ignoredOptionalDependencies: ['foo'], + }, + projects: {}, + filteredInstall: false, + } + + jest.mocked(loadWorkspaceState).mockReturnValue(mockWorkspaceState) + + const opts: CheckDepsStatusOptions = { + rootProjectManifest: {}, + rootProjectManifestDir: '/project', + pnpmfile: [], + ...mockWorkspaceState.settings, + ignoredOptionalDependencies: ['foo', 'bar'], + } + const result = await checkDepsStatus(opts) + + expect(result.upToDate).toBe(false) + expect(result.issue).toBe('The value of the ignoredOptionalDependencies setting has changed') + }) + + it('returns upToDate: false when patchedDependencies have changed', async () => { + const lastValidatedTimestamp = Date.now() - 10_000 + const mockWorkspaceState: WorkspaceState = { + lastValidatedTimestamp, + pnpmfiles: [], + settings: { + excludeLinksFromLockfile: false, + linkWorkspacePackages: true, + preferWorkspacePackages: true, + patchedDependencies: { foo: 'patches/foo.patch' }, + }, + projects: {}, + filteredInstall: false, + } + + jest.mocked(loadWorkspaceState).mockReturnValue(mockWorkspaceState) + + const opts: CheckDepsStatusOptions = { + rootProjectManifest: {}, + rootProjectManifestDir: '/project', + pnpmfile: [], + ...mockWorkspaceState.settings, + patchedDependencies: { foo: 'patches/foo-v2.patch' }, + } + const result = await checkDepsStatus(opts) + + expect(result.upToDate).toBe(false) + expect(result.issue).toBe('The value of the patchedDependencies setting has changed') + }) + + it('returns upToDate: false when peersSuffixMaxLength has changed', async () => { + const lastValidatedTimestamp = Date.now() - 10_000 + const mockWorkspaceState: WorkspaceState = { + lastValidatedTimestamp, + pnpmfiles: [], + settings: { + excludeLinksFromLockfile: false, + linkWorkspacePackages: true, + preferWorkspacePackages: true, + peersSuffixMaxLength: 1000, + }, + projects: {}, + filteredInstall: false, + } + + jest.mocked(loadWorkspaceState).mockReturnValue(mockWorkspaceState) + + const opts: CheckDepsStatusOptions = { + rootProjectManifest: {}, + rootProjectManifestDir: '/project', + pnpmfile: [], + ...mockWorkspaceState.settings, + peersSuffixMaxLength: 100, + } + const result = await checkDepsStatus(opts) + + expect(result.upToDate).toBe(false) + expect(result.issue).toBe('The value of the peersSuffixMaxLength setting has changed') + }) +}) + describe('checkDepsStatus - pnpmfile modification', () => { beforeEach(() => { jest.resetModules() diff --git a/workspace/state/src/createWorkspaceState.ts b/workspace/state/src/createWorkspaceState.ts index 04cbdf7d25..20aa4e3295 100644 --- a/workspace/state/src/createWorkspaceState.ts +++ b/workspace/state/src/createWorkspaceState.ts @@ -1,6 +1,6 @@ import { pick } from 'ramda' import { type ConfigDependencies } from '@pnpm/types' -import { type WorkspaceState, type WorkspaceStateSettings, type ProjectsList } from './types.js' +import { WORKSPACE_STATE_SETTING_KEYS, type WorkspaceState, type WorkspaceStateSettings, type ProjectsList } from './types.js' export interface CreateWorkspaceStateOptions { allProjects: ProjectsList @@ -20,25 +20,7 @@ export const createWorkspaceState = (opts: CreateWorkspaceStateOptions): Workspa }, ])), pnpmfiles: opts.pnpmfiles, - settings: pick([ - 'autoInstallPeers', - 'catalogs', - 'dedupeDirectDeps', - 'dedupeInjectedDeps', - 'dedupePeerDependents', - 'dev', - 'excludeLinksFromLockfile', - 'hoistPattern', - 'hoistWorkspacePackages', - 'injectWorkspacePackages', - 'linkWorkspacePackages', - 'nodeLinker', - 'optional', - 'preferWorkspacePackages', - 'production', - 'publicHoistPattern', - 'workspacePackagePatterns', - ], opts.settings), + settings: pick(WORKSPACE_STATE_SETTING_KEYS, opts.settings), filteredInstall: opts.filteredInstall, configDependencies: opts.configDependencies, }) diff --git a/workspace/state/src/index.ts b/workspace/state/src/index.ts index 0296954d6c..78e0e95c5d 100644 --- a/workspace/state/src/index.ts +++ b/workspace/state/src/index.ts @@ -1,3 +1,3 @@ export { loadWorkspaceState } from './loadWorkspaceState.js' export { type UpdateWorkspaceStateOptions, updateWorkspaceState } from './updateWorkspaceState.js' -export { type WorkspaceState, type WorkspaceStateSettings, type ProjectsList } from './types.js' +export { WORKSPACE_STATE_SETTING_KEYS, type WorkspaceState, type WorkspaceStateSettings, type ProjectsList } from './types.js' diff --git a/workspace/state/src/types.ts b/workspace/state/src/types.ts index d855f8f6b6..820100fab3 100644 --- a/workspace/state/src/types.ts +++ b/workspace/state/src/types.ts @@ -15,22 +15,29 @@ export interface WorkspaceState { settings: WorkspaceStateSettings } -export type WorkspaceStateSettings = Pick +export const WORKSPACE_STATE_SETTING_KEYS = [ + 'autoInstallPeers', + 'catalogs', + 'dedupeDirectDeps', + 'dedupeInjectedDeps', + 'dedupePeerDependents', + 'dev', + 'excludeLinksFromLockfile', + 'hoistPattern', + 'hoistWorkspacePackages', + 'ignoredOptionalDependencies', + 'injectWorkspacePackages', + 'linkWorkspacePackages', + 'nodeLinker', + 'optional', + 'overrides', + 'packageExtensions', + 'patchedDependencies', + 'peersSuffixMaxLength', + 'preferWorkspacePackages', + 'production', + 'publicHoistPattern', + 'workspacePackagePatterns', +] as const satisfies ReadonlyArray + +export type WorkspaceStateSettings = Pick diff --git a/workspace/state/test/createWorkspaceState.test.ts b/workspace/state/test/createWorkspaceState.test.ts index b50c4942d0..bad0932df3 100644 --- a/workspace/state/test/createWorkspaceState.test.ts +++ b/workspace/state/test/createWorkspaceState.test.ts @@ -27,6 +27,45 @@ test('createWorkspaceState() on empty list', () => { })) }) +test('createWorkspaceState() saves lockfile-affecting settings', () => { + prepareEmpty() + + const state = createWorkspaceState({ + allProjects: [], + pnpmfiles: [], + filteredInstall: false, + settings: { + autoInstallPeers: true, + dedupeDirectDeps: true, + excludeLinksFromLockfile: false, + preferWorkspacePackages: false, + linkWorkspacePackages: false, + injectWorkspacePackages: false, + overrides: { + foo: '1.0.0', + }, + packageExtensions: { + bar: { dependencies: { baz: '2.0.0' } }, + }, + ignoredOptionalDependencies: ['qux'], + patchedDependencies: { + 'some-pkg': 'patches/some-pkg.patch', + }, + peersSuffixMaxLength: 100, + }, + }) + + expect(state.settings.overrides).toStrictEqual({ foo: '1.0.0' }) + expect(state.settings.packageExtensions).toStrictEqual({ + bar: { dependencies: { baz: '2.0.0' } }, + }) + expect(state.settings.ignoredOptionalDependencies).toStrictEqual(['qux']) + expect(state.settings.patchedDependencies).toStrictEqual({ + 'some-pkg': 'patches/some-pkg.patch', + }) + expect(state.settings.peersSuffixMaxLength).toBe(100) +}) + test('createWorkspaceState() on non-empty list', () => { preparePackages(['a', 'b', 'c', 'd'].map(name => ({ location: `./packages/${name}`,