Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feature]: allow providing history object when calling createBrowserRouter() #9422

Closed
jorroll opened this issue Oct 7, 2022 · 47 comments
Closed
Labels

Comments

@jorroll
Copy link

jorroll commented Oct 7, 2022

What is the new or updated feature that you are suggesting?

The new createBrowserRouter() API internally calls createBrowserHistory() 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 instantiate createBrowserHistory() myself and provide the history object as an option to createBrowserRouter(). Using the old, v6.3 api, doing something like this was possible via the unstable_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 own createBrowserHistory() to it (using the new createBrowserHistory export from "@remix-run/router", but

  1. history.listen() only accepts a single listener
  2. If I work around #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).

@jorroll jorroll added the feature label Oct 7, 2022
@ChristophP
Copy link
Contributor

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 #9385

@Mikilll94

This comment was marked as duplicate.

@sergioavazquez

This comment was marked as duplicate.

@yangoff

This comment was marked as duplicate.

@ChristophP

This comment was marked as off-topic.

@brophdawg11
Copy link
Contributor

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 [email protected] 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).

@yann-combarnous
Copy link

yann-combarnous commented Nov 3, 2022

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.

@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.

@brophdawg11
Copy link
Contributor

brophdawg11 commented Nov 3, 2022

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

@yann-combarnous
Copy link

yann-combarnous commented Nov 3, 2022

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

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.

@ryanflorence
Copy link
Member

ryanflorence commented Nov 3, 2022

Updating to RouterProvider

Please note that you can make the jump to <RouterProvider> really easily:

See this live

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>
  )
}
  • use the router to navigate outside of the tree
  • if you want to take advantage of the data APIs, incrementally move routes to the router instance when you want to

@evoyy
Copy link

evoyy commented Nov 5, 2022

It 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;
};

@hsbtr
Copy link
Contributor

hsbtr commented Nov 6, 2022

updating to router provider

ts reported an error during use
image

@rtatarinov
Copy link

@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

@ryanflorence
Copy link
Member

Put it in a module and import it both places, I was just showing the code, not the organization of it :)

@Hypnosphi
Copy link

Hypnosphi commented Nov 8, 2022

@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

@jorroll
Copy link
Author

jorroll commented Nov 8, 2022

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?

@Hypnosphi
Copy link

@jorroll nice idea, this should work

@evoyy
Copy link

evoyy commented Nov 9, 2022

This may be frowned upon, but I store the router as a property of a global object called $app that I attach to window.

@landisdesign
Copy link
Contributor

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.

@landisdesign
Copy link
Contributor

@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

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);

@sergioavazquez
Copy link

@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.

@davidpn11
Copy link

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?

@brophdawg11
Copy link
Contributor

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

@landisdesign
Copy link
Contributor

The recommendation is still the same - router.navigate() is the correct way to trigger a navigation outside components when using RouterProvider.

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?

@dbartholomae
Copy link

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).

@sergioavazquez
Copy link

@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:

  • Changing APIs constantly as you do, is terrible for developers that consume your products.
  • Removing key features without a solid alternative, is even worse.

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!

@brophdawg11
Copy link
Contributor

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.

@sergioavazquez
Copy link

sergioavazquez commented Mar 28, 2023

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)

@landisdesign
Copy link
Contributor

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?

@timdorr
Copy link
Member

timdorr commented Mar 28, 2023

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.

@g3tr1ght
Copy link

g3tr1ght commented May 27, 2023

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 #9422 (comment) locks you into "Data router". Also, intercepting native browser back navigation locks you into "Data router" as well: #8139 (comment). As a result simply using basic routing features now results in the entire library ending up in the application bundle: #10354 (comment).
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: #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.

@ChristophP
Copy link
Contributor

ChristophP commented May 28, 2023

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.

@ChristophP
Copy link
Contributor

ChristophP commented Jul 5, 2023

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

@hasan-wajahat
Copy link

hasan-wajahat commented Nov 15, 2023

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.

@shamseer-ahammed
Copy link

@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>

@luangong
Copy link

luangong commented Sep 9, 2024

@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?

@ChristophP
Copy link
Contributor

ChristophP commented Sep 9, 2024

@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
])

@luangong
Copy link

luangong commented Sep 9, 2024

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?

@ChristophP
Copy link
Contributor

@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?

@iamoskvin
Copy link

@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

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?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests