SSR with React's readable stream

I’d like to use React 16’s renderToNodeStream, but I need to add some code after the stream has completed, because certain data (maybe apollo data and Loadable data - Helmet data too, but that’s a different case) that I won’t have until after the stream is complete.

So my question is, how can I attach something to the readable stream to determine when it’s complete, so I can output a little bit more HTML (a script block with my preloadables)?

Things I’ve tried: I tried attaching ‘data’ and ‘end’ event handlers on the React stream, but I couldn’t write to sink in the ‘end’ event, and attaching anything to ‘data’ seems to break it all (and I wouldn’t know when it’s finished anyway).

react-helmet-async has a similar problem, except I can’t wait until after the stream is sent to the server in that case, because Helmet needs to work with the <head> tag, which will already have been sent to the client. I’m thinking some kind of caching system should work for Helmet. That would mean the first load would get generic <head> data, which would be correct client side on page load, but subsequent loads would receive the correct data.

Here’s the code I have now, using renderToString:

import { WebApp } from 'meteor/webapp'
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 { HelmetProvider } from 'react-helmet-async'
import App from '/imports/App'

Loadable.preloadAll().then(() => onPageLoad(sink => {
  const context = {}
  const modules = []
  const modulesResolved = []
  const helmetContext = {}
  const app = <HelmetProvider context={helmetContext}>
    <Loadable.Capture report={(moduleName) => { modules.push(moduleName) }}
      reportResolved={(resolvedModuleName) => { modulesResolved.push(resolvedModuleName) }}>
      <StaticRouter location={sink.request.url} context={context}>
        <App />
      </StaticRouter>
    </Loadable.Capture>
  </HelmetProvider>
  sink.renderIntoElementById('root', renderToString(app))
  sink.appendToBody(`<script> var __preloadables__ = ${JSON.stringify(modulesResolved)}; </script>`)

  const { helmet } = helmetContext
  sink.appendToHead(helmet.meta.toString())
  sink.appendToHead(helmet.title.toString())
  sink.appendToHead(helmet.link.toString())

  WebApp.addHtmlAttributeHook(() => (
    Object.assign({
      lang: 'en'
    }, helmet.htmlAttributes.toComponent())
  ))

  // :TODO: Figure out how to do helmet.bodyAttributes...
}))

It’s also viewable in this starter in /server/main.js.

where you’ll take modules for loadable.capture?

I got it to work using a coule of npm packages (string-to-stream and streamqueue):


import { WebApp } from 'meteor/webapp'
import React from 'react'
import { StaticRouter } from 'react-router'
import { renderToNodeStream } from 'react-dom/server'
import { onPageLoad } from 'meteor/server-render'
import Loadable from 'react-loadable'
import s2s from 'string-to-stream'
import sq from 'streamqueue'
import { HelmetProvider } from 'react-helmet-async'
import App from '/imports/App'

Loadable.preloadAll().then(() => onPageLoad(sink => {
  const context = {}
  const modules = []
  const modulesResolved = []
  const helmetContext = {}

  const app = <HelmetProvider context={helmetContext}>
    <Loadable.Capture report={(moduleName) => { modules.push(moduleName) }}
      reportResolved={(resolvedModuleName) => { modulesResolved.push(resolvedModuleName) }}>
      <StaticRouter location={sink.request.url} context={context}>
        <App />
      </StaticRouter>
    </Loadable.Capture>
  </HelmetProvider>

  const appStream = renderToNodeStream(app)
  const queuedStreams = sq(
    () => appStream,
    () => s2s(`<script id="__preloadables__">__preloadables__=${JSON.stringify(modulesResolved)};</script>`)
  )
  sink.renderIntoElementById('root', queuedStreams)

  // const { helmet } = helmetContext
  // sink.appendToHead(helmet.meta.toString())
  // sink.appendToHead(helmet.title.toString())
  // sink.appendToHead(helmet.link.toString())

  // WebApp.addHtmlAttributeHook(() => (
  //   Object.assign({
  //     lang: 'en'
  //   }, helmet.htmlAttributes.toComponent())
  // ))

  // :TODO: Figure out how to do helmet.bodyAttributes...
}))

This works slightly differently from before. Since I’m attaching the __preloadables__ script to the end of the react output, instead of outside of the mounting node, we need to clean that up client side before hydration:

    // remove the __preloadables__ DOM script node
    const script = document.getElementById('__preloadables__')
    script.parentNode.removeChild(script)

This doesn’t take care of the Helmet issue (where the stream has already sent the <head> tags, so we can’t inject those later, like Helmet must to do by design). The next thing to figure out then is, do I want to use caching to work around that design limitation of Helmet, or find another solution to <head> tag management?

So this was fun to figure out, but now in order to get Helmet to work I’ll have to implement some kind of cache, which doesn’t sound fun.

My next question is - is this work worth it? If I use a CDN/WAF like cloudflare, can they effectively stream this static HTML for me? In other words, would using a front end cache like that solve the same problem in a simpler way?

I’m looking at this other project react-safety-helmet which uses a redux store (seems like overkill - react-helmet-async is simpler) and a stream buffer to collect the output of renderToNodeStream. I had previously reasoned would negate the benefits of using a stream. In some ways it would - I don’t think this solution would deliver the first byte to the browser any faster than renderToString, but it might at least help with stream back pressure, because the buffered stream can be paused and otherwise managed.

Any node stream experts who know whether this makes sense?

That might be enough of a benefit to using that to warrant using it, but I don’t know if it’s realistic to use that in Meteor, since the sink object seems to be locked down as soon as the stream starts. Meteor might need a more convenient method for dealing with buffered streams in this case.