Skip to content

Node 20 is EOL and the dependency tree has moved past it #856

Description

@4444J99

Surfaced while unblocking the npm-deps group bump (#855, superseded #852). Three separate failures in that one PR had the same root cause, so it is worth naming as one problem rather than fixing case by case.

The situation

package.json declares engines.node: ">=20.0.0" and the blocking CI matrix (.github/workflows/ci.yml:23) runs 20.x. Node 20 LTS went end-of-life on 2026-04-30 — three months ago. Publishers have started dropping it.

Packages now on main that declare a floor the CI runtime does not meet:

Package Declares Where
@testing-library/jest-dom@7 node >=22 src/ask-styx
openai@7 node >=22.0.0 src/web
testcontainers@12.0.4undici@8.9.0 node >=22.19.0 src/api

And one that had to be held back because it does not merely warn, it breaks:

Package Declares Outcome on Node 20
jsdom@30 node ^22.22.2 || ^24.15.0 || >=26.0.0 TypeError: webidl.util.markAsUncloneable is not a function — every ask-styx vitest worker fails to start, 0 tests run

CI is green today because npm treats engines as advisory. That is the dangerous part: the first three are latent, not fixed. They break whenever they reach for an API Node 20 does not have, and the failure will look unrelated to the bump that introduced it — exactly how the jsdom one presented.

It also cost real time. The jsdom failure reproduced only in CI; locally this machine runs Node 26, so the whole suite passed before the push.

What fixing it involves

Not just the two lines in package.json and ci.yml:

  • engines.node in the root and any workspace that sets its own
  • the CI matrix, plus the pinned node-version: 20.x at ci.yml:146 and :239
  • the Docker image in .config/docker/Dockerfile
  • the Render runtime in render.yaml
  • Expo/React Native's supported Node range for src/mobile
  • @types/node

That is a deployment-runtime change, which is why it is an issue rather than a PR — it touches what actually runs in production, not just what CI installs.

Why not just stay on 20

The alternative is pinning back every package that moves its floor, indefinitely, on a runtime that no longer receives security patches. #855 already had to hold two majors for this reason. The pinning list only grows.

Related

TypeScript 6.0.3 → 7.0.2 was also held back in #855, for a different reason: TS7 is the native compiler and does not expose the JS compiler API ts-jest needs, so all 32 mobile suites failed to load. That one needs its own migration PR (install @typescript/native, alias @typescript/typescript6 as typescript for ts-jest) and is not blocked on the Node decision.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions