Can I use Meteor SSR with React Loadable (dynamic import)?


#1

I am starting a new Meteor + React app and I would like to have SSR + code splitting.
I have successfully set up SSR working with react-router using onPageLoad.

Now I am trying to set up code splitting using React Loadable, but I don’t really see how to handle SSR.
There is an SSR section in React Loadable’s README but it uses Webpack and Babel.

Any help would be appreciated.
Thanks!


#2

I’ve got this mostly working (with auth routes, which are not rendered on the server), except one problem at the end - React Loadable will override the SSR html and display the loading component instead of using what’s there. There’s a whole section in the React Loadable readme about how to handle that in WebPack (and it’s complex) but I’m not sure how to set that up in Meteor.

I could use some help there.

Here’s a basic example of what I have working, in case anyone is interested:

import { Meteor } from 'meteor/meteor'
import { withTracker } from 'meteor/react-meteor-data'
import React, { Component } from 'react'
import Loadable from 'react-loadable'
import { Switch, Route, Redirect } from 'react-router-dom'
import Loading from './ui/common/Loading'

const MainLayout = Loadable({
  loader: () => import('./ui/layouts/MainLayout'),
  loading: Loading
})
const Login = Loadable({
  loader: () => import('./ui/account/Login'),
  loading: Loading
})
const ForgotPassword = Loadable({
  loader: () => import('./ui/account/ForgotPassword'),
  loading: Loading
})

const NotFound = Loadable({
  loader: () => import('./ui/common/NotFound'),
  loading: Loading
})

// This is doing double duty. It protects the auth routes,
// but also prevents SSR from trying to render these
// routes.
const PrivateRoute = withTracker((props) => ({
  userId: Meteor.isClient && Meteor.userId()
}))(({ render, ...props }) => (
  <Route {...props} render={
    props => props.userId
      ? render(props)
      : <Redirect to={{
        pathname: '/sign-in',
        state: { from: props.location }
      }} />
  } />
))

// We specifically want to delay the creation of the Loadable
// object, because we don't want it to register to preload
// on the server.
const LateLoadable = (config) => class extends Component {
  constructor (props) {
    super(props)
    this.loadable = Loadable(config)
  }
  render () {
    return <this.loadable {...this.props} />
  }
}

const AdminApp = LateLoadable({
  loader: () => import('./ui/admin/AdminApp'),
  loading: Loading
})

export default class App extends Component {
  render () {
    return <Switch>
      <Route path="/sign-in" render={(props) => (
        <MainLayout pageClass="page home">
          <Login mode="login" />
        </MainLayout>
      )} />
      <Route path="/sign-up" render={(props) => (
        <MainLayout pageClass="page sign-up">
          <Login mode="sign-up" />
        </MainLayout>
      )} />
      <Route path="/forgot-password" render={(props) => (
        <MainLayout pageClass="page forgot-password">
          <ForgotPassword />
        </MainLayout>
      )} />
      <PrivateRoute path="/admin" render={(props) => (<AdminApp {...props} />)} />
      <Route path="/" render={(props) => (<MainLayout {...props} />)} />
      <Route render={(props) => (
        <MainLayout {...props}>
          <NotFound />
        </MainLayout>
      )} />
    </Switch>
  }
}

server/main.js

import React from 'react'
import { StaticRouter } from 'react-router'
import { renderToString } from 'react-dom/server'
import { onPageLoad } from 'meteor/server-render'
import Loadable from 'react-loadable'
import App from '/imports/App'

Loadable.preloadAll()

onPageLoad(sink => {
  const context = {}
  const modules = []
  sink.renderIntoElementById('root', renderToString(
    <Loadable.Capture report={moduleName => modules.push(moduleName)}>
      <StaticRouter location={sink.request.url} context={context}>
        <App />
      </StaticRouter>
    </Loadable.Capture>
  ))

  // ***We have to do something with this value***
  console.log(modules)

  // import { Helmet } from 'react-helmet'
  // const helmet = Helmet.renderStatic();
  // sink.appendToHead(helmet.meta.toString());
  // sink.appendToHead(helmet.title.toString());
  // sink.appendToHead(helmet.link.toString());
})

client/main.js


import { onPageLoad } from 'meteor/server-render'
import React from 'react'
import { hydrate } from 'react-dom'
import { BrowserRouter } from 'react-router-dom'

onPageLoad(async sink => {
  const App = (await import('/imports/App')).default
  hydrate(
    <BrowserRouter><App /></BrowserRouter>,
    document.getElementById('root')
  )
})

