preactjs/preact-iso

Blocking a route change

jhlgns opened this issue · 3 comments

I would like to block route changes under some condition, for example when then user has entered data that has not yet been saved.
For plain HTML/JS there is window.addEventListener("beforeunload", e => { if (condition) e.preventDefault() }).
However, I don't see a way to block route changes in this library yet.
It would be cool to have something like

<Router canRoute={e => someCallbackReturningABoolean(e)}>
    ...
</Router>

Is there currently a way to achieve this or is this planned to be implemented?
Thanks in advance!

Add an event listener for intercepting 'click' and 'popstate' events, blocking them if your user has unsaved work.

To elaborate a bit:

const block = true; // use local storage or perhaps a global var

function blockOrAllow(event) {
    if (block) {
        if (!confirm('Are you sure you want to leave?')) {
            event.preventDefault();
            event.stopImmediatePropagation();
        }
    }
}

if (typeof window !== 'undefined') {
    addEventListener('click', blockOrAllow);
    addEventListener('popstate', blockOrAllow);
}

Just set this before you go to render LocationProvider and you should be good to go.

Awesome, I came up with small utility to conveniently block route changes with links and beforeunload/popstate.
This doesn't handle manual calls to LocationHook.route though.
But that is solvable - I will just make a wrapper around that and make sure to never call the underlying implementation.

import { useEffect } from "preact/hooks";

let _shouldBlock: (() => string | null) | null = null;
let _pageBlockerWasSetUp = false;

// Call this before the LocationProvider gets rendered
export function setupPageBlocker() {
    if (_pageBlockerWasSetUp) {
        console.debug("The page blocker was already set up");
    }

    _pageBlockerWasSetUp = true;

    const listener = (event: Event) => {
        if (_shouldBlock == null) {
            return;
        }

        const link = event.type != "click" ? null : (event.target as HTMLElement).closest("a[href]") as HTMLLinkElement | null;
        if (event.type == "click" && link == null) {
            return;
        }

        const message = _shouldBlock();
        if (message == null) {
            return;
        }

        console.debug("Asking user for confirmation on event of type", event.type, "on", event.target, "because the page blocker is active, event:", event);

        const confirmed = confirm(message);
        if (confirmed) {
            console.debug("The confirmation was given, allowing the event to pass");

            // Disable the blocker if the event is a click event and confirmed by the user
            // because next, beforeunload happens which should not need to be confirmed again
            if (event.type == "click" && link != null) {
                const href = link.attributes.getNamedItem("href")!.value;
                const isExternalLink = new URL(document.baseURI).origin !== new URL(href, document.baseURI).origin;
                if (isExternalLink) {
                    _shouldBlock = null;
                }
            }

            return;
        }

        console.debug("Confirmaton was not given, blocking the event");

        event.preventDefault();
        event.stopImmediatePropagation();
    }

    window.addEventListener("click", listener);
    window.addEventListener("beforeunload", listener);
    window.addEventListener("popstate", listener);
}

export function useRouteBlocker(shouldBlock: () => string | null) {
    useEffect(() => {
        _shouldBlock = shouldBlock;
        return () => _shouldBlock = null;
    }, []);
}

Yup, something along these lines would be the recommendation.