49bea9ecbd
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>
109 lines
4.1 KiB
TypeScript
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)."
|
|
)
|
|
})
|