brillout / react-streaming

React Streaming. Full-fledged & Easy.
MIT License
216 stars 14 forks source link

React Streaming

react-streaming

React Streaming. Full-fledged & Easy.

Unfamiliar with React Streaming? Check out Dan's article about SSR and Streaming.

⚠️ While react-streaming in itself is stable and used in production, note that React's SSR streaming support is still early and that the React team is working on high-level APIs that will make parts of react-streaming obsolete, see @sebmarkbage comment at "RFC: injectToStream".

Contents

Intro

Features (for React users)

Features (for library authors)

Easy

import { renderToStream } from 'react-streaming/server'
const {
  pipe, // Node.js (Vercel, AWS EC2, ...)
  readable // Edge (Cloudflare Workers, Deno Deploy, Netlify Edge, Vercel Edge, ...)
} = await renderToStream(<Page />)


Why Streaming

React 18's new SSR streaming architecture unlocks many capabilities:

Problem: the current React Streaming architecture is low-level and difficult to use.

Solution: react-streaming.

react-streaming makes it easy to build the libraries of tomorrow, for example:

  • Use Telefunc to fetch data for your Next.js or Vike app. (Instead of Next.js's getServerSideProps() / Vike's data().)
  • Better GraphQL tools, e.g. Vilay.


Usage

Get Started

  1. Install

    npm install react-streaming
  2. Server-side

    import { renderToStream } from 'react-streaming/server'
    const {
     pipe, // Defined if running in Node.js, otherwise `null`
     readable // Defined if running on Edge (e.g. Cloudflare Workers), otherwise `null`
    } = await renderToStream(<Page />)

That's it.

Options

const options = {
  // ...
}
await renderToStream(<Page />, options)

Bots

By default, react-streaming disables streaming for bots and crawlers, such as:

[!NOTE] These bots explore your website by navigating the HTML of your pages. It isn't clear what bots do when they encounter an HTML stream (to be researched); it's therefore safer to provide bots with a fully rendered HTML at once that contains all the content of your page (i.e. disable HTML streaming) instead of hoping that bots will await the HTML stream.

For react-streaming to be able to determine whether a request comes from a bot or a real user, you need to provide options.userAgent.

[!NOTE] If you use Vike with vike-react, you can simply set renderPage({ headersOriginal }) instead. (The User-Agent request header will then automatically be passed to react-streaming).

You can implement a custom strategy, see options.seoStrategy.

You can use $ curl to see the HTML response that bots and crawlers receive:

# What bots and crawls get: no HTML Streaming, just "classic SSR"
$ curl http://localhost:3000/star-wars
# What regular users get: HTML Streaming
$ curl http://localhost:3000/star-wars -N -H "User-Agent: chrome"

[!NOTE] By default curl sets User-Agent: curl/8.5.0, which react-streaming interprets as bot.

Error Handling

The promise await renderToStream() resolves after the page shell is rendered. This means that if an error occurs while rendering the page shell, then the promise rejects with that error.

:book: The page shell is the set of all components before <Suspense> boundaries.

try {
  await renderToStream(<Page />)
  // ✅ Page shell succesfully rendered and is ready in the stream buffer.
} catch(err) {
  // ❌ Something went wrong while rendering the page shell.
}

The stream returned by await renderToStream() doesn't emit errors.

:book: If an error occurs during the stream, then that means that a <Suspense> boundary failed. Instead of emiting a stream error, React swallows the error on the server-side and retries to resolve the <Suspense> boundary on the client-side. If the <Suspense> fails again on the client-side, then the client-side throws the error.

This means that errors occuring during the stream are handled by React and there is nothing for you to do on the server-side. That said, you may want to gracefully handle the error on the client-side e.g. with react-error-boundary.

You can use options.onBoundaryError() for error tracking purposes.

Abort

After a default timeout of 20 seconds react-streaming aborts the rendering stream, as recommended by React here and there.

When the timeout is reached react-streaming ends the stream and tells React to stop rendering. Note that there isn't any thrown error: React merely stops server-side rendering and continues on the client-side, see explanation at Error Handling.

You can also manually abort:

const { abort } = await renderToStream(<Page />, { timeout: null })
abort()

SSR

In general, with React Streaming, all the content of your page is included in the HTML stream. This means you get all the benefits of SSR. However, it isn't clear whether crawlers fully wait the HTML stream to complete. It's therefore safer to disable HTML Streaming for crawlers and fall back to "classical SSR", see Bots.

[!NOTE] The order in which the content of your page is included in the HTML stream depends on which data comes first. For example, if you use a loading fallback component, the content of the loading component appears first, followed by the content of the main component after the <Suspense> boundary resolves.

