Skip to content

feat: add content script registration optional#2288

Open
eupthere wants to merge 6 commits intowxt-dev:mainfrom
eupthere:content-script-optional
Open

feat: add content script registration optional#2288
eupthere wants to merge 6 commits intowxt-dev:mainfrom
eupthere:content-script-optional

Conversation

@eupthere
Copy link
Copy Markdown
Contributor

@eupthere eupthere commented Apr 21, 2026

Overview

Adds support for registration: "optional" for content scripts so host match patterns are treated as optional origins instead of required host permissions.

Related Docs:
MDN optional_host_permissions
Chrome Extensions Docs MV2 - Optional Permissions

What changed:

  • Extended content script registration types to include "optional".
  • Updated manifest generation to:
    • Exclude registration: "optional" scripts from content_scripts (same dynamic-registration model as runtime scripts).
    • Move their matches into optional_host_permissions instead of host_permissions.
  • Added MV2 conversion support:
    • optional_host_permissions are merged into optional_permissions when targeting MV2.
  • Updated dev reload handling so "optional" follows runtime-style content script reload flow.
  • Added/updated tests for:
    • optional registration manifest output in MV3
    • optional host permission conversion in MV2
    • validation behavior around matches
  • Updated docs to include the new "optional" registration mode and describe intended optional-host runtime usage.

Please review this:

function validateContentScriptEntrypoint(
  definition: ContentScriptEntrypoint,
): ValidationResult[] {
  const errors = validateBaseEntrypoint(definition);
  if (
    definition.options.registration !== 'runtime' &&
    definition.options.matches == null
  ) {
    errors.push({
      type: 'error',
      message:
        '`matches` is required for content scripts not registered at runtime',
      value: definition.options.matches,
      entrypoint: definition,
    });
  }
  return errors;
}

I intentionally kept registration: 'runtime' as the only mode that can omit matches, to preserve manual injection flows (for example browser.scripting.executeScript({ target: { tabId } ... })) where URL match patterns are not needed in entrypoint options.

registration: "optional" requires matches because this mode derives origin scope from matches to populate optional_host_permissions (and MV2 conversion to optional_permissions).

Could you confirm whether this assumption about the original runtime exception is correct, and whether keeping optional stricter is the intended design?

Manual Testing

wxt core test pass
bun run --filter wxt test run

Generated Manifest

// packages/wxt-demo/src/entrypoints/content.ts
export default defineContentScript({
  registration: 'optional',
  matches: ['https://example.com/*'],
  main() {
    console.log('optional test');
  },
});
cd packages/wxt-demo
npm run build:chrome-mv2
npm run build:chrome-mv3
MV2
{
  "manifest_version": 2,
  "name": "wxt-demo",
  "version": "1.0.0",
  "icons": {
    "16": "icons/16.png",
    "32": "icons/32.png",
    "48": "icons/48.png",
    "128": "icons/128.png"
  },
  "permissions": ["storage"],
  "default_locale": "en",
  "web_accessible_resources": [
    "iframe-src.html",
    "unlisted.js",
    "content-scripts/ui.css"
  ],
  "background": { "scripts": ["background.js"] },
  "browser_action": { "default_title": "Popup", "default_popup": "popup.html" },
  "options_ui": { "open_in_tab": false, "page": "options.html" },
  "sandbox": { "pages": ["example.html", "sandbox.html"] },
  "content_scripts": [
    {
      "matches": ["https://*.duckduckgo.com/*"],
      "css": ["content-scripts/automount.css"],
      "js": ["content-scripts/automount.js", "content-scripts/ui.js"]
    },
    { "matches": ["<all_urls>"], "js": ["content-scripts/example-tsx.js"] },
    { "matches": ["*://*.google.com/*"], "js": ["content-scripts/iframe.js"] },
    {
      "matches": ["*://*.crunchyroll.com/*"],
      "js": ["content-scripts/location-change.js"]
    },
    {
      "matches": ["*://*/*"],
      "js": ["content-scripts/main-world.js"],
      "world": "MAIN"
    }
  ],
  "optional_permissions": ["https://example.com/*"]
}
MV3
{
  "manifest_version": 3,
  "name": "wxt-demo",
  "version": "1.0.0",
  "icons": {
    "16": "icons/16.png",
    "32": "icons/32.png",
    "48": "icons/48.png",
    "128": "icons/128.png"
  },
  "permissions": ["storage", "sidePanel"],
  "default_locale": "en",
  "web_accessible_resources": [
    {
      "resources": ["iframe-src.html", "unlisted.js"],
      "matches": ["*://*.google.com/*", "*://*.example.com/*"]
    },
    {
      "resources": ["content-scripts/ui.css"],
      "use_dynamic_url": true,
      "matches": ["https://*.duckduckgo.com/*"]
    }
  ],
  "background": { "service_worker": "background.js" },
  "action": { "default_title": "Popup", "default_popup": "popup.html" },
  "options_ui": { "open_in_tab": false, "page": "options.html" },
  "sandbox": { "pages": ["example.html", "sandbox.html"] },
  "side_panel": { "default_path": "sidepanel.html" },
  "content_scripts": [
    {
      "matches": ["https://*.duckduckgo.com/*"],
      "css": ["content-scripts/automount.css"],
      "js": ["content-scripts/automount.js", "content-scripts/ui.js"]
    },
    { "matches": ["<all_urls>"], "js": ["content-scripts/example-tsx.js"] },
    { "matches": ["*://*.google.com/*"], "js": ["content-scripts/iframe.js"] },
    {
      "matches": ["*://*.crunchyroll.com/*"],
      "js": ["content-scripts/location-change.js"]
    },
    {
      "matches": ["*://*/*"],
      "js": ["content-scripts/main-world.js"],
      "world": "MAIN"
    }
  ],
  "optional_host_permissions": ["https://example.com/*"]
}

Related Issue

This PR closes #2239

@eupthere eupthere requested a review from aklinker1 as a code owner April 21, 2026 13:33
@netlify
Copy link
Copy Markdown

netlify Bot commented Apr 21, 2026

Deploy Preview for creative-fairy-df92c4 ready!

Name Link
🔨 Latest commit 8115eac
🔍 Latest deploy log https://app.netlify.com/projects/creative-fairy-df92c4/deploys/69eb510b4413e8000902b5e8
😎 Deploy Preview https://deploy-preview-2288--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.

@github-actions github-actions Bot added the pkg/wxt Includes changes to the `packages/wxt` directory label Apr 21, 2026
@eupthere
Copy link
Copy Markdown
Contributor Author

@aklinker1 Right now optional_host_permissions dedupe is exact-string only.

Would be nice if this was deduped if user already had urls in optional_host_permissions. example if I had *.app.com in optional_host_permissions theres no need to put auth.app.com because its covered by the existing one

The issue mentions deduping as a nice to have.

Since WXT already uses @webext-core/match-patterns, I checked whether it can help directly. It has URL checks (includes(url)) but I didn’t find a pattern-to-pattern coverage API (covers(otherPattern) / subset).

I can either:

  • implement a small coverage check in WXT now, or
  • add coverage support to @webext-core/match-patterns first and then wire it in here.

Happy to do whichever you prefer.

Comment thread packages/wxt/src/core/utils/__tests__/manifest.test.ts Outdated
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 24, 2026

Open in StackBlitz

@wxt-dev/analytics

npm i https://pkg.pr.new/@wxt-dev/analytics@2288

@wxt-dev/auto-icons

npm i https://pkg.pr.new/@wxt-dev/auto-icons@2288

@wxt-dev/browser

npm i https://pkg.pr.new/@wxt-dev/browser@2288

@wxt-dev/i18n

npm i https://pkg.pr.new/@wxt-dev/i18n@2288

@wxt-dev/is-background

npm i https://pkg.pr.new/@wxt-dev/is-background@2288

@wxt-dev/module-react

npm i https://pkg.pr.new/@wxt-dev/module-react@2288

@wxt-dev/module-solid

npm i https://pkg.pr.new/@wxt-dev/module-solid@2288

@wxt-dev/module-svelte

npm i https://pkg.pr.new/@wxt-dev/module-svelte@2288

@wxt-dev/module-vue

npm i https://pkg.pr.new/@wxt-dev/module-vue@2288

@wxt-dev/runner

npm i https://pkg.pr.new/@wxt-dev/runner@2288

@wxt-dev/storage

npm i https://pkg.pr.new/@wxt-dev/storage@2288

@wxt-dev/unocss

