Skip to content

Build: Wrap script bundles in an IIFE to contain 'use strict' - #79792

Merged
westonruter merged 8 commits into
trunkfrom
fix/esbuild-file-level-use-strict-65515
Aug 4, 2026
Merged

Build: Wrap script bundles in an IIFE to contain 'use strict'#79792
westonruter merged 8 commits into
trunkfrom
fix/esbuild-file-level-use-strict-65515

Conversation

@westonruter

Copy link
Copy Markdown
Member

What?

Wrap each package script bundle produced by @wordpress/build (esbuild) in an IIFE so the 'use strict' directive esbuild emits ends up at the function level instead of the file level.

Why?

esbuild's format: 'iife' output currently begins with a file-level 'use strict'; directive, e.g. wp-includes/js/dist/hooks.js:

"use strict";
var wp;
(wp ||= {}).hooks = (() => {  })();

WordPress concatenates registered admin scripts by joining raw file contents (wp-admin/load-scripts.php does $out .= get_file( $path ) . "\n"; — no per-script wrapping). A 'use strict' directive at the very top of the first script in a concatenated chunk sits in the combined script's directive prologue, which forces every script after it in that chunk into strict mode.

Many legacy core scripts are written for sloppy mode and rely on things strict mode forbids (implicit globals, etc.). The reported symptom is the ThickBox modal on the Plugins screen: the bundle wp-hooks,jquery-core,jquery-migrate,thickbox puts wp-hooks (strict) first, forcing thickbox.js into strict mode, where its implicit global imgLoader = new Image() throws:

Uncaught ReferenceError: imgLoader is not defined

This regressed when packages migrated to esbuild in #72125. Trac: https://core.trac.wordpress.org/ticket/65515

How?

In packages/wp-build/lib/build.mjs, wrap the bundle:

  • banner: '(function() {' — esbuild's 'use strict' now lands as the first statement inside the function (function-level), so it can no longer sit in a concatenated file's prologue.
  • A footer closes the IIFE. Because esbuild's globalName assigns onto a locally-declared var root (and a window.… global name would emit var window, shadowing the real global), that assignment would be trapped inside the wrapper — so the footer re-exposes the package global on window.

Resulting output:

(function() {
"use strict";
var wp;
(wp ||= {}).hooks = (() => {  })();
(window.wp ||= {}).hooks = wp.hooks;
})();

Sourcemaps remain valid (esbuild accounts for banner/footer line counts), unlike a post-build string transform.

Testing Instructions

  1. npm run build and confirm no bundle in build/scripts/*/index.js begins with "use strict" — each now starts with (function() {.
  2. Load the block editor; verify it works and window.wp.* globals (e.g. wp.hooks, wp.i18n, wp.element) are populated.
  3. Regression check for the underlying bug: in an admin context with script concatenation enabled (CONCATENATE_SCRIPTS on, SCRIPT_DEBUG off), open a plugin's "View details" ThickBox modal on the Plugins screen and confirm it opens without a console ReferenceError.

🤖 Generated with Claude Code

esbuild's `format: 'iife'` emits a `'use strict'` directive at the top of
each package bundle. Because WordPress concatenates registered scripts by
joining raw file contents (wp-admin/load-scripts.php), a file-level
directive on the first script in a chunk forces strict mode onto every
sloppy-mode script bundled after it -- e.g. thickbox, which then crashes
with `ReferenceError: imgLoader is not defined` on its implicit globals.

Wrap each bundle in an IIFE so esbuild's `'use strict'` becomes the first
statement inside the function (function level) instead of at file scope.
esbuild's `globalName` assigns onto a locally-declared `var` root, which
the wrapper would trap, so re-expose the package global on `window` in the
footer.

Ref: https://core.trac.wordpress.org/ticket/65515

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown

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: westonruter <westonruter@git.wordpress.org>
Co-authored-by: t-hamano <wildworks@git.wordpress.org>
Co-authored-by: peterwilsoncc <peterwilsoncc@git.wordpress.org>
Co-authored-by: jsnajdr <jsnajdr@git.wordpress.org>
Co-authored-by: siliconforks <siliconforks@git.wordpress.org>
Co-authored-by: youknowriad <youknowriad@git.wordpress.org>

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

@westonruter westonruter added [Type] Build Tooling Issues or PRs related to build tooling Backport to WP 7.0 Beta/RC Backport to WP Minor Release Pull request that needs to be backported to a WordPress minor release and removed Backport to WP 7.0 Beta/RC labels Jul 1, 2026
@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown

Size Change: +1.91 kB (+0.02%)

Total Size: 7.78 MB

📦 View Changed
Filename Size Change
build/scripts/a11y/index.min.js 1.09 kB +27 B (+2.54%)
build/scripts/annotations/index.min.js 2.52 kB +25 B (+1%)
build/scripts/api-fetch/index.min.js 3.33 kB +23 B (+0.7%)
build/scripts/autop/index.min.js 2.21 kB +29 B (+1.33%)
build/scripts/base-styles/index.min.js 137 B +39 B (+39.8%) 🚨
build/scripts/blob/index.min.js 657 B +26 B (+4.12%)
build/scripts/block-directory/index.min.js 44.1 kB +31 B (+0.07%)
build/scripts/block-editor/index.min.js 428 kB +30 B (+0.01%)
build/scripts/block-library/index.min.js 356 kB +37 B (+0.01%)
build/scripts/block-serialization-default-parser/index.min.js 1.19 kB +31 B (+2.67%)
build/scripts/block-serialization-spec-parser/index.min.js 3.11 kB +32 B (+1.04%)
build/scripts/blocks/index.min.js 45.3 kB +31 B (+0.07%)
build/scripts/commands/index.min.js 19.9 kB +30 B (+0.15%)
build/scripts/components/index.min.js 278 kB +37 B (+0.01%)
build/scripts/compose/index.min.js 11.7 kB +27 B (+0.23%)
build/scripts/core-commands/index.min.js 4.37 kB +28 B (+0.64%)
build/scripts/core-data/index.min.js 36.6 kB +37 B (+0.1%)
build/scripts/customize-widgets/index.min.js 14.7 kB +33 B (+0.23%)
build/scripts/data-controls/index.min.js 823 B +28 B (+3.52%)
build/scripts/data/index.min.js 9.76 kB +24 B (+0.25%)
build/scripts/date/index.min.js 23.7 kB +30 B (+0.13%)
build/scripts/deprecated/index.min.js 778 B +22 B (+2.91%)
build/scripts/dom-ready/index.min.js 501 B +25 B (+5.25%) 🔍
build/scripts/dom/index.min.js 5.11 kB +28 B (+0.55%)
build/scripts/edit-post/index.min.js 52 kB +29 B (+0.06%)
build/scripts/edit-site/index.min.js 306 kB +29 B (+0.01%)
build/scripts/edit-widgets/index.min.js 22.3 kB +38 B (+0.17%)
build/scripts/editor/index.min.js 501 kB +29 B (+0.01%)
build/scripts/element/index.min.js 5.22 kB +28 B (+0.54%)
build/scripts/escape-html/index.min.js 618 B +31 B (+5.28%) 🔍
build/scripts/format-library/index.min.js 29.6 kB +32 B (+0.11%)
build/scripts/hooks/index.min.js 1.86 kB +30 B (+1.64%)
build/scripts/html-entities/index.min.js 523 B +29 B (+5.87%) 🔍
build/scripts/i18n/index.min.js 2.49 kB +27 B (+1.09%)
build/scripts/is-shallow-equal/index.min.js 601 B +29 B (+5.07%) 🔍
build/scripts/keyboard-shortcuts/index.min.js 1.64 kB +29 B (+1.8%)
build/scripts/keycodes/index.min.js 1.59 kB +27 B (+1.73%)
build/scripts/list-reusable-blocks/index.min.js 2.52 kB +27 B (+1.08%)
build/scripts/media-utils/index.min.js 122 kB +38 B (+0.03%)
build/scripts/notices/index.min.js 1.71 kB +26 B (+1.55%)
build/scripts/nux/index.min.js 899 B +22 B (+2.51%)
build/scripts/patterns/index.min.js 12.3 kB +37 B (+0.3%)
build/scripts/plugins/index.min.js 2.95 kB +24 B (+0.82%)
build/scripts/preferences-persistence/index.min.js 2.18 kB +26 B (+1.21%)
build/scripts/preferences/index.min.js 3.33 kB +29 B (+0.88%)
build/scripts/primitives/index.min.js 1.04 kB +26 B (+2.56%)
build/scripts/priority-queue/index.min.js 1.64 kB +27 B (+1.67%)
build/scripts/private-apis/index.min.js 1.17 kB +28 B (+2.46%)
build/scripts/react-i18n/index.min.js 857 B +24 B (+2.88%)
build/scripts/redux-routine/index.min.js 3.39 kB +27 B (+0.8%)
build/scripts/reusable-blocks/index.min.js 3.27 kB +24 B (+0.74%)
build/scripts/rich-text/index.min.js 14.8 kB +32 B (+0.22%)
build/scripts/router/index.min.js 5.99 kB +27 B (+0.45%)
build/scripts/server-side-render/index.min.js 1.97 kB +22 B (+1.13%)
build/scripts/shortcode/index.min.js 1.61 kB +26 B (+1.64%)
build/scripts/style-engine/index.min.js 2.49 kB +29 B (+1.18%)
build/scripts/sync/index.min.js 42.3 kB +31 B (+0.07%)
build/scripts/theme/index.min.js 26.2 kB +37 B (+0.14%)
build/scripts/token-list/index.min.js 766 B +27 B (+3.65%)
build/scripts/undo-manager/index.min.js 944 B +26 B (+2.83%)
build/scripts/upload-media/index.min.js 15.6 kB +33 B (+0.21%)
build/scripts/url/index.min.js 4.02 kB +33 B (+0.83%)
build/scripts/viewport/index.min.js 1.24 kB +25 B (+2.06%)
build/scripts/warning/index.min.js 478 B +24 B (+5.29%) 🔍
build/scripts/widgets/index.min.js 7.84 kB +28 B (+0.36%)
build/scripts/wordcount/index.min.js 1.07 kB +30 B (+2.9%)

compressed-size-action

@peterwilsoncc peterwilsoncc left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Rather than use banner for the header, would it be possible to introduce baseConfig.header as an equivalent to footer. My reading of the word banner is that it's intended for an inline comment /* This file is generated from....

Also needs to use multi-line comment format per coding standards https://developer.wordpress.org/coding-standards/inline-documentation-standards/javascript/#multi-line-comments

@siliconforks

Copy link
Copy Markdown
Contributor

Rather than use banner for the header, would it be possible to introduce baseConfig.header as an equivalent to footer. My reading of the word banner is that it's intended for an inline comment /* This file is generated from....

Is there actually a header option, though? I don't see one in the esbuild documentation.

The documentation for banner says "This is similar to footer which inserts at the end instead of the beginning."

And the documentation for footer says "This is similar to banner which inserts at the beginning instead of the end."

So it sounds like they expect you to use these two options if you need to wrap the output.

Co-authored-by: peterwilsoncc <peterwilsoncc@git.wordpress.org>

@peterwilsoncc peterwilsoncc left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks @siliconforks, my mistake.

@github-actions

github-actions Bot commented Jul 15, 2026

Copy link
Copy Markdown

Flaky tests detected in a807a88.
Some tests passed with failed attempts. The failures may not be related to this commit but are still reported for visibility. See the documentation for more information.

🔍 Workflow run URL: https://github.com/WordPress/gutenberg/actions/runs/30682728569
📝 Reported issues:

@peterwilsoncc

Copy link
Copy Markdown
Contributor

@westonruter Thinking about it overnight, I think a changelog entry is warranted for this:

Bug: Wrap script bundles in an IIFE to avoid use strict affecting external code in concatenated JavaScript files.

@westonruter westonruter added [Type] Bug An existing feature does not function as intended and removed [Type] Bug An existing feature does not function as intended labels Jul 16, 2026
westonruter and others added 2 commits July 15, 2026 20:06
Co-authored-by: peterwilsoncc <peterwilsoncc@git.wordpress.org>
@westonruter

Copy link
Copy Markdown
Member Author

Thinking about it overnight, I think a changelog entry is warranted for this

@peterwilsoncc thanks, added in bd1ba10

@westonruter
westonruter requested a review from t-hamano July 22, 2026 03:45
@t-hamano
t-hamano requested a review from Copilot August 1, 2026 03:48

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Wraps esbuild package bundles in IIFEs to prevent strict mode leaking into concatenated legacy scripts.

Changes:

  • Adds function-scoped bundle wrappers and restores package globals on window.
  • Documents the fix in the changelog.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
packages/wp-build/lib/build.mjs Adds IIFE wrapping and global re-exposure.
packages/wp-build/CHANGELOG.md Records the strict-mode isolation fix.

* bundled after it, throwing on e.g. implicit globals. Wrapping confines the
* directive to this bundle. See https://core.trac.wordpress.org/ticket/65515.
*/
baseConfig.banner = { js: '(function() {' };

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

🤖 Validation of this suggestion by Claude Code (Opus 5)

The mechanism Copilot describes is real, but it doesn't apply here, and the "High" severity
isn't warranted.

The premise is correct. wp-admin/load-scripts.php does concatenate raw file contents with
only a newline between them:

$out .= get_file( $path ) . "\n";

And ASI does not insert a semicolon before (foo()\n(function(){})() parses as one call
chain. So the bug class is real.

But the trigger condition doesn't occur for any file that can precede these bundles:

  1. The set of possible predecessors is closed and small. load-scripts.php only serves
    handles registered by wp_default_scripts() / wp_default_packages_*(), and
    WP_Scripts::do_item() only concatenates a script when in_default_dir() passes
    (default_dirs = array( '/wp-admin/js/', '/wp-includes/js/' )). Plugin and theme scripts are
    never concatenated, so the only thing that can sit immediately before a bundle is a
    core-shipped file.

  2. All of them were checked. Parsing all 849 core JS files under wp-includes/js,
    wp-admin/js, and js/_enqueues with acorn and comparing the top-level statement count of
    src against src + "\n(function() {})();" — if the leading ( were absorbed, the count
    wouldn't increase — yields zero absorbers, minified and unminified alike.

  3. The condition is also narrower than "omits its final semicolon." The preceding file's last
    token has to be a valid callee: an identifier, ), ], a literal, or the } of an
    object/function expression. A file ending in a function declaration's }, or ending in a
    comment (as the sourcemap-annotated builds do), is already safe.

  4. Core has shipped this exact pattern for years. 15 concatenatable minified core scripts
    already begin with a bare ( and no defensive semicolon — including
    js/dist/vendor/react.min.js, js/dist/vendor/react-dom.min.js, js/media-views.min.js,
    js/codemirror/codemirror.min.js, and js/jquery/jquery.hotkeys.min.js. If any preceding
    core script could swallow a leading (, React would already be broken in the admin.

Separately, the footer here ends with })();, so this bundle can't break whatever is
concatenated after it either.

Adding the ; is still reasonable as free defense-in-depth — ;(function(){ is the
conventional concatenation-safety idiom, it costs one byte, and sourcemaps are unaffected since
the banner stays on one line. But it guards against a condition no core file currently meets,
and 15 existing core scripts already don't guard against it.

@t-hamano
t-hamano requested a review from youknowriad August 1, 2026 06:07
@t-hamano
t-hamano requested a review from jsnajdr August 1, 2026 06:07
@youknowriad

Copy link
Copy Markdown
Contributor

Just want to add that this makes sense to me. I don't see any other alternative.

@jsnajdr

jsnajdr commented Aug 3, 2026

Copy link
Copy Markdown
Member

It's a pity that esbuild cannot do this automatically. The script content is already an IIFE whose result is assigned to the exporting global. Now we have a double IIFE.

@jsnajdr jsnajdr left a comment

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 tried to find out where the "use strict" statement comes in the first place. esbuild inserts it when the tsconfig.json file declares the "strict": true or "alwaysStrict": true option.

If we wanted to disable it, we could pass a custom tsconfig:

esbuild.build({
  // ...
  tsconfigRaw: { compilerOptions: { alwaysStrict: false } },
});

I think it would be quite safe to remove, but we probably don't want to make such a change. The old webpack build added it too, although there it was inside the generated IIFE and didn't leak outside.

I think that the leaking use strict is an esbuild bug that's worth reporting. Doing it here: evanw/esbuild#4505

if ( packageJson.wpScriptDefaultExport ) {
footerJs += `if (typeof ${ globalName } === 'object' && ${ globalName }.default) { ${ globalName } = ${ globalName }.default; }\n`;
}
footerJs += `(window.${ scriptGlobal } ||= {}).${ globalMember } = ${ scriptGlobal }.${ globalMember };\n`;

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.

The globalName variable now becomes redundant, and doesn't need to be passed to esbuild as the globalName option. With the additional IIFE wrapper, it's no longer a global, it's a local variable. It can have a constant name, e.g., foo. Then the esbuild option is globalName: 'foo', and the footer assignment code is like:

`(window.${scriptGlobal} ||= {}).${globalMember} = foo;`
`(window.${scriptGlobal} ||= {}).${globalMember} = foo.default;`

@westonruter

Copy link
Copy Markdown
Member Author

I'm going ahead with the merge so that this is part of RC1, as there have been 3 approvers (1 in comment only).

@westonruter
westonruter merged commit a74e27c into trunk Aug 4, 2026
63 of 65 checks passed
@westonruter
westonruter deleted the fix/esbuild-file-level-use-strict-65515 branch August 4, 2026 20:33
@github-actions github-actions Bot added this to the Gutenberg 23.8 milestone Aug 4, 2026
@westonruter

Copy link
Copy Markdown
Member Author

@t-hamano We need to add the Backport to WP 7.1 Beta/RC label too?

@t-hamano

t-hamano commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

I'm going ahead with the merge so that this is part of RC1, as there have been 3 approvers (1 in comment only).
We need to add the "Backport to WP 7.1 Beta/RC" label too?

Let's backport this to 7.1 and include it in RC1.

@t-hamano t-hamano added the Backport to WP 7.1 Beta/RC Pull request that needs to be backported to the WordPress major release that's currently in beta label Aug 5, 2026
@github-actions github-actions Bot removed the Backport to WP 7.1 Beta/RC Pull request that needs to be backported to the WordPress major release that's currently in beta label Aug 5, 2026
gutenbergplugin pushed a commit that referenced this pull request Aug 5, 2026
* Build: Wrap script bundles in an IIFE to contain 'use strict'

esbuild's `format: 'iife'` emits a `'use strict'` directive at the top of
each package bundle. Because WordPress concatenates registered scripts by
joining raw file contents (wp-admin/load-scripts.php), a file-level
directive on the first script in a chunk forces strict mode onto every
sloppy-mode script bundled after it -- e.g. thickbox, which then crashes
with `ReferenceError: imgLoader is not defined` on its implicit globals.

Wrap each bundle in an IIFE so esbuild's `'use strict'` becomes the first
statement inside the function (function level) instead of at file scope.
esbuild's `globalName` assigns onto a locally-declared `var` root, which
the wrapper would trap, so re-expose the package global on `window` in the
footer.

Ref: https://core.trac.wordpress.org/ticket/65515

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Use multi-line comment format

Co-authored-by: peterwilsoncc <peterwilsoncc@git.wordpress.org>

* Add changelog entry

Co-authored-by: peterwilsoncc <peterwilsoncc@git.wordpress.org>

---------

Co-authored-by: westonruter <westonruter@git.wordpress.org>
Co-authored-by: t-hamano <wildworks@git.wordpress.org>
Co-authored-by: peterwilsoncc <peterwilsoncc@git.wordpress.org>
Co-authored-by: jsnajdr <jsnajdr@git.wordpress.org>
Co-authored-by: siliconforks <siliconforks@git.wordpress.org>
Co-authored-by: youknowriad <youknowriad@git.wordpress.org>
@github-actions github-actions Bot added the Backported to WP Core Pull request that has been successfully merged into WP Core label Aug 5, 2026
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown

I just cherry-picked this PR to the wp/7.1 branch to get it included in the next release: 14eff27

pento pushed a commit to WordPress/wordpress-develop that referenced this pull request Aug 5, 2026
This updates the pinned commit hash of the Gutenberg repository from `fd715a6833679d098d9fee84b642f8f1bc27341b` to `f05e40e91c54f29c449b1f33d0db89f5166812d9`.

A full list of changes included in this commit can be found on GitHub: 
WordPress/gutenberg@fd715a6...f05e40e

- Writing flow: forward delete an empty paragraph without breaking apart the next block (WordPress/gutenberg#80813)
- Upload Media: Fail the item when the /finalize request fails (WordPress/gutenberg#80725)
- Fix template `modified` and `date` return value for file templates (WordPress/gutenberg#80733)
- Boot: Adjust specificity of the image reset styles so components can size their own images (WordPress/gutenberg#80845)
- Quote: Ensure paragraph placeholder appears after deleting nested blocks (WordPress/gutenberg#77151)
- Block editor: make the Group action wrap blocks with a group transform (WordPress/gutenberg#80891)
- Copy: preserve the block when its entire text is selected (WordPress/gutenberg#80994)
- Add opt-out for block style state controls (WordPress/gutenberg#80956) (WordPress/gutenberg#81004)
- Tabs: Support Home and End keys for keyboard navigation (WordPress/gutenberg#80912)
- Rename blockStatesEnabled setting to blockStatesEditingEnabled (WordPress/gutenberg#81058)
- [WP 7.1] Background: Fix the legacy gradient UI where a gradient cannot be selected (WordPress/gutenberg#81059)
- Views: honor developer-defined view config overrides (WordPress/gutenberg#80832)
- Playlist: Add track icon (WordPress/gutenberg#81078)
- Remove the CODEOWNERS file from wp/7.1. (WordPress/gutenberg#81104)
- Notes: Email users mentioned in a note (WordPress/gutenberg#79606)
- Backport 81068 80744 80642 (