How to intercept browser navigation and prompt user in React (including Browser Navigation Away and Tab close)? - javascript

Here's my scenario:
I have an editor in a React component that might have unsaved data.
The only way to know this, for now, is to call a function that returns a boolean.
So I have to determine if the component has unsaved data when user is trying to 1. navigate away 2. pressed any browser navigation buttons 3. Closed the tab or window.
I'm using React Router 5
I have tried using Prompt like this:
<Prompt
when={this.getIsEditorDirty()}
message={location => `Are you sure you want to quit editing ?`}
/>
But when only takes the value as state as expected not through a function. So it doesn't behave correctly.
Since if the editor is dirty or not can be only be known through a function (assuming getIsEditorDirty()), I don't seem to find any proper way to intercept any kind of user navigation.
What could be the possible solution or way around here ?

I don't know if it's still the case in Router 5, but in 4 I do it by returning true from function passed as "message" prop if the editor is "clean".
getBlockMessage = () => {
return this.isChanged() ? LEAVE_MSG : true;
}
// ...
<Prompt message={this.getBlockMessage}/>

Here's the full implementation of the solution in addition to what #g_ain has posted. This should clarify the solution even further.
This also includes the tricky part where the usage of when in Prompt is needed to prevent an endless navigation loop. (If you are using a custom Confirmation Modal)
//Assuming this is the callback from
//your custom prompt modal's YES button
onConfirmExitClick() {
history.push(this.state.nextLocation);
}
shouldShowExitConfirmModal(nextLocation) {
if (this.getIsEditorDirty()) {
this.setState({
nextLocation,
showExitConfirmModal: true
});
return false;
} else {
return true;
}
}
//Inside render()
//Notice the when prop, it doesn't fire when the custom modal is showing
<Prompt
when={!this.state.showExitConfirmModal}
message={nextLocation => this.shouldShowExitConfirmModal(nextLocation)}
/>

Related

Next.js behavior on back button pressed

I have a page I am trying to fix in order to keep scroll position when user presses back button (browser). Let's say I have a component called list, where I show the user some products. To see all the products the user can scroll down the list component. When the user clicks on some product, the application redirects the user to the detail component. Then when the user tries to go back to the list, hits the back button of the browser, the list component gets rendered and it seems like it scrolls to top automatically.
As far as I know, pressing the back button of the browser triggers a window.history.back() action, nothing else happens.
For a solution, I have implemented a variable in the context of my application that saves the scrollY value and then, in the componentWillMount (or useEffect) of the component I am trying to render (list component), I set the scroll position to the value set in the context.
Details of my solution are here, as I have based my entire code in this stack overflow's post:
How to change scroll behavior while going back in next js?
I have checked the value using some logs and the scroll position is saved correctly in the context, however, as I am using a window event listener, it sets the value to zero just after the list component is rendered.
In my code I am not using any kind of scroll configuration, so I was wondering if that behavior is some sort of default for either Next.js or react. It happens when the user hits the back button of the browser, but I am a newbie to next and I don't know if I am missing something or what, I don't even know if this issue has something to do with React or Next.js itself.
This gist may be of assistance as it includes a custom hook to manage scroll position: https://gist.github.com/claus/992a5596d6532ac91b24abe24e10ae81
import { useEffect } from 'react';
import Router from 'next/router';
function saveScrollPos(url) {
const scrollPos = { x: window.scrollX, y: window.scrollY };
sessionStorage.setItem(url, JSON.stringify(scrollPos));
}
function restoreScrollPos(url) {
const scrollPos = JSON.parse(sessionStorage.getItem(url));
if (scrollPos) {
window.scrollTo(scrollPos.x, scrollPos.y);
}
}
export default function useScrollRestoration(router) {
useEffect(() => {
if ('scrollRestoration' in window.history) {
let shouldScrollRestore = false;
window.history.scrollRestoration = 'manual';
restoreScrollPos(router.asPath);
const onBeforeUnload = event => {
saveScrollPos(router.asPath);
delete event['returnValue'];
};
const onRouteChangeStart = () => {
saveScrollPos(router.asPath);
};
const onRouteChangeComplete = url => {
if (shouldScrollRestore) {
shouldScrollRestore = false;
restoreScrollPos(url);
}
};
window.addEventListener('beforeunload', onBeforeUnload);
Router.events.on('routeChangeStart', onRouteChangeStart);
Router.events.on('routeChangeComplete', onRouteChangeComplete);
Router.beforePopState(() => {
shouldScrollRestore = true;
return true;
});
return () => {
window.removeEventListener('beforeunload', onBeforeUnload);
Router.events.off('routeChangeStart', onRouteChangeStart);
Router.events.off('routeChangeComplete', onRouteChangeComplete);
Router.beforePopState(() => true);
};
}
}, [router]);
}
Looking at your url, using shallow routing could solve the problem. Where the URL will get updated. And the page won't get replaced, only the state of the route is changed. So you can change your logic according to that.
A good example is in the official documentation:
https://nextjs.org/docs/routing/shallow-routing
And you might use display: 'hidden' to hide and show your components conditionally according to your state!
It's a way around but it could be even more useful depending on your exact situation !
After looking for another solution that does not use the window.scroll and similar methods, I have found a solution.
1st solution (worked, but for me that I have an infinite list that is loaded via API call, sometimes the window.scroll method wasn't accurate): I take the window.scrollY value and set it in the session storage, I did this before leaving the list page, so in the details page, if user hits the back button, at the moment the page is loading, I get the Y value from session storage and use the window.scroll method to force the page to scroll to the previously configured value.
As I mentioned earlier, this worked, but in my case, I have a list that is populated from an async API call, so sometimes the page loaded without all the images and the scroll was already configured, then the images and data were loaded and the user ended up seeing some other place in the page rather than the desire position.
2nd solution: In my case we are talking about a e commerce app, so I found this solution useful as it focuses in a particular item with its corresponding ID instead of the Y coord of the window. Scroll Restoration in e commerce app

Want to have an event handler for the browser's back button with next.js

I am having a modal which while opening pushes a hash to url example.com/#modal, on browser back button click, I want to recognise that event so that I can toggle the state of modal. the point is, since Im using it with next.js (server side rendering), I will not be having access to window object (correct me if I am wrong). so I need an alternate way to handle the event of browser back button.
You can use next/router's beforePopState to act on changes to the session history navigation (back/forward actions), and make sure it'll only happen when leaving the current page.
useEffect(() => {
router.beforePopState(({ as }) => {
if (as !== router.asPath) {
// Will run when leaving the current page; on back/forward actions
// Add your logic here, like toggling the modal state
}
return true;
});
return () => {
router.beforePopState(() => true);
};
}, [router]); // Add any state variables to dependencies array if needed.
#Summy I hope your issue is resolved by now. If not you can try this for the browser back button:-
Next.js + React Go back to the previous page
If you want to use hashbang URLs you can't use SSR, since /# and /#/one is the same route server-side, so there is no way for the server to know what to render, it will need to send a basic template and let the client fill it, in that case, I think using CRA or Parcel with React Router and its HashRouter is a better option, that way you will have a single index.html and let the client decide what to render.
in NextJs we can use beforePopState function and do what we want such close modal or show a modal or check the back address and decide what to do
https://stackoverflow.com/a/60702584/4717739
https://stackoverflow.com/a/69560739/4717739

React & MobX - Confirmation dialog when a user navigates away from the existing page

I have something that looks like this:
import React from 'react';
import PropTypes from 'prop-types';
import { Prompt } from 'react-router-dom';
const ConfirmationDialog = (props) => {
if (props.navigatingAway) {
window.onbeforeunload = () => true;
} else {
window.onbeforeunload = null;
}
return (
<Prompt
when={props.navigatingAway}
message="Are you sure?"
/>
);
};
ConfirmationDialog.propTypes = {
navigatingAway: PropTypes.bool.isRequired,
};
export default ConfirmationDialog;
I'm trying to figure out the best way to extend this so that navigatingAway actually does something. I don't understand what criteria to use for it, necessarily, just that it should trigger the confirmation window when:
a user changes the URL and attempts to navigate away
a user clicks on a link
a user refreshes the browser
What would be the best way to check for URL changes for when?
You don't need to come up with a way to 'detect' when one of your scenarios is occurring.
a user changes the URL and attempts to navigate away
a user refreshes the browser
These are already handled by virtue of assigning a callback to onbeforeunload.
a user clicks on a link
This is already handled by virtue of Prompt being rendered, if you're handling navigation with react-router.
props.navigatingAway, then, would be better named props.shouldPreventNavigation or something along those lines, because it should signal IF you should prevent navigating, not whether you ARE navigating.
For example, if you ALWAYS want a prompt to appear before navigation while ConfirmationDialog is mounted, then props.shouldPreventNavigation should just always be true, and you're done. A common use case would be to set it to true if there is unsaved data in a form.
From the docs for Prompt:
Instead of conditionally rendering a <Prompt> behind a guard, you can always render it but pass when={true} or when={false} to prevent or allow navigation accordingly.
To illustrate this, the following two snippets are functionally equivalent, apart from performance and such:
render() {
return (
<Prompt
when={this.props.navigatingAway}
message="Are you sure?"
/>
)
}
render() {
if (this.props.navigatingAway) {
return (
<Prompt
when={true}
message="Are you sure?"
/>
)
}
return null;
}
If Prompt isn't working properly out of the box when when={true}, then it could be that your routing isn't being properly managed by react-router.
As a side note, make sure you consider what happens with window.onbeforeunload if, for example, your ConfirmationDialog unmounts while it has a callback assigned. Use the appropriate lifecycle methods to manage this, or things are gonna get weird when you're testing this.

Dom node insertion/removal without mutation observers

I'm building a Tour component in React whose purpose is to introduce the user to the web app's interface. Parts of the "Tour" involve validating the user's actions, (e.g. if the current step involves opening a modal, once the user does so, the "Tour" should progress otherwise it should show an error if the user tries to progress by clicking 'Next').
For this I need to detect changes in the DOM, (e.g. a modal being opened or a div with a specific class appearing). I've had some ideas about wiring up an 'onNext' function that progresses the tutorial once the user interacts with certain target elements (e.g. 'Open Modal' button), but this seems like a hack, I want to govern the progression of the tour only by the actual elements present in the DOM not by listening for clicks that will result in the necessary elements showing up eventually.
One of the big constraints is avoiding MutationObservers in addition to usage of jQuery. With that said, I'm interested in hunches about how to validate the dom, how would one use pure javascript and the dom to determine the addition and removal of elements?
I think you're best served by implementing a Flux architecture to handle this. Redux is a good fit.
Create a Redux Reducer for your tour progression. The state of this reducer should be a key that corresponds to the current step of the tour that the user is within.
All components used in the tour should have access to this tour state as a prop. Use this prop to determine functionality. I.e. for your example of a dialog that must be opened, the code might look like this, within a relevant component;
openModal(){
if(this.props.tourStep == 'prompt_modal_open'){
ActionCreator.progressTourStep();
}
// code for actually opening the modal goes here
},
someOtherAction(){
if(this.props.tourStep == 'prompt_modal_open'){
//Display error message here
} else {
//normal action result here
}
}
When the user is not taking the tour, simply set tourStep in the reducer to undefined, and any tour related functionality will be turned off.
Alternately, if you want to keep your components clean and "dumb", you can put this logic directly into the action creator with the help of Redux-Thunk;
ActionCreator.openModal = function(){
return function(dispatch, getState){
var state = getState();
if(state.tourStep == 'prompt_modal_open'){
dispatch({type: 'progress_tour_step'});
}
dispatch({type: 'open_modal'});
}
}
ActionCreator.someOtherAction = function(){
return function(dispatch, getState){
var state = getState();
if(state.tourStep != undefined){
dispatch({type: 'show_error'});
} else {
dispatch({type: 'some_other_action_type'});
}
}
}

React confirm modal and redux middleware

I'm new to React and Redux also. I want to remove item from the list so I dispatch an action deleteSelectedItems then I use redux middleware to catch it and show confirm. That looks like below:
Action:
export const deleteSelectedItems = () => {
return {
type: ActionTypes.ITEM.DELETE_SELECTED,
payload: {
confirm: {
message: 'Are you sure you want to delete these selected items?'
}
}
}
};
Middleware:
const confirmMiddleware = store => next => action => {
if (action.payload.confirm) {
if (confirm(action.payload.confirm.message)) {
next(action);
}
} else {
next(action);
}
};
Everything works well. Now, I don't want to use confirm() to show confirm dialog, I want to use my own ConfirmDialog component instead.
I found #Dan Abramov solution, that is great. But I am confused how to integrate those together. I want to use confirmMiddleware to dispatch an action that show modal but I don't know how to handle when user click ok or cancel on modal. How can I do that?
I managed to independently re-invent the modal management technique that Dan describes in that issue, and then pushed it a bit farther. I did a writeup of my approach at Implement a confirm modal using React & Redux.Quoting myself:
I have a central component that is responsible for displaying all currently open dialogs, in the proper layout order (ie, I can have "DialogB" on top of "DialogA", etc). The component that wants to trigger showing the dialog runs an action creator that dispatches a "SHOW_DIALOG" action, with the name of the dialog component in the payload, and arbitrary additional data attached to the action. That data is added to the store, and the dialog managing component will pass that extra data to the dialog component as props when it's rendered.
I've created some generic "picker" dialogs, like ColorPicker and IconPicker. These dialogs know nothing about the rest of my app. They can take some optional bits of data in their props (such as the initially selected color value), and are also looking for a special prop with a name like "onColorSelected". The component that requested the dialog can include the entirety of another action as part of the payload, and when the dialog has its "OK" button clicked on, that new action will be dispatched along with the "return value" of the dialog.
So, in general, my suggestion is to include a plain action object that gets passed along to the dialog, and the dialog can dispatch a copy of the action when it is closed.
Redux middleware isn't really the right place for UI, it only really works in your existing implementation because window.confirm has magical powers and can stop the thread of execution.
Instead I would recommend dispatching a separate action to open the confirm dialog, e.g. CONFIRM_DELETE_ITEMS which toggles a flag to indicate the dialog should be displayed then dispatch the DELETE_ITEMS action when the dialog confirm button has been clicked.
e.g.
function Root({ dispatch, confirmDeleteItems }) {
return (
<div>
{confirmDeleteItems ? (
<ConfirmDialog onConfirm={() => dispatch(deleteItems())} onDeny={() = dispatch(hideModal())} />
) : null}
<button onClick={() => dispatch(confirmDeleteItems())}>Delete</button>
</div>
)
}

Categories