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
.
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()
.