diff --git a/fixtures/fizz/README.md b/fixtures/fizz/README.md new file mode 100644 index 0000000000000..63acd3ddc545d --- /dev/null +++ b/fixtures/fizz/README.md @@ -0,0 +1,30 @@ +# Fizz Fixtures + +A set of basic tests for Fizz primarily focussed on baseline perfomrance of legacy renderToString and streaming implementations. + +## Setup + +To reference a local build of React, first run `npm run build` at the root +of the React project. Then: + +``` +cd fixtures/fizz +yarn +yarn start +``` + +The `start` command runs a webpack dev server and a server-side rendering server in development mode with hot reloading. + +**Note: whenever you make changes to React and rebuild it, you need to re-run `yarn` in this folder:** + +``` +yarn +``` + +If you want to try the production mode instead run: + +``` +yarn start:prod +``` + +This will pre-build all static resources and then start a server-side rendering HTTP server that hosts the React app and service the static resources (without hot reloading). diff --git a/fixtures/fizz/package.json b/fixtures/fizz/package.json new file mode 100644 index 0000000000000..42becc4b8bce4 --- /dev/null +++ b/fixtures/fizz/package.json @@ -0,0 +1,53 @@ +{ + "name": "react-ssr", + "version": "0.1.0", + "private": true, + "engines": { + "node": ">=14.9.0" + }, + "license": "MIT", + "dependencies": { + "@babel/core": "7.14.3", + "@babel/register": "7.13.16", + "babel-loader": "8.1.0", + "babel-preset-react-app": "10.0.0", + "compression": "^1.7.4", + "concurrently": "^5.3.0", + "express": "^4.17.1", + "nodemon": "^2.0.6", + "react": "link:../../build/node_modules/react", + "react-dom": "link:../../build/node_modules/react-dom", + "react-error-boundary": "^3.1.3", + "resolve": "1.12.0", + "rimraf": "^3.0.2", + "webpack": "4.44.2", + "webpack-cli": "^4.2.0" + }, + "devDependencies": { + "cross-env": "^7.0.3", + "prettier": "1.19.1" + }, + "scripts": { + "start": "concurrently \"npm run server:dev\" \"npm run bundler:dev\"", + "start:prod": "concurrently \"npm run server:prod\" \"npm run bundler:prod\"", + "server:dev": "cross-env NODE_ENV=development nodemon -- --inspect server/server.js", + "server:prod": "cross-env NODE_ENV=production nodemon -- server/server.js", + "bundler:dev": "cross-env NODE_ENV=development nodemon -- scripts/build.js", + "bundler:prod": "cross-env NODE_ENV=production nodemon -- scripts/build.js" + }, + "babel": { + "presets": [ + [ + "react-app", + { + "runtime": "automatic" + } + ] + ] + }, + "nodemonConfig": { + "ignore": [ + "build/*" + ] + } +} diff --git a/fixtures/fizz/public/main.css b/fixtures/fizz/public/main.css new file mode 100644 index 0000000000000..34b4ecb1ce7f4 --- /dev/null +++ b/fixtures/fizz/public/main.css @@ -0,0 +1,74 @@ +body { + font-family: system-ui, sans-serif; +} + +* { + box-sizing: border-box; +} + +nav { + padding: 20px; +} + +.sidebar { + padding: 10px; + height: 500px; + float: left; + width: 30%; +} + +.post { + padding: 20px; + float: left; + width: 60%; +} + +h1, h2 { + padding: 0; +} + +ul, li { + margin: 0; +} + +.post p { + font-size: larger; + font-family: Georgia, serif; +} + +.comments { + margin-top: 40px; +} + +.comment { + border: 2px solid #aaa; + border-radius: 4px; + padding: 20px; +} + +/* https://codepen.io/mandelid/pen/vwKoe */ +.spinner { + display: inline-block; + transition: opacity linear 0.1s; + width: 20px; + height: 20px; + border: 3px solid rgba(80, 80, 80, 0.5); + border-radius: 50%; + border-top-color: #fff; + animation: spin 1s ease-in-out infinite; + opacity: 0; +} +.spinner--active { + opacity: 1; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} +@keyframes spin { + to { + transform: rotate(360deg); + } +} \ No newline at end of file diff --git a/fixtures/fizz/scripts/build.js b/fixtures/fizz/scripts/build.js new file mode 100644 index 0000000000000..452f7c750f61f --- /dev/null +++ b/fixtures/fizz/scripts/build.js @@ -0,0 +1,53 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +'use strict'; + +const path = require('path'); +const rimraf = require('rimraf'); +const webpack = require('webpack'); + +const isProduction = process.env.NODE_ENV === 'production'; +rimraf.sync(path.resolve(__dirname, '../build')); +webpack( + { + mode: isProduction ? 'production' : 'development', + devtool: isProduction ? 'source-map' : 'cheap-module-source-map', + entry: [path.resolve(__dirname, '../src/index.js')], + output: { + path: path.resolve(__dirname, '../build'), + filename: 'main.js', + }, + module: { + rules: [ + { + test: /\.js$/, + use: 'babel-loader', + exclude: /node_modules/, + }, + ], + }, + }, + (err, stats) => { + if (err) { + console.error(err.stack || err); + if (err.details) { + console.error(err.details); + } + process.exit(1); + } + const info = stats.toJson(); + if (stats.hasErrors()) { + console.log('Finished running webpack with errors.'); + info.errors.forEach(e => console.error(e)); + process.exit(1); + } else { + console.log('Finished running webpack.'); + } + } +); diff --git a/fixtures/fizz/server/delays.js b/fixtures/fizz/server/delays.js new file mode 100644 index 0000000000000..20a1e870b4e0c --- /dev/null +++ b/fixtures/fizz/server/delays.js @@ -0,0 +1,18 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +// Tweak these to play with different kinds of latency. + +// How long the data fetches on the server. +exports.API_DELAY = 2000; + +// How long the server waits for data before giving up. +exports.ABORT_DELAY = 10000; + +// How long serving the JS bundles is delayed. +exports.JS_BUNDLE_DELAY = 4000; diff --git a/fixtures/fizz/server/render-to-buffer.js b/fixtures/fizz/server/render-to-buffer.js new file mode 100644 index 0000000000000..ba509b812b733 --- /dev/null +++ b/fixtures/fizz/server/render-to-buffer.js @@ -0,0 +1,83 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import {Writable} from 'stream'; +import * as React from 'react'; +import {renderToPipeableStream} from 'react-dom/server'; +import App from '../src/App'; +import {ABORT_DELAY} from './delays'; + +// In a real setup, you'd read it from webpack build stats. +let assets = { + 'main.js': '/main.js', + 'main.css': '/main.css', +}; + +function HtmlWritable(options) { + Writable.call(this, options); + this.chunks = []; + this.html = ''; +} + +HtmlWritable.prototype = Object.create(Writable.prototype); +HtmlWritable.prototype.getHtml = function getHtml() { + return this.html; +}; +HtmlWritable.prototype._write = function _write(chunk, encoding, callback) { + this.chunks.push(chunk); + callback(); +}; +HtmlWritable.prototype._final = function _final(callback) { + this.html = Buffer.concat(this.chunks).toString(); + callback(); +}; + +module.exports = function render(url, res) { + let writable = new HtmlWritable(); + res.socket.on('error', error => { + console.error('Fatal', error); + }); + let didError = false; + let didFinish = false; + + writable.on('finish', () => { + // If something errored before we started streaming, we set the error code appropriately. + res.statusCode = didError ? 500 : 200; + res.setHeader('Content-type', 'text/html'); + res.send(writable.getHtml()); + }); + + const {pipe, abort} = renderToPipeableStream(, { + bootstrapScripts: [assets['main.js']], + onAllReady() { + // Full completion. + // You can use this for SSG or crawlers. + didFinish = true; + }, + onShellReady() { + // If something errored before we started streaming, we set the error code appropriately. + pipe(writable); + }, + onShellError(x) { + // Something errored before we could complete the shell so we emit an alternative shell. + res.statusCode = 500; + res.send('

Error

'); + }, + onError(x) { + didError = true; + console.error(x); + }, + }); + // Abandon and switch to client rendering if enough time passes. + // Try lowering this to see the client recover. + setTimeout(() => { + if (!didFinish) { + abort(); + } + }, ABORT_DELAY); +}; diff --git a/fixtures/fizz/server/render-to-stream.js b/fixtures/fizz/server/render-to-stream.js new file mode 100644 index 0000000000000..4ebeb94b41d63 --- /dev/null +++ b/fixtures/fizz/server/render-to-stream.js @@ -0,0 +1,57 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import * as React from 'react'; +import {renderToPipeableStream} from 'react-dom/server'; +import App from '../src/App'; +import {ABORT_DELAY} from './delays'; + +// In a real setup, you'd read it from webpack build stats. +let assets = { + 'main.js': '/main.js', + 'main.css': '/main.css', +}; + +module.exports = function render(url, res) { + // The new wiring is a bit more involved. + res.socket.on('error', error => { + console.error('Fatal', error); + }); + let didError = false; + let didFinish = false; + const {pipe, abort} = renderToPipeableStream(, { + bootstrapScripts: [assets['main.js']], + onAllReady() { + // Full completion. + // You can use this for SSG or crawlers. + didFinish = true; + }, + onShellReady() { + // If something errored before we started streaming, we set the error code appropriately. + res.statusCode = didError ? 500 : 200; + res.setHeader('Content-type', 'text/html'); + pipe(res); + }, + onShellError(x) { + // Something errored before we could complete the shell so we emit an alternative shell. + res.statusCode = 500; + res.send('

Error

'); + }, + onError(x) { + didError = true; + console.error(x); + }, + }); + // Abandon and switch to client rendering if enough time passes. + // Try lowering this to see the client recover. + setTimeout(() => { + if (!didFinish) { + abort(); + } + }, ABORT_DELAY); +}; diff --git a/fixtures/fizz/server/render-to-string.js b/fixtures/fizz/server/render-to-string.js new file mode 100644 index 0000000000000..5063318ef0ad2 --- /dev/null +++ b/fixtures/fizz/server/render-to-string.js @@ -0,0 +1,26 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import * as React from 'react'; +import {renderToString} from 'react-dom/server'; +import App from '../src/App'; +import {API_DELAY, ABORT_DELAY} from './delays'; + +// In a real setup, you'd read it from webpack build stats. +let assets = { + 'main.js': '/main.js', + 'main.css': '/main.css', +}; + +module.exports = function render(url, res) { + res.send( + '' + + renderToString() + + '' + ); +}; diff --git a/fixtures/fizz/server/server.js b/fixtures/fizz/server/server.js new file mode 100644 index 0000000000000..38519e5743105 --- /dev/null +++ b/fixtures/fizz/server/server.js @@ -0,0 +1,108 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +'use strict'; + +const babelRegister = require('@babel/register'); +babelRegister({ + ignore: [/[\\\/](build|server\/server|node_modules)[\\\/]/], + presets: [['react-app', {runtime: 'automatic'}]], + plugins: ['@babel/transform-modules-commonjs'], +}); + +const express = require('express'); +const compress = require('compression'); +const {readFileSync} = require('fs'); +const path = require('path'); +const renderToString = require('./render-to-string'); +const renderToStream = require('./render-to-stream'); +const renderToBuffer = require('./render-to-buffer'); +const {JS_BUNDLE_DELAY} = require('./delays'); + +const PORT = process.env.PORT || 4000; +const app = express(); + +app.use(compress()); +app.get( + '/', + handleErrors(async function(req, res) { + await waitForWebpack(); + renderToStream(req.url, res); + }) +); +app.get( + '/string', + handleErrors(async function(req, res) { + await waitForWebpack(); + renderToString(req.url, res); + }) +); +app.get( + '/stream', + handleErrors(async function(req, res) { + await waitForWebpack(); + renderToStream(req.url, res); + }) +); +app.get( + '/buffer', + handleErrors(async function(req, res) { + await waitForWebpack(); + renderToBuffer(req.url, res); + }) +); +app.use(express.static('build')); +app.use(express.static('public')); + +app + .listen(PORT, () => { + console.log(`Listening at ${PORT}...`); + }) + .on('error', function(error) { + if (error.syscall !== 'listen') { + throw error; + } + const isPipe = portOrPipe => Number.isNaN(portOrPipe); + const bind = isPipe(PORT) ? 'Pipe ' + PORT : 'Port ' + PORT; + switch (error.code) { + case 'EACCES': + console.error(bind + ' requires elevated privileges'); + process.exit(1); + break; + case 'EADDRINUSE': + console.error(bind + ' is already in use'); + process.exit(1); + break; + default: + throw error; + } + }); + +function handleErrors(fn) { + return async function(req, res, next) { + try { + return await fn(req, res); + } catch (x) { + next(x); + } + }; +} + +async function waitForWebpack() { + while (true) { + try { + readFileSync(path.resolve(__dirname, '../build/main.js')); + return; + } catch (err) { + console.log( + 'Could not find webpack build output. Will retry in a second...' + ); + await new Promise(resolve => setTimeout(resolve, 1000)); + } + } +} diff --git a/fixtures/fizz/src/App.js b/fixtures/fizz/src/App.js new file mode 100644 index 0000000000000..99dc52179c183 --- /dev/null +++ b/fixtures/fizz/src/App.js @@ -0,0 +1,62 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import Html from './Html'; +import BigComponent from './BigComponent'; + +export default function App({assets, title}) { + const components = []; + + for (let i = 0; i <= 1000; i++) { + components.push(); + } + + return ( + +

{title}

+ {components} + + ); +} + +function HeavyComponent() { + return ( + <> + +
+

Title

+

Content

+
+
+
+
+
12345
+
+
+
+
+
+ + ); +} diff --git a/fixtures/fizz/src/BigComponent.js b/fixtures/fizz/src/BigComponent.js new file mode 100644 index 0000000000000..248806be6d137 --- /dev/null +++ b/fixtures/fizz/src/BigComponent.js @@ -0,0 +1,49 @@ +export default function BigComponent() { + return ( +
+
+

Description

+

+ This page has repeating sections purposefully to create very large + trees that stress the rendering and streaming capabilities of Fizz +

+
+
+

Another Section

+

this section has a list

+
    +
  • item one
  • +
  • item two
  • +
  • item three
  • +
  • item four
  • +
  • item five
  • +
+

it isn't a very interesting list

+
+
+

Third Section

+

+ This is the third and final section you will see before the sections + repeat +

+

it isn't a very interesting section

+
+
+
+
+
+
+ + we're deep in some nested divs here, not that you can tell + visually + +
+
+
+
+
+
+
+
+ ); +} diff --git a/fixtures/fizz/src/Html.js b/fixtures/fizz/src/Html.js new file mode 100644 index 0000000000000..e834f84bac872 --- /dev/null +++ b/fixtures/fizz/src/Html.js @@ -0,0 +1,34 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export default function Html({assets, children, title}) { + return ( + + + + + + + {title} + + +