Search code examples
reactjsserver-side-renderingnode-streamsreact-helmet

Making renderToNodeStream work with an async Helmet version


I am trying to convert my working SSR code from renderToString to renderToNodeStream, which became available in thew React.JS, for improved time-to-first-byte. I know that react-helmet is synchronous, and won't work with renderToNodeStream(), yet there are some "async" forks that people use to make helmet interoperate with async/stream rendering. I picked the react-helmet-async library for my experiments, and converted my code to the below:

  const helmetData = new HelmetData({});
  store
    .runSaga(rootSaga)
    .toPromise()
    .then(() => {
      frontloadServerRender(() =>
        // to support AMP, use renderToStaticMarkup()
        // we no longer care for AMP
        {
          const routeMarkupNodeStream = renderToNodeStream(
            <Capture report={m => modules.push(m)}>
              <Helmet helmetData={helmetData}>
                <Provider store={store}>
                  <StaticRouter location={req.url} context={context}>
                    <Frontload isServer={true}>
                      <App />
                    </Frontload>
                  </StaticRouter>
                </Provider>
              </Helmet>
            </Capture>
          );
          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();
            return;
          }

          // Otherwise, we carry on...
          const state = store.getState();

          const { helmet } = helmetData.context;

          // Let's format those assets into pretty <script> tags
          const extraChunks = extractAssets(manifest, modules).map(
            c =>
              `<script type="text/javascript" src="/${c.replace(
                /^\//,
                ''
              )}"></script>`
          );

          console.log('starting to write node-stream');
          console.log('html:', helmet.htmlAttributes.toString());
          console.log('title:', helmet.title.toString());

          res.write(
            injectHeaderHTML(cachedHtmlData, {
              html: helmet.htmlAttributes.toString(),
              title: helmet.title.toString(),
              meta: helmet.meta.toString(),
              headScript: helmet.script.toString(),
              link: helmet.link.toString()
            })
          );

          console.log('wrote head');

          // On fastify, we got to set the content to html
          // res.header('Content-Type', 'text/html');

          routeMarkupNodeStream.pipe(res, { end: false });

          routeMarkupNodeStream.on('pipe', () => {
            console.log('Piped!');
          });

          routeMarkupNodeStream.on('end', () => {
            res.end(
              injectFooterHTML(cachedHtmlData, {
                scripts: extraChunks,
                state: JSON.stringify(state).replace(/</g, '\\u003c')
              })
            );
            console.log('finished writing');
          });
        }
      );
    })
    .catch(e => {
      res.status(500).send(e.message);
    });
  // Let's do dispatches to fetch category and event info, as necessary
  const { dispatch } = store;

When I run the above code, it crashes with:

starting to write node-stream
html: 
title: <title data-rh="true"></title>
wrote head
events.js:292
      throw er; // Unhandled 'error' event
      ^

Invariant Violation: You may be attempting to nest <Helmet> components within each other, which is not allowed. Refer to our API for more information.
    at Object.invariant [as default] (/.../node_modules/invariant/invariant.js:40:15)
    at e.warnOnInvalidChildren (/.../node_modules/react-helmet-async/src/index.js:147:5)
    at /.../node_modules/react-helmet-async/src/index.js:188:14
    at /.../node_modules/react/cjs/react.development.js:1104:17
    at /.../node_modules/react/cjs/react.development.js:1067:17
    at mapIntoArray (/.../node_modules/react/cjs/react.development.js:964:23)
    at mapChildren (/.../node_modules/react/cjs/react.development.js:1066:3)
    at Object.forEach (/.../node_modules/react/cjs/react.development.js:1103:3)
    at e.mapChildrenToProps (/.../node_modules/react-helmet-async/src/index.js:172:20)
    at e.r.render (/.../node_modules/react-helmet-async/src/index.js:229:23)
Emitted 'error' event on ReactMarkupReadableStream instance at:
    at emitErrorNT (internal/streams/destroy.js:106:8)
    at emitErrorCloseNT (internal/streams/destroy.js:74:3)
    at processTicksAndRejections (internal/process/task_queues.js:80:21) {
  framesToPop: 1
}

I also tried let helmetContext = {}; and wrapping my app with <HelmetProvider context={helmetContext}>. There, my helmetContext stays set to {}, so const { helmet } = helmetContext; returns undefined.


Solution

  • It seems to me that react-helmet-async requires multiple render passes, in my case. Specifically, for it to work, before const { helmet } = helmetData.context;, we need to do something like renderToString(app) or renderToStaticMarkup(app), else there is nothing in the context. Meaning that to use an async renderToNodeStream, we need to do a synchronous rendering first.

    If the above is correct, I don't see how using react-helmet-async with renderToNodeStream() could provide better time-to-first-byte (TTFB) than react-helmet with renderToString().