fix: preserve named catalog group during interactive upgrade --latest (#11567)

When upgrading a dependency that uses a named catalog (e.g. "catalog:foo"),
the previous specifier's catalog name now takes priority over the global
saveCatalogName option. This prevents the package.json from being rewritten
to "catalog:" and the updated version from landing in the default catalog
instead of the named one.

Closes #10115

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
MCMXC
2026-05-14 05:08:37 -06:00
committed by GitHub
parent e526f89650
commit 8c06d1a2f9
3 changed files with 143 additions and 2 deletions

View File

@@ -0,0 +1,6 @@
---
"@pnpm/installing.deps-installer": patch
"pnpm": patch
---
Fix `pnpm upgrade --interactive --latest -r` not respecting named catalog groups. Previously, upgrading a dependency using a named catalog (e.g. `"catalog:foo"`) would incorrectly rewrite `package.json` to `"catalog:"` and place the updated version in the default catalog instead of the named one [#10115](https://github.com/pnpm/pnpm/issues/10115).

View File

@@ -711,8 +711,9 @@ export async function mutateModules (
})
if (opts.catalogMode !== 'manual') {
const catalogBareSpecifier = `catalog:${opts.saveCatalogName == null || opts.saveCatalogName === 'default' ? '' : opts.saveCatalogName}`
for (const wantedDep of wantedDeps) {
const perDepCatalogName = getPerDepCatalogName(wantedDep, opts.saveCatalogName)
const catalogBareSpecifier = `catalog:${perDepCatalogName === 'default' ? '' : perDepCatalogName}`
const catalog = resolveFromCatalog(opts.catalogs, { ...wantedDep, bareSpecifier: catalogBareSpecifier })
const catalogDepSpecifier = matchCatalogResolveResult(catalog, pickCatalogSpecifier)
@@ -723,7 +724,7 @@ export async function mutateModules (
semver.validRange(catalogDepSpecifier) &&
semver.eq(wantedDep.bareSpecifier, catalogDepSpecifier)
) {
wantedDep.saveCatalogName = opts.saveCatalogName ?? 'default'
wantedDep.saveCatalogName = perDepCatalogName
continue
}
@@ -1097,6 +1098,27 @@ function isWantedDepBareSpecifierSame (
return prevCatalogEntrySpec === nextCatalogEntrySpec
}
/**
* Determines the catalog name for a dependency during installSome.
*
* If the dependency's previous specifier already uses a named catalog
* (e.g. "catalog:foo"), that catalog name takes priority over the global
* saveCatalogName option. This ensures that interactive updates and
* `--latest` upgrades preserve the per-dependency catalog group.
*/
function getPerDepCatalogName (
wantedDep: { prevSpecifier?: string },
globalSaveCatalogName: string | undefined
): string {
if (wantedDep.prevSpecifier) {
const catalogFromPrev = parseCatalogProtocol(wantedDep.prevSpecifier)
if (catalogFromPrev != null) {
return catalogFromPrev
}
}
return globalSaveCatalogName ?? 'default'
}
export async function addDependenciesToPackage (
manifest: ProjectManifest,
dependencySelectors: string[],

View File

@@ -1755,6 +1755,119 @@ describe('update', () => {
expect(Object.keys(readLockfile().snapshots)).toEqual(['@pnpm.e2e/foo@100.1.0'])
})
test('update --latest works on named catalog dependency', async () => {
await addDistTag({ package: '@pnpm.e2e/foo', version: '100.1.0', distTag: 'latest' })
const { options, projects, readLockfile } = preparePackagesAndReturnObjects([{
name: 'project1',
dependencies: {
'@pnpm.e2e/foo': 'catalog:foo',
},
}])
const catalogs = {
foo: { '@pnpm.e2e/foo': '1.0.0' },
}
const mutateOpts = {
...options,
lockfileOnly: true,
catalogs,
}
await mutateModules(installProjects(projects), mutateOpts)
expect(readLockfile().catalogs.foo).toEqual({
'@pnpm.e2e/foo': { specifier: '1.0.0', version: '1.0.0' },
})
const { updatedCatalogs, updatedManifest } = await addDependenciesToPackage(
projects['project1' as ProjectId],
['@pnpm.e2e/foo'],
{
...mutateOpts,
dir: path.join(process.cwd(), 'project1'),
allowNew: false,
update: true,
updateToLatest: true,
})
expect(updatedManifest).toEqual({
name: 'project1',
dependencies: {
'@pnpm.e2e/foo': 'catalog:foo',
},
})
expect(updatedCatalogs).toEqual({
foo: {
'@pnpm.e2e/foo': '100.1.0',
},
})
const lockfile = readLockfile()
expect(lockfile.catalogs).toEqual({
foo: { '@pnpm.e2e/foo': { specifier: '100.1.0', version: '100.1.0' } },
})
expect(Object.keys(lockfile.snapshots)).toEqual(['@pnpm.e2e/foo@100.1.0'])
})
test('update --latest works on named catalog dependency with catalogMode=prefer', async () => {
await addDistTag({ package: '@pnpm.e2e/foo', version: '100.1.0', distTag: 'latest' })
const { options, projects, readLockfile } = preparePackagesAndReturnObjects([{
name: 'project1',
dependencies: {
'@pnpm.e2e/foo': 'catalog:foo',
},
}])
const catalogs = {
foo: { '@pnpm.e2e/foo': '1.0.0' },
}
const mutateOpts = {
...options,
lockfileOnly: true,
catalogs,
}
await mutateModules(installProjects(projects), mutateOpts)
expect(readLockfile().catalogs.foo).toEqual({
'@pnpm.e2e/foo': { specifier: '1.0.0', version: '1.0.0' },
})
const { updatedCatalogs, updatedManifest } = await addDependenciesToPackage(
projects['project1' as ProjectId],
['@pnpm.e2e/foo'],
{
...mutateOpts,
catalogMode: 'prefer',
dir: path.join(process.cwd(), 'project1'),
allowNew: false,
update: true,
updateToLatest: true,
})
expect(updatedManifest).toEqual({
name: 'project1',
dependencies: {
'@pnpm.e2e/foo': 'catalog:foo',
},
})
expect(updatedCatalogs).toEqual({
foo: {
'@pnpm.e2e/foo': '100.1.0',
},
})
const lockfile = readLockfile()
expect(lockfile.catalogs).toEqual({
foo: { '@pnpm.e2e/foo': { specifier: '100.1.0', version: '100.1.0' } },
})
expect(Object.keys(lockfile.snapshots)).toEqual(['@pnpm.e2e/foo@100.1.0'])
})
// This test will update @pnpm.e2e/bar, but make sure @pnpm.e2e/foo is
// untouched. On the registry-mock, the versions for @pnpm.e2e/bar are:
//