From ef40f3ef5b202bd59c16bc8c75bf756c9127bfb6 Mon Sep 17 00:00:00 2001 From: Brandon Kraft Date: Tue, 26 May 2026 15:35:13 -0500 Subject: [PATCH 1/3] Build: Preserve __esModule on window.wp.* IIFE globals for webpack interop The esbuild build pipeline appends a footer that does `Object.assign({}, ns)` to materialize each WP package's IIFE namespace as data properties. `Object.assign` only copies enumerable own properties, so esbuild's non-enumerable `__esModule: true` marker was being stripped. Without the marker, webpack's `__webpack_require__.n` helper in external consumers (e.g. WooCommerce) takes the CJS branch and returns the whole namespace instead of `.default`, breaking default imports from `wordpress/*` packages and crashing the Cart block on Gutenberg trunk. The fix branches on `wpScriptDefaultExport`: - Non-default-export packages: re-seed the `Object.assign` target with a non-enumerable, writable `__esModule` when the source has it. Writable so a future esbuild emit that makes the source's `__esModule` enumerable would not trip a strict-mode TypeError. - `wpScriptDefaultExport` packages: keep the plain `Object.assign({}, ns)` since the global has been unwrapped to the default value and no longer represents an ES module namespace; any stray `__esModule` flag on the unwrapped default is correctly dropped so webpack's interop takes the CJS branch and returns the value directly. Fixes #78697 --- packages/wp-build/CHANGELOG.md | 4 ++++ packages/wp-build/lib/build.mjs | 32 +++++++++++++++++++++++++++++--- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/packages/wp-build/CHANGELOG.md b/packages/wp-build/CHANGELOG.md index a7e9f3899ab041..555477c1cea0da 100644 --- a/packages/wp-build/CHANGELOG.md +++ b/packages/wp-build/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Bug Fixes + +- Preserve the non-enumerable `__esModule` marker on `window.wp.*` IIFE globals so that external webpack consumers (e.g. WooCommerce) can still default-import `@wordpress/*` packages exposed as webpack externals. + ## 0.14.0 (2026-05-14) ### Bug Fixes diff --git a/packages/wp-build/lib/build.mjs b/packages/wp-build/lib/build.mjs index 0ae6c549245942..e8adbfc3bf65cd 100755 --- a/packages/wp-build/lib/build.mjs +++ b/packages/wp-build/lib/build.mjs @@ -587,9 +587,35 @@ async function bundlePackage( packageName, options = {} ) { // esbuild marks the getters non-configurable, so we can't rewrite // them in place; replacing the whole namespace with a shallow // copy is the simplest way to materialize each value once. - footerParts.push( - `if(${ globalName }&&typeof ${ globalName }==='object'){${ globalName }=Object.assign({},${ globalName });}` - ); + if ( packageJson.wpScriptDefaultExport ) { + // The first footer has already unwrapped the global to the + // default value. We still copy here to materialize any getters + // the default value may carry; the plain `Object.assign({}, …)` + // also drops any non-enumerable `__esModule` flag the unwrapped + // default might inherit, which is correct — the global no + // longer represents an ES module namespace, so webpack's + // `__webpack_require__.n` should take the CJS branch and + // return the value directly. + footerParts.push( + `if(${ globalName }&&typeof ${ globalName }==='object'){${ globalName }=Object.assign({},${ globalName });}` + ); + } else { + // esbuild's `__toCommonJS` helper marks the namespace with a + // non-enumerable `__esModule: true`. `Object.assign` only + // copies enumerable own properties, so we re-seed the target + // with that marker when present. Without it, webpack's + // `__webpack_require__.n` helper in external consumers (e.g. + // WooCommerce) takes the CJS branch and returns the whole + // namespace instead of `.default`, breaking + // `import X from '@wordpress/'`. The target's + // `__esModule` is marked writable so a hypothetical future + // esbuild emit that makes the source's `__esModule` + // enumerable would not trip a strict-mode TypeError when + // `Object.assign` walks it. + footerParts.push( + `if(${ globalName }&&typeof ${ globalName }==='object'){${ globalName }=Object.assign(${ globalName }.__esModule?Object.defineProperty({},'__esModule',{value:true,writable:true}):{},${ globalName });}` + ); + } } if ( footerParts.length ) { baseConfig.footer = { js: footerParts.join( '' ) }; From be32a3dd8d0962ad36f814b46cd6ce408a75c53d Mon Sep 17 00:00:00 2001 From: Brandon Kraft Date: Wed, 3 Jun 2026 09:17:57 -0500 Subject: [PATCH 2/3] Build: Simplify __esModule preservation to a single seeded Object.assign Per review feedback, collapse the branched footer into one line that pre-seeds the Object.assign target with a non-enumerable, writable `__esModule`. This drops the conditional and the wpScriptDefaultExport split while keeping the descriptor faithful to esbuild's __toCommonJS output (non-enumerable, so it does not leak into Object.keys, spread, or downstream Object.assign). --- packages/wp-build/lib/build.mjs | 48 ++++++++++++--------------------- 1 file changed, 17 insertions(+), 31 deletions(-) diff --git a/packages/wp-build/lib/build.mjs b/packages/wp-build/lib/build.mjs index e8adbfc3bf65cd..25c7fbf6291b26 100755 --- a/packages/wp-build/lib/build.mjs +++ b/packages/wp-build/lib/build.mjs @@ -585,37 +585,23 @@ async function bundlePackage( packageName, options = {} ) { } if ( globalName ) { // esbuild marks the getters non-configurable, so we can't rewrite - // them in place; replacing the whole namespace with a shallow - // copy is the simplest way to materialize each value once. - if ( packageJson.wpScriptDefaultExport ) { - // The first footer has already unwrapped the global to the - // default value. We still copy here to materialize any getters - // the default value may carry; the plain `Object.assign({}, …)` - // also drops any non-enumerable `__esModule` flag the unwrapped - // default might inherit, which is correct — the global no - // longer represents an ES module namespace, so webpack's - // `__webpack_require__.n` should take the CJS branch and - // return the value directly. - footerParts.push( - `if(${ globalName }&&typeof ${ globalName }==='object'){${ globalName }=Object.assign({},${ globalName });}` - ); - } else { - // esbuild's `__toCommonJS` helper marks the namespace with a - // non-enumerable `__esModule: true`. `Object.assign` only - // copies enumerable own properties, so we re-seed the target - // with that marker when present. Without it, webpack's - // `__webpack_require__.n` helper in external consumers (e.g. - // WooCommerce) takes the CJS branch and returns the whole - // namespace instead of `.default`, breaking - // `import X from '@wordpress/'`. The target's - // `__esModule` is marked writable so a hypothetical future - // esbuild emit that makes the source's `__esModule` - // enumerable would not trip a strict-mode TypeError when - // `Object.assign` walks it. - footerParts.push( - `if(${ globalName }&&typeof ${ globalName }==='object'){${ globalName }=Object.assign(${ globalName }.__esModule?Object.defineProperty({},'__esModule',{value:true,writable:true}):{},${ globalName });}` - ); - } + // them in place; replacing the whole namespace with a shallow copy + // is the simplest way to materialize each value once. + // + // Seed the copy with a non-enumerable `__esModule` so webpack's + // `__webpack_require__.n` helper in external consumers (e.g. + // WooCommerce) still resolves `import X from '@wordpress/'` + // to `.default` rather than the whole namespace. A plain + // `Object.assign({}, …)` would otherwise drop esbuild's + // non-enumerable `__esModule` marker. Non-enumerable matches the + // descriptor esbuild's `__toCommonJS` (and webpack's own + // `__webpack_require__.r`) emit, so it does not leak into + // `Object.keys`, spread, or downstream `Object.assign`. `writable` + // guards against a strict-mode write if a future esbuild ever + // emitted an enumerable `__esModule` for `Object.assign` to copy. + footerParts.push( + `if(${ globalName }&&typeof ${ globalName }==='object'){${ globalName }=Object.assign(Object.defineProperty({},'__esModule',{value:true,writable:true}),${ globalName });}` + ); } if ( footerParts.length ) { baseConfig.footer = { js: footerParts.join( '' ) }; From cbca833a8a56d18df13327c66298b09835ca3f4a Mon Sep 17 00:00:00 2001 From: Brandon Kraft Date: Wed, 3 Jun 2026 11:41:18 -0500 Subject: [PATCH 3/3] docs: shorten __esModule footer comment per review --- packages/wp-build/lib/build.mjs | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/packages/wp-build/lib/build.mjs b/packages/wp-build/lib/build.mjs index 25c7fbf6291b26..e35dc30054a4df 100755 --- a/packages/wp-build/lib/build.mjs +++ b/packages/wp-build/lib/build.mjs @@ -588,17 +588,9 @@ async function bundlePackage( packageName, options = {} ) { // them in place; replacing the whole namespace with a shallow copy // is the simplest way to materialize each value once. // - // Seed the copy with a non-enumerable `__esModule` so webpack's - // `__webpack_require__.n` helper in external consumers (e.g. - // WooCommerce) still resolves `import X from '@wordpress/'` - // to `.default` rather than the whole namespace. A plain - // `Object.assign({}, …)` would otherwise drop esbuild's - // non-enumerable `__esModule` marker. Non-enumerable matches the - // descriptor esbuild's `__toCommonJS` (and webpack's own - // `__webpack_require__.r`) emit, so it does not leak into - // `Object.keys`, spread, or downstream `Object.assign`. `writable` - // guards against a strict-mode write if a future esbuild ever - // emitted an enumerable `__esModule` for `Object.assign` to copy. + // Seed the copy with a non-enumerable `__esModule` flag so that + // bundlers treat it as an ESM module, triggering interop + // conventions for default exports. footerParts.push( `if(${ globalName }&&typeof ${ globalName }==='object'){${ globalName }=Object.assign(Object.defineProperty({},'__esModule',{value:true,writable:true}),${ globalName });}` );