DevPause

How a Chrome extension survives switching chats on ChatGPT, Claude and Gemini without a page reload

Click from one ChatGPT conversation to another in the sidebar and the URL changes, the message list changes, but the page never reloads. That is true on Claude and Gemini too — all three are single-page apps that use history.pushState and client-side rendering instead of a real navigation. It creates a specific problem for a Chrome extension: Manifest V3 content scripts are injected once per page load, matched by URL, at a fixed moment (document_idle in DevPause's case). If the page never reloads, the content script never runs again — whatever code answered the first chat has to be the same code that answers the fifth one, twenty minutes later, after the DOM has been torn down and rebuilt by React, Claude's own framework, and Angular, respectively.

The content script runs once; the DOM changes forever

DevPause's manifest.json declares this, like any content script:

"content_scripts": [{
  "matches": [
    "https://chatgpt.com/*",
    "https://claude.ai/*",
    "https://gemini.google.com/*"
  ],
  "js": ["src/plain.js", "src/content.js", "src/insert.js", "src/tools.js"],
  "run_at": "document_idle",
  "all_frames": false
}]

run_at: document_idle means Chrome injects these files once, shortly after the initial page load finishes. There is no hook for "the client-side router changed the URL" in the content-script lifecycle — because from Chrome's point of view nothing navigated. The only way the script keeps working after that is if it never stops running: no DOMContentLoaded-only logic, no one-shot scan, just a listener that stays alive for as long as the tab's JavaScript context exists, which on an SPA is "until the user closes the tab or hits refresh."

The actual mechanism: one observer, started once

The last lines of tools.js are the whole answer:

chrome.storage.local.get(["lang", "wide", "font", "autosend"], (v) => {
  // ...read settings...
  scan([document.body]);
  new MutationObserver(onMutations).observe(document.body, { childList: true, subtree: true });
});

This runs exactly once, right after injection. The MutationObserver it creates does not: it keeps firing for the entire life of the tab, regardless of how many times the chat changes underneath it. Every DOM mutation React (ChatGPT), Claude's renderer, or Angular (Gemini) makes while swapping one conversation for another is a childList mutation under document.body, and the observer sees every one of them — that is the whole trick. The extension does not need to know that a navigation happened; it only needs to notice that new matching nodes appeared, which is true whether they appeared because of a navigation or because the AI is still streaming the current answer.

Collecting nodes without rescanning the whole page

A naive version of this would call querySelectorAll over the whole document on every mutation, which is expensive and would re-run on every character the AI streams in. Instead:

const onMutations = (muts) => {
  for (const m of muts) for (const n of m.addedNodes) if (n.nodeType === 1 && !n.hasAttribute("data-dp-bar")) pending.add(n);
  if (pending.size && !scanTimer) scanTimer = setTimeout(scan, SCAN_MS);
};

Added nodes queue up in a Set (deduplicated automatically, since a Set of DOM elements is referential), and the actual scan is debounced by 600 ms (SCAN_MS) so a burst of mutations — typical of a chat switch, where a whole message list mounts at once — triggers one scan, not dozens. The one exclusion, !n.hasAttribute("data-dp-bar"), stops the extension's own inserted button bars from being queued as if they were new chat content, which would otherwise create an infinite mutation loop: inserting a bar is itself a DOM mutation the observer would see.

Marking answers so they are never doubled

When a chat is reopened, its messages are typically remounted as fresh DOM nodes — not the same elements as before, because the framework re-renders from its own state rather than reusing detached nodes. That means "has this answer already got a bar?" cannot be answered by object identity alone across a navigation. DevPause answers it with both a WeakSet for the current node and a DOM attribute that survives as long as the node does:

const done = new WeakSet();
function mark(el) {
  if (done.has(el) || el.hasAttribute("data-dp-msg")) return;
  if (el.parentElement && el.parentElement.closest("[data-dp-msg]")) return;
  done.add(el);
  el.setAttribute("data-dp-msg", "");
  el.insertAdjacentElement("afterend", makeBar());
}

The WeakSet check is the fast path inside one page's lifetime; the data-dp-msg attribute check is the one that matters for a node that got added, removed, and somehow reappeared (some frameworks keep off-screen panes mounted). The parent-closest check guards against a different failure mode: some sites wrap one answer in nested elements that each match the message selector, which would otherwise produce two stacked bars under one answer.

Stale nodes from optimistic UI

Chat UIs frequently render an element immediately (optimistic UI) and then replace it once the real data arrives — a pattern that fires an addedNodes mutation for a node that is gone by the time the debounced scan runs. scan() filters these out before touching them:

function scan(roots) {
  const list = roots || [...pending];
  pending = new Set();
  for (const root of list) {
    if (!root.isConnected) continue;
    // ...mark matches inside root...
  }
}

root.isConnected is a plain DOM property: true only if the node is still attached to the document. Skipping disconnected nodes avoids inserting a button bar next to an element that has already been discarded, which would otherwise leave an orphaned bar with nothing to attach to visually.

Three frameworks, one selector table

The part that does change per site is what counts as "an answer," because each product's markup is different:

SiteFramework (visible from markup)Answer selector (cfg.msg)
chatgpt.comReact, custom data attributes[data-message-author-role="assistant"]
claude.aiReact-like renderer, utility classes[data-testid="assistant-message"], .font-claude-message, div[data-is-streaming]
gemini.google.comAngular, custom elementsmodel-response

Gemini's case is worth a note on its own: model-response is a custom element, not a class or data attribute, which is a reliable tell of an Angular (or any Web Components-based) app — and it matters for this code because element.matches("model-response") and root.querySelectorAll("model-response") work exactly the same way they would for a div. The observer and the scan logic above do not know or care which framework rendered a node; they only call the standard DOM methods every element exposes, which is why one observer, one debounce, and one marking scheme cover three unrelated rendering stacks without a single if (site === ...) branch inside the mutation-handling code itself — that branch happens once, earlier, when cfg is picked for the current hostname.

What resets on a chat switch, and what does not

Because the script never stops running, module-level state like the chosen language, wide-screen toggle and font size (s.lang, s.wide, s.font) is never re-read from scratch on a chat switch — it already lives in memory from when the page loaded, and applyView() re-applies it to the whole document, not to a specific message, so a newly mounted answer picks up the current wide-screen state automatically without any per-message code. What does reset implicitly is the "last" button bar used by the keyboard shortcuts: it is recomputed from document.querySelectorAll("[data-dp-msg]") every scan, which only returns currently attached nodes — so after switching chats, Alt+Shift+P ("Continue") correctly targets the latest answer in the chat that is now on screen, not a bar left over from the previous one, because the previous one's nodes are no longer connected to the document at all.

Frequently asked questions

Does this mean the extension keeps running even after I close a chat?

It keeps running for as long as the tab stays open on one of the three supported sites, since that is the same JavaScript context the whole time. Closing the tab, or navigating to a different site entirely, ends it the normal way a page's scripts end.

Why not just re-inject the content script on route changes?

Manifest V3 has no API for "re-run my content script when the SPA's router fires," and polling location.href to fake one would add a second detection mechanism next to the one the buttons already need (watching for new message nodes). Watching the DOM directly covers both at once.

Could a chat's own re-render ever remove a button bar DevPause added?

Only if the framework re-renders the DOM subtree the bar was inserted into, in which case the next scan simply reinserts it after the answer reappears — the dedupe logic above is exactly what makes that safe to do unconditionally.

DevPause is a free Chrome extension that adds utility buttons under ChatGPT, Claude and Gemini answers — clean copy, shorter, simpler, as a table, continue, and a wide-screen toggle. It is ad-supported (one small text line while an answer streams) rather than subscription-based, and the observer and selectors discussed above are the real, current code it ships with.

Install DevPause