remix-run / react-router

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

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

Closed callmeberzerker closed 1 year ago

callmeberzerker commented 2 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.

openscript commented 2 years ago

I tried to find a workaround with useEffect with the location as a dependencies, but couldn't get it working. With many applications we are stuck on 6.beta.1 because of this and #8035.

KubaOczko commented 2 years ago

Is there any guidance on how to implement the same behavior? I didn't find any extension point within the current router implementation. We can't continue with v6 without the possibility to block navigation based on some condition 😢

fgatti675 commented 2 years ago

Removing usePrompt and useBlocker in the 7th iteration of the beta was really unexpected. It would be great to get an alternative way to do the same. Is there any way we can include the removed code in our own codebase? Thanks

dohomi commented 2 years ago

I also experienced this and just found out it happened very unexpected in beta7. +1 for bringing it back or at least outline some workaround.

piecyk commented 2 years ago

As react-router expose NavigationContext under UNSAFE_NavigationContext we can add useBlocker hook back using the same code.

https://github.com/remix-run/react-router/commit/256cad70d3fd4500b1abcfea66f3ee622fb90874#diff-b60f1a2d4276b2a605c05e19816634111de2e8a4186fe9dd7de8e344b65ed4d3L344-L381

```tsx import * as React from 'react' import { UNSAFE_NavigationContext } from 'react-router-dom' import type { History, Blocker, Transition } from 'history' export function useBlocker(blocker: Blocker, when = true): void { const navigator = React.useContext(UNSAFE_NavigationContext).navigator as History React.useEffect(() => { if (!when) return const unblock = navigator.block((tx: Transition) => { const autoUnblockingTx = { ...tx, retry() { // Automatically unblock the transition so it can play all the way // through before retrying it. TODO: Figure out how to re-enable // this block if the transition is cancelled for some reason. unblock() tx.retry() }, } blocker(autoUnblockingTx) }) return unblock }, [navigator, blocker, when]) } ```

Don't exactly know why "block" method was omitted from History type? @mjackson maybe we could add it back?

mjackson commented 2 years 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.

mjackson commented 2 years ago

I might add (for those of you who want to upgrade immediately to v6) that you could consider another type of user experience instead of blocking navigation away from the current page.

The canonical use case for blocking is so you can show the user a prompt to confirm navigation away from a page with unsaved data. Instead of blocking navigation, another valid way to handle this situation could be to just save the state of the form to local/session storage in the browser as the form values change. Then, when the user returns, repopulate the default values of all the form fields with the ones you saved from the last visit. This experience should be less jarring than throwing up a prompt in front of the user while still giving you the resilience you're looking for.

Again, we are planning on re-adding this functionality to v6 after our stable release. I only offer this suggestion as a possible alternative for those of you who may wish to upgrade immediately.

piecyk commented 2 years ago

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.

Yes, that's completely understandable. Looking forward for v6 release, great work 👏

My question was about this change, with omitting 'block' from Navigator

Screenshot 2021-10-29 at 06 51 28
mjackson commented 2 years ago

@piecyk You can always create your own type to add that method back. I simply removed all the methods from the History interface that aren't being used anywhere in the router.

yanickrochon commented 2 years ago

A version with breaking changes notice would've been nice prior to a major release. Right now, the project recommends to upgrade, yet the migration guide is partial and application critical features have been removed without notice.

(After updating to v6, I am currently debugging a few errors with react-router-dom : "TypeError: pathname.match is not a function", and "TypeError: meta.relativePath.startsWith is not a function". The migration is anything but smooth. Compared to this, @material-ui, now @mui, made an excellent job at facilitating the upgrade process, starting a few months ago.)

frankalbenesius commented 2 years ago

I also ran into this omission while trying to migrate from v5 to v6. My migration was going so well! I love the changes that come in v6. They are super intuitive. Really great job!!! Thank you! I will wait patiently for useBlocker or another recommended way of blocking history in v6 before doing our migration.

I will also attempt to convince my company's product owners to use this pattern when possible. Thanks for the suggestion!

steveoh commented 2 years ago

We need a way to cancel a long running process if the page is navigated away from in an electron app. There are more use cases than form data or unsaved changes.

rmorse commented 2 years ago

As react-router expose NavigationContext under UNSAFE_NavigationContext we can add useBlocker hook back using the same code.

