Skip to content

ESM build unusable with Parcel: UMD export guard in constructorio-id depends on esbuild's __require shim (still broken in 2.91.0) #483

Description

@adam-maschek-momox

Follow-up to #477 (closed 2026-07-29 as completed, with no diagnosis in the thread) and to @benedikt-krieger-hyghstreet's last comment on #452. The bug is still present in 2.91.0. I hit it independently in a different Shopify theme + Parcel codebase, root-caused it, and verified a one-line fix — so I'm filing this with the details rather than reopening.

Summary

The published ESM build (lib/esm/constructorio.js, which the module field selects) throws TypeError: ConstructorioID is not a constructor when bundled with Parcel. The CommonJS entry (lib/constructorio.js) works fine.

This is not a minifier problem. parcel build --no-optimize and parcel build --no-scope-hoist fail identically — the difference is only in the error text, because the identifier is mangled in the optimized build.

Reproduction

mkdir repro && cd repro && npm init -y
npm i @constructor-io/constructorio-client-javascript@2.91.0 parcel

index.js:

import ConstructorioClient from '@constructor-io/constructorio-client-javascript';
window.client = new ConstructorioClient({ apiKey: 'key_xxxxxxxx' });
npx parcel build index.js

Evaluating the resulting bundle throws at construction time:

TypeError: r is not a constructor                          // optimized build
TypeError: ConstructorioID is not a constructor            // --no-optimize

Deep-importing the CommonJS entry instead — '@constructor-io/constructorio-client-javascript/lib/constructorio.js' — works. That is the workaround we have been shipping.

Root cause

@constructor-io/constructorio-id@2.7.1, src/constructorio-id.js:313:

// export module for node or environments with module loaders, such as webpack
if (typeof module !== 'undefined' && typeof require !== 'undefined') {
  module.exports = ConstructorioID;
}

When scripts/build-esm.js bundles this with esbuild into an ESM output, esbuild rewrites the free require to its dynamic-require shim. In the published lib/esm/constructorio.js of 2.91.0 the guard is line 304:

if (typeof module !== "undefined" && typeof __require !== "undefined") {
  module.exports = ConstructorioID;
}

and __require is declared at line 22:

var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
  get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
}) : x)(function(x) { ... });

__require is never actually called anywhere in the 300 KB bundle — grep -c '__require(' returns 0. It exists solely to make that guard true.

Parcel's JS transformer knows require is not defined in a browser ESM asset, so it constant-folds the initialiser. The generated bundle contains literally:

var $cd4e8188bf5849f9$var$__require = /* @__PURE__ */ ((x)=>undefined)(function(x) { ... });

So __require === undefinedtypeof __require !== "undefined" is falsemodule.exports = ConstructorioID never executes → esbuild's __commonJS wrapper require_constructorio_id() (called at lib/esm/constructorio.js:7085) returns {}new ConstructorioID(...) at lib/esm/constructorio.js:7180 throws.

I haven't tested other bundlers, but this would explain why it isn't universal: a bundler that leaves the shim intact binds __require to the Proxy fallback, which is defined, so the guard passes and the bug stays hidden.

Suggested fix

The guard is asking "is a CommonJS loader present?" using a token that bundlers are entitled to rewrite or fold away. Options, best first:

  1. Fix the guard in @constructor-io/constructorio-id — drop the typeof require half. typeof module !== 'undefined' && typeof module.exports !== 'undefined' is the conventional UMD test and does not depend on require surviving a downstream build.
  2. Give constructorio-id a real ESM entry (export default ConstructorioID plus an exports/module field) so the ESM build never routes it through esbuild's CommonJS interop at all. Given Seperate ES6 build for a small and modern build #452's goal was a clean modern build, this seems like the right end state.
  3. Failing both, define __require unconditionally in the ESM output (an esbuild --define or banner in scripts/build-esm.js).

Verified: patching the published lib/esm/constructorio.js:304 to

if (typeof module !== "undefined") {

and rebuilding makes the Parcel ESM bundle fully functional — the client constructs, search / browse / tracker are present, clientId and sessionId are generated — in the optimized build, with no other change. Because __require is never called, removing that half of the condition is safe.

The ESM build is worth having, which is why I'd like to stop pinning the CJS entry: for our entry point it comes out at 87,179 B raw / 22,090 B gzip, versus 105,519 B / 24,680 B via lib/constructorio.js.

Environment

  • @constructor-io/constructorio-client-javascript 2.90.0 and 2.91.0 — both reproduce
  • @constructor-io/constructorio-id 2.7.1
  • parcel 2.16.4, @swc/core 1.15.26, node 23.10.0
  • browserslist: ["defaults and fully supports es6-module", "Chrome >= 97", "Firefox >= 96", "iOS >= 15"]

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