Userscripts at the SPA

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:

  1. Expand your @match for all possible URLs
  2. Hijack fetch and/or XMLHttpRequest
  3. 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.

Leave a Reply

Your email address will not be published. Required fields are marked *