fix(tui): bundle React-rendering deps so a consumer install can't split React

`ink-form` and `ink-scroll-view` were externalized by the TUI bundler and
both declare a loose `react` peer (">=18"). Installed under a project that
has its own React 18, npm hoists them to that project's root — where the
peer is satisfied — while the Inspector's React 19 nests underneath. The
bundle then renders through React 19 while those two call hooks on React
18, whose dispatcher is null, so opening a tool test form (or any scroll
view) dies with "Cannot read properties of null (reading 'useState')".

Inline them instead: their `import "react"` is emitted into the bundle and
so resolves from the build directory exactly like the bundle's own, with
npm's placement no longer part of the decision. This also pins their
transitive deps to what this repo resolved — notably `ink-select-input@6`
via `overrides`, which npm ignores for a package installed as a dependency,
so consumers were getting the React-18-era v5.

`ink` stays external: it can't be bundled (its CJS `signal-exit@3` fails
ESM interop with `Dynamic require of "assert"`) and doesn't need to be —
its ">=19" peer keeps npm from hoisting it next to an unusable React.

Both packages are dropped from the root `dependencies`, since the tarball
now ships their code rather than having consumers install them.

`clients/tui/__tests__/tsupConfig.test.ts` is the durable guard: the
failure is invisible in this repo and in every smoke (a dev install has
one React) and only appears once the package is installed under another
project, so the invariant is asserted against the config instead — every
dependency declaring a `react` peer must be bundled unless exempted.

Closes #1952

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLYmVPVLdeYpE6aMTUsCks
This commit is contained in:
cliffhall 2026-08-11 23:00:01 -04:00
commit eb5bba62ed

View file

@ -106,3 +106,35 @@ Tests live in `__tests__/`. The coverage gate currently covers the TUI's
non-React logic (server resolution, logger, tab metadata, and the `utils/`
form/URL helpers); the Ink components and `App.tsx` are an interim exclusion in
`vitest.config.ts` pending a renderer-based follow-up.
### Bundling: React-rendering dependencies must be inlined (#1952)
`tsup.config.ts` splits the TUI's dependencies into bundled (`noExternal`) and
externalized (`external`). For anything that **renders React components**, that
choice decides which React instance it gets at runtime, and getting it wrong
crashes the TUI in a way nothing in this repo can see.
An external package's `import "react"` resolves from wherever npm placed **that
package** — and npm places it next to a React satisfying its own peer range.
`ink-form` and `ink-scroll-view` both accept `react: ">=18"`, so a project that
installs the Inspector alongside its own React 18 satisfies them: they hoist to
that project's root while the Inspector's React 19 nests underneath. The bundle
then renders through React 19 while those two call hooks on React 18, whose
dispatcher is null — so opening a tool test form (or any scroll view) dies with
`TypeError: Cannot read properties of null (reading 'useState')`.
Bundling them removes npm from the decision: their `import "react"` is emitted
into `build/index.js`, so it resolves from the build directory exactly like the
bundle's own. It also pins their transitive deps to what *this* install
resolved — notably `ink-select-input@6` via the `overrides` entry, since npm
ignores a dependency's `overrides` and a consumer install would otherwise pull
the React-18-era v5 that `ink-form` asks for.
`ink` itself stays external: it can't be bundled (its CJS `signal-exit@3`
dependency fails ESM interop with `Dynamic require of "assert" is not
supported`), and it doesn't need to be — its `react` peer is `">=19"`, which
keeps npm from hoisting it next to a React the Inspector couldn't also use.
`__tests__/tsupConfig.test.ts` enforces this: every dependency declaring a
`react` peer must be in `noExternal` unless it is listed as external by design.
Add a React-rendering dependency, and that test tells you to bundle it.