Thanks @piecyk !

I made a gist of this for react-router-dom for easier access: https://gist.github.com/rmorse/426ffcc579922a82749934826fa9f743

jamesdh commented 2 years ago

Nowhere is the removal of this mentioned anywhere in the v5 to v6 upgrade docs. I just burned an hour trying to figure out why I couldn't resolve Prompt until I stumbled upon this issue.

timdorr commented 2 years ago

A note has been added in #8436

vvakame commented 2 years ago

@storybook/router uses v6 already. this dependency introduce react-router native .d.ts instead of @types/react-router-dom. this changes will occur tsc compile error. so, If we can update to v6. I'm very glad 😉

matsgm commented 2 years ago

@storybook/router uses v6 already. this dependency introduce react-router native .d.ts instead of @types/react-router-dom. this changes will occur tsc compile error. so, If we can update to v6. I'm very glad 😉

@storybook/router 6.2.9 uses @reach/router.

The following (and probably some newer versions) is working with React Router v5:

"@storybook/addon-actions": "6.2.9",
"@storybook/addon-docs": "6.2.9",
"@storybook/addons": "6.2.9",
"@storybook/react": "6.2.9",
Haraldson commented 2 years ago

@mjackson Is there a roadmap for when this will be prioritized and worked on? Got 97% of the way with my upgrade, and the remaining bits are comprised of <Prompt /> and optional route parameters.

onionhammer commented 2 years ago

Cant upgrade from beta until this is back :/

onionhammer commented 2 years ago

@wojtekmaj Yes, if you use ts-expect-error, this is true.

karagog commented 2 years ago

I think the notice about Prompt and useBlocker should be at the top of the documentation page. That is a critical blocker for me, but I only discovered it after updating >90% of my codebase to v6. I'll stash the changes and resume the upgrade after this feature is in (or there is a workaround posted) and cross my fingers that the merge gods will smile on me later.

edmbn commented 2 years ago

@karagog have you seen this previous answer https://github.com/remix-run/react-router/issues/8139#issuecomment-977790637? For me it is working fine.

karagog commented 2 years ago

@karagog have you seen this previous answer #8139 (comment)? For me it is working fine.

Thanks, but IIUC that will only bring to life the beta-version of useBlocker(), which it sounds like it wasn't ready for primetime so not too hot about using that in my project. Also that code may "work" for now but will be an added maintenance burden I'd rather leave up to the react-router folks to release when it's ready.

I think taking time and doing it the right way is generally a good approach, but I think they should call it out more prominently in the upgrade guide because it's a critical blocker for some folks. I have no problem waiting, but the upgrade doc promises a relatively smooth upgrade without mentioning this major caveat until the very end.

code-jongleur commented 2 years ago

We have also been blocked by the missing component and corresponding hooks in multiple web projects during the v6 migration. It would be great, if these could be reintroduced again in future!

In our case I had to extend usePrompt of your nice implementation, @piecyk https://github.com/remix-run/react-router/issues/8139#issuecomment-977790637 to support that "message" might be a function and not a string and gets the target location submitted as in the v5 behaviour:

export function usePrompt(message, when = true) {
  const { basename } = useContext(NavigationContext);

  const blocker = useCallback(
    (tx) => {
      if (typeof message === "function") {
        let targetLocation = tx?.location?.pathname;
        if (targetLocation.startsWith(basename)) {
          targetLocation = targetLocation.substring(basename.length);
        }
        if (message(targetLocation)) {
          tx.retry();
        }
      } else if (typeof message === "string") {
        if (window.confirm(message)) {
          tx.retry();
        }
      }
    },
    [message, basename]
  );

Unfortunately our previous usage relied on this behaviour.

A prompt component could be easily implemented like this

import { usePrompt } from "./reactRouterDomPromptBlocker";

const Prompt = ({ message, when }) => {
  usePrompt(message, when);

  return null;
};

export default Prompt;

No warranties for my code examples, especially if they work in future … ;)

heath-freenome commented 2 years ago

I also crashed into this... I will attempt to use this solution temporarily, since we are using <Prompt /> only in one of our internal libraries so I could build it temporarily... But I would love to not rely on unsupported code. Is there a timeline for having this added back in? Here we are on v6.2.1 and it is still not officially supported yet

heath-freenome commented 2 years ago

We have also been blocked by the missing component and corresponding hooks in multiple web projects during the v6 migration. It would be great, if these could be reintroduced again in future!

In our case I had to extend usePrompt of your nice implementation, @piecyk #8139 (comment) to support that "message" might be a function and not a string and gets the target location submitted as in the v5 behaviour:

export function usePrompt(message, when = true) {
  const { basename } = useContext(NavigationContext);

  const blocker = useCallback(
    (tx) => {
      if (typeof message === "function") {
        let targetLocation = tx?.location?.pathname;
        if (targetLocation.startsWith(basename)) {
          targetLocation = targetLocation.substring(basename.length);
        }
        if (message(targetLocation)) {
          tx.retry();
        }
      } else if (typeof message === "string") {
        if (window.confirm(message)) {
          tx.retry();
        }
      }
    },
    [message, basename]
  );

Unfortunately our previous usage relied on this behaviour.

A prompt component could be easily implemented like this

import { usePrompt } from "./reactRouterDomPromptBlocker";

const Prompt = ({ message, when }) => {
  usePrompt(message, when);

  return null;
};

export default Prompt;

No warranties for my code examples, especially if they work in future … ;)

In order to preserve the behavior from RR5 (where the prompt callback takes the location and action, I modified the above usePrompt code as follows:

/**
 * Prompts the user with an Alert before they leave the current screen.
 *
 * @param  message
 * @param  when
 */
export function usePrompt(message, when = true) {
  const blocker = useCallback(
    (tx) => {
      let response;
      if (typeof message === 'function') {
        response = message(tx?.location, tx?.action);
        if (typeof response === 'string') {
          response = window.confirm(response);
        }
      } else if (typeof message === 'string') {
        response = window.confirm(message);
      }
      if (response) {
        tx.retry();
      }
    },
    [message]
  );
  return useBlocker(blocker, when);
}
brianespinosa commented 2 years ago

@mjackson Is there a roadmap for when this will be prioritized and worked on? Got 97% of the way with my upgrade, and the remaining bits are comprised of <Prompt /> and optional route parameters.

I tried starting a discussion to get some feedback on where this might be on a roadmap, even if it is just a priority order. Having a little more clarity on what is priority would be a massive help.

aliechti commented 2 years ago

Thanks for your help @code-jongleur and @heath-freenome

This is my temporary solution for useBlocker and usePrompt in TypeScript:

import type {Blocker, History, Transition} from 'history';
import {ContextType, useCallback, useContext, useEffect} from 'react';
import {Navigator as BaseNavigator, UNSAFE_NavigationContext as NavigationContext} from 'react-router-dom';

interface Navigator extends BaseNavigator {
    block: History['block'];
}

type NavigationContextWithBlock = ContextType<typeof NavigationContext> & { navigator: Navigator };

/**
 * @source https://github.com/remix-run/react-router/commit/256cad70d3fd4500b1abcfea66f3ee622fb90874
 */
export function useBlocker(blocker: Blocker, when = true) {
    const {navigator} = useContext(NavigationContext) as NavigationContextWithBlock;

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

        const unblock = navigator.block((tx: Transition) => {
            const autoUnblockingTx = {
                ...tx,
                retry() {
                    // Automatically unblock the transition so it can play all the way
                    // through before retrying it. TODO: Figure out how to re-enable
                    // this block if the transition is cancelled for some reason.
                    unblock();
                    tx.retry();
                },
            };

            blocker(autoUnblockingTx);
        });

        return unblock;
    }, [navigator, blocker, when]);
}

/**
 * @source https://github.com/remix-run/react-router/issues/8139#issuecomment-1021457943
 */
export function usePrompt(message: string | ((
    location: Transition['location'],
    action: Transition['action'],
) => string), when = true) {
    const blocker = useCallback((tx: Transition) => {
        let response;
        if (typeof message === 'function') {
            response = message(tx.location, tx.action);
            if (typeof response === 'string') {
                response = window.confirm(response);
            }
        } else {
            response = window.confirm(message);
        }
        if (response) {
            tx.retry();
        }
    }, [message]);
    return useBlocker(blocker, when);
}
gschwa commented 2 years ago

I tried the above code from @thejahweh and the navigation was blocked but the URL is changing and the confirmation is not being shown. I am using the HashRouter, is there any chance that is causing issues?

akraines commented 2 years ago

See https://github.com/TanStack/react-location/discussions/214#discussioncomment-2156933 - react-location might be an alternative for those looking to upgrade to RR6

sethwright commented 2 years ago

I think the notice about Prompt and useBlocker should be at the top of the documentation page. That is a critical blocker for me, but I only discovered it after updating >90% of my codebase to v6. I'll stash the changes and resume the upgrade after this feature is in (or there is a workaround posted) and cross my fingers that the merge gods will smile on me later.

Agree. I also upgraded 90+% of my code without knowing critical features were missing. Partly my bad for trusting without reading the whole doc first but still, it would be nice if a notice could be put at the top of the page.

woodreamz commented 2 years ago

You should add a specific warning message at the top of the migration guide. It's really frustrating to discover Prompt, usePrompt and useBlocker are not part of the v6 when 90% of the app has been migrated to v6.

brianespinosa commented 2 years ago

@woodreamz agreed regarding a note on the migration docs. Someone could probably open a PR to add that.

And again... it would be helpful if any maintainer at all could give some more clarity on the roadmap so we have an idea what the priorities are, and if anyone could pitch in to get these features working.

NehalDamania commented 2 years ago

Any updates?

Galileo01 commented 2 years ago

Obviously,it's a breaking change , continue focus on

Beej126 commented 2 years ago

@aliechti ... Thanks for your help @code-jongleur and @heath-freenome

This is my temporary solution for useBlocker and usePrompt in TypeScript: [...]

thanks to @aliechti, @code-jongleur and @heath-freenome for their enlightening samples.

bottom line up front, my unblock() call wasn't working

in case helps others, i added using a ref to avoid the navigator.block() being called for every re-render...

if you look at the code for history.block() it pushes your blocker callback to a stack and returns a function for unblock() which removes from that stack... re-renders would add another instance of my block function to that stack and the last unblock my scope wound up with wasn't going to remove all of them because it's a value comparison.

i believe the useCallback() in previous code samples above by @code-jongleur and @heath-freenome are probably the equivalent solve.

here's my full working tsx file:

// react-router-dom v6 removed useBlocker() due to edge case downsides
// https://github.com/remix-run/react-router/releases/tag/v6.0.0-beta.7
// more deets about why: https://github.com/remix-run/history/issues/690
// this restores that feature since we don't care about the edge cases

import type { Blocker, History, Transition } from 'history';
import { ContextType, useContext, useEffect, useRef } from 'react';
import { Navigator as BaseNavigator, UNSAFE_NavigationContext as NavigationContext } from 'react-router-dom';

interface Navigator extends BaseNavigator {
    block: History['block'];
}

type NavigationContextWithBlock = ContextType<typeof NavigationContext> & { navigator: Navigator };

// rmorse gist: https://gist.github.com/rmorse/426ffcc579922a82749934826fa9f743
// lower level history library example, helpful for seeing use of confirmation flow inside useBlocker:https://github.com/remix-run/history/blob/main/docs/blocking-transitions.md

// approach below copied and tweaked from github issue:
// https://github.com/remix-run/react-router/issues/8139#issuecomment-1023105785
export function useBlocker(blocker: Blocker, when = true) {
    const { navigator } = useContext(NavigationContext) as NavigationContextWithBlock;

    //main tweak required to OP was wrapping unblock in ref so we're only pushing one blocker on the stack for this when expression (i.e. not for every render)
    const refUnBlock = useRef<()=>void>();

    useEffect(() => {
        if (!when) {
            refUnBlock.current?.();
            return;
        }

        if (!refUnBlock.current) refUnBlock.current = navigator.block((tx: Transition) => {
            const autoUnblockingTx = {
                ...tx,
                retry() {
                    refUnBlock.current?.(); //need to unblock so retry succeeds
                    tx.retry();
                },
            };

            blocker(autoUnblockingTx);
        });

    }, [navigator, blocker, when]);
}
skycrazyk commented 2 years ago

My example based on https://github.com/remix-run/react-router/issues/8139#issuecomment-1023105785 and https://github.com/remix-run/react-router/issues/8139#issuecomment-1094106064 with custom prompt (based on Ant Design Modal component)

