Closed callmeberzerker closed 1 year ago
We have a workaround in the open source identity solution project Logto.
You may check out the UnsavedChangesAlertModal
for some inspiration.
2 hour wasted... roll backed to v5.
Perhaps not the most elegant solution, but it is the farthest I've come so far. Still testing for edge cases. I was able to come up with a hook that would prevent ppl from closing / leaving the page or navigating away with/ without react router when the page is "dirty". I used @xiaoyijun post as reference.
import { UNSAFE_NavigationContext as NavigationContext, Navigator, useNavigate } from "react-router-dom";
import { useContext, useEffect, useRef } from "react";
import type { Blocker } from "history";
type BlockerNavigator = Navigator & {
location: Location;
block: (blocker: Blocker) => () => void;
};
function beforeUnloadHandler(e: BeforeUnloadEvent) {
e.preventDefault();
e.returnValue = "";
return "Are you sure you want to leave?\n\nChanges that you made may not be saved";
}
function usePreventPageLoad(pageIsDirty: boolean) {
const { navigator } = useContext(NavigationContext);
const unblockRef = useRef(function () {
[].forEach((e) => e);
});
const navigate = useNavigate();
useEffect(() => {
if (!pageIsDirty) {
return;
}
// To prevent window / tab from closing
window.addEventListener("beforeunload", beforeUnloadHandler);
// To prevent react-router-dom from navigating to another page
const { block } = navigator as BlockerNavigator;
unblockRef.current = block((transition) => {
const {
location: { pathname: targetPathname },
action,
} = transition;
window.removeEventListener("beforeunload", beforeUnloadHandler);
if (window.confirm("Change page?\n\nChanges that you made may not be saved.")) {
unblockRef.current();
// We use this to make it work with navigations without react-router (ie. Browser's go back button)
if (action.toLowerCase() === "pop") {
navigate(targetPathname);
} else {
// We use this to make it work with navigations via react-router (ie. navLink)
transition.retry();
}
}
});
return () => window.removeEventListener("beforeunload", beforeUnloadHandler);
}, [navigator, navigate, pageIsDirty]);
return unblockRef;
}
export default usePreventPageLoad;
From the component where u're using it, you can unblock the page if needed (like before doing a form submit)
function MyComponent() {
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const unblockRef = usePreventPageLoad(hasUnsavedChanges);
...
unblockRef.current(); // unblock if necessary before moving to another page
...
}
Let me know if anyones sees some major concerns with that approach!
@lacazeto - I've taken your code after some examination and it is performing well. What we had to add is that the unblockRef.current() is also called in the cleanup of useEffect, otherwise it was staying active even though the "dirty" flag changes back to false,
@lacazeto - I've taken your code after some examination and it is performing well. What we had to add is that the unblockRef.current() is also called in the cleanup of useEffect, otherwise it was staying active even though the "dirty" flag changes back to false,
Yeah, well spot! In my case, that was overlooked as I was never setting dirty back to false again. Just cared to know if the form was interacted somehow and that was it!
But thx for pointing it out!
I went with a mixture of all those solutions, basically, but one thing I didn't quite figure out yet is how to prevent the form submission itself from being blocked. If I use an onSubmit
on the form and do setDirty(false)
it's a race condition because this is not a synchronous thing.
How did you guys solve it? I feel like waiting a few milliseconds before letting the submission go on is a very cheap hack...
I went with a mixture of all those solutions, basically, but one thing I didn't quite figure out yet is how to prevent the form submission itself from being blocked. If I use an
onSubmit
on the form and dosetDirty(false)
it's a race condition because this is not a synchronous thing.How did you guys solve it? I feel like waiting a few milliseconds before letting the submission go on is a very cheap hack...
In my example, I would do smt like e.preventDefault() + unblockRef.current() + postData(payload)
and navigate somewhere else upon success.
In my example, I would do smt like
e.preventDefault() + unblockRef.current() + postData(payload)
and navigate somewhere else upon success.
is unblockRef.current() synchronous in this example? I can't unblock synchronously since I am using the usePrompt from (this)[https://github.com/remix-run/react-router/issues/8139#issuecomment-1147980133] example. I pass a useState variable to the usePrompt which means I can't really instantly set it to false again.
I realize this thread should mainly be about the shortcomings of react-router v6 and how to fix them, but for anyone looking to prevent the back button, I came up with a seemingly very robust solution using native browser methods. My use case was a component that hides itself when the back button is pressed once, without routing the page. This would be pretty useful for prompts as well.
https://stackoverflow.com/a/73312578/650775
Essentially, you can push a fake history event onto the history, and then listen for when it gets popped in order to do something
function closeQuickView() {
closeMe() // do whatever you need to close this component
}
useEffect(() => {
// Add a fake history event so that the back button does nothing if pressed once
window.history.pushState('fake-route', document.title, window.location.href);
addEventListener('popstate', closeQuickView);
// Here is the cleanup when this component unmounts
return () => {
removeEventListener('popstate', closeQuickView);
// If we left without using the back button, aka by using a button on the page, we need to clear out that fake history event
if (window.history.state === 'fake-route') {
window.history.back();
}
};
}, []);
hello gamers i have realized a very cool solution i think.
so the issue is that the navigator thingy won't let u block anymore bcuz they got rid of the method for it but if u just wrap it around the History type, u get to have all its methods back
basically:
// it picks only the methods from histroy that are those
export declare type Navigator = Pick<History, "go" | "push" | "replace" | "createHref">;
```javascript
// u get the mback
import { UNSAFE_NavigationContext as NavigationContext } from 'react-router-dom'
import type { History } from 'history'
const { navigator } = useContext(NavigationContext)
const blockNavigator = navigator as History
this is jsut a quick implementation of a usePrompt hook:
import { useState, useContext, useEffect } from 'react'
import { UNSAFE_NavigationContext as NavigationContext } from 'react-router-dom'
import type { History } from 'history'
export const usePrompt = () => {
const [isOpen, setIsOpen] = useState(false)
const { navigator } = useContext(NavigationContext)
const blockNavigator = navigator as History
useEffect(() => {
const unblock = blockNavigator.block((tx) => {
setIsOpen(true)
if (!setIsOpen) {
unblock()
tx.retry()
}
})
}, [blockNavigator])
return [isOpen, setIsOpen] as const
}
which gives you the state and a getter if you want to make a custom modal or something, you can just change if (!setIsOpen) to if (window.confirm(text)) if you just want the window confirmation thing
hello gamers i have realized a very cool solution i think.
so the issue is that the navigator thingy won't let u block anymore bcuz they got rid of the method for it but if u just wrap it around the History type, u get to have all its methods back
basically:
// it picks only the methods from histroy that are those export declare type Navigator = Pick<History, "go" | "push" | "replace" | "createHref">; ```javascript // u get the mback import { UNSAFE_NavigationContext as NavigationContext } from 'react-router-dom' import type { History } from 'history' const { navigator } = useContext(NavigationContext) const blockNavigator = navigator as History
this is jsut a quick implementation of a usePrompt hook:
import { useState, useContext, useEffect } from 'react' import { UNSAFE_NavigationContext as NavigationContext } from 'react-router-dom' import type { History } from 'history' export const usePrompt = () => { const [isOpen, setIsOpen] = useState(false) const { navigator } = useContext(NavigationContext) const blockNavigator = navigator as History useEffect(() => { const unblock = blockNavigator.block((tx) => { setIsOpen(true) if (!setIsOpen) { unblock() tx.retry() } }) }, [blockNavigator]) return [isOpen, setIsOpen] as const }
which gives you the state and a getter if you want to make a custom modal or something, you can just change if (!setIsOpen) to if (window.confirm(text)) if you just want the window confirmation thing
it‘s very helpful to me. Based on your code, I made a little change. It's easier to use
import { useState, useContext, useEffect, useRef, useCallback } from 'react';
import { UNSAFE_NavigationContext as NavigationContext } from 'react-router-dom';
import type { History } from 'history';
/** @description Blocks all navigation attempts.
* @param when {boolean} Whether to start intercepting navigation.
* @example
* const [flag, setFlag, next] = usePrompt(false);
* useEffect(() => {
* if (flag) {
* // do something like show a dialog etc;
* // at the right time resume bocked navigate
* next();
* }
* }, [flag]);
*/
export const usePrompt = (when = false) => {
const [flag, setFlag] = useState(false);
const confirm = useRef<any>(null);
const context = useRef<any>(null);
const { navigator } = useContext(NavigationContext);
const blockNavigator = navigator as History;
const next = useCallback(() => {
confirm.current();
context.current?.retry?.();
}, [flag]);
useEffect(() => {
if (!when) return;
const unblock = blockNavigator.block((tx) => {
setFlag(true);
context.current = tx;
});
confirm.current = unblock;
return unblock;
}, [blockNavigator, when]);
return [flag, setFlag, next] as const;
};
@qianlongdoit blockNavigator.block is not a function
unfortunately. Sources don't have this feature either.
ave realized a very cool solution i think.
I've also added some updates, in my case I don't want to block routing if some of the query parameters changes. It might not work for all the cases but should give you an idea of how to do it
import { isEqual } from 'lodash';
import { useState, useContext, useEffect, useRef, useCallback } from 'react';
import { UNSAFE_NavigationContext as NavigationContext, useLocation } from 'react-router-dom';
import { usePrevious } from 'react-use';
import { searchStrToObj } from '../helpers/jsHelper';
/** @description Blocks all navigation attempts.
* @param when {boolean} Whether to start intercepting navigation.
* @param skip {boolean| { queryParams: string[] }} Wheter we want to skip preventing if some part of url was changed (for example if changing query parameter doesn't mean that we reload the page)
* @example
* const [flag, setFlag, next] = usePrompt(false);
* useEffect(() => {
* if (flag) {
* // do something like show a dialog etc;
* // at the right time resume bocked navigate
* next();
* }
* }, [flag]);
*/
export const usePrompt = (when = false, { skip } = {}) => {
const [flag, setFlag] = useState(false);
const confirm = useRef(null);
const context = useRef(null);
const location = useLocation();
const { navigator } = useContext(NavigationContext);
const blockNavigator = navigator;
const locationRef = useRef(null)
locationRef.current = location;
const next = useCallback(() => {
confirm.current();
context.current?.retry?.();
}, [flag]);
const shouldSkip = (newLocation) => {
if (!skip) return false;
if (typeof skip === 'boolean') {
return skip;
}
if (locationRef.current.pathname !== newLocation.pathname) {
return false;
}
const { queryParams } = skip;
const currentSearch = searchStrToObj(locationRef.current.search)
const newSearch = searchStrToObj(newLocation.search);
queryParams.forEach(key => {
delete currentSearch[key];
delete newSearch[key];
})
return isEqual(currentSearch, newSearch)
}
useEffect(() => {
if (!when) return;
const unblock = blockNavigator.block((tx) => {
if (shouldSkip(tx.location)) {
confirm.current?.()
tx.retry?.();
return;
}
setFlag(true);
context.current = tx;
});
confirm.current = unblock;
return unblock;
}, [blockNavigator, when]);
return [flag, setFlag, next];
};
// inside your component
const [flag, setFlag, next] = usePrompt(!curatorSaved, { skip: { queryParams: ['cameraName'] } });
useEffect(() => {
if (flag) {
openInfoDialog({
title: 'Do you want to leave current project?',
description: 'All not saved changes will be lost',
cancelButtonText: 'No',
buttonText: 'Yes',
onCancelClick: () => {
setFlag(false);
},
onButtonClick: () => {
setFlag(false);
next();
},
});
}
}, [flag]);
// helpers
export const searchStrToObj = (str) => {
const s = new URLSearchParams(str)
return Object.fromEntries(s);
}
want to block routing
@qianlongdoit
blockNavigator.block is not a function
unfortunately. Sources don't have this feature either.
@xr0master It works in version 6.3.0, update your dependcies and see whether it works?
@qianlongdoit, it has already been removed in 6.4.0 pre release.
Damn. Is route blocking is still a feature that react-router developers are going to support in the future? Looks like it's not getting closer, but incontrary it's getting worse and worse from month to month. Probably somebody knows, how to clarify plans of this library developers for this feature?
@avasuro I also don’t understand why, with such stubborn confidence, developers destroy all workarounds without an alternative. I would love to stay on version 5, but this version does not support React 18...
@avasuro I also don’t understand why, with such stubborn confidence, developers destroy all workarounds without an alternative. I would love to stay on version 5, but this version does not support React 18...
We are working on V5 with react 18 and we experience no issues at all. (and we are waiting form prompt to migrate to v6)
@avasuro I also don’t understand why, with such stubborn confidence, developers destroy all workarounds without an alternative. I would love to stay on version 5, but this version does not support React 18...
@xr0master I locked version on ~6.3.0
. 6.3.0
still provides navigator.block
, so you can build useBlocker
yourself e.g. following https://github.com/remix-run/react-router/issues/8139#issuecomment-1023105785.
@wojtekmaj Thanks, but I have to have the relative="path"
feature.
We are working on V5 with react 18 and we experience no issues at all. (and we are waiting form prompt to migrate to v6)
[off-topic] Oh! Thanks, I tried again, removed the node_modules
and the lock file and a fresh install was able to install the v5 router with react 18! It really works. But without the strict mode. [/off-topic]
Guys are you kidding me? A little more and it will be 1 year for this issue.
When will this issue be resolved? I don’t even see workarounds yet, maybe someone will tell you please how to make analogues of these hooks?
Confirmed we cannot upgrade to v6.4.0 due to the removal of navigator.block
. The versioning scheme for this project seems to be broken when they update the minor version from v6.3.0 to v6.4.0 and create a breaking change.
removing of navigator.block
breaks a lot of sites and should be marked as major change, not minor.
removing of
navigator.block
breaks a lot of sites and should be marked as major change, not minor.
Agreed! At this point I'm looking for an alternative to react-router since they broke my app (AGAIN) and this time with a point version. Goodbye React-Router... It's too bad that you didn't want to get the Prompt
function working again. This was a KEY feature that my company relies on.
Just fyi, it's still possible to use navigator.block via history
package with v6.4.0, by replacing BrowserRouter with unstable_HistoryRouter, upgrade steps
import React from 'react';
import ReactDOM from 'react-dom/client';
import { unstable_HistoryRouter as HistoryRouter } from 'react-router-dom';
import { createBrowserHistory } from 'history';
import App from './App';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<HistoryRouter history={createBrowserHistory({ window })}>
<App />
</HistoryRouter>
</React.StrictMode>
);
https://stackblitz.com/edit/github-fqzmgn-ktacur-historyrouter
@westprophet checkout the example
Purely technically it is open source and we cannot demand anything. We can only do PR ourselves. Of course, it's annoying that a rather serious feature was removed without explanation. Because of this, it is not even clear what kind of PR to do. The second problem is the lack of alternatives for React, or am I wrong?
@xr0master react-location may be an option for some folks, as suggested a bit earlier (https://github.com/remix-run/react-router/issues/8139#issuecomment-1046330015)
for the recent folks chiming in with frustration, make sure you read all the earlier comments in this thread... including taking care to expand the ones github will be automatically hiding from you to lower the volume at this point... there's several of us that have been free sailing with easy workarounds since the beginning of the year, e.g. mine is only 50 lines of code back in April.
for the recent folks chiming in with frustration, make sure you read all the earlier comments in this thread... including taking care to expand the ones github will be automatically hiding from you to lower the volume at this point... there's several of us that have been free sailing with easy workarounds since the beginning of the year, e.g. mine is only 50 lines of code back in April.
Those workarounds (including mine) hinge on getting access to the navigator.block()
API which has been removed from 6.4.0... Good luck upgrading
@heath-freenome you can still use navigator.block via history package, checkout the https://github.com/remix-run/react-router/issues/8139#issuecomment-1247080906
Any fix in the near future? Is it in planning for the next major version of react-route?
Official document says "It will be added in the near future." however, the hope is gone in v6.4.0
unstable_HistoryRouter
I have a very large application that has complex referencing.
I need HashRouter, not HistoryRouter
But, thanks for your answer
I need HashRouter, not HistoryRouter
@westprophet HashRouter is just HistoryRouter with createHashHistory from history package
Just fyi, it's still possible to use navigator.block via
history
package with v6.4.0, by replacing BrowserRouter with unstable_HistoryRouter, upgrade steps
- add history package
- use unstable_HistoryRouter, rather than BrowserRouter
import React from 'react'; import ReactDOM from 'react-dom/client'; import { unstable_HistoryRouter as HistoryRouter } from 'react-router-dom'; import { createBrowserHistory } from 'history'; import App from './App'; ReactDOM.createRoot(document.getElementById('root')).render( <React.StrictMode> <HistoryRouter history={createBrowserHistory({ window })}> <App /> </HistoryRouter> </React.StrictMode> );
- add hook that use navigator.block, like useBlocker
https://stackblitz.com/edit/github-fqzmgn-ktacur-historyrouter
@westprophet checkout the example
it's very useful to me , good job!
Quote: 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.
Where banana?
Has this issue been fixed? I see a merge into master from above pull request.
@pantirugabriel That merge was into a different repo. This issue is still open.
I hope this is added back in for the next release,
I realize it's a bit ironic to write this and trigger all the notifications myself, but
Obviously, with 400+ reactions this is a heated topic. We know this, the maintainers know this. Things like:
"Any updates?"
"Is this going to be included in the next release?"
"+1"
are NOT gonna speed anything up. But there's one thing these replies will do: trigger notifications for 100s of people subscribed to this thread. If you'd like to be notified about the progress, if any, hit Subscribe button on the right hand side.
Removing navigator.block
in 6.4 was not a breaking change from 6.3 because navigator.block
was never a part of the v6 API. I know it's frustrating for those who were been using it as a workaround for removed v5 APIs, but as @mjackson noted above v6 only exposed a subset of the history object's API. Relying on internal implementation details was never what we advised. I'll take it a step further and advise that you should not rely on implementation details, and doing so means you accept any risk that your code will break in future updates.
For those looking for an update: we've spent almost a year rethinking what we might do with useBlocker
or usePrompt
. We hoped we'd be able to add it back, but history blocking was always somewhat half-baked and—in too many cases—leads to broken, buggy user experiences.
Our primary goal with OSS is to help folks build better software, and if a feature fails to meet that goal then we probably shouldn't ship it. Learning these lessons is part of the process, and though painful, breaking major changes are sometimes required to move the project forward.
Given what we've learned, we can't guarantee that these patterns will make it back into future releases. As such our recommendations are:
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.
usePrompt
or useBlocker
, v5 is for you. It is stable and actively maintained. It should work in React 18 as well, so if that is your concern we welcome issues or PRs addressing compatibility bugs.In the mean time, we are working on some demos and updates to our documentation to show specific patterns we think are more appropriate than blocking history. I'll be sure to update the thread as those are completed.
I understand the decision. Sometimes it is needed to throw away old stuff that became a barrier. We had this in our projects often enough, too.
In the meanwhile we decided for our projects where we use v6 to remove all code based on usePrompt/useBlocker and conceptually rework the user interface, because the „unsaved changes“ dialogs where we needed it for were not really great in UX. And the generic web browser messages that popped up in some situations were even worse.
Since we automatically save every relevant stuff in our web app, the UX is better and we can use full-powered v6. There are even situations where saving the last user input is not needed at all and not a real loss. The code is better readable and better maintainable if we compare it to the previous version.
Maybe conceptually changing your user interfaces might also be a way for some of you.
@code-jongleur mind sharing how you keep the form state persisted? Do you use sessionStorage to avoid having to manage cleaning it up? Is there any times you ran into situations where you needed a time stamp or something to check how long ago they saved this state?
I'd love to move to this strategy but wondered about when to throw a at the users previous form state.
mind sharing how you keep the form state persisted? Do you use sessionStorage to avoid having to manage cleaning it up? Is there any times you ran into situations where you needed a time stamp or something to check how long ago they saved this state?
It depends on the application and context. Usually the user is logged in and there is a session etc. The work is saved almost immediately when the user makes some changes. We collect the changes in the frontend and then send a save request.
The form data/user's work is sent to the backend and next time, he visits the page with the same url, loaded again. That's also nice when the user closes the browser in accident and re-opens it. Then he can continue the work without any break. I do not remember that we need a timestamp for this, only in scenarios where a document is locked for a special user and unlocked after a period of time.
Sometimes it's enough to use the local storage, eg for very user specific stuff like last collapse or expand state of some panels or for input elements that are only relevant for the current user. That's also an option. We have a mixture of both. Hope my answer helps you somehow.
I don't want to be rude to anyone, this is open source and everyone does what they think is right, but it sounds funny: "We've been thinking about how to do this for a whole year (!!!), because we didn't think about it during technical design, and now it’s like a hole in the head."
Furthermore, funny true stories about UX and how cool that this feature is removed.
@code-jongleur If calling a built-in block
function causes more complexity in your code than writing a draft state or a sync, you have a big problem with app architecture. I'm glad you had the chance to rewrite it all.
Now let's turn off the cynical mode and discuss how to make such functionality. @ericchernuka you can immediately synchronize the new state with the server if your project logic allows it. This is important because any accidental click/press on any input will immediately go into "production". If this is not possible, you need to develop a draft state. It is necessary to indicate somewhere in a VERY noticeable place a banner/alert that this view is in the draft state so that the client understands that what he sees is not in "production" yet.
Where to store the draft state depends on your application and how important the loss of information is. The sessionStorage
is cleared when the user closes the browser window (not the tab!!!). If it is too short, then it is better to use the localStorage
. It can also be cleared by OS, but this doesn't happen often.
Probably the most difficult thing is restoring the draft state after opening the view. If the view's state has been changed from somewhere else, then your draft state is in conflict. How to solve it again depends on your application, if possible it is better to drop the draft state if the version on the server has changed.
As you can see, creating a draft state is much more complicated than calling an "ugly built-in popup" in one line. It requires a logical decision of the product manager, as well as a large number of changes in the application. Undoubtedly, this improves UX, but requires resources, instead of developing more useful things.
Good luck with your architecture! And once again I want to say thanks to the contributors of this library, they do it for free, please do not forget about it.
P.S. We have decided to stick with version 5 for now, despite various warnings from NPM about package incompatibilities.
removing of
navigator.block
breaks a lot of sites and should be marked as major change, not minor.Agreed! At this point I'm looking for an alternative to react-router since they broke my app (AGAIN) and this time with a point version. Goodbye React-Router... It's too bad that you didn't want to get the
Prompt
function working again. This was a KEY feature that my company relies on.
Give UI Router a try, it's one of the best routers out there! https://ui-router.github.io/
@xr0master re: dependency warnings, https://github.com/remix-run/react-router/pull/9382 should get rid of that if we can cut a quick patch. Seems like that would be the only one triggered by us.
Just a heads-up: the folks maintaining React Admin aren't updating their code because they think you will be reintroducing this method. Since it looks like you won't, I guess someone should let @fzaninotto know that a different approach is needed in order to stay compatible with the newest versions of React Router.
As an FYI, there are more use cases for block than just "put up a dialog". I have an implementation of a wizard that will prevent the user from moving forward or backward when there is a condition that requires the user to fix something, such as waiting for an API to finish, dealing with API errors, etc.
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.