Skip to content

feat: Add globalName entrypoint option.#2017

Open
jviney wants to merge 1 commit intowxt-dev:mainfrom
jviney:add-global-name-option
Open

feat: Add globalName entrypoint option.#2017
jviney wants to merge 1 commit intowxt-dev:mainfrom
jviney:add-global-name-option

Conversation

@jviney
Copy link

@jviney jviney commented Dec 24, 2025

Overview

Implements #2004

The IIFE name is not currently configurable for content scripts and unlisted scripts. The variable name can be important when inserting scripts with world=MAIN or directly via a <script> tag to ensure there is no conflict with an existing variable name on the page.

This PR is a followup to the discussion in #1897 where we decided to add a globalName option.

Questions:

  • The globalName property is available on all entrypoints - should we restrict this to just content scripts and unlisted scripts in the type definitions?
  • Is there any benefit to having the globalName option available for other entrypoint types?
  • How shall we indicate that the default value will switch to the behaviour of false in a future WXT version? Would this version change the generated output for other script types too (eg: background, popup), or will we leave them generating a named IIFE.

Manual Testing

Specify the globalName option on a content script or unlisted script entrypoint and check the generated JS output matches what's expected for the given option.

@netlify
Copy link

netlify bot commented Dec 24, 2025

Deploy Preview for creative-fairy-df92c4 ready!

Name Link
🔨 Latest commit 3ec531a
🔍 Latest deploy log https://app.netlify.com/projects/creative-fairy-df92c4/deploys/694bbcef1cabbb0008e235fd
😎 Deploy Preview https://deploy-preview-2017--creative-fairy-df92c4.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

Copy link
Member

@aklinker1 aklinker1 left a comment

Choose a reason for hiding this comment

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

  • The globalName property is available on all entrypoints - should we restrict this to just content scripts and unlisted scripts in the type definitions?

Only content scripts and unlisted scripts are bundled as IIFEs, so this option is only relevant for them.

  • Is there any benefit to having the globalName option available for other entrypoint types?

No, for the above reason.

  • How shall we indicate that the default value will switch to the behaviour of false in a future WXT version? Would this version change the generated output for other script types too (eg: background, popup), or will we leave them generating a named IIFE.

You don't need to indicate anything in this PR. For the PR that makes the breaking change, add a section to the PR explaining the change and migration path for people.

https://github.com/wxt-dev/wxt/blob/main/CONTRIBUTING.md#breaking-changes-policy

Copy link
Member

@aklinker1 aklinker1 Feb 6, 2026

Choose a reason for hiding this comment

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

I don't think we need a full plugin to do this... If I remember correctly, vite will already output a anonymous IIFE if we don't specify a global name?

Copy link
Member

Choose a reason for hiding this comment

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

I am wrong. We need a plugin like you created!

*
* @default true
*/
globalName?: string | boolean | ((entrypoint: Entrypoint) => string);
Copy link
Member

@aklinker1 aklinker1 Feb 6, 2026

Choose a reason for hiding this comment

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

Yeah, this needs to be moved to the content script and unlisted scripts types. I think it's fine if you duplicate this or feel free to create a shared base type.

