diff --git a/fixtures/flight-ssr-bench/README.md b/fixtures/flight-ssr-bench/README.md new file mode 100644 index 000000000000..98cefe6ba7de --- /dev/null +++ b/fixtures/flight-ssr-bench/README.md @@ -0,0 +1,62 @@ +# Flight SSR Benchmark + +Measures the performance overhead of the React Server Components (RSC) Flight pipeline compared to plain Fizz server-side rendering, across both Node and Edge (web streams) APIs. + +## Prerequisites + +Build React from the repo root first: + +```sh +yarn build-for-flight-prod +``` + +Then install the fixture's dependencies: + +```sh +cd fixtures/flight-ssr-bench +yarn install +``` + +## Scripts + +| Script | Purpose | +| --- | --- | +| `yarn bench` | Sequential benchmark with Flight script injection (realistic framework pipeline). Best for measuring Edge vs Node overhead. | +| `yarn bench:bare` | Sequential benchmark without script injection. Best for measuring React-internal changes (e.g. Flight serialization optimizations) with less noise from stream plumbing. | +| `yarn bench:server` | HTTP server benchmark using autocannon at c=1 and c=10. Best for measuring real-world req/s. The c=1 results are also useful for tracking React-internal changes. | +| `yarn bench:concurrent` | In-process concurrent benchmark (50 in-flight renders). Measures throughput under load without HTTP overhead. | +| `yarn bench:profile` | CPU profiling via V8 inspector. Saves `.cpuprofile` files to `build/profiles/`. | +| `yarn start` | Starts the HTTP server for manual browser testing at `http://localhost:3001`. Append `.rsc` to any Flight URL to see the raw Flight payload. | + +## What it measures + +Each script benchmarks 8 render variants: + +- **Fizz (Node, sync/async)** -- plain `renderToPipeableStream`, no RSC +- **Fizz (Edge, sync/async)** -- plain `renderToReadableStream`, no RSC +- **Flight + Fizz (Node, sync/async)** -- full RSC pipeline: Flight server (`renderToPipeableStream`) -> Flight client (`createFromNodeStream`) -> Fizz (`renderToPipeableStream`) +- **Flight + Fizz (Edge, sync/async)** -- full RSC pipeline: Flight server (`renderToReadableStream`) -> Flight client (`createFromReadableStream`) -> Fizz (`renderToReadableStream`) + +The "sync" variants use a fully synchronous app (no Suspense boundaries). The "async" variants use per-row async components with staggered delays and individual Suspense boundaries (~250 boundaries per render). + +### Script injection + +The `yarn bench` and `yarn bench:server` scripts simulate what real frameworks do: tee the Flight stream and inject `'; + }); + + rscPipe(trunk); + flightStream = forSsr; + } else { + flightStream = new PassThrough(); + rscPipe(flightStream); + } + + let cachedResult; + function Root() { + if (!cachedResult) { + cachedResult = createFromNodeStream(flightStream, ssrManifest); + } + return React.use(cachedResult); + } + + const output = new PassThrough(); + + const {pipe} = renderToPipeableStream(React.createElement(Root), { + onShellReady() { + if (inject) { + // Buffer HTML chunks within a tick to avoid injecting scripts mid-tag. + const trailer = ''; + let buffered = []; + let timeout = null; + const injector = new Transform({ + transform(chunk, _encoding, cb) { + buffered.push(chunk); + if (!timeout) { + timeout = setTimeout(() => { + for (const buf of buffered) { + let str = buf.toString(); + if (str.endsWith(trailer)) { + str = str.slice(0, -trailer.length); + } + this.push(str); + } + buffered.length = 0; + timeout = null; + if (flightScripts) { + this.push(flightScripts); + flightScripts = ''; + } + }, 0); + } + cb(); + }, + flush(cb) { + if (timeout) { + clearTimeout(timeout); + for (const buf of buffered) { + let str = buf.toString(); + if (str.endsWith(trailer)) { + str = str.slice(0, -trailer.length); + } + this.push(str); + } + buffered.length = 0; + } + if (flightScripts) { + this.push(flightScripts); + flightScripts = ''; + } + this.push(trailer); + cb(); + }, + }); + pipe(injector); + injector.pipe(output); + } else { + pipe(output); + } + }, + onError(e) { + console.error('Flight+Fizz Node error:', e); + output.destroy(e); + }, + }); + + return output; +} + +// --------------------------------------------------------------------------- +// Flight + Fizz (Edge) — RSC render → tee → Fizz + script injection via web +// streams. HTML chunks are buffered within a tick to avoid injecting scripts +// mid-tag. The trailer is stripped, Flight scripts injected, +// and the trailer re-added at flush. +// Returns a promise that resolves to a web ReadableStream. +// --------------------------------------------------------------------------- + +function renderFlightFizzEdge( + renderRSCEdge, + AppComponent, + itemCount, + clientManifest, + ssrManifest, + opts +) { + const inject = !opts || opts.inject !== false; + const React = require('react'); + const {renderToReadableStream} = require('react-dom/server'); + const { + createFromReadableStream, + } = require('react-server-dom-webpack/client.edge'); + + const webStream = renderRSCEdge(clientManifest, AppComponent, itemCount); + + let forSsr; + let injector; + + if (inject) { + const htmlTrailer = ''; + const enc = new TextEncoder(); + + let forInline; + [forSsr, forInline] = webStream.tee(); + + let resolveInline; + const inlinePromise = new Promise(function (r) { + resolveInline = r; + }); + const htmlDecoder = new TextDecoder(); + let buffered = []; + let timeout = null; + + function flushBuffered(controller) { + for (const chunk of buffered) { + let buf = htmlDecoder.decode(chunk, {stream: true}); + if (buf.endsWith(htmlTrailer)) { + buf = buf.slice(0, -htmlTrailer.length); + } + controller.enqueue(enc.encode(buf)); + } + const remaining = htmlDecoder.decode(); + if (remaining.length) { + let buf = remaining; + if (buf.endsWith(htmlTrailer)) { + buf = buf.slice(0, -htmlTrailer.length); + } + controller.enqueue(enc.encode(buf)); + } + buffered.length = 0; + timeout = null; + } + + function writeFlightChunk(data, controller) { + controller.enqueue( + enc.encode( + '' + ) + ); + } + + injector = new TransformStream({ + start(controller) { + (async function () { + const reader = forInline.getReader(); + const decoder = new TextDecoder('utf-8', {fatal: true}); + for (;;) { + const {done, value} = await reader.read(); + if (done) break; + writeFlightChunk(decoder.decode(value, {stream: true}), controller); + } + const remaining = decoder.decode(); + if (remaining.length) { + writeFlightChunk(remaining, controller); + } + resolveInline(); + })(); + }, + transform(chunk, controller) { + buffered.push(chunk); + if (!timeout) { + timeout = setTimeout(function () { + flushBuffered(controller); + }, 0); + } + }, + async flush(controller) { + await inlinePromise; + if (timeout) { + clearTimeout(timeout); + flushBuffered(controller); + } + controller.enqueue(enc.encode(htmlTrailer)); + }, + }); + } else { + forSsr = webStream; + } + + const cachedResult = createFromReadableStream(forSsr, { + serverConsumerManifest: ssrManifest, + }); + function Root() { + return React.use(cachedResult); + } + + return renderToReadableStream(React.createElement(Root)).then( + function (htmlStream) { + return injector ? htmlStream.pipeThrough(injector) : htmlStream; + } + ); +} + +// --------------------------------------------------------------------------- +// Utilities: collect streams into strings. +// --------------------------------------------------------------------------- + +function nodeStreamToString(nodeStream) { + return new Promise(function (resolve, reject) { + const chunks = []; + nodeStream.on('data', function (chunk) { + chunks.push(chunk); + }); + nodeStream.on('end', function () { + resolve(Buffer.concat(chunks).toString('utf-8')); + }); + nodeStream.on('error', reject); + }); +} + +function webStreamToString(webStream) { + const reader = webStream.getReader(); + const chunks = []; + function read() { + return reader.read().then(function ({done, value}) { + if (done) { + return Buffer.concat(chunks).toString('utf-8'); + } + chunks.push(Buffer.from(value)); + return read(); + }); + } + return read(); +} + +module.exports = { + renderFizzNode, + renderFizzEdge, + renderFlightFizzNode, + renderFlightFizzEdge, + nodeStreamToString, + webStreamToString, +}; diff --git a/fixtures/flight-ssr-bench/rsc-client-ref-loader.js b/fixtures/flight-ssr-bench/rsc-client-ref-loader.js new file mode 100644 index 000000000000..325d5b342dab --- /dev/null +++ b/fixtures/flight-ssr-bench/rsc-client-ref-loader.js @@ -0,0 +1,22 @@ +'use strict'; + +const url = require('url'); + +// Webpack loader that runs in the RSC compilation. +// When a module starts with 'use client', it replaces the entire source +// with a client module proxy. This makes the RSC renderer serialize a +// client reference into the Flight stream instead of rendering the component. +module.exports = function rscClientRefLoader(source) { + const trimmed = source.trimStart(); + if ( + trimmed.startsWith("'use client'") || + trimmed.startsWith('"use client"') + ) { + const href = url.pathToFileURL(this.resourcePath).href; + return [ + `const { createClientModuleProxy } = require('react-server-dom-webpack/server');`, + `module.exports = createClientModuleProxy(${JSON.stringify(href)});`, + ].join('\n'); + } + return source; +}; diff --git a/fixtures/flight-ssr-bench/src/App.js b/fixtures/flight-ssr-bench/src/App.js new file mode 100644 index 000000000000..c50ea1bd3f5f --- /dev/null +++ b/fixtures/flight-ssr-bench/src/App.js @@ -0,0 +1,18 @@ +import Shell from './components/Shell'; +import Sidebar from './components/Sidebar'; +import Dashboard from './components/Dashboard'; +import Footer from './components/Footer'; + +export default function App({itemCount}) { + return ( + + + + + +