preface

React 18 recently had its first release candidate (18.0.0-RC.0), which meant that all of its features were stable enough to go into production testing. Streaming SSR with Suspense is one of the important features in this paper.

Server Side Rendering (SSR)

First, how does React Server Side rendering work?

During user visits, React SSR (like SSR with Chocolate in the image below) renders the React component into HTML and sends it to the client in advance, so that the client can display the basic static HTML content before JavaScript rendering is complete. Reduce white screen waiting time.

After loading JavaScript, the React event logic is bound to the existing HTML components (i.e. the process of Hydration). After Hydration, a normal React application is implemented.



But these SSR’s also existdisadvantages:

  • The server needs to have the HTML of all the components ready to return. If a component requires data that takes a long time, it blocks the entire HTML generation.
  • Chocolate is a one-off, with users waiting for the client to load JavaScript for all components and finish before interacting with any of them. (When rendering logic is complex, there may be a long period of non-interactivity between the first rendering of the page and its interactivity)
  • There is no support in React SSR for code splitting combinations commonly used for client renderingReact.lazyandSuspense.

In React 18, the new SSR architecture React Fizz introduces two major new features to address this shortcoming: Streaming HTML and Selective Hydration

Streaming HTML

Generally speaking, streaming rendering is the transfer of HTML blocks over the network, and then the client receives the blocks and renders them step by step to improve the user experience when the page is opened. This is usually done using Chunked Transfer encoding mechanisms in HTTP/1.1.

renderToNodeStream

React 16 also has a stream APIrenderToNodeStream that returns a readable stream (and then pipes that stream into node.js’s response stream) to render to the client. Shorter TFFB times than the original renderToString.

TFFB: Time To First Byte: the number of milliseconds between sending a page request and receiving the First Byte of reply data

app.get("/".(req, res) = > {
  res.write(
    "
      Hello World"
  );
  res.write("<div id='root'>");
  const stream = renderToNodeStream(<App />);
  stream.pipe(res, { end: false });
  stream.on("end".() = > {
    // Write the rest of the HTML after the stream ends
    res.write("</div></body></html>");
    res.end();
  });
});
Copy the code

RenderToNodeStream, however, needs to start rendering from the top down of the DOM tree, rather than wait for data from one component to render HTML for the rest (as shown below). The API will be officially deprecated in React 18.

renderToPipeableStream

The new RenderTop AbleStream API has features for both Streaming and Suspense, though it is more complicated to use.

// react-dom/src/server/ReactDOMFizzServerNode.js
// Type definition

typeOptions = { identifierPrefix? :string, namespaceURI? :string, nonce? :string, bootstrapScriptContent? :string, bootstrapScripts? :Array<string>, bootstrapModules? :Array<string>, progressiveChunkSize? :number.Called when at least one root fallback (in Suspense) is available to showonCompleteShell? :() = > void.// Called when the shell has reported an error before completion, and can be used to return other resultsonErrorShell? :() = > void.// Called after all waiting tasks are complete, but may not be flushed yet.onCompleteAll? :() = > void, onError? :(error: mixed) = > void};type Controls = {
  // Cancel the waiting I/O and switch to client rendering
  abort(): void,
  pipe<T: Writable>(destination: T): T,
};

function renderToPipeableStream(children: ReactNodeList, options? : Options,) :Controls
Copy the code

Here is the first React Demo as an example.

import { renderToPipeableStream } from "react-dom/server";
import App from ".. /src/App";
import { DataProvider } from ".. /src/data";

