Migrating from patch-package to native patching in npm v12

In my previous post I walked through the three features I brought to npm v12. The first of them, native dependency patching with npm patch, replaces a job that most npm projects have handed to patch-package for years. This post is the practical follow-up: how to move an existing patch-package setup to npm’s native patching, including a script that converts your existing patch files for you.

Every command and output below was run against npm 12.0.2 and patch-package 8.0.1 on a real project before I wrote it down.

Why migrate at all?

patch-package has served the ecosystem well, and if you’re still on npm v11 there’s no reason to rip it out. On npm v12, though, three things change the picture.

Creating new patches with patch-package fails under npm v12 defaults. To build a patch, patch-package installs a clean copy of the package in a temp directory, and for npm projects it installs it from the tarball URL recorded in your lockfile. npm v12 blocks remote tarball URLs by default via allow-remote, so that inner install fails:

$ npx patch-package is-odd
patch-package 8.0.1
• Creating temporary folder
• Installing [email protected] with npm
{
  status: 1,
  ...
}

patch-package hides npm’s output, but replaying the same install by hand shows the cause:

npm error code EALLOWREMOTE
npm error Fetching packages of type "remote" have been disabled
npm error Refusing to fetch "is-odd@https://registry.npmjs.org/is-odd/-/is-odd-3.0.1.tgz"

You can work around it with npm_config_allow_remote=all npx patch-package <pkg>, but that means loosening a security default every time you touch a patch. Applying existing patches is unaffected: npm v12 denies install scripts from your dependencies, but your own project’s postinstall still runs.

patch-package is a postinstall script, so anything that skips scripts skips your patches. npm ci --ignore-scripts is common in CI and Docker builds, and with patch-package it silently gives you unpatched code:

$ npm ci --ignore-scripts
$ node -e "require('is-odd')(1.5)"
Error: expected an integer        # the patch never ran

Native patches are applied by the installer itself, so --ignore-scripts has no effect on them.

patch-package can’t see transitive dependencies under isolated mode. With install-strategy=linked, direct dependencies are symlinks into node_modules/.store/, so patch-package still manages to patch those. A nested dependency lives only inside the store, though, and patch-package can’t find it. Worse, the install still exits 0:

Applying patches...
[email protected] ✔
Error: Patch file found for package is-number which is not present at node_modules/is-odd/node_modules/is-number
---
patch-package finished with 1 error(s).

added 61 packages, and audited 62 packages in 821ms

I’ve opened ds300/patch-package#596 to teach patch-package the .store layout (#595), and it’s still open at the time of writing, so on npm v12 the native route is the one that works today.

Beyond those, native patching gives you a few things patch-package can’t:

  • The patch’s hash is recorded in package-lock.json, so npm ci rejects a patch file that changed without the lockfile being updated.
  • A patch that no longer applies, or that matches nothing installed, fails the install instead of logging a warning.
  • npm ls shows which installed packages are patched.
  • npm patch update rebases a patch onto a new version of the package with a 3-way merge.

What’s different

patch-packagenpm native
Where patches are declaredImplied by file names in patches/patchedDependencies in the root package.json
File name@scope+name+1.2.3.patchpatches/@scope/[email protected]
Paths inside the diffa/node_modules/<pkg>/index.jsa/index.js (relative to the package root)
When patches applypostinstall scriptDuring install, right after each package is extracted
LockfileNot involvedPatch hash recorded in package-lock.json
Nested copiesparent++child+1.2.3.patch targets one location[email protected] applies to every installed copy of that version
Several patches per package--append sequencesOne patch per selector (the migration merges them)
Dev-only patches.dev.patch suffixNot needed: patches for omitted packages are skipped

Two practical consequences:

  1. You can’t point patchedDependencies at your old files. Because patch-package‘s diff paths include node_modules/<pkg>/, npm looks for node_modules/is-odd/index.js inside the package and fails:npm error code EPATCHFAILED npm error file node_modules/is-odd/index.js npm error patch target is missing: node_modules/is-odd/index.js The files need to be regenerated, which is what the steps below do.
  2. Your lockfile moves to lockfileVersion 4. The first time you add a patch, npm tells you it’s upgrading the lockfile:npm warn shrinkwrap patchedDependencies requires lockfileVersion 4; upgrading the lockfile from version 3. Make sure everyone, CI included, is on npm v12 from that point on. I ran npm ci on the migrated project with npm 11: it exited 0 and installed unpatched code, because older npm simply ignores patchedDependencies. To turn that into a hard error, add a devEngines guard:npm pkg set devEngines.packageManager.name=npm \ 'devEngines.packageManager.version=>=12' \ devEngines.packageManager.onFail=error With that in place, npm 11 stops with EBADDEVENGINES instead of installing.

