A framework for page-modifying web extensions in an SPA-world
The world of frontend web development has in recent years transitioned largely to SPA development; frameworks like React, Angular and Vue, and the massive ecosystem of tools that surround them, make it attractive to write applications that update the DOM themselves instead of relying on the browser’s built-in navigation.
This creates a challenge for web extensions that wish to modify the DOM in one way or another: how do you trigger your own DOM modifications, so the UI stays consistent, and your changes aren’t removed when the underlying framework reconciles the DOM structure with its own internal structure?
An initial approach might be to hook into the underlying framework: if we can modify the frameworks internal structure instead of modifying the DOM, then our changes will survive framework changes; we would essentially be re-using the same source-of-truth as the framework. Unfortunately, most frameworks do not offer such hooks - and with the privacy provided by JS scoping, most also encapsulate their code in a way that is unreachable for us.
A different approach would be to observe the DOM, and re-apply our changes whenever an element we’re interested in is updated. This requires some finessing to catch all changes, and risks introducing jank if we aren’t careful, but generally works quite well. In this blog post I’ll describe the framework I tend to use when writing page-modifying web extensions in an SPA-dominated world.
When writing web extensions, we often want to modify specific parts of the DOM, so we can extend or replace functionality on the page. Traditionally this would be done by modifying the page whenever our script is injected - usually on page load - but this faces challenges when it comes to SPAs. Since they don’t trigger page reloads, we must instead listen for DOM changes directly.
Through my work on web extensions I’ve found it useful to split the functionality up into reusable parts. My go-to architecture looks a little something like this:
The injection instances
Each “injection” is a simple function that gets triggered whenever an element gets inserted or removed from the DOM, along with related information about which elements it is interested in. In particular, it is defined by the TypeScript type
This is a very flexible structure; by enforcing no opinions on what the injection does with the element it is interested in, we allow it to do a number of things. Do manual DOM modifications? Sure thing. Simple logging? I don’t see why not. Inject your own React root, in case you want to develop your extensions UI in React? Easily implemented using a helper function:
The job of actually observing the DOM is also relatively simple: since each injection defines the selectors it is interested in, the only job of the observer is to keep track of elements as they are added and removed:
This architecture is an incredibly flexible way implement web extensions for SPA websites. By giving each individual feature the ability to easily react to relevant elements being added or removed, we save ourselves a lot of internal dependencies.
For an example of how this can be used, see my web extension for Azure DevOps, which primarily uses React for its custom UI, and plain JS for non-UI DOM modifications:
Here we have a number of features, each implementing a different part of the extension:
- Feature #1 - Pinned projects
reactInjectionhelper function to insert a new React node in the header, whenever the header is added or removed. This React node renders a list of pinned projects. Since projects are pinned from other parts of the extension, they are kept track of in a zustand-based state store. This allows the extension to update the React nodes across React roots, or even from non-React parts of the extension.
- Feature #2 - Correctly rendered errors
Uses a plain injection, with no React involved. Whenever a new error is rendered on the error list it parses the error content, and - if the error contains ANSI code - replaces it with a parsed HTML version of the ANSI error. This is done in plain JS.
- Feature #3 - Project pinning
reactInjectionhelper function to insert a new React node in each sidebar link. The props for this React node is generated on a case-by-case basis, by parsing the sidebar link. It contains a button that, when clicked, toggles the pinned status of a project in the zustand store from feature #1.