@@ -7,11 +7,13 @@ import {
77 statsLogger ,
88} from '@pnpm/core-loggers'
99import { calcDepState , type DepsStateCache , findRuntimeNodeVersion } from '@pnpm/deps.graph-hasher'
10+ import { readModulesDir } from '@pnpm/fs.read-modules-dir'
1011import { symlinkDependency } from '@pnpm/fs.symlink-dependency'
11- import type {
12- DependenciesGraph ,
13- DependenciesGraphNode ,
14- LinkedDependency ,
12+ import {
13+ type DependenciesGraph ,
14+ type DependenciesGraphNode ,
15+ isValidDependencyAlias ,
16+ type LinkedDependency ,
1517} from '@pnpm/installing.deps-resolver'
1618import type { InstallationResultStats } from '@pnpm/installing.deps-restorer'
1719import { linkDirectDeps } from '@pnpm/installing.linking.direct-dep-linker'
@@ -33,6 +35,7 @@ import type {
3335 SupportedArchitectures ,
3436} from '@pnpm/types'
3537import { symlinkAllModules } from '@pnpm/worker'
38+ import { rimraf } from '@zkochan/rimraf'
3639import pLimit from 'p-limit'
3740import { pathExists } from 'path-exists'
3841import { difference , equals , isEmpty , pick , pickBy , props } from 'ramda'
@@ -346,6 +349,10 @@ interface LinkNewPackagesResult {
346349 added : number
347350}
348351
352+ type ModulesLinkJob = Pick < DependenciesGraphNode , 'children' | 'modules' | 'name' | 'optionalDependencies' > & {
353+ removedAliases ?: string [ ]
354+ }
355+
349356async function linkNewPackages (
350357 currentLockfile : LockfileObject ,
351358 wantedLockfile : LockfileObject ,
@@ -372,23 +379,55 @@ async function linkNewPackages (
372379 prefix : opts . lockfileDir ,
373380 } )
374381
375- const existingWithUpdatedDeps = [ ]
382+ const existingWithUpdatedDeps : ModulesLinkJob [ ] = [ ]
376383 if ( ! opts . force && ( currentLockfile . packages != null ) && ( wantedLockfile . packages != null ) ) {
384+ const currentPackages = currentLockfile . packages
385+ const wantedPackages = wantedLockfile . packages
377386 // add subdependencies that have been updated
378- // TODO: no need to relink everything. Can be relinked only what was changed
379- for ( const depPath of wantedRelDepPaths ) {
380- if ( currentLockfile . packages [ depPath ] &&
381- ( ! equals ( currentLockfile . packages [ depPath ] . dependencies , wantedLockfile . packages [ depPath ] . dependencies ) ||
382- ! isEmpty ( currentLockfile . packages [ depPath ] . optionalDependencies ?? { } ) ||
383- ! isEmpty ( wantedLockfile . packages [ depPath ] . optionalDependencies ?? { } ) )
387+ await Promise . all ( wantedRelDepPaths . map ( ( depPath ) => limitModulesDirReads ( async ( ) => {
388+ if ( currentPackages [ depPath ] &&
389+ ( ! equals ( currentPackages [ depPath ] . dependencies , wantedPackages [ depPath ] . dependencies ) ||
390+ ! isEmpty ( currentPackages [ depPath ] . optionalDependencies ?? { } ) ||
391+ ! isEmpty ( wantedPackages [ depPath ] . optionalDependencies ?? { } ) )
384392 ) {
385393 // TODO: come up with a test that triggers the usecase of depGraph[depPath] undefined
386394 // see related issue: https://github.com/pnpm/pnpm/issues/870
387395 if ( depGraph [ depPath ] && ! newDepPathsSet . has ( depPath ) ) {
388- existingWithUpdatedDeps . push ( depGraph [ depPath ] )
396+ const { actualChildrenChanged, removedAliases : actualRemovedAliases } = await getActualChildrenDiff (
397+ depGraph [ depPath ] ,
398+ depGraph ,
399+ opts . lockfileDir ,
400+ opts . optional
401+ )
402+ if ( actualChildrenChanged ) {
403+ existingWithUpdatedDeps . push ( {
404+ children : depGraph [ depPath ] . children ,
405+ modules : depGraph [ depPath ] . modules ,
406+ name : depGraph [ depPath ] . name ,
407+ optionalDependencies : depGraph [ depPath ] . optionalDependencies ,
408+ removedAliases : actualRemovedAliases ,
409+ } )
410+ return
411+ }
412+ const { changedChildren, removedAliases } = getChangedChildren ( {
413+ currentDependencies : currentPackages [ depPath ] . dependencies ,
414+ currentOptionalDependencies : currentPackages [ depPath ] . optionalDependencies ,
415+ wantedDependencies : wantedPackages [ depPath ] . dependencies ,
416+ wantedOptionalDependencies : wantedPackages [ depPath ] . optionalDependencies ,
417+ allChildren : depGraph [ depPath ] . children ,
418+ } )
419+ if ( ! isEmpty ( changedChildren ) || removedAliases . length > 0 ) {
420+ existingWithUpdatedDeps . push ( {
421+ children : changedChildren ,
422+ modules : depGraph [ depPath ] . modules ,
423+ name : depGraph [ depPath ] . name ,
424+ optionalDependencies : depGraph [ depPath ] . optionalDependencies ,
425+ removedAliases,
426+ } )
427+ }
389428 }
390429 }
391- }
430+ } ) ) )
392431 }
393432
394433 if ( ! newDepPathsSet . size && ( existingWithUpdatedDeps . length === 0 ) ) return { newDepPaths : [ ] , added }
@@ -457,6 +496,7 @@ async function selectNewFromWantedDeps (
457496}
458497
459498const limitLinking = pLimit ( 16 )
499+ const limitModulesDirReads = pLimit ( 16 )
460500
461501async function linkAllPkgs (
462502 storeController : StoreController ,
@@ -525,33 +565,109 @@ async function linkAllPkgs (
525565}
526566
527567async function linkAllModules (
528- depNodes : DependenciesGraphNode [ ] ,
568+ depNodes : ModulesLinkJob [ ] ,
529569 depGraph : DependenciesGraph ,
530570 opts : {
531571 lockfileDir : string
532572 optional : boolean
533573 }
534574) : Promise < void > {
575+ await Promise . all ( depNodes . flatMap ( ( depNode ) => ( depNode . removedAliases ?? [ ] ) . map ( async ( alias ) => limitModulesDirReads ( async ( ) => removeObsoleteChild ( depNode . modules , alias ) ) ) ) )
535576 await symlinkAllModules ( {
536577 deps : depNodes . map ( ( depNode ) => {
537- const children = opts . optional
538- ? depNode . children
539- : pickBy ( ( _ , childAlias ) => ! depNode . optionalDependencies . has ( childAlias ) , depNode . children )
540- const childrenPaths : Record < string , string > = { }
541- for ( const [ alias , childDepPath ] of Object . entries ( children ?? { } ) ) {
542- if ( childDepPath . startsWith ( 'link:' ) ) {
543- childrenPaths [ alias ] = path . resolve ( opts . lockfileDir , childDepPath . slice ( 5 ) )
544- } else {
545- const pkg = depGraph [ childDepPath ]
546- if ( ! pkg || ! pkg . installable && pkg . optional || alias === depNode . name ) continue
547- childrenPaths [ alias ] = pkg . dir
548- }
549- }
550578 return {
551- children : childrenPaths ,
579+ children : getChildrenPaths ( depNode , depGraph , opts . lockfileDir , opts . optional ) ,
552580 modules : depNode . modules ,
553581 name : depNode . name ,
554582 }
555583 } ) ,
556584 } )
557585}
586+
587+ function getChangedChildren (
588+ opts : {
589+ currentDependencies : Record < string , string > | undefined
590+ currentOptionalDependencies : Record < string , string > | undefined
591+ wantedDependencies : Record < string , string > | undefined
592+ wantedOptionalDependencies : Record < string , string > | undefined
593+ allChildren : Record < string , DepPath >
594+ }
595+ ) : { changedChildren : Record < string , DepPath > , removedAliases : string [ ] } {
596+ const { currentOptionalDependencies, wantedOptionalDependencies, allChildren } = opts
597+ // Use null-prototype maps so a child literally named `constructor`,
598+ // `toString`, `__proto__`, etc. is treated as a normal alias instead
599+ // of colliding with an inherited `Object.prototype` key during the
600+ // `in`/index lookups below.
601+ const currentChildren : Record < string , string > = Object . assign ( Object . create ( null ) , opts . currentDependencies , currentOptionalDependencies )
602+ const wantedChildren : Record < string , string > = Object . assign ( Object . create ( null ) , opts . wantedDependencies , wantedOptionalDependencies )
603+ const changedChildren : Record < string , DepPath > = { }
604+ const removedAliases : string [ ] = [ ]
605+ for ( const [ alias , wantedChildDepPath ] of Object . entries ( wantedChildren ) ) {
606+ const optionalityChanged = hasOwn ( wantedOptionalDependencies , alias ) !== hasOwn ( currentOptionalDependencies , alias )
607+ if ( currentChildren [ alias ] !== wantedChildDepPath || optionalityChanged ) {
608+ const resolvedChildDepPath = hasOwn ( allChildren , alias ) ? allChildren [ alias ] : undefined
609+ if ( resolvedChildDepPath != null ) {
610+ changedChildren [ alias ] = resolvedChildDepPath
611+ }
612+ }
613+ }
614+ for ( const alias of Object . keys ( currentChildren ) ) {
615+ if ( ! hasOwn ( wantedChildren , alias ) ) {
616+ removedAliases . push ( alias )
617+ }
618+ }
619+ return { changedChildren, removedAliases }
620+ }
621+
622+ function hasOwn ( obj : Record < string , unknown > | undefined , key : string ) : boolean {
623+ return obj != null && Object . hasOwn ( obj , key )
624+ }
625+
626+ async function getActualChildrenDiff (
627+ depNode : ModulesLinkJob ,
628+ depGraph : DependenciesGraph ,
629+ lockfileDir : string ,
630+ optional : boolean
631+ ) : Promise < { actualChildrenChanged : boolean , removedAliases : string [ ] } > {
632+ if ( depNode . optionalDependencies . size === 0 ) {
633+ return { actualChildrenChanged : false , removedAliases : [ ] }
634+ }
635+ const currentAliases = new Set ( ( await readModulesDir ( depNode . modules ) ?? [ ] ) . filter ( ( alias ) => alias !== depNode . name ) )
636+ const nextAliases = new Set ( Object . keys ( getChildrenPaths ( depNode , depGraph , lockfileDir , optional ) ) )
637+ const removedAliases = Array . from ( currentAliases ) . filter ( ( alias ) => ! nextAliases . has ( alias ) )
638+ const actualChildrenChanged = removedAliases . length > 0 ||
639+ Array . from ( nextAliases ) . some ( ( alias ) => ! currentAliases . has ( alias ) )
640+ return { actualChildrenChanged, removedAliases }
641+ }
642+
643+ async function removeObsoleteChild ( modulesDir : string , alias : string ) : Promise < void > {
644+ // Guard against an alias that would escape the modules directory (e.g. `../../x`).
645+ if ( ! isValidDependencyAlias ( alias ) ) return
646+ await rimraf ( path . join ( modulesDir , alias ) )
647+ if ( alias [ 0 ] === '@' ) {
648+ await fs . rmdir ( path . join ( modulesDir , alias . split ( '/' ) [ 0 ] ) ) . catch ( ( ) => { } )
649+ }
650+ }
651+
652+ function getChildrenPaths (
653+ depNode : ModulesLinkJob ,
654+ depGraph : DependenciesGraph ,
655+ lockfileDir : string ,
656+ optional : boolean
657+ ) : Record < string , string > {
658+ const children = optional
659+ ? depNode . children
660+ : pickBy ( ( _ , childAlias ) => ! depNode . optionalDependencies . has ( childAlias ) , depNode . children )
661+ const childrenPaths : Record < string , string > = { }
662+ for ( const [ alias , childDepPath ] of Object . entries ( children ?? { } ) ) {
663+ if ( alias === depNode . name ) continue
664+ if ( childDepPath . startsWith ( 'link:' ) ) {
665+ childrenPaths [ alias ] = path . resolve ( lockfileDir , childDepPath . slice ( 5 ) )
666+ continue
667+ }
668+ const pkg = depGraph [ childDepPath ]
669+ if ( ! pkg || ! pkg . installable && pkg . optional ) continue
670+ childrenPaths [ alias ] = pkg . dir
671+ }
672+ return childrenPaths
673+ }
0 commit comments