By the way, this was ported from an app which previously used Flow Router, and so it contains a few useful tricks for anyone looking for a reference porting from FlowRouter to React Router. Using RR’s render method allows you to reuse FR’s Layout system for example. The biggest pain in porting from FR to RR was replacing all the standard <a> links in the app with RR’s <Link>, since RR doesn’t automatically nab all the local links like Flow does.


#3

I’m trying to do the same thing right now. Have some ideas, but still need to grind through it.

I think the solution is to use Loadable.preloadAll() but I am not sure where you would do it. The example has you preload everything before the server starts, but I don’t think we have the ability to do that in Meteor.

I think that will solve this:

React Loadable will override the SSR html and display the loading component instead of using what’s there

I suppose you could an await Loadable.preloadAll() somewhere in the onPageLoad, but that seems wasteful to do on every request.


#4

I’m already using Loadable.preloadAll() - that returns a promise, and I should probably delay setting up onPageLoad until that completes, but thats actually all working.

The problem is that in order for the client side to use the modules, they all have to be available before hydration. In the Loadable readme, it talks about using babel and webpack plugins, to preload the necessary bundles by embedding them into the HTML directly, and then waiting for preloadReady to make sure it’s all loaded before doing hydration.

We can probably do something similar in Meteor, except we can just use the dynamic import system and then wait for that to complete on the client. But I’m not having any luck getting Loadable.Capture to output anything useful. If I can get a manifest of imports I need to load, and then deliver that to the client, the loading logic should be easy to get working.


#5

Ah! I’m sorry! I completely missed that!

Ok, so about what your actual problem is! What I was going to try was put the result from <Loadable.Capture report={moduleName => modules.push(moduleName)}> into something like

sink.appendToBody(`
    <script>
      window.__MODULES__=${JSON.stringify(modules)};
    </script>
`);

Then in the client side onPageLoad, read from window.__MODULES__ and do an await import(....) before hydrate.

I think you are able to do dynamic imports with a variable like this as long as you have a static dynamic import for the same thing in your app, which you should in your Loadable component.

I haven’t tried this yet, but this was how I was going to start.


#6

So in order to get Loadable.Capture to work you need to include the Loadable babel plugin. I did that through package.json:

  "babel": {
    "plugins": [
      "react-loadable/babel",
    ]
  }

Now the capture report method works, so I modified my onPageLoad handler thusly:

Loadable.preloadAll().then(() => onPageLoad(sink => {
  const context = {}
  const modules = []
  sink.renderIntoElementById('root', renderToString(
    <Loadable.Capture report={moduleName => { modules.push(moduleName) }}>
      <StaticRouter location={sink.request.url} context={context}>
        <App />
      </StaticRouter>
    </Loadable.Capture>
  ) + `<script>var preloadables = ${JSON.stringify(modules)}</script>`)

  // import { Helmet } from 'react-helmet'
  // const helmet = Helmet.renderStatic();
  // sink.appendToHead(helmet.meta.toString());
  // sink.appendToHead(helmet.title.toString());
  // sink.appendToHead(helmet.link.toString());
}))

So that gets the import statements to the client, but the paths are all wrong. They need to be translated to root relative or something. I’m not sure how to do that part.

If we have absolute paths, the next we have to load all these before we hydrate, so modify the onPageLoad handler in client like so:

onPageLoad(async sink => {
  const App = (await import('/imports/App')).default

  if (window.preloadables) {
    await Promise.all(preloadables.map((moduleName) => import(moduleName)))
  }

  hydrate(
    <BrowserRouter><App /></BrowserRouter>,
    document.getElementById('root')
  )
})

I just have to figure out how to get paths which will work there.


#7

Oh hey, your solution is pretty close to what I worked out (I didn’t see this until later, sorry about that). It only works with absolute (root level) imports though, no file relative imports (I even took a stab at modifying the react-loadable/babel plugin to try to convert all imports to root level, hoping the original source location was available in there and I could use that to figure it out, but it’s a bit over my head).

I’m still getting a warning about the strings not matching when hydrating - React Loadable is still showing the loading component, even though the modules should be preloaded. I have confirmed that the Promises have all resolved before we get to the hydrate call. I even dug into the Loadable source, and found some hard coded __webpack__ vars, that I polyfilled (well, I made some assumptions and stuffed some data in there that should work).

I’m just about to debug my way through it. Maybe something is just incorrect. Here’s my code:


