mirror of
https://github.com/pnpm/pnpm.git
synced 2026-04-28 02:53:15 -04:00
fix: replace catalog: protocol in injected workspace dependencies (#9266)
* feat: return `workspace` for `resolvedVia` * test: catalog protocol on injected deps * refactor: create new `getCatalogReplacementPref` function * refactor: pass through `resolvedVia` field to `ResolveDependencyResult` * fix: replace `catalog:` protocol in injected workspace dependencies --------- close #8715
This commit is contained in:
committed by
Zoltan Kochan
parent
936430a8a0
commit
f0f95abfbb
6
.changeset/legal-rooms-care.md
Normal file
6
.changeset/legal-rooms-care.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@pnpm/resolve-dependencies": patch
|
||||
pnpm: patch
|
||||
---
|
||||
|
||||
Fix usages of the [`catalog:` protocol](https://pnpm.io/catalogs) in [injected local workspace packages](https://pnpm.io/package_json#dependenciesmetainjected). This previously errored with `ERR_PNPM_SPEC_NOT_SUPPORTED_BY_ANY_RESOLVER`. [#8715](https://github.com/pnpm/pnpm/issues/8715)
|
||||
@@ -719,6 +719,162 @@ test('lockfile catalog snapshots should remove unused entries', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
// Regression test for https://github.com/pnpm/pnpm/issues/8715
|
||||
//
|
||||
// Catalogs on injected deps require more consideration since the injected dep
|
||||
// is no longer seen as an "importer". The catalog protocol is traditionally
|
||||
// only for "importers" (i.e. packages matching the `packages` filter in
|
||||
// pnpm-workspace.yaml).
|
||||
//
|
||||
// Since injected deps copy the workspace package into the node_modules/.pnpm
|
||||
// dir, a bit more work has to be done to make catalogs usable on these unique
|
||||
// packages.
|
||||
//
|
||||
// Example of a package at packages/project2 getting "injected".
|
||||
//
|
||||
// node_modules/.pnpm/project2@file+packages+project2/node_modules/project2
|
||||
//
|
||||
test('catalogs work in injected dep', async () => {
|
||||
expect.hasAssertions()
|
||||
|
||||
const { options, projects, readLockfile } = preparePackagesAndReturnObjects([
|
||||
{
|
||||
name: 'project1',
|
||||
dependencies: {
|
||||
project2: 'workspace:*',
|
||||
},
|
||||
dependenciesMeta: {
|
||||
project2: { injected: true },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'project2',
|
||||
dependencies: {
|
||||
'is-positive': 'catalog:',
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const install = () => mutateModules(installProjects(projects), {
|
||||
...options,
|
||||
lockfileOnly: true,
|
||||
// This setting turns injected deps into regular symlinked workspace
|
||||
// packages if peer dependencies aren't resolved differently.
|
||||
dedupeInjectedDeps: false,
|
||||
catalogs: {
|
||||
default: { 'is-positive': '1.0.0' },
|
||||
},
|
||||
})
|
||||
|
||||
// This should run without "is-positive@catalog: isn't supported by any
|
||||
// available resolver." errors.
|
||||
await expect(install()).resolves.not.toThrow()
|
||||
|
||||
const lockfile = readLockfile()
|
||||
|
||||
// The resolved catalogs should be correct.
|
||||
expect(lockfile.catalogs).toStrictEqual({
|
||||
default: {
|
||||
'is-positive': { specifier: '1.0.0', version: '1.0.0' },
|
||||
},
|
||||
})
|
||||
|
||||
expect(lockfile.importers).toEqual({
|
||||
// Check that project2 was indeed injected into project1. Otherwise this
|
||||
// test wouldn't be checking the correct scenario.
|
||||
project1: {
|
||||
dependencies: {
|
||||
project2: { specifier: 'workspace:*', version: 'file:project2' },
|
||||
},
|
||||
dependenciesMeta: {
|
||||
project2: { injected: true },
|
||||
},
|
||||
},
|
||||
project2: {
|
||||
dependencies: {
|
||||
'is-positive': { specifier: 'catalog:', version: '1.0.0' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Double check the correct version of is-positive as requested from the
|
||||
// catalog was installed and not the latest.
|
||||
expect(lockfile.snapshots).toStrictEqual({
|
||||
'is-positive@1.0.0': {},
|
||||
'project2@file:project2': {
|
||||
dependencies: { 'is-positive': '1.0.0' },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('catalogs work when inject-workspace-packages=true', async () => {
|
||||
expect.hasAssertions()
|
||||
|
||||
const { options, projects, readLockfile } = preparePackagesAndReturnObjects([
|
||||
{
|
||||
name: 'project1',
|
||||
dependencies: {
|
||||
project2: 'workspace:*',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'project2',
|
||||
dependencies: {
|
||||
'is-positive': 'catalog:',
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const install = () => mutateModules(installProjects(projects), {
|
||||
...options,
|
||||
lockfileOnly: true,
|
||||
// This setting turns injected deps into regular symlinked workspace
|
||||
// packages if peer dependencies aren't resolved differently.
|
||||
dedupeInjectedDeps: false,
|
||||
injectWorkspacePackages: true,
|
||||
catalogs: {
|
||||
default: { 'is-positive': '1.0.0' },
|
||||
},
|
||||
})
|
||||
|
||||
// This should run without "is-positive@catalog: isn't supported by any
|
||||
// available resolver." errors.
|
||||
await expect(install()).resolves.not.toThrow()
|
||||
|
||||
const lockfile = readLockfile()
|
||||
|
||||
// The resolved catalogs should be correct.
|
||||
expect(lockfile.catalogs).toStrictEqual({
|
||||
default: {
|
||||
'is-positive': { specifier: '1.0.0', version: '1.0.0' },
|
||||
},
|
||||
})
|
||||
|
||||
expect(lockfile.importers).toEqual({
|
||||
// Check that project2 was indeed injected into project1. Otherwise this
|
||||
// test wouldn't be checking the correct scenario.
|
||||
project1: {
|
||||
dependencies: {
|
||||
project2: { specifier: 'workspace:*', version: 'file:project2' },
|
||||
},
|
||||
},
|
||||
project2: {
|
||||
dependencies: {
|
||||
'is-positive': { specifier: 'catalog:', version: '1.0.0' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Double check the correct version of is-positive as requested from the
|
||||
// catalog was installed and not the latest.
|
||||
expect(lockfile.snapshots).toStrictEqual({
|
||||
'is-positive@1.0.0': {},
|
||||
'project2@file:project2': {
|
||||
dependencies: { 'is-positive': '1.0.0' },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
describe('add', () => {
|
||||
test('adding is-positive@catalog: works', async () => {
|
||||
const { options, projects, readLockfile } = preparePackagesAndReturnObjects([{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import path from 'path'
|
||||
import { matchCatalogResolveResult, type CatalogResolver } from '@pnpm/catalogs.resolver'
|
||||
import { type CatalogResolution, matchCatalogResolveResult, type CatalogResolver } from '@pnpm/catalogs.resolver'
|
||||
import {
|
||||
deprecationLogger,
|
||||
progressLogger,
|
||||
@@ -190,6 +190,7 @@ export type PkgAddress = {
|
||||
depIsLinked: boolean
|
||||
isNew: boolean
|
||||
isLinkedDependency?: false
|
||||
resolvedVia?: string
|
||||
nodeId: NodeId
|
||||
pkgId: PkgResolutionId
|
||||
normalizedPref?: string // is returned only for root dependencies
|
||||
@@ -250,7 +251,7 @@ export interface ResolvedPackage {
|
||||
}
|
||||
}
|
||||
|
||||
type ParentPkg = Pick<PkgAddress, 'nodeId' | 'installable' | 'rootDir' | 'optional' | 'pkgId'>
|
||||
type ParentPkg = Pick<PkgAddress, 'nodeId' | 'installable' | 'rootDir' | 'optional' | 'pkgId' | 'resolvedVia'>
|
||||
|
||||
export type ParentPkgAliases = Record<string, PkgAddress | true>
|
||||
|
||||
@@ -551,18 +552,10 @@ async function resolveDependenciesOfImporterDependency (
|
||||
const originalPref = extendedWantedDep.wantedDependency.pref
|
||||
|
||||
if (catalogLookup != null) {
|
||||
// The lockfile from a previous installation may have already resolved this
|
||||
// cataloged dependency. Reuse the exact version in the lockfile catalog
|
||||
// snapshot to ensure all projects using the same cataloged dependency get
|
||||
// the same version.
|
||||
const existingCatalogResolution = ctx.wantedLockfile.catalogs
|
||||
?.[catalogLookup.catalogName]
|
||||
?.[extendedWantedDep.wantedDependency.alias]
|
||||
const replacementPref = existingCatalogResolution?.specifier === catalogLookup.specifier
|
||||
? replaceVersionInPref(catalogLookup.specifier, existingCatalogResolution.version)
|
||||
: catalogLookup.specifier
|
||||
|
||||
extendedWantedDep.wantedDependency.pref = replacementPref
|
||||
extendedWantedDep.wantedDependency.pref = getCatalogReplacementPref(
|
||||
catalogLookup,
|
||||
ctx.wantedLockfile,
|
||||
extendedWantedDep.wantedDependency)
|
||||
}
|
||||
|
||||
const result = await resolveDependenciesOfDependency(
|
||||
@@ -830,6 +823,44 @@ async function resolveDependenciesOfDependency (
|
||||
supportedArchitectures: options.supportedArchitectures,
|
||||
parentIds: options.parentIds,
|
||||
}
|
||||
|
||||
// The catalog protocol is normally replaced when resolving the dependencies
|
||||
// of importers. However, when a workspace package is "injected", it becomes a
|
||||
// "file:" dependency and is no longer an "importer" from the perspective of
|
||||
// pnpm.
|
||||
//
|
||||
// To allow the catalog protocol to still be used for injected workspace
|
||||
// packages, it's necessary to check if the parent package was an injected
|
||||
// workspace package and replace the catalog: protocol for the current package.
|
||||
const isInjectedWorkspacePackage = options.parentPkg.resolvedVia === 'workspace' &&
|
||||
options.parentPkg.pkgId.startsWith('file:')
|
||||
if (isInjectedWorkspacePackage) {
|
||||
const catalogLookup = matchCatalogResolveResult(ctx.catalogResolver(extendedWantedDep.wantedDependency), {
|
||||
found: (result) => result.resolution,
|
||||
unused: () => undefined,
|
||||
misconfiguration: (result) => {
|
||||
throw result.error
|
||||
},
|
||||
})
|
||||
|
||||
// The standard process for replacing the catalog protocol when resolving
|
||||
// the dependencies of "importers" stores the catalog lookup in the
|
||||
// dependency resolution result. This allows the catalogs snapshot section
|
||||
// of the wanted lockfile to be kept up to date.
|
||||
//
|
||||
// We can do a simple replacement here instead and discard the catalog
|
||||
// lookup object. It's not necessary to store this information for injected
|
||||
// workspace packages. The injected workspace package will still be resolved
|
||||
// as an importer separately, and we can rely on that process keeping the
|
||||
// importers lockfile catalog snapshots up to date.
|
||||
if (catalogLookup != null) {
|
||||
extendedWantedDep.wantedDependency.pref = getCatalogReplacementPref(
|
||||
catalogLookup,
|
||||
ctx.wantedLockfile,
|
||||
extendedWantedDep.wantedDependency)
|
||||
}
|
||||
}
|
||||
|
||||
const resolveDependencyResult = await resolveDependency(extendedWantedDep.wantedDependency, ctx, resolveDependencyOpts)
|
||||
|
||||
if (resolveDependencyResult == null) return { resolveDependencyResult: null }
|
||||
@@ -1530,6 +1561,7 @@ async function resolveDependency (
|
||||
return {
|
||||
alias: wantedDependency.alias || pkg.name,
|
||||
depIsLinked,
|
||||
resolvedVia: pkgResponse.body.resolvedVia,
|
||||
isNew,
|
||||
nodeId,
|
||||
normalizedPref: options.currentDepth === 0 ? pkgResponse.body.normalizedPref : undefined,
|
||||
@@ -1651,3 +1683,22 @@ function peerDependenciesWithoutOwn (pkg: PackageManifest): PeerDependencies {
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function getCatalogReplacementPref (
|
||||
catalogLookup: CatalogResolution,
|
||||
wantedLockfile: LockfileObject,
|
||||
wantedDependency: WantedDependency
|
||||
): string {
|
||||
// The lockfile from a previous installation may have already resolved this
|
||||
// cataloged dependency. Reuse the exact version in the lockfile catalog
|
||||
// snapshot to ensure all projects using the same cataloged dependency get the
|
||||
// same version.
|
||||
const existingCatalogResolution = wantedLockfile.catalogs
|
||||
?.[catalogLookup.catalogName]
|
||||
?.[wantedDependency.alias]
|
||||
const replacementPref = existingCatalogResolution?.specifier === catalogLookup.specifier
|
||||
? replaceVersionInPref(catalogLookup.specifier, existingCatalogResolution.version)
|
||||
: catalogLookup.specifier
|
||||
|
||||
return replacementPref
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user