Skip to content

Switch the frontend toolchain from npm to pnpm - #19542

Draft
brianjhanson wants to merge 4 commits into
6.xfrom
chore/pnpm
Draft

Switch the frontend toolchain from npm to pnpm#19542
brianjhanson wants to merge 4 commits into
6.xfrom
chore/pnpm

Conversation

@brianjhanson

@brianjhanson brianjhanson commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Adds pnpm-workspace.yaml and pnpm-lock.yaml, drops package-lock.json, and converts the local packages to the workspace: protocol.

Config that doesn't map 1:1:

  • overrides and allowScripts move to pnpm-workspace.yaml, where the latter is spelled allowBuilds and keys on the bare package name rather than name@version.
  • enablePrePostScripts: true, since pnpm doesn't run pre*/post* hooks by default and prebuild/predev/prestorybook are load-bearing here.
  • The Font Awesome token leaves the committed .npmrc — pnpm won't expand environment variables in registry credentials read from a project-level file. CI writes it to ~/.npmrc before installing; locally it goes in pnpm's own auth store via pnpm config set.

pnpm's isolated node_modules exposed 15 dependencies that only resolved through npm's hoisting. Two of them affect packages we publish: @craftcms/ui's dist imports autosize without declaring it, and @craftcms/webpack owns the webpack loaders even though webpack resolves them from the consuming config's context (fixed with resolveLoader.modules). The rest are direct imports in resources/js that were never declared.

The lockfile was derived with pnpm import so installed versions match what package-lock.json resolved — this changes the package manager only, not the dependency tree.

Adds pnpm-workspace.yaml and pnpm-lock.yaml, drops package-lock.json, and
converts the local packages to the workspace: protocol.

Config that doesn't map 1:1:

- `overrides` and `allowScripts` move to pnpm-workspace.yaml, where the
  latter is spelled `allowBuilds` and keys on the bare package name rather
  than name@version.
- `enablePrePostScripts: true`, since pnpm doesn't run pre*/post* hooks by
  default and prebuild/predev/prestorybook are load-bearing here.
- The Font Awesome token leaves the committed .npmrc — pnpm won't expand
  environment variables in registry credentials read from a project-level
  file. CI writes it to ~/.npmrc before installing; locally it goes in
  pnpm's own auth store via `pnpm config set`.

pnpm's isolated node_modules exposed 15 dependencies that only resolved
through npm's hoisting. Two of them affect packages we publish:
@craftcms/ui's dist imports autosize without declaring it, and
@craftcms/webpack owns the webpack loaders even though webpack resolves
them from the consuming config's context (fixed with resolveLoader.modules).
The rest are direct imports in resources/js that were never declared.

The lockfile was derived with `pnpm import` so installed versions match
what package-lock.json resolved — this changes the package manager only,
not the dependency tree.
Both packages invoke `webpack` and `webpack-dev-server` from their own
scripts, and craftcms-legacy also runs `vp`, but only @craftcms/webpack
declared any of them. Under npm those binaries were hoisted into the root
node_modules/.bin; pnpm only links the bins of a package's direct
dependencies, so `pnpm run build:bundles` fails from a clean install with
"webpack: command not found".

Resolves to the webpack 5.102.1 already in the tree — no new version, no
duplicate.
31 dependencies were declared in more than one workspace manifest, and 14
of those had already drifted onto different ranges. Catalogs make the
version a single fact: manifests say `catalog:` and pnpm substitutes the
real version at publish time, exactly as it does for `workspace:`.

@craftcms/legacy is deliberately held on older majors (vue 2, tailwind 3,
stylelint 16, TypeScript 5.9) because it builds the Craft 5 control panel
bundles, so those get a named `legacy` catalog rather than being forced onto
the modern versions.

Three published ranges had their floor raised to what is actually resolved
and tested against: @craftcms/playwright's @playwright/test (^1.47.0 ->
^1.59.1), @craftcms/webpack's dotenv (^16.3.1 -> ^16.6.1), and
@craftcms/legacy's inputmask (^5.0.9 -> ^5.0.10).

Not catalogued:

- peerDependencies, which are intentionally wide consumer contracts —
  @craftcms/garnish keeps jquery ^3.5.0.
- vite and vitest, which stay in `overrides`; Vite+ has to replace Vite
  inside third-party packages too, which a catalog cannot do.
- @types/jquery and pkg-dir, which disagree across packages in ways that
  aren't a modern/legacy split and need a decision of their own.

Every one of the 217 importer resolutions in the lockfile is unchanged.
Sets pnpm's minimumReleaseAge, which refuses to resolve or install any
version published less than a week ago. Compromised npm releases are
generally caught and unpublished within hours, so this skips the window in
which they're installable at close to no cost — Craft's build output reaches
a lot of sites, and none of our dependencies need same-day upgrades.

pnpm enforces this both when resolving and as a verification pass over an
existing lockfile, so a lockfile someone produced with the policy disabled
fails CI rather than silently installing.

Enabling it flagged two packages already in the tree: @codemirror/state
6.7.2 and @codemirror/view 6.43.10, both published two days ago and pulled
in transitively by @codemirror/language on open ranges. They're pinned via
overrides to 6.7.1 and 6.43.9 — the newest releases that clear the window.
Those pins can be dropped once the newer versions age out.

No direct dependency resolution changed.
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.

1 participant