useAsync()

import { useAsync } from 'react-streaming'

function Page({ movieId }) {
  return (
    <Suspense fallback={<p>Loading...</p>}>
      <Movie id={movieId}/>
    </Suspense>
  )
}

async function fetchMovie(id) {
  const response = await fetch(`https://star-wars.brillout.com/api/films/${id}.json`)
  return response.json()
}

// This component is isomorphic: it works on both the client-side and server-side. The
// data fetched during SSR is automatically passed and re-used on the client-side.
function Movie({ id }) {
  const key = [
    'star-wars-movies',
    id // Re-run `fetchMovie()` if `id` changes
  ]
  const movie = useAsync(key, () => fetchMovie(id))
  return (
    <ul>
      <li>
        Title: {movie.title}
      </li>
      <li>
        Release Date: {movie.release_date}
      </li>
    </ul>
  )
}

See useAsync() (Library Authors) for more information.


Usage (Library Authors)

Overview

react-streaming enables you to suspend the React rendering and await for something to happen. (Usually data fetching.) The novelty here is that it's isomorphic:

You have the choice between:

useAsync() (Library Authors)

This section is a low-level description of useAsync(). For a high-level description, see useAsync() instead.

import { useAsync } from 'react-streaming'

function SomeComponent() {
  const someAsyncFunc = async function () {
    const value = 'someData'
    return value
  }
  const key = ['some', 'invalidating', 'values']
  // useAsync() suspends rendering until the promise returned by someAsyncFunc() resolves
  const value = useAsync(key, someAsyncFunc)
  assert(value === 'someData')
}

When <SomeComponent> is rendered on the server-side (SSR), it injects the resolved value into the stream and the client-side picks up the injected value. This means that the client-side doesn't call someAsyncFunc(): instead, the client-side re-uses the value resolved on the server-side.

If you want someAsyncFunc() to be re-run, then change key. The someAsyncFunc() is only re-run if when the component is un-mounted and re-mounted, or if key changes. For example, changing the state of your component (e.g. with useState()) will not re-run someAsyncFunc() if you provide the same key.

Usually the key is set to ['name-of-the-function', ...functionArguments].

You can think of key to serve a similar purpose to React Queries's key, and to the deps argument of React's useEffect(fn, deps).

injectToStream()

type Chunk = string | Buffer
type Options = { flush?: boolean }
injectToStream(chunk: Chunk  | Promise<Chunk>, options?: Options)`

The injectToStream() function enables you to inject chunks to the stream.

There are two ways to access injectToStream():

  1. With renderToStream():
    import { renderToStream } from 'react-streaming/server'
    const stream = await renderToStream(<Page />)
    const { injectToStream } = stream
  2. With useStream():

    import { useStream } from 'react-streaming'
    
    function SomeComponent() {
      const stream = useStream()
      if (stream === null) {
        // No stream available. This is the case:
        // - On the client-side.
        // - When `option.disable === true`.
        // - When react-streaming is not installed.
      }
      const { injectToStream } = stream
    }

Usage examples:

// Inject JavaScript (e.g. for progressive hydration)
injectToStream('<script type="module" src="https://github.com/brillout/react-streaming/raw/main/main.js"></script>', { flush: true })

// Inject CSS (e.g. for CSS-in-JS)
injectToStream('<styles>.some-component { color: blue }</styles>', { flush: true })

// Pass data to client
injectToStream(`<script type="application/json">${JSON.stringify(someData)}</script>`)

For a full example of using injectToStream(), have a look at useAsync()'s implementation.

If setting options.flush to true, then the stream will be flushed after chunk has been written to the stream. This is only applicable for Node.js streams and only if you are using a compression library that makes a flush() method available. For example, compression adds a res.flush() method. The option is ignored if there isn't a flush() method available.

doNotClose()

Typical usage:

const makeClosableAgain = stream.doNotClose()
// Ensure chunk is injected before the stream ends
injectToStream(chunk)
makeClosableAgain()

Like injectToStream(), there are two ways to access it:

import { renderToStream } from 'react-streaming/server'
const stream = await renderToStream(<Page />)
const { doNotClose } = stream
import { useStream } from 'react-streaming'
function SomeComponent() {
  const stream = useStream()
  const { doNotClose } = stream
}

hasStreamEnded()

Check whether the stream has ended.

Like injectToStream(), there are two ways to access it:

import { renderToStream } from 'react-streaming/server'
const stream = await renderToStream(<Page />)
const { hasStreamEnded } = stream
import { useStream } from 'react-streaming'
function SomeComponent() {
  const stream = useStream()
  const { hasStreamEnded } = stream
}