Skip to content

NFT does not walk ESM conditional exports branches for @swc/helpers #580

Closed as not planned

Description

@Diego4523BJJ

@swc/helpers has conditional exports (CJS default, ESM import)
NFT follows only the CJS branch
In Node ESM context ("type": "module"), the runtime resolves the import condition → missing ESM file → crash
Minimal repro: create a Next.js project with outputFileTracing: true, build locally, inspect .nft.json for any file that imports from @swc/helpers/_/_interop_require_default
Expected: both cjs/_interop_require_default.cjs and esm/_interop_require_default.js are traced
Actual: only cjs is traced
Workaround: outputFileTracingIncludes: { "*": ["./node_modules/@swc/helpers/esm/**"] }

Activity

  1. styfle commented on May 18, 2026

    @styfle
    Member

    Please create a minimal repo without Next.js so we can verify if the bug is in Next.js or not

  2. chanson commented on May 18, 2026

    @chanson

    We're also hitting this in a Next.js 15 app on Vercel using Node 22.x. In our case it's @reduxjs/toolkit (transitive dependency via recharts) rather than @swc/helpers, but seems to be the same root cause where nft traces the CJS path but Node resolves to the ESM entry at runtime.

    @reduxjs/toolkit exports map:

      {
        "module-sync": { "default": "./dist/redux-toolkit.modern.mjs" },
        "import": { "default": "./dist/redux-toolkit.modern.mjs" },
        "default": { "default": "./dist/cjs/index.js" }
      }
    

    Runtime resolves to redux-toolkit.modern.mjs, which then imports from redux/dist/redux.mjs, redux-thunk/dist/redux-thunk.mjs, etc. — all of which have the same CJS/ESM split in their exports maps. None of the ESM files are included in the SSR bundle.

    This started after a deploy today, which I'm guessing was our first deploy with Node 22.22.3 rolled out on Vercel. We confirmed by rolling back to a deploy from last week (which continued to work) vs reverting the change we deployed today and redeploying, resulting in the same error. The relevant Node changes appear to be nodejs/node#59679 (separate cache for require(esm) in imported CJS) and nodejs/node#62080 (path normalization in finalizeResolution).

    We worked around it two ways:

    1. outputFileTracingIncludes covering the full dist/** for each affected package
    2. Dynamically importing the affected components to keep them out of the SSR bundle entirely
  3. Diego4523BJJ commented on May 18, 2026

    @Diego4523BJJ
    Author

    Please create a minimal repo without Next.js so we can verify if the bug is in Next.js or not

    Minimal repro without Next.js, as requested.

    package.json

    {
      "name": "nft-swc-helpers-esm-bug-repro",
      "version": "1.0.0",
      "private": true,
      "scripts": {
        "trace:cjs": "node trace.mjs entry.cjs",
        "trace:esm": "node trace.mjs entry.mjs"
      },
      "dependencies": {
        "@swc/helpers": "0.5.21",
        "@vercel/nft": "^0.27.0"
      }
    }

    entry.cjs (mimics Next.js / SWC compiled output for Node Lambda)

    const helper = require("@swc/helpers/_/_interop_require_default");
    console.log("helper:", typeof helper);

    entry.mjs (control — Node ESM context)

    import { _ } from "@swc/helpers/_/_interop_require_default";
    console.log("interop helper loaded:", typeof _);

    trace.mjs

    import { nodeFileTrace } from "@vercel/nft";
    import { resolve } from "node:path";
    
    const entry = resolve(process.argv[2] ?? "entry.cjs");
    const result = await nodeFileTrace([entry]);
    const swc = [...result.fileList].filter((f) => f.includes("@swc/helpers")).sort();
    const hasCjs = swc.some((f) => f.endsWith("cjs/_interop_require_default.cjs"));
    const hasEsm = swc.some((f) => f.endsWith("esm/_interop_require_default.js"));
    
    console.log("@swc/helpers files traced:");
    for (const f of swc) console.log("  " + f);
    console.log("  cjs included:", hasCjs);
    console.log("  esm included:", hasEsm);
    process.exit(hasEsm ? 0 : 1);

    Run:

    npm install
    npm run trace:cjs   # exits 1 — bug reproduced
    npm run trace:esm   # exits 0 — bug absent
    

    npm run trace:cjs output (current @vercel/nft behaviour):

    @swc/helpers files traced:
      node_modules/@swc/helpers/_/_interop_require_default/package.json
      node_modules/@swc/helpers/cjs/_interop_require_default.cjs
      node_modules/@swc/helpers/package.json
      cjs included: true
      esm included: false   <-- bug
    

    npm run trace:esm output (no bug):

    @swc/helpers files traced:
      node_modules/@swc/helpers/_/_interop_require_default/package.json
      node_modules/@swc/helpers/cjs/_interop_require_default.cjs
      node_modules/@swc/helpers/esm/_interop_require_default.js
      node_modules/@swc/helpers/package.json
      cjs included: true
      esm included: true
    

    The differentiator is the importer's module system. CJS require() makes NFT walk only the default (CJS) condition of the subpath's exports map. Under Node 22.22.3+ (nodejs/node#59679, nodejs/node#62080), the same require() at runtime resolves via the import condition because @swc/helpers/package.json declares "type": "module" — so the ESM file is what actually gets loaded. NFT and Node's resolver disagree, and the ESM sibling is missing from the lambda bundle.

    This matches @chanson's @reduxjs/toolkit observation above — same pattern, different package.

  4. styfle commented on May 21, 2026

    @styfle
    Member

    You're using nft@0.27.0 which is quite old. Try upgrading to nft@1.5.0 and it works as expected.

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions