Chrome Extension Tutorial: Migrating to Manifest V3 from V2

In November 2020, Chrome introduced Manifest V3. For a long time, extensions have been using Manifest V2, so this is a big transition, especially with the new features in V3.

In this tutorial, we will see the steps needed to go from Manifest V2 to V3. I will be using the extension from a previous tutorial (Chrome Extension Tutorial — Replace Images in Any Website with Pikachu) with a  new branch. If you're not familiar with it, we built a chrome extension that replaces all images in a website with random Pikachu images that we retrieved through an API. You can checkout the repository here.


Why Migrate to Manifest V3?

As Chrome's Documentation puts it:

Extensions using MV3 will enjoy enhancements in security, privacy, and performance; they can also use more contemporary Open Web technologies adopted in MV3, such as service workers and promises.

Changes to manifest.json

Changing the Version

The first obvious step is that you need to change the version of your manifest. In your manifest.json file, change it as follows:

{
	...,
    "manifest_version": 3,
    ...
}

If you try to add your extension to chrome now (or reload it if it's already there), you'll see different errors regarding changes that you still need to make to your manifest.json file.

Host Permissions

In Manifest V2, there were two ways to get permission for your apis or any host you will need to make requests to from the extension: either in the permissions array or in the optional_permissions array.

In Manifest V3, all host permissions are now separate in a new array with the key host_permissions. Host permissions should not be added with other permissions anymore.

Going back to our example, this was our permissions array:

{
	...,
    "permissions": [
    	"https://some-random-api.ml/*"
  	],
    ...
}

Now, it should change to this:

{
	...,
    "host_permissions": [
    	"https://some-random-api.ml/*"
  	],
    ...
}

In our case, we just needed to change the key from permissions to host_permissions. However, if your extension has other values in permissions, then you should keep them in it and just move your host permissions to host_permissions.

Background Scripts

Manifest V3 replaces background scripts with service workers. We'll talk about how to make the transition in a bit, but first the transition need to be made in manifest.json.

The background object currently looks like this in our extension:

{
	...,
    "background": {
    	"scripts": ["assets/js/background.js"],
    	"persistent": false
  	},
    ...
}

What we need to do is change the scripts array key to service_worker, and now you should have one service worker instead of multiple background pages or scripts. So, it should look like this:

{
	...,
    "background": {
    	"service_worker": "assets/js/background.js"
  	},
    ...
}

Note that we don't need to add persistent anymore. Also, if you have page inside background, that should be changed to a service worker as well.

Actions

Actions used to be browser_action and page_action, but now they're unified into action in Manifest V3. This is due to the fact that over time they became similar, and separating them became unnecessary.

We don't use it in our extension, but this is an example of how it should be like:

{
	...,
    "action": {
    	//include everything in browser_action
        //include everything in page_action
    },
    ...
}

There are also changes in the code needed, we will get to that later.

Content Security Policy

Again, this is not used in our extension, but we still need to go over it. If your extension had a Content Security Policy (CSP), then you need to change it from a string (the way it was in Manifest V2) to an object (the way it is in Manifest v3).

An example of how it should be like in Manifest V3:

{
	...,
    "content_security_policy": {
  		"extension_pages": "...",
 	 	"sandbox": "..."
	},
    ...
}

Web-Accessible Resources

The last change you need to make in the manifest.json is changing the web_accessible_resources array to an object detailing all the resources. Here's an example of how it should be like in V3:

{
	...,
    "web_accessible_resources": {
    	"resources": [
        	//the array of resources you had before
        ]
    },
    ...
}

The object also will support in future releases the keys matches(array of URLs), extension_ids(array of keys), and use_dynamic_url(boolean).

Adding the Extension

Now if you go to chrome://extensions in your browser and add your extension or reload it, it will change to a Manifest V3 extension successfully. However, in our case it will show you an error button in the extension box, and when you click it, it will say "service worker registration failed." That's because there's still more work to do in our code.


From Background Scripts to Service Workers

First, what are service workers and what's the difference between them and background scripts?

Background scripts are essential in almost all extensions. They allow you to do some actions or execute code without the need for the user to open a certain page or do something. This can be used to send notifications, manage communication with content scripts, and much more. Background scripts are generally always running in the background.

Service workers are executed when needed. Unlike background scripts, they are not always running in the background. At the top level, service workers should register listeners to some events that would allow them later on to be executed.

The shift from background scripts to service workers depends on your code in extension. Some extensions might need a lot of reworking, while others not so much.

The first step you need to do is move your file that was previously a background script or page to the root of the extension. This is actually why in our extension we received the error stating that the registration of the service worker failed. Our background script's path was js/assets/background.js relative to the root of our extension.

If your case is similar, move your background script to the root of your extension, then change the value of service_worker in your manifest to reflect the change:

{
	...,
    "background": {
    	"service_worker": "background.js"
  	},
    ...
}

If you reload the extension, the service worker should register successfully.

Now, let's look at the code. In our extension, our background script looked as follows:

chrome.runtime.onMessage.addListener(function(message, sender, senderResponse){
  if(message.msg === "image"){
    fetch('https://some-random-api.ml/img/pikachu')
          .then(response => response.text())
          .then(data => {
            let dataObj = JSON.parse(data);
            senderResponse({data: dataObj, index: message.index});
          })
          .catch(error => console.log("error", error))
      return true;  // Will respond asynchronously.
  }
});

Basically our background script listened to a message using chrome.runtime.onMessage.addListener, and if the message was asking for an image, it would send a request to the API, and then return the data to our content script.

Our background script actually does not need any additional change. The reason behind that is that the background script that is now a service worker just registers an event listener and executes code when that event occurs, which is exactly what a service worker should be doing.

However, not all extensions are like that as there are different use cases. Here's what you need to check for and amend in your background script:

Global Variables

As stated above, background scripts previously were always running in the back. Meaning if I had the following code:

let count = 0;

chrome.runtime.onMessage.addListener( (message) => {
	count++;
    console.log(count);
});

Each time the background script received a message, the count would increment. So, at first it would be 0, then 1, then 2, and so on.

In service workers, this will not work anymore. Service workers will run only when they need to, and terminate when they've finished their job. So, the above code would always print in the console "1".

Changing this depends on your use case. In the above example, the count could be passed back and forth between your background script and content script to get the result needed. An even better way would be to use Chrome's Storage API.

Using that, the code will look something like this:

chrome.runtime.onMessage.addListener ( (message) => {
	chrome.storage.local.get(["count"], (result) => {
        const count = result.count ? result.count++ : 1;
    	chrome.storage.local.set({count});
        console.log(count);
    });
});

Again, it depends on your code, so make sure to make the changes based on what's best for you.

Timers and Alarms

Timers were used in background scripts with no problems as they are always running in the background. However, this will not work in service workers. You should replace all timers with the Alarms API.

Accessing the DOM

Service workers do not have access to windows or the DOM. If your extension needs that, you can use libraries like jsdom or use chrome.windows.create and chrome.tabs.create. It depends on your usage and what fits your needs.

This is also needed if your background scripts record audio or video, as that is not possible in service workers.

Creating Canvas

If your background script previously created canvas, you can still do that with the OffscreenCanvas API. All you have to do is replace document with OffscreenCanvas.

For example, if this was your code:

let canvas = document.createElement('canvas');

Then you should change it to:

let canvas = new OffscreenCanvas(width, height);

Checking Your Extension

After you are done with making the changes need to change your background script to a service worker, reload your extension in the browser to see if it is working properly.

In our case, there was no change needed in background.js other than moving it to the root. So, if you reload the extension and go to a page, you will find that images have been replaced with Pikachu images successfully.


Actions API

As mentioned before, browser_action and page_action are now merged into action. The same should be applied in your code. If you are using browserAction or pageAction like below:

chrome.browserAction.onClicked.addListener(tab => { … });
chrome.pageAction.onClicked.addListener(tab => { … });

It should be changed to use the new Actions API as follows:

chrome.action.onClicked.addListener(tab => { … });

So, make sure to replace all browserAction and pageAction usages with action.


executeScript

If your code executed arbitrary strings using executeScript's code property, you have two ways to change it. Also, instead of using chrome.tabs.executeScript, you need to replace tabs with scripting so that it will be chrome.scripting.executeScript.

Moving the Code To a New File

You need to move the value of code to a new file and use executeScript's file property.

For example, if your code had something like this:

chrome.tabs.executeScript({
    code: alert("Hello, World!")
});

You should move the value of code, which here is alert("Hello, World!") to a new file (let's call it hello-world.js):

alert("Hello, World!");

Then change your previous code to the following:

chrome.scripting.executeScript({
    file: 'hello-world.js'
});

Put the Code in a Function

If your code can be put in a function instead, like the example code, then just move it to a function in the same file, then assign the function property of executeScripts to the function you created:

function greeting() {
    alert("Hello, World!");
}

chrome.scripting.executeScript({
    function: greeting
});

Additional Work

There is a list of other changes and things you need to look for in your code:

  1. If your extension uses the webRequest API, which is typically used in an enterprise setting where the extension is forced-installed, you need to replace it with the declarativeNetRequest API.
  2. If you are making any CORS requests in your content scripts, make sure to move them to your service worker.
  3. Remotely-hosted code is not allowed anymore. You need to find another way to execute your remotely hosted code. Chrome's documentation suggests using either Configuration-driven features and logic which means you retrieve a JSON file with the configuration needed and you cache it locally for later use, or Externalize logic with a remote service which means you have to move your application logic from your extension to a remote web service.
  4. Check the API Reference for any deprecated APIs or methods you might be using.