wp-build: Replace getter-based exports with data properties#78303
Conversation
esbuild's IIFE output emits each package export as a non-configurable
getter (`Object.defineProperty(ns, k, { get: () => binding, enumerable: true })`)
to preserve ESM live-binding semantics across hoisting. After the IIFE
finishes the bindings are stable, but every property access on
`wp.element`/`wp.data`/`wp.compose`/etc. by downstream packages and at
render time still goes through a getter call — about 2x slower than a
data property read in V8.
Across a post-editor first-block trace this accounts for ~100 ms of
self-time combined under `get @ <pkg>/index.min.js` entries, called
from every hook and selector across all blocks.
Add an esbuild `footer` to packages that expose a global namespace.
Once the IIFE has assigned `globalName`, replace the namespace with a
shallow copy. `Object.assign({}, ns)` reads each getter exactly once
and produces a plain object with data properties — same values,
direct property access from then on. We have to replace the
namespace object because esbuild marks the getters
`configurable: false`, so they can't be rewritten in place.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
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 If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message. To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook. |
|
Size Change: +2.35 kB (+0.03%) Total Size: 7.96 MB 📦 View Changed
ℹ️ View Unchanged
|
| // copy is the simplest way to materialize each value once. | ||
| footerParts.push( | ||
| `if(${ globalName }&&typeof ${ globalName }==='object'){${ globalName }=Object.assign({},${ globalName });}` | ||
| ); |
There was a problem hiding this comment.
Here the iife build assigns the module object with getters to the globalName, and then our footer overwrites that. That's not elegant. An alternative is to have a synthetic entrypoint like:
module.exports = { ...require('real-entrypoint') };The React vendor build in build-vendors.mjs already does something similar to build React script.
There was a problem hiding this comment.
Have to revert this, it's blowing up the bundle sizes, it seems tree shaking is broken
Per @jsnajdr's review: the previous version built a getter-property module first and then overwrote it from a footer — functional but inelegant. Bundle a synthetic CJS entrypoint that spreads the real entry's exports into a plain object instead, so esbuild evaluates the spread inside the IIFE and `globalName` is a plain object with data properties from the start. No runtime freeze step, same end result. `wpScriptDefaultExport` packages get a synthetic that unwraps the namespace (`module.exports = require(...).default`), replacing the previous post-hoc `globalName = globalName.default` footer. The same `stdin`+spread trick is already used by `bin/packages/build-vendors.mjs` for the react-dom global. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
jsnajdr
left a comment
There was a problem hiding this comment.
In any case this is a great find.
f715807 to
291a030
Compare
…n entry" This reverts commit f715807.
|
For history — the CI runs that preceded the revert (3rd commit): Footer approach (1st commit,
Synthetic stdin (2nd commit,
Root cause: the synthetic-stdin spread The Going back to the footer for now. If we want to preserve the elegance later, the path is per-package codegen of explicit named re-exports (so esbuild sees an ESM file with 🤖 Authored by Claude (Opus 4.7) in collaboration with @ellatrix. |
|
Flaky tests detected in 8a4e33e. 🔍 Workflow run URL: https://github.com/WordPress/gutenberg/actions/runs/25876184298
|
|
Let's merge and see how it affects the graph over several commits to have a better idea about the impact. |
|
FYI, adding missing changelog entry for this change in #78807 |
What?
Bundle each
wpScriptpackage via a synthetic CJS entrypoint that spreads the real entry's exports into a plain object. The IIFE result assigned towp.<pkg>becomes a plain object with data properties instead of esbuild's getter-based namespace.Why?
esbuild's IIFE output emits each export as a non-configurable getter (
Object.defineProperty(ns, k, { get: () => binding })) to preserve ESM live-binding semantics across hoisting. After the IIFE finishes the bindings are stable, but every access onwp.element/wp.data/wp.compose/ etc. still pays a getter call — ~2× slower than a data property read in V8. Across the editor mount that's hundreds of thousands of reads from every hook and selector.How?
Replace the
entryPointswithstdincontainingmodule.exports = { ...require('<entry>') }. Esbuild evaluates the spread inside the IIFE, so the assigned global is plain from the start.wpScriptDefaultExportpackages getmodule.exports = require('<entry>').defaultinstead, unwrapping the namespace at build time and removing the previous post-hoc unwrap.bin/packages/build-vendors.mjsalready uses the same trick for the react-dom global.Perf (CI, vs trunk)
The typing wins are the cleanest signal: every selector read and hook lookup during a keystroke goes through what was previously a getter, now a plain property read.
Use of AI Tools
Authored with Claude Code assistance; reviewed and verified by the author.