Skip to content

Commit 2c28ba4

Browse files
larixermerceyz
authored andcommitted
fix(nm): Reinstall removed module directories (#3467)
1 parent 56c7bff commit 2c28ba4

File tree

4 files changed

+172
-34
lines changed

4 files changed

+172
-34
lines changed

.yarn/versions/934c31c2.yml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
releases:
2+
"@yarnpkg/cli": patch
3+
"@yarnpkg/plugin-nm": patch
4+
5+
declined:
6+
- "@yarnpkg/plugin-compat"
7+
- "@yarnpkg/plugin-constraints"
8+
- "@yarnpkg/plugin-dlx"
9+
- "@yarnpkg/plugin-essentials"
10+
- "@yarnpkg/plugin-init"
11+
- "@yarnpkg/plugin-interactive-tools"
12+
- "@yarnpkg/plugin-npm-cli"
13+
- "@yarnpkg/plugin-pack"
14+
- "@yarnpkg/plugin-patch"
15+
- "@yarnpkg/plugin-pnp"
16+
- "@yarnpkg/plugin-pnpm"
17+
- "@yarnpkg/plugin-stage"
18+
- "@yarnpkg/plugin-typescript"
19+
- "@yarnpkg/plugin-version"
20+
- "@yarnpkg/plugin-workspace-tools"
21+
- "@yarnpkg/builder"
22+
- "@yarnpkg/core"
23+
- "@yarnpkg/doctor"

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ Yarn now accepts sponsorships! Please give a look at our [OpenCollective](https:
88

99
### Installs
1010
- The pnpm linker no longer tries to remove `node_modules` directory, when `node-modules` linker is active
11-
- The nm linker applies hoisting algorithm on aliased dependencies
11+
- The node-modules linker has received various improvements:
12+
- applies hoisting algorithm on aliased dependencies
13+
- reinstalls modules that have their directories removed from node_modules by the user
1214

1315
**Note:** features in `master` can be tried out by running `yarn set version from sources` in your project (existing contrib plugins are updated automatically, while new contrib plugins can be added by running `yarn plugin import from sources <name>`).
1416

packages/acceptance-tests/pkg-tests-specs/sources/node-modules.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1620,6 +1620,35 @@ describe(`Node_Modules`, () => {
16201620
}),
16211621
);
16221622

1623+
it(`should reinstall and rebuild dependencies deleted by the user on the next install`,
1624+
makeTemporaryEnv(
1625+
{
1626+
dependencies: {
1627+
[`no-deps-scripted`]: `1.0.0`,
1628+
[`one-dep-scripted`]: `1.0.0`,
1629+
},
1630+
},
1631+
{
1632+
nodeLinker: `node-modules`,
1633+
},
1634+
async ({path, run, source}) => {
1635+
await run(`install`);
1636+
await xfs.removePromise(`${path}/node_modules/one-dep-scripted` as PortablePath);
1637+
1638+
const {stdout} = await run(`install`);
1639+
1640+
// Yarn must reinstall and rebuild only the removed package
1641+
expect(stdout).not.toMatch(new RegExp(`no-deps-scripted@npm:1.0.0 must be built`));
1642+
expect(stdout).toMatch(new RegExp(`one-dep-scripted@npm:1.0.0 must be built`));
1643+
1644+
await expect(source(`require('one-dep-scripted')`)).resolves.toMatchObject({
1645+
name: `one-dep-scripted`,
1646+
version: `1.0.0`,
1647+
});
1648+
},
1649+
),
1650+
);
1651+
16231652
it(`should support portals to external workspaces`,
16241653
makeTemporaryEnv(
16251654
{

packages/plugin-nm/sources/NodeModulesLinker.ts

Lines changed: 117 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ const NODE_MODULES = `node_modules` as Filename;
2121
const DOT_BIN = `.bin` as Filename;
2222
const INSTALL_STATE_FILE = `.yarn-state.yml` as Filename;
2323

24-
type InstallState = {locatorMap: NodeModulesLocatorMap, locationTree: LocationTree, binSymlinks: BinSymlinkMap, nmMode: NodeModulesMode};
24+
type InstallState = {locatorMap: NodeModulesLocatorMap, locationTree: LocationTree, binSymlinks: BinSymlinkMap, nmMode: NodeModulesMode, mtimeMs: number};
2525
type BinSymlinkMap = Map<PortablePath, Map<Filename, PortablePath>>;
2626
type LoadManifest = (locator: LocatorKey, installLocation: PortablePath) => Promise<Pick<Manifest, 'bin'>>;
2727

@@ -232,7 +232,7 @@ class NodeModulesInstaller implements Installer {
232232
if (preinstallState === null || nmModeSetting !== preinstallState.nmMode) {
233233
this.opts.project.storedBuildState.clear();
234234

235-
preinstallState = {locatorMap: new Map(), binSymlinks: new Map(), locationTree: new Map(), nmMode: nmModeSetting};
235+
preinstallState = {locatorMap: new Map(), binSymlinks: new Map(), locationTree: new Map(), nmMode: nmModeSetting, mtimeMs: 0};
236236
}
237237

238238
const hoistingLimitsByCwd = new Map(this.opts.project.workspaces.map(workspace => {
@@ -392,7 +392,7 @@ async function extractCustomPackageData(pkg: Package, fetchResult: FetchResult)
392392
};
393393
}
394394

395-
async function writeInstallState(project: Project, locatorMap: NodeModulesLocatorMap, binSymlinks: BinSymlinkMap, nmMode: {value: NodeModulesMode}) {
395+
async function writeInstallState(project: Project, locatorMap: NodeModulesLocatorMap, binSymlinks: BinSymlinkMap, nmMode: {value: NodeModulesMode}, {installChangedByUser}: {installChangedByUser: boolean}) {
396396
let locatorState = ``;
397397

398398
locatorState += `# Warning: This file is automatically generated. Removing it is fine, but will\n`;
@@ -445,6 +445,10 @@ async function writeInstallState(project: Project, locatorMap: NodeModulesLocato
445445
const rootPath = project.cwd;
446446
const installStatePath = ppath.join(rootPath, NODE_MODULES, INSTALL_STATE_FILE);
447447

448+
// Force install state file rewrite, so that it has mtime bigger than all node_modules subfolders
449+
if (installChangedByUser)
450+
await xfs.removePromise(installStatePath);
451+
448452
await xfs.changeFilePromise(installStatePath, locatorState, {
449453
automaticNewlines: true,
450454
});
@@ -454,7 +458,13 @@ async function findInstallState(project: Project, {unrollAliases = false}: {unro
454458
const rootPath = project.cwd;
455459
const installStatePath = ppath.join(rootPath, NODE_MODULES, INSTALL_STATE_FILE);
456460

457-
if (!xfs.existsSync(installStatePath))
461+
let stats;
462+
try {
463+
stats = await xfs.statPromise(installStatePath);
464+
} catch (e) {
465+
}
466+
467+
if (!stats)
458468
return null;
459469

460470
const locatorState = parseSyml(await xfs.readFilePromise(installStatePath, `utf8`));
@@ -481,7 +491,7 @@ async function findInstallState(project: Project, {unrollAliases = false}: {unro
481491
const location = ppath.join(rootPath, npath.toPortablePath(relativeLocation));
482492
const symlinks = miscUtils.getMapWithDefault(binSymlinks, location);
483493
for (const [name, target] of Object.entries(locationSymlinks as any)) {
484-
symlinks.set(toFilename(name), npath.toPortablePath([location, NODE_MODULES, target].join(ppath.delimiter)));
494+
symlinks.set(toFilename(name), npath.toPortablePath([location, NODE_MODULES, target].join(ppath.sep)));
485495
}
486496
}
487497
}
@@ -510,7 +520,7 @@ async function findInstallState(project: Project, {unrollAliases = false}: {unro
510520
}
511521
}
512522

513-
return {locatorMap, binSymlinks, locationTree: buildLocationTree(locatorMap, {skipPrefix: project.cwd}), nmMode};
523+
return {locatorMap, binSymlinks, locationTree: buildLocationTree(locatorMap, {skipPrefix: project.cwd}), nmMode, mtimeMs: stats.mtimeMs};
514524
}
515525

516526
const removeDir = async (dir: PortablePath, options: {contentsOnly: boolean, innerLoop?: boolean, allowSymlink?: boolean}): Promise<any> => {
@@ -818,40 +828,109 @@ const copyPromise = async (dstDir: PortablePath, srcDir: PortablePath, {baseFs,
818828
};
819829

820830
/**
821-
* This function removes node_modules roots that do not exist on the filesystem from the location tree.
822-
*
823-
* This is needed to transparently support workflows on CI systems. When
824-
* user caches only top-level node_modules and forgets to cache node_modules
825-
* from deeper workspaces. By removing non-existent node_modules roots
826-
* we make our location tree to represent the real tree on the file system.
827-
*
828-
* Please note, that this function doesn't help with any other inconsistency
829-
* on a deeper level inside node_modules tree, it helps only when some node_modules roots
830-
* do not exist at all
831+
* Synchronizes previous install state with the actual directories available on disk
831832
*
832833
* @param locationTree location tree
834+
* @param binSymlinks bin symlinks map
835+
* @param stateMtimeMs state file timestamp (this file is written after all node_modules files and directories)
833836
*
834-
* @returns location tree with non-existent node_modules roots stripped
837+
* @returns location tree and bin symlinks with modules, unavailable on disk, removed
835838
*/
836-
function refineNodeModulesRoots(locationTree: LocationTree, binSymlinks: BinSymlinkMap): {locationTree: LocationTree, binSymlinks: BinSymlinkMap} {
837-
const refinedLocationTree: LocationTree = new Map([...locationTree]);
838-
const refinedBinSymlinks: BinSymlinkMap = new Map([...binSymlinks]);
839+
function syncPreinstallStateWithDisk(locationTree: LocationTree, binSymlinks: BinSymlinkMap, stateMtimeMs: number, project: Project): {locationTree: LocationTree, binSymlinks: BinSymlinkMap, locatorLocations: Map<LocatorKey, Set<PortablePath>>, installChangedByUser: boolean} {
840+
const refinedLocationTree: LocationTree = new Map();
841+
const refinedBinSymlinks = new Map();
842+
const locatorLocations = new Map();
843+
let installChangedByUser = false;
844+
845+
const syncNodeWithDisk = (parentPath: PortablePath, entry: Filename, parentNode: LocationNode, refinedNode: LocationNode, nodeModulesDiskEntries: Set<Filename>) => {
846+
let doesExistOnDisk = true;
847+
const entryPath = ppath.join(parentPath, entry);
848+
let childNodeModulesDiskEntries = new Set<Filename>();
849+
850+
if (entry === NODE_MODULES) {
851+
let stats;
852+
try {
853+
stats = xfs.statSync(entryPath);
854+
} catch (e) {
855+
}
839856

840-
for (const [workspaceRoot, node] of locationTree) {
841-
const nodeModulesRoot = ppath.join(workspaceRoot, NODE_MODULES);
842-
if (!xfs.existsSync(nodeModulesRoot)) {
843-
node.children.delete(NODE_MODULES);
844-
845-
// O(m^2) complexity algorithm, but on a very few values, so not worth the trouble to optimize it
846-
for (const location of refinedBinSymlinks.keys()) {
847-
if (ppath.contains(nodeModulesRoot, location) !== null) {
848-
refinedBinSymlinks.delete(location);
857+
doesExistOnDisk = !!stats;
858+
859+
if (!stats) {
860+
installChangedByUser = true;
861+
} else if (stats.mtimeMs > stateMtimeMs) {
862+
installChangedByUser = true;
863+
childNodeModulesDiskEntries = new Set(xfs.readdirSync(entryPath));
864+
} else {
865+
childNodeModulesDiskEntries = new Set(parentNode.children.get(NODE_MODULES)!.children.keys());
866+
}
867+
868+
const binarySymlinks = binSymlinks.get(parentPath);
869+
if (binarySymlinks) {
870+
const binPath = ppath.join(parentPath, NODE_MODULES, DOT_BIN);
871+
let binStats;
872+
try {
873+
binStats = xfs.statSync(binPath);
874+
} catch (e) {
849875
}
876+
877+
if (!binStats) {
878+
installChangedByUser = true;
879+
} else if (binStats.mtimeMs > stateMtimeMs) {
880+
installChangedByUser = true;
881+
882+
const diskEntries = new Set(xfs.readdirSync(binPath));
883+
const refinedBinarySymlinks = new Map();
884+
refinedBinSymlinks.set(parentPath, refinedBinarySymlinks);
885+
886+
for (const [entry, target] of binarySymlinks) {
887+
if (diskEntries.has(entry)) {
888+
refinedBinarySymlinks.set(entry, target);
889+
}
890+
}
891+
} else {
892+
refinedBinSymlinks.set(parentPath, binarySymlinks);
893+
}
894+
}
895+
} else {
896+
doesExistOnDisk = nodeModulesDiskEntries.has(entry);
897+
}
898+
899+
const node = parentNode.children.get(entry)!;
900+
if (doesExistOnDisk) {
901+
const {linkType, locator} = node;
902+
const childRefinedNode = {children: new Map(), linkType, locator};
903+
refinedNode.children.set(entry, childRefinedNode);
904+
if (locator) {
905+
const locations = miscUtils.getSetWithDefault(locatorLocations, locator);
906+
locations.add(entryPath);
907+
locatorLocations.set(locator, locations);
908+
}
909+
910+
for (const childEntry of node.children.keys()) {
911+
syncNodeWithDisk(entryPath, childEntry, node, childRefinedNode, childNodeModulesDiskEntries);
850912
}
913+
} else if (node.locator) {
914+
project.storedBuildState.delete(structUtils.parseLocator(node.locator).locatorHash);
915+
}
916+
};
917+
918+
for (const [workspaceRoot, node] of locationTree) {
919+
const {linkType, locator} = node;
920+
const refinedNode = {children: new Map(), linkType, locator};
921+
refinedLocationTree.set(workspaceRoot, refinedNode);
922+
if (locator) {
923+
const locations = miscUtils.getSetWithDefault(locatorLocations, node.locator);
924+
locations.add(workspaceRoot);
925+
locatorLocations.set(node.locator, locations);
926+
}
927+
928+
if (node.children.has(NODE_MODULES)) {
929+
syncNodeWithDisk(workspaceRoot, NODE_MODULES, node, refinedNode, new Set());
851930
}
852931
}
853932

854-
return {locationTree: refinedLocationTree, binSymlinks: refinedBinSymlinks};
933+
return {locationTree: refinedLocationTree, binSymlinks: refinedBinSymlinks, locatorLocations, installChangedByUser};
855934
}
856935

857936
function isLinkLocator(locatorKey: LocatorKey): boolean {
@@ -942,7 +1021,12 @@ export function getGlobalHardlinksStore(configuration: Configuration): PortableP
9421021
async function persistNodeModules(preinstallState: InstallState, installState: NodeModulesLocatorMap, {baseFs, project, report, loadManifest, realLocatorChecksums}: {project: Project, baseFs: FakeFS<PortablePath>, report: Report, loadManifest: LoadManifest, realLocatorChecksums: Map<LocatorHash, string | null>}) {
9431022
const rootNmDirPath = ppath.join(project.cwd, NODE_MODULES);
9441023

945-
const {locationTree: prevLocationTree, binSymlinks: prevBinSymlinks} = refineNodeModulesRoots(preinstallState.locationTree, preinstallState.binSymlinks);
1024+
const {
1025+
locationTree: prevLocationTree,
1026+
binSymlinks: prevBinSymlinks,
1027+
locatorLocations: prevLocatorLocations,
1028+
installChangedByUser,
1029+
} = syncPreinstallStateWithDisk(preinstallState.locationTree, preinstallState.binSymlinks, preinstallState.mtimeMs, project);
9461030

9471031
const locationTree = buildLocationTree(installState, {skipPrefix: project.cwd});
9481032

@@ -1082,7 +1166,7 @@ async function persistNodeModules(preinstallState: InstallState, installState: N
10821166

10831167
// Update changed locations
10841168
const addList: Array<{srcDir: PortablePath, dstDir: PortablePath, linkType: LinkType, realLocatorHash: LocatorHash}> = [];
1085-
for (const [prevLocator, {locations}] of preinstallState.locatorMap.entries()) {
1169+
for (const [prevLocator, locations] of prevLocatorLocations) {
10861170
for (const location of locations) {
10871171
const {locationRoot, segments} = parseLocation(location, {
10881172
skipPrefix: project.cwd,
@@ -1205,7 +1289,7 @@ async function persistNodeModules(preinstallState: InstallState, installState: N
12051289
const binSymlinks = await createBinSymlinkMap(installState, locationTree, project.cwd, {loadManifest});
12061290
await persistBinSymlinks(prevBinSymlinks, binSymlinks, project.cwd);
12071291

1208-
await writeInstallState(project, installState, binSymlinks, nmMode);
1292+
await writeInstallState(project, installState, binSymlinks, nmMode, {installChangedByUser});
12091293

12101294
if (nmModeSetting == NodeModulesMode.HARDLINKS_GLOBAL && nmMode.value == NodeModulesMode.HARDLINKS_LOCAL) {
12111295
report.reportWarningOnce(MessageName.NM_HARDLINKS_MODE_DOWNGRADED, `'nmMode' has been downgraded to 'hardlinks-local' due to global cache and install folder being on different devices`);

0 commit comments

Comments
 (0)