Preventing page navigation inside a Backbone-driven SPA - backbone.js

The justification
In my BB app, I allow rapid input from users which gets queued & sent off periodically in the background to the server. The problem I currently have is if a user leaves the page they effectively discard any pending changes sitting in the queue.
So basically what I want to do is inform the user before they leave to give them the opportunity to wait for the changes to be saved rather than just exiting & discarding.
The nitty gritty
So for the general cases where the user refreshes or attempts to navigate to an external URL we can handle the onbeforeunload event. Where it becomes slightly tricky is when we are in the context of an SPA whereby switching between pages does not cause a page refresh.
My immediate thought was to use a global click event handler for all anchors and validate whether or not I want to allow the click, which would work for in-site link navigation. However, where this falls over is navigating via the browsers Back/Forward buttons.
I also had a look at Backbone.routefilter, which at first glance appeared to do exactly what I needed. However, using the simple case as described in the docs, the route was still being executed.
The question
How do we intercept navigation for all scenarios within a Backbone SPA?

Direct link navigation
Use a global event handler to capture all click events
$(document).on('click', 'a[href^="/"]', function (e) {
var href = $(e.currentTarget).attr('href');
e.preventDefault();
if (doSomeValidation()) {
router.navigate(href, { trigger: true });
}
});
Page refreshing / external URL navigation
Handle the onbeforeunload event on the window
$(window).on('beforeunload', function (e) {
if (!doSomeValidation()) {
return 'Leaving now will may result in data loss';
}
});
Browser back/forward button navigation
Behind the scenes Backbone.Router uses the Backbone.history which ultimately leverages the HTML5 pushstate API. Depending on what options you pass to Backbone.history.start, and what your browser is capable of, the API will hook into either the onhashchange event or the onpopstate event.
Delving into the source for Backbone.history.start it becomes apparent that regardless of whether you are using push state or not, the same event handler is used i.e. checkUrl.
if (this._hasPushState) {
addEventListener('popstate', this.checkUrl, false);
} else if (this._wantsHashChange && this._hasHashChange && !this.iframe) {
addEventListener('hashchange', this.checkUrl, false);
} else if (this._wantsHashChange) {
this._checkUrlInterval = setInterval(this.checkUrl, this.interval);
}
Therefore, we can override this method & perform our validation in there
var originalCheckUrl = Backbone.history.checkUrl;
Backbone.history.checkUrl = function (e) {
if (doSomeValidation()) {
return originalCheckUrl.call(this, e);
} else {
// re-push the current page into the history (at this stage it's been popped)
window.history.pushState({}, document.title, Backbone.history.fragment);
// cancel the original event
return false;
}
};

Related

Confirmation on browser back button

I'm trying to achieve the following with Gatsby
The user is on a form page, if they hit the browser back button, a pop up would appear, asking if they want to leave.
If the user selects ok, then it would go back.
If the user selects cancel, then it would stay on this page
I was able to "almost" make it happen by doing the following
useEffect(() => {
const confirmExit = e => {
const leaveThisPage = window.confirm("Would you like to leave this page?")
if (!leaveThisPage) {
window.history.forward()
}
}
window.addEventListener("popstate", confirmExit)
return () => {
window.removeEventListener("popstate", confirmExit)
}
}, [])
There is one issue, if the user selects cancel, then the browser would go to the previous page. Then window.history.forward() would fire and sends them back.
I noticed that popstate event cannot be cancelled, so e.preventDefault() won't work.
Note: I also tried to test with window.onbeforeunload, but it only triggers if I close to window, or if my previous is from outside my app. Is there a work around for my issue?
Gatsby is using #reach/router under the hood, which doesn't support intercepting and preventing navigation (emphasis mine):
No history blocking. I found that the only use-case I had was preventing the user from navigating away from a half-filled out form. Not only is it pretty easy to just save the form state to session storage and bring it back when they return, but history blocking doesn’t happen when you navigate away from the app (say to another domain). This kept me from actually using history blocking and always opting to save the form state to session storage. (Source)
I second the recommendation for saving the form state using localStorage.

Detect mobile browser close event for a mobile

I want to perform some operation when my application gets killed. Which method can be used for this? I am working on React Js.
Preventing the whole browser from being killed isn't possible, as that behaviour could easily be exploited by malicious scripts. You can, however, detect when the tab is closed.
Try the window: beforeunload event
window.addEventListener('beforeunload', function (e) {
// Cancel the event
e.preventDefault();
// Chrome requires returnValue to be set
e.returnValue = '';
});

Get user's response for beforeunload event if the user leaves the domain

I have an AngularJS app and use the following code to let the user confirm if s/he really wants to leave the page and might loose unsaved data:
window.addEventListener('beforeunload', function (e) {
e.preventDefault();
e.returnValue = '';
return 'Do you really want to leave?'
});
window.addEventListener('unload', function (e) {
console.log('unload');
myRestService.close();
e.preventDefault();
e.returnValue = '';
});
This works well and the browser displays the message box where the user has to decide to leave or to stay on the page. However, if the user really wants to leave the page, I have to make a call to a rest backend. For that I thought that I can also register for the unload event that is fired when the user actually leaves the back. That works if the user reloads the back or navigates to a different url within the app. However, the event is not fired, or at least I can't see it, when the user navigates to anthoer page or closes the browser.
How can I also catch these events and make sure that my rest call still goes through?
You can call a function before tab close like this:
$window.onbeforeunload = function (arg) {
$scope.onclose();
}
and now you can all your rest api in close function
for more detail and other methods follow this link

React-Router v4 - Prevent Transition With Function

I was able to prevent navigation as per the v4 docs, but I'm trying to hook up a function so that I can use a modal instead of an alert.
Function:
abandonForm = (route) => {
this.props.showModal('confirm');
console.log('leaving..');
}
In my page:
<NavigationPrompt when={true} message={(location) => this.abandonForm('confirm')} />
this.props.showModal('confirm') activates the modal successfully, but behind the modal the page still transitions - how can I prevent transition until a button in the modal is clicked?
Browsers only allow navigation cancellation by means of the alert box that you've mentioned. This restriction is motivated by phishing/scamming sites that try to use javascript gimmicks to create user experiences that convincingly mimic something that a browser or the OS would do (whom the user trusts). Even the format of the text shown in the alert box is crafted so that it's obvious that it originates from the site.
Of course, as long as the current URL stays within your app, you have control over it using react-router's history. For example you can do the following on navigation:
allow the navigation without confirmation
immediately navigate back to the previous location, but now with a modal on top
navigate away for real this time when the user clicks on a button in the modal.
The disadvantage of this approach (leaving out the sheer complexity of it) is that the user will not get a confirmation dialog if they try to navigate to a different site entirely.
Use:
this.unBlock = this.props.history.block((location, navigateToSelectedRoute) => {
// save navigateToSelectedRoute eg this.navigateToSelectedRoute =
// navigateToSelectedRoute;
// use this.navigateToSelectedRoute() afterwards to navigate to link
// show custom modal using setState
});
and when unblocking is done then call this.unBlock() to remove the listener.
Documentation here for history api

Run routing as an event

I have a frontend javascript application built with require.js and backbone.js. Most parts of the application use the standard/recommended way of building application, including routing by using the Backbone Router object.
Now I want to add some more visual changes in one part of the application. Instead of clicking a link and the router render another view, I want some visual changes before that happens. Like GUI-effects happening when clicking the link, then when that effect is complete, the new view should render like before.
I guess one possible way to do this is by hooking a click event to the given link, cancel normal propagation (canceling the route catching in the backbone object), perform the visual stuff, and then manually call the router or render the view directly. Then I would need to have access to the router object from the view (to call the action method that normally catch the click), or I would need to render the view from within the click event added to the link, causing the render code to be duplicated (in the event function and in the view).
Is there a good and tidy way to do something like this, without making ugly spaghetti-code?
You can use the following code to catch all clicks on every link, and then do what you want :
$(document).on('click', 'a:not([data-bypass])', function (evt) {
var href = $(this).attr('href');
var protocol = this.protocol + '//';
if (href.slice(protocol.length) !== protocol) {
evt.preventDefault();
var rootLength = Backbone.history.root.length - ((Backbone.history.root.substring(Backbone.history.root.length - 1) === '/') ? 1 : 0);
// Here before calling the history.navigate that trigger your router
// routes, do your visual effects
Backbone.history.navigate(href.slice(rootLength), {
trigger: true
});
}
});

Resources