react-router-redux SSR actions - extract StaticRouter's custom history

Created on 1 Apr 2017  路  9Comments  路  Source: ReactTraining/react-router

Feature request

Give me the nod and I'll make a PR

Ideally universal redux users can have consistent methods for redirecting. The Redirect component isn't always appropriate (e.g. inside a saga or componentWillMount phase). So the history method action creators should be an option on the SSR too.

The StaticRouter and react-router-redux middleware are incompatible for server side redirects using react-router-redux actions. The StaticRouter is making it's own custom history instance to modify the context object, but the react-router-redux middleware requires a history instance to be supplied... so the history actions will never reach the StaticRouter's instance.

There 2 options that I can think of:

  1. Allow the static router's custom history instance to be created outside of the StaticRouter so it can be supplied to both the StaticRouter and the middleware on a server render.
  2. A custom ConnectedStaticRouter within react-router-redux that listens to a provided history instance and updates the context.

I think the first option is cleaner, it can be an advanced usage and by default the StaticRouter can create it's own history instance like it currently does.

edit:

I've made a gist of an implementation that I've got working for my first suggestion:
https://gist.github.com/tomatau/9c6011dcb5b9f357368aac2b3a2b1430

Most helpful comment

I've come up with an alternative solution, however it is a tad bit hacky and I think @tomatau's method is cleaner. I will post it here FWIW, hopefully illustrates the problem at hand, and leads to more discussion. This seems to me to be an issue that could be encountered by anyone using react-router-redux on the server (one can also simply not use it serverside and use staticContext instead).

The main motivation for myself to implement this was that I wanted a consistent way to handle redirects (with referrer) on client and server. A client redirect might look like this:

