{"id":969,"date":"2025-07-17T06:25:08","date_gmt":"2025-07-17T06:25:08","guid":{"rendered":"https:\/\/www.crccheck.com\/blog\/?p=969"},"modified":"2025-07-17T14:10:29","modified_gmt":"2025-07-17T14:10:29","slug":"userscripts-at-the-spa","status":"publish","type":"post","link":"https:\/\/www.crccheck.com\/blog\/userscripts-at-the-spa\/","title":{"rendered":"Userscripts at the SPA"},"content":{"rendered":"\n<p>Userscripts are a powerful way to take back control of the web, but their design hasn&#8217;t kept pace with the modern web. In particular, they struggle with content loaded in the background\u2014think 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 <code><a href=\"https:\/\/www.tampermonkey.net\/documentation.php?locale=en#meta:run_at\">@run-at<\/a><\/code>, none of them handle dynamic content.<\/p>\n\n\n\n<p>The secret is to getting them to work is:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Expand your <code>@match<\/code> for all possible URLs<\/li>\n\n\n\n<li>Hijack <code>fetch<\/code> and\/or <code>XMLHttpRequest<\/code><\/li>\n\n\n\n<li>Emit your own page load event<\/li>\n<\/ol>\n\n\n\n<h2 class=\"wp-block-heading\">Hijacking XMLHttpRequest<\/h2>\n\n\n\n<p>The pseudocode for triggering your userscript looks like:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ Create our own event\n\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\n\nconst scriptEventTarget = new EventTarget();\n\nfunction fireNewContent() {\n  scriptEventTarget.dispatchEvent(\n    new CustomEvent(\"newContent\", { detail: { timestamp: Date.now() } }),\n  );\n}\n\nlet throttleTimer;\n\/* Throttle new results events to debounce newContent event *\/\nfunction throttledNewContent() {\n  if (throttleTimer) {\n    clearTimeout(throttleTimer);\n  }\n\n  throttleTimer = setTimeout(() => {\n    fireNewContent();\n    throttleTimer = null;\n  }, 500);\n}\n\n\/\/ Patch XMLHttpRequest\n\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\n\nconst OriginalXMLHttpRequest = unsafeWindow.XMLHttpRequest;\n\n\/\/ Override XMLHttpRequest constructor to catch AJAX requests\nunsafeWindow.XMLHttpRequest = function () {\n  const xhr = new OriginalXMLHttpRequest();\n  xhr.addEventListener(\"readystatechange\", function () {\n    if (this.readyState === 4 &amp;&amp; this.status === 200) {\n      \/\/ Check if response is HTML content\n      const contentType = this.getResponseHeader(\"content-type\");\n      if (contentType &amp;&amp; contentType.includes(\"text\/html\")) {\n        throttledNewContent();\n      }\n    }\n  });\n  return xhr;\n};\n\n\/\/ Preserve prototype chain\nunsafeWindow.XMLHttpRequest.prototype = OriginalXMLHttpRequest.prototype;\n\nscriptEventTarget.addEventListener(\"newContent\", function (event) {\n  console.log(\"New content found:\", event.detail);\n  main();\n});<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Hijacking fetch<\/h2>\n\n\n\n<p>Newer code may use native Fetch instead AJAX. Replacing or adding this to the code above, the pseudocode for <code>fetch<\/code> looks something like:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>const { fetch: originalFetch } = unsafeWindow;\nunsafeWindow.fetch = async (...args) => {\n  let &#91;resource, config] = args;\n  const response = await originalFetch(resource, config);\n  const contentType = response.headers.get(\"content-type\");\n  if (contentType &amp;&amp; contentType.includes(\"text\/html\")) {\n    throttledNewContent();\n  }\n  return response;\n};<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Next steps<\/h2>\n\n\n\n<p>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 <code>location.href<\/code>. Figuring out the filter criteria is important; these intercepts will fire for <strong>everything<\/strong> including analytics. So far, I&#8217;ve only had to use <code>XMLHttpRequest<\/code> so my <code>fetch<\/code> example is lacking. I hope to make this code more generic and reusable as I start writing more userscripts that take advantage of this.<\/p>\n\n\n\n<p>One last tip is: <em>you may not need this at all!<\/em> I don&#8217;t know what you&#8217;re trying to do, but remember <a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Learn_web_development\/Core\/Scripting\/Event_bubbling\">event bubbling exists<\/a> which means sometimes all you need is an event listener on a root element like <code>&lt;body><\/code> instead of trying to find dynamically created targets.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Userscripts are a powerful way to take back control of the web, but their design hasn&#8217;t kept pace with the modern web. In particular, they struggle with content loaded in the background\u2014think single page apps, HTMX, and infinite scroll. Recall that userscripts are designed to run at page load, and while there are several options&hellip;<\/p>\n <a href=\"https:\/\/www.crccheck.com\/blog\/userscripts-at-the-spa\/\" title=\"Userscripts at the SPA\" class=\"entry-more-link\"><span>Read More<\/span> <span class=\"screen-reader-text\">Userscripts at the SPA<\/span><\/a>","protected":false},"author":2,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"Layout":"","footnotes":""},"categories":[5,4],"tags":[12],"class_list":["entry","author-showmewhatyougot","post-969","post","type-post","status-publish","format-standard","category-drafts","category-technical","tag-js"],"_links":{"self":[{"href":"https:\/\/www.crccheck.com\/blog\/wp-json\/wp\/v2\/posts\/969","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.crccheck.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.crccheck.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.crccheck.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/www.crccheck.com\/blog\/wp-json\/wp\/v2\/comments?post=969"}],"version-history":[{"count":8,"href":"https:\/\/www.crccheck.com\/blog\/wp-json\/wp\/v2\/posts\/969\/revisions"}],"predecessor-version":[{"id":980,"href":"https:\/\/www.crccheck.com\/blog\/wp-json\/wp\/v2\/posts\/969\/revisions\/980"}],"wp:attachment":[{"href":"https:\/\/www.crccheck.com\/blog\/wp-json\/wp\/v2\/media?parent=969"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.crccheck.com\/blog\/wp-json\/wp\/v2\/categories?post=969"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.crccheck.com\/blog\/wp-json\/wp\/v2\/tags?post=969"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}