Files
hermes-agent/tests-js/react-dom-pair-compat.test.ts
T
joaomarcos 49bea9ecbd fix(desktop): resolve react/react-dom from one origin (#84018)
The desktop window opened blank white on a fresh install: React threw
"Minified React error #527" before the first paint, from the
`vendor-react-<hash>.js` chunk.

`apps/desktop` pins react and react-dom to the same exact version, but
`vite.config.ts` aliased both to a hardcoded `../../node_modules/<pkg>` —
straight into the monorepo root, where npm is free to hoist a different
react. `@streamdown/math` is a root dependency whose react peer is
`^18.0.0 || ^19.0.0` and which declares no react-dom peer, so npm hoists
the newest react (19.2.8) to the root while react-dom stays at the
version hoisted from the workspaces (19.2.7). react-dom's own peer is
`react: ^19.2.7`, which 19.2.8 satisfies, so the install reports success
and nothing warns. The bundle then shipped react 19.2.8 with react-dom
19.2.7 and React refused to run.

`npm ci` masks this because the lockfile pins the root react to 19.2.7,
which is why CI is green. The recurrence engine is
`_run_npm_install_deterministic()`: when `npm ci` fails it falls back to
`npm install --no-save`, which re-resolves the whole tree and never
records the result — so the split comes back on the next update and
leaves no trace.

Fix the resolution rather than the hoist. The aliases now resolve both
packages from the desktop workspace itself, where npm guarantees the
declared versions are reachable (it nests a copy under the workspace
exactly when the hoisted one differs), so the pair can only ever match.
Pinning react at the npm layer instead was rejected: every manifest-level
pin tried (root dependency, root `overrides`, a scoped override on
`@streamdown/math`) breaks a fresh install with
`ERESOLVE ... peer ink-text-input@"6.0.0" from @hermes/ink@0.0.1`.

Two guards keep it from silently returning:

- `assert-root-install.mjs` (the existing preflight of `build`,
  `dev:renderer` and `preview`) now fails the build when the resolved
  react and react-dom versions differ, so a split surfaces as an
  actionable error instead of a white window.
- A `tests-js` contract test asserts every workspace pins the two to the
  same exact version, and that the desktop bundler no longer points at a
  hardcoded `node_modules` path.

Verified on a synthetic split tree (root react 19.2.8 / react-dom 19.2.7,
workspace react 19.2.7): the old aliases resolve 19.2.8 + 19.2.7, the new
ones resolve 19.2.7 + 19.2.7, and the preflight exits 1 when the
workspace itself resolves the mismatch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 21:37:54 -07:00

109 lines
4.1 KiB
TypeScript

/**
* Invariant: every workspace that renders React ships one react/react-dom pair.
*
* React validates at import time that ``react`` and ``react-dom`` come from the
* same installed copy. When they don't, it throws "Minified React error #527"
* *before* the first paint — the Electron window just stays blank white, with
* the only clue buried in the devtools console of a packaged build.
*
* npm never warns about this. ``apps/desktop`` pins both packages to one exact
* version, but a root dependency whose react peer is a loose range (e.g.
* ``^18.0.0 || ^19.0.0``, and with no react-dom peer to keep the two in step)
* makes npm hoist the newest react to the monorepo root while react-dom stays
* at the pinned version. react-dom's own peer range is a caret, so the newer
* react still "satisfies" it and the install reports success.
*
* ``apps/desktop/vite.config.ts`` used to alias both packages to a hardcoded
* ``../../node_modules/<pkg>`` — i.e. straight into the split — so the bundle
* shipped the mismatched pair. It now resolves them from the workspace itself,
* where npm guarantees the declared versions are reachable.
*
* This is a *contract* test: it asserts no specific version, only that the two
* halves of the pair can never drift apart again — neither through a loosened
* manifest spec, nor by re-pinning the bundler at the hoisted copy.
*/
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import { test } from 'vitest'
const REPO_ROOT = path.resolve(__dirname, '..')
const DESKTOP_VITE_CONFIG = path.join(REPO_ROOT, 'apps', 'desktop', 'vite.config.ts')
interface Manifest {
dependencies?: Record<string, string>
devDependencies?: Record<string, string>
workspaces?: string[]
}
function readManifest(file: string): Manifest {
return JSON.parse(fs.readFileSync(file, 'utf-8')) as Manifest
}
/** Every workspace manifest, resolved from the root ``workspaces`` globs. */
function workspaceManifests(): { name: string, manifest: Manifest }[] {
const patterns = readManifest(path.join(REPO_ROOT, 'package.json')).workspaces ?? []
const found: { name: string, manifest: Manifest }[] = []
for (const pattern of patterns) {
// The globs in use are plain paths or a single trailing ``/*``.
const parent = pattern.endsWith('/*') ? path.join(REPO_ROOT, pattern.slice(0, -2)) : null
const dirs = parent === null
? [pattern]
: fs.existsSync(parent)
? fs.readdirSync(parent).map((entry) => `${pattern.slice(0, -2)}/${entry}`)
: []
for (const dir of dirs) {
const file = path.join(REPO_ROOT, dir, 'package.json')
if (fs.existsSync(file)) {found.push({ name: dir, manifest: readManifest(file) })}
}
}
return found
}
test('workspaces declaring react and react-dom pin them to the same exact version', () => {
const offenders: string[] = []
for (const { name, manifest } of workspaceManifests()) {
const deps = { ...manifest.devDependencies, ...manifest.dependencies }
const react = deps['react']
const reactDom = deps['react-dom']
if (!react || !reactDom) {continue}
if (react !== reactDom) {
offenders.push(`${name} declares react"${react}" but react-dom"${reactDom}"`)
continue
}
if (!/^\d/.test(react)) {
offenders.push(`${name} declares a floating range react/react-dom"${react}"`)
}
}
assert.deepEqual(
offenders,
[],
'react and react-dom must be pinned to the same exact version per workspace, ' +
'otherwise npm can hoist a newer react next to the older react-dom and React ' +
`throws error #527 (blank window): ${offenders.join('; ')}`
)
})
test('the desktop bundler does not alias react at the hoisted root copy', () => {
const config = fs.readFileSync(DESKTOP_VITE_CONFIG, 'utf-8')
assert.ok(
!config.includes('node_modules/react'),
'apps/desktop/vite.config.ts hardcodes a node_modules path for react/react-dom. ' +
'That pins the bundle to the hoisted copies, which npm is free to resolve to a ' +
'different version than the pinned react-dom. Resolve both from the workspace ' +
"instead (see this test's module docstring)."
)
})