Blog

A framework for page-modifying web extensions in an SPA-world

Published May 30, 2023

The world of fron­tend web de­vel­op­ment has in re­cent years tran­si­tioned largely to SPA de­vel­op­ment; frame­works like React, An­gu­lar and Vue, and the mas­sive ecosys­tem of tools that sur­round them, make it at­trac­tive to write ap­pli­ca­tions that up­date the DOM them­selves in­stead of re­ly­ing on the browser’s built-​in nav­i­ga­tion.

This cre­ates a chal­lenge for web ex­ten­sions that wish to mod­ify the DOM in one way or an­other: how do you trig­ger your own DOM mod­i­fi­ca­tions, so the UI stays con­sis­tent, and your changes aren’t re­moved when the un­der­ly­ing frame­work rec­on­ciles the DOM struc­ture with its own in­ter­nal struc­ture?

An ini­tial ap­proach might be to hook into the un­der­ly­ing frame­work: if we can mod­ify the frame­works in­ter­nal struc­ture in­stead of mod­i­fy­ing the DOM, then our changes will sur­vive frame­work changes; we would es­sen­tially be re-​using the same source-​of-truth as the frame­work. Un­for­tu­nately, most frame­works do not offer such hooks - and with the pri­vacy pro­vided by JS scop­ing, most also en­cap­su­late their code in a way that is un­reach­able for us.

A dif­fer­ent ap­proach would be to ob­serve the DOM, and re-​apply our changes when­ever an el­e­ment we’re in­ter­ested in is up­dated. This re­quires some fi­ness­ing to catch all changes, and risks in­tro­duc­ing jank if we aren’t care­ful, but gen­er­ally works quite well. In this blog post I’ll de­scribe the frame­work I tend to use when writ­ing page-​modifying web ex­ten­sions in an SPA-​dominated world.

The idea

When writ­ing web ex­ten­sions, we often want to mod­ify spe­cific parts of the DOM, so we can ex­tend or re­place func­tion­al­ity on the page. Tra­di­tion­ally this would be done by mod­i­fy­ing the page when­ever our script is in­jected - usu­ally on page load - but this faces chal­lenges when it comes to SPAs. Since they don’t trig­ger page re­loads, we must in­stead lis­ten for DOM changes di­rectly.

Through my work on web ex­ten­sions I’ve found it use­ful to split the func­tion­al­ity up into reusable parts. My go-to ar­chi­tec­ture looks a lit­tle some­thing like this:

Diagram of framework architecture

The in­jec­tion in­stances

Each “in­jec­tion” is a sim­ple func­tion that gets trig­gered when­ever an el­e­ment gets in­serted or re­moved from the DOM, along with re­lated in­for­ma­tion about which el­e­ments it is in­ter­ested in. In par­tic­u­lar, it is de­fined by the Type­Script type

type InjectionConfig = {
selector: string; // a selector matching any element we are interested in
mount: ($elm: InjectedHTMLElement) => void; // called when a relevant element is added to the DOM
unmount?: ($elm: InjectedHTMLElement) => void; // called when a previously mounted element is removed from the DOM
};
// extend our notion of HTMLElements to keep track of which injections it is related to; this will be useful later
type InjectedHTMLElement = HTMLElement & {
___attached?: Set<InjectionConfig>;
};

This is a very flex­i­ble struc­ture; by en­forc­ing no opin­ions on what the in­jec­tion does with the el­e­ment it is in­ter­ested in, we allow it to do a num­ber of things. Do man­ual DOM mod­i­fi­ca­tions? Sure thing. Sim­ple log­ging? I don’t see why not. In­ject your own React root, in case you want to de­velop your ex­ten­sions UI in React? Eas­ily im­ple­mented using a helper func­tion:

type InjectedReactElement = InjectedHTMLElement & {
___reactRoot?: ReturnType<typeof createRoot>;
};
/** Utility function to ease the writing of React-based injections */
function reactInjection(
selector: string, // selectors for elements we're interested in
rootGenerator: ($elm: HTMLElement) => HTMLElement, // generates the root element we want to insert our React node into
reactNode: ($elm: HTMLElement) => React.ReactNode // generates the React node we want to render (e.g. <App />)
): InjectionConfig {
return {
selector,
mount: ($elm: InjectedReactElement) => {
const $container = rootGenerator($elm);
const root = createRoot($container);
Object.defineProperty($elm, "___reactRoot", {
enumerable: false,
value: root,
});
root.render(reactNode($elm));
},
unmount: ($elm: InjectedReactElement) => {
if (!$elm.___reactRoot) {
return;
}
$elm.___reactRoot.unmount();
},
};
}

The ob­server

The job of ac­tu­ally ob­serv­ing the DOM is also rel­a­tively sim­ple: since each in­jec­tion de­fines the se­lec­tors it is in­ter­ested in, the only job of the ob­server is to keep track of el­e­ments as they are added and re­moved:

class InjectionObserver {
#observer: MutationObserver;
#injections: InjectionConfig[];
#observed: Set<InjectedHTMLElement> = new Set();
constructor(injections: InjectionConfig[]) {
this.onMutations = this.onMutations.bind(this);
this.attach = this.attach.bind(this);
this.detach = this.detach.bind(this);
this.#injections = injections;
this.#observer = new MutationObserver(this.onMutations);
this.#observer.observe(document.documentElement || document.body, {
subtree: true,
childList: true,
});
this.attach(document.documentElement || document.body);
}
disconnect() {
this.#observer.disconnect();
}
attach($elm: HTMLElement) {
this.#injections.forEach((config) => {
const { selector, mount } = config;
const $elms: InjectedHTMLElement[] = Array.from(
$elm.querySelectorAll ? $elm.querySelectorAll(selector) : []
);
if ($elm.matches && $elm.matches(selector)) {
$elms.push($elm);
}
$elms.forEach(($elm) => {
if (!$elm.___attached) {
Object.defineProperty($elm, "___attached", {
value: new Set([config]),
enumerable: false,
configurable: true,
});
mount($elm);
this.#observed.add($elm);
} else if (!$elm.___attached.has(config)) {
$elm.___attached.add(config);
mount($elm);
}
});
});
}
detach($elm: HTMLElement) {
this.#observed.forEach(($mounted) => {
if (!$mounted.___attached) {
return;
}
if ($elm.contains($mounted)) {
$mounted.___attached.forEach((config) => config.unmount?.($mounted));
this.#observed.delete($mounted);
delete $mounted.___attached;
}
});
}
onMutations(records: MutationRecord[]) {
records.forEach((record) => {
record.removedNodes.forEach(this.detach);
record.addedNodes.forEach(this.attach);
});
}
}

The re­sult

This ar­chi­tec­ture is an in­cred­i­bly flex­i­ble way im­ple­ment web ex­ten­sions for SPA web­sites. By giv­ing each in­di­vid­ual fea­ture the abil­ity to eas­ily react to rel­e­vant el­e­ments being added or re­moved, we save our­selves a lot of in­ter­nal de­pen­den­cies.

For an ex­am­ple of how this can be used, see my web ex­ten­sion for Azure De­vOps, which pri­mar­ily uses React for its cus­tom UI, and plain JS for non-​UI DOM mod­i­fi­ca­tions:

Screenshot of the features in an Azure DevOps extension

Here we have a num­ber of fea­tures, each im­ple­ment­ing a dif­fer­ent part of the ex­ten­sion:

Feature #1 - Pinned projects

Uses the reactInjection helper func­tion to in­sert a new React node in the header, when­ever the header is added or re­moved. This React node ren­ders a list of pinned projects. Since projects are pinned from other parts of the ex­ten­sion, they are kept track of in a zu­s­tand-​based state store. This al­lows the ex­ten­sion to up­date the React nodes across React roots, or even from non-​React parts of the ex­ten­sion.

Feature #2 - Correctly rendered errors

Uses a plain in­jec­tion, with no React in­volved. When­ever a new error is ren­dered on the error list it parses the error con­tent, and - if the error con­tains ANSI code - re­places it with a parsed HTML ver­sion of the ANSI error. This is done in plain JS.

Feature #3 - Project pinning

Uses the reactInjection helper func­tion to in­sert a new React node in each side­bar link. The props for this React node is gen­er­ated on a case-​by-case basis, by pars­ing the side­bar link. It con­tains a but­ton that, when clicked, tog­gles the pinned sta­tus of a project in the zu­s­tand store from fea­ture #1.