Detecting page change in userscripts

This is more of a bugfixing help thread, which I’m not sure if it’s an appropriate thread to make, but I imagine many other userscript developers are encountering the same problem as me after the latest dashboard update.
Basically, the problem is that Wanikani now does everything on the same webpage - e.g. when you click on the Review button to start a quiz, you don’t actually move to a new page. Instead, the site modifies the page URL and all the page content.
This is a problem for userscripts, because userscripts are only injected when you move to a new page. So if your userscript is set to only run on pages with URL that matches wanikani.com/subjects/review, it will not load if you first visit the dashboard page and then click on the Review button. This can be solved by manually refreshing the page once you start your reviews, but that’s not an ideal solution and userscripts should be able to handle this.
So, I’m looking for a way to detect when the user has clicked on the Review button, or some other button on the page, or anything that changes the URL, so that the userscript knows to run whenever this occurs. Some googling has suggested the “navigate” event which triggers when the URL changes, but it’s not supported on Firefox or Safari, which is a dealbreaker for me. Does anyone know of any alternatives?

3 Likes

You should be able to listen for the turbo:load event, although I am having a bit of trouble with the elements on the page not being loaded yet with that or with turbo:render… But it’s a start

1 Like

I think the approach is basically:

  1. Set your pages to all of wanikani.
  2. Listen to DOMContentLoaded, check if it’s your target page and load if so.
  3. Listen to turbo:load (on document.documentElement) and run your script if the url property of that event is the same as your target page

Probably worth getting this into wkof ultimately.

1 Like

I’ve used this in the past when I needed to wait for something to exist.

1 Like

Thank you, this also explains why I also need to refresh my dashboard when I get back from reviews to make my heatmap show back up. I go to reviews, refresh to get double check to work, then get back from reviews and have to refresh again to get heatmap to work. Frustrating but ultimately workable.

3 Likes

The most reliable method I’ve seen to deal with this is the way Confusion Guesser does it with a mutation observer on the document body. I recommend looking through the code of that script.

2 Likes

Ah that must be exactly why my script sometimes doesn’t show on the page until I manually refresh (and sometimes it does). When I’m coming from another ‘page’ like just after I finish a review set, it doesn’t show. That was driving me nuts.

Though I agree with you, I think if this were to be updated in WKOF, the fundamental usage of all module inclusion and their ready states needs to be updated.
That is to say (and correct me if I’m wrong @rfindley), since the normal method of inclusion is wkof.ready(moduleName).then(callback), things are only configured to run once, and that cannot change, by definition of how promises work.

But since things are getting unloaded without a real page refresh, all hell breaks loose.
There are multiple ways this could be solved, but imo, if wkof.ready(module_list) more or less worked the way wkof.wait_state(state_var, value, callback, persistent) with persistent set to true works (e.g., something like wkof.on_ready(module_list, callback)), then all of the complicated turbo loading logic could be left to wkof, while all that general scripts would need to change is their callback invocation.

My script uses this extensively. Here’s the source: wk-keyboard-focus/index.ts at main · bfricka/wk-keyboard-focus · GitHub

Slowly wrapping my head around that, but my naive attempt (without straight up copy-pasting something I don’t understand) keeps firing twice. I’m assuming, initial cached version, and then on refresh? Seems to be working fine after I add a check before the script even runs that my custom html isn’t already on the page.

1 Like

There shouldn’t be anything wrong with your approach, it is just the case that Wanikani fires certain events involved in the answering process twice at times (and if you have double-check going this is exacerbated further by that script re-emitting some events). So including a check is the right way to go.

1 Like

Yeah it just seems unnecessary to run the whole thing, and then again a quarter-second later. But at least it’s working every time and also not putting two copies on the page.

1 Like

I just remembered I have my own personal script that adapted for this issue to show the current SRS stage of an item in the statistics element during reviews. So I can actually just show that instead of pointing you to Confusion Guesser’s code:

    window.addEventListener("turbo:before-render", async (e) => {
        let observer = new MutationObserver(m => {
			if (m[0].target.childElementCount > 0) return;
			observer.disconnect();
			observer = null;
			setup(true);
		});
		observer.observe(e.detail.newBody, {childList: true});
    });