onPageLoad(async sink => {
  __webpack_modules__ = {}
  let App
  if (window.preloadables) {
    await Promise.all(
      preloadables.map(
        (moduleName) => import(moduleName)
          .then((mod) => { __webpack_modules__[moduleName] = mod; return mod })
      ).concat([
        import('/imports/App').then(mod => { App = mod.default; return mod }),
        Loadable.preloadReady()
      ])
    )
  }
  hydrate(
    <BrowserRouter><App /></BrowserRouter>,
    document.getElementById('root')
  )
})

#8

Thanks for letting me know!

I haven’t got as far as you. I’ve been trying to get it to work with Fast Render (since I have some pubs dependent on the accounts system) but have had no luck. I think it’s because Fast Render’s custom onPageLoad handler isn’t async await aware. I don’t think it waits on Apollo’s getDataFromTree or the module loading. Still have to dig a bit. If I figure something out I’ll submit a PR.

Thanks for giving updates on your progress. Didn’t know about the plugin so I’ll give that a shot, too.

I have been looking through this repo for inspiration… it looks like they have SSR working with Meteor, but it doesn’t use React Loadable.


#9

So after digging into this over the weekend, it looks like React Loadable has made too many assumptions about the way it’ll be used with WebPack to be used for Meteor’s SSR easily. So we always get a warning about the hydrated HTML not matching.

The problem is basically that Meteor’s import method is always run asynchronously, even if you import a module which has been previously loaded. \

The load method inside React Loadable seems to suggest that in WebPack’s dynamic import method, it’ll run synchronously if the module has been preloaded - I’m not certain of this.

// From React Loadable
function load (loader) {
  var promise = loader();

  var state = {
    loading: true,
    loaded: null,
    error: null
  };

  // I'm guessing this runs synchronously in WebPack, 
  // and that's how it knows to not show loading?
  // This doesn't work to avoid rendering loading in Meteor.
  state.promise = promise.then(function (loaded) {
    state.loading = false;
    state.loaded = loaded;
    return loaded;
  }).catch(function (err) {
    state.loading = false;
    state.error = err;
    throw err;
  });

  return state;
}

I wonder if Meteor should be running already loaded import promises synchronously?

So you can preload all the modules manually, but when you call Loadable’s preloadReady method, it doesn’t really know which Loadables to wait for. Preloading the modules before hydration might make this perceptually irrelevant though (I don’t see a flash of “Loading” when I preload the modules) … I’m not sure.

I also can only get this to work for rooted import paths - I wonder whether Loadable’s babel plugin can be modified to include the current path, or always convert the path to rooted paths, so that preloading the modules always works. I also wonder if there is a simpler Meteor tool to get that to work (Meteor’s isobuild system has been harder to figure out that WebPack’s, and I’m not sure where to ask questions about it).

So anyway, if we are going to make this work in Meteor, we need a new preloadReady method which can take a list of modules, load them, then avoid showing the Loading component if they have already been loaded. A synchronous way to import dynamic modules would be the easiest way to do this, but maybe we can use a registry of promises. We’d still use the babel plugin which gets us the module path outside of the loader method, and instead of just running the loader as Loadable does now, we’d look at the modulesName, and see if we have a preloaded promise matching that path. If we have an extant promise, we just use that, and resolve immediately.

Actually, that wouldn’t even add too much cruft on top of what we already have in React Loadable. I wonder if Jamie (maintainer of React Loadable) would accept a PR with that in it.


#10

So actually, the built in webpack method in React Loadable’s implementation already delivers the rooted path - if I change that to use require.resolve instead of require.resolveWeak, which Meteor doesn’t implement.


#11

I ended up modifying React Loadable quite a bit to get this to work. Here is my pull request:

This PR contains the code to get it to work.


#12

Wow I had no idea this would require that much effort!

Starting to wonder if it’s worth using react-loadable if it isn’t compatible with Meteor. The entire point of the library was to be ab

The approach used in https://github.com/lhz516/react-code-split-ssr seems much simpler. I don’t love the API, but the approach appears to work without any issues.

I’m still stuck trying to get SSR working with Fast Render, so I won’t be much help right now unfortunately.


#13

Yeah, I was wondering the same. The maintainer of React Loadable doesn’t appear to be very responsive to PRs either. I may just rewrite the thing, and see how that goes. Maybe I’ll take a stab at fast-render too - shouldn’t be too hard, once I understand how to do it.

The changes (additions really) I made to Loadable in my repo work though. It’s pretty slick really. :slight_smile: