'React-Router 6's useBlocker prevents navigation outside app

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 resets the app and redirects the user to the home page.

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;
        }
      }
    },
    [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, 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;
        }
      }
    },
    [location, navigate],
  );

  useBlocker(blocker, !isExternalLinkActive);

  return null;
}

Is there any better way to do this?



Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source