Closed jorroll closed 2 years ago
I'm have a similar need to listen to history changes for other parts of the app on the same page that aren't written in React. Related https://github.com/remix-run/react-router/issues/9385
Yeah, this is really needed to be able to navigate outside of React components.
Being able to navigate outside React components is a key feature. Please add this back as I'm stuck on v6.3 on several projects until you do.
i'm stuck too, please, it is imprescindible
Yes, please Shopify :-) https://remix.run/blog/remixing-shopify
Hey folks! There's a bunch of separate issues around navigating from outside the react tree in 6.4, so I choose this one as the de-facto "source of truth" issue 🙃. I'll drop an answer here and then link off to it from as many other issues as I can find. Let's try to centralize further discussion here if we can so we don't miss anything!
There's some nuance here based on the version you're using so I'll try my best to provide a clear explanation of the paths forward.
Folks on 6.0 through 6.3
For those of you using unstable_HistoryRouter
in 6.0->6.3, that was dropped from the updated docs but it's still there and you can still use it (with the same warnings as before). It's not considered stable and is open to breaking in the future.
Folks on 6.4+ but not using a Data Router
If you've upgraded to 6.4+ but aren't using a data router, then you can also still use unstable_HistoryRouter
but there are some nuanced changes to the way we interface with window.history
internally. Your best bet is probably just to add history@5
as your own dependency and import createBrowserHistory
from there.
You can try using the createBrowserHistory
from react-router-dom@6.4
but you will need to instantiate it as createBrowserHistory({ v5Compat: true })
to opt-into the v5 behavior. The newer version also currently doesn't allow multiple listeners since it wasn't intended for external consumption, so it's only really useful for navigating from outside the react tree, but not listening for updates from outside the tree. If you need that then go with history@5
.
Folks on 6.4+ using a Data Router (RouterProvider
)
🎉 Congrats on upgrading to the new data routers and we hope you're loving the UX improvements you can get from loaders/actions/fetchers! If you're still in need of a way to navigate or respond to updates from outside the react tree, then there's both good and bad news!
The good news is that you can just do it manually via the router
instance you get from createBrowserRouter
! By introducing data APIs, history
is really just an implementation detail now and the router
is the entry point. This is because we have to fetch data prior to routing so we can't just respond to history
events anymore.
let router = createBrowserRouter(...);
// If you need to navigate externally, instead of history.push you can do:
router.navigate('/path');
// And instead of history.replace you can do:
router.navigate('/path', { replace: true });
// And instead of history.listen you can:
router.subscribe((state) => console.log('new state', state));
Now, for the bad news 😕 . Just like unstable_HistoryRouter
we also consider this type of external navigation and subscribing to be unstable, which is why we haven't documented this and why we've marked all the router APIs as @internal PRIVATE - DO NOT USE
in JSDoc/Typescript. This isn't to say that they'll forever be unstable, but since it's not the normally expected usage of the router, we're still making sure that this type of external-navigation doesn't introduce problems (and we're fairly confident it doesn't with the introduction of useSyncExternalStore
in react 18!)
If this type of navigation is necessary for your app and you need a replacement for unstable_HistoryRouter
when using RouterProvider
then we encourage you use the router.navigate
and router.subscribe
methods and help us beta test the approach! Please feel free to open new GH issues if you run into any using that approach and we'll use them to help us make the call on moving that towards future stable release.
Thanks folks!
Also w.r.t. this specific issue I'm going to close it as I think the router provides the behavior you need, however please note that you cannot cancel these events - subscribe will be called after the navigations.
I'd like to subscribe to history events and respond to them within my application--both before and after react router receives these events (e.g. maybe I want to cancel an event before it reaches React Router).
If you're looking to cancel an event then that's a different thing - please check out this comment and follow along in that issue - but I don't think we'll be bringing blocking back into v6 since it's always been a bit of a hacky implementation (since you cannot "block" a back/forward navigation from the browser).
Folks on 6.4+ but not using a Data Router
If you've upgraded to 6.4+ but aren't using a data router, then you can also still use
unstable_HistoryRouter
but there are some nuanced changes to the way we interface withwindow.history
internally. Your best bet is probably just to addhistory@5
as your own dependency and importcreateBrowserHistory
from there.
@brophdawg11, thx for the explanation. I tried this approach, and I am getting typescript error:
Property 'encodeLocation' is missing in type 'BrowserHistory' but required in type 'History'.ts(2741)
So it seems like this approach is broken due to the new "encodeLocation" requirement for History.
Ah - thanks for calling this out - that's a new 6.4-only thing. Does everything work fine if you ignore that with // @ts-expect-error
? That encodeLocation
method is new in the internal history
and only used in <RouterProvider>
so it shouldn't ever be referenced if you're using unstable_HistoryRouter
Ah - thanks for calling this out - that's a new 6.4-only thing. Does everything work fine if you ignore that with
// @ts-expect-error
? ThatencodeLocation
method is new in the internalhistory
and only used in<RouterProvider>
so it shouldn't ever be referenced if you're usingunstable_HistoryRouter
Thanks @brophdawg11 , yes silencing TS error works, but may miss future issues. What about adding the new method to "history" package as well and bump to 5.4.0 ? This will be smoother for transition, IMO.
Please note that you can make the jump to <RouterProvider>
really easily:
Your code probably looks something like this today:
const history = createBrowserHistory({ window });
<unstable_HistoryRouter history={history} />
We're just gonna swap that for createBrowserRouter
with a single splat route and <RouterProvider>
.
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import { createBrowserRouter, RouterProvider } from "react-router-dom"
const router = createBrowserRouter([
// match everything with "*"
{ path: "*", element: <App /> }
])
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<RouterProvider router={router} />
</React.StrictMode>
)
And then the rest of your routes will work as they always did inside of components:
import { Routes, Route, Link } from "react-router-dom";
export default function App() {
return (
<main>
<h1>Can use descendant Routes as before</h1>
<ul>
<li>
<Link to="about">About</Link>
</li>
</ul>
<Routes>
<Route index element={<div>Index</div>} />
<Route path="about" element={<div>About Page</div>} />
<Route path="contact" element={<div>Contact Page</div>} />
</Routes>
</main>
)
}
router
to navigate outside of the treeIt is worth mentioning that after updating to <RouterProvider>
, the router will catch any errors and if you want them to propagate up to a root error boundary at the top of the component tree, you have to specify an errorElement
for every <Route>
and re-throw the error:
<Route path="about" element={<div>About Page</div>} errorElement={<RouteErrorBoundary />} />
// ...
function RouteErrorBoundary() {
const error = useRouteError();
throw error;
};
updating to router provider
ts reported an error during use
@ryanflorence Thanks for your suggestion. But actually this approach has a problem withcircular dependency
.
The router
uses the App
, component. But the App
component uses any component, which can use the router
Put it in a module and import it both places, I was just showing the code, not the organization of it :)
@ryanflorence unfortunately it won't help because the cycle exists between entities, not just between files. So React components probably shouldn't use router
at all, they have access to useLocation
/useNavigate
instead
So React components probably shouldn't use
router
at all
@Hypnosphi admittedly I haven't tried any of the suggestions mentioned here yet, but, worst case scenario, couldn't you just use react context to pass the router
instance down to components?
@jorroll nice idea, this should work
This may be frowned upon, but I store the router
as a property of a global object called $app
that I attach to window
.
Some form of history listener, synthetic or otherwise, would allow for an analog for forward/backward navigation when using navigate()
. navigate
can be used within an event handler, to cause forward motion, but if a user requests backward motion, we have no event handler to track this. We end up having to use an effect on Location, which has different lifecycle considerations.
@ryanflorence Thanks for your suggestion. But actually this approach has a problem with
circular dependency
. Therouter
uses theApp
, component. But theApp
component uses any component, which can use therouter
When a function uses something that creates a circular dependency, I'll typically inject the dependency into a variable at the top level of the module, so that when the function is finally called, that dependency is available:
Component.jsx:
let router = null;
export function injectRouter(newRouter) {
router = newRouter;
}
export default function Component() {
// use router with impunity, because it was injected as part of the module declaration below.
}
router.js:
import { injectRouter } from 'Component';
export const router = ...
injectRouter(router);
@Valar103769 What's your point? If you need to navigate outside components in a single case, then it's required... Of course most of the navigation occurs inside components. That's not the point. Please don't minimize this issue as it's causing huge problems for a lot of people. Using unstable methods is a workaround, we need a fix.
Hey!
I wonder what would be the suggested approach for micro frontends. Ideally, they would all share the same history
. Before, we could create a browser history on the parent app using createBrowserHistory
and inject that history
on subapps when those are mounted.
Now, we can't pass history
as props or even extract it from Router
or any of the Router components. I noticed you can pass a Navigator
prop to Router
, but I couldn't find a way to get that object anywhere, there are no hooks for it. This would also require creating an interface so it could be used by other apps that don't have ReactRouter v6 (or other frameworks even), making it much less flexible than having the history
.
Any ideas or examples on how to do it?
@davidpn11 Couldn't that be done with the new router
instead of history
as explained in this comment https://github.com/remix-run/react-router/issues/9422#issuecomment-1301182219 ?
@ChristophP my biggest concern with passing the router
solution is it doesn't allow us to specify nested routes in the micro frontends
for example, it's common to have a shell application that mounts micro frontend applications, where the shell will let the micro frontend define its own nested routes
is there a way to dynamically re-configure the routes in the router
?
also, the shared history
object was a way to keep the state of the router in sync across the shell application and the micro frontend applications
for example, each micro frontend would create its own unstable_HistoryRouter
, and pass the shared history
object to it as a way to have one common history state
@ryanflorence I have a problem referencing the router outside the tree following this example https://github.com/remix-run/react-router/issues/9422#issuecomment-1302564759
I am importing css and css modules in my jsx, and this breaks my application, since I am importing the router in a ts file. The router references <App>
, and with it all the css modules which get misinterpreted as malformed typescript
the error is this
body {
^
SyntaxError: Unexpected token '{'
Do you have any suggestions? Thanks for your time and patence
@fdrcslv I don't think that this is a router-specific problem. Where are you importing these? In a test? E.g. for jest you can tell jest to treat these as empty objects: https://stackoverflow.com/questions/39418555/syntaxerror-with-jest-and-react-and-importing-css-files
@dbartholomae I'm importing the router in a ts file where is defined a state machine with which I control part of the redux state and some side effects.
The error only comes up when I import the router there.
Maybe the problem is that the state machine (or rather its parent) is referenced in a component? I would expect a circular reference error, and not this weird css thing.
EDIT:
I tried importing directly the <App>
component in my ts file and the same error happens. Since the router references the same <App>
I don't know how to get around this issue
@brophdawg11 Are we any closer to a solution? Almost 6 months went by, this feature request is now closed, and as far as I'm aware the only way to navigate outside components is by using an unstable
method. Why is this feature request closed? Was there a decision to drop this feature? Thanks!
The recommendation is still the same - router.navigate()
is the correct way to trigger a navigation outside components when using RouterProvider
. This issue remains closed as we are not planning on exposing history
or allowing a custom history
because history
is no longer the driving "navigator" in a RouterProvider
, the router
is.
There's been some comments about micro-frontends in here, that is something we are planning on tackling eventually so you can dynamically add new sections of the route tree to your router
. For the time being through, remember that a RouterProvider
can still use descendant <Routes>
(as shown by Ryan in this comment), they just can't participate in data loading APIs (loader/action/fetcher/useMatches/etc.). That should allow you to dynamically render sub-apps via <Routes>
: https://codesandbox.io/s/routerprovider-mfe-zj2xq0
The recommendation is still the same -
router.navigate()
is the correct way to trigger a navigation outside components when usingRouterProvider
.
By taking this stance and not exposing a useful history
, you've invalidated a Web API. That seems counter to the project's state goals of making things work like normal web pages.
When we want a page to work like it's on the server, everything seems to work. When we want a page to work like it's on the client... it seems like that should work, too, no?
Forget how to navigate. How do we add event listeners to history
? Should we not be able to do that? Or should we be able to do so through the standard browser API without a hook, and Router behaves properly with that?
I'm still confused how testing should work in this case. Currently, we do the following:
function createTestWrapper({ history }) {
const store = createReduxStore({history});
return <Provider store={store}><HistoryRouter history={history}>{children}</HistoryRouter></Provider>
}
it('navigates to /page after 5 seconds on click', async () => {
const history = createMemoryHistory({ initialEntries: '/profile' });
render(<Profile />, { wrapper: createTestWrapper({ history}) });
await click(screen.findByText("Navigate in 5 seconds"));
jest.advanceTimersByTime(5_000);
expect(history.location.pathname).toBe('/page');
})
Without a HistoryRouter
, I'm not sure how to ensure that all components in the test use the same history (in this case: the initial setup, the redux store, the React components, and the expect statement).
@brophdawg11 I understand, but as you wrote above, this is in beta and not well documented. This is not a solution yet. I'm reluctant to refactor my entire router to a beta or unstable approach. What happens if you decide to deprecate this in the future? I understand the complexities that arise from implementing new features, but navigating outside the router is a MUST for many projects. We've all been handling data-fetching on our own and we can keep doing so, but navigation is a router thing. I can't do that somewhere else.
My two cents on this matter, and I'm sure I'm not alone:
The React team did an entire version (React 17) in order not to break compatibility. Which should speak of its importance.
Please considering providing a long term, well documented, stable alternative to this issue.
Thanks!
By taking this stance and not exposing a useful history, you've invalidated a Web API. That seems counter to the project's state goals of making things work like normal web pages.
I disagree. Keep in mind that there are two things at play here -- window.history
and the history
package. The history
package we've hidden as an implementation detail is not a Web API. It's a light abstraction around window.history
that attempts to reduce some of the flaws with window.history
. But it was also a flawed abstraction in that it was not "making things work like normal web pages". history
was using a "navigate/render then fetch" approach. The new router is a correction back to "fetch then navigate/render" which is exactly what the browser would do in an MPA - so the idea of emulating the browser is still very much at the core of the router design decisions.
window.history
has a number of known design flaws which is why it's being eventually replaced with the Navigation API. We chose to treat history
as an implementation detail both to work around it's flaws as well as future proof us for the arrival of the Navigation API.
Forget how to navigate. How do we add event listeners to history? Should we not be able to do that? Or should we be able to do so through the standard browser API without a hook, and Router behaves properly with that?
The standard browser API is popstate
event, which you are welcome to use, although it's not super helpful since it won't tell you about PUSH/REPLACE navigations. router.subscribe()
is the new abstraction (replacing history.listen
) for knowing about state/location changes. Eventually we will likely add a more advanced Events API but for now router.subscribe
is the way to go. If you need to know before a navigation I'd use click handlers on Link
or your own wrapper function around router.navigate
.
This is not a solution yet. I'm reluctant to refactor my entire router to a beta or unstable approach
That's perfectly fine - you're welcome to wait until we stabilize router.navigate
. But I hope the chicken and egg problem is not missed here. We've specifically asked for some early adopters to try it out in order for us to feel more comfortable stabilizing it 😄
Removing key features without a solid alternative, is even worse.
I'm not sure how this applies. The Data APIs were introduced in a fully backwards compatible manner and you are not obligated to use them (see above). If you're talking about navigating outside the components then the "solid alternative" is router.navigate
. Ryan has said as much above with "use the router to navigate outside of the tree" comment.
We're in disagreement about what a solid alternative is. A solid alternative
is not in beta or unstable. You can't both admit it's fine for me to wait until you stabilize this feature, and then negate the fact that you're not providing a solid alternative. You're contradicting yourself. Yes I'm talking about navigating outside components which is a must for any router. It's not a minor feature or a nice to have.
I also disagree with you blaming users for not helping you test a beta feature. Not everyone can do that, it depends on team size, infrastructure, etc.
Whenever you release a feature, people start depending on it and you have a responsibility to provide a STABLE path forward for it. Otherwise you are at fault. You're leaving people out. Users that chose your product and trusted you.
I'd love to see an open ticket, tracking the progress of this stabilization or asking for help to achieve that. At least. But they keep being closed (yes, I've been tracking this issue through several tickets already)
router.subscribe()
is the new abstraction (replacing history.listen) for knowing about state/location changes.
This sounds perfect. I didn't find it referenced in the documentation. Am I missing it?
router.subscribe()
is the new abstraction (replacing history.listen) for knowing about state/location changes.This sounds perfect. I didn't find it referenced in the documentation. Am I missing it?
It's currently marked as internal, so similar to an unstable_
API. So, no docs for now, but it's simple enough to figure out from the types. It sends over a RouterState
object on updates.
Such an interesting maintainers vs users debate. Let me share a few observations and let's be objective please.
Navigation outside of React rendering tree is a basic application requirement. Applications aren't limited to React components. React is a rendering library and complete application needs to fill gaps which React leaves. Navigation is one of such gaps. As @brophdawg11 mentioned, browsers already implement history API and some implement navigation API. History package implements abstraction over history API. I liked the beauty of history package and React Router v5 where users have options to either use only React Router v5 or a combo of history package and React Router v5 in a way that you don't end up with a duplicate history package in the bundle (neat).
@brophdawg11 solution shared by Ryan https://github.com/remix-run/react-router/issues/9422#issuecomment-1302564759 locks you into "Data router". Also, intercepting native browser back navigation locks you into "Data router" as well: https://github.com/remix-run/react-router/issues/8139#issuecomment-1397299637. As a result simply using basic routing features now results in the entire library ending up in the application bundle: https://github.com/remix-run/react-router/issues/10354#issue-1670025873. From @mjackson v6 announcement blogpost: https://remix.run/blog/react-router-v6#why-another-major-version:
Also, it's not just your code that's getting smaller and more efficient... it's ours too! Our minified gzipped bundle size dropped by more than 50% in v6! React Router now adds less than 4kb to your total app bundle, and your actual results will be even smaller once you run it through your bundler with tree-shaking turned on.
This is actually the other way around now, every non-trivial application ends up with 63kB RRv6 bundle vs 28kB RRv5: https://github.com/remix-run/react-router/issues/10354. We're not even using data related features and it has nothing to do with the bundlers/configs, it's RRv6 code design decisions.
Is it possible to clearly define boundaries what router should be doing vs what it shouldn't? Would it make sense to start another navigation library (like history) which will abstract browser history and navigation API and supersede history library? Would it make sense to split React Router library into two: one purely navigation-related and another data-related so that we don't end up dragging a lot of unnecessary code to be able to use basic navigation features?
I'm not sure if majority of the users want a simple (from the usage perspective) package that used to provide an abstraction over navigation-related browser API in React projects to become an architectural jello: [Docs]: most ANNOYING doc I've ever seen in a long time for such a mainstream lib.
Not being able to listen to Route changes natively is one of the reasons why Libraries like the history package were created in the first place.
Hopefully, going forward the new Navigation API will solve that problem and make routing libraries less invasive https://developer.chrome.com/docs/web-platform/navigation-api/ Not supported in all browsers yet but it looks promising for the future.
In the future it will be possible to do the following:
window.navigation.addEventListener("navigate", (event) => {
// do whatever you need to do in response to navigation
});
with the new Navigation API
It's already in Chrome and Edge but will need some more time until it's in all Browsers
Because of the different downsides of the mentioned approaches I took another "route". I am basically relying on browser event listeners to fire events when I want to navigate outside of router context. Then I have a listener inside the part of the app that has access to router. This listener then manages the navigation. It is a bit old fashioned but it worked out very well for me and I have had this code in production for months now without any complaints. If you want to see the code details have a look at the section "Navigating outside of Router's context" on my blog: https://frontendwithhasan.com/articles/react-router-upgrade#navigating-outside-of-routers-context.
@brophdawg11 @ryanflorence this solution is only for those who are on createBrowserRouter
or createRoutesFromElements
i cant use any of these in my app, and i am aware that that i wont have support for DATA api's, how can i navigate outside of react context ?
this is what my app looks like
<BrowserRouter>
...routes
</BrowserRouter>
@brophdawg11 What is the recommended way to navigate outside of React components in a Remix app in SPA mode?
I’m trying to redirect the user to the login page whenever the REST API returns 401 Unauthorized, via Axios interceptors. Since there is no createBrowserRouter
in @remix-run/react
, I have to import it from react-router-dom
, but what route config should I pass to the function? Remix already handles route config with its file-based routes convention.
Or, how can I get the router
object returned from createBrowserRouter()
called by Remix internally?
@brophdawg11 A loader
is a good place to do this kind of logik.
Define a loader like this
import { replace, createBrowserRouter, type LoaderFunction } from 'react-router-dom';
const requireAuth: LoaderFunction = async ({ request }) => {
const currentUrl = new URL(request.url);
const params = new URLSearchParams({
from: `${currentUrl.pathname}${currentUrl.search}`,
});
// If the user is not logged in and tries to access a protected page, we redirect
// them to `/login` you can even use a `from` parameter that allows login to redirect back
// to this page upon successful authentication
if (isUserAuthenticated()) {
throw replace(`/login${params.toString()}`); // redirect to login page
}
return null;
};
Then wrap all your routes that require authentication like this
const router = createBrowserRouter([
{ path: '/login', Component: LoginPage }, // do not require auth for login page
{ loader: requireAuth, children: [
// all routes that are supposed to require authentication
] } // pass loader
])
Hi @ChristophP. Thank you for your suggestion! I’m actually developing a single-page application so there is no loader
but clientLoader
. All the client routes are protected and thus require authentication.
The problem is that even though the frontend “thinks” the user is logged in, the session on the server side could have been timed out or deleted. So I needed a way to globally check the HTTP status code and redirect to the login page in case of 401, so that other parts of the frontend code doesn’t need to worry about the authentication status. That’s why I was trying to take advantage of Axios interceptors. Does your solution still work in my case?
@luangong Disclaimer: I haven't worked with Remix, only with React router. But given their similarities I guess we both work with SPAs.
So as long as you can have an axios interceptor
callback I guess you could take the router returned by createBrowserRouter()
and call router.navigate("/login", { replace: true })
in you interceptor. That should take care of the expired auth case. For the other case where someone is navigating to a page while not being authenticated, the other solution with loaders should do.
Does this help?
@ryanflorence Thanks for your suggestion. But actually this approach has a problem with
circular dependency
. Therouter
uses theApp
, component. But theApp
component uses any component, which can use therouter
What could be the solution for this circular DEP problem? It breaks HMR in vite. I tried the solution with the injectRouter function, but it also breaks HMR in vite. I need to make API requests from both components and from loaders. In both cases, I would like to make redirects in case of 401 errors. I made an apiClient.ts that does this work. But the problem is that I need to use a router in it, and I am getting a circular dependency problem. Maybe there are other solutions?
What is the new or updated feature that you are suggesting?
The new
createBrowserRouter()
API internally callscreateBrowserHistory()
but doesn't provide a way for end-users to get access to the created history object if we'd like to subscribe to history events. I'd like the ability to instantiatecreateBrowserHistory()
myself and provide the history object as an option tocreateBrowserRouter()
. Using the old,v6.3
api, doing something like this was possible via theunstable_HistoryRouter
.It seems likely that something along these lines is already planned, but there doesn't appear to be a tracking issue for this feature.
I actually tried using the internal API myself to implement a custom
createBrowserRouter()
and provide my owncreateBrowserHistory()
to it (using the newcreateBrowserHistory
export from"@remix-run/router"
, buthistory.listen()
only accepts a single listener#1
and monkeypatch the history object to accept multiple listeners, it doesn't appear as though the listener is ever actually called (perhaps this is a placeholder method which isn't fleshed out yet? Obviously this is internal API at the moment so I'm not surprised things aren't working yet).Why should this feature be included?
I'd like to subscribe to history events and respond to them within my application--both before and after react router receives these events (e.g. maybe I want to cancel an event before it reaches React Router).