Skip to content

Build: Preserve __esModule on window.wp.* IIFE globals for webpack interop#78699

Closed
kraftbj wants to merge 6 commits into
WordPress:trunkfrom
kraftbj:fix-issue-78697
Closed

Build: Preserve __esModule on window.wp.* IIFE globals for webpack interop#78699
kraftbj wants to merge 6 commits into
WordPress:trunkfrom
kraftbj:fix-issue-78697

Conversation

@kraftbj
Copy link
Copy Markdown
Contributor

@kraftbj kraftbj commented May 26, 2026

What?

Closes #78697

The esbuild build pipeline introduced in #72140 appends a footer to every window.wp.* IIFE bundle that materializes esbuild's getter-based exports as data properties via Object.assign( {}, ns ). The shallow copy drops esbuild's non-enumerable __esModule: true marker, which broke webpack default-import interop for external consumers (e.g. WooCommerce — Cart/Checkout blocks crash against Gutenberg trunk because isShallowEqual resolves to the whole namespace object instead of the default function).

Why?

Object.assign( {}, source ) only copies enumerable own properties. esbuild's __toCommonJS helper declares __esModule via Object.defineProperty( ns, '__esModule', { value: true } ), which is non-enumerable by default, so the marker was lost on the rebuilt namespace.

Once __esModule is gone, webpack's __webpack_require__.n( wp.foo ) interop helper takes the CommonJS branch and returns () => wp.foo (the whole namespace) instead of () => wp.foo.default. Consumers doing import isShallowEqual from 'wordpress/is-shallow-equal' then call the namespace as a function and crash:

```
TypeError: isShallowEqual is not a function
```

Every Gutenberg package with a default export and wpScript enabled was affected. The original webpack pipeline preserved the marker via the IIFE's internal Object.defineProperty(…, '__esModule'), so the bundled WordPress core build still works — only Gutenberg trunk (and the nightly build) regress.

How?

The second footer (the one that materializes getters) now branches on wpScriptDefaultExport:

  • Non-default-export packages (wpScriptDefaultExport is falsy): the global is the ES module namespace. Re-seed the Object.assign target with a non-enumerable, writable __esModule when the source has it. The perf optimization (getters → data properties, ~2x cheaper per access in V8) is preserved because the enumerable getters still flow through Object.assign. 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.
  • wpScriptDefaultExport packages: the first footer has already unwrapped the global to its default value (today always a function or class for the eight current opt-ins: api-fetch, deprecated, dom-ready, redux-routine, server-side-render, warning, token-list, shortcode). Keep the plain Object.assign( {}, ns ) to materialize any getters; this also drops any stray __esModule flag the unwrapped default might carry, which is correct — the global no longer represents an ES module namespace, so webpack's interop should take the CJS branch and return the value directly. (For function/class defaults the second footer is a no-op anyway because typeof !== 'object', so no observable change for current packages.)

The new comment block documents both branches.

Testing Instructions

  1. Build any package (e.g. npm run build:packages) and inspect the IIFE footer for one of the affected globals:
    ```
    tail -c 200 build/is-shallow-equal/index.min.js
    ```
    The footer now reads (roughly):
    ```
    if(wp.isShallowEqual&&typeof wp.isShallowEqual==='object'){wp.isShallowEqual=Object.assign(wp.isShallowEqual.__esModule?Object.defineProperty({},'__esModule',{value:true,writable:true}):{},wp.isShallowEqual);}
    ```
  2. Load the bundle in a browser:
    ```js
    wp.isShallowEqual.__esModule // true
    Object.keys( wp.isShallowEqual ) // does not include '__esModule'
    typeof wp.isShallowEqual.default // 'function'
    ```
  3. End-to-end repro: boot the bph/gutenberg nightly setup (or build trunk locally) with WooCommerce installed, then visit a page with the Cart block. Before this PR the page errors with TypeError: vr(...) is not a function (webpack's __webpack_require__.n helper) from wc-blocks-data.js. After this PR the Cart block renders.
  4. Confirm the eight wpScriptDefaultExport packages still expose their default value as the global (typeof wp.apiFetch === 'function', new wp.shortcode( … ), etc.).

Testing Instructions for Keyboard

Not applicable — build-tool change with no UI surface.

Screenshots or screencast

Not applicable.

Use of AI Tools

Yes — Claude Code was used to draft the fix from the analysis in the linked issue and to run iterative adversarial reviews (multiple passes of compound-engineering and Codex CLI). I verified each step, wrote the test scenarios, and reviewed every line.

…terop

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 WordPress#78697
@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 26, 2026

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.

Co-authored-by: kraftbj <kraftbj@git.wordpress.org>
Co-authored-by: prettyboymp <prettyboymp@git.wordpress.org>
Co-authored-by: jsnajdr <jsnajdr@git.wordpress.org>
Co-authored-by: gigitux <gigitux@git.wordpress.org>
Co-authored-by: arthur791004 <arthur791004@git.wordpress.org>
Co-authored-by: youknowriad <youknowriad@git.wordpress.org>
Co-authored-by: kmanijak <karolmanijak@git.wordpress.org>

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

Copy link
Copy Markdown

@prettyboymp prettyboymp left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tested against WooCommerce core and this fixed the issue with the conversion of is-shallow-equal that was introduced.

@arthur791004 arthur791004 requested a review from youknowriad June 3, 2026 13:24
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Jun 3, 2026

Warning: Type of PR label mismatch

To merge this PR, it requires exactly 1 label indicating the type of PR. Other labels are optional and not being checked here.

  • Required label: Any label starting with [Type].
  • Labels found: .

Read more about Type labels in Gutenberg. Don't worry if you don't have the required permissions to add labels; the PR reviewer should be able to help with the task.

@arthur791004
Copy link
Copy Markdown
Contributor

Let's loop in @youknowriad for feedback, since this is related to #72140 as mentioned 🙂

@youknowriad
Copy link
Copy Markdown
Contributor

Thanks for the fix, trying to understand a bit better. esbuild switch happened on the previous WP release, is this a new issue? Why is it only surfacing now on Woo?

@jsnajdr
Copy link
Copy Markdown
Member

jsnajdr commented Jun 3, 2026

The cause of this bug is a very recent optimization by @ellatrix in #78303. One of the optimizations we did during our RSM projects 🙂

The is-shallow-equal package should be the only one affected, because it's the only one that has a default export and named exports, too. Other packages are either "one default export only" or "multiple named exports and no default". The __esModule field is not crucial for them.

// copy is the simplest way to materialize each value once.
footerParts.push(
`if(${ globalName }&&typeof ${ globalName }==='object'){${ globalName }=Object.assign({},${ globalName });}`
);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that all we need to do is that instead of Object.assign({}, ...) we do Object.assign({ __esModule: true }, ...). That covers everything, the patch doesn't need to be that complicated.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call, that's much cleaner — pre-seeding the target also makes the writable concern moot in the common case. One tweak: a plain { __esModule: true } literal defines the property as enumerable, but esbuild's __toCommonJS (and webpack's own __webpack_require__.r) mark it non-enumerable, so the literal would leak __esModule into Object.keys(), spread, and downstream Object.assign. Keeping it non-enumerable is still a one-liner:

`if(${ globalName }&&typeof ${ globalName }==='object'){${ globalName }=Object.assign(Object.defineProperty({},'__esModule',{value:true,writable:true}),${ globalName });}`

That drops both the conditional and the two-branch split. I kept writable: true as a cheap guard so a future esbuild change emitting an enumerable __esModule wouldn't trip a strict-mode write when Object.assign copies it — happy to drop it if you'd rather keep it minimal. Pushed in be32a3d.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it's great that Object.defineProperty returns the object 🙂

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).
Copy link
Copy Markdown
Contributor

@gigitux gigitux left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's wait for @jsnajdr for the last feedback, but I tested this patch with WooCommerce and I'm unable to reproduce the issue. I wonder if it makes sense to add a unit test to cover this use-case. I added it in #78911

Copy link
Copy Markdown
Member

@jsnajdr jsnajdr left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good 👍

// copy is the simplest way to materialize each value once.
footerParts.push(
`if(${ globalName }&&typeof ${ globalName }==='object'){${ globalName }=Object.assign({},${ globalName });}`
);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it's great that Object.defineProperty returns the object 🙂

Comment thread packages/wp-build/lib/build.mjs Outdated
// `__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.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment is too long and includes too many internal details about bundlers. I would write it as:

Seed the copy with a non-enumerable __esModule flag so that bundlers treat it as an ESM module, triggering interop conventions for default exports.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in cbca833 — used your wording, kept the existing line about materializing the getters.

@kraftbj
Copy link
Copy Markdown
Contributor Author

kraftbj commented Jun 4, 2026

Superceded by #78917

@kraftbj kraftbj closed this Jun 4, 2026
@kraftbj kraftbj deleted the fix-issue-78697 branch June 4, 2026 14:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

6 participants