Picture of the author
Visit my website
Published on
·
Reading time
6 min read

Building a Chrome Extension That Also Runs Like a Website

A practical example of factory pattern decoupling

Share this page

Cover image for this article featuring a Chrome browser over an image of leaves.
Image source: Chrome browser design over a background image from Unsplash. Cover image made using Figma.

Problem

I recently developed a Chrome extension called Retro Notes using React. The contents you fill up in the textboxes are synced with your Chrome profile using Chrome's storage sync API. The implementation is pretty simple — I directly make use of the API, as documented, to get and set storage values.

The extension works as expected when running it within the Chrome extension context. This way, the app has access to Chrome's storage API. However, when I run this React app as a standalone website, it fails because it cannot access Chrome's storage API anymore.

Image showing the error visible when running the Chrome extension as a standalone website without decoupling the dependency.

This limits my development experience as every time I make any changes to the app, even if it's just cosmetic, I have to re-open the app as a Chrome extension locally instead of just starting the website locally.

So, how do we solve this?

Solution

The problem here is that we have tight coupling between the two components — the custom React component and Chrome's storage API object. When running without the Chrome extension context, chrome.storage.sync becomes invalid and because of this tight coupling, we cannot easily replace this either. Tight coupling generally makes testing difficult and because of the direct dependency, changes to the dependency will also affect the dependent component. Therefore, this is considered bad practice.

const CustomComponent = () => {
  const handleButtonOnClick = () => {
    chrome.storage.sync.set({ ['key']: 'value' }) // Tight coupling!
  }

  return (
    <>
      <h1>Hello world</h1>
      <button onClick={handleButtonOnClick}>Save</button>
    </>
  )
}

export default CustomComponent

On the contrary, when we decouple or loosely couple dependencies, systems don't rely heavily on each other. In this example, the React component doesn't need to know what type of storage is being used and the implementation details of how we're setting values into the storage — but it does, so let's change that.

Step 1: Decoupling the storage implementation details

Let's start by extracting the Chrome storage API code into its own file. We'll wrap the chrome.storage.sync.set() method into a generic setStorage() method and just pass the relevant parameters. I'm using TypeScript, so I can create an interface to strongly type these methods and use that interface here. Notice that I've named the interface IStorageApi instead of IChromeStorageApi — there's a reason for this, and we'll get to it later in this article. The full code snippet can be found below.

import { IStorageApi } from '../interfaces/IStorage'

const ChromeStorageApi: IStorageApi = {
  setStorage: (componentKey: string, value: string) => {
    chrome.storage.sync.set({ [componentKey]: value })
  },
}

export default ChromeStorageApi

Now, we can import the Chrome storage API file we created above into our custom component, and then call the setStorage() method instead.

import ChromeStorageApi from '../api/ChromeStorageApi'

const CustomComponent = () => {
  const handleButtonOnClick = () => {
    ChromeStorageApi.setStorage('key', 'value') // Better, but still tightly coupled to one storage method
  }

  return (
    <>
      <h1>Hello world</h1>
      <button onClick={handleButtonOnClick}>Save</button>
    </>
  )
}

export default CustomComponent

This is better — we no longer are tightly coupling the storage functionality into the custom component, but we are still tightly coupling the storage method. Let's say, I decide to swap the storage method and instead of using Chrome's storage API, I use my own backend server's API endpoint. This would mean that I'd need to refactor this file only to update the references from ChromeStorageApi to CustomStorageApi, for example.

Additionally, if I run the application as a standalone website, outside of Chrome's extension context, it still doesn't work because we're using the Chrome storage API directly. So, we haven't really improved the development experience just yet.

Step 2: Decoupling the storage method

To resolve this, let's create another file called DevelopmentStorage.ts that inherits the same IStorageApi interface, but in the implementation of the method, instead of using Chrome's storage API, we'll store the data in a local object. Since we should be able to implement different implementations of the storage interface, naming the interface to something generic like IStorageApi makes more sense. The full code snippet is below.

import { IStaticStorage } from '../interfaces/IStaticStorage'
import { IStorageApi } from '../interfaces/IStorage'

const staticStorage: IStaticStorage = {
  ['key']: 'value',
}

const DevelopmentStorageApi: IStorageApi = {
  setStorage: (componentKey: string, value: string) => {
    staticStorage[componentKey] = value
  },
}

export default DevelopmentStorageApi

Next, we'll create a factory. Depending on the condition, it either returns the actual Chrome storage API module or it returns the mocked development storage API module. In our case, when we run the site as an extension, process.env.NODE_ENV is set to production and when we run the site as a standalone site, the value is set to development.

import ChromeStorageApi from './ChromeStorage'
import DevelopmentStorageApi from './DevelopmentStorage'
import { IStorageApi } from '../interfaces/IStorage'

const isDevelopment: boolean = process.env.NODE_ENV === 'development'
const StorageApi: IStorageApi = isDevelopment ? DevelopmentStorageApi : ChromeStorageApi

export default StorageApi

Finally, let's update the custom component and use the factory-generated module instead of directly referencing a particular module.

The React component doesn't need to know the implementation details of how the items are fetched and stored into Chrome's storage. It can simply interact with an API that is responsible for providing this storage functionality. This way, if we want to store the values into our custom storage, we wouldn't need to make any changes to the custom component.

import StorageApi from '../api/Storage'

const CustomComponent = () => {
  const handleButtonOnClick = () => {
    StorageApi.setStorage('key', 'value') // Decoupled!
  }

  return (
    <>
      <h1>Hello world</h1>
      <button onClick={handleButtonOnClick}>Save</button>
    </>
  )
}

export default CustomComponent

Now, if I run the application as a standalone website, it loads perfectly fine and is usable. Needless to say, I can also run this app as a Chrome extension without any issues.

Screenshot of the Chrome extension when running as a standalone website.

References

If you'd like to see the complete code for Retro Notes, it's available on GitHub. If you'd like to try out the Retro Notes Chrome extension, download it from the Chrome Web Store.

That's it. Thanks for reading!