Switch the frontend toolchain from npm to pnpm - #19542
Conversation
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.
📚 Storybook previews@craftcms/ui — open Storybook Changed components: resources/js — open Storybook No changed components detected in this Storybook. |
# Conflicts: # package-lock.json
|
This PR had conflicts with Claude's response
|
Important
You'll need to install pnpm once this is merged, and you'll need to replace
npm installwithpnpm install.npm installdoesn't get along with pnpmOne-time setup
corepack enable pnpm also works on Node 22/24. You can skip installing pnpm entirely and use
vp install, which resolves the pinned pnpm for you.Without the token, the optional @awesome.me/* kit is skipped and the install carries on without it.
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:
overridesandallowScriptsmove to pnpm-workspace.yaml, where the latter is spelledallowBuildsand 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.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 importso installed versions match what package-lock.json resolved — this changes the package manager only, not the dependency tree.