react-router
react-router copied to clipboard
[V6] [Feature] Getting `usePrompt` and `useBlocker` back in the router
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.
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.
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 😢
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
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.
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
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?
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.
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.
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
@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.
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.)
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!
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.
As react-router expose
NavigationContextunderUNSAFE_NavigationContextwe 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
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.
A note has been added in #8436
@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/routeruses v6 already. this dependency introducereact-routernative.d.tsinstead 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",
@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.
Cant upgrade from beta until this is back :/
@wojtekmaj Yes, if you use ts-expect-error, this is true.
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.
@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 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.
We have also been blocked by the missing <Prompt/> 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 … ;)
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
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
usePromptof 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);
}
@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.
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);
}
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?
See https://github.com/TanStack/react-location/discussions/214#discussioncomment-2156933 - react-location might be an alternative for those looking to upgrade to RR6