import {Modal, Button} from 'antd'
import {useBlocker, Tx} from '@/hooks'

type NavBlockerProps = {
    when?: boolean
    onSave?: () => Promise<any> | undefined
}

export const NavBlocker: FC<NavBlockerProps> = ({when, onSave}) => {
    const [t] = useTranslation(undefined, {keyPrefix: 'NavBlocker'})
    const [block, setBlock] = useState(when)
    const [visible, setVisible] = useState(false)
    const [tx, setTx] = useState<Tx | undefined>()
    const [saving, setSaving] = useState(false)

    const show = () => setVisible(true)
    const hide = () => setVisible(false)

    const skip = () => {
        setBlock(false)
        setTimeout(() => {
            tx?.retry()
            hide()
        }, 0)
    }

    const save = async () => {
        setSaving(true)

        try {
            await onSave?.()
            skip()
        } catch (e) {
            console.error(e)
        }

        setSaving(false)
    }

    useEffect(() => {
        setBlock(when)
    }, [when])

    useBlocker((tx) => {
        setTx(tx)
        show()
    }, block)

    return (
        <Modal
            visible={visible}
            width={580}
            onCancel={hide}
            footer={[
                <Button type="primary" ghost key="save" onClick={save} loading={saving}>
                    {t('save')}
                </Button>,
                <Button type="primary" ghost danger key="skip" onClick={skip}>
                    {t('skip')}
                </Button>,
                <Button onClick={hide} key="cancel">
                    {t('cancel')}
                </Button>,
            ]}
        >
            {t('message')}
        </Modal>
    )
}
import type {Transition} from 'history'
export type Tx = Transition & {retry: () => void}
gschwa commented 2 years ago

Has anyone been able to get this workaround working for a HashRouter? My navigation is blocked but I see the URL changing but my Modal is not showing.

sam-woodridge commented 2 years ago

Any updates on this?

mondayrris commented 2 years ago

I have made a demonstration on using usePrompt and useBlocker, based on the code from aliechti.

This demonstration is also specifically for react-iframe form, for anyone who just want to watch the usage only, see usePromptBlocker.ts and Demo.tsx

myzonjkee commented 2 years ago

Made a MUI example based on the code from aliechti 😁

city17 commented 2 years ago

Is there a timeline when to approximately expect these features to be added back into v6? A rough idea would be very helpful, so we don't implement an alternative to find out it is added back in next week.

davidlukerice commented 2 years ago

Is there a timeline when to approximately expect these features to be added back into v6? A rough idea would be very helpful, so we don't implement an alternative to find out it is added back in next week.

Sounds like it will be a bit since they're focusing on getting the "remixified" react-router out first. Our team just went with a modified version of what Myzonj made until Prompt gets integrated back in. It was a pretty quick integration.

receter commented 2 years ago

This is my temporary solution for useBlocker and usePrompt in TypeScript:

I used the below wrapper component in combination with the hooks from @aliechti to supplement the missing component.

const Prompt = function ({message, when}) {
  usePrompt(message, when);
  return false;
}
thkim9012 commented 2 years ago

please bring it back...

above few guys implemented code has significant bug which irrecoverable by refresh page.

david-crespo commented 2 years ago

For anyone trying to use the above workaround in the 6.4 prerelease: it won't work because @remix-run/router has its own history implementation and it doesn't include block.

https://github.com/remix-run/react-router/blob/8bae986/packages/router/history.ts#L587-L614

heath-freenome commented 2 years ago

For anyone trying to use the above workaround in the 6.4 prerelease: it won't work because @remix-run/router has its own history implementation and it doesn't include block.

https://github.com/remix-run/react-router/blob/8bae986/packages/router/history.ts#L587-L614

So they are making it WORSE?! :(

aminify commented 2 years ago

Why not just adding useUnstablePrompt or useUnstableBlock or something like that to the library? It was already working fine for everyone. It is quite a shame that version 6 has come this long way without being feature complete with the previous version! So much time has passed.

withinboredom commented 2 years ago

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.

Let this be a lesson for anyone who thinks "we can do it later."

ThiefMaster commented 2 years ago

Awesome, I just spend half an hour upgrading to v6 just to realize at the very end that Prompt isn't supported...

Any ETA when <Prompt /> will finally come back?