Skip to content

Explore using npm install-strategy=linked#75814

Draft
manzoorwanijk wants to merge 2 commits into
trunkfrom
update/use-npm-install-strategy-linked
Draft

Explore using npm install-strategy=linked#75814
manzoorwanijk wants to merge 2 commits into
trunkfrom
update/use-npm-install-strategy-linked

Conversation

@manzoorwanijk

@manzoorwanijk manzoorwanijk commented Feb 23, 2026

Copy link
Copy Markdown
Member

Addresses #76195

What?

Closes

Why?

How?

Testing Instructions

Testing Instructions for Keyboard

Screenshots or screencast

Before After

Notes

apply-patches.mjs
/* eslint-disable no-console */
/**
 * Custom patch applier for npm install-strategy=linked.
 *
 * With the linked strategy, dependencies are stored in node_modules/.store/
 * instead of being nested under consuming packages. patch-package can't find
 * them at the expected nested paths, so this script locates packages in .store/
 * and applies patches using the `patch` command.
 *
 * patch-package doesn't work well.
 * @see https://github.com/ds300/patch-package/pull/596
 */

import fs from 'node:fs';
import path from 'node:path';
import { execSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';

const __dirname = path.dirname( fileURLToPath( import.meta.url ) );
const patchesDir = __dirname;
const rootDir = path.join( __dirname, '..' );
const nodeModulesDir = path.join( rootDir, 'node_modules' );
const storeDir = path.join( nodeModulesDir, '.store' );

/**
 * Find a package in the .store directory by name and version.
 *
 * @param {string} packageName - Package name (e.g. "lighthouse")
 * @param {string} version     - Package version (e.g. "12.2.2")
 * @return {string|null} Path to the package directory, or null if not found.
 */
function findInStore( packageName, version ) {
	if ( ! fs.existsSync( storeDir ) ) {
		return null;
	}

	const prefix = `${ packageName }@${ version }-`;
	const entries = fs.readdirSync( storeDir );
	const match = entries.find( ( entry ) => entry.startsWith( prefix ) );

	if ( match ) {
		const pkgPath = path.join(
			storeDir,
			match,
			'node_modules',
			packageName
		);
		if ( fs.existsSync( pkgPath ) ) {
			return pkgPath;
		}
	}

	return null;
}

/**
 * Parse a patch filename to extract package name and version.
 * Format: <package-name>+<version>.patch
 * Scoped: @scope+name+<version>.patch
 *
 * @param {string} filename - The patch filename.
 * @return {{ packageName: string, version: string }|null} Parsed info or null.
 */
function parsePatchFilename( filename ) {
	const match = filename.match( /^(.+)\+(\d+\.\d+\.\d+.*)\.patch$/ );
	if ( ! match ) {
		return null;
	}

	// Convert + back to / for scoped packages (e.g. @scope+name -> @scope/name)
	const packageName = match[ 1 ].replace( /\+/g, '/' );
	return { packageName, version: match[ 2 ] };
}

// Read all .patch files from the patches directory.
const patchFiles = fs
	.readdirSync( patchesDir )
	.filter( ( f ) => f.endsWith( '.patch' ) );

let hasErrors = false;

for ( const patchFile of patchFiles ) {
	const parsed = parsePatchFilename( patchFile );
	if ( ! parsed ) {
		console.log( `  Skipping ${ patchFile } (could not parse filename)` );
		continue;
	}

	const { packageName, version } = parsed;

	// Try the standard node_modules path first (for non-linked installs).
	let packageDir = path.join( nodeModulesDir, packageName );

	if ( ! fs.existsSync( packageDir ) ) {
		// Try the .store directory for linked installs.
		packageDir = findInStore( packageName, version );
	}

	if ( ! packageDir ) {
		console.error(
			`  Error: Could not find ${ packageName }@${ version } in node_modules or .store`
		);
		hasErrors = true;
		continue;
	}

	// Read the patch and rewrite paths to target the actual package location.
	const patchPath = path.join( patchesDir, patchFile );
	let patchContent = fs.readFileSync( patchPath, 'utf8' );

	// The patch has paths like: a/node_modules/<pkg>/file → rewrite to actual location.
	// Always use forward slashes — git apply rejects backslashes on Windows.
	const relativePkgDir = path
		.relative( rootDir, packageDir )
		.split( path.sep )
		.join( '/' );
	const pathPattern = new RegExp(
		`(a|b)/node_modules/${ packageName.replace(
			/[.*+?^${}()|[\]\\]/g,
			'\\$&'
		) }/`,
		'g'
	);
	patchContent = patchContent.replace(
		pathPattern,
		`$1/${ relativePkgDir }/`
	);

	// Write temp patch and apply it.
	const tmpPatch = path.join( patchesDir, `.tmp-${ patchFile }` );
	fs.writeFileSync( tmpPatch, patchContent );

	try {
		// Check if the patch is already applied (reverse dry-run succeeds).
		try {
			execSync(
				`git apply --check --reverse --ignore-whitespace "${ tmpPatch }"`,
				{ cwd: rootDir, stdio: 'pipe' }
			);
			// If reverse dry-run succeeds, patch is already applied.
			console.log(
				`  ✔ ${ patchFile } already applied to ${ packageName }@${ version }`
			);
			continue;
		} catch {
			// Reverse failed — patch is not yet applied, proceed.
		}

		execSync( `git apply --ignore-whitespace "${ tmpPatch }"`, {
			cwd: rootDir,
			stdio: 'pipe',
		} );
		console.log(
			`  ✔ Applied ${ patchFile } to ${ packageName }@${ version }`
		);
	} catch ( error ) {
		const stderr = error.stderr?.toString() || '';
		const stdout = error.stdout?.toString() || '';
		console.error(
			`  ✖ Failed to apply ${ patchFile }: ${ stderr || stdout }`
		);
		hasErrors = true;
	} finally {
		fs.unlinkSync( tmpPatch );
	}
}

if ( hasErrors ) {
	process.exit( 1 );
}

/* eslint-enable no-console */

@manzoorwanijk manzoorwanijk self-assigned this Feb 23, 2026
@github-actions github-actions Bot added [Package] Core data /packages/core-data [Package] Components /packages/components [Package] Redux Routine /packages/redux-routine [Package] Notices /packages/notices [Package] Priority Queue /packages/priority-queue [Package] Theme /packages/theme labels Feb 23, 2026
@manzoorwanijk manzoorwanijk force-pushed the update/use-npm-install-strategy-linked branch 3 times, most recently from 3b0ea84 to 61ad5b1 Compare February 23, 2026 08:46
@manzoorwanijk manzoorwanijk force-pushed the update/use-npm-install-strategy-linked branch 4 times, most recently from 15be57c to 4a29dff Compare February 23, 2026 11:02
owlstronaut pushed a commit to npm/cli that referenced this pull request Feb 24, 2026
)

