Closed callmeberzerker closed 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
}}
/>
@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?'}
/>
@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
@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).
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: ).
@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 (
...
);
};
@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 :)
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
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.
are these features still not implemented? do we have a timeframe?
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.
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]
)
);
}
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
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!
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.
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.
Does anybody know why I'm getting this erro while running tests when using useBlocker
hook in my react app?
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 })
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.
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?
@dennisoelkers Please open a new Q&A discussion, this is a closed issue.
useBlocker
and usePrompt
are new data-router features, so you will need to use them inside a RouterProvider
, not a BrowserRouter
.
useBlocker
andusePrompt
are new data-router features, so you will need to use them inside aRouterProvider
, not aBrowserRouter
.
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.
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!
I think in general most people won't be able to upgrade to v6 since in the latest beta
usePrompt
anduseBlocker
are removed.Most apps rely on the
usePrompt
orPrompt
to prevent navigation in case the user has unsaved changes (aka theform
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.