Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/guide/essentials/entrypoints.md
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,7 @@ export default defineContentScript({
cssInjectionMode: undefined | "manifest" | "manual" | "ui",

// Configure how/when content script will be registered
registration: undefined | "manifest" | "runtime",
registration: undefined | "manifest" | "runtime" | "optional",

main(ctx: ContentScriptContext) {
// Executed when content script is loaded, can be async
Expand Down
6 changes: 6 additions & 0 deletions docs/guide/essentials/scripting.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,9 @@ export default defineContentScript({
},
});
```

## 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.
Comment on lines +30 to +35
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.

99 changes: 99 additions & 0 deletions packages/wxt/src/core/utils/__tests__/manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1281,6 +1281,47 @@ describe('Manifest Utils', () => {
expect(actual.content_scripts).toEqual([]);
expect(actual.host_permissions).toEqual(['*://google.com/*']);
});

it('should add optional_host_permissions instead of content_scripts when registration=optional', async () => {
const cs: ContentScriptEntrypoint = {
type: 'content-script',
name: 'one',
inputPath: 'entrypoints/one.content.ts',
outputDir: contentScriptOutDir,
options: {
matches: ['*://google.com/*'],
registration: 'optional',
},
skipped: false,
};
const styles: OutputAsset = {
type: 'asset',
fileName: 'content-scripts/one.css',
};

const entrypoints = [cs];
const buildOutput: Omit<BuildOutput, 'manifest'> = {
publicAssets: [],
steps: [{ entrypoints: cs, chunks: [styles] }],
};
setFakeWxt({
config: {
manifestVersion: 3,
outDir,
command: 'build',
},
});

const { manifest: actual } = await generateManifest(
entrypoints,
buildOutput,
);

expect(actual.content_scripts).toEqual([]);
expect(actual.optional_host_permissions).toEqual([
'*://google.com/*',
]);
});
});
});

Expand Down Expand Up @@ -1870,6 +1911,64 @@ describe('Manifest Utils', () => {
});
});

describe('optional_host_permissions', () => {
it('should keep optional_host_permissions as-is for MV3', async () => {
const expectedOptionalHostPermissions = ['https://google.com/*'];
const expectedOptionalPermissions: Browser.runtime.ManifestOptionalPermission[] =
['cookies'];
setFakeWxt({
config: {
manifest: {
optional_host_permissions: expectedOptionalHostPermissions,
optional_permissions: expectedOptionalPermissions,
},
manifestVersion: 3,
command: 'build',
},
});
const output = fakeBuildOutput();

const { manifest: actual } = await generateManifest([], output);

expect(actual.optional_permissions).toEqual(
expectedOptionalPermissions,
);
expect(actual.optional_host_permissions).toEqual(
expectedOptionalHostPermissions,
);
});

it('should move optional_host_permissions to optional_permissions for MV2, ignoring duplicates', async () => {
const expectedOptionalPermissions = [
'cookies',
'https://google.com/*',
'*://*.youtube.com/*',
];
setFakeWxt({
config: {
manifest: {
optional_host_permissions: [
'https://google.com/*',
'https://google.com/*',
'*://*.youtube.com/*',
],
optional_permissions: ['cookies'],
},
manifestVersion: 2,
command: 'build',
},
});
const output = fakeBuildOutput();

const { manifest: actual } = await generateManifest([], output);

expect(actual.optional_permissions).toEqual(
expectedOptionalPermissions,
);
expect(actual.optional_host_permissions).toBeUndefined();
});
});

describe('Dev mode', () => {
it('should not add any code for production builds', async () => {
setFakeWxt({
Expand Down
29 changes: 28 additions & 1 deletion packages/wxt/src/core/utils/__tests__/validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ describe('Validation Utils', () => {
{
type: 'error',
message:
'`matches` is required for manifest registered content scripts',
'`matches` is required for content scripts that are not registered at runtime',
value: null,
entrypoint,
},
Expand Down Expand Up @@ -117,5 +117,32 @@ describe('Validation Utils', () => {

expect(actual).toEqual(expected);
});

it('should return an error when "registration: optional" content scripts don\'t have matches', () => {
const entrypoint = fakeContentScriptEntrypoint({
options: {
registration: 'optional',
// @ts-expect-error: Testing validation of invalid `optional` content script without `matches`
matches: null,
},
});
const expected = {
errors: [
{
type: 'error',
message:
'`matches` is required for content scripts that are not registered at runtime',
value: null,
entrypoint,
},
],
errorCount: 1,
warningCount: 0,
};

const actual = validateEntrypoints([entrypoint]);

expect(actual).toEqual(expected);
});
});
});
54 changes: 52 additions & 2 deletions packages/wxt/src/core/utils/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ export async function generateManifest(
convertActionToMv2(manifest);
convertCspToMv2(manifest);
moveHostPermissionsToPermissions(manifest);
moveOptionalHostPermissionsToOptionalPermissions(manifest);
}

if (wxt.config.manifestVersion === 3) {
Expand Down Expand Up @@ -395,13 +396,21 @@ function addEntrypoints(
if (wxt.config.command === 'serve' && wxt.config.manifestVersion === 3) {
contentScripts.forEach((script) => {
script.options.matches?.forEach((matchPattern) => {
addHostPermission(manifest, matchPattern);
if (script.options.registration === 'optional') {
addOptionalHostPermission(manifest, matchPattern);
} else {
addHostPermission(manifest, matchPattern);
}
Comment on lines +399 to +403
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?

});
});
} else {
// Manifest scripts
const hashToEntrypointsMap = contentScripts
.filter((cs) => cs.options.registration !== 'runtime')
.filter(
(cs) =>
cs.options.registration !== 'runtime' &&
cs.options.registration !== 'optional',
)
.reduce((map, script) => {
const hash = hashContentScriptOptions(script.options);
if (map.has(hash)) map.get(hash)?.push(script);
Expand Down Expand Up @@ -433,6 +442,16 @@ function addEntrypoints(
addHostPermission(manifest, matchPattern);
});
});

// Optional runtime content scripts
const optionalContentScripts = contentScripts.filter(
(cs) => cs.options.registration === 'optional',
);
optionalContentScripts.forEach((script) => {
script.options.matches?.forEach((matchPattern) => {
addOptionalHostPermission(manifest, matchPattern);
});
});
}

const contentScriptCssResources = getContentScriptCssWebAccessibleResources(
Expand Down Expand Up @@ -616,6 +635,17 @@ function addPermission(
manifest.permissions.push(permission);
}

function addOptionalPermission(
manifest: Browser.runtime.Manifest,
permission: string,
): void {
manifest.optional_permissions ??= [];
// @ts-expect-error: Allow using strings for permissions for MV2 support
if (manifest.optional_permissions.includes(permission)) return;
// @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.

function addHostPermission(
manifest: Browser.runtime.Manifest,
hostPermission: string,
Expand All @@ -625,6 +655,15 @@ function addHostPermission(
manifest.host_permissions.push(hostPermission);
}

function addOptionalHostPermission(
manifest: Browser.runtime.Manifest,
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)

manifest.optional_host_permissions.push(hostPermission);
}

/**
* - "<all_urls>" → "<all_urls>"
* - "_://play.google.com/books/_" → "_://play.google.com/_"
Expand Down Expand Up @@ -669,6 +708,17 @@ function moveHostPermissionsToPermissions(
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

manifest: Browser.runtime.Manifest,
): void {
if (!manifest.optional_host_permissions?.length) return;

manifest.optional_host_permissions.forEach((permission: string) =>
addOptionalPermission(manifest, permission),
);
delete manifest.optional_host_permissions;
}

function convertActionToMv2(manifest: Browser.runtime.Manifest): void {
if (
manifest.action == null ||
Expand Down
3 changes: 2 additions & 1 deletion packages/wxt/src/core/utils/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ function validateContentScriptEntrypoint(
) {
errors.push({
type: 'error',
message: '`matches` is required for manifest registered content scripts',
message:
'`matches` is required for content scripts that are not registered at runtime',
value: definition.options.matches,
entrypoint: definition,
});
Expand Down
6 changes: 5 additions & 1 deletion packages/wxt/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -709,10 +709,14 @@ export interface BaseContentScriptEntrypointOptions extends BaseScriptEntrypoint
* - `"runtime"`: The content script's `matches` is added to `host_permissions`
* and you are responsible for using the scripting API to register/execute
* the content script dynamically at runtime.
* - `"optional"`: The content script's `matches` is added to
* `optional_host_permissions` and you are responsible for requesting access
* and using the scripting API to register/execute the content script at
* runtime.
*
* @default 'manifest'
*/
registration?: PerBrowserOption<'manifest' | 'runtime'>;
registration?: PerBrowserOption<'manifest' | 'runtime' | 'optional'>;
}

export interface MainWorldContentScriptEntrypointOptions extends BaseContentScriptEntrypointOptions {
Expand Down
2 changes: 1 addition & 1 deletion packages/wxt/src/utils/internal/dev-server-websocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export function getDevServerWebSocket(): WxtWebSocket {
}

export interface ReloadContentScriptPayload {
registration?: 'manifest' | 'runtime';
registration?: 'manifest' | 'runtime' | 'optional';
contentScript: {
matches: string[];
js?: string[];
Expand Down
2 changes: 1 addition & 1 deletion packages/wxt/src/virtual/utils/reload-content-scripts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export async function reloadContentScriptMv3({
registration,
contentScript,
}: ReloadContentScriptPayload) {
if (registration === 'runtime') {
if (registration === 'runtime' || registration === 'optional') {
await reloadRuntimeContentScriptMv3(contentScript);
} else {
await reloadManifestContentScriptMv3(contentScript);
Expand Down
Loading