#99348·Next.js

WASM: codeFrameColumns rejects `color: boolean` and `maxWidth: undefined`, which the napi binding and Next's own callers accept

Author: vtamaraCreated Sep 28, 2026Updated Sep 28, 2026
LabelsSWCTypeScriptError Overlay

Link to the code that reproduces this issue

https://github.com/vtamara/wasm-code-frame-columns

To Reproduce

create-next-app -e reproduction-template with next@canary. The only changes are the ones that force the WASM SWC bindings (NEXT_TEST_WASM=1 and NEXT_TEST_WASM_DIR=node_modules/@next/swc-wasm-nodejs, the switches Next's own test runner uses) so the bug reproduces on any OS; the WASM bindings are the default where there are no native ones (openbsd/x64, or a failed native download).

  1. npm install
  2. npm run repro — calls codeFrameColumns with the four option shapes Next itself passes and prints the result of each one. Three of them throw; the invalid ones are the shapes used by getOriginalCodeFrame() (the dev overlay), lib/typescript/diagnosticFormatter and build/load-jsconfig.
  3. npm run dev (Next 16 with the WASM bindings and --webpack, since Turbopack needs the native bindings) and open http://localhost:3000/trigger-error, a page that throws a real ENOENT while rendering: the server console loses the real error, and the dev overlay paints the message and the call stack but without its code frame (screenshot in the next field).
  4. Open http://localhost:3000/api/trigger-error, the same ENOENT on a route handler: nothing relays the error to the browser, so the real cause never reaches the console at all.
  5. With native bindings the same commands render the code frames (the reproduction repo forces WASM, so remove the two environment variables to compare).

Current vs. Expected behavior

Expected: an error on the server side shows its code frame (the source excerpt around the throw) in the dev overlay and in the server console, and codeFrameColumns accepts the options Next passes it, as the native binding does.

Current: on the WASM bindings every code frame fails with a serialization error, so the real error loses its location and, in the server console, its message:

⨯ unhandledRejection: Error: Error: invalid type: boolean `false`, expected enum CodeFrameColorMode
    at ignore-listed frames
 GET /trigger-error 500 in 2.1s (next.js: 305ms, application-code: 1745ms)

(The ENOENT: no such file or directory, open 'does-not-exist.md' of the reproduction only appears there because the browser relays its own console.error; in the API-route case there is no relay and the console shows only the binding error. npm run repro shows the same failure without an app: invalid type: boolean \false`, expected enum CodeFrameColorMode, invalid type: boolean `true`, expected enum CodeFrameColorModeandinvalid type: unit value, expected usize`.)

The dev overlay paints the error message and the call stack but no code frame (and an Unhandled Runtime Error/1 Issue badge appears in the corner):

Next dev overlay with the error but no code frame

Same server error, same overlay, with the native bindings: the frame with the source excerpt is rendered under the message.

Provide environment information

bash
# npx --no-install next info, in the reproduction repository (the WASM bindings are forced on
# any OS, so the platform is not relevant to the bug; this is the machine where we first hit it)
Operating System:
  Platform: openbsd
  Arch: x64
  Version: APRENDIENDODEJESUS#10
  Available memory (MB): 16368
  Available CPU cores: 1
Binaries:
  Node: 22.20.0
  npm: 10.9.3
  Yarn: 1.22.22
  pnpm: 11.20.0
Relevant Packages:
  next: 16.4.0-canary.51 // Latest available version is detected (16.4.0-canary.51).
  eslint-config-next: N/A
  react: 19.3.0
  react-dom: 19.3.0
  typescript: 5.9.3
Next.js Config:
  output: N/A

Which area(s) are affected? (Select all that apply)

SWC, Error Overlay, TypeScript

Which stage(s) are affected? (Select all that apply)

next dev (local), next build (local)

Additional context

The two bindings take different paths over the same public options contract:

  • napi — crates/next-napi-bindings/src/code_frame.rs declares the tolerant shape and normalizes it in impl From<NapiCodeFrameOptions> for CodeFrameOptions:

    color: Option<Either<NapiCodeFrameColorMode, bool>>, max_width: Option<u32> (None | Some(Either::B(false)) → CodeFrameColorMode::None, Some(Either::B(true)) → CodeFrameColorMode::Error, max_width.unwrap_or(DEFAULT_MAX_WIDTH)). This matches the generated type (color?: NapiCodeFrameColorMode | boolean, maxWidth?: number).

  • wasm — crates/wasm/src/lib.rs, code_frame_columns deserializes the JS value straight into CodeFrameOptions with serde_wasm_bindgen::from_value, and crates/next-code-frame/src/frame.rs has no Either<_, bool> and no Option on those fields:

    rust
    pub enum CodeFrameColorMode { None, Error, Warning, Info }
    
    #[derive(Debug, Clone, Deserialize)]
    #[serde(rename_all = "camelCase", default)]
    pub struct CodeFrameOptions {
        pub color: CodeFrameColorMode,
        pub max_width: usize,
        // …
    }

    serde(default) applies to missing fields: it does not make the enum accept a boolean nor usize accept an explicit undefined. The wasm .d.ts types the options as any, so nothing rejects these shapes at the JS boundary.

Next's own callers use exactly the shapes the napi path documents:

  • packages/next/src/next-devtools/server/shared.ts → getOriginalCodeFrame() passes { color: <isTTY>, maxWidth }, with process.stdout?.isTTY ?? false as the default and maxWidth filled from process.stdout.columns (undefined when stdout is not a TTY, which is the case for next dev writing to a log or in CI).
  • packages/next/src/lib/typescript/diagnosticFormatter.ts → { color: true }.
  • packages/next/src/build/load-jsconfig.ts → { message, color: true }.

100% of code frames fail on the WASM path: the dev overlay, next build TypeScript diagnostics and the jsconfig.json parse error path.

Suggested fix, either is fine and small: (1) in crates/wasm/src/lib.rs, deserialize into a tolerant option struct (the mirror of NapiCodeFrameOptions plus its From conversion) before calling render_code_frame; or (2) make CodeFrameOptions tolerant (color: Option<Either<CodeFrameColorMode, bool>>, max_width: Option<usize>) and default them where they are used. Option 1 keeps the tolerance at one place per binding boundary; option 2 makes the crate accept what the JS types already allow.

Impact: next dev on the WASM fallback cannot render the frame, so an unrelated binding error replaces the real one — the same class of problem that #85592 fixed for @babel/code-frame ("throwing RangeErrors when rendering errors, hiding the original issue"), and #85592 lists this exact risk ("environments where native bindings behave differently (e.g. WASM fallback path)"). next build fails with the serialization error instead of the actual TypeScript or jsconfig.json diagnostic.

Versions: reproduced on [email protected] (with @next/[email protected]) and on [email protected] (with @next/[email protected]); the source check above is against canary.

Workaround while it is unfixed: a pnpm patch over packages/next/src/shared/lib/errors/code-frame.ts that coerces the options before calling the binding (color: false → delete the property, the default on both bindings; color: true → 'Error'; maxWidth: undefined → a number). Note that 'None' is not portable: the native enum has no None variant, so deleting the property is what keeps both paths working. Happy to send this as a PR if that is the preferred place for the fix.

--- Issue composed with the help of DeepSeek