The setup(fromTurbo = false) { ... } function takes that argument to do things slightly differently in the event that we are in a turbo refresh. This has been the most reliable method for me with dealing with this issue.

Edit: the second reason I’m making this comment is because I just remembered that it is not Confusion Guesser I used, but Later Crabigator. Confusion Guesser does use a mutation observer, but it doesn’t observe the document body.

3 Likes

So I didn’t (yet) fully understand Confusion Guesser’s code (saved for the weekend) but I hacked up something a little simpler? I think? That still works. I’d be interested in hearing what’s wrong with my way (which I have no trouble believing, so don’t be afraid to hurt my feelings).

I basically assume that the first time you go to wanikani, it’s not a turbo visit. The script runs the normal way, and at the end of it I have:

// Turbo sometimes re-visits the dashboard without a real page reload
// Create a new MutationObserver instance with a callback function
    const observer = new MutationObserver(callback);
    function callback(mutationsList, observer) {
        if (document.URL.endsWith("wanikani.com/") || document.URL.endsWith("/dashboard")) {
            observer.disconnect();
            observer = null;
            run();
        }
        // else do nothing, keep observing
    }
    const config = { childList: true };
    observer.observe(document.body, config);

The idea is to leave the observer running after the script is done doing it’s work, to restart it if turbo navigates away and then back. So I’m not using before-render or newBody at all.

BUT! I also have the before-mentioned problem of it running twice, once when the page is pulled from the cache, and again when it updates from the server. (oddly enough not always, just unpredictably sometimes)

1 Like

I wouldn’t really say this is any simpler, the only real change is that your observer isn’t inside an event listener which itself is fairly simple. The difference is that as you have it now, this function will only care about navigation back to the dashboard. You ideally want it to be able to handle navigation to any page on the site with a turbo render, so to that end listening for the before-render event is the way to go (you would then take the approach of your run() function checking the url and only executing on the correct ones + doing additional logic for turbo loads if necessary).

The other thing I’ve learned from dealing with this issue (not just turbo and not just Wanikani, but React and other frameworks on other sites) is that checking the url in the observer is unreliable as many of these frameworks are able to change the page without changing the url. In this instance though we aren’t dealing with that, so that’s why checking for the right url in the run() function would work.

For my script, I check for the count of children on the new body because I noticed that the first time it observes the body the body is empty, the second time it is populated and that’s when I want to inject things. Really the condition for injection will depend on your script but if you need to be sure that the elements you need for selectors are on the page, I recommend checking for them or more generically for child element count like I have.

3 Likes

So start the script on every page navigation, but exit immediately if it’s not the right target page. Yeah that’s different. In my case, I actually don’t want it to run on any page other than the dashboard, but I guess hypothetically someone’s first navigation to wanikani might be some page other than the dashboard, and then my script wouldn’t run even when they navigated (via turbo) TO the dashboard. Interesting.

1 Like

Yeah unfortunately with turbo you have to design the script to run on all pages. But the good thing is the script won’t be injected every navigation.

2 Likes

You certainly don’t want to try to set observer to null if you use const. Additionally, you’re shadowing the variable, so that’s not going to null out main one. In general, you probably don’t need to clean up the MutationObserver instance anyway. As long as you disconnect, you’ve done enough. But if you’re really worried about it:

let observer = new MutationObserver((mutationsList) => {
  // Do stuff then clean up
  observer.disconnect()
  observer = null
})
2 Likes

Huh well that seems obvious now, wonder why i didn’t get an error? :smiley:
Thanks for the tips.

1 Like

You know, now that you mention it I have no idea why Later Crabigator tries to set a const to null. I don’t think I’ve actually examined this piece of code since learning much more javascript than when I first copy-pasted and adapted it. Thanks for the sanity check.

Edit: oh wait it doesn’t, I’m just sleepy and thought the code you quoted was my code. Sorry ><

1 Like