Migrating, step by step

I’ll use a small project with the kinds of patches you’d find in a real repo: a scoped package, a package with two --appended patches, and a nested dependency:

patches/
├── @sindresorhus+slugify+2.2.1.patch
├── is-odd++is-number+6.0.0.patch
├── is-odd+3.0.1+001+initial.patch
└── is-odd+3.0.1+002+second.patch

1. Remove patch-package

Start from a clean install, then drop the dependency and the postinstall hook:

npm ci
npm uninstall patch-package
npm pkg delete scripts.postinstall

If your postinstall runs other things too ("postinstall": "patch-package && husky"), edit it by hand and keep the rest.

2. Convert one patch by hand

For a single package, the conversion is three commands. npm patch add extracts a pristine copy of the package into a temp directory and prints its path:

$ npm patch add is-odd
You can now edit the following directory: /var/folders/.../npm-patch/[email protected]
When done, run: npm patch commit /var/folders/.../npm-patch/[email protected]

Instead of editing by hand, apply the old patch into that directory with git apply. The -p flag strips the leading path segments: -p3 removes a/node_modules/is-odd/ from an unscoped package, -p4 removes a/node_modules/@scope/name/ from a scoped one.

git -C /var/folders/.../npm-patch/[email protected] apply -p3 "$PWD/patches/is-odd+3.0.1.patch"
npm patch commit /var/folders/.../npm-patch/[email protected]
Patched [email protected] -> patches/[email protected]

npm patch commit diffs the directory against the original tarball, writes the new patch, adds it to patchedDependencies, and updates the lockfile. Then delete the old is-odd+3.0.1.patch.

3. Or convert them all with a script

For more than a couple of patches, this script does the same thing for every patch-package file. It groups --appended patches for the same package and applies them in order into one edit directory, works out the -p depth for scoped and nested patches, and removes the old files once each package is committed. It uses git and runs on macOS and Linux.

// migrate-patches.mjs
/**
 * Convert patch-package patches into npm native patches.
 * Usage: node migrate-patches.mjs [patch-package-dir]
 */
import { execFileSync } from 'node:child_process'
import { mkdtempSync, readdirSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'

const dir = resolve(process.argv[2] || 'patches')
const npm = (...args) => execFileSync('npm', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'] })

// Parse "@scope+name+1.2.3+001+label.patch" or "parent++child+1.2.3.patch".
const parse = file => {
  const chain = file.replace(/(\.dev)?\.patch$/, '').split('++').map(part => {
    const bits = part.split('+')
    const v = bits.findIndex(b => /^\d+\.\d+\.\d+/.test(b))
    const name = (v === -1 ? bits : bits.slice(0, v)).join('/')
    return { name, version: bits[v], seq: Number(bits[v + 1] || 0) }
  })
  const { name, version, seq } = chain.at(-1)
  // Strip "a/" plus every segment of node_modules/<parent>/node_modules/<name>.
  const strip = 1 + chain.reduce((n, p) => n + 1 + p.name.split('/').length, 0)
  return { file, name, version, seq, strip }
}

const groups = new Map()
for (const file of readdirSync(dir).filter(f => f.endsWith('.patch') && f.includes('+'))) {
  const p = parse(file)
  const key = `${p.name}@${p.version}`
  groups.set(key, [...(groups.get(key) || []), p])
}

for (const [key, patches] of groups) {
  const editDir = mkdtempSync(join(tmpdir(), 'npm-patch-migrate-'))
  npm('patch', 'add', key, '--edit-dir', editDir)
  for (const p of patches.sort((a, b) => a.seq - b.seq)) {
    execFileSync('git', ['apply', `-p${p.strip}`, join(dir, p.file)], { cwd: editDir, stdio: 'inherit' })
  }
  process.stdout.write(npm('patch', 'commit', editDir))
  for (const p of patches) {
    rmSync(join(dir, p.file))
  }
}

Run it from the project root (pass the directory if you used patch-package‘s --patch-dir):

$ node migrate-patches.mjs
npm warn shrinkwrap patchedDependencies requires lockfileVersion 4; upgrading the lockfile from version 3.
Patched @sindresorhus/[email protected] -> patches/@sindresorhus/[email protected]
Patched [email protected] -> patches/[email protected]
Patched [email protected] -> patches/[email protected]

The two is-odd patches are now a single patches/[email protected], and package.json has:

"patchedDependencies": {
  "@sindresorhus/[email protected]": "patches/@sindresorhus/[email protected]",
  "[email protected]": "patches/[email protected]",
  "[email protected]": "patches/[email protected]"
}

If a patch no longer applies (patch-package tolerates a version mismatch with a warning, npm does not), git apply stops the script at that package. Fix the patch or bump the dependency, then run the script again: packages that already converted are gone from the old naming scheme, so it picks up where it left off.

4. Verify and commit

Do a clean install and check what’s patched:

$ rm -rf node_modules && npm ci
$ npm ls --all | grep patched
├─┬ @sindresorhus/[email protected] [patched: patches/@sindresorhus/[email protected]]
└─┬ [email protected] [patched: patches/[email protected]]
  └── [email protected] [patched: patches/[email protected]]

$ npm patch ls
patches/@sindresorhus/[email protected]	@sindresorhus/[email protected]	(1 node)
patches/[email protected]	[email protected]	(1 node)
patches/[email protected]	[email protected]	(1 node)

Run your test suite, then commit package.jsonpackage-lock.json, and the patches/ directory together. A patch file without its lockfile entry is exactly what npm ci is designed to reject.

Day-to-day with native patches

Making a new patch is the same add, edit, commit loop, no allow-remote workaround required:

npm patch add lodash
# edit the files in the printed directory
npm patch commit <printed directory>

If more than one version is installed, npm asks you to pick with an exact selector like npm patch add [email protected].

Upgrading a patched package is where the strictness pays off. Bump the dependency without touching the patch, and the install stops instead of quietly shipping unpatched code:

$ npm install @sindresorhus/[email protected]
npm error code EPATCHUNUSED
npm error The following patches were registered but matched no installed package:
npm error   @sindresorhus/[email protected] -> patches/@sindresorhus/[email protected]
npm error Use --allow-unused-patches to install anyway.

Rebase the patch first, then install the new version:

$ npm patch update @sindresorhus/slugify --to 3.0.1
Updated @sindresorhus/[email protected] -> @sindresorhus/[email protected] (patches/@sindresorhus/[email protected])
$ npm install @sindresorhus/[email protected]

npm patch update replays your patch onto the new version with a 3-way merge. If it conflicts, it leaves the merged files in an edit directory for you to resolve and finish with npm patch commit.

Dropping a patch once upstream ships the fix:

$ npm patch rm is-number
Removed patch: [email protected]

That removes the entry, deletes the patch file, and reinstalls the pristine package.

Tampering is caught. Edit a patch file by hand without reinstalling and npm ci refuses to proceed:

npm error Invalid: patch for [email protected] does not match the patch recorded in the lock file

Run npm install after an intentional edit to record the new hash.

Things to know

  • Registry packages only. file:, git, and remote tarball dependencies can’t be patched (EPATCHNONREGISTRY); edit those at the source.
  • Root package.json only. In a monorepo, patchedDependencies belongs in the root manifest, not a workspace’s.
  • Text files only. Patches are text diffs, so changes to binary files (images, wasm, native addons) can’t be captured.
  • package.json edits aren’t captured. To fix a dependency’s manifest, use packageExtensions instead.
  • Custom location. Set patches-dir if you want patches somewhere other than patches/.
  • Escape hatches are CLI-only. --allow-unused-patches and --ignore-patch-failures are ignored in .npmrc and rejected by npm ci, so a relaxed setting can’t quietly leak into CI.

Conclusion

For most projects, the migration is one script run and one commit: uninstall patch-package, convert the patch files, verify with npm ci, done. What you get in return is patching that survives --ignore-scripts, works under isolated mode, can’t drift from the lockfile, and tells you loudly when an upgrade breaks a patch.

If you’re coming from another package manager, the model will feel familiar: it’s the same idea as pnpm patch and yarn patch. The implementation landed in npm/cli#9439, and if you run into an edge case during your migration, please open an issue. That’s how the rough edges get found.

Tags: , ,

Leave a Reply