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
- Run a recent Gutenberg trunk build locally (or grab the bph/gutenberg nightly).
- 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 } ) );
- 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
What I expected
External plugins (e.g. WooCommerce) that consume
@wordpress/*packages as webpack externals can use default imports against thewindow.wp.*globals: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:So the built bundle now ends with, for example:
__esModuleis declared viaObject.defineProperty( ns, '__esModule', { value: true } )(non-enumerable by default).Object.assign( {}, source )only copies enumerable own properties, so the new object loses__esModule.Once
__esModuleis 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 doingimport X from '@wordpress/<package>'then ends up calling the namespace object as a function:Repro
@wordpress/is-shallow-equal(or any other affected package) as a webpack external:TypeError.I hit this in WooCommerce — Gutenberg trunk crashes the Cart block via
client/blocks/assets/js/data/cart/push-changes.ts:197, which callsisShallowEqual( localState.customerData, … ). Pageerror in DevTools:vris webpack's__webpack_require__.nhelper.Verified the same footer is appended to every WP-script bundle:
Packages with
wpScriptDefaultExport: true(likeapi-fetch) are unwrapped to a function first and skip the second footer (typeof !== 'object'), so they're not affected — but every package withwpScriptDefaultExportfalsy/missing that still has a default export is.I noticed
packages/is-shallow-equal/src/index.tsalready has this comment:…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:
@wordpress/is-shallow-equal@4.58.0still only exportsdefault, soimport { isShallowEqual } from '@wordpress/is-shallow-equal'doesn't type-check.__esModulepreserved — default imports work) and Gutenberg trunk (__esModulestripped — 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.mjswould preserve interop transparently:Option C preserves all non-enumerable descriptors (including
__esModuleand anything else the esbuild output uses internally), at slightly higher cost thanObject.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