Search code examples
reactjsreact-reduxserver-side-renderingredux-sagaconnected-react-router

Correctly initializing the store on the server for SSR of React.JS app made with ConnectedRouter


I am trying to do SSR for a React App made with Redux, Saga, and ConnectedRouter. I found a couple of relevant examples, specifically https://github.com/mnsht/cra-ssr and https://github.com/noveogroup-amorgunov/react-ssr-tutorial

Those I understand supposed to work. I am however having a problem hooking state and history.

My loader code is below:

export default (req, res) => {
  const injectHTML = (data, { html, title, meta, body, scripts, state }) => {
    data = data.replace('<html>', `<html ${html}>`);
    data = data.replace(/<title>.*?<\/title>/g, title);
    data = data.replace('</head>', `${meta}</head>`);
    data = data.replace(
      '<div id="root"></div>',
      `<div id="root">${body}</div><script>window.__PRELOADED_STATE__ = ${state}</script>${scripts.join(
        ''
      )}`
    );

    return data;
  };
  // Load in our HTML file from our build
  fs.readFile(
    path.resolve(__dirname, '../build/index.html'),
    'utf8',
    (err, htmlData) => {
      // If there's an error... serve up something nasty
      if (err) {
        console.error('Read error', err);
        return res.status(404).end();
      }
      // Create a store (with a memory history) from our current url
      const { store } = createStore(req.url);
      // Let's do dispatches to fetch category and event info, as necessary
      const { dispatch } = store;
      if (
        req.url.startsWith('/categories') &&
        req.url.length - '/categories'.length > 1
      ) {
        dispatch(loadCategories());
      }
      const context = {};
      const modules = [];
      frontloadServerRender(() =>
        renderToString(
          <Loadable.Capture report={m => modules.push(m)}>
            <Provider store={store}>
              <StaticRouter location={req.url} context={context}>
                <Frontload isServer={true}>
                  <App />
                </Frontload>
              </StaticRouter>
            </Provider>
          </Loadable.Capture>
        )
      ).then(routeMarkup => {
        if (context.url) {
          // If context has a url property, then we need to handle a redirection in Redux Router
          res.writeHead(302, {
            Location: context.url
          });
          res.end();
        } else {
          // We need to tell Helmet to compute the right meta tags, title, and such
          const helmet = Helmet.renderStatic();
          ...

Below is how I made the store:

import { createStore, applyMiddleware, compose } from 'redux';
import { routerMiddleware } from 'connected-react-router';
import createSagaMiddleware from 'redux-saga';
import history, { isServer } from './utils/history';
import createReducer from './reducers';

export default function configureStore(
  initialState = !isServer ? window.__PRELOADED_STATE__ : {}
) {
  // Delete state, since we have it stored in a variable
  if (!isServer) {
    delete window.__PRELOADED_STATE__;
  }

  let composeEnhancers = compose;
  const reduxSagaMonitorOptions = {};

  // If Redux Dev Tools and Saga Dev Tools Extensions are installed, enable them
  /* istanbul ignore next */
  if (
    process.env.REACT_APP_STAGE !== 'production' &&
    !isServer &&
    typeof window === 'object'
  ) {
    /* eslint-disable no-underscore-dangle */
    if (window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__)
      composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({});

    // NOTE: Uncomment the code below to restore support for Redux Saga
    // Dev Tools once it supports redux-saga version 1.x.x
    // if (window.__SAGA_MONITOR_EXTENSION__)
    //   reduxSagaMonitorOptions = {
    //     sagaMonitor: window.__SAGA_MONITOR_EXTENSION__,
    //   };
    /* eslint-enable */
  }

  const sagaMiddleware = createSagaMiddleware(reduxSagaMonitorOptions);

  // Create the store with two middlewares
  // 1. sagaMiddleware: Makes redux-sagas work
  // 2. routerMiddleware: Syncs the location/URL path to the state
  const middlewares = [sagaMiddleware, routerMiddleware(history)];

  const enhancers = [applyMiddleware(...middlewares)];

  const store = createStore(
    createReducer(),
    !isServer ? initialState : {},
    composeEnhancers(...enhancers)
  );

  // Extensions
  store.runSaga = sagaMiddleware.run;
  store.injectedReducers = {}; // Reducer registry
  store.injectedSagas = {}; // Saga registry

  // Make reducers hot reloadable, see http://mxs.is/googmo
  /* istanbul ignore next */
  if (module.hot) {
    module.hot.accept('./reducers', () => {
      store.replaceReducer(createReducer(store.injectedReducers));
    });
  }

  return { store, history };
}

and my history:

export const isServer = !(
  typeof window !== 'undefined' &&
  window.document &&
  window.document.createElement
);

const history = isServer
  ? createMemoryHistory({
      initialEntries: ['/']
    })
  : createBrowserHistory();

export default history;

I tried making the above into createHistory(url) and on the server doing initialEntries: [url].

That is kind of fine, but it does not solve my real problem and that is the createReducer(). The examples that I found do createReducer(history) and that is fine. However, they do not inject reducers dynamically, while my code does. Hence, I cannot easily change my version below:

export default function createReducer(history, injectedReducers = {}) {
  const rootReducer = combineReducers({
    global: globalReducer,
    router: connectRouter(history),
    ...injectedReducers
  });

  return rootReducer;
}

Into the version, which only statically assembles reducers like below (sorry for typescript):

export default (history: History) =>
    combineReducers<State>({
        homepage,
        catalog,
        shoes,
        router: connectRouter(history),
    });

The code is from https://github.com/noveogroup-amorgunov/react-ssr-tutorial/blob/master/src/store/rootReducer.ts verbatim.

Any suggestions? How would I do all of the above and still the below working correctly?

export function injectReducerFactory(store, isValid) {
  return function injectReducer(key, reducer) {
    if (!isValid) checkStore(store);

    invariant(
      isString(key) && !isEmpty(key) && isFunction(reducer),
      '(app/utils...) injectReducer: Expected `reducer` to be a reducer function'
    );

    // Check `store.injectedReducers[key] === reducer` for hot reloading when a key is the same but a reducer is different
    if (
      Reflect.has(store.injectedReducers, key) &&
      store.injectedReducers[key] === reducer
    )
      return;

    store.injectedReducers[key] = reducer; // eslint-disable-line no-param-reassign
    store.replaceReducer(createReducer(store.injectedReducers));
  };
}

Currently, my code works without errors, yet the state is not initialized. The same as if I did not dispatch any actions. Values are whatever they are set to initially.


Solution

  • Turns out, my loader was missing saga support. Fixed it. Now, it looks like:

      // Create a store (with a memory history) from our current url
      const { store } = createStore(req.url);
      const context = {};
      const modules = [];
      store
        .runSaga(rootSaga)
        .toPromise()
        .then(() => {
          // We need to tell Helmet to compute the right meta tags, title, and such
          const helmet = Helmet.renderStatic();
          frontloadServerRender(() =>
            renderToString(
              <Loadable.Capture report={m => modules.push(m)}>
                <Provider store={store}>
                  <StaticRouter location={req.url} context={context}>
                    <Frontload isServer={true}>
                      <App />
                    </Frontload>
                  </StaticRouter>
                </Provider>
              </Loadable.Capture>
            )
          ).then(routeMarkup => {
            if (context.url) {
              // If context has a url property, then we need to handle a redirection in Redux Router
              res.writeHead(302, {
                Location: context.url
              });
              res.end();
            } else {
              // Otherwise, we carry on...
              // Let's give ourself a function to load all our page-specific JS assets for code splitting
              const extractAssets = (assets, chunks) =>
                Object.keys(assets)
                  .filter(
                    asset => chunks.indexOf(asset.replace('.js', '')) > -1
                  )
                  .map(k => assets[k]);
              // Let's format those assets into pretty <script> tags
              const extraChunks = extractAssets(manifest, modules).map(
                c =>
                  `<script type="text/javascript" src="/${c.replace(
                    /^\//,
                    ''
                  )}"></script>`
              );
              if (context.status === 404) {
                res.status(404);
              }
              // Pass all this nonsense into our HTML formatting function above
              const html = injectHTML(htmlData, {
                html: helmet.htmlAttributes.toString(),
                title: helmet.title.toString(),
                meta: helmet.meta.toString(),
                body: routeMarkup,
                scripts: extraChunks,
                state: JSON.stringify(store.getState()).replace(/</g, '\\u003c')
              });
              // We have all the final HTML, let's send it to the user already!
              res.send(html);
            }
          });
        })
        .catch(e => {
          console.log(e.message);
          res.status(500).send(e.message);
        });
      // Let's do dispatches to fetch category and event info, as necessary
      const { dispatch } = store;
      if (
        req.url.startsWith('/categories') &&
        req.url.length - '/categories'.length > 1
      ) {
        dispatch(loadCategories());
      } else if (
        req.url.startsWith('/events') &&
        req.url.length - '/events'.length > 1
      ) {
        const id = parseInt(req.url.substr(req.url.lastIndexOf('/') + 1));
        dispatch(loadEvent(id));
      }
      store.close();
    }