-
Notifications
You must be signed in to change notification settings - Fork 27k
This issue was moved to a discussion.
You can continue the conversation there. Go to discussion →
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Route cancellation #2476
Comments
If someone needs this feature I found that better workaround is just to throw an error in Router.ready(() => {
Router.router.on('routeChangeStart', () => {
throw new Error('Abort');
});
}); |
Seems like a reasonable solution would be to abort the routing if the return value from |
The workarounds above no longer work in Next.js 4, I believe this is because there are no methods exposed on the router itself that would give us control over this. Particularly, there is no way to expose componentLoadCancel to manually cancel a route A use-case where this would be helpful is "unsaved changes" dialogs/warnings. I'm open to making a PR if this is something that would be desired. |
The workarounds only work for a link clicked but don't work when using browser navigation for instance. I agree with @erikras, that would be really nice to be able to return a boolean in onRouteChangeStart to cancel the route change! Router.onRouteChangeStart = (url) => {
if (!window.confirm('You really want to leave?')) {
return false;
}
return true;
} Unfortunately, onRouteChangeStart is just an event and can't manage any value to return. |
I think it makes sense to cancel route by throwing a custom error. Router.onRouteChangeStart = (url) => {
if (!window.confirm('You really want to leave?')) {
throw new Router.Abort();
}
} What do you think? |
Both work for me as long as we have this feature which is really useful. I think It's more common to manage this with a boolean but more understandable with this |
Did anything like this make it into 5.0? Anybody done something like this in 5.0 yet? |
Also looking for this feature. Working on a editor app and need to be able to cancel route changes in case there are unsaved changes. |
The only way to work with this was using
|
@Negan1911 , where do you catch the raised error? |
@justbrody no need to catch it, it will appear on the console but prevent the navigation |
@Negan1911 , Thanks for the quick reply. I'm trying not to polute the console, people will get nervous seeing errors in the console. |
@justbrody Yeah i know, my QA got crazy because of that, but nothing to do at this level ¯_(ツ)_/¯¯ |
@Negan1911 I'm going to wrap the Router calls with my own implementation for more control. |
I just ended up dumping work-in-progress stuff to |
While I agree that users need the ability to intercept routing events, I don't necessarily think that Rather, (taking after React of course) what about a hook like |
@isaachinman I think that creating a hook would restrict the number of possibilities. You don't want to have to use react to handle this kind of event. You want to be able to intercept it anywhere in the app (can be a library, singleton, component etc.). There is an attempt to solve this issue here #2694 |
@DonovanCharpin I never said anything about using React to handle the event, simply mentioned that the implementation would be similar. |
Any new with this? @Negan1911 I tried your proposal of the workaround in Next.js 9.0.4 and is blocking the back refresh, that is cool... However, the URL changed to the previous history state instead of keeping the same 😕. |
I know it doesn't solve the underlying issue but since we have some control over Next's |
If that can help, here's the component we've implemented to prevent leaving the page when there are unsaved changes. It handles both navigation and closed tabs. Adding to the examples above, it emits a import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, defineMessages } from 'react-intl';
import { Router } from '../server/pages';
/**
* A component to warn users if they try to leave with unsaved data. Just set
* `hasUnsavedChanges` to true when this is the case and this component will block any
* attempt to leave the page.
*/
class WarnIfUnsavedChanges extends React.Component {
static propTypes = {
hasUnsavedChanges: PropTypes.bool,
children: PropTypes.node,
intl: PropTypes.object,
};
componentDidMount() {
window.addEventListener('beforeunload', this.beforeunload);
Router.router.events.on('routeChangeStart', this.routeChangeStart);
}
componentWillUnmount() {
window.removeEventListener('beforeunload', this.beforeunload);
Router.router.events.off('routeChangeStart', this.routeChangeStart);
}
messages = defineMessages({
warning: {
id: 'WarningUnsavedChanges',
defaultMessage: 'You are trying to leave this page with un-saved changes. Are you sure?',
},
});
/**
* NextJS doesn't yet provide a nice way to abort page loading. We're stuck with throwing
* an error, which will produce an error in dev but should work fine in prod.
*/
routeChangeStart = () => {
const { hasUnsavedChanges, intl } = this.props;
if (hasUnsavedChanges && !confirm(intl.formatMessage(this.messages.warning))) {
Router.router.abortComponentLoad();
Router.router.events.emit('routeChangeError'); // For NProgress to stop the loading indicator
throw new Error('Abort page navigation, please ignore this error');
}
};
/** Triggered when closing tabs */
beforeunload = e => {
const { hasUnsavedChanges, intl } = this.props;
if (hasUnsavedChanges) {
e.preventDefault();
const message = intl.formatMessage(this.messages.warning);
e.returnValue = message;
return message;
}
};
render() {
return this.props.children;
}
}
export default injectIntl(WarnIfUnsavedChanges); |
@Betree Thank you for your solution! But it doesn't fully work because the URL will always be changed, even if user doesn't want to leave the page. Did you find any solution for this? Moreover, |
I improved @toreyhickman's solution by wrapping it up into a little functional component that can be included in any other component to provide a customizable navigation prompt - it works identically to the import {useEffect} from 'react'
import SingletonRouter, {Router} from 'next/router'
const NavigationPrompt = ({when, message}) => {
useEffect(() => {
SingletonRouter.router.change = (...args) => {
if(!when) return Router.prototype.change.apply(SingletonRouter.router, args);
return confirm(message)
? Router.prototype.change.apply(SingletonRouter.router, args)
: new Promise((resolve, reject) => resolve(false));
}
return () => {
delete SingletonRouter.router.change
}
}, [])
return null;
}
export default NavigationPrompt; usage (will show a navigation prompt with a custom message if the button has been clicked more than 10 times): import {useState} from 'react'
import NavigationPrompt from './NavigationPrompt'
export default () => {
const [clicks, setClicks] = useState(0);
return (<div>
<p>You've clicked {clicks} times</p>
<button onClick={() => setClicks(c => c + 1)}>Click</button>
<NavigationPrompt when={() => clicks > 10} message={`You've clicked ${clicks} times, are you sure you want to leave?`} />
</div>)
} |
Hi @lcswillems ! Thanks for sharing the code works correctly for me. But how do I detect a type = submit? In that case I shouldn't be blocked. |
Hi @defucilis , thanks for code, i tried implemented your code, it work when use router.push, but there is an undefined error when use next/link |
@lcswillems it nearly works, the only issue is when the user uses the browser back/forward buttons, the changes to the browser history are not corrected. E.g. when the user navigates back and aborts, the previous history item will still be "eaten", and by the code replaced with the old url. But the real previous browser history item will be gone. It's similar when moving forward from the page that blocks navigation. Somehow we need to clean up browser history manually. |
I wonder if we'll ever have an official API for this, it's been almost 4 years. |
Is there anyway to cancel a route change without throwing an error? |
Is there a way I can do something like @lcswillems example using refs? |
This is a similar solution to that I've come up with for myself, but I've noticed something that's crazy that was missed in my implementation, to which this falls victim as well. The problemEven though the unmounting of the component is prevented and the page does not change, the route actual route is nevertheless changing, if we use this to prevent navigation between history entries.
|
I have the same problem. It seems to occur when a route change happens by popstate event like browser's back/forward. It is possible to restore the correct URL using |
I used
Please let me know if you find any issues or room for improvement. cc: @ryym , @fabb |
@GorkemSahin I'm very pleased with your solution, it works for me. However, I get a warning in my console if I cancel when trying to go back to the previous page: I'm using edge beta btw. Also, I would like to make a feature request, Is it possible to make this a functional component or enable a callback so that I can render a custom modal for warning the user? Thank you very much for your help! |
@Coelhomatias hm, not sure why you get all these warnings. I'm not getting them on our project. Yeah you can use a custom modal or component but only for routing requests within your web app. Actions like going to another website, closing the tab/browser etc. can't be prevented with custom modals in modern browsers. I'd recommend having a hook that returns a component to be rendered and a promise to be fulfilled. Then you can await for that in the routeChangeStart event handler. The buttons on the modal would fullfill or reject the promise, based on which you interrupt the navigation or not. But then again it'll work for one case only (navigation within your Next.js app) and for our project we ended up deciding that it's not worth adding all that code for one use case and also at the expense of losing consistency in dialog modal UIs. |
Ohh ok, I didn't know that, then I'll stick with browser alerts. Thank you! Edit: I fixed the issue by replacing |
@GorkemSahin Hi! ` const BloquearRouterContext = React.createContext<{ interface IBloquearRouterProvider { export const BloquearRouterProvider: React.FunctionComponent = ( const valor = { return <BloquearRouterContext.Provider value={valor} {...props} /> export function useBloquearRouter() { if (!context) { ` |
@GorkemSahin Thank you for your solution :) For example, let's suppose that a page
Now the route is set to
The only solution I found is to use const useWarningOnExit = (shouldWarn) => {
const message = "Are you sure that you want to leave?";
const lastHistoryState = useRef(global.history?.state);
useEffect(() => {
const storeLastHistoryState = () => {
lastHistoryState.current = history.state;
};
Router.events.on("routeChangeComplete", storeLastHistoryState);
return () => {
Router.events.off("routeChangeComplete", storeLastHistoryState);
};
}, []);
useEffect(() => {
let isWarned = false;
const routeChangeStart = (url) => {
if (Router.asPath !== url && shouldWarn && !isWarned) {
isWarned = true;
if (window.confirm(message)) {
Router.push(url);
} else {
isWarned = false;
Router.events.emit("routeChangeError");
// HACK
const state = lastHistoryState.current;
if (state != null && history.state != null && state.idx !== history.state.idx) {
history.go(state.idx < history.state.idx ? -1 : 1);
}
// eslint-disable-next-line no-throw-literal
throw "Abort route change. Please ignore this error.";
}
}
};
const beforeUnload = (e) => {
if (shouldWarn && !isWarned) {
const event = e || window.event;
event.returnValue = message;
return message;
}
return null;
};
Router.events.on("routeChangeStart", routeChangeStart);
window.addEventListener("beforeunload", beforeUnload);
return () => {
Router.events.off("routeChangeStart", routeChangeStart);
window.removeEventListener("beforeunload", beforeUnload);
};
}, [message, shouldWarn]);
}; |
@ryym Thanks for your solution. I've tried it, however noticed one strange thing with throwing an error on issue.mov |
@zoltantothcom No, I don't know about that behavior. I guess there is an effect or something that clears or recreates the form on route change. |
@zoltantothcom you can try to use localStorage and when it keeps well clean store |
FYI there's a tiny patch you can make to Next.js to make it allow route cancellations Replacing this line with the following try {
Router.events.emit('routeChangeStart',as,routeProps);
} catch(err) {
if (err != null && err.cancelled) {
Router.events.emit('routeChangeComplete',this.asPath,{});
return
}
throw err
} Here's the patch for the v11.0.1 releasediff --git a/node_modules/next/dist/next-server/lib/router/router.js b/node_modules/next/dist/next-server/lib/router/router.js
index d265358..786a468 100644
--- a/node_modules/next/dist/next-server/lib/router/router.js
+++ b/node_modules/next/dist/next-server/lib/router/router.js
@@ -132,7 +132,7 @@ let resolvedAs=as;// url and as should always be prefixed with basePath by this
pathname=pathname?(0,_normalizeTrailingSlash.removePathTrailingSlash)(delBasePath(pathname)):pathname;if(shouldResolveHref&&pathname!=='/_error'){;options._shouldResolveHref=true;if(process.env.__NEXT_HAS_REWRITES&&as.startsWith('/')){const rewritesResult=(0,_resolveRewrites.default)(addBasePath(addLocale(cleanedAs,this.locale)),pages,rewrites,query,p=>resolveDynamicRoute(p,pages),this.locales);resolvedAs=rewritesResult.asPath;if(rewritesResult.matchedPage&&rewritesResult.resolvedHref){// if this directly matches a page we need to update the href to
// allow the correct page chunk to be loaded
pathname=rewritesResult.resolvedHref;parsed.pathname=addBasePath(pathname);url=(0,_utils.formatWithValidation)(parsed);}}else{parsed.pathname=resolveDynamicRoute(pathname,pages);if(parsed.pathname!==pathname){pathname=parsed.pathname;parsed.pathname=addBasePath(pathname);url=(0,_utils.formatWithValidation)(parsed);}}}const route=(0,_normalizeTrailingSlash.removePathTrailingSlash)(pathname);if(!isLocalURL(as)){if(process.env.NODE_ENV!=='production'){throw new Error(`Invalid href: "${url}" and as: "${as}", received relative href and external as`+`\nSee more info: https://nextjs.org/docs/messages/invalid-relative-url-external-as`);}window.location.href=as;return false;}resolvedAs=delLocale(delBasePath(resolvedAs),this.locale);if((0,_isDynamic.isDynamicRoute)(route)){const parsedAs=(0,_parseRelativeUrl.parseRelativeUrl)(resolvedAs);const asPathname=parsedAs.pathname;const routeRegex=(0,_routeRegex.getRouteRegex)(route);const routeMatch=(0,_routeMatcher.getRouteMatcher)(routeRegex)(asPathname);const shouldInterpolate=route===asPathname;const interpolatedAs=shouldInterpolate?interpolateAs(route,asPathname,query):{};if(!routeMatch||shouldInterpolate&&!interpolatedAs.result){const missingParams=Object.keys(routeRegex.groups).filter(param=>!query[param]);if(missingParams.length>0){if(process.env.NODE_ENV!=='production'){console.warn(`${shouldInterpolate?`Interpolating href`:`Mismatching \`as\` and \`href\``} failed to manually provide `+`the params: ${missingParams.join(', ')} in the \`href\`'s \`query\``);}throw new Error((shouldInterpolate?`The provided \`href\` (${url}) value is missing query values (${missingParams.join(', ')}) to be interpolated properly. `:`The provided \`as\` value (${asPathname}) is incompatible with the \`href\` value (${route}). `)+`Read more: https://nextjs.org/docs/messages/${shouldInterpolate?'href-interpolation-failed':'incompatible-href-as'}`);}}else if(shouldInterpolate){as=(0,_utils.formatWithValidation)(Object.assign({},parsedAs,{pathname:interpolatedAs.result,query:omitParmsFromQuery(query,interpolatedAs.params)}));}else{// Merge params into `query`, overwriting any specified in search
-Object.assign(query,routeMatch);}}Router.events.emit('routeChangeStart',as,routeProps);try{var _self$__NEXT_DATA__$p,_self$__NEXT_DATA__$p2,_options$scroll;let routeInfo=await this.getRouteInfo(route,pathname,query,as,resolvedAs,routeProps);let{error,props,__N_SSG,__N_SSP}=routeInfo;// handle redirect on client-transition
+Object.assign(query,routeMatch);}}try{Router.events.emit("routeChangeStart",as,routeProps)}catch(t){if(null!=t&&t.cancelled)return void Router.events.emit("routeChangeComplete",this.asPath,{});throw t};try{var _self$__NEXT_DATA__$p,_self$__NEXT_DATA__$p2,_options$scroll;let routeInfo=await this.getRouteInfo(route,pathname,query,as,resolvedAs,routeProps);let{error,props,__N_SSG,__N_SSP}=routeInfo;// handle redirect on client-transition
if((__N_SSG||__N_SSP)&&props){if(props.pageProps&&props.pageProps.__N_REDIRECT){const destination=props.pageProps.__N_REDIRECT;// check if destination is internal (resolves to a page) and attempt
// client-navigation if it is falling back to hard navigation if
// it's not and then your code can be really simple and clean: const routeChangeStart = () => {
if (activateAlert && !window.confirm(message)) {
const newErr = new Error('Abort route')
newErr.cancelled = true
throw newErr
}
}
router.events.on('routeChangeStart', routeChangeStart) |
I've tried the above solution (and many others), but the URL is always changing when I hit back. Is this just me? |
Just to weigh in, I tried my best to break @ryym 's solution and could not. Chances are good that if you are having trouble, it has something to do with the way you are storing the values of your form, or something else related to state. Using native form elements has the correct behavior. Just in case it is helpful to someone, I have @ryym 's example below, but converted to TypeScript, and I named some of the items as I figured out what they did. In addition, it uses the @danvoyce , the URL should change, but then change back when you hit cancel if you are doing a NextRouter route. Look for TypeScript Sampleimport { useRef, useEffect, useCallback } from "react";
import { useRouter } from "next/router";
/**
* Throwing an actual error class trips the Next.JS 500 Page, this string literal does not.
*/
const throwFakeErrorToFoolNextRouter = (): never => {
throw "Abort route change. Please ignore this error.";
};
const useWarningOnExit = (shouldWarn: boolean): void => {
const router = useRouter();
const message = "Are you sure that you want to leave?";
const lastHistoryState = useRef<{ idx: number }>(global.history?.state);
useEffect(() => {
const storeLastHistoryState = (): void => {
lastHistoryState.current = history.state;
};
router.events.on("routeChangeComplete", storeLastHistoryState);
return () => {
router.events.off("routeChangeComplete", storeLastHistoryState);
};
}, [router]);
/**
* @experimental HACK - idx is not documented
* Determines which direction to travel in history.
*/
const revertTheChangeRouterJustMade = useCallback(() => {
const state = lastHistoryState.current;
if (
state !== null &&
history.state !== null &&
state.idx !== history.state.idx
) {
history.go(
lastHistoryState.current.idx < history.state.idx ? -1 : 1
);
}
}, []);
const killRouterEvent = useCallback(() => {
router.events.emit("routeChangeError");
revertTheChangeRouterJustMade();
throwFakeErrorToFoolNextRouter();
}, [revertTheChangeRouterJustMade, router]);
useEffect(() => {
let isWarned = false;
const routeChangeStart = (url: string): void => {
if (router.asPath !== url && shouldWarn && !isWarned) {
isWarned = true;
if (window.confirm(message)) {
router.push(url);
return;
}
isWarned = false;
killRouterEvent();
}
};
const beforeUnload = (e: BeforeUnloadEvent): string | null => {
if (shouldWarn && !isWarned) {
const event = e ?? window.event;
event.returnValue = message;
return message;
}
return null;
};
router.events.on("routeChangeStart", routeChangeStart);
window.addEventListener("beforeunload", beforeUnload);
return () => {
router.events.off("routeChangeStart", routeChangeStart);
window.removeEventListener("beforeunload", beforeUnload);
};
}, [message, shouldWarn, killRouterEvent, router]);
}; |
Made a simple hook to handle route change with confirmation (when the form is dirty): import * as React from "react"
import { FormState } from "react-hook-form"
import { useRouter } from "next/router"
type Props<T> = {
formState: FormState<T>
message?: string
}
const defaultMessage = "Are you sure to leave without save?"
export function useLeaveConfirm<T>({
formState,
message = defaultMessage,
}: Props<T>) {
const Router = useRouter()
const onRouteChangeStart = React.useCallback(() => {
if (formState.isDirty) {
if (window.confirm(message)) {
return true
}
throw "Abort route change by user's confirmation."
}
}, [formState])
React.useEffect(() => {
Router.events.on("routeChangeStart", onRouteChangeStart)
return () => {
Router.events.off("routeChangeStart", onRouteChangeStart)
}
}, [onRouteChangeStart])
return
} If you are using |
This issue was moved to a discussion.
You can continue the conversation there. Go to discussion →
Expected Behavior
A method in router API that allows aborting upcoming route change.
Current Behavior
Right now router exposes
abortComponentLoad
method but it seems to be designed only for internal use and it doesn't work if it's called inrouteChangeStart
handler. It works only after this piece of code was executed:Workaround:
Context
This is useful for example when building complex forms and we want to warn a user before he leaves the page without submitting.
Your Environment
ANY
The text was updated successfully, but these errors were encountered: