Skip to content

Support React 19, harden dependencies, and migrate to pnpm - #35

Merged
Devon-White merged 25 commits into
mainfrom
Devon/pnpm-migration
Aug 24, 2026
Merged

Support React 19, harden dependencies, and migrate to pnpm#35
Devon-White merged 25 commits into
mainfrom
Devon/pnpm-migration

Conversation

@Devon-White

@Devon-White Devon-White commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

Consolidates #34 into this PR because the dependency fixes and package-manager migration are one coupled change: pnpm's strict layout and scoped overrides are what make the dependency declarations, React matrix, packaging checks, and security fixes reliable.

Dependency and compatibility changes

  • Supports React 18 and 19 and matrix-tests both runtime and type packages.
  • Uses React 18 as the declaration-emission floor so published types work for Docusaurus 3 consumers, while independently rebuilding against React 19.
  • Raises the Docusaurus peer floor to ^3.9.0 || ^4.0.0 and Node to >=20, matching current Docusaurus 3.
  • Reclassifies Docusaurus as peer/build tooling and declares dependencies that previously worked only through hoisting.
  • Fixes the removed OpenAI icon, GFM defaults, trailing-slash copy behavior, and several type/package hygiene defects.

pnpm migration

  • Replaces Yarn 1 and Lerna/Nx with pnpm 10.34.5 workspace filtering.
  • Keeps Node 20 compatibility; pnpm 11 requires a newer Node release.
  • Uses scoped pnpm overrides for vulnerable build-time transitive dependencies.
  • Ensures build:website builds workspace packages first, including from a clean checkout.
  • Uses explicit workspace links and removes --if-present from required build and publish gates.

Packaging and swizzling

  • Excludes tests and TypeScript build caches from published tarballs.
  • Ships and exports the hooks, types, and icon helpers needed by TypeScript swizzles.
  • CI ejects CopyPageContent into the website and type-checks the result.
  • Published React declarations are built against the lowest supported type version.

Verification

Verified on Node 20.20.2 and pnpm 10.34.5:

  • frozen install and clean package/website builds
  • React 18 and React 19 builds, type checks, 27 tests, and website builds
  • formatting, linting, root test-project type checking, and package prepublish checks
  • real Docusaurus TypeScript swizzle and consumer type check
  • package tarball contents and workspace-link resolution

This supersedes #34; its complete commit history is included here.

…sories)

The `security-audit` CI job was unsatisfiable: `yarn audit --groups
dependencies` reported 71 unique advisories and Yarn Classic's `--level`
flag does not affect the exit code (verified: exit 30 even with
`--level critical`).

The cause was our own `resolutions` block plus a ~9-month-stale lockfile,
not the Docusaurus version. `@docusaurus/core@3.9.1` and `@3.10.2` declare
byte-identical ranges for every vulnerable package.

The unscoped `>=x` resolutions collapsed every semver range for a package
name into one lockfile entry, actively creating advisories:

  js-yaml@4.1.0, ^3.10.0, ^3.13.1, ^3.14.2, ^3.6.1, ^4.1.0 -> 3.14.2
  brace-expansion@^1.1.7, ^2.0.1                           -> 4.0.1
  webpack-dev-server@^5.2.2                                -> frozen 5.2.2

Removing them alone: 71 -> 67. Refreshing the lockfile (every remaining
advisory was a caret-ranged transitive whose patch was already in range):
67 -> 5.

The 5 survivors are all @docusaurus/core build-tool paths that never reach
a consumer, and all are unfixable at Docusaurus 3.x:
  - image-size x2 (HIGH)  no patch published at any version
  - serialize-javascript x2 via copy-webpack-plugin -- see docusaurus#11801
  - uuid x1 via webpack-dev-server > sockjs (pinned to uuid@^8 by sockjs)

Three breakages surfaced by the refresh, fixed here:

* react-icons 5.7.0 removed `SiOpenai` (Simple Icons dropped the OpenAI
  logo). This was a live bug in the PUBLISHED theme: it declares
  `react-icons: ^5.5.0`, so any fresh install today resolves 5.7.0 where
  `SiOpenai` is undefined at runtime and the ChatGPT dropdown item crashes
  with "Element type is invalid". Switched to `RiOpenaiFill`.

* Docusaurus 3.10 renamed `future.experimental_faster` -> `future.faster`.

* Docusaurus 3.10 added the `mdx1CompatDisabledByDefault` v4 sub-flag
  (absent in 3.9.1), which `future.v4: true` enables. That disables the
  MDX1 HTML-comment shim, so `<!-- truncate -->` no longer parses.
  Converted the blog truncate markers to `{/* truncate */}`.
…eers

