Feature summary
React 18 gave us a new type of SSR: Streaming SSR. Through Streaming SSR we can achieve two things:
- Streaming HTML: HTML can be sent to the browser parts by parts from server without waiting for the whole HTML to be generated(how it used to work prior to React 18). Browsers can render HTML faster and improve performance scores such as FP, FCP, LCP, etc.
- Selective Hydration: During the hydration stage in the browser, you can choose to hydrate only the sections already gets rendered, rather than waiting for the whole page to be rendered and all the javascript to be loaded -> and then start the hydration. It can achieve better interactivity for websites by binding events to the area already rendered.
How it works
Example usage
React docs gave a simple usage example in node.js environment:
let didError = false;
const stream = renderToPipeableStream(
<App />,
{
bootstrapScripts: ["main.js"],
onShellReady() {
// The content above all Suspense boundaries is ready.
// If something errored before we started streaming,
// we set the error code appropriately.
res.statusCode = didError ? 500 : 200;
res.setHeader('Content-type', 'text/html');
stream.pipe(res);
},
onShellError(error) {
// Something errored before we could complete the shell
// so we emit an alternative shell.
res.statusCode = 500;
res.send('<!doctype html><p>Loading...</p><script src="clientrender.js"></script>');
},
onAllReady() {
// stream.pipe(res);
},
onError(err) {
didError = true;
console.error(err);
}
}
);
Note: renderToPipeableStream is the API to achieve Streaming SSR in node.js environment.
Streaming HTML
HTTP supports data transimission through streaming, thus when we set Transfer-Encoding: chunked in Request headers, the server will send the Response back in chunks. A simple example be like:
const http = require("http");
const url = require("url");
const sleep = (ms) => {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
};
const server = http.createServer(async (req, res) => {
const { pathname } = url.parse(req.url);
if (pathname === "/") {
res.statusCode = 200;
res.setHeader("Content-Type", "text/html");
res.setHeader("Transfer-Encoding", "chunked");
res.write("<html><body><div>First segment</div>");
// Manually setup delay to show make the chunked response more obvious
await sleep(2000);
res.write("<div>Second segment</div></body></html>");
res.end();
return;
}
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("okay");
});
server.listen(8080);
When we visit localhost:8080, we can see the HTML is rendered in two segments of First Segment
and Second Segment
, with a 2s delay in between. First segment
is rendered immediately, and Second segment
is rendered after 2s.
But Streaming HTML in React is much more complex. If we take below App component as an example:
//File 1: Content.js
export default function Content() {
return (
<div> This is content </div>
);
}
//File 2:App.js
import { Suspense, lazy } from "react";
const Content = lazy(() => import("./Content"));
export default function App() {
return (
<html>
<head></head>
<body>
<div>App shell</div>
<Suspense>
<Content />
</Suspense>
</body>
</html>
);
}
When we visit the website for the first time, the results rendered by SSR will be separated into two parts. The first part will look like this after formatting:
<!DOCTYPE html>
<html>
<head></head>
<body>
<div>App shell</div>
<!--$?-->
<template id="B:0"></template>
<!--/$-->
</body>
</html>
The template tag is a placeholder slot for the content of the Suspense component. The content between <!--$?-->
and <!--/$-->
are rendered in separate stage.
And the transmitted second part will look like this after formatting:
<div hidden id="S:0">
<div> This is content </div>
</div>
<script>
function $RC(a, b) {
a = document.getElementById(a);
b = document.getElementById(b);
b.parentNode.removeChild(b);
if (a) {
a = a.previousSibling;
var f = a.parentNode,
c = a.nextSibling,
e = 0;
do {
if (c && 8 === c.nodeType) {
var d = c.data;
if ("/$" === d)
if (0 === e) break;
else e--;
else "$" !== d && "$?" !== d && "$!" !== d || e++
}
d = c.nextSibling;
f.removeChild(c);
c = d
} while (c);
for (; b.firstChild;) f.insertBefore(b.firstChild, c);
a.data = "$";
a._reactRetry && a._reactRetry()
}
};
$RC("B:0", "S:0")
</script>
The div
includes id="S:0"
is the result rendered by Suspense
. But this div
has a hidden
attribute. And the $RC
attribute will insert this div to the placeholder slot in the first part of the HTML where template
tag is located while deleting template
label.
In conclusion, React Streaming SSR will render everything first other than <Suspense>
, and when <Suspense>
finishes the component rendering, it will send the rendering result as well as the javascript related to this component to browser. And the javascript will update dom
at the end to reach the final result of HTML(full page).
But when you visit the website for the second time, the whole page of HTML will be rendered but not separated. Why? Because when the user requests the website for the first time, the Content
component is not cached in the browser, so the server will render the HTML in two parts. But when the user requests the website for the second time, the Content
component is cached in the browser, so the server will render the whole HTML at once.
Hence, the HTML will only be rendered separately when it is needed.
Requesting data in a separate stage is a more common usage for Streaming SSR, other than dynamically render javascript modules(code splitting) to achieve rendering HTML at a separate stage.
If we change the Content
component to below, and use getData
to obtain data:
let data;
const getData = () => {
if (!data) {
data = new Promise((resolve) => {
// Transfer the data 2 seconds later
setTimeout(() => {
data = "content from remote";
resolve();
}, 2000);
});
throw data;
}
// promise-like
if (data && data.then) {
throw data;
}
const result = data;
data = undefined;
return result;
};
export default function Content() {
// Get the data here
const data = getData();
return <div>{data}</div>;
}
The data required by Content
component will be rendered 2 seconds later. Note that codesandbox just upgraded their website, the effect on Streaming SSR might not work -- try it in local development.
To ensure Streaming SSR runs smoothly: before the data is ready,
getData
needs to throw apromise
and thepromise
will be catched by Suspense.
Selective Hydration
...TBC