npm i https://pkg.pr.new/@wxt-dev/unocss@2288

@wxt-dev/webextension-polyfill

npm i https://pkg.pr.new/@wxt-dev/webextension-polyfill@2288

wxt

npm i https://pkg.pr.new/wxt@2288

commit: 8115eac

@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 24, 2026

Codecov Report

❌ Patch coverage is 80.76923% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 79.84%. Comparing base (09b5a19) to head (8115eac).
⚠️ Report is 13 commits behind head on main.

Files with missing lines Patch % Lines
packages/wxt/src/core/utils/manifest.ts 84.00% 3 Missing and 1 partial ⚠️
...es/wxt/src/virtual/utils/reload-content-scripts.ts 0.00% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2288      +/-   ##
==========================================
+ Coverage   79.74%   79.84%   +0.09%     
==========================================
  Files         130      130              
  Lines        3802     3825      +23     
  Branches      860      867       +7     
==========================================
+ Hits         3032     3054      +22     
- Misses        686      687       +1     
  Partials       84       84              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment thread packages/wxt/src/core/utils/__tests__/validation.test.ts Outdated
Comment thread packages/wxt/src/core/utils/__tests__/validation.test.ts Outdated
Comment thread packages/wxt/src/core/utils/__tests__/validation.test.ts Outdated
// @ts-expect-error: Allow using strings for permissions for MV2 support
manifest.optional_permissions.push(permission);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

I’m not sure reduce makes sense in addOptionalPermission, since that helper is just ensuring the array exists, deduping, and mutating the manifest. Did you mean refactoring the optionalContentScripts.forEach(...) logic, rather than the manifest.optional_permissions.push(permission) part?

Copy link
Copy Markdown
Collaborator

@PatrykKuniczak PatrykKuniczak Apr 24, 2026

Choose a reason for hiding this comment

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

Yeah i mean refactor optionalContentScripts.forEach(...) because you have nested forEach and it doesn't look good.

I think you can omit entire addOptionalPermission, but now i have better idea.

Let's use .reduce like it's in line 414 for hostPermissions, it'll be good enough for there.

I should add this comment above, but as i mentioned here, i want to do it in other way previously 😆

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.

Could you suggest the change please? I can't think of how using Array.prototype.reduce can help with readability. The optionalContentScript is pretty much identical to the existing code:

  // Runtime content scripts
  const runtimeContentScripts = contentScripts.filter(
    (cs) => cs.options.registration === 'runtime',
  );
  runtimeContentScripts.forEach((script) => {
    script.options.matches?.forEach((matchPattern) => {
      addHostPermission(manifest, matchPattern);
    });
  });

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.

Yeah, I'm not sure what you're requesting here either @PatrykKuniczak

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Ok guys, my bad .reduce() here, isn't good.
But IMO a little better is:

contentScripts
  .filter((cs) => cs.options.registration === 'optional')
  .flatMap(script => script.options.matches || [])
  .forEach((matchPattern) => {
    addOptionalHostPermission(manifest, matchPattern);
  });

But if you think it isn't let's leave what it is.

delete manifest.host_permissions;
}

function moveOptionalHostPermissionsToOptionalPermissions(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The same like for addOptionalPermission

Comment thread packages/wxt/src/core/utils/validation.ts Outdated
Comment on lines +399 to +403
if (script.options.registration === 'optional') {
addOptionalHostPermission(manifest, matchPattern);
} else {
addHostPermission(manifest, matchPattern);
}
Copy link
Copy Markdown
Collaborator

@PatrykKuniczak PatrykKuniczak Apr 24, 2026

Choose a reason for hiding this comment

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

What if i want to use both in the same time?

Copy link
Copy Markdown
Contributor Author

@eupthere eupthere Apr 24, 2026

Choose a reason for hiding this comment

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

I think that’s a totally fair point, and I hadn’t considered that case.

export default defineContentScript({
    matches: ['https://my-app.com/*'],
    registration: 'optional',
    // ...
});

The original issue’s proposed API treats registration as a single option for the whole content script, so it doesn’t currently allow choosing required vs optional behavior per match.

If we want to support both without introducing breaking changes, maybe we could extend the API, for example by adding a new optional field to separate optional matches from required ones. That way the current API keeps working, while still leaving room for mixed-permission content scripts.

Maybe something like this..

export default defineContentScript({
  matches: ['https://required.com/*'],
  optionalMatches: ['https://optional.com/*'],
})

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

export default defineContentScript({
  matches: ['https://required.com/*'],
  optionalMatches: ['https://optional.com/*'],
})

Yeah i was thinking about that, because we can't close our users to THIS or THIS approach, especially if on native approach, you can have both.

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.

@marcellino-ornelas @aklinker1 Any feedback on this approach?

Copy link
Copy Markdown
Member

@aklinker1 aklinker1 Apr 26, 2026

Choose a reason for hiding this comment

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

Hmm... I like optionalMatches. As I was reviewing this, I was starting to get confused how we would describe the difference between runtime and optional, having a separate property might make more sense.

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.

Nice catch on this @PatrykKuniczak

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.

I like the optionalMatches here as well 🙌🏽 This is actually something were supporting right now in our custom module wasnt sure how to express this at the time of my issue tho soo left this out. Being able to support both like this is amazing and solves all of our usecases ❤️

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.

although how would the registration option work here now? you need registration: 'runtime' soo it doesn't hit the manifest and optional content scripts need the registration: 'runtime' but your matches may want to use registration: 'manifest'

Unless were saying here all matches goes to manifest and all optionalMatches goes to runtime. The only weird case would be if you wanted your matches configured to runtime and have them go to host_permissions which I haven't found a use case for that yet but maybe someone out there does?

- Clarified error message for content scripts not registered at runtime.
- Clarify the error message for invalid `optional` content scripts without `matches`.
Comment on lines +30 to +35

## Optional Host Registration

When using `registration: 'optional'`, WXT adds the script's `matches` to
`optional_host_permissions` instead of `host_permissions`. You must request host
access before registering/executing the script at runtime.
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 doesn't go here. Let's put it here instead: https://wxt.dev/guide/essentials/content-scripts.html

We should add an entire section that covers all three options.

I need to update the example from above to not use defineContentScript, you should use an unlisted script for that use-case.

Comment on lines +399 to +403
if (script.options.registration === 'optional') {
addOptionalHostPermission(manifest, matchPattern);
} else {
addHostPermission(manifest, matchPattern);
}
Copy link
Copy Markdown
Member

@aklinker1 aklinker1 Apr 26, 2026

Choose a reason for hiding this comment

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

Hmm... I like optionalMatches. As I was reviewing this, I was starting to get confused how we would describe the difference between runtime and optional, having a separate property might make more sense.

// @ts-expect-error: Allow using strings for permissions for MV2 support
manifest.optional_permissions.push(permission);
}

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.

Yeah, I'm not sure what you're requesting here either @PatrykKuniczak

@marcellino-ornelas
Copy link
Copy Markdown
Contributor

marcellino-ornelas commented Apr 29, 2026

The only other improvement here I would recommend is to dedupe optional_host_permissions 😃 heres what I mean.

example
In our wxt.config.ts file we define optional_host_permissions to something like this:

optional_host_permissions: [
    "https://*.internal-server.com"
]

This way one host gives you access to anything internal. But in our content scripts were more specific like https://app1.internal-server.com/some/path because we only want it to run on certain websites.

It would be sweet if:

  • If there is optional_host_permissions defined in wxt config already, append optionalMatches hosts instead of replace
  • (this would be sweet) Only add optionalMatches hosts to optional_host_permissions if one doesnt match it already. Soo in my above example https://app1.internal-server.com wouldnt be added again because https://*.internal-server.com already covers that requirement.

No big deal if not we can easily change our custom module to remove the dups if not 😃 Although I think overall this behavior would be sweet for not only optional content scripts but also regular matches too

hostPermission: string,
): void {
manifest.optional_host_permissions ??= [];
if (manifest.optional_host_permissions.includes(hostPermission)) return;
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.

Left this comment separately but you might want to consider using MatchPatterns here soo not only do you check whether its included but you can also check to see if a more wildcard host captures this host more broadly

see this comment for more details:
#2288 (comment)

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.

Love the deduping idea, and thanks for the implementation tips!

I am considering using @webext-core/match-patterns as it's already in wxt core dependency. I just need some feedback on the pattern matching API : #2288 (comment)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pkg/wxt Includes changes to the `packages/wxt` directory

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support new content script registration optional

4 participants