Docusaurus is a build tool: it produces static assets and has no server
runtime. Classifying it as a runtime `dependency` made every consumer
install a second copy of @docusaurus/core -- and put webpack-dev-server,
image-size and copy-webpack-plugin into the production audit scope, where
none of them can ever reach a consumer.

This is the remedy the Docusaurus maintainers themselves prescribe
(facebook/docusaurus#5501, which defers to react/create-react-app#11174):
move the build tool out of `dependencies`, then audit production only.

theme-llms-txt
  - @docusaurus/core + theme-common: dependencies -> peerDependencies
    (matches docusaurus-theme-openapi-docs, redocusaurus, and every other
    maintained third-party theme; none ship core as a dependency)
  - react/react-dom peers: ^18.0.0 -> ^18.0.0 || ^19.0.0, matching
    @docusaurus/theme-classic exactly. The old range was self-contradictory:
    it capped at React 18 while shipping @types/react ^19.1.13 and being
    consumed by a React 19 site.

plugin-llms-txt
  - declare peers for what is actually imported: @docusaurus/logger, utils,
    utils-validation. These were devDependencies only, so at consumer sites
    they resolved purely by hoisting.
  - remark-parse: undeclared but imported -> dependencies
  - hast-util-to-mdast, @types/mdast: undeclared type-only imports -> devDeps
  - commander: NOT declared. It is genuinely Docusaurus-supplied -- the
    instance arrives via `extendCli`, hoisted from @docusaurus/core and
    @docusaurus/types. Declaring our own copy could resolve to a different
    major than the one we receive. Instead the type is now derived from
    Docusaurus's own Plugin interface:
      type ExtendCliArg = Parameters<NonNullable<Plugin['extendCli']>>[0]
    This also drops `CommanderStatic`, a commander@5-only name.
  - drop @types/react-dom (unused); @types/react -> ^19

website
  - all @docusaurus/*, sass, docusaurus-plugin-sass, typescript -> devDeps;
    `dependencies` now holds only what reaches the browser bundle
  - remove the duplicate block that declared four @Docusaurus packages in
    BOTH dependencies (^3.9.1) and devDependencies (pinned 3.8.1, a version
    the lockfile did not even contain)
  - typescript ~5.6.2 -> ^5.8.3, matching the root

root
  - add react/react-dom/@types to devDependencies. Yarn Classic evaluates
    workspace peers against the root manifest, so without this it emitted
    false "unmet peer dependency react" warnings. Also gives the React
    18-vs-19 CI matrix a single hoisted copy to swap.

engines.node: >=18.0.0 -> >=20.0 across all three (the root already said
>=20.0.0; Docusaurus 3.9 dropped Node 18).

Audit result, production scope: 71 advisories / exit 30 -> 0 advisories /
exit 0. Unmet-peer warnings on install: 27 -> 10.
…tests

Audit gate
----------
Removed the `security-audit` job (`yarn audit --groups dependencies`) and
added .github/workflows/dependency-review.yml using
actions/dependency-review-action@v5.

The old job gated absolute INVENTORY, which meant any PR -- even a README
edit -- failed on pre-existing transitive advisories, with no mechanism to
accept one that has no fix. The new job gates the DELTA: it fails only on
dependencies a PR actually introduces. `fail-on-scopes` defaults to
`runtime`, so build tooling is out of scope by design, and `allow-ghsas`
gives a first-party way to accept an advisory with no available fix.

This mirrors facebook/docusaurus, which runs no audit step at all across
any of its 18 workflows -- only dependency-review, CodeQL, a Socket
Firewall supply-chain job, and Dependabot.

Added .github/dependabot.yml (cooldown + grouped updates, modelled on
Docusaurus's) so the lockfile cannot rot for nine months again -- that
staleness was the root cause of the whole pile-up.

Tests
-----
`yarn test` ran `lerna run test`, and neither package defined a `test`
script, so the CI test job passed having executed nothing. The one test
file in the repo had never run in CI.

- root `test` now runs jest directly; dropped the misleading `test:packages`
  and `lerna:test` aliases
- jest.config.cjs split into three projects: `monorepo` and `plugin` on the
  node environment, `theme` on jsdom
- added jsdom + @testing-library/{react,dom,jest-dom,user-event}; RTL 16
  peers on react ^18 || ^19, so it works under both matrix legs
- `@docusaurus/*` and `@theme/*` are webpack aliases with no Jest
  equivalent, so test/mocks/ provides stand-ins with settable state, reset
  between tests by test/setup-theme.ts
- 18 new tests covering useDropdownState (including the ref that carries
  the React-19-only RefObject<T | null> signature) and CopyPageContent
  (the null-while-loading hydration path, trailing-slash key probing from
  PR #21, and the remount fetch cache)

Two pre-existing CI blind spots fixed along the way:

* `.eslintignore` excluded `__tests__/`, so no test file had ever been
  linted and the eslintrc override for them was dead config. Removing it
  surfaced real errors in monorepo.test.ts (conditional expects, invalid
  titles) -- fixed rather than suppressed. The conditional-expect block
  silently passed via `expect(true).toBe(true)` when it found no plugin
  packages; it now asserts unconditionally.

* per-package `format:check` used an unquoted `src/**/*.{ts,tsx}` glob. The
  plugin has no .tsx files, so the shell either errored or passed the
  pattern through literally and prettier exited 2. Quoted so prettier
  expands it itself.

monorepo.test.ts also gained two regression tests pinning the invariants
this branch establishes: @docusaurus/core must never be a runtime
dependency, and React peers must accept both 18 and 19.

React hygiene
-------------
With `jsx: react-jsx` already set, only CopyPageContent used React as a
value. Converted the other ten files to `import type React`, dropped the
entirely-unused import in DocBreadcrumbs, and switched React.useMemo to a
named import. Removed the dead `globals: { JSX: true }` from .eslintrc.cjs,
a leftover from the global JSX namespace that @types/react@19 deleted.
The packages now claim `react: ^18.0.0 || ^19.0.0`. This makes that claim
verifiable instead of aspirational.

The `test` job runs as a matrix over React 18 and 19, replacing the separate
test-plugins and test-website jobs (both now run inside each leg, so the
website is built against the matrix version too). Only the "WIP" check is
required by branch protection, so the job rename does not affect the merge
gate.

The pin step swaps React in the root AND the website workspace: the website
declares its own React range, so swapping only the root leaves a nested copy
behind and the leg would silently test the wrong version. A guard step asserts
the root and the website resolve the same react/package.json and that its
version matches the matrix leg, so a botched pin fails loudly rather than
producing a false pass.

Docs
----
- compatibility tables in both package READMEs (there were none)
- root README said Node >= 18 while engines said >= 20, and listed Husky as
  part of the toolchain though no .husky directory exists -- both corrected
- SECURITY.md explains the gating model: why we gate the delta rather than the
  inventory, why runtime scope is the right filter for a static-site build
  tool, how to accept an unfixable advisory via `allow-ghsas`, and why
  unscoped Yarn Classic `resolutions` are a trap
- `packageManager: yarn@1.22.22` so Corepack pins the version; dropped the
  stray `engines.npm` from what is a Yarn-only repo

Verified against packed tarballs installed with npm, which enforces peer
ranges strictly where Yarn Classic only warns:

  published 1.0.0-alpha.9 + React 19 -> npm install FAILS
    ERESOLVE: peer react@"^18.0.0" ... Found: react@19.2.8
  this branch + React 18            -> installs clean, 1 @docusaurus/core
  this branch + React 19            -> installs clean, 1 @docusaurus/core

Both consumer sites then ran `docusaurus build` against Docusaurus 3.10.2
successfully, generated llms.txt / llms-full.txt / markdown, and shipped the
theme into the client bundle.
…plugin

The earlier commits tightened the plugin's manifest but left equivalent
issues in the theme. Bringing it to parity.

Removed three unused devDependencies from the theme:
  - @docusaurus/plugin-content-docs -- no code reference. The only thing that
    needed it was thought to be `@theme-init/DocBreadcrumbs`, but that is
    declared by @docusaurus/module-type-aliases (`declare module
    '@theme-init/*'`), which the theme already keeps.
  - fs-extra -- not imported by the theme. The build calls
    ../../scripts/copyUntypedFiles.js, which resolves fs-extra from the root.
  - @types/react-dom -- the theme has zero react-dom imports, and the root
    now provides the types workspace-wide. react-dom stays a peerDependency.

Removed src/utils/htmlExtractor.ts. `extractHtmlContent` was unreferenced
dead code and a superseded duplicate of `extractContentFromDom` in
useCopyActions -- identical selector-then-fallback logic, except it parsed a
string via DOMParser instead of reading the live document. It was being
compiled into lib/ and published.

Removed packages/tsconfig.json. Nothing extended, built, or referenced it,
and it disagreed with the configs actually in use (outDir ./dist against the
lib/ everything else emits to, rootDir ../).

Added the theme to the root tsconfig.json `references`. The plugin and the
website were listed but the theme never was, so `tsc --build` at the root
silently skipped it. It now type-checks as part of the solution.

Verified: `tsc --build` at the root now succeeds across all three projects;
the theme's client project type-checks standalone; and clean-built tarballs
install and build in scratch Docusaurus 3.10.2 consumers on React 18 and 19,
with one @docusaurus/core and llms.txt generated in both.

Note on packaging: `yarn pack` does not run `prepublishOnly`, so a stale
lib/ can leak orphaned output into a locally packed tarball -- tsc --build
does not delete outputs whose sources are gone. The real publish path is
unaffected because prepublishOnly runs `clean` first, confirmed by packing
after a clean build.
Three defects found while chasing editor errors on the theme's .tsx files.

1. The theme's type-check was a no-op
------------------------------------
The package had two overlapping TS projects, both with `include: ["src"]`:
tsconfig.json (NodeNext, no jsx) referencing tsconfig.client.json (CommonJS,
jsx, Docusaurus types). Project-reference resolution meant the root project
ended up with exactly ONE file -- src/theme-llms-txt.d.ts -- while the client
project compiled everything real.

Consequences:
  - `yarn type-check` (tsc --noEmit on the root project) checked 1 file. Only
    `tsc --build` caught anything, which is why the react-icons SiOpenai
    breakage showed up in `build` but not `type-check`.
  - Editors resolve the nearest tsconfig.json, which did not include any .tsx
    file, so tsserver fell back to an inferred project with no `jsx` and no
    module aliases. Every theme component showed spurious "Cannot find module"
    errors for @theme/*, @docusaurus/*, and *.module.css -- 11 in total.

The split bought nothing: the package is entirely CommonJS + JSX (no
"type": "module"), and the client project already compiled the Node-side
entry too. Collapsed into a single project and deleted tsconfig.client.json.
Theme type-check coverage: 1 file -> 22.

2. Test files were being published
----------------------------------
The theme ships src/theme as the TypeScript swizzle template, so the
component test added earlier was landing in the tarball and would have been
copied into users' sites by `docusaurus swizzle --typescript`. Moved theme
component tests to src/__tests__, outside any published directory.

3. lib/.tsbuildinfo was being published
---------------------------------------
TypeScript's incremental build cache was the largest single file in both
tarballs. Moved it out of lib/ (declared once per base config as
${configDir}/.tsbuildinfo) and extended `clean` to remove it -- otherwise a
clean followed by a build would be skipped as up to date. Unpacked size:
204KB -> 111KB (theme), 473KB -> 366KB (plugin). This also removes a stray
lib/.tsbuildinfo that tsconfig.base.json was writing to the repo root.

A note on the fix for 2 and 3
-----------------------------
The obvious fix -- npm-style "!" negations in files[] -- is a trap here.
Yarn Classic does not implement that syntax, and on encountering one it stops
honouring files[] as a whitelist altogether, packing MORE than intended.
Measured on the theme with negations present: npm pack produced 90 entries
with 0 leaks, yarn pack produced 141 entries with 5 leaks. Since the release
path may invoke either, files[] is kept purely positive and correctness comes
from layout instead. Verified both packers now emit identical file sets with
zero leaks.

Also fixed: getTsconfigFiles in the monorepo test used fs.readJSON, which
throws on comments. tsconfig is JSONC and the repo uses comments, so it now
reads via ts.readConfigFile -- TypeScript is already a devDependency.

Two new regression tests, both confirmed to fail when the defect is
reintroduced: published tarballs contain no test files or build metadata
(asserted against `npm pack --dry-run`), and files[] contains no negation
patterns.
Type surface
  - plugin-llms-txt.d.ts declared `finalConfig: any` for the CopyButton and
    DropdownMenu props -- the two `any`s in the published type surface, on the
    props swizzlers are most likely to touch. Replaced with an explicit
    ResolvedCopyPageContentOptions interface mirroring the theme's
    useCopyButtonConfig. Restated rather than imported because these @theme/*
    declarations intentionally live in the plugin package (documented in the
    theme's own d.ts), which does not depend on the theme. A stale comment
    referred to it by a name that does not exist, ResolvedCopyPageButtonOptions.

Dependency ranges
  - root @docusaurus/{eslint-plugin,utils,utils-validation} were still ^3.0.0
    while the packages moved to ^3.10.2; aligned.
  - root @types/node ^20.8.0 -> ^22.15.19, matching both packages.
  - dropped root `concurrently` (unused; only the theme uses it, at ^9, so the
    root's ^8 was a second major of the same tool in one workspace).
  - dropped @typescript-eslint/{eslint-plugin,parser} from the plugin package.
    ESLint runs only at the root, the package has no eslint config, and its
    lint script is an echo -- they were dead weight pinned a patch behind root.

New regression test
  The theme reads the plugin's global data via a hardcoded
  'docusaurus-plugin-llms-txt', while the plugin owns that string as
  PLUGIN_NAME. The theme cannot import it without turning a types-only
  devDependency into a runtime one, so the duplication is deliberate -- but if
  the two drift, usePluginData returns undefined and the copy button silently
  renders nothing, with no error anywhere. Now asserted. Confirmed to fail when
  the name is changed on either side.
The header opened a second /** inside an already-open block comment, so the
license line and package description were part of a broken comment structure.
tslib
  The theme build sets importHelpers/noEmitHelpers, so 15 files in the
  published lib/ require tslib at runtime. It was declared nowhere: it had
  been supplied transitively by @docusaurus/core, and moving core to a peer
  in 2aa2cf3 removed that path. Under pnpm or a nested npm layout the theme
  failed on first render with "Cannot find module 'tslib'".

@docusaurus/{logger,utils,utils-validation}
  These were reclassified as peerDependencies, but they are libraries the
  plugin value-imports at runtime across ten sites, not build tooling. Sites
  install @docusaurus/core and a preset and never name these, so Yarn Classic
  left three unmet peers and installed nothing -- the plugin kept working
  only by hoisting, which is exactly what the reclassification set out to
  fix. Every first-party Docusaurus plugin declares them as dependencies and
  peers only @docusaurus/core; matched that.

GFM defaults
  Joi does not fill nested defaults when the parent key is absent, so
  {llmsTxt: {...}} left markdown.remarkGfm undefined while
  {markdown: {}, llmsTxt: {...}} set it to true. applyGfmConfiguration read
  undefined as "not configured, skip" and returned early, so DEFAULT_GFM was
  never merged; getMarkdownConfig then coerced it to true and the plugin
  registry passed remark-gfm no options at all. Two configs with identical
  intent emitted different Markdown, and table pipe alignment silently
  regressed for CJK and emoji content. undefined now resolves to the same
  defaults as an explicit `remarkGfm: true`, and DEFAULT_GFM is copied rather
  than handed out by reference.

  Verified against the built output: markdown absent, markdown: {} and
  remarkGfm: true all now yield singleTilde=false tablePipeAlign=true
  tableCellPadding=true stringLength=function; false still disables; an
  object still merges over the defaults.
Three of the theme's "renders nothing" tests passed for any input.
CopyPageContent returns null on its first pass regardless, because
useCopyContentData starts isLoading and only fetches inside an effect -- and
`await waitFor(() => expect(fetch).toHaveBeenCalled())` resolves on its first
synchronous check, before the fetch chain flushes. So the assertions were
equally true of a route that must render the button.

Each test now renders once at a displayable route first. That doubles as a
positive control and warms useCopyContentData's module-level cache, so the
second render takes the cache's synchronous branch and has already settled by
the time render() returns.

Confirmed by mutation. Deleting a guard from the component now fails the test
named for it:

  !shouldDisplay       -> "route is marked shouldDisplay: false"
  pluginConfig=false   -> "the plugin is disabled"
  !siteConfig          -> "global data omits siteConfig"

Against the previous tests, deleting the first two simultaneously left both
of their tests passing; only an unrelated test caught it.

The third is a new case. Removing !siteConfig failed nothing even after the
rewrite: with global data absent entirely there is no data URL either, so
!shouldDisplay returns null first and the guard is unreachable. siteConfig is
optional on PluginGlobalData, so the guard is load-bearing -- the new test
supplies route data that says displayable with siteConfig missing, the only
shape where that guard is what stops useCopyActions receiving undefined.

Also:

  - jest.config.cjs counted the co-located spec files as production source in
    collectCoverageFrom, so adding a test lowered reported coverage.
  - tsconfig.test.json inherited rootDir "${configDir}/src" from the base
    config, which resolves to a nonexistent <repo>/src. `tsc -p` exited 2 on
    option diagnostics and so reported no semantic errors at all. With rootDir
    corrected and @docusaurus/module-type-aliases added to types (for the
    *.module.css declarations) it exits 0. Nothing invokes it in CI yet.
  - test/mocks/style.cjs returned a string for every key including
    Symbol.toPrimitive, so any string coercion of the styles object threw
    "string Symbol(Symbol.toPrimitive) is not a function". Symbol keys now
    fall through as undefined.
Every CI job failed at install:

  error @testing-library/jest-dom@6.10.0: The engine "node" is incompatible
  with this module. Expected version ">=22". Got "20.20.2"

jest-dom 6.10.0 raised engines.node from ">=14" to ">=22". The declaration
here was an unpinned "^6", so the caret floated across that bump. Yarn Classic
treats an engines mismatch as an error, not a warning, so `yarn install
--frozen-lockfile` aborted before any job reached its first step.

Pinned to ~6.9.1, the last release supporting Node 20. Both packages declare
engines.node ">=20.0" and .github/actions/setup pins CI to Node 20, so the
alternative -- raising the floor to 22 for a devDependency -- would have meant
either contradicting the published support contract or no longer testing it.

Audited the rest of the tree: jest-dom was the only installed package
requiring Node 22 unconditionally. Everything else that looks close
(lerna ^20.19, the npmcli/sigstore chain ^20.17, jest ^20.0.0) is satisfied
by 20.20.2.

Added .nvmrc so local shells match the CI default. This went unnoticed
because it only reproduces below Node 22, and the version was pinned in the
composite action but nowhere a contributor's shell would read it.

Verified on Node 20.20.2 -- the exact version CI reported -- via nvm:
install --frozen-lockfile, build:packages, format:check, lint, type-check,
test:ci (27 tests) and build:website all exit 0.
Dependency Review failed the PR on two high-severity advisories:

  yarn.lock >> tar@6.2.1
    GHSA-8qq5-rm4j-mr97  Arbitrary File Overwrite and Symlink Poisoning via
                         Insufficient Path Sanitization
    GHSA-34x7-hfp2-rc4v  Arbitrary File Creation/Overwrite via Hardlink Path
                         Traversal

Both are patched only in the 7.x line (7.5.3 and 7.5.7); 6.2.1 is the last
6.x release, so there is no in-major upgrade. lerna@9.0.1 and
@lerna/create@9.0.1 pin tar to exactly 6.2.1 -- they are the only things in
the tree still on 6.x, everything else already resolves 7.5.22.

main carried `"tar": ">=7.5.2"` in resolutions, which is what had been holding
this down; 12cad9c removed the whole resolutions block. That removal was right
in general -- the unscoped entries collapsed unrelated semver ranges and in
some cases forced packages DOWN a major (js-yaml 4.x -> 3.14.2) -- but it also
dropped the one entry doing real work. Restored just that entry.

Why forcing lerna across a tar major is safe here: this repo publishes with
changesets, not `lerna publish`. lerna is only ever invoked as `lerna run
<script> --stream` (build, type-check, lint, format, clean, prepublishOnly),
which is the task runner and never touches tar. tar sits on lerna's pack and
publish path, which nothing in this repo calls.

Tried the narrow form first -- `"lerna/tar"` and `"@lerna/create/tar"` --
but Yarn Classic did not apply it; both nested copies stayed at 6.2.1.

Bumping lerna instead is not an option yet: lerna@10.0.0 does ship tar@7.5.20,
but its engines are `^22.13.0 || ^24.0.0 || ^26.0.0`, which would break the
Node 20 floor both packages declare and CI pins.

Verified on Node 20.20.2: tar resolves to 7.5.22 everywhere with no 6.x copy
left, `lerna --version` still reports 9.0.1, and install --frozen-lockfile,
build:packages, format:check, lint, type-check, test:ci (27 tests) and
build:website all exit 0.
Two unrelated CI failures.

1. Test (React 18) -- Cannot find module '@testing-library/react'
-----------------------------------------------------------------
Only the 18 leg failed; 19 passed. Reproduced in a clean worktree on Node
20.20.2: `yarn workspace website add react@^18 react-dom@^18` leaves the root
tree incomplete. It removes hoisted root devDependencies from node_modules
while leaving them in both package.json and yarn.lock --
@testing-library/react, @testing-library/user-event, @types/react-dom and
rimraf all vanish, so test/setup-theme.ts cannot resolve its imports and both
theme suites fail to run. Nothing about React 18 causes this; the 19 leg
survived only because its `yarn add` was a no-op against the already-resolved
tree.

A plain `yarn install` after the pin steps restores everything and leaves
React at the pinned 18.3.1. Verified end to end in the worktree: react 18.3.1
with build:packages, type-check, test:ci (27 tests) and build:website all
exiting 0.

2. Dependency Review -- js-yaml@4.1.0
-------------------------------------
  GHSA-5p4m-2wfm-xmqj  Quadratic CPU consumption in !!omap resolution
  GHSA-52cp-r559-cp3m  YAML merge-key chains force quadratic CPU consumption

Same shape as the tar failure: lerna pins the version exactly. Bumped lerna
9.0.1 -> 9.0.7, the newest 9.x, which keeps `node: ^20.19.0 || ...` and moves
its own pins to tar@7.5.11 and js-yaml@4.1.1. That also makes the existing
tar resolution honest -- it is now an in-major bump (7.5.11 -> 7.5.22) rather
than forcing lerna across a major, so the caveat in the previous commit no
longer applies.

js-yaml is still short of the 4.3.1 fix, and cannot be resolved away:
Yarn Classic 1.22 ignores the scoped `lerna/js-yaml` form -- verified, the
nested copy stays put -- and an unscoped entry rewrites every range for the
name, dragging read-yaml-file@1.1.0 from 3.15.1 to 4.x. That package calls
`yaml.safeLoad`, removed in v4, and @changesets/cli depends on it. Since
changesets is what publishes these packages, forcing it would trade a
dev-time DoS for a broken release pipeline. lerna@10 ships js-yaml 4.3.1 but
requires Node >=22, which the Node 20 floor rules out.

Allow-listed both, with the reasoning and the removal condition in the
workflow. Neither reaches a consumer: they are reachable only when lerna
parses this repo's own config, and `yarn audit --groups dependencies` reports
0 advisories across the 364 runtime packages.

Full gate on Node 20.20.2 with lerna 9.0.7: install --frozen-lockfile,
build:packages, format:check, lint, type-check, test:ci (27 tests) and
build:website all exit 0.
Third and last advisory in the delta. Scanned all 707 packages this branch
adds to the lockfile against the GitHub advisory database: exactly three
HIGH/CRITICAL hits, the two js-yaml ones already accepted and this.

brace-expansion@5.0.8 is pinned exactly by nx, which lerna depends on at
`>=21.5.3 <23.0.0`. GHSA-rgw5-rvv9-x895 is patched in 5.0.9 and no release in
that range ships it -- only nx 23.2.0 canaries do, and 23.x is outside
lerna's range anyway.

Not a regression: main resolves brace-expansion to 4.0.1, which the same
advisory covers (>= 4.0.0, < 5.0.9). The delta gate flags this only because
the version string changed, and 5.0.8 is the better of the two -- it already
carries the GHSA-mh99-v99m-4gvg fix that 4.0.1 lacks. Everything else in the
tree is on the patched 5.0.9; only nx's exact pin holds 5.0.8.

An unscoped `brace-expansion` resolution is the same trap as js-yaml: it
would drag the 1.1.18 and 2.1.4 consumers across two majors. main had exactly
that entry, and it is why main sits on 4.0.1 for everything.
51 net comment lines removed. Clears the two max-len warnings in
CopyPageContent.test.tsx and keeps every comment inside the 80-char limit
.eslintrc.cjs sets for comments. Rationale that belongs in history rather
than beside the code now lives in the commit messages that introduced it.
The version/publish/changelog/ignoreChanges blocks were copied from
facebook/docusaurus (identical labels and cacheDir) and have never run:
no commit in history matches either of lerna's configured release messages,
both CHANGELOGs are changesets format, and lerna-changelog isn't installed.
command.build was inert too -- lerna has no build command.

Versioning is changesets, as the README already stated. Two configured
release systems next to each other is a trap: `lerna version` would apply
conventional-commit bumps while the .changeset/*.md files sat unused.

lerna now declares only what it does here -- run and exec. bootstrap and
link were removed in lerna 7, so yarn workspaces does all linking.
Yarn 1.22 is EOL and was the root cause of several problems this branch
worked around rather than fixed:

  - Scoped `resolutions` are silently ignored, so three dev-only advisories
    (js-yaml x2, brace-expansion) had to be allow-listed in Dependency Review.
    The unscoped form is unusable: it rewrites every range for a name, and
    forcing js-yaml to 4.x breaks read-yaml-file's `safeLoad`, which
    @changesets/cli -- our release tooling -- depends on.
  - `yarn workspace <name> add` drops hoisted root devDependencies from
    node_modules, which is why the React 18 CI leg needed a trailing
    `yarn install`.
  - Undeclared dependencies resolved by hoisting, hiding real packaging bugs.

pnpm fixes all three. Overrides are path-scoped, so js-yaml resolves to
4.3.1 where lerna needs it while read-yaml-file keeps 3.15.1; the allow-list
is deleted and the blunt `tar` resolution with it. Verified in the lockfile:
zero occurrences of js-yaml@4.1, brace-expansion@5.0.8 or tar@6.

Also fixes the React matrix, which was only half honest. The old step ran
`yarn add -W --dev @types/react@^18`, which rewrites the root while both
packages pin ^19, so Yarn nested v19 and the React 18 leg type-checked
against React 19 types. pnpm overrides apply tree-wide: the leg now resolves
@types/react 18.3.31, and scripts/checkReactVersions.js asserts it from the
lockfile.

Making that honest immediately caught a real bug the leg was supposed to
catch: useDropdownState annotated its ref as RefObject<HTMLDivElement | null>,
the React 19 shape, which React 18 rejects for a `ref` prop. Both packages
advertise `react: ^18 || ^19`, so this was a broken support claim. The return
type is now inferred, which is correct under either major. ts-jest is
transpile-only, so the existing runtime test could never have caught it --
its comment claimed otherwise and has been corrected.

Strictness surfaced three undeclared dependencies, all real:
  - chokidar, imported by scripts/copyUntypedFiles.js
  - @docusaurus/module-type-aliases, in tsconfig.test.json's `types`
  - tslib, needed by root test files under `importHelpers`

Notes:
  - pnpm is pinned to 10.34.5: pnpm 11 requires Node >=22.13 and both
    packages declare Node >=20.
  - .npmrc sets enable-pre-post-scripts, which pnpm disabled in v7, so
    prebuild:website stays wired to build:packages.
  - lerna stays as the task runner (npmClient: pnpm). It is no longer
    load-bearing for security now that overrides can be scoped.
  - Consumer-facing `yarn add` lines in the package READMEs are untouched.

Verified on Node 20.20.2 with pnpm 10.34.5, on both React majors:
install --frozen-lockfile, build:packages, format:check, lint, type-check,
test:ci (27 tests), tsc -p tsconfig.test.json and build:website all exit 0.
@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown

Dependency Review Summary

The full dependency review summary was too large to display here (3247KB, limit is 1024KB).

Please download the artifact named "dependency-review-summary" to view the complete report.

View full job summary

Swapping yarn.lock for pnpm-lock.yaml makes the whole dependency graph look
new to Dependency Review, so pre-existing advisories that were previously in
the base lockfile now read as introduced. Two surfaced: image-size 2.0.2,
reached through @docusaurus/core.

Both have patched=NONE via the GitHub advisory API -- no release fixes them
at any version -- and both are parser DoS in a build-time image reader that
never runs in a deployed site. That is exactly what allow-ghsas is for; the
dev-only advisories lerna and nx pin are handled by pnpm.overrides instead.

Also drops the pull_request branch filter from ci.yml. It only fired for PRs
targeting main or develop, so this PR -- stacked on the branch it builds on --
got no CI at all, which is the one thing that cannot be verified locally.
Push events still only build main and develop.
Dependency Review flagged serialize-javascript@6.0.2 (GHSA-5c6j-r48x-rmvq,
high, RCE via RegExp.flags). It was previously written off as unfixable at
Docusaurus 3.x, but that was a Yarn limitation, not a real one: the advisory
is patched in 7.0.3 and only two build-time webpack plugins pull it, so a
scoped override moves them without touching anything else.

  copy-webpack-plugin>serialize-javascript: ^7.0.5
  css-minimizer-webpack-plugin>serialize-javascript: ^7.0.5

Resolves to 7.1.0. Verified the cross-major bump is safe by building the
website, which is what exercises both plugins: exit 0, 55 documents.

Adds a changeset for the React 18 ref fix, which is consumer-facing -- a site
on React 18 that type-checked a swizzled CopyPageContent hit TS2322.
facebook/docusaurus keeps all pnpm configuration in pnpm-workspace.yaml and
has no `pnpm` section in package.json at all. That is also where pnpm's own
docs now point. Moved overrides and onlyBuiltDependencies across and annotated
each override with the GHSA it addresses, as they do.

Verified byte-identical lockfile before and after, plus the full gate on Node
20.20.2: build:packages, format:check, lint, type-check, test:ci (27 tests)
and build:website all exit 0.

Two of their settings are deliberately NOT adopted:

  nodeLinker: 'hoisted' -- they opt out of pnpm's strict layout, likely for
  create-docusaurus templates and test-website scaffolding. We want the strict
  default: it is what surfaced chokidar, @docusaurus/module-type-aliases and
  tslib as undeclared. Keep this in mind as the escape hatch if a Docusaurus
  plugin ever fails to resolve.

  linkWorkspacePackages: true instead of workspace:* -- their comment says the
  protocol would require adopting pnpm for publishing. That does not apply
  here: only website uses workspace:*, and it is private, so the protocol never
  reaches a published manifest.
Moving the pnpm settings out of package.json broke both matrix legs:
scripts/pinReact.js still wrote pkg.pnpm.overrides, which no longer exists,
so it died with "Cannot read properties of undefined (reading 'overrides')"
before either leg installed.

It now edits pnpm-workspace.yaml with yaml's parseDocument, which keeps the
comments explaining why each existing override is there -- a plain
parse/stringify round-trip would drop them.

Verified the full React 18 leg end to end: react 18.3.1, @types/react
18.3.31, then build:packages, type-check, test:ci and build:website all
exit 0; restoring the default resolves 19.2.8 / 19.2.18.
@Devon-White Devon-White changed the title Migrate from Yarn 1 to pnpm Support React 19, harden dependencies, and migrate to pnpm Aug 23, 2026
@Devon-White
Devon-White changed the base branch from Devon/deps-security-react19 to main August 23, 2026 00:36
@Devon-White
Devon-White merged commit d9a14fd into main Aug 24, 2026
4 checks passed
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.

2 participants