Continuing the `install-strategy=linked` fixes from #8996. While testing
on the [Gutenberg
monorepo](WordPress/gutenberg#75814), `esbuild`
installs fail because its postinstall script runs twice in parallel
against the same store directory.

## Summary

With `install-strategy=linked`, postinstall scripts run twice for every
store package — once for the store entry and once for its symlink. For
packages like `esbuild` whose postinstall modifies files in-place
(`fs.linkSync` to replace the JS wrapper with a native binary), this
race condition corrupts the install.

## Root cause

In `rebuild.js`, `#runScripts` destructures `isStoreLink` from
`node.target` (the store entry) to decide whether to skip a node. But
`isStoreLink` is a property of the link node itself (`node`), not its
target. Store entries don't have `isStoreLink`, so it's always
`undefined` and the guard never triggers. Both the store entry and the
store link run scripts against the same directory in parallel.

## Changes

- Fixed the skip condition in `rebuild.js` `#runScripts` to use
`node.isLink && node.target?.isInStore` instead of reading `isStoreLink`
from `node.target`. This correctly skips store links (symlinks pointing
to store entries) while still allowing workspace links and store entries
themselves to run scripts.
- Added a regression test that verifies postinstall scripts run exactly
once for store packages.

## References
Fixes #9012
manzoorwanijk added a commit to manzoorwanijk/npm-cli that referenced this pull request Feb 25, 2026
…m#9013)

Continuing the `install-strategy=linked` fixes from npm#8996. While testing
on the [Gutenberg
monorepo](WordPress/gutenberg#75814), `esbuild`
installs fail because its postinstall script runs twice in parallel
against the same store directory.

## Summary

With `install-strategy=linked`, postinstall scripts run twice for every
store package — once for the store entry and once for its symlink. For
packages like `esbuild` whose postinstall modifies files in-place
(`fs.linkSync` to replace the JS wrapper with a native binary), this
race condition corrupts the install.

## Root cause

In `rebuild.js`, `#runScripts` destructures `isStoreLink` from
`node.target` (the store entry) to decide whether to skip a node. But
`isStoreLink` is a property of the link node itself (`node`), not its
target. Store entries don't have `isStoreLink`, so it's always
`undefined` and the guard never triggers. Both the store entry and the
store link run scripts against the same directory in parallel.

## Changes

- Fixed the skip condition in `rebuild.js` `#runScripts` to use
`node.isLink && node.target?.isInStore` instead of reading `isStoreLink`
from `node.target`. This correctly skips store links (symlinks pointing
to store entries) while still allowing workspace links and store entries
themselves to run scripts.
- Added a regression test that verifies postinstall scripts run exactly
once for store packages.

## References
Fixes npm#9012
@manzoorwanijk manzoorwanijk force-pushed the update/use-npm-install-strategy-linked branch 2 times, most recently from 8fcbc89 to 28bd283 Compare February 26, 2026 10:08
@WordPress WordPress deleted a comment from github-actions Bot Feb 26, 2026
@manzoorwanijk manzoorwanijk force-pushed the update/use-npm-install-strategy-linked branch from 28bd283 to bf53481 Compare February 27, 2026 05:17
@manzoorwanijk manzoorwanijk force-pushed the update/use-npm-install-strategy-linked branch from bf53481 to 74902e8 Compare March 3, 2026 07:18
@manzoorwanijk manzoorwanijk force-pushed the update/use-npm-install-strategy-linked branch from b9d0c56 to 02536f6 Compare March 12, 2026 20:23
@github-actions

github-actions Bot commented Jun 15, 2026

Copy link
Copy Markdown

Size Change: -1.81 kB (-0.02%)

Total Size: 8.05 MB

📦 View Changed
Filename Size Change
build/modules/vips/worker.min.js 4.24 MB +72 B (0%)
build/modules/workflow/index.min.js 19 kB -868 B (-4.36%)
build/scripts/block-editor/index.min.js 381 kB +767 B (+0.2%)
build/scripts/block-library/index.min.js 323 kB -908 B (-0.28%)
build/scripts/commands/index.min.js 20.1 kB -876 B (-4.17%)

compressed-size-action

@github-actions

github-actions Bot commented Jun 15, 2026

Copy link
Copy Markdown

Flaky tests detected in f0f26e4.
Some tests passed with failed attempts. The failures may not be related to this commit but are still reported for visibility. See the documentation for more information.

🔍 Workflow run URL: https://github.com/WordPress/gutenberg/actions/runs/27821468685
📝 Reported issues:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Package] Block editor /packages/block-editor [Package] Block library /packages/block-library [Package] Components /packages/components [Package] E2E Tests /packages/e2e-tests [Package] Editor /packages/editor [Package] Media Utils /packages/media-utils [Package] Notices /packages/notices [Package] Redux Routine /packages/redux-routine [Package] Server Side Render /packages/server-side-render [Package] Theme /packages/theme [Package] UI /packages/ui

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants