remix-run / react-router

Declarative routing for React
https://reactrouter.com
MIT License
53.09k stars 10.31k forks source link

[V6] [Feature] Getting `usePrompt` and `useBlocker` back in the router #8139

Closed callmeberzerker closed 1 year ago

callmeberzerker commented 3 years ago

I think in general most people won't be able to upgrade to v6 since in the latest beta usePrompt and useBlocker are removed.

Most apps rely on the usePrompt or Prompt to prevent navigation in case the user has unsaved changes (aka the form is dirty).

With this issue maybe we can have some feedback on why they (usePrompt, Prompt) were removed (they worked fine in the previous beta version v6.0.0-beta.6) and what makes them problematic, what's the outlook for getting them back in the router and potentially userland solutions to this problem.

Anubiso commented 1 year ago

Our use-case is to use <Prompt> to show a Warning-Confirm-Modal when the User has changed stuff in the Configuration in the state, but has not submitted it.

<Prompt message={(location) => {
          if (confirmedRouteChange.current || !formIsDirty) {
            return true
          }

          showModal({
            text: 'You have unsaved changes, they will be lost if you continue.',
            title: 'Warning',
            icon: 'warning',
            overrideSwalProps: {
              showCancelButton: true,
              cancelButtonText: 'Cancel',
            },
          }).then((result) => {
            if (result.value) {
              confirmedRouteChange.current = true
              history.push(location)
            }
          })

          return false
        }}
      />
ingro commented 1 year ago

@chaance we are only using the <Prompt> component (didn't need getUserConfirmation) to show a confirm dialog if the user tries to change location with an unsaved form, something like this:

export function RouteLeavingGuard({
    when,
    title = 'Warning',
    text,
    shouldBlockNavigation = () => true,
}) {
    const { isOpen, open, close } = useDisclosure(false);
    const [lastLocation, setLastLocation] = useState(null);
    const [confirmedNavigation, setConfirmedNavigation] = useState(false);

    const history = useHistory();

    const handleBlockedNavigation = (nextLocation) => {
        if (!confirmedNavigation && shouldBlockNavigation(nextLocation)) {
            open();
            setLastLocation(nextLocation);

            return false;
        }

        return true;
    };

    const handleConfirmNavigationClick = () => {
        close();
        setConfirmedNavigation(true);
    }; 

    useEffect(() => {
        if (confirmedNavigation && lastLocation) {
            // Navigate to the previous blocked location with your navigate function
            history.push(lastLocation);
        }
    }, [confirmedNavigation, lastLocation]);

    return (
        <>
            <Prompt when={when} message={handleBlockedNavigation} />
            {isOpen && (
                <ConfirmDialog 
                    title={title}
                    onCancel={close}
                    onConfirm={handleConfirmNavigationClick}
                >
                    {text}
                </ConfirmDialog>
            )}
        </>
    );
};

And using it like this:

<RouteLeavingGuard
    when={formState.isDirty && formState.isSubmitSuccessful === false}
    text={'The form contains unsaved data, are you sure you want to exit?'}
/>
mleister97 commented 1 year ago

@chaance here you can find our implementation: https://github.com/remix-run/react-router/issues/9698

We are only using it for forms to prevent unintentional leaving of the page with a dirty form

trb commented 1 year ago

@ryanflorence (...) All we have access to is onpopstate too sweat_smile

I just need a way to disable/enable your event handler :) For everything else I've found workarounds that have acceptable trade-offs for my use case (preventing users from navigating during long file uploads).

gjvoosten commented 1 year ago

Does your app use the getUserConfirmation prop to customize the <Prompt> experience?

No, we merely used <Prompt when={…} message={…} /> and have replaced it with a custom implementation of Prompt based on UNSAFE_NavigationContext (seeing as the near future promised in https://github.com/remix-run/react-router/issues/8139#issuecomment-954425560 turned out to be only true for large values of near :smirk: ).

tadasgo commented 1 year ago

@chaance Here is the short summary, but if interested there is full blog post about the implementation and issues faced.

Needed a functionality to prevent user navigation, and warn that the changes might be lost if he decides to continue. These were the main cases I tried to cover:

This is the final solution:

import { History } from 'history';
import {
  useContext,
  useEffect,
  useLayoutEffect,
  useRef,
} from 'react';
import { UNSAFE_NavigationContext } from 'react-router-dom';

interface BlockerControl {
  confirm: () => void;
  cancel: () => void;
}

interface Blocker {
  onBlock: (control: BlockerControl) => void;
  enabled?: boolean;
}

export const useBlocker = ({ onBlock, enabled }: Blocker) => {
  const { block } = useContext(UNSAFE_NavigationContext).navigator as History;

  const onBlockRef = useRef(onBlock);
  useLayoutEffect(() => {
    onBlockRef.current = onBlock;
  });

  useEffect(() => {
    if (!enabled) {
      return;
    }

    let isActive = false;

    const unblock = block(({ retry }) => {
      if (isActive) {
        unblock();
        return retry();
      }

      onBlockRef.current({
        confirm: retry,
        cancel: () => {
          isActive = false;
        },
      });

      isActive = true;
    });

    return unblock;
  }, [block, enabled]);
};

This is how we are using it with our promise based modals:

const AnyReactComponent = () => {
  ...
  const confirmationModal = useModal(ConfirmationModal);

  useBlocker({
    enabled: formState.isDirty,
    onBlock: (navigation) =>
      confirmationModal.show().then((result) => {
        if (result.action === 'CONFIRM') {
          return navigation.confirm();
        }

        navigation.cancel();
      }),
  });

  return (
    ...
  );
};
AsuraKev commented 1 year ago

@chaance Finally this is happening!. We mostly use usePrompt and we hope the new version can still provide flexibility to utilize the window.prompt as well custom modal scenarios :)

larrygotto commented 1 year ago

Came by this issue while upgrading our platform to v6. So glad this is happening right now. Feels like we won't have to delay the upgrade for too long. Thanks for looking into this issue

andreas-soroko commented 1 year ago

Hey,

glad to hear that function will come back!

I'd like to get as much feedback as possible so that we can avoid as many problems as we can along the way.

i think we have a kind of unique use of the blocking (or better react-router) function(s).

Technically we have a react application, lets call it Frame, that "hosts" other (react) apps via iframes.
These apps are using a custom history to forward all actions up the Frame via postMessage and back to the app (kind of single source of truth).
The Frame itself handles which apps (multiple apps can be opened at the same time) has requested a block. If a route change was triggerd, the Frame looks up if it has some block actions and asks the the specific app via postMessage if the app wants to block the route change.

Our apps are using mainly the <Prompt when={boolean} message={func} /> component, in some rare cases the history.block function.

The Frame makes use of the Prompt component and getUserConfirmation function, we are creating a new BrowserHistory and providing via options a getUserConfirmation function, that simply stores the information to show a custom modal.

Can be a bit confusing, but i hope it was understandable. Otherwise i could provide some more information.

chazlm commented 1 year ago

are these features still not implemented? do we have a timeframe?

chaance commented 1 year ago

There is a draft PR open at https://github.com/remix-run/react-router/pull/9709. This is a work-in-progress but keep an eye on the activity there to see where things stand.

JWo1F commented 1 year ago

Hi there. If anyone is also waiting for the usePrompt and useBlocker hooks to appear, in version 6.5.0 you can override the navigate function of the router this way:

const router = createBrowserRouter(routesArray);
const _navigate = router.navigate.bind(router);

type Listener = () => boolean | Promise<boolean>;
const listeners: Listener[] = [];

router.navigate = async (...args) => {
  const params = args as [any];

  if (listeners.length > 0) {
    const promises = listeners.map((fn) => fn());
    const values = await Promise.all(promises);
    const allowed = values.every(Boolean);

    if (!allowed) return;
  }

  return _navigate(...params);
};

const beforeUnload = (e: BeforeUnloadEvent) => {
  // Cancel the event.
  e.preventDefault();
  // Chrome (and legacy IE) requires returnValue to be set.
  e.returnValue = '';
};

function blockNavigation(fn: Listener) {
  if (listeners.length === 0) {
    addEventListener('beforeunload', beforeUnload, { capture: true });
  }

  listeners.push(fn);

  return () => {
    const index = listeners.indexOf(fn);
    listeners.splice(index, 1);
    if (listeners.length === 0) {
      removeEventListener('beforeunload', beforeUnload, { capture: true });
    }
  };
}

You can then use the blockNavigation function to block the navigation:

function useBlocker(dirty: boolean, blocker: Listener) {
  useEffect(() => {
    if (!dirty) return;
    return blockNavigation(blocker);
  }, [blocker, dirty]);
}

And use it in the project (you should change the code to suit your needs, this is just an example):

function usePrompt(message: ReactNode, dirty = true) {
  useBlocker(
    dirty,
    useCallback(
      // async function:
      async () => await confirmUnsavedChanges(message),
      // or just confirm
      // () => confirm('Unsaved changes!'), // boolean
      // or just boolean function:
      // () => doWeNeedToGo(), // boolean
      [message]
    )
  );
}
Messa1 commented 1 year ago

If you really need blocking I'd recommend you remain on v5 until we have time to get it back into v6. v5 will continue to be supported for the foreseeable future. You shouldn't feel any pressure to upgrade to v6 immediately.

As for why it was removed in v6, we decided we'd rather ship with what we have than take even more time to nail down a feature that isn't fully baked. We will absolutely be working on adding this back in to v6 at some point in the near future, but not for our first stable release of 6.x.

1 year later and still nothing

develra commented 1 year ago

Thanks for all the awesome progress on this!

I really love the loaders feature in v6.4+ and wanted to be able to keep using that while being able to move forward with a migration that relies on usePrompt - so implemented a version pretty similar to previous react-router versions here: https://github.com/remix-run/react-router/pull/9821

I know that ends up being sort of ugly and hacky - but is working well for my current use case. I really appreciate the direction the current PR is going, but have two requests for the final version.

1) I'm using useBlocker that returns false to report some metrics before react-router handles a navigation. I would love for the final implementation to either allow multiple blockers or to expose a new useBeforeNavigation hook for this purpose.

2) My impression with the current state of the PR is that react-router doesn't want to deal with the ugly hacks to deal with the "what happens when you click back again with a prompt open" use case. I totally understand that desire, but maybe it would make sense to expose init.history.listen and the history stack (init.history.state.idx) to allow custom implementations of usePrompt that deal with the hacki-ness, without giving too much exposure to history and the foot-guns that entails.

Thanks for all the hard work on resolving this important use-case in a deterministic and easy to maintain way - y'all rock!

chaance commented 1 year ago

I totally understand that desire, but maybe it would make sense to expose init.history.listen and the history stack (init.history.state.idx) to allow custom implementations of usePrompt that deal with the hacki-ness, without giving too much exposure to history and the foot-guns that entails.

Unfortunately I think both of those suggestions would be leaking hacky implementation details and foot-guns. history.listen is only used for POP navigations in the data routers, and it's only there because of browser API limitations. If the future navigation API settles and gives us better primitives, we could revisit our implementation at any time (another reason for not exposing history directly). The index, too, is a hacky workaround and isn't reliable.

We will likely opt to do as we did before: give you the best effort attempt to block navigation without breaking the back button in most cases, but outline the tradeoffs.

chaance commented 1 year ago

unstable_useBlocker has been shipped in v6.7.0-pre.3, and I threw up a Gist showing how you can use it to recreate usePrompt (from the v6 beta releases) and <Prompt> from v5. The gist explains the limitations of our approach, so we're leaving it up to end users (you) to own these implementations so you can decide exactly which tradeoffs you'd like to accept for your own use-case.

unstable_useBlocker will be shipped in v6.7.0, but I'd encourage you all to try out the pre-release and offer any feedback you might have. We're confident in the implementation but marked it unstable for now to make sure we're comfortable with the API.

vladutclp commented 1 year ago

Does anybody know why I'm getting this erro while running tests when using useBlocker hook in my react app?

image

I'm using createBrowserRouter to create the router that is passed to RouterProvider component.

In my test file I render the component like this:

const renderComponent = () =>
        render(<ContactDetailsPage />, { wrapper: BrowserRouter })
brophdawg11 commented 1 year ago

Hey folks! unstable_useBlocker and unstable_usePrompt are now released in 6.7.0. Docs should be up on reactrouter.com in the coming days. Until then, there's an example of useBlocker usage in examples/navigation-blocking in the repo.

dennisoelkers commented 1 year ago

I have the same issue as @vladutclp, with both useBlocker and usePrompt. Both are executed in a component which is nested below a BrowserRouter. What am I doing wrong?

machour commented 1 year ago

@dennisoelkers Please open a new Q&A discussion, this is a closed issue.

brophdawg11 commented 1 year ago

useBlocker and usePrompt are new data-router features, so you will need to use them inside a RouterProvider, not a BrowserRouter.

esetnik commented 1 year ago

useBlocker and usePrompt are new data-router features, so you will need to use them inside a RouterProvider, not a BrowserRouter.

As a point of feedback, the documentation could be significantly improved for how to transition the previous setup using a BrowserRouter to the new RouterProvider. I spent an hour trying to get my v6.4.0 -> v6.7.0 transitioned using

createBrowserRouter(
  createRoutesFromElements(
  ...

but was ultimately unsuccessful and had to roll back.

chaance commented 1 year ago

We are working on documentation as we speak, but yes it should (and will) definitely be improved.

Locking the issue now that we've wrapped up the core work. Feel free to open new issues if you run into usage problems, and stay tuned for docs!