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.4 → undici@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.
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.jsondeclaresengines.node: ">=20.0.0"and the blocking CI matrix (.github/workflows/ci.yml:23) runs20.x. Node 20 LTS went end-of-life on 2026-04-30 — three months ago. Publishers have started dropping it.Packages now on
mainthat declare a floor the CI runtime does not meet:@testing-library/jest-dom@7node >=22src/ask-styxopenai@7node >=22.0.0src/webtestcontainers@12.0.4→undici@8.9.0node >=22.19.0src/apiAnd one that had to be held back because it does not merely warn, it breaks:
jsdom@30node ^22.22.2 || ^24.15.0 || >=26.0.0TypeError: webidl.util.markAsUncloneable is not a function— everyask-styxvitest worker fails to start, 0 tests runCI is green today because npm treats
enginesas 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 thejsdomone presented.It also cost real time. The
jsdomfailure 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.jsonandci.yml:engines.nodein the root and any workspace that sets its ownnode-version: 20.xatci.yml:146and:239.config/docker/Dockerfilerender.yamlsrc/mobile@types/nodeThat 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.2was also held back in #855, for a different reason: TS7 is the native compiler and does not expose the JS compiler APIts-jestneeds, so all 32 mobile suites failed to load. That one needs its own migration PR (install@typescript/native, alias@typescript/typescript6astypescriptfor ts-jest) and is not blocked on the Node decision.