Comment on lines +476 to +485
expect(
await project.serializeFile(
'.output/chrome-mv3/content-scripts/content.js',
),
).toMatchInlineSnapshot(`
".output/chrome-mv3/content-scripts/content.js
----------------------------------------
var content=(function(){"use strict";function E(e){return e}const h={matches:["*://*/*"],main(){}};function s(e,...t){}const u={debug:(...e)=>s(console.debug,...e),log:(...e)=>s(console.log,...e),warn:(...e)=>s(console.warn,...e),error:(...e)=>s(console.error,...e)},l=globalThis.browser?.runtime?.id?globalThis.browser:globalThis.chrome;var g=class d extends Event{static EVENT_NAME=a("wxt:locationchange");constructor(t,n){super(d.EVENT_NAME,{}),this.newUrl=t,this.oldUrl=n}};function a(e){return\`\${l?.runtime?.id}:content:\${e}\`}function v(e){let t,n;return{run(){t==null&&(n=new URL(location.href),t=e.setInterval(()=>{let r=new URL(location.href);r.href!==n.href&&(window.dispatchEvent(new g(r,n)),n=r)},1e3))}}}var m=class c{static SCRIPT_STARTED_MESSAGE_TYPE=a("wxt:content-script-started");isTopFrame=window.self===window.top;abortController;locationWatcher=v(this);receivedMessageIds=new Set;constructor(t,n){this.contentScriptName=t,this.options=n,this.abortController=new AbortController,this.isTopFrame?(this.listenForNewerScripts({ignoreFirstEvent:!0}),this.stopOldScripts()):this.listenForNewerScripts()}get signal(){return this.abortController.signal}abort(t){return this.abortController.abort(t)}get isInvalid(){return l.runtime.id==null&&this.notifyInvalidated(),this.signal.aborted}get isValid(){return!this.isInvalid}onInvalidated(t){return this.signal.addEventListener("abort",t),()=>this.signal.removeEventListener("abort",t)}block(){return new Promise(()=>{})}setInterval(t,n){const r=setInterval(()=>{this.isValid&&t()},n);return this.onInvalidated(()=>clearInterval(r)),r}setTimeout(t,n){const r=setTimeout(()=>{this.isValid&&t()},n);return this.onInvalidated(()=>clearTimeout(r)),r}requestAnimationFrame(t){const n=requestAnimationFrame((...r)=>{this.isValid&&t(...r)});return this.onInvalidated(()=>cancelAnimationFrame(n)),n}requestIdleCallback(t,n){const r=requestIdleCallback((...i)=>{this.signal.aborted||t(...i)},n);return this.onInvalidated(()=>cancelIdleCallback(r)),r}addEventListener(t,n,r,i){n==="wxt:locationchange"&&this.isValid&&this.locationWatcher.run(),t.addEventListener?.(n.startsWith("wxt:")?a(n):n,r,{...i,signal:this.signal})}notifyInvalidated(){this.abort("Content script context invalidated"),u.debug(\`Content script "\${this.contentScriptName}" context invalidated\`)}stopOldScripts(){window.postMessage({type:c.SCRIPT_STARTED_MESSAGE_TYPE,contentScriptName:this.contentScriptName,messageId:Math.random().toString(36).slice(2)},"*")}verifyScriptStartedEvent(t){const n=t.data?.type===c.SCRIPT_STARTED_MESSAGE_TYPE,r=t.data?.contentScriptName===this.contentScriptName,i=!this.receivedMessageIds.has(t.data?.messageId);return n&&r&&i}listenForNewerScripts(t){let n=!0;const r=i=>{if(this.verifyScriptStartedEvent(i)){this.receivedMessageIds.add(i.data.messageId);const S=n;if(n=!1,S&&t?.ignoreFirstEvent)return;this.notifyInvalidated()}};addEventListener("message",r),this.onInvalidated(()=>removeEventListener("message",r))}};function b(){}function o(e,...t){}const p={debug:(...e)=>o(console.debug,...e),log:(...e)=>o(console.log,...e),warn:(...e)=>o(console.warn,...e),error:(...e)=>o(console.error,...e)};var w=(async()=>{try{const{main:e,...t}=h;return await e(new m("content",t))}catch(e){throw p.error('The content script "content" crashed on startup!',e),e}})();return w})();
content;"
`);
Copy link
Member

Choose a reason for hiding this comment

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

What about using this for the expectation?

Suggested change
expect(
await project.serializeFile(
'.output/chrome-mv3/content-scripts/content.js',
),
).toMatchInlineSnapshot(`
".output/chrome-mv3/content-scripts/content.js
----------------------------------------
var content=(function(){"use strict";function E(e){return e}const h={matches:["*://*/*"],main(){}};function s(e,...t){}const u={debug:(...e)=>s(console.debug,...e),log:(...e)=>s(console.log,...e),warn:(...e)=>s(console.warn,...e),error:(...e)=>s(console.error,...e)},l=globalThis.browser?.runtime?.id?globalThis.browser:globalThis.chrome;var g=class d extends Event{static EVENT_NAME=a("wxt:locationchange");constructor(t,n){super(d.EVENT_NAME,{}),this.newUrl=t,this.oldUrl=n}};function a(e){return\`\${l?.runtime?.id}:content:\${e}\`}function v(e){let t,n;return{run(){t==null&&(n=new URL(location.href),t=e.setInterval(()=>{let r=new URL(location.href);r.href!==n.href&&(window.dispatchEvent(new g(r,n)),n=r)},1e3))}}}var m=class c{static SCRIPT_STARTED_MESSAGE_TYPE=a("wxt:content-script-started");isTopFrame=window.self===window.top;abortController;locationWatcher=v(this);receivedMessageIds=new Set;constructor(t,n){this.contentScriptName=t,this.options=n,this.abortController=new AbortController,this.isTopFrame?(this.listenForNewerScripts({ignoreFirstEvent:!0}),this.stopOldScripts()):this.listenForNewerScripts()}get signal(){return this.abortController.signal}abort(t){return this.abortController.abort(t)}get isInvalid(){return l.runtime.id==null&&this.notifyInvalidated(),this.signal.aborted}get isValid(){return!this.isInvalid}onInvalidated(t){return this.signal.addEventListener("abort",t),()=>this.signal.removeEventListener("abort",t)}block(){return new Promise(()=>{})}setInterval(t,n){const r=setInterval(()=>{this.isValid&&t()},n);return this.onInvalidated(()=>clearInterval(r)),r}setTimeout(t,n){const r=setTimeout(()=>{this.isValid&&t()},n);return this.onInvalidated(()=>clearTimeout(r)),r}requestAnimationFrame(t){const n=requestAnimationFrame((...r)=>{this.isValid&&t(...r)});return this.onInvalidated(()=>cancelAnimationFrame(n)),n}requestIdleCallback(t,n){const r=requestIdleCallback((...i)=>{this.signal.aborted||t(...i)},n);return this.onInvalidated(()=>cancelIdleCallback(r)),r}addEventListener(t,n,r,i){n==="wxt:locationchange"&&this.isValid&&this.locationWatcher.run(),t.addEventListener?.(n.startsWith("wxt:")?a(n):n,r,{...i,signal:this.signal})}notifyInvalidated(){this.abort("Content script context invalidated"),u.debug(\`Content script "\${this.contentScriptName}" context invalidated\`)}stopOldScripts(){window.postMessage({type:c.SCRIPT_STARTED_MESSAGE_TYPE,contentScriptName:this.contentScriptName,messageId:Math.random().toString(36).slice(2)},"*")}verifyScriptStartedEvent(t){const n=t.data?.type===c.SCRIPT_STARTED_MESSAGE_TYPE,r=t.data?.contentScriptName===this.contentScriptName,i=!this.receivedMessageIds.has(t.data?.messageId);return n&&r&&i}listenForNewerScripts(t){let n=!0;const r=i=>{if(this.verifyScriptStartedEvent(i)){this.receivedMessageIds.add(i.data.messageId);const S=n;if(n=!1,S&&t?.ignoreFirstEvent)return;this.notifyInvalidated()}};addEventListener("message",r),this.onInvalidated(()=>removeEventListener("message",r))}};function b(){}function o(e,...t){}const p={debug:(...e)=>o(console.debug,...e),log:(...e)=>o(console.log,...e),warn:(...e)=>o(console.warn,...e),error:(...e)=>o(console.error,...e)};var w=(async()=>{try{const{main:e,...t}=h;return await e(new m("content",t))}catch(e){throw p.error('The content script "content" crashed on startup!',e),e}})();return w})();
content;"
`);
const output = await project.serializeFile(
'.output/chrome-mv3/content-scripts/content.js',
)
expect(output).toMatch(/^var content=[\s\S]*^content;$/gm);

If a test doesn't require the full file to match, I'd prefer we narrow it down to only what is required. That way if something unrelated to the global name changes and effects this test, it still passes.

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.

2 participants