Improved `wxt:locationchange`
Feature Request
Currently, this event is powered by polling the current URL every second. This means it's slow to react to URL changes.
Unfortunately, it's not as easy as monkeypatching the history APIs:
const ogPushState = history.pushState;
history.pushState = (...args) => {
const result = ogPushState.apply(history, args);
// do something
return result;
};
This doesn't work in isolated content scripts. history.pushState in the content script is not the same function as history.pushState in the main world.
So that means we need to inject a script to monkey-patch these APIs.
Something like this:
// wxt-locationchange.ts
export default defineUnlistedScript(() => {
const ogPushState = history.pushState;
const ogReplaceState = history.replaceState;
let prevUrl = location.href;
function dispatchNavigationEvent() {
const newUrl = location.href;
if (newUrl !== prevUrl) {
window.dispatchEvent(
new CustomEvent('wxt:locationchange', { detail: { prevUrl, newUrl } }),
);
}
prevUrl = newUrl;
}
// Monkeypatch SPA navigation in main world
history.pushState = (...args) => {
const result = ogPushState.apply(history, args);
dispatchNavigationEvent();
return result;
};
history.replaceState = (...args) => {
const result = ogReplaceState.apply(history, args);
dispatchNavigationEvent();
return result;
};
// Listen to back/forward button clicks
window.addEventListener('popstate', dispatchNavigationEvent);
});
// content.ts
export default defineContentScript({
// ...
main(ctx) {
injectScript("/wxt-locationchange.ts");
ctx.addListener(window, "wxt:locationchange", (event) => {
console.log("Locaiton changed!", event.detail);
});
},
});
WXT should be able to generate this wxt-locationchange.js file for you, and include it in your build when you need it. Then when you call ctx.addEventListener(window, "wxt:locationchange", () => { ... }), WXT should inject the monkeypatching script instead of polling.
Downside to this approach is that we're modifying global variables... Which is frowned upon in general. Not really sure what else we can do about that 🤷
Is your feature request related to a bug?
https://discord.com/channels/1212416027611365476/1357508502804758649
What are the alternatives?
None others that I'm aware of.
Additional context
Related to #1029. Could simply be that when a content script has spa: true in it's definition that WXT adds this logic.
FWIW, I do this exact monkey-patching in the main world context in order to detect SPA navigations in Browserflow and it works well!
In addition to popstate, you'll probably want to add a listener to hashchange as well.
Here's the code I have in Browserflow if it helps:
/**
* Dispatches a "locationchange" event on client-side navigations
*
* - Adds listeners to "hashchange" and "popstate"
* - Patches history.pushState and history.replaceState
*
* This script must be run in the main world (the context of the host page) in order for the patching to work
*/
export function observeUrl(): () => void {
const dispatch = () => window.dispatchEvent(new Event("locationchange"));
function patchHistoryScript(): void {
function patchedMethod<T extends (...args: any[]) => any>(
method: T,
callback: (...args: Parameters<T>) => void,
): T {
return function (this: any, ...args: Parameters<T>) {
const value: ReturnType<T> = method.apply(this, args);
callback(...args);
return value;
} as T;
}
(window.history as any).pushStateOriginal = window.history.pushState;
(window.history as any).replaceStateOriginal = window.history.replaceState;
window.history.pushState = patchedMethod(
window.history.pushState,
dispatch,
);
window.history.replaceState = patchedMethod(
window.history.replaceState,
dispatch,
);
}
function restoreHistoryScript(): void {
window.history.pushState = (window.history as any).pushStateOriginal;
window.history.replaceState = (window.history as any).replaceStateOriginal;
}
window.addEventListener("hashchange", dispatch);
window.addEventListener("popstate", dispatch);
patchHistoryScript();
return () => {
window.removeEventListener("hashchange", dispatch);
window.removeEventListener("popstate", dispatch);
restoreHistoryScript();
};
}
I haven't looked at the existing implementation for this so it might already be covered, but in case it's helpful: One thing to note is that this listener needs to be cleaned up when the content script is removed (e.g. if the extension is invalidated or the same content script is injected again — IIRC, WXT automatically handles the former but not the latter) or else the locationchange/wxt:locationchange will be fired multiple times.
Another option would be to utilize the Navigation API where it's supported. It's been in all Chromium-based browsers for three years.