Skip to content

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

Closed
1 task done
cmwd opened this issue Jul 6, 2017 · 59 comments
Closed
1 task done

Route cancellation #2476

cmwd opened this issue Jul 6, 2017 · 59 comments
Labels
Navigation Related to Next.js linking (e.g., <Link>) and navigation.
Milestone

Comments

@cmwd
Copy link

cmwd commented Jul 6, 2017

  • I have searched the issues of this repository and believe that this is not a duplicate.

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 in routeChangeStart handler. It works only after this piece of code was executed:

async fetchComponent (route, as) {
  let cancelled = false
  const cancel = this.componentLoadCancel = function () {
    cancelled = true
  }

Workaround:

Router.ready(() => {
  Router.router.on('routeChangeStart', () => {
    setTimeout(() => {
      Router.router.abortComponentLoad();
    });
  });
});

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

@cmwd
Copy link
Author

cmwd commented Jul 11, 2017

If someone needs this feature I found that better workaround is just to throw an error in routeChangeStart event handler:

Router.ready(() => {
  Router.router.on('routeChangeStart', () => {
    throw new Error('Abort');
  });
});

@erikras
Copy link

erikras commented Jul 19, 2017

Seems like a reasonable solution would be to abort the routing if the return value from onRouteChangeStart() is === false. That's what I attempted before having to search and find this issue.

@tlackemann
Copy link

tlackemann commented Oct 25, 2017

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.

@DonovanCharpin
Copy link

DonovanCharpin commented Dec 27, 2017

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.

@cmwd
Copy link
Author

cmwd commented Dec 27, 2017

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?

@DonovanCharpin
Copy link

DonovanCharpin commented Dec 27, 2017

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 Abort method.

@ryanmeisters
Copy link

Did anything like this make it into 5.0? Anybody done something like this in 5.0 yet?

@paulobarcelos
Copy link

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.

@Negan1911
Copy link

The only way to work with this was using

Router.router.events.on('routeChangeStart', () => {
    throw new Error('nooo')
})

@justbrody
Copy link

@Negan1911 , where do you catch the raised error?

@Negan1911
Copy link

@justbrody no need to catch it, it will appear on the console but prevent the navigation

@justbrody
Copy link

@Negan1911 , Thanks for the quick reply. I'm trying not to polute the console, people will get nervous seeing errors in the console.

@Negan1911
Copy link

@justbrody Yeah i know, my QA got crazy because of that, but nothing to do at this level ¯_(ツ)_/¯¯

@justbrody
Copy link

@Negan1911 I'm going to wrap the Router calls with my own implementation for more control.
Nextjs-team, thanks for a great library and I hope the requested feature of @cmwd will someday be implemented.

@thebearingedge
Copy link

thebearingedge commented Oct 1, 2018

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.

I just ended up dumping work-in-progress stuff to localStorage. Asking the user if they want to restore or not when they come back suffices for my scenario (accidental navigation).

@isaachinman
Copy link
Contributor

While I agree that users need the ability to intercept routing events, I don't necessarily think that onRouteChangeStart is the right place.

Rather, (taking after React of course) what about a hook like shouldRouteChange that returns true by default, but can be overridden with proprietary logic?

@DonovanCharpin
Copy link

@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

@isaachinman
Copy link
Contributor

@DonovanCharpin I never said anything about using React to handle the event, simply mentioned that the implementation would be similar.

@aralroca
Copy link
Contributor

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 😕.

@bebraw
Copy link

bebraw commented Oct 11, 2019

I know it doesn't solve the underlying issue but since we have some control over Next's Link component, I imagine we could override the default behavior to add a check to whether it should be triggered or not. This adds extra complexity, though, as you have to communicate the state to a custom Link component that wraps Next's logic. An alternative would be to go through Router instead of Link if you go this way.

@Betree
Copy link

Betree commented Oct 21, 2019

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 routeChangeError event to stop any loading indicator (or similar) that may have been triggered with the route change.

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);

@lcswillems
Copy link

lcswillems commented Dec 8, 2019

@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, Router.abortComponentLoad doesn't exist. Am I wrong?

@defucilis
Copy link

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 Prompt component built into react-router-dom:

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>)
}

@lcanavesio
Copy link

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.

@anjar
Copy link

anjar commented Dec 24, 2020

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

@fabb
Copy link
Contributor

fabb commented Feb 11, 2021

@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.

@davidalejandroaguilar
Copy link

I wonder if we'll ever have an official API for this, it's been almost 4 years.

@njfix6
Copy link

njfix6 commented Apr 7, 2021

Is there anyway to cancel a route change without throwing an error?

@RodrigoNovais
Copy link

Is there a way I can do something like @lcswillems example using refs?
I mean, it was working well while I was using states to keep track of my values but once I changed to references to avoid unnecessarily redraws I couldn't figure out a way to make it work again

@ScottAgirs
Copy link

ScottAgirs commented May 11, 2021

usePreventRouteChangeIf(importing, () => setShowImportInProgressWarning(true));

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 problem

Even 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.

Is this just me or others have the same issue?

@ryym
Copy link

ryym commented May 13, 2021

actual route is nevertheless changing

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 history.back() or history.forward() immediately after stopping the route change by usePreventRouteChangeIf. However I don't know how to decide whether to use back() or forward(), since there is no information about whether user tried to go back or forward.

@GorkemSahin
Copy link

GorkemSahin commented May 18, 2021

I used beforePopState of Next.js' router to handle the the case when user clicks on the back button of the browser, which was the only issue with @lcswillems 's solution. This seems to work with page refresh, closing the tab/browser, navigating within the web app, leaving the web app by going to another URL, and pressing the back button.

  export const useWarningOnExit = (shouldWarn: boolean, warningText?: string) => {
    const message = warningText || 'Are you sure that you want to leave?'

    useEffect(() => {
      let isWarned = false

      const routeChangeStart = (url: string) => {
        if (Router.asPath !== url && shouldWarn && !isWarned) {
          isWarned = true
          if (window.confirm(message)) {
            Router.push(url)
          } else {
            isWarned = false
            Router.events.emit('routeChangeError')
            Router.replace(Router, Router.asPath, { shallow: true })
            // eslint-disable-next-line no-throw-literal
            throw 'Abort route change. Please ignore this error.'
          }
        }
      }

      const beforeUnload = (e: BeforeUnloadEvent) => {
        if (shouldWarn && !isWarned) {
          const event = e || window.event
          event.returnValue = message
          return message
        }
        return null
      }

      Router.events.on('routeChangeStart', routeChangeStart)
      window.addEventListener('beforeunload', beforeUnload)
      Router.beforePopState(({ url }) => {
        if (Router.asPath !== url && shouldWarn && !isWarned) {
          isWarned = true
          if (window.confirm(message)) {
            return true
          } else {
            isWarned = false
            window.history.pushState(null, '', url)
            Router.replace(Router, Router.asPath, { shallow: true })
            return false
          }
        }
        return true
      })

      return () => {
        Router.events.off('routeChangeStart', routeChangeStart)
        window.removeEventListener('beforeunload', beforeUnload)
        Router.beforePopState(() => {
          return true
        })
      }
    }, [message, shouldWarn])
  }

Please let me know if you find any issues or room for improvement. cc: @ryym , @fabb

@Coelhomatias
Copy link

@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:

image

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!

@GorkemSahin
Copy link

@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.

@Coelhomatias
Copy link

Coelhomatias commented May 26, 2021

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.

Ohh ok, I didn't know that, then I'll stick with browser alerts. Thank you!

Edit: I fixed the issue by replacing Router.replace(Router, Router.asPath, { shallow: true }); with Router.replace(Router.asPath, Router.asPath, { shallow: true }); on lines 13 and 39

@lcanavesio
Copy link

lcanavesio commented May 26, 2021

@GorkemSahin Hi!
just in case you didn't create a context ?
I am thinking of using something like that

`
import React, { useContext, useState } from "react"
import useWarningOnExit from "../utils/useWarningOnExit "

const BloquearRouterContext = React.createContext<{
activarRouter: boolean
setActivarRouter?: (value: boolean) => void
}>(undefined)

interface IBloquearRouterProvider {
bloquearRouter?: boolean
}

export const BloquearRouterProvider: React.FunctionComponent = (
props: IBloquearRouterProvider,
) => {
const [activarRouter, setActivarRouter] = useState(
props.bloquearRouter,
)
useWarningOnExit(activarRouter)

const valor = {
activarRouter,
setActivarRouter,
}

return <BloquearRouterContext.Provider value={valor} {...props} />
}

export function useBloquearRouter() {
const context = useContext(BloquearRouterContext)

if (!context) {
return null
}
return context
}

`

@ryym
Copy link

ryym commented May 28, 2021

@GorkemSahin Thank you for your solution :)
It works fine to cancel browser back. But the problem is the popstate event is also triggered by the forward button click. In that case I think we should not do Route.replace because it breaks the browser history.

For example, let's suppose that a page /foo has a form and it warns on exit if the form has unsaved changes (useWarningOnExit(formIsDirty)). And if a user acts like following:

  1. opens /foo.
  2. opens /bar from a link in /foo.
  3. goes back to /foo.
  4. edits the form without saving.
  5. clicks the browser's forward button to go to /bar.
  6. A confirm dialog pops up and user clicks Cancel.

Now the route is set to /foo so it seems to work as expected, but after that user cannot clicks the forward button anymore since the history is modified.

  • Before cancel: the history is [/foo, /bar] (and /foo is the current position).
  • After cancel: the history is [/foo].

The only solution I found is to use history.state.idx which would be used by Next.js internally. I don't know what this state is for, but it seems Next.js increments idx each time a user opens a new page. Therefore we can use it to decide whether we should go back or forward when a user cancels the route change.
However the idx state is not documented at all so we should not rely on this...

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]);
};

@zoltantothcom
Copy link

@ryym Thanks for your solution. I've tried it, however noticed one strange thing with throwing an error on routeChangeStart - the form fields get cleared 🤷‍♂️ Have you experienced that behavior?

issue.mov

@ryym
Copy link

ryym commented Jun 9, 2021

@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.

@lcanavesio
Copy link

@zoltantothcom you can try to use localStorage and when it keeps well clean store

@steelbrain
Copy link

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 release
diff --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)

@danvoyce
Copy link

danvoyce commented Aug 8, 2021

I've tried the above solution (and many others), but the URL is always changing when I hit back. Is this just me?

@dkershner6
Copy link

dkershner6 commented Sep 23, 2021

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 useRouter hook rather than the default exported class instance. Made it a lot easier to grok, at least for me, but the work is all @ryym 's and it should function identically (albeit slightly less performant).

@danvoyce , the URL should change, but then change back when you hit cancel if you are doing a NextRouter route. Look for revertTheChangeRouterJustMade in the code below, chances are good this is not firing for you.

TypeScript Sample
import { 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]);
};

@bluebill1049
Copy link

bluebill1049 commented Nov 2, 2021

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 react-hook-form, then you can simply pass down the formState as props.

@timneutkens timneutkens added the Navigation Related to Next.js linking (e.g., <Link>) and navigation. label Nov 18, 2021
@vercel vercel locked and limited conversation to collaborators Dec 7, 2021
@balazsorban44 balazsorban44 converted this issue into discussion #32231 Dec 7, 2021

This issue was moved to a discussion.

You can continue the conversation there. Go to discussion →

Labels
Navigation Related to Next.js linking (e.g., <Link>) and navigation.
Projects
None yet
Development

No branches or pull requests