Skip to content

Commit a2154cd

Browse files
fix: linked strategy fixes for scoped packages, aliases, and peer deps (#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 #6122
1 parent c029cb2 commit a2154cd

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
@@ -105,7 +105,7 @@ module.exports = cls => class IsolatedReifier extends cls {
105105
node.root.path,
106106
'node_modules',
107107
'.store',
108-
`${node.name}@${node.version}`
108+
`${node.packageName}@${node.version}`
109109
)
110110
mkdirSync(dir, { recursive: true })
111111
// TODO this approach feels wrong
@@ -145,6 +145,21 @@ module.exports = cls => class IsolatedReifier extends cls {
145145
const optionalDeps = edges.filter(e => e.optional).map(e => e.to.target)
146146
const nonOptionalDeps = edges.filter(e => !e.optional).map(e => e.to.target)
147147

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

248265
const getKey = (idealTreeNode) => {
249-
return `${idealTreeNode.name}@${idealTreeNode.version}-${treeHash(idealTreeNode)}`
266+
return `${idealTreeNode.packageName}@${idealTreeNode.version}-${treeHash(idealTreeNode)}`
250267
}
251268

252269
const root = {
@@ -301,7 +318,7 @@ module.exports = cls => class IsolatedReifier extends cls {
301318
isProjectRoot: false,
302319
isTop: false,
303320
location,
304-
name: node.name,
321+
name: node.packageName || node.name,
305322
optional: node.optional,
306323
top: { path: proxiedIdealTree.root.localPath },
307324
children: [],
@@ -335,7 +352,7 @@ module.exports = cls => class IsolatedReifier extends cls {
335352
return
336353
}
337354
processed.add(key)
338-
const location = join('node_modules', '.store', key, 'node_modules', c.name)
355+
const location = join('node_modules', '.store', key, 'node_modules', c.packageName)
339356
generateChild(c, location, c.package, true)
340357
})
341358
bundledTree.nodes.forEach(node => {
@@ -361,13 +378,17 @@ module.exports = cls => class IsolatedReifier extends cls {
361378

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

372393
const processDeps = (dep, optional, external) => {
373394
optional = !!optional
@@ -379,12 +400,16 @@ module.exports = cls => class IsolatedReifier extends cls {
379400

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

389414
binNames.forEach(bn => {
390415
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
@@ -422,7 +422,7 @@ module.exports = cls => class Reifier extends cls {
422422
if (includeWorkspaces) {
423423
// add all ws nodes to filterNodes
424424
for (const ws of this.options.workspaces) {
425-
const ideal = this.idealTree.children.get(ws)
425+
const ideal = this.idealTree.children.get && this.idealTree.children.get(ws)
426426
if (ideal) {
427427
filterNodes.push(ideal)
428428
}

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)