DEV Community

Emir Mrkaljević
Emir Mrkaljević

Posted on

Your content script ran once. The shop never reloaded: handling single-page navigation in an MV3 extension

ShrinkWatch EU is a small open-source extension that shows the price per kg or litre on European supermarket web shops and warns when a product got smaller than the last time you saw it. In 0.3.5 I fixed a bug that every extension author hits sooner or later.

The bug

On Mercadona's web shop you search for "leche", click a product, and the URL changes to /product/3400/.... But no page load happens. The shop is a single-page app, so it calls history.pushState and redraws.

My content script is injected once, at document_idle. It checked location.pathname against the shop's product pattern, saw /search-results, and quit. So the badge only showed up if you reloaded the product page.

Why not just patch history.pushState?

Content scripts live in an isolated world. Wrapping history.pushState there does not see the page's own calls. You would need to inject a script into the page's main world, which is more code, more review questions from add-on stores, and more ways to break a shop.

What I did instead

  1. Stay alive on the whole shop. The script no longer exits on non-product pages. It decides what to do from a small pure function:
SW.pageMode = (adapter, pathname, forced) => ({
  product: !!forced || !!(adapter.productPath && adapter.productPath.test(pathname)),
  listing: !!adapter.listing,
});
Enter fullscreen mode Exit fullscreen mode
  1. Watch the path, not the URL. A cheap 700 ms check plus a popstate listener for back/forward. Query and hash changes are ignored, because shops love adding tracking parameters.
SW.watchPath = (getPath, onChange, { interval = 700, window } = {}) => {
  let last = getPath();
  const check = () => { const now = getPath(); if (now !== last) { const prev = last; last = now; onChange(now, prev); } };
  const id = setInterval(check, interval);
  window?.addEventListener('popstate', check);
  return { check, stop() { clearInterval(id); window?.removeEventListener('popstate', check); } };
};
Enter fullscreen mode Exit fullscreen mode
  1. Cancel stale work. The product reader retries for a while (prices often render late). Each path change bumps a counter, and an old retry loop that sees a newer counter stops, so it can never paint the previous product's numbers on the new page.

  2. Remove the old badge on every path change, then read again if the new path is a product.

Testing it without a browser

Because the watcher takes getPath, setInterval and window as inputs, node --test can drive it with fake timers and a fake popstate. No headless browser needed for the logic.

Privacy note

None of this makes a network request. The extension only reads the page you are already looking at and keeps history in local storage.

This article was written with AI assistance. The code, tests and examples come from the repo.

Top comments (0)