function render(url, res) {
  // res is a writable response stream
  res.socket.on("error".(error) = > {
    console.error("Fatal", error);
  });
  let didError = false;
  const data = createServerData();
  Return a Writable Stream
  const { pipe, abort } = renderToPipeableStream(
    <DataProvider data={data}>
      <App assets={assets} />
    </DataProvider>,
    {
      bootstrapScripts: [assets["main.js"]],
      onCompleteShell() {
        // Set the correct status code before Stream is transmitted
        res.statusCode = didError ? 500 : 200;
        res.setHeader("Content-type"."text/html");
        pipe(res);
      },
      onErrorShell(x) {
        // Replace the shell when an error occurs
        res.statusCode = 500;
        res.send("
      

Error

"
); }, onError(x) { didError = true; console.error(x); }});// Drop server-side rendering and switch to client-side rendering. setTimeout(abort, ABORT_DELAY); } Copy the code



The following is an excerpt from the actual HTML being transmitted; initially the Suspense Comment component is not ready, and only placeholder Spinner is returned. Each component that is Suspense has an annotated and id that is not visible to the usertemplatePlaceholders are used to record Chunks of transferred state, and these placeholders are later populated by valid components.

Template can be used as a child of any label type component and is therefore used as a placeholder.

The data structure

A render with Suspense can be divided into data structures, the bottom Chunk being a string or basic HTML fragment.

  • Request
    • SuspenseBoundary
      • Segment
        • Chunk

Source: ReactFizzServer. Js

/ / state
const PENDING = 0;
const COMPLETED = 1;
const FLUSHED = 2;
const ABORTED = 3;
const ERRORED = 4;

type PrecomputedChunk = Uint8Array;
type Chunk = string;

type Segment = {
  status: 0 | 1 | 2 | 3 | 4.// typically a segment will be flushed by its parent, except if its parent was already flushed
  parentFlushed: boolean.// starts as 0 and is lazily assigned if the parent flushes early
  id: number.// the index within the parent's chunks or 0 at the root
  +index: number,
  +chunks: Array<Chunk | PrecomputedChunk>,
  +children: Array<Segment>,
  // The context that this segment was created in.
  formatContext: FormatContext,
  // If this segment represents a fallback, this is the content that will replace that fallback.
  +boundary: null | SuspenseBoundary,
};

type SuspenseBoundary = {
  id: SuspenseBoundaryID,
  rootSegmentID: number.// if it errors or infinitely suspends
  forceClientRender: boolean.parentFlushed: boolean.// when it reaches zero we can show this boundary's content
  pendingTasks: number.// completed but not yet flushed segments.
  completedSegments: Array<Segment>,
  // used to determine whether to inline children boundaries.
  byteSize: number.// used to cancel task on the fallback if the boundary completes or gets canceled.
  fallbackAbortableTasks: Set<Task>,
};

type Request = {
	destination: null | Destination,
  +responseState: ResponseState,
  +progressiveChunkSize: number.status: 0 | 1 | 2.fatalError: mixed,
  nextSegmentId: number.// when it reaches zero, we can close the connection.
  allPendingTasks: number.// when this reaches zero, we've finished at least the root boundary.
  pendingRootTasks: number.// Completed but not yet flushed root segments.
  completedRootSegment: null | Segment,
  abortableTasks: Set<Task>,
  // Queues to flush in order of priority
  pingedTasks: Array<Task>,
  // Errored or client rendered but not yet flushed.
  clientRenderedBoundaries: Array<SuspenseBoundary>,
  // Completed but not yet fully flushed boundaries to show.
  completedBoundaries: Array<SuspenseBoundary>,
  // Partially completed boundaries that can flush its segments early.
  partialBoundaries: Array<SuspenseBoundary>,
  onError: (error: mixed) = > void.onCompleteAll: () = > void.onCompleteShell: () = > void.onErrorShell: (error: mixed) = > void,}Copy the code

Placeholder format

Different ID prefix endings represent different elements:

  • Placeholder(placeholder block) :P:
  • Segment(Valid fragment to insert) :S:, usuallydiv, tables, mathematical formulas, SVG will use the corresponding elements
  • BoundarySuspense boundary) :B:
  • Id:R:

Blocks of states where different comments start with annotations that represent different Suspense boundaries:

Suspense Boundary is not a concrete fragment or custom tag in order not to affect CSS selectors and presentation effects

  • Completed(Completed) :<! - $-- -- >
  • Pending(Waiting) :<! - $? -->
  • ClientRendered(Client rendered) :<! - $! -->

Comments at the end of the annotation are unified as

Rendering process

The sidebar, POST, and comments components are Pending except main, which is Completed.

