Skip to content

API stores form instruments that import React, which the web app and gateway then refuse to render #1803

Open

Description

@joshunrau

The API stores form instruments that every client then refuses to render. InstrumentInterpreter.interpret rejects a non-interactive instrument whose bundle imports /runtime/v1/react@* or /runtime/v1/react-dom@* (findReactImport, added in f46a257). That check runs only in the browser. InstrumentsService.create evaluates the same bundle server-side and validates it against $AnyInstrument, but never applies the React rule. So a form whose block imports useState, whether from an instrument repository import or a bundle upload, is accepted, stored and listed. Every attempt to open it in apps/web or the gateway then fails with "Cannot import '/runtime/v1/react@19.x/index.js' in an instrument of kind 'FORM'". The commit message says the browser is "the earliest point the two kinds can be told apart", but the API evaluates the bundle and knows its kind first.

Where

apps/api/src/instruments/instruments.service.ts:94-109:

    const result = await this.virtualizationService.eval(bundle);
    // ...
    const parseResult = await $AnyInstrument.safeParseAsync(result.value);
    if (!parseResult.success) {
      throw new UnprocessableEntityException({ /* ... */ });
    }
    const instance = parseResult.data;

compared with the client-side rule, packages/instrument-interpreter/src/index.ts:30-37:

    if (instrument.kind !== 'INTERACTIVE') {
      const reactImport = findReactImport(bundle);
      if (reactImport) {
        throw new Error(
          `Cannot import '${reactImport}' in an instrument of kind '${instrument.kind}': React is available only to interactive instruments, ...`

Reproduce

  1. In an instrument repository, add a form under lib/forms/ whose block imports { useState } from /runtime/v1/react@19.x.
  2. Add the repository in apps/web (Admin > Instrument Repositories) and assign the form to a group.
  3. Start a session and open the form.

Actual: the import succeeds and the form is listed, but opening it shows the instrument error page with "React is available only to interactive instruments".
Expected: the API rejects the bundle with 422 and the same message, so the repository import reports that instrument as failed and it is never offered to users.

Tests

apps/api/src/instruments/__tests__/instruments.service.test.ts: it('should reject a non-interactive bundle that imports react, so no stored form fails in every client').

e2e, in testing/src/specs/admin-instrument-repos.spec.ts: a repository fixture form that imports react is reported as failed, not imported.

Suggested fix

Move findReactImport (or the whole kind check) into a shared package the API can import, such as @opendatacapture/instrument-utils or runtime-internal. Call it in InstrumentsService.create after the $AnyInstrument parse and throw UnprocessableEntityException with the interpreter's message. Keep the client-side check for bundles that never pass through the API, such as the playground.

Activity

  1. added
    BugType: existing behavior is wrong
    Priority: LowNice to have, cosmetic or speculative
    Difficulty: LowIsolated change, about 2 hours or less
    Area: APIapps/api (NestJS backend, Prisma models, CASL permissions)
    on Oct 7, 2026
  2. joshunrau commented on Oct 26, 2026

    @joshunrau
    CollaboratorAuthor

    Still reproduces on main (d86740e). Nothing under apps/api/src/instruments, apps/api/src/instrument-repos, packages/instrument-interpreter or packages/instrument-utils has changed since the issue was filed.

    InstrumentsService.create is the only code in apps/api/src that writes a bundle (apps/api/src/instruments/instruments.service.ts:139). Every other instrumentModel.update writes provenance or archivedAt. So one check there covers every way in. Two paths can carry a React import:

    • POST /v1/instruments (apps/api/src/instruments/instruments.controller.ts:31-35). The playground's Upload Bundle dialog checks only for type and transpilation errors before posting the bundle (apps/playground/src/components/Header/ActionsDropdown/UploadBundleDialog.tsx:57-107).
    • The repository import (apps/api/src/instrument-repos/instrument-repos.service.ts:282).

    Demo seeding (apps/api/src/demo/demo.service.ts:61-81) and createSeries (instruments.service.ts:199) also go through create, and the check changes neither. No built-in non-interactive instrument imports React. DNP_GENERAL_CONSENT_FORM uses JSX, but that compiles to the exempt jsx-runtime. The series source createSeries generates has no imports.

    Corrections

    • The e2e can't go in admin-instrument-repos.spec.ts. That suite can't run a real import, because the API must not reach GitHub under NODE_ENV=test (testing/src/specs/admin-instrument-repos.spec.ts:3-6). The tests fix: resolve 26 low-difficulty issues, one commit each #1825 adds there stub POST /v1/instrument-repos with page.route, so the API never handles the request.
    • The import doesn't report a failed instrument. importInstruments logs Failed to Import Instrument and moves on (instrument-repos.service.ts:333-339), and the response is only the repo record. After the fix the form is just missing from the repository, and the only trace is a server log line. Reporting per-instrument failures would be a separate change.
    • The error page isn't the only symptom. The CSV upload page lists the form (apps/web/src/routes/_app/upload/index.tsx:49-52) and then renders nothing when it is opened, because useInstrument swallows the interpreter error. That is Assignment lists, dashboard dialogs and the record view show empty, error or blank states while their data is still loading #1757.

    Plan

    1. Move packages/instrument-interpreter/src/imports.ts into packages/instrument-utils. Export one function there that takes the kind and the bundle and returns the rejection message, or null. The rule, the matcher and the message then live in one place. The API already depends on instrument-utils, and the interpreter gets a workspace:* dependency on it. The API should not import the interpreter, because its entry pulls in evaluateInstrument (new Function in the caller's context).

    2. interpret() throws Error with that message. create calls the function right after the parse (:109), before generateInstrumentId and the exists check (:111-112), and throws UnprocessableEntityException(message). A broken bundle that is already stored then gets a 422 with the reason when it is re-uploaded, not a 409. The playground dialog already shows the response's message (UploadBundleDialog.tsx:119-123).

    3. Update the docs:

      • the "Storing" steps in .agents/docs/architecture/instrument-pipeline.md:104-122
      • the React trap and the tests note in packages/instrument-interpreter/AGENTS.md (:26-33, :41)
      • the module and importer list in packages/instrument-utils/AGENTS.md:3-6
      • the instrument-utils consumers in .agents/docs/workspace-map.md:48

      packages/instrument-guidelines/AGENTS.md:460 ("rejected when the instrument is loaded") stays true, so the published package needs no release.

    Forms stored before the fix stay listed and broken. The fix only guards new writes.

    Tests

    • Move imports.test.ts to packages/instrument-utils/src/__tests__/, and add cases for the new function, including the INTERACTIVE exemption. The interpreter's index.test.ts keeps its interpret() cases.
    • apps/api/src/instruments/__tests__/instruments.service.test.ts, in describe('create'): mock eval to return formInstance('FORM_A', 1), and pass a bundle string containing __import("/runtime/v1/react@19.x/index.js"). Assert a 422 carrying the interpreter's message, and that instrumentModel.exists and instrumentModel.create are never called. A schema failure is also a 422 that skips exists, so asserting the status alone proves nothing. Then run the same bundle with interactiveInstrument, which is still stored.
    • e2e in testing/src/specs/admin-instruments.spec.ts: with apiRequestContext and adminToken, POST /api/v1/instruments a hand-written FORM bundle. Expect a 422 whose body contains "React is available only to interactive instruments". The bundle defines __import the way the bundler preamble does (packages/instrument-bundler/src/bundle.ts:12-18). It imports zod for validationSchema, plus React's index. Instrument upload validation ignores the instrument's language, accepting untranslated bilingual instruments and rejecting language: ['en'] #1712 and FILE instrument schema accepts file groups that can never be completed: min above max, or two groups sharing a basename #1706 plan the same post-a-bundle, expect-422 test, so one ApiClient helper that posts a raw bundle can serve all three.

    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: APIapps/api (NestJS backend, Prisma models, CASL permissions)BugType: 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