Skip to content
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

[v6] useBlocker prevents navigation outside app #7782

Closed
wojtekmaj opened this issue Mar 5, 2021 · 6 comments
Closed

[v6] useBlocker prevents navigation outside app #7782

wojtekmaj opened this issue Mar 5, 2021 · 6 comments

Comments

@wojtekmaj
Copy link

I have a React-Router v6 based app. I created a component that, if mounted, prevents the user from using "Back" button. If they decide to proceed anyway, it redirects the user to the home page instead.

import { useCallback } from 'react';
import { useBlocker, useLocation, useNavigate } from 'react-router';

const locationProperties = ['pathname', 'search', 'state'];

function isSameLocation(location1, location2) {
  return locationProperties.every((property) => location1[property] === location2[property]);
}

export default function NavigationGuard() {
  const location = useLocation();
  const navigate = useNavigate();

  const blocker = useCallback(
    ({ action, location: nextLocation, retry }) => {
      switch (action) {
        case 'PUSH':
        case 'REPLACE': {
          retry();
          return;
        }
        case 'POP': {
          if (isSameLocation(nextLocation, location)) {
            retry();
            return;
          }

          const answer = confirm('Are you sure you want to leave this page?');

          if (answer) {
            navigate('/');
          }

          return;
        }
      }
    },
    [dispatch, location, navigate],
  );

  useBlocker(blocker);

  return null;
}

This works perfectly, with one small gotcha: when the user clicks an external link, a prompt asking for confirmation appears. This is unintended.

I investigated a little bit, and it appears that whenever useBlocker is present (and active) on the page, history package adds onBeforeUnload listener, causing said unwanted prompt.

The only solution I could come up with was to listen for events on links that are about to be clicked and disable useBlocker right before they are actually clicked, but this seems hacky as hell.

-import { useCallback } from 'react';
+import { useCallback, useState } from 'react';
import { useBlocker, useLocation, useNavigate } from 'react-router';
+import { useEventListener } from '@wojtekmaj/react-hooks';
+
+import { closest } from 'utils/dom';

const locationProperties = ['pathname', 'search', 'state'];

function isSameLocation(location1, location2) {
  return locationProperties.every((property) => location1[property] === location2[property]);
}

+function isSameOrigin(location1, location2) {
+  return new URL(location1).origin === new URL(location2).origin;
+}

export default function NavigationGuard() {
  const location = useLocation();
  const navigate = useNavigate();
+  const [isExternalLinkActive, setIsExternalLinkActive] = useState(false);
+
+  function onActionStart(event) {
+    const link = closest(event.target, 'a');
+
+    if (!link || isSameOrigin(document.location, link.href)) {
+      return;
+    }
+
+    setIsExternalLinkActive(true);
+  }
+
+  function onActionEnd() {
+    if (!isExternalLinkActive) {
+      return;
+    }
+
+    setImmediate(() => {
+      setIsExternalLinkActive(false);
+    });
+  }
+
+  useEventListener(document, 'pointerdown', onActionStart);
+  useEventListener(document, 'focusin', onActionStart);
+
+  useEventListener(document, 'pointerdown', onActionEnd);
+  useEventListener(document, 'blur', onActionEnd);

  const blocker = useCallback(
    ({ action, location: nextLocation, retry }) => {
      switch (action) {
        case 'PUSH':
        case 'REPLACE': {
          retry();
          return;
        }
        case 'POP': {
          if (isSameLocation(nextLocation, location)) {
            retry();
            return;
          }

          const answer = confirm('Are you sure you want to leave this page?');

          if (answer) {
            navigate('/');
          }

          return;
        }
      }
    },
    [dispatch, location, navigate],
  );

-  useBlocker(blocker);
+  useBlocker(blocker, !isExternalLinkActive);

  return null;
}

Is there any better way to do this?

@performautodev
Copy link

Hello,

The key is to use the hook useBlocker outside the main logic component you can find an example here :
https://codesandbox.io/s/react-router-6-use-prompt-use-blocker-kce6k?file=/example.js

Regards;

@Zloka
Copy link

Zloka commented Apr 26, 2021

Bump, I believe I have a similar issue.

I use useBlocker to capture navigation changes, and handle them by calling my own logic, which depends on the current application state.

However, I am actually most specifically interested in the POP events. There is no reason for me to ask the user for confirmation when closing the tab, so I would like to disable the onBeforeUnload listener entirely in my case. Any idea how I could go about doing this? 🤔

@thedriveman

This comment has been minimized.

@Zloka

This comment has been minimized.

@mjackson
Copy link
Member

The intent of useBlocker is to prevent navigation away from the current page. In order to prevent navigation to another domain (or any document reload) we have to use the beforeunload hook.

You could definitely argue that it's not the job of the history library to block document reloads since you already have the ability to hook into beforeunload yourself. But that's not the way useBlocker was designed to work in history v5.

@timdorr
Copy link
Member

timdorr commented Dec 10, 2021

Closing since this API is no longer in the library. #8139 will track when it's added back.

@timdorr timdorr closed this as completed Dec 10, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

6 participants