@connect(({ auth }) => ({ auth })
class Profile extends Component {
  render () {
    const { auth, location } = this.props

   return auth.user ? (
     <h1>Welcome to secret profile page</h1>
   ) : (
     <Redirect to={{
       pathname: '/login',
       state: { referrer: location }
     }} />
   )
  }
}

Which works fine on client, and the <Login> component will happily read out this.props.location.state.referrer, letting us redirect back to the initial route without dirtying the url. Yay!

But it breaks with <StaticRouter>. <StaticRouter>'s custom history object does not have location.state, as also mentioned here.

But that's OK - we can use the redux store for this sort of state!? Not entirely - in the current implementation, no way for the react-router-redux middleware to access the history object (and therefore keep store.router state up to date).

This was the approach I came up with:

import { routerReducer, ConnectedRouter as _ConnectedRouter, routerMiddleware, replace } from 'react-router-redux'
import createHistory from 'history/createMemoryHistory'

class ConnectedRouter extends _ConnectedRouter {
  static childContextTypes = {
    router: PropTypes.object.isRequired
  }

  getChildContext() {
    return {
      router: {
        staticContext: this.props.context
      }
    }
  }
}

const rootReducer = combineReducers({
  router: routerReducer
})

app.get('/*', (req, res) => {
  const history = createHistory()
  const middlewares = [
    routerMiddleware(history)
  ]
  const store = createStore(rootReducer, undefined, applyMiddleware(...middlewares))

  store.dispatch(replace(req.url))

  const routerContext = {}
  const App = () =>
    <Provider store={store}>
      <ConnectedRouter history={history} context={routerContext}>
        <Entrypoint />
      </ConnectedRouter>
    </Provider>

  const appHTML = renderToString(<App />)

  // Would check routerContext.foo if we were using StaticRouter
  // Now, since we have a functional react-router-redux on server, can check that state
  const { router } = store.getState()

  if (router.location.state && router.location.state.referrer) {
    // A <Redirect> (with a referrer ) was rendered
    console.log('Going to', router.location.pathname, 'from', router.location.state.referrer.pathname)
    const next = router.location.state.referrer.pathname
    return res.redirect(302, router.location.pathname + `?next=${next}`) 
  }

  res.send(htmlTemplate(appHTML))
})

Adding staticRouter into context is needed for this.

All that being said, I think a <StaticRouter> w/o react-router-redux on the server is a fine choice (and I'm leaning back towards that), guess it comes down to how important consistency is, and if you need LOCATION_CHANGED middlewares on server.

If something like createStaticHistory existed, that could also be a solution.

Mentioning #4905 as well, since this discussion is relevant to the implementation of a server-rendered react-router-redux app.

All 9 comments

Updated the description to be clear I'll make a PR if there aren't any objections to my suggestion!... please someone reply 馃槩

I've come up with an alternative solution, however it is a tad bit hacky and I think @tomatau's method is cleaner. I will post it here FWIW, hopefully illustrates the problem at hand, and leads to more discussion. This seems to me to be an issue that could be encountered by anyone using react-router-redux on the server (one can also simply not use it serverside and use staticContext instead).

The main motivation for myself to implement this was that I wanted a consistent way to handle redirects (with referrer) on client and server. A client redirect might look like this:

@connect(({ auth }) => ({ auth })
class Profile extends Component {
  render () {
    const { auth, location } = this.props

   return auth.user ? (
     <h1>Welcome to secret profile page</h1>
   ) : (
     <Redirect to={{
       pathname: '/login',
       state: { referrer: location }
     }} />
   )
  }
}

Which works fine on client, and the <Login> component will happily read out this.props.location.state.referrer, letting us redirect back to the initial route without dirtying the url. Yay!

But it breaks with <StaticRouter>. <StaticRouter>'s custom history object does not have location.state, as also mentioned here.

But that's OK - we can use the redux store for this sort of state!? Not entirely - in the current implementation, no way for the react-router-redux middleware to access the history object (and therefore keep store.router state up to date).

This was the approach I came up with:

import { routerReducer, ConnectedRouter as _ConnectedRouter, routerMiddleware, replace } from 'react-router-redux'
import createHistory from 'history/createMemoryHistory'

class ConnectedRouter extends _ConnectedRouter {
  static childContextTypes = {
    router: PropTypes.object.isRequired
  }

  getChildContext() {
    return {
      router: {
        staticContext: this.props.context
      }
    }
  }
}

const rootReducer = combineReducers({
  router: routerReducer
})

app.get('/*', (req, res) => {
  const history = createHistory()
  const middlewares = [
    routerMiddleware(history)
  ]
  const store = createStore(rootReducer, undefined, applyMiddleware(...middlewares))

  store.dispatch(replace(req.url))

  const routerContext = {}
  const App = () =>
    <Provider store={store}>
      <ConnectedRouter history={history} context={routerContext}>
        <Entrypoint />
      </ConnectedRouter>
    </Provider>

  const appHTML = renderToString(<App />)

  // Would check routerContext.foo if we were using StaticRouter
  // Now, since we have a functional react-router-redux on server, can check that state
  const { router } = store.getState()

  if (router.location.state && router.location.state.referrer) {
    // A <Redirect> (with a referrer ) was rendered
    console.log('Going to', router.location.pathname, 'from', router.location.state.referrer.pathname)
    const next = router.location.state.referrer.pathname
    return res.redirect(302, router.location.pathname + `?next=${next}`) 
  }

  res.send(htmlTemplate(appHTML))
})

Adding staticRouter into context is needed for this.

All that being said, I think a <StaticRouter> w/o react-router-redux on the server is a fine choice (and I'm leaning back towards that), guess it comes down to how important consistency is, and if you need LOCATION_CHANGED middlewares on server.

If something like createStaticHistory existed, that could also be a solution.

Mentioning #4905 as well, since this discussion is relevant to the implementation of a server-rendered react-router-redux app.

I'm also fighting for implementing react-router-redux on server side. I just dropped it and started using just StaticRouter, but it seems not to be the ideal solution.

Hey! Sorry I completely forgot about this issue! I've got the solution I posted working in my boilerplate here:

Server:
https://github.com/tomatau/breko-hub/blob/master/src/server/utils/createStaticHistory.js
https://github.com/tomatau/breko-hub/blob/master/src/server/components/StaticRouter.js
https://github.com/tomatau/breko-hub/blob/master/src/server/middleware/setStore.js
https://github.com/tomatau/breko-hub/blob/master/src/server/middleware/renderApp.js#L13-L26

Client:
https://github.com/tomatau/breko-hub/blob/master/src/app/main.js

This is an important use case for me as I use action creators for location changes. This is an important separation of concerns for me to manage side effects from outside of components... Using <SaticRouter /> on the server render means that all redirects must happen inside components that have access to a context with the router and that simple won't do for me.

edit:
There is one unmentioned caveat with my solution. You should fire a LOCATION_CHANGE directly after creating the store for each request to make the routing reducer in the SSR match the initial render of the client... if that matters to you.

The primary reason for "syncing" the history with the store is to get location updates into state. With a StaticRouter, there are no updates. It's intentionally unchanging and stubs out a history instance to do this.

It instead sounds like you want to use a memory history. This is what v2/3 did inside of match. You can check the state afterwards to determine if a navigation happened. Even cooler, you could write a small middleware to intercept navigation events on the server and handle them in express.

But we shouldn't have to do anything here. You just shouldn't use StaticRouter with RRR.

You just shouldn't use StaticRouter with RRR.

@timdorr your suggestion doesn't make sense.

https://github.com/ReactTraining/react-router/blob/master/packages/react-router/modules/Redirect.js#L45

How can a redirect work on the server that's driven by components without the static context provided by a static router?

I think this should be revisited, been struggling to use react-router-redux with SSR

EDIT:
managed to get it working with redirects on memory history by providing a "fake" staticContext

import { parsePath } from 'history/PathUtils'

// ...express stuff

const initialLocation = parsePath(request.url)

const history = createMemoryHistory({
  initialEntries: [initialLocation],
  initialIndex: 0,
})

// react-router-redux reducer and middleware inside
const store = configureStore({}, history)

// hack to get <Redirect /> to work on server side without StaticRouter
// @see https://github.com/ReactTraining/react-router/blob/master/packages/react-router/modules/Redirect.js#L47
class FakeStaticContext extends React.Component {
  static childContextTypes = {
    router: PropTypes.object.isRequired,
  }

  getChildContext() {
    return {
      router: {
        staticContext: {},
      },
    }
  }

  render() {
    return this.props.children
  }
}

const app = (
  <ReduxProvider store={store}>
    <FakeStaticContext>
      <ConnectedRouter history={history}>
        <App />
      </ConnectedRouter>
    </FakeStaticContext>
  </ReduxProvider>
)

const appString = renderToString(app)

if (initialLocation.pathname !== history.location.pathname) {
  response.status(302).setHeader('Location', history.location.pathname)
  response.end()
  return
}

// ...rest of express stuff to return a normal response

@williamoliveira that's essentially the same as my suggestion (which has changed slightly) - although maybe slightly better as the connected router handles the initial location change action dispatch on componentWillMount :)

I'd suggest you change the staticContext from an object literal to the memory history itself, just so that it adheres to the contract that's within existing react-router implementation.

But, to solve this within react router itself, the simplest solution would be to allow passing this history object into the StaticRouter... Right now it falls victim to the OCP and SOC.

@timdorr i think as @williamoliveira suggested above totally makes sense.

We MUST use a ConnectedStaticRouter if we need to give a 302 redirect if there was a <Redirect /> component somewhere out there in the component tree, but with ConnectedRouter we can't.

see here:

https://github.com/ReactTraining/react-router/blob/master/packages/react-router/modules/Redirect.js#L35

and here:

https://github.com/ReactTraining/react-router/blob/master/packages/react-router/modules/Redirect.js#L38-L45

history.push WILL NOT BE CALLED on server side rendering without this router.staticContext

please suggest how to give a proper 302 response in server side with react-router-redux without providing this router.staticContext context?

Was this page helpful?
0 / 5 - 0 ratings

Related issues

misterwilliam picture misterwilliam  路  3Comments

stnwk picture stnwk  路  3Comments

winkler1 picture winkler1  路  3Comments

tomatau picture tomatau  路  3Comments

ryansobol picture ryansobol  路  3Comments