<body>
  <noscript><b>Enable JavaScript to run this app.</b></noscript>
  <! - $-- -- >
  <main>
    <nav><a href="/">Home</a></nav>
    <aside class="sidebar">
      <! - $? -->
      <template id="B:0"></template>
      <div
        class="spinner spinner--active"
        role="progressbar"
        aria-busy="true"
      ></div>
      <! - / $-- -- >
    </aside>
    <article class="post">
      <! - $? -->
      <template id="B:1"></template>
      <div
        class="spinner spinner--active"
        role="progressbar"
        aria-busy="true"
      ></div>
      <! - / $-- -- >
      <section class="comments">
        <h2>Comments</h2>
        <! - $? -->
        <template id="B:2"></template>
        <div
          class="spinner spinner--active"
          role="progressbar"
          aria-busy="true"
        ></div>
        <! - / $-- -- >
      </section>
      <h2>Thanks for reading!</h2>
    </article>
  </main>
  <! - / $-- -- >
</body>
Copy the code

The HTML also contains scripts to replace placeholders with actual components:

Concrete implementation: ReactDOMServerFormatConfig. Js

<script>
  // function completeSegment(containerID, placeholderID)
  function $RS(a, b) {
    // ...
  }
</script>

<script>
  // function completeBoundary(suspenseBoundaryID, contentID)
  function $RC(a, b) {
    // ...
  }
</script>

<script>
  // function clientRenderBoundary(suspenseBoundaryID)
  function $Rx(a, b) {
    // ...
  }
</script>
Copy the code

The following is a simple demonstration with Placechildren

ReplaceChildren is an experimental DOM API launched in 2020 and currently supported by all major browsers.

<div hidden id="comments">
  <! -- Comments -->
  <p>foo</p>
  <p>bar</p>
</div>
<script>
  // New replacement child API
  document
    .getElementById("sections-spinner")
    .replaceChildren(document.getElementById("comments"));
</script>
Copy the code

After components end in Suspense they continue to transmit the prepared comment components and scripts to replace placeholders, which the browser parses for “incremental rendering.”

<div hidden id="S:2"><template id="P:5"></template></div>
<div hidden id="S:5">
  <p class="comment">Wait, it doesn&#x27;t wait for React to load?</p>
  <p class="comment">How does this even work?</p>
  <p class="comment">I like marshmallows</p>
</div>
<script>
  $RS("S:5"."P:5");
</script>
<script>
  $RC("B:2"."S:2");
</script>
Copy the code

This completes a server-side streaming rendering of the HTML, at which point the client-side JavaScript may not be loaded.

The resulting HTML will retain the content snippets to be inserted (with hidden tags that are not visible to the user), although Suspense and Streaming don’t guarantee the same order as DOM because of Suspense and Streaming, it should not affect THE SEO effect.

Another new feature in React 18, the React Server Component, also uses streaming of servers. See Plasmic’s React Server Component for more details.

Selective Hydration

With lazy and Suspense support, another feature is that React SSR can flood parts of the page that are already in place as early as possible without blocking other parts. On the other hand, flooding React 18 is lazy.

This allows components that do not need to be synchronously loaded to be optionally wrapped in lazy and Suspense (as with client rendering). The granularity of React water injection depends on the scope of Suspense, where each layer is a “level” of water injection (either all components have been flooded or none has been flooded).

import { lazy } from "react";

const Comments = lazy(() = > import("./Comments.js"));

// ...

<Suspense fallback={<Spinner />} ><Comments />
</Suspense>;
Copy the code

Likewise, streaming HTML does not block the water injection process. If the JavaScript is loaded before the HTML is, React will start flooding the completed HTML.

React maintains several priority queues that record user interaction clicks to flood the corresponding components first. After the flooding is complete, the components respond to the interaction, which is called event replay.

conclusion

The React Fizz architecture is not explained in detail in this article for reasons of length, but the features of Streaming SSR and selective waterflooding are briefly introduced. In practice, users only need to selectively introduce Suspense to enjoy the great improvement brought by Streaming SSR. It’s worth noting that Suspense for data requests is not yet supported by SSR in React 18.0, which may be available in 18.x along with React-Fetch and Server Component.

The resources

Rendering on the Web New Suspense SSR Architecture in React 18 Keeping browser interactive during hydration Upgrading to React 18 on the server What changes are planned for Suspense in 18 Library Upgrade Guide: (e.g. react-helmet) Basic Fizz Architecture