Skip to content

es build is not loadable by Node's ESM resolver #1002

Description

@ivanmjartan

es build is not loadable by Node's ESM resolver ‚every entry point throws ERR_MODULE_NOT_FOUND

Summary

In @rc-component/picker@1.12.0 the exports map serves the es build to the import condition for every
subpath, but the es build is bundler-only output:

  1. its relative specifiers are extensionless (import { commonLocale } from "./common";), and
  2. there is no "type": "module" marker ‚neither at the package root nor as a nested es/package.json.

Consequence: any consumer that reaches the package through the import condition ‚ i.e. any "type": "module"
package, or anything running under plain Node ESM ‚I cannot load it at all. require() (the lib build) works fine;
bundlers work fine; Node ESM is completely broken.

Reproduction

mkdir rc-repro && cd rc-repro
npm init -y
npm pkg set type=module
npm i @rc-component/picker@1.12.0 react@19 react-dom@19 moment

node --input-type=module --eval 'import "@rc-component/picker"'
node --input-type=module --eval 'import "@rc-component/picker/locale/en_US"'

Both fail (Node v24.12.0, npm-installed, unpatched):

Error [ERR_MODULE_NOT_FOUND]: Cannot find module '.../@rc-component/picker/es/PickerInput/RangePicker'
  imported from .../@rc-component/picker/es/index.js
  code: 'ERR_MODULE_NOT_FOUND'

Error [ERR_MODULE_NOT_FOUND]: Cannot find module '.../@rc-component/picker/es/locale/common'
  imported from .../@rc-component/picker/es/locale/en_US.js
  code: 'ERR_MODULE_NOT_FOUND'

The CJS path is healthy, which shows the problem is isolated to the es build:

node --input-type=commonjs --eval 'console.log(require("@rc-component/picker/locale/en_US").default.locale)'
# -> en_US

Both missing files exist on disk as es/PickerInput/RangePicker.js and es/locale/common.jsonly the file
extension is missing from the specifier.
Counted in 1.12.0:

  • 391 extensionless relative specifiers across es/**/*.js
  • 158 extensionless relative type specifiers across es/**/*.d.ts (from '../interface', from '.', ‚Ķ),
    which moduleResolution: "node16"/"nodenext"/"bundler"-style ESM type resolution also cannot follow

Note on Node versions: on Node ‚â• 22.7 the ESM syntax in es/*.js is auto-detected, so the files are parsed as ESM
and fail on the extensionless specifier as above. On older Node (no module-syntax detection) the same files are
parsed as CJS, because the nearest package.json has no "type" field, and fail with
SyntaxError: Cannot use import statement outside a module. Broken either way, just with a different message.

The breakage also looks inconsistent, which makes it hard to diagnose: @rc-component/picker/generate/moment
does load under Node ESM, because es/generate/moment.js happens to import only bare specifiers
(moment, @rc-component/util) and no relative paths.

Second, related problem: the default export needs runtime unwrapping

Because the es build uses export default while the package is not marked as ESM, the shape a consumer receives
depends on which build the resolver picked, so no single import form is portable:

// lib (CJS) build via ESM import ‚module.exports is the namespace
import m from ".../lib/locale/en_US.js";      // m = { __esModule: true, default: locale } -> m.locale is undefined
import * as ns from ".../lib/locale/en_US.js"; // Object.keys(ns) = ['__esModule','default','module.exports']
                                               // the locale object is at ns.default.default
// es build inlined by a bundler ‚the locale object is at ns.default

TypeScript agrees with the pessimistic reading: since the package has no "type": "module", es/locale/en_US.d.ts
is treated as CJS under nodenext, so export default locale types as a default property rather than a module
default. Consumers end up writing this at every rc-picker import site:

import * as momentGenerateConfigModule from "@rc-component/picker/generate/moment";
import { defaultImport } from "default-import";

const momentGenerateConfig = defaultImport(
    momentGenerateConfigModule as unknown as { default: MomentGenerateConfig },
);

…i.e. a cast plus a runtime interop helper (default-import) purely to survive the two shapes the same module can
arrive in (real ESM namespace when a bundler inlines it, CJS interop when a test runner or Node externalizes it).

What we tried, and why it doesn't work

Patching the exports map locally:

"./locale/*": {
  "types": "./es/locale/*.d.ts",
  "module": "./es/locale/*.js",
  "import": "./lib/locale/*.js",
  "require": "./lib/locale/*.js"
}
  • "module" is inert for this purpose: Node's resolver only understands node, import, require, default
    (and node-addons) ‚"module" is a bundler-only convention, so it changes nothing for Node.
  • Pointing import at lib/ does make Node load the module, but it swaps the ESM namespace for CJS interop, which
    is exactly the double-wrapped default described above ‚so consumer code that worked under a bundler now reads
    the wrong shape.
  • And it would have to be applied to every subpath (., ./generate/*, ./interface, ‚Ķ), not just ./locale/*.

Impact

A "type": "module" library that statically imports this package becomes un-importable under plain Node ‚the
failure is not local to the picker component, it propagates to the whole barrel/entry point that transitively
reaches it. That breaks SSR, node -e 'import "<pkg>"' package smoke tests, and test runners that externalize
node_modules instead of bundling them.

Our current workaround is to keep every rc-picker import behind a dynamic import() (React lazy) so it never
appears in the Node-reachable static graph, plus the defaultImport casts above.

Suggested fix (any one of these solves the primary issue)

  1. Emit extensions in the es buildfrom "./common.js" ‚and add es/package.json containing
    { "type": "module" } (or emit .mjs). Babel: babel-plugin-add-import-extension; tsc:
    rewriteRelativeImportExtensions / write specifiers with .js. Apply the same to the relative type specifiers
    in es/**/*.d.ts.
  2. Or point the import condition at a genuinely Node-resolvable ESM build and keep es behind the bundler-only
    "module" condition.
  3. Also worth adding "type": "commonjs" explicitly at the root so lib/ is unambiguous and Node's syntax
    detection never has to guess.

Fixing (1) additionally removes the need for default-import-style unwrapping, because a real ESM module's default
export resolves identically in Node and in bundlers.

Environment

  • @rc-component/picker 1.12.0 (unmodified, fresh npm i)
  • Node v24.12.0, npm 11.x, macOS (darwin 24.6.0)
  • Consumer package: "type": "module", TypeScript moduleResolution: nodenext, React 19, moment 2.30.1

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