Search code examples
reactjsreact-hooksreact-router-dommicro-frontendwebpack-module-federation

React Router Dom v6.4 doesn't allow history.listen (prior suggestions deprecated) with createBrowserRouter or createMemoryRouter


react-router-dom v. 6.4.2 doesn't allow history.listen as referenced in the code example below. This is for a mfe with module federation.

In the code example using history.listen, if a link is clicked in the remote (loaded as mfe) then the memory history (memory router now) current path will be updated. It will then call onNavigate to tell the host container which is using browser history (browser router now) that the current path has changed.

Prior suggestions were to use UNSAFE_NavigationContext, useHistory, unstable_HistoryRouter, import {...} from 'history', etc. Apparently, those prior methods were temporary migration aids from v5 to v6.3 and with v6.4+ are now deprecated in favor of the new data api's in 6.4. See here

we do not intend to support custom histories moving forward. This API is here as a migration aid. We recommend removing custom histories from your app.

Additionally, from the maintainers of RRD:

We recommend updating your app to use one of the new routers from 6.4.

After searching here and within both open and closed issues on remix-RRD I have been unable to find a workable solution based on the above for replacing history.listen, .push or .location with the new data api's (routers) using createBrowserRouter or createMemoryRouter as referenced here

There are many open issues on the react-router-dom page relating to this use case.

Original marketing/src/bootstrap.tsx from remote

import React from 'react'
import { createRoot } from 'react-dom/client'
import { createMemoryHistory, createBrowserHistory } from 'history' <= Not Supported
import App from './App'

let root: { render: (arg0: JSX.Element) => void } | null = null

// Mount function to start up the app
const mount = (el: any, { onNavigate, defaultHistory, initialPath }: any) => {
  if (!el) {
    root = null
    return
  }
  // If defaultHistory in development and isolation use BrowserHistory
  const history =
    defaultHistory ||
    // Otherwise use MemoryHistory and initial path from container
    createMemoryHistory({
      initialEntries: [initialPath],
    })

  if (onNavigate) {
    history.listen(onNavigate)                  <= Not Supported
  }

  root = root ? root : createRoot(el)

  root.render(<App history={history} />)

  return {
    onParentNavigate({ pathname: nextPathname }: any) {
      const { pathname } = history.location      <= Not Supported

      if (pathname !== nextPathname) {
        history.push(nextPathname)               <= Not Supported
      }
    },
  }
}

// If we are in development and in isolation,
// call mount immediately
if (process.env.NODE_ENV === 'development') {
  const devRoot = document.querySelector('#_marketing-dev-root')

  if (devRoot) {
    mount(devRoot, { defaultHistory: createBrowserHistory() })
  }
}

// We are running through container
// and we should export the mount function
export { mount }

Replacement marketing/src/bootstrap.tsx from remote (in progress)

import React from 'react'
import { createRoot } from 'react-dom/client'
import {
  createBrowserRouter,
  createMemoryRouter,
} from 'react-router-dom'

import App from './App'

import ErrorPage from './pages/ErrorPage'

import Landing from './components/Landing'
import Pricing from './components/Pricing'

let root: { render: (arg0: JSX.Element) => void } | null = null

const routes = [
  {
    path: '/',
    errorElement: <ErrorPage />,
    children: [
      {
        index: true,
        element: <Landing />,
        errorElement: <ErrorPage />,
      },
      {
        path: 'pricing',
        element: <Pricing />,
        errorElement: <ErrorPage />,
      },
    ],
  },
]

// Mount function to start up the app
const mount = (
  el: Element,
  {
    onNavigate,
    defaultRouter,
  }: {
    onNavigate: (() => void) | null
    defaultRouter: any
  },
): unknown => {
  if (!el) {
    root = null
    return
  }
  // if in development and isolation, use browser router. If not, use memory router
  const router = defaultRouter || createMemoryRouter(routes)

  if (onNavigate) {
    router.listen(onNavigate) // There is no history.listen anymore.  router.listen is not a function
  }

  root = root ? root : createRoot(el)
  
  root.render(<App router={router} />)
}

// If we are in development and in isolation,
// call mount immediately
if (process.env.NODE_ENV === 'development') {
  const devRoot = document.querySelector('#_marketing-dev-root')

  if (devRoot) {
    mount(devRoot, { defaultRouter: createBrowserRouter(routes) })
    console.log('defaultRouter')
  }
}

// We are running through container
// and we should export the mount function
export { mount }

Original marketing/src/App.tsx from remote

import './MuiClassNameSetup'
import React from 'react'
import { Switch, Route, Router } from 'react-router-dom'
import Landing from './components/Landing'
import Pricing from './components/Pricing'

export default function _({ history }: any) {
  return (
    <div>
      <Router history={history}>
        <Switch>
          <Route exact path="/pricing" component={Pricing} />
          <Route path="/" component={Landing} />
        </Switch>
      </Router>
    </div>
  )
}

Replacement marketing/src/App.tsx from remote (in progress)

import './MuiClassNameSetup'
import React from 'react'
import {
  RouterProvider,
} from 'react-router-dom'

export default function App({ router }: any) {
  return <RouterProvider router={router} />
}

Original container/src/components/MarketingApp.tsx from host

import { mount } from 'marketing/MarketingApp'
import React, { useRef, useEffect } from 'react'
import { useHistory } from 'react-router-dom'   <= Not Supported

export default function _() {
  const ref = useRef(null)
  const history = useHistory()                  <= Not Supported

  useEffect(() => {
    const { onParentNavigate } = mount(ref.current, {
      initialPath: history.location.pathname,
      onNavigate: ({ pathname: nextPathname }: any) => {
        const { pathname } = history.location   <= Not Supported

        if (pathname !== nextPathname) {
          history.push(nextPathname)            <= Not Supported
        }
      },
    })

    history.listen(onParentNavigate)            <= Not Supported
  }, [history])

  return <div ref={ref} />
}

Replacement container/src/components/MarketingApp.tsx from host (in progress)

import { mount } from 'marketing/MarketingApp'
import React, { useRef, useEffect } from 'react'

export default function _() {
  const ref = useRef(null)

  useEffect(() => {
    mount(ref.current, {
      onNavigate: () => {
        console.log('The container noticed navigation in Marketing')
      },
    })
  })

  return <div ref={ref} />
}

Looking for a solution to replace history.listen, history.location and history.push that works with the new v6.4 data api's?


Solution

  • One of the maintainers of RRD just posted a new implementation detail to replace history.listen which is for v6.4+. See router.subscribe() below.

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

    Unfortunately, the new implementation is also unstable and is considered a beta test implementation.

    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.