Skip to content

fix!: run root preinstall before reify#9267

Merged
owlstronaut merged 1 commit into
latestfrom
fix/preinstall-ordering-2660
May 21, 2026
Merged

fix!: run root preinstall before reify#9267
owlstronaut merged 1 commit into
latestfrom
fix/preinstall-ordering-2660

Conversation

@owlstronaut
Copy link
Copy Markdown
Contributor

BREAKING CHANGE: root `preinstall` now runs before dependencies are installed.

Summary

Moves the root preinstall script back to its documented position: before dependencies are installed. Restores behavior that shipped through npm 6 and was unintentionally broken in npm 7 when Arborist took over reify.

Fixes #2660.

The bug

The scripts docs have always said preinstall runs "before the package is installed." Since npm 7, the root package.json's preinstall has actually run after arb.reify() i.e. after every dependency has been resolved, fetched, and unpacked. Arborist explicitly excludes the project root from its rebuild queue (reify.js:1234, !diff.ideal.isProjectRoot), and lib/commands/install.js appended preinstall to the post-reify lifecycle loop along with install, postinstall, prepublish, preprepare, prepare, postprepare. npm ci has the same shape.

Net effect: there is currently no way to run a script at the root before dependencies hit disk. Projects that want to bootstrap auth, generate files consumed during resolution, or gate installs behind a precondition have had no supported hook for five years.

The fix

Split the root lifecycle loop in two:

  1. preinstall runs via a small helper before arb.reify().
  2. install, postinstall, prepublish, preprepare, prepare, postprepare run after reify, as they did before.

Same split applied to npm ci.

Why now, and why not wait for a lifecycle redesign

This has history:

A comprehensive lifecycle redesign is still the right long-term project and has clearly not happened in five years. Gating the revert on it was reasonable in 2021; but has proven that continuing to gate on it in 2026 just means users keep hitting a bug the docs promise doesn't exist. npm 12 is the right window to ship the scoped revert and decouple it from a future lifecycle rewrite.

The code change here is functionally the same as #2713 The differences are: it also patches npm ci, updates the docs to match reality, adds regression tests, and ships in a major (npm 12) rather than being blocked on a design gate that no longer has owners.

Closes / supersedes

When this lands:

@owlstronaut owlstronaut requested a review from a team as a code owner April 21, 2026 21:43
@owlstronaut owlstronaut mentioned this pull request Apr 21, 2026
2 tasks
Comment thread lib/commands/ci.js
Comment thread test/lib/commands/ci.js
Comment thread lib/commands/install.js
@owlstronaut owlstronaut force-pushed the fix/preinstall-ordering-2660 branch from 4ce2da1 to 586dd87 Compare May 20, 2026 16:44
@owlstronaut owlstronaut requested a review from a team as a code owner May 20, 2026 16:44
@owlstronaut owlstronaut requested a review from nishantms May 20, 2026 16:47
* `postprepare`

`preinstall` runs before any dependencies are fetched or unpacked into `node_modules`, so scripts can prepare the environment (for example, setting up authentication for a private registry) before resolution begins. The remaining scripts run after installation has completed.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: this means any scripts that require() a dependency will probably fail

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was the biggest reason for the change in the first place. This tripped up more folks than anticipated. It seems obvious in hindsight but it isn't.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 — worth calling out explicitly in the doc since this is the historical footgun. Maybe a sentence at the end of the new paragraph, e.g.:

Because dependencies are not yet on disk when preinstall runs, scripts cannot require() any package from node_modules at this point. Use install / postinstall for setup that depends on installed packages.

@gorkemmulayim

This comment was marked as off-topic.

t.ok(post, 'postinstall ran')
t.equal(pre.depInstalled, false, 'preinstall runs before dependencies are installed')
t.equal(post.depInstalled, true, 'postinstall runs after dependencies are installed')
})
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit — the new test covers the success path; should we also lock in the failure contract? Something like:

await t.test('a failing preinstall prevents reify', async t => {
  // mock '@npmcli/run-script' to throw on preinstall
  // assert npm.exec('install') rejects AND node_modules/abbrev does not exist
})

The "no dep on disk before preinstall" guarantee is the real reason for the change — worth a regression test so a future refactor cannot quietly swallow a rejection and still reify.

Comment thread test/lib/commands/ci.js Outdated
'package-lock.json': JSON.stringify(packageLock),
},
mocks: {
'@npmcli/run-script': (opts) => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit — the install.js equivalent mocks this as async (opts) => {...}; here it is sync. Both work, but matching the shape would make it obvious the helper returns a promise:

'@npmcli/run-script': async (opts) => { ... },

nishantms
nishantms previously approved these changes May 21, 2026
Copy link
Copy Markdown

@nishantms nishantms left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, no major blocker, few nits

BREAKING CHANGE: root \`preinstall\` now runs before dependencies are installed.
@owlstronaut owlstronaut merged commit 2a03860 into latest May 21, 2026
25 checks passed
@owlstronaut owlstronaut deleted the fix/preinstall-ordering-2660 branch May 21, 2026 18:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] Preinstall script runs after installing dependencies

4 participants