Skip to content

Commit 79e3556

Browse files
committed
fix: linked strategy fixes for scoped packages, aliases, and peer deps (npm#8996)
We're looking at using `install-strategy=linked` in the [Gutenberg monorepo](https://github.com/WordPress/gutenberg) (~200 workspace packages), which powers the WordPress Block Editor. While [testing it in a PR](WordPress/gutenberg#75213), we ran into several issues with the linked strategy that this PR fixes. ## Summary 1. Scoped workspace packages were losing their `@scope/` prefix in `node_modules` because the symlink name came from the folder basename instead of the package name. 2. Aliased packages (e.g. `"prettier": "npm:custom-prettier@3.0.3"`) in workspace projects were getting symlinked under the real package name instead of the alias, so `require('prettier')` would fail. 3. With `legacy-peer-deps = true`, peer dependencies weren't being placed alongside packages in the store, so `require()` calls for peer deps failed from within the store. 4. With `strict-peer-deps = true`, the linked strategy could crash with `Cannot read properties of undefined` when store entries or parent nodes were missing for excluded peer deps. ## Root cause `assignCommonProperties` was using a single `result.name` for both consumer-facing symlinks and store-internal paths. For workspaces, `node.name` comes from the folder basename (missing the scope). For aliases, `node.packageName` gives the real name but we need the alias for the consumer symlink. Separately, `legacy-peer-deps` tells the arborist to skip creating peer dep edges entirely, so the isolated reifier never saw them and never placed them in the store. And `strict-peer-deps` can cause nodes to be excluded from the tree while still being referenced by edges, leading to undefined lookups. ## Changes - Split proxy identity into `result.name` (consumer-facing: alias or scoped workspace name) and `result.packageName` (store-internal: real package name from `package.json`). Store paths (`getKey`, `treeHash`, `generateChild`, `processEdges`, `processDeps`) use `packageName`; consumer symlinks keep using `name`. - When `legacyPeerDeps` is enabled, resolve missing peer dep edges from the tree via `node.resolve()` so they still get symlinked in the store. - Guard against undefined `from` and `target` nodes in `processEdges`/`processDeps` to prevent crashes with `strict-peer-deps`. - Guard `idealTree.children.get(ws)` in `reify.js` since the isolated tree uses an array for `children`, not a Map. - Test fixture updates: `recursive: true` for `mkdirSync`, scoped workspace glob support. - New tests for scoped workspace packages, aliased packages in workspaces, and peer deps with `legacyPeerDeps`. ## References Fixes npm#6122
1 parent 6755ca2 commit 79e3556

4 files changed

Lines changed: 206 additions & 11 deletions

File tree

workspaces/arborist/lib/arborist/isolated-reifier.js

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ module.exports = cls => class IsolatedReifier extends cls {
106106
node.root.path,
107107
'node_modules',
108108
'.store',
109-
`${node.name}@${node.version}`
109+
`${node.packageName}@${node.version}`
110110
)
111111
mkdirSync(dir, { recursive: true })
112112
// TODO this approach feels wrong
@@ -146,6 +146,21 @@ module.exports = cls => class IsolatedReifier extends cls {
146146
const optionalDeps = edges.filter(e => e.optional).map(e => e.to.target)
147147
const nonOptionalDeps = edges.filter(e => !e.optional).map(e => e.to.target)
148148

149+
// When legacyPeerDeps is enabled, peer dep edges are not created on the
150+
// node. Resolve them from the tree so they get symlinked in the store.
151+
const peerDeps = node.package.peerDependencies
152+
if (peerDeps && node.legacyPeerDeps) {
153+
const edgeNames = new Set(edges.map(e => e.name))
154+
for (const peerName of Object.keys(peerDeps)) {
155+
if (!edgeNames.has(peerName)) {
156+
const resolved = node.resolve(peerName)
157+
if (resolved && resolved !== node && !resolved.inert) {
158+
nonOptionalDeps.push(resolved)
159+
}
160+
}
161+
}
162+
}
163+
149164
result.localDependencies = await Promise.all(nonOptionalDeps.filter(n => n.isWorkspace).map(this.workspaceProxyMemo))
150165
result.externalDependencies = await Promise.all(nonOptionalDeps.filter(n => !n.isWorkspace).map(this.externalProxyMemo))
151166
result.externalOptionalDependencies = await Promise.all(optionalDeps.map(this.externalProxyMemo))
@@ -156,7 +171,9 @@ module.exports = cls => class IsolatedReifier extends cls {
156171
]
157172
result.root = this.rootNode
158173
result.id = this.counter++
159-
result.name = node.name
174+
/* istanbul ignore next - packageName is always set for real packages */
175+
result.name = result.isWorkspace ? (node.packageName || node.name) : node.name
176+
result.packageName = node.packageName || node.name
160177
result.package = { ...node.package }
161178
result.package.bundleDependencies = undefined
162179
result.hasInstallScript = node.hasInstallScript
@@ -229,7 +246,7 @@ module.exports = cls => class IsolatedReifier extends cls {
229246
getChildren: node => node.dependencies,
230247
filter: node => node,
231248
visit: node => {
232-
branch.push(`${node.name}@${node.version}`)
249+
branch.push(`${node.packageName}@${node.version}`)
233250
deps.push(`${branch.join('->')}::${node.resolved}`)
234251
},
235252
leave: () => {
@@ -247,7 +264,7 @@ module.exports = cls => class IsolatedReifier extends cls {
247264
}
248265

249266
const getKey = (idealTreeNode) => {
250-
return `${idealTreeNode.name}@${idealTreeNode.version}-${treeHash(idealTreeNode)}`
267+
return `${idealTreeNode.packageName}@${idealTreeNode.version}-${treeHash(idealTreeNode)}`
251268
}
252269

253270
const root = {
@@ -302,7 +319,7 @@ module.exports = cls => class IsolatedReifier extends cls {
302319
isProjectRoot: false,
303320
isTop: false,
304321
location,
305-
name: node.name,
322+
name: node.packageName || node.name,
306323
optional: node.optional,
307324
top: { path: proxiedIdealTree.root.localPath },
308325
children: [],
@@ -336,7 +353,7 @@ module.exports = cls => class IsolatedReifier extends cls {
336353
return
337354
}
338355
processed.add(key)
339-
const location = join('node_modules', '.store', key, 'node_modules', c.name)
356+
const location = join('node_modules', '.store', key, 'node_modules', c.packageName)
340357
generateChild(c, location, c.package, true)
341358
})
342359
bundledTree.nodes.forEach(node => {
@@ -362,13 +379,17 @@ module.exports = cls => class IsolatedReifier extends cls {
362379

363380
let from, nmFolder
364381
if (externalEdge) {
365-
const fromLocation = join('node_modules', '.store', key, 'node_modules', node.name)
382+
const fromLocation = join('node_modules', '.store', key, 'node_modules', node.packageName)
366383
from = root.children.find(c => c.location === fromLocation)
367384
nmFolder = join('node_modules', '.store', key, 'node_modules')
368385
} else {
369386
from = node.isProjectRoot ? root : root.fsChildren.find(c => c.location === node.localLocation)
370387
nmFolder = join(node.localLocation, 'node_modules')
371388
}
389+
/* istanbul ignore next - strict-peer-deps can exclude nodes from the tree */
390+
if (!from) {
391+
return
392+
}
372393

373394
const processDeps = (dep, optional, external) => {
374395
optional = !!optional
@@ -380,12 +401,16 @@ module.exports = cls => class IsolatedReifier extends cls {
380401

381402
let target
382403
if (external) {
383-
const toLocation = join('node_modules', '.store', toKey, 'node_modules', dep.name)
404+
const toLocation = join('node_modules', '.store', toKey, 'node_modules', dep.packageName)
384405
target = root.children.find(c => c.location === toLocation)
385406
} else {
386407
target = root.fsChildren.find(c => c.location === dep.localLocation)
387408
}
388409
// TODO: we should no-op is an edge has already been created with the same fromKey and toKey
410+
/* istanbul ignore next - strict-peer-deps can exclude nodes from the tree */
411+
if (!target) {
412+
return
413+
}
389414

390415
binNames.forEach(bn => {
391416
target.binPaths.push(join(from.realpath, 'node_modules', '.bin', bn))

workspaces/arborist/lib/arborist/reify.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -438,7 +438,7 @@ module.exports = cls => class Reifier extends cls {
438438
if (includeWorkspaces) {
439439
// add all ws nodes to filterNodes
440440
for (const ws of this.options.workspaces) {
441-
const ideal = this.idealTree.children.get(ws)
441+
const ideal = this.idealTree.children.get && this.idealTree.children.get(ws)
442442
if (ideal) {
443443
filterNodes.push(ideal)
444444
}

workspaces/arborist/test/fixtures/isolated-nock.js

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -164,9 +164,13 @@ async function getRepo (graph) {
164164
// Generate the root of the graph on disk
165165
const root = graph.root
166166
const workspaces = graph.workspaces || []
167+
const hasScoped = workspaces.some(w => w.name.startsWith('@'))
168+
const workspaceGlobs = hasScoped
169+
? ['packages/*', 'packages/@*/*']
170+
: ['packages/*']
167171
const repo = {
168172
'package.json': JSON.stringify({
169-
workspaces: workspaces.length !== 0 ? ['packages/*'] : undefined,
173+
workspaces: workspaces.length !== 0 ? workspaceGlobs : undefined,
170174
...root,
171175
}),
172176
packages: {},
@@ -192,7 +196,7 @@ function createDir (dir, structure) {
192196
Object.entries(structure).forEach(([key, value]) => {
193197
if (typeof value === 'object') {
194198
const newDir = path.join(dir, key)
195-
fs.mkdirSync(newDir)
199+
fs.mkdirSync(newDir, { recursive: true })
196200
createDir(newDir, value)
197201
} else {
198202
fs.writeFileSync(path.join(dir, key), value)

workspaces/arborist/test/isolated-mode.js

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,65 @@ tap.test('simple peer dependencies scenarios', async t => {
309309
rule7.apply(t, dir, resolved, asserted)
310310
})
311311

312+
tap.test('peer dependencies with legacyPeerDeps', async t => {
313+
/*
314+
* With legacyPeerDeps, peer dep edges are not created on the node.
315+
* The linked strategy should still place peer deps alongside the
316+
* package in the store so require() works from the real path.
317+
*
318+
* root -> phpegjs
319+
* phpegjs -> pegjs (peer dep, no edge with legacyPeerDeps)
320+
* root -> pegjs
321+
*/
322+
323+
const graph = {
324+
registry: [
325+
{ name: 'phpegjs', version: '1.0.0', peerDependencies: { pegjs: '*', missing: '*' } },
326+
{ name: 'pegjs', version: '2.0.0' },
327+
{
328+
name: 'adapter',
329+
version: '1.0.0',
330+
dependencies: { pegjs: '*' },
331+
peerDependencies: { pegjs: '*' },
332+
},
333+
],
334+
root: {
335+
name: 'foo',
336+
version: '1.2.3',
337+
dependencies: { phpegjs: '1.0.0', pegjs: '2.0.0', adapter: '1.0.0' },
338+
},
339+
}
340+
341+
const resolved = {
342+
'foo@1.2.3 (root)': {
343+
'phpegjs@1.0.0': {
344+
'pegjs@2.0.0 (peer)': {},
345+
},
346+
'pegjs@2.0.0': {},
347+
'adapter@1.0.0': {
348+
'pegjs@2.0.0': {},
349+
},
350+
},
351+
}
352+
353+
const { dir, registry } = await getRepo(graph)
354+
355+
const cache = fs.mkdtempSync(`${getTempDir()}/test-`)
356+
const arborist = new Arborist({ path: dir, registry, packumentCache: new Map(), cache, legacyPeerDeps: true })
357+
await arborist.reify({ installStrategy: 'linked' })
358+
359+
// phpegjs should be able to require its peer dep pegjs
360+
t.ok(setupRequire(dir)('phpegjs', 'pegjs'),
361+
'phpegjs can require peer dep pegjs with legacyPeerDeps')
362+
363+
const asserted = new Set()
364+
rule1.apply(t, dir, resolved, asserted)
365+
rule2.apply(t, dir, resolved, asserted)
366+
rule3.apply(t, dir, resolved, asserted)
367+
rule5.apply(t, dir, resolved, asserted)
368+
rule7.apply(t, dir, resolved, asserted)
369+
})
370+
312371
tap.test('Lock file is same in hoisted and in isolated mode', async t => {
313372
const graph = {
314373
registry: [
@@ -1300,6 +1359,113 @@ tap.test('scoped package', async t => {
13001359
rule7.apply(t, dir, resolved, asserted)
13011360
})
13021361

1362+
tap.test('scoped workspace packages', async t => {
1363+
/*
1364+
* Dependency graph:
1365+
*
1366+
* root -> @scope/package-b (workspace)
1367+
* @scope/package-b -> @scope/package-a (workspace)
1368+
* root -> @scope/package-a (workspace)
1369+
*/
1370+
1371+
const graph = {
1372+
registry: [
1373+
{ name: 'which', version: '1.0.0' },
1374+
],
1375+
root: {
1376+
name: 'myproject', version: '1.0.0', dependencies: { '@scope/package-a': '*', '@scope/package-b': '*' },
1377+
},
1378+
workspaces: [
1379+
{ name: '@scope/package-a', version: '1.0.0', dependencies: { which: '1.0.0' } },
1380+
{ name: '@scope/package-b', version: '1.0.0', dependencies: { '@scope/package-a': '*' } },
1381+
],
1382+
}
1383+
1384+
const resolved = {
1385+
'myproject@1.0.0 (root)': {
1386+
'@scope/package-a@1.0.0 (workspace)': {
1387+
'which@1.0.0': {},
1388+
},
1389+
'@scope/package-b@1.0.0 (workspace)': {
1390+
'@scope/package-a@1.0.0 (workspace)': {
1391+
'which@1.0.0': {},
1392+
},
1393+
},
1394+
},
1395+
}
1396+
1397+
const { dir, registry } = await getRepo(graph)
1398+
1399+
const cache = fs.mkdtempSync(`${getTempDir()}/test-`)
1400+
const arborist = new Arborist({ path: dir, registry, packumentCache: new Map(), cache })
1401+
await arborist.reify({ installStrategy: 'linked' })
1402+
1403+
const asserted = new Set()
1404+
rule1.apply(t, dir, resolved, asserted)
1405+
rule2.apply(t, dir, resolved, asserted)
1406+
rule3.apply(t, dir, resolved, asserted)
1407+
rule4.apply(t, dir, resolved, asserted)
1408+
rule7.apply(t, dir, resolved, asserted)
1409+
})
1410+
1411+
tap.test('aliased packages in workspace', async t => {
1412+
/*
1413+
* Dependency graph:
1414+
*
1415+
* root -> prettier (alias for npm:custom-prettier@3.0.3)
1416+
* custom-prettier -> isexe
1417+
* root -> my-pkg (workspace)
1418+
* my-pkg -> prettier (alias for npm:custom-prettier@3.0.3)
1419+
*/
1420+
1421+
const graph = {
1422+
registry: [
1423+
{ name: 'custom-prettier', version: '3.0.3', dependencies: { isexe: '1.0.0' } },
1424+
{ name: 'isexe', version: '1.0.0' },
1425+
],
1426+
root: {
1427+
name: 'myproject',
1428+
version: '1.0.0',
1429+
dependencies: { prettier: 'npm:custom-prettier@3.0.3' },
1430+
},
1431+
workspaces: [
1432+
{ name: 'my-pkg', version: '1.0.0', dependencies: { prettier: 'npm:custom-prettier@3.0.3' } },
1433+
],
1434+
}
1435+
1436+
const resolved = {
1437+
'myproject@1.0.0 (root)': {
1438+
'prettier@3.0.3': {
1439+
'isexe@1.0.0': {},
1440+
},
1441+
'my-pkg@1.0.0 (workspace)': {
1442+
'prettier@3.0.3': {
1443+
'isexe@1.0.0': {},
1444+
},
1445+
},
1446+
},
1447+
}
1448+
1449+
const { dir, registry } = await getRepo(graph)
1450+
1451+
const cache = fs.mkdtempSync(`${getTempDir()}/test-`)
1452+
const arborist = new Arborist({ path: dir, registry, packumentCache: new Map(), cache })
1453+
await arborist.reify({ installStrategy: 'linked' })
1454+
1455+
// Verify symlink uses alias name, not real package name
1456+
t.ok(setupRequire(dir)('prettier'), 'root can require via alias "prettier"')
1457+
t.notOk(
1458+
pathExists(path.join(dir, 'node_modules', 'custom-prettier')),
1459+
'no custom-prettier symlink at root node_modules'
1460+
)
1461+
1462+
const asserted = new Set()
1463+
rule1.apply(t, dir, resolved, asserted)
1464+
rule2.apply(t, dir, resolved, asserted)
1465+
rule3.apply(t, dir, resolved, asserted)
1466+
rule7.apply(t, dir, resolved, asserted)
1467+
})
1468+
13031469
tap.test('failing optional peer deps are not installed', async t => {
13041470
// Input of arborist
13051471
const graph = {

0 commit comments

Comments
 (0)