This site uses one functional cookie to keep feature rollouts consistent for you. Nothing is set until you choose. See the privacy notice.
Dev notes
Switching package managers, what broke, and what it told us about the dependency graph.
npm was fine. Nothing was broken. The motivation was pnpm's strict dependency resolution: it only exposes packages you explicitly declare, so you can't accidentally import something a transitive dependency happens to provide. npm hoists everything flat and you never notice these "phantom deps" until one disappears.
The speed is nice too. Content-addressable store, hard links, faster CI installs. But the strictness is the real value.
pnpm resolved our loose semver ranges to their actual minimums. "eslint": "^9" became 9.0.0 instead of whatever npm had locked, and our config imports eslint/config which didn't exist until ESLint 9.15. TypeScript ^5 went to 5.0.2, but @vitejs/plugin-react uses the as "module.exports" export syntax which needs TS 5.5+. And @types/node@^20 went to 20.0.0, but Playwright 1.59+ needs types from 20.19+ for the Page type to include newer properties like localStorage and sessionStorage.
None were real bugs. They were ranges that said "anything from X.0.0" when the codebase actually needed X.15+. The fix was tightening the minimums to match reality.
Bumping ESLint from ~9.0 to 9.39 pulled in new react-hooks rules. The react-hooks/refs rule flagged three ref assignments in the particle scene that were being set during render. React 19 discourages writing to ref.current during render because the component won't re-render when the ref changes, which can cause stale reads in concurrent mode. The fix was moving the assignments into a useEffect.
Two other patterns needed suppression rather than refactoring. R3F's useFrame callback intentionally mutates Three.js objects every frame — that's the whole point of an imperative render loop, and the react-hooks/immutability rule can't distinguish it from accidental mutation. And syncing URL search params to local state via useEffect + setState is a standard pattern that the new set-state-in-effect rule doesn't distinguish from the problematic cases it targets (synchronous cascading renders).
The lock file swapped from package-lock.json to pnpm-lock.yaml. GitHub Actions CI now uses pnpm/action-setup with --frozen-lockfile (pnpm's equivalent of npm ci). A packageManager field in package.json tells corepack and Vercel which package manager to use. Vercel auto-detects pnpm from the lock file and needed zero config changes.
Dependency minimums were tightened across the board: eslint, typescript, @types/node, @playwright/test, and @axe-core/playwright all got version floors that match what the codebase actually requires. package-lock.json was added to .gitignore to prevent accidental npm usage from generating a stale lock file.
Loose semver ranges like ^9 or ^5 are a liability. The range says "anything from 9.0.0 to 9.x.x" but the codebase actually depends on features from 9.15+. npm's lock file hides the mismatch because it pins to whatever was latest at install time. pnpm makes the implicit explicit.
Switching package managers is the easy part. The version resolution differences and the lint/type errors they expose are where the actual work is. Every type mismatch we hit was a genuine inconsistency in the dependency graph that npm silently papered over.
CI and deployment were non-events. GitHub Actions has pnpm/action-setup as a first-class action, and Vercel auto-detects the lock file. The only thing worth remembering: add package-lock.json to .gitignore or muscle memory will generate one and confuse the next deploy.