Skip to content

A React 18 instrument that imports only react-dom@18.x has its JSX compiled against React 19 and fails to render #1801

Open

Description

@joshunrau

A React 18 interactive instrument gets React 19's JSX runtime whenever it does not import react@18.x itself. resolveJsxImportSource decides which React the JSX compiles against by scanning the source for /runtime/v1/react@<version> only. An instrument that renders with /runtime/v1/react-dom@18.x/client and uses JSX does not need to import react@18.x: the automatic JSX runtime makes the import unnecessary for any component without hooks. So it falls through to the react@19.x default. React 19's JSX runtime stamps elements with Symbol.for('react.transitional.element'), which React 18's reconciler rejects. The task then fails at render with "Objects are not valid as a React child", which is exactly the failure this derivation exists to prevent (packages/instrument-bundler/AGENTS.md). For the same reason, a source that imports react-dom@18.x and react@19.x is not flagged as mixing majors.

Where

packages/instrument-bundler/src/build.ts:14-38:

const DEFAULT_REACT_PACKAGE = 'react@19.x';

const REACT_PACKAGE_PATTERN = /['"]\/runtime\/v1\/(react@[^'"/]+)/g;
// ...
  return `/runtime/v1/${packages.values().next().value ?? DEFAULT_REACT_PACKAGE}`;

Reproduce

  1. Bundle an interactive instrument whose index.tsx imports only createRoot from /runtime/v1/react-dom@18.x/client.js, declares const App = () => <p>hi</p>;, and calls createRoot(document.body).render(<App />) in render.
  2. Open it in apps/web.

Actual: the bundle imports /runtime/v1/react@19.x/jsx-runtime.js next to /runtime/v1/react-dom@18.x/client.js (verified on main), and rendering fails with React 18's "Objects are not valid as a React child" error.
Expected: the JSX compiles against /runtime/v1/react@18.x, and a source that imports react-dom of one major with react of another is a build error.

Tests

packages/instrument-bundler/src/__tests__/build.test.ts: it('should compile JSX against the react paired with an imported react-dom, so a react-dom 18 instrument without hooks still renders'). Add a fixture under src/__tests__/repositories/ (as the package's AGENTS.md asks) that imports only react-dom@18.x/client. Also add it('should reject react-dom and react of different majors in one instrument').

e2e: none needed beyond the fixture, since no built-in instrument uses React 18.

Suggested fix

Match both packages, /['"]\/runtime\/v1\/react(?:-dom)?@([^'"/]+)/g, collect the version, and build react@<version> from it. The existing "more than one version" error then also covers a mismatched react/react-dom pair.

Activity

  1. added
    BugType: existing behavior is wrong
    Priority: LowNice to have, cosmetic or speculative
    Difficulty: LowIsolated change, about 2 hours or less
    Area: InstrumentsInstrument authoring API, bundling, runtime, rendering, measures and the built-in library
    on Oct 7, 2026
  2. joshunrau commented on Oct 26, 2026

    @joshunrau
    CollaboratorAuthor

    Still reproduces on main (d86740e). REACT_PACKAGE_PATTERN is unchanged since f1865a3 added it, and no open PR touches build.ts.

    One grammar, shared with #1803

    The interpreter's REACT_MODULE_PATTERN (packages/instrument-interpreter/src/imports.ts:6) already matches react(?:-dom)?@. The bundler's copy never got the react-dom arm, and that drift is this bug. #1803 plans to move the interpreter's pattern into instrument-utils, which the bundler does not depend on. So I'd put one parser for /runtime/v1/react(-dom)@<label>(/<subpath>) a level lower, in @opendatacapture/runtime-internal. It is the only first-party package that both the bundler and the interpreter already depend on. The API, react-core and web already depend on it through instrument-bundler or subject-utils, so instrument-utils can take it as a workspace:* dependency without adding anything to them.

    The parser returns { name, version, subpath } per match. The React rule keeps its JSX-runtime exemption on top of it, and resolveJsxImportSource reads only the versions. Whichever of #1801 and #1803 lands first adds it. The export goes in both src/index.js and the hand-written src/index.d.ts. Then update the "Four small helpers" line (packages/runtime-internal/AGENTS.md:3-4) and the test list (:32-33).

    Corrections to the suggested fix

    • Compare on the version, but put name@version in the error. If only the version is captured, the mixed case reports found 18.x, 19.x and the author can't tell which import is the odd one. found react-dom@18.x, react@19.x tells them.
    • Turning react-dom@<label> into react@<label> is only correct because each react-dom wrapper depends on the react wrapper with the same label (vendor/react-dom@18.x/package.json:23, vendor/react-dom@19.x/package.json:35). runtime/v1/test/vendor-pairing.test.ts checks that the peer resolves to the paired wrapper's copy. It does not check that the labels match. Say so in a comment where the rewrite happens.
    • packages/instrument-bundler/AGENTS.md:34-41 describes the scan as looking for /runtime/v1/react@<major>. Update it in the same commit.

    Tests

    • Unit: the two build.test.ts cases as proposed. The new fixture sits next to interactive-react18, which imports both packages, and is registered in src/__tests__/repositories/index.ts. The existing interactive fixture already imports only react-dom@19.x/client. It avoided this bug only because the default happens to match. If the parser moves, its cases go in packages/runtime-internal/src/__tests__/index.test.ts, and packages/instrument-interpreter/src/__tests__/imports.test.ts guards findReactImport against regressions.
    • e2e: "none needed" conflicts with the repo rule, and a cheap e2e exists. testing/src/specs/playground.spec.ts:188-213 opens a share link, bundles it with the real bundler (apps/playground/src/components/Viewer/Viewer.tsx:6) and asserts inside iframe[name="interactive-instrument"]. Add a share link with a single index.tsx that imports only createRoot from /runtime/v1/react-dom@18.x/client.js and renders a <p>. Click Begin and expect the text in the task frame. Name the file .tsx: the other share-link sources are index.ts, and the ts loader does not parse JSX. This test fails on main today.

    #1660, #1817 and #1825 also add tests to playground.spec.ts, and #1825 adds one test to build.test.ts. Expect trivial rebases only.

    Triage note written with Claude Code. Code references point at main @ d86740e.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Area: InstrumentsInstrument authoring API, bundling, runtime, rendering, measures and the built-in libraryBugType: existing behavior is wrongDifficulty: LowIsolated change, about 2 hours or lessPriority: LowNice to have, cosmetic or speculative

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions