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.

describe('optional_host_permissions', () => {
it('should keep optional_host_permissions as-is for MV3', async () => {
const expectedOptionalHostPermissions = ['https://google.com/*'];
const expectedOptionalPermissions = ['cookies' as const];
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.

this shouldn't be:

Suggested change
const expectedOptionalPermissions = ['cookies' as const];
const expectedOptionalPermissions = ['cookies'] as const;

??
'cookies' is literal anyway

or this isn't necessary at all

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.

Thanks for catching that. That was a type suppression from LLM, and I should’ve caught it before opening the PR.

I’ve replaced it with the proper type here: 8115eac

@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: cfff8d1

@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.68%. Comparing base (09b5a19) to head (cfff8d1).

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.68%   -0.07%     
==========================================
  Files         130      130              
  Lines        3802     3825      +23     
  Branches      860      867       +7     
==========================================
+ Hits         3032     3048      +16     
- Misses        686      691       +5     
- Partials       84       86       +2     

☔ 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.

const entrypoint = fakeContentScriptEntrypoint({
options: {
registration: 'optional',
// @ts-expect-error
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.

Suggested change
// @ts-expect-error
// @ts-expect-error Let's add a reason

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.

Added here 8360042

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?

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

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 rather than change the existing meaning of registration: 'optional', for example by adding a new optional field to separate optional matches from required ones. That way the current API keeps working as proposed, while still leaving room for mixed-permission content scripts.

Maybe something like this..

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

- Clarified error message for content scripts not registered at runtime.
- Clarify the error message for invalid `optional` content scripts without `matches`.
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

2 participants