flow-router: Prevent user from exiting a route

For example, say the user is halfway through typing a message in a blog post; if they click on a link that could take them away from the route, we need the ability to cancel the route change. Which we do, using triggersExit and stop().

However, the reality is more complex; we don’t want to stop the user from changing routes, only warn them that they will lose data if they do and give them a cancel option. So we popup a modal with a confirm / cancel option. The problem now is that this requires a callback, so the triggersExit function returns without having called stop() and thus continues, regardless of the user choice.

Could we not have a continue() function that must be called in order for the route to change, or alternatively a callback(cancel) type method structure?

About this issue

  • Original URL
  • State: open
  • Created 9 years ago
  • Comments: 22 (5 by maintainers)

Most upvoted comments

I have put together the following workaround for this problem that effectively ‘prevents’ route changes when needed, e.g. when the user has unsaved changes. This allows other code to rely on the router to enforce handling unsaved changes, without needing to run additional checks.

It seems we can’t simply stop() unwanted route changes without having an inconsistent address bar, so my approach ‘rewinds’ the route change as cleanly as possible. It copes nicely with the user pressing the browser back button.

In the code below, If there are unsaved changes, preventRouteChange() uses window.confirm() to prompt the user synchronously, using the browser’s built-in prompt. This is the most robust approach because it blocks the viewport and prevents the user from clicking the back/forward buttons. Alternatively, you might use setTimeout to fire off an asynchronous warning prompt of your own design after the route change has been prevented: targetContext provides the user’s intended destination for your ‘yes’ callback. But beware, if you do this, the user can still press the back/forward buttons while your prompt is displayed! So be sure to handle that if necessary.

Where appropriate, I recommend combining this solution with window.onbeforeunload which will also catch attempts to navigate away from the app - e.g. closing the browser window. Another benefit of using window.confirm() is that the prompt looks and functions the same as the window.onbeforeunload prompt, which means you have the same user experience for both navigating away and closing the window.

Obviously, it would be better if it were possible to prevent route changes globally, but this is currently not possible.

One last point that came up with all of this: Perhaps FlowRouter should detect dangerous calls to go() within route triggers, and handle them better - e.g. queue them for execution, or abort current route handling and move to the new route requested by go()? My workaround was to wrap the call in setTimeout().

I’d be interested to hear your comments.

// Prevent routing when there are unsaved changes
// ----------------------------------------------

// This function will be called on every route change.
// Return true to 'prevent' the route from changing.
function preventRouteChange (targetContext) {
  if (Session.get('unsavedChanges')) {
    if (!window.confirm('Unsaved changes will be lost. Are you sure?')) {
      return true;
    }
    Session.set('unsavedChanges', false);
  }
  return false;
}

// Workaround FlowRouter to provide the ability to prevent route changes
var previousPath,
  isReverting,
  routeCounter = 0,
  routeCountOnPopState;

window.onpopstate = function () {
  // For detecting whether the user pressed back/forward button.
  routeCountOnPopState = routeCounter;
};

FlowRouter.triggers.exit([function (context, redirect, stop) {
  // Before we leave the route, cache the current path.
  previousPath = context.path;
}]);

FlowRouter.triggers.enter([function (context, redirect, stop) {
  routeCounter++;

  if (isReverting) {
    isReverting = false;
    // This time, we are simply 'undoing' the previous (prevented) route change.
    // So we don't want to actually fire any route actions.
    stop();
  }
  else if (preventRouteChange(context)) {
    // This route change is not allowed at the present time.

    // Prevent the route from firing.
    stop();

    isReverting = true;

    if (routeCountOnPopState == routeCounter - 1) {
      // This route change was due to browser history - e.g. back/forward button was clicked.
      // We want to undo this route change without overwriting the current history entry.
      // We can't use redirect() because it would overwrite the history entry we are trying
      // to preserve.

      // setTimeout allows FlowRouter to finish handling the current route change.
      // Without it, calling FlowRouter.go() at this stage would cause problems (we would
      // ultimately end up at the wrong URL, i.e. that of the current context).
      setTimeout(function () {
        FlowRouter.go(previousPath);
      });
    }
    else {
      // This is a regular route change, e.g. user clicked a navigation control.
      // setTimeout for the same reasons as above.
      setTimeout(function () {
        // Since we know the user didn't navigate using browser history, we can safely use
        // history.back(), keeping the browser history clean.
        history.back();
      });
    }
  }
}]);

Up here, Do we have an “official way” to do this now ?

This works for me:

function wrapRouting(original) {
    return (...args) => {
        if(PageHelper.isRoutingAllowed())
            return original.apply(null, args);
    }
}

FlowRouter._page.redirect = wrapRouting(FlowRouter._page.redirect);
FlowRouter._page.show = wrapRouting(FlowRouter._page.show);

I have reworked the solution in my previous comment and added more notes.