Userscripts are a powerful way to take back control of the web, but their design hasn’t kept pace with the modern web. In particular, they struggle with content loaded in the background—think single page apps, HTMX, and infinite scroll. Recall that userscripts are designed to run at page load, and while there are several options as defined by @run-at
, none of them handle dynamic content.
The secret is to getting them to work is:
- Expand your
@match
for all possible URLs - Hijack
fetch
and/orXMLHttpRequest
- Emit your own page load event
Hijacking XMLHttpRequest
The pseudocode for triggering your userscript looks like:
// Create our own event
///////////////////////
const scriptEventTarget = new EventTarget();
function fireNewContent() {
scriptEventTarget.dispatchEvent(
new CustomEvent("newContent", { detail: { timestamp: Date.now() } }),
);
}
let throttleTimer;
/* Throttle new results events to debounce newContent event */
function throttledNewContent() {
if (throttleTimer) {
clearTimeout(throttleTimer);
}
throttleTimer = setTimeout(() => {
fireNewContent();
throttleTimer = null;
}, 500);
}
// Patch XMLHttpRequest
///////////////////////
const OriginalXMLHttpRequest = unsafeWindow.XMLHttpRequest;
// Override XMLHttpRequest constructor to catch AJAX requests
unsafeWindow.XMLHttpRequest = function () {
const xhr = new OriginalXMLHttpRequest();
xhr.addEventListener("readystatechange", function () {
if (this.readyState === 4 && this.status === 200) {
// Check if response is HTML content
const contentType = this.getResponseHeader("content-type");
if (contentType && contentType.includes("text/html")) {
throttledNewContent();
}
}
});
return xhr;
};
// Preserve prototype chain
unsafeWindow.XMLHttpRequest.prototype = OriginalXMLHttpRequest.prototype;
scriptEventTarget.addEventListener("newContent", function (event) {
console.log("New content found:", event.detail);
main();
});
Hijacking fetch
Newer code may use native Fetch instead AJAX. Replacing or adding this to the code above, the pseudocode for fetch
looks something like:
const { fetch: originalFetch } = unsafeWindow;
unsafeWindow.fetch = async (...args) => {
let [resource, config] = args;
const response = await originalFetch(resource, config);
const contentType = response.headers.get("content-type");
if (contentType && contentType.includes("text/html")) {
throttledNewContent();
}
return response;
};
Next steps
The approach I took was to assume getting any html content back should trigger my userscript. You could also look for different content types, or look for changes to location.href
. Figuring out the filter criteria is important; these intercepts will fire for everything including analytics. So far, I’ve only had to use XMLHttpRequest
so my fetch
example is lacking. I hope to make this code more generic and reusable as I start writing more userscripts that take advantage of this.
One last tip is: you may not need this at all! I don’t know what you’re trying to do, but remember event bubbling exists which means sometimes all you need is an event listener on a root element like <body>
instead of trying to find dynamically created targets.