From 291a030950a7f268d53520c3d4dd618862894ac9 Mon Sep 17 00:00:00 2001 From: ella Date: Thu, 14 May 2026 17:40:14 +0200 Subject: [PATCH 1/3] wp-build: Replace getter-based exports with data properties MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 @ /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) --- packages/wp-build/lib/build.mjs | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/packages/wp-build/lib/build.mjs b/packages/wp-build/lib/build.mjs index 39e746630bec46..0beb8f82267658 100755 --- a/packages/wp-build/lib/build.mjs +++ b/packages/wp-build/lib/build.mjs @@ -564,11 +564,35 @@ async function bundlePackage( packageName, options = {} ) { globalName, }; - // For packages with default exports, add a footer to properly expose the default + // Compose the footer in pieces: + // 1. If the package has a default export, unwrap the namespace so + // `globalName` IS the default value. + // 2. For every package that exposes a global namespace, replace + // esbuild's getter-based re-exports with direct data properties. + // esbuild emits each export as `Object.defineProperty(ns, k, + // { get: () => binding })` to preserve ESM live-binding semantics + // across hoisting; consumers then pay a getter call on every + // property access. Across the editor mount that's hundreds of + // thousands of getter invocations (every hook, every selector). + // Once the IIFE has finished, the bindings are stable, so we can + // read each getter once and replace it with a plain data + // property — same value, ~2x cheaper per access in V8. + const footerParts = []; if ( packageJson.wpScriptDefaultExport && globalName ) { - baseConfig.footer = { - js: `if (typeof ${ globalName } === 'object' && ${ globalName }.default) { ${ globalName } = ${ globalName }.default; }`, - }; + footerParts.push( + `if (typeof ${ globalName } === 'object' && ${ globalName }.default) { ${ globalName } = ${ globalName }.default; }` + ); + } + 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. + footerParts.push( + `if(${ globalName }&&typeof ${ globalName }==='object'){${ globalName }=Object.assign({},${ globalName });}` + ); + } + if ( footerParts.length ) { + baseConfig.footer = { js: footerParts.join( '' ) }; } const baseBundlePlugins = [ From f715807d205bcd798a36d59e194ac5121f85cdaf Mon Sep 17 00:00:00 2001 From: ella Date: Thu, 14 May 2026 19:01:52 +0200 Subject: [PATCH 2/3] wp-build: Replace getter exports with data via synthetic stdin entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/wp-build/lib/build.mjs | 60 +++++++++++++++------------------ 1 file changed, 28 insertions(+), 32 deletions(-) diff --git a/packages/wp-build/lib/build.mjs b/packages/wp-build/lib/build.mjs index 0beb8f82267658..3f50edba948d20 100755 --- a/packages/wp-build/lib/build.mjs +++ b/packages/wp-build/lib/build.mjs @@ -554,8 +554,35 @@ async function bundlePackage( packageName, options = {} ) { ? `${ scriptGlobal }.${ camelCase( packageName ) }` : undefined; + // esbuild's IIFE output exposes each export as a non-configurable + // getter (`Object.defineProperty(ns, k, { get: () => binding })`) to + // preserve ESM live-binding semantics across hoisting. Once the IIFE + // has finished the bindings are stable, but consumers still pay a + // getter call on every access — about 2× slower than a data property + // in V8, and across the editor mount that's hundreds of thousands of + // reads from every hook and selector. + // + // Bundle a synthetic CJS entrypoint that spreads the real entry's + // exports into a plain object. Esbuild evaluates the spread inside + // the IIFE, so what's assigned to `globalName` is a plain object with + // data properties from the start. For `wpScriptDefaultExport` + // packages, the synthetic also unwraps the namespace so `globalName` + // IS the default value (replacing the previous post-hoc unwrap). + // + // `bin/packages/build-vendors.mjs` uses the same `stdin` trick to + // build the react-dom global. + const entrySpec = JSON.stringify( entryPoint ); + const stdinContents = packageJson.wpScriptDefaultExport + ? `module.exports = require(${ entrySpec }).default;` + : `module.exports = { ...require(${ entrySpec }) };`; + const baseConfig = { - entryPoints: [ entryPoint ], + stdin: { + contents: stdinContents, + resolveDir: packageDir, + sourcefile: 'index.js', + loader: 'js', + }, bundle: true, sourcemap: true, format: 'iife', @@ -564,37 +591,6 @@ async function bundlePackage( packageName, options = {} ) { globalName, }; - // Compose the footer in pieces: - // 1. If the package has a default export, unwrap the namespace so - // `globalName` IS the default value. - // 2. For every package that exposes a global namespace, replace - // esbuild's getter-based re-exports with direct data properties. - // esbuild emits each export as `Object.defineProperty(ns, k, - // { get: () => binding })` to preserve ESM live-binding semantics - // across hoisting; consumers then pay a getter call on every - // property access. Across the editor mount that's hundreds of - // thousands of getter invocations (every hook, every selector). - // Once the IIFE has finished, the bindings are stable, so we can - // read each getter once and replace it with a plain data - // property — same value, ~2x cheaper per access in V8. - const footerParts = []; - if ( packageJson.wpScriptDefaultExport && globalName ) { - footerParts.push( - `if (typeof ${ globalName } === 'object' && ${ globalName }.default) { ${ globalName } = ${ globalName }.default; }` - ); - } - 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. - footerParts.push( - `if(${ globalName }&&typeof ${ globalName }==='object'){${ globalName }=Object.assign({},${ globalName });}` - ); - } - if ( footerParts.length ) { - baseConfig.footer = { js: footerParts.join( '' ) }; - } - const baseBundlePlugins = [ momentTimezoneAliasPlugin(), styleRuntimeAliasPlugin(), From 8a4e33eebeac43800061f35bbc95298151bab9c9 Mon Sep 17 00:00:00 2001 From: ella Date: Thu, 14 May 2026 19:53:28 +0200 Subject: [PATCH 3/3] Revert "wp-build: Replace getter exports with data via synthetic stdin entry" This reverts commit f715807d205bcd798a36d59e194ac5121f85cdaf. --- packages/wp-build/lib/build.mjs | 60 ++++++++++++++++++--------------- 1 file changed, 32 insertions(+), 28 deletions(-) diff --git a/packages/wp-build/lib/build.mjs b/packages/wp-build/lib/build.mjs index 3f50edba948d20..0beb8f82267658 100755 --- a/packages/wp-build/lib/build.mjs +++ b/packages/wp-build/lib/build.mjs @@ -554,35 +554,8 @@ async function bundlePackage( packageName, options = {} ) { ? `${ scriptGlobal }.${ camelCase( packageName ) }` : undefined; - // esbuild's IIFE output exposes each export as a non-configurable - // getter (`Object.defineProperty(ns, k, { get: () => binding })`) to - // preserve ESM live-binding semantics across hoisting. Once the IIFE - // has finished the bindings are stable, but consumers still pay a - // getter call on every access — about 2× slower than a data property - // in V8, and across the editor mount that's hundreds of thousands of - // reads from every hook and selector. - // - // Bundle a synthetic CJS entrypoint that spreads the real entry's - // exports into a plain object. Esbuild evaluates the spread inside - // the IIFE, so what's assigned to `globalName` is a plain object with - // data properties from the start. For `wpScriptDefaultExport` - // packages, the synthetic also unwraps the namespace so `globalName` - // IS the default value (replacing the previous post-hoc unwrap). - // - // `bin/packages/build-vendors.mjs` uses the same `stdin` trick to - // build the react-dom global. - const entrySpec = JSON.stringify( entryPoint ); - const stdinContents = packageJson.wpScriptDefaultExport - ? `module.exports = require(${ entrySpec }).default;` - : `module.exports = { ...require(${ entrySpec }) };`; - const baseConfig = { - stdin: { - contents: stdinContents, - resolveDir: packageDir, - sourcefile: 'index.js', - loader: 'js', - }, + entryPoints: [ entryPoint ], bundle: true, sourcemap: true, format: 'iife', @@ -591,6 +564,37 @@ async function bundlePackage( packageName, options = {} ) { globalName, }; + // Compose the footer in pieces: + // 1. If the package has a default export, unwrap the namespace so + // `globalName` IS the default value. + // 2. For every package that exposes a global namespace, replace + // esbuild's getter-based re-exports with direct data properties. + // esbuild emits each export as `Object.defineProperty(ns, k, + // { get: () => binding })` to preserve ESM live-binding semantics + // across hoisting; consumers then pay a getter call on every + // property access. Across the editor mount that's hundreds of + // thousands of getter invocations (every hook, every selector). + // Once the IIFE has finished, the bindings are stable, so we can + // read each getter once and replace it with a plain data + // property — same value, ~2x cheaper per access in V8. + const footerParts = []; + if ( packageJson.wpScriptDefaultExport && globalName ) { + footerParts.push( + `if (typeof ${ globalName } === 'object' && ${ globalName }.default) { ${ globalName } = ${ globalName }.default; }` + ); + } + 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. + footerParts.push( + `if(${ globalName }&&typeof ${ globalName }==='object'){${ globalName }=Object.assign({},${ globalName });}` + ); + } + if ( footerParts.length ) { + baseConfig.footer = { js: footerParts.join( '' ) }; + } + const baseBundlePlugins = [ momentTimezoneAliasPlugin(), styleRuntimeAliasPlugin(),