Skip to content

esbuild build pipeline strips __esModule from window.wp.* globals, breaking webpack default-import interop for external consumers #78697

@kraftbj

Description

@kraftbj

What I expected

External plugins (e.g. WooCommerce) that consume @wordpress/* packages as webpack externals can use default imports against the window.wp.* globals:

import isShallowEqual from '@wordpress/is-shallow-equal';
isShallowEqual( a, b );

This worked in the webpack-based bundle pipeline because the IIFE preserved Object.defineProperty( ns, '__esModule', { value: true } ), and webpack's __webpack_require__.n( wp.isShallowEqual ) then returned () => wp.isShallowEqual.default.

What actually happens

PR #72140 / packages/wp-build/lib/build.mjs appends a footer to every window.wp.* IIFE:

// packages/wp-build/lib/build.mjs (around line 580)
if ( globalName ) {
    footerParts.push(
        `if(${globalName}&&typeof ${globalName}==='object'){${globalName}=Object.assign({},${globalName});}`
    );
}

So the built bundle now ends with, for example:

if ( wp.isShallowEqual && typeof wp.isShallowEqual === 'object' ) {
    wp.isShallowEqual = Object.assign( {}, wp.isShallowEqual );
}

__esModule is declared via Object.defineProperty( ns, '__esModule', { value: true } ) (non-enumerable by default). Object.assign( {}, source ) only copies enumerable own properties, so the new object loses __esModule.

Once __esModule is gone, webpack's runtime interop helper (__webpack_require__.n) takes the CommonJS branch and returns () => module (the whole namespace) instead of () => module.default. Any external consumer doing import X from '@wordpress/<package>' then ends up calling the namespace object as a function:

TypeError: isShallowEqual is not a function

Repro

  1. Run a recent Gutenberg trunk build locally (or grab the bph/gutenberg nightly).
  2. In a separate plugin that uses @wordpress/is-shallow-equal (or any other affected package) as a webpack external:
    import isShallowEqual from '@wordpress/is-shallow-equal';
    console.log( isShallowEqual( { a: 1 }, { a: 1 } ) );
  3. Boot the plugin in a WP install that has Gutenberg trunk active; the call throws TypeError.

I hit this in WooCommerce — Gutenberg trunk crashes the Cart block via client/blocks/assets/js/data/cart/push-changes.ts:197, which calls isShallowEqual( localState.customerData, … ). Pageerror in DevTools:

TypeError: vr(...) is not a function
    at wc-blocks-data.js:5:17588
    at gutenberg/build/scripts/data/index.min.js
    at Object.dispatch

vr is webpack's __webpack_require__.n helper.

Verified the same footer is appended to every WP-script bundle:

=== is-shallow-equal ===  wp.isShallowEqual=Object.assign({},wp.isShallowEqual)
=== data ===              wp.data=Object.assign({},wp.data)
=== data-controls ===     wp.dataControls=Object.assign({},wp.dataControls)
=== api-fetch ===         wp.apiFetch=Object.assign({},wp.apiFetch)
=== element ===           wp.element=Object.assign({},wp.element)
=== compose ===           wp.compose=Object.assign({},wp.compose)
=== hooks ===             wp.hooks=Object.assign({},wp.hooks)
=== notices ===           wp.notices=Object.assign({},wp.notices)

Packages with wpScriptDefaultExport: true (like api-fetch) are unwrapped to a function first and skip the second footer (typeof !== 'object'), so they're not affected — but every package with wpScriptDefaultExport falsy/missing that still has a default export is.

I noticed packages/is-shallow-equal/src/index.ts already has this comment:

// `isShallowEqual` is exported also as a named export because esbuild cannot
// expose the default export from the `window.wp.isShallowEqual` global.
export { isShallowEqual, isShallowEqualObjects, isShallowEqualArrays };

…so the team is aware default imports don't work and is migrating consumers to named imports. The issue is that external consumers (WooCommerce, third-party plugins) don't have a clean migration path:

  • The npm package @wordpress/is-shallow-equal@4.58.0 still only exports default, so import { isShallowEqual } from '@wordpress/is-shallow-equal' doesn't type-check.
  • The runtime behavior differs between WordPress core's bundled build (__esModule preserved — default imports work) and Gutenberg trunk (__esModule stripped — default imports break). Consumers need to pick one or compose a per-package shim.

Suggested fixes

Any of the following in packages/wp-build/lib/build.mjs would preserve interop transparently:

// Option A — re-declare __esModule non-enumerable on the copy:
if ( globalName ) {
    footerParts.push(
        `if(${globalName}&&typeof ${globalName}==='object'){` +
            `var __c=Object.assign({},${globalName});` +
            `if(${globalName}.__esModule)Object.defineProperty(__c,'__esModule',{value:true});` +
            `${globalName}=__c;` +
        `}`
    );
}

// Option B — skip the materialization when the module is an ESM namespace:
if ( globalName ) {
    footerParts.push(
        `if(${globalName}&&typeof ${globalName}==='object'&&!${globalName}.__esModule){` +
            `${globalName}=Object.assign({},${globalName});` +
        `}`
    );
}

// Option C — copy non-enumerable descriptors too:
if ( globalName ) {
    footerParts.push(
        `if(${globalName}&&typeof ${globalName}==='object'){` +
            `${globalName}=Object.defineProperties({},Object.getOwnPropertyDescriptors(${globalName}));` +
        `}`
    );
}

Option C preserves all non-enumerable descriptors (including __esModule and anything else the esbuild output uses internally), at slightly higher cost than Object.assign.

Impact

Anything outside Gutenberg that uses a @wordpress/* package as a webpack external and imports a default export. Off the top of my head: WooCommerce (Cart/Checkout blocks crash on trunk), almost certainly other large plugins, and probably the WordPress.com / WP-Admin Calypso bundles.

Environment

  • Gutenberg 23.3.20260523 (nightly via bph/gutenberg)
  • WordPress 6.x test container (wp-env)
  • Webpack-based consumer

Metadata

Metadata

Assignees

Labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions