Skip to content

Instantly share code, notes, and snippets.

@N1kto
Last active December 15, 2023 01:53
Show Gist options
  • Save N1kto/ecea9bfe4daaabd7e41cbd2f0f284fb5 to your computer and use it in GitHub Desktop.
Save N1kto/ecea9bfe4daaabd7e41cbd2f0f284fb5 to your computer and use it in GitHub Desktop.
A workaround for react-router v6 history block

Blocking user navigation within react-router v6

Upgraded react-router to v6 (6.4.3 as of writing) and suddenly realized (or maybe not suddenly) that <Prompt /> is gone and navigator (aka history) object has no longer block method exposed? Here is a workaroud.

It's a bit hacky and may stop working in the next versions of react-router, but (1) it does the trick and (2) as promised (several times) in this issue there should be a blocking API provided wihtin react-router soon.

This workaround is based on simple strategy: block react-router from navigation by carefully "crashing" it when block is needed and allowing it to operate when block is released. To achieve that we exploit the window prop of Router component. We provide a proxy window object which has a special handling of history field. Within react-router, any navigation action requires access to window.history, so if we control this access we somewhat control the navigation execution within react-router. But this control is very limited. In fact, we can only make it "crash" when react-router tries to access history field to process navigation and thus effectively prevent the navigation from happening.

Checkout the code for further details.

Caveats

This solution doesn't allow you to cusotmize the confirmation dialog. Only native browser confirmations are available.

Happy coding!

import routerWindow from './routerWindow';
export const App: React.FC = () => {
return (
<Router window={routerWindow}>
{/* Your routes here */}
</Router>
);
};
import React, { useEffect, useRef } from 'react';
import { blockRouting } from '../../routerWindow';
interface CustomPromptProps {
message?: string;
when?: boolean;
}
const CustomPrompt: React.FC<CustomPromptProps> = ({ message, when }) => {
const unblockRef = useRef<ReturnType<typeof blockRouting> | null>(null);
useEffect(() => {
if (when) {
unblockRef.current = blockRouting(message);
}
return () => {
if (unblockRef.current) {
unblockRef.current();
}
};
}, [when, message]);
return null;
};
export default CustomPrompt;
const BLOCKED_NAVIGATION_ERROR_MESSAGE = 'Router navigation was blocked';
let routingBlock: { prompt: string; href: string } | null = null;
const blockedNavigationErrorHandler = (evt: ErrorEvent) => {
if (evt.error.message === BLOCKED_NAVIGATION_ERROR_MESSAGE) {
// prevent red message in console
evt.preventDefault();
// stop bubbling
evt.stopPropagation();
// mark cancelled
return true;
} else {
// TODO: should we re-throw here?
// mark as not cancelled
return false;
}
};
// handles browser back/forward controls
const popstateHandler = (evt: PopStateEvent) => {
if (routingBlock) {
// revert the blocked url. this "breaks" browser back/forward buttons since we are pushing to
// the top of the history stack, so when unblocked, those buttons won't navigate relative to the
// blocked href position in the history stack, but relative to the top of the stack (hence
// probably no forward). If there was way to know whether this popstate was caused by forward or
// back buttons we could use history.go(-1) or history.go(1) - in that case back/forward buttons
// would work as expected upon block release, however we don't have such info here
window.history.pushState({}, '', routingBlock.href);
}
};
const beforeUnloadHandler = (evt: BeforeUnloadEvent) => {
if (routingBlock) {
evt.preventDefault();
return (evt.returnValue = routingBlock.prompt);
}
};
function cleanup() {
routingBlock = null;
window.removeEventListener('error', blockedNavigationErrorHandler, true);
window.removeEventListener('popstate', popstateHandler, true);
window.removeEventListener('beforeunload', beforeUnloadHandler, true);
}
export function blockRouting(prompt?: string) {
routingBlock = {
prompt: prompt ?? "Are you sure you want to navigate away?",
href: window.location.href,
};
window.addEventListener('error', blockedNavigationErrorHandler, true);
window.addEventListener('popstate', popstateHandler, true);
window.addEventListener('beforeunload', beforeUnloadHandler, true);
return cleanup;
}
// when nothing is blocked passes through all history fields, throws when blocked
const historyProxy = new Proxy(window.history, {
get(target, prop, receiver) {
if (routingBlock) {
const confirmed = window.confirm(routingBlock.prompt);
if (confirmed) {
// release the block and let the navigation happen
cleanup();
} else {
// block react-router navigation by throwing an error here
throw new Error('Router navigation was blocked');
}
}
const value = target[prop];
if (value instanceof Function) {
return function (this: any, ...args: any[]) {
return value.apply(this === receiver ? target : this, args);
};
}
return value;
},
});
// pass-through proxy of window object with special handling of "history" field which is used by
// react-router
const windowProxy = new Proxy(window, {
get(target, prop, receiver) {
if (prop === 'history') {
return historyProxy;
}
const value = target[prop];
if (value instanceof Function) {
return function (this: any, ...args: any[]) {
return value.apply(this === receiver ? target : this, args);
};
}
return value;
},
});
export default windowProxy;
import React from 'react';
import CustomPrompt from 'custom_prompt';
const SomeComponent = () => {
const [state, setState] = useState({ formData: {}, changed: false });
const { formData, changed } = state;
const handleChange = useCallback((newData) => {
setState({ formData: newData, changed: true });
}, [])
return (
<div>
<SomeFormWithLotsOfFieldsToFill onChange={handleChange} data={formData} />
<CustomPrompt when={changed} message="You gonna lose your data, are you sure?" />
</div>
);
};
export default SomeComponent;
@quinndgodfrey
Copy link

Thanks for posting this , I'm hoping I can do something similar until v6 of react-router is updated to have similar functionality.

I'm currently having trouble getting this to work for me though, I'm not seeing a 'window' prop on the 'Router' component.

I've copied the interface for the props that are passed into the router component and they are as follows:

export interface RouterProps {
  basename?: string;
  children?: React.ReactNode;
  location: Partial<Location> | string;
  navigationType?: NavigationType;
  navigator: Navigator;
  static?: boolean;
}

I also don't see version 6.3.4, so I was wondering if that was maybe a version that is not longer available and had different props that were used or something.

@N1kto
Copy link
Author

N1kto commented Dec 14, 2022

@quinndgodfrey my bad, I did typo in version number. It should be "react-router-dom": "6.4.3". Thank you for pointing at this.

@ameen7626
Copy link

Thank you for sharing this. After I removed the error that was being thrown in the historyproxy function, I noticed that the warning prompt doesn't close when I click the "Cancel" button. I'm curious about the reason behind this behavior.

@victorcordeiro22
Copy link

same here, is there a way to avoid throwing this error and simply close the confirmation alert?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment