From 7652b2a70f9904b68a2ab7c5888b7b3aa93e7469 Mon Sep 17 00:00:00 2001 From: Rory Hunter Date: Fri, 16 Dec 2016 11:09:06 +0000 Subject: [PATCH 1/6] Build the prod bundle as UMD and export render function Derive a library name from the project name in package.json. In the index.js template, don't render the App unless window is defined, and export a render-to-string function. --- .../react-scripts/config/webpack.config.prod.js | 16 ++++++++++++++++ packages/react-scripts/package.json | 1 + packages/react-scripts/template/src/index.js | 9 ++++++++- 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/react-scripts/config/webpack.config.prod.js b/packages/react-scripts/config/webpack.config.prod.js index 218eb46bb0c..4273c9f4c93 100644 --- a/packages/react-scripts/config/webpack.config.prod.js +++ b/packages/react-scripts/config/webpack.config.prod.js @@ -16,9 +16,12 @@ const HtmlWebpackPlugin = require('html-webpack-plugin'); const ExtractTextPlugin = require('extract-text-webpack-plugin'); const ManifestPlugin = require('webpack-manifest-plugin'); const InterpolateHtmlPlugin = require('react-dev-utils/InterpolateHtmlPlugin'); +const camelCase = require('lodash.camelcase'); const paths = require('./paths'); const getClientEnvironment = require('./env'); +const packageJson = require(paths.appPackageJson); + // Webpack uses `publicPath` to determine where the app is being served from. // It requires a trailing slash, or the file assets will get an incorrect path. const publicPath = paths.servedPath; @@ -50,6 +53,15 @@ const extractTextPluginOptions = shouldUseRelativeAssetPaths { publicPath: Array(cssFilename.split('/').length).join('../') } : {}; +let libraryName; +let libraryTarget; + +if (packageJson.name) { + // Will also strip non-alphanumerics + libraryName = camelCase(packageJson.name); + libraryTarget = 'umd'; +} + // This is the production configuration. // It compiles slowly and is focused on producing a fast and minimal bundle. // The development configuration is different and lives in a separate file. @@ -71,6 +83,10 @@ module.exports = { chunkFilename: 'static/js/[name].[chunkhash:8].chunk.js', // We inferred the "public path" (such as / or /my-project) from homepage. publicPath: publicPath, + // Options for generating a final bundle that is a module or library + library: libraryName, + libraryTarget: libraryTarget, + umdNamedDefine: true, }, resolve: { // This allows you to set a fallback for where Webpack should look for modules. diff --git a/packages/react-scripts/package.json b/packages/react-scripts/package.json index b2cb03bb972..5c7c73b8fd1 100644 --- a/packages/react-scripts/package.json +++ b/packages/react-scripts/package.json @@ -48,6 +48,7 @@ "html-webpack-plugin": "2.28.0", "http-proxy-middleware": "0.17.3", "jest": "18.1.0", + "lodash.camelcase": "4.3.0", "object-assign": "4.1.1", "postcss-loader": "1.3.3", "promise": "7.1.1", diff --git a/packages/react-scripts/template/src/index.js b/packages/react-scripts/template/src/index.js index 5d76a18a853..3c0dadfb915 100644 --- a/packages/react-scripts/template/src/index.js +++ b/packages/react-scripts/template/src/index.js @@ -1,6 +1,13 @@ import React from 'react'; import ReactDOM from 'react-dom'; +import { renderToString } from 'react-dom/server'; import App from './App'; import './index.css'; -ReactDOM.render(, document.getElementById('root')); +if (typeof window !== 'undefined') { + ReactDOM.render(, document.getElementById('root')); +} + +export default function render() { + return renderToString(); +} From f2851fefaf707197c6a9cb1d912de2cad3357262 Mon Sep 17 00:00:00 2001 From: Rory Hunter Date: Fri, 17 Mar 2017 12:41:36 +0000 Subject: [PATCH 2/6] Only polyfill window.Promise if window is defined --- packages/react-scripts/config/polyfills.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/react-scripts/config/polyfills.js b/packages/react-scripts/config/polyfills.js index 14031a1688d..0ae2e51afc5 100644 --- a/packages/react-scripts/config/polyfills.js +++ b/packages/react-scripts/config/polyfills.js @@ -15,7 +15,9 @@ if (typeof Promise === 'undefined') { // inconsistent state due to an error, but it gets swallowed by a Promise, // and the user has no idea what causes React's erratic future behavior. require('promise/lib/rejection-tracking').enable(); - window.Promise = require('promise/lib/es6-extensions.js'); + if (typeof window !== 'undefined') { + window.Promise = require('promise/lib/es6-extensions.js'); + } } // fetch() polyfill for making API calls. From 1fe65c0cd8bf38132ede8478f184355b1e39b7e3 Mon Sep 17 00:00:00 2001 From: Rory Hunter Date: Mon, 20 Mar 2017 12:48:53 +0000 Subject: [PATCH 3/6] Add e2e test for server-render-capable bundle --- .travis.yml | 2 + tasks/e2e-server-render.sh | 186 +++++++++++++++++++++++++++++++++++++ tasks/test-bundle.js | 48 ++++++++++ 3 files changed, 236 insertions(+) create mode 100755 tasks/e2e-server-render.sh create mode 100644 tasks/test-bundle.js diff --git a/.travis.yml b/.travis.yml index fa2c7d8b364..60fb6f1718a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -14,6 +14,7 @@ script: - 'if [ $TEST_SUITE = "simple" ]; then tasks/e2e-simple.sh; fi' - 'if [ $TEST_SUITE = "installs" ]; then tasks/e2e-installs.sh; fi' - 'if [ $TEST_SUITE = "kitchensink" ]; then tasks/e2e-kitchensink.sh; fi' + - 'if [ $TEST_SUITE = "server-render" ]; then tasks/e2e-server-render.sh; fi' env: global: - USE_YARN=no @@ -21,6 +22,7 @@ env: - TEST_SUITE=simple - TEST_SUITE=installs - TEST_SUITE=kitchensink + - TEST_SUITE=server-render matrix: include: - node_js: 0.10 diff --git a/tasks/e2e-server-render.sh b/tasks/e2e-server-render.sh new file mode 100755 index 00000000000..6d14d9b1175 --- /dev/null +++ b/tasks/e2e-server-render.sh @@ -0,0 +1,186 @@ +#!/bin/bash +# Copyright (c) 2015-present, Facebook, Inc. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. An additional grant +# of patent rights can be found in the PATENTS file in the same directory. + +# ****************************************************************************** +# This is an end-to-end test intended to run on CI. +# You can also run it locally but it's slow. +# ****************************************************************************** + +# Start in tasks/ even if run from root directory +cd "$(dirname "$0")" + +# CLI and app temporary locations +# http://unix.stackexchange.com/a/84980 +temp_cli_path=`mktemp -d 2>/dev/null || mktemp -d -t 'temp_cli_path'` +temp_app_path=`mktemp -d 2>/dev/null || mktemp -d -t 'temp_app_path'` + +function cleanup { + echo 'Cleaning up.' + cd "$root_path" + # Uncomment when snapshot testing is enabled by default: + # rm ./packages/react-scripts/template/src/__snapshots__/App.test.js.snap + echo rm -rf "$temp_cli_path" $temp_app_path +} + +# Error messages are redirected to stderr +function handle_error { + echo "$(basename $0): ERROR! An error was encountered executing line $1." 1>&2; + cleanup + echo 'Exiting with error.' 1>&2; + exit 1 +} + +function handle_exit { + cleanup + echo 'Exiting without error.' 1>&2; + exit +} + +function create_react_app { + node "$temp_cli_path"/node_modules/create-react-app/index.js "$@" +} + +# Check for the existence of one or more files. +function exists { + for f in "$@"; do + test -e "$f" + done +} + +# Exit the script with a helpful error message when any error is encountered +trap 'set +x; handle_error $LINENO $BASH_COMMAND' ERR + +# Cleanup before exit on any termination signal +trap 'set +x; handle_exit' SIGQUIT SIGTERM SIGINT SIGKILL SIGHUP + +# Echo every command being executed +set -x + +# Go to root +cd .. +root_path=$PWD + +# Prevent lerna bootstrap, we only want top-level dependencies +cp package.json package.json.bak +grep -v "lerna bootstrap" package.json > temp && mv temp package.json +npm install +mv package.json.bak package.json + +# We need to install create-react-app deps to test it +cd "$root_path"/packages/create-react-app +npm install +cd "$root_path" + +# If the node version is < 4, the script should just give an error. +if [[ `node --version | sed -e 's/^v//' -e 's/\..*//g'` -lt 4 ]] +then + cd $temp_app_path + err_output=`node "$root_path"/packages/create-react-app/index.js test-node-version 2>&1 > /dev/null || echo ''` + [[ $err_output =~ You\ are\ running\ Node ]] && exit 0 || exit 1 +fi + +# Still use npm install instead of directly calling lerna bootstrap to test +# postinstall script functionality (one npm install should result in a working +# project) +npm install + +if [ "$USE_YARN" = "yes" ] +then + # Install Yarn so that the test can use it to install packages. + npm install -g yarn + yarn cache clean +fi + +# Lint own code +./node_modules/.bin/eslint --max-warnings 0 . + +# ****************************************************************************** +# Pack react-scripts and create-react-app so we can verify they work. +# ****************************************************************************** + +# Pack CLI +cd "$root_path"/packages/create-react-app +cli_path=$PWD/`npm pack` + +# Go to react-scripts +cd "$root_path"/packages/react-scripts + +# Save package.json because we're going to touch it +cp package.json package.json.orig + +# Replace own dependencies (those in the `packages` dir) with the local paths +# of those packages. +node "$root_path"/tasks/replace-own-deps.js + +# Finally, pack react-scripts +scripts_path="$root_path"/packages/react-scripts/`npm pack` + +# Restore package.json +rm package.json +mv package.json.orig package.json + +# ****************************************************************************** +# Now that we have packed them, create a clean app folder and install them. +# ****************************************************************************** + +# Install the CLI in a temporary location +cd "$temp_cli_path" + +# Initialize package.json before installing the CLI because npm will not install +# the CLI properly in the temporary location if it is missing. +npm init --yes + +# Now we can install the CLI from the local package. +npm install "$cli_path" + +# Install the app in a temporary location +cd $temp_app_path +create_react_app --scripts-version="$scripts_path" test-app + +# ****************************************************************************** +# Now that we used create-react-app to create an app depending on react-scripts, +# let's make sure all npm scripts are in the working state. +# ****************************************************************************** + +verify_bundle() { + bundle_name=$(echo $PWD/build/static/js/main.*.js) + node "$root_path/tasks/test-bundle.js" "$bundle_name" +} + +# Enter the app directory +cd test-app + +# Test the build +npm run build +# Check for expected output +exists build/static/js/main.*.js + +verify_bundle + +# ****************************************************************************** +# Finally, let's check that everything still works after ejecting. +# ****************************************************************************** + +# Eject... +echo yes | npm run eject + +# ...but still link to the local packages +npm link "$root_path"/packages/babel-preset-react-app +npm link "$root_path"/packages/eslint-config-react-app +npm link "$root_path"/packages/react-dev-utils +npm link "$root_path"/packages/react-scripts + +# Test the build +npm run build +# Check for expected output +exists build/static/js/main.*.js + +verify_bundle + +# Cleanup +cleanup diff --git a/tasks/test-bundle.js b/tasks/test-bundle.js new file mode 100644 index 00000000000..e9a3ef986e3 --- /dev/null +++ b/tasks/test-bundle.js @@ -0,0 +1,48 @@ +'use strict'; + +const chalk = require('chalk'); + +function fail(msg) { + console.error(chalk.red(`✘ ${msg}`)); + process.exit(1); +} + +function pass(msg) { + console.log(chalk.green(`✔ ${msg}`)); +} + +const bundleName = process.argv[2]; + +if (!bundleName) { + fail('Must supply a require-able bundle path'); +} + +const bundle = require(bundleName); + +if (typeof bundle !== 'object') { + fail('Bundle require did not return an object'); +} + +pass('Loaded bundle successfully'); + +if (!bundle.hasOwnProperty('default')) { + fail('bundle does not have a "default" property'); +} + +pass('Bundle has a default property'); + +if (typeof bundle.default !== 'function') { + fail(`bundle.default is not a function, but: ${typeof bundle.default}`); +} + +pass("Bundle's default property is a function"); + +const html = bundle.default(); + +if (!html.match(/^
Date: Fri, 17 Mar 2017 12:59:52 +0000 Subject: [PATCH 4/6] Write SSR documentation --- README.md | 4 ++-- packages/react-scripts/template/README.md | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ec24d939774..741536a0584 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,7 @@ The [User Guide](https://github.com/facebookincubator/create-react-app/blob/mast - [Making a Progressive Web App](https://github.com/facebookincubator/create-react-app/blob/master/packages/react-scripts/template/README.md#making-a-progressive-web-app) - [Deployment](https://github.com/facebookincubator/create-react-app/blob/master/packages/react-scripts/template/README.md#deployment) - [Advanced Configuration](https://github.com/facebookincubator/create-react-app/blob/master/packages/react-scripts/template/README.md#advanced-configuration) +- [Server-side Rendering](https://github.com/facebookincubator/create-react-app/blob/master/packages/react-scripts/template/README.md#server-side-rendering) - [Troubleshooting](https://github.com/facebookincubator/create-react-app/blob/master/packages/react-scripts/template/README.md#troubleshooting) A copy of the user guide will be created as `README.md` in your project folder. @@ -165,7 +166,7 @@ Please refer to the [User Guide](https://github.com/facebookincubator/create-rea * Autoprefixed CSS, so you don’t need `-webkit` or other prefixes. * A `build` script to bundle JS, CSS, and images for production, with sourcemaps. -**The feature set is intentionally limited**. It doesn’t support advanced features such as server rendering or CSS modules. The tool is also **non-configurable** because it is hard to provide a cohesive experience and easy updates across a set of tools when the user can tweak anything. +**The feature set is intentionally limited**. It doesn’t support advanced features such as CSS modules. The tool is also **non-configurable** because it is hard to provide a cohesive experience and easy updates across a set of tools when the user can tweak anything. **You don’t have to use this.** Historically it has been easy to [gradually adopt](https://www.youtube.com/watch?v=BF58ZJ1ZQxY) React. However many people create new single-page React apps from scratch every day. We’ve heard [loud](https://medium.com/@ericclemmons/javascript-fatigue-48d4011b6fc4) and [clear](https://twitter.com/thomasfuchs/status/708675139253174273) that this process can be error-prone and tedious, especially if this is your first JavaScript build stack. This project is an attempt to figure out a good way to start developing React apps. @@ -183,7 +184,6 @@ You don’t have to ever use `eject`. The curated feature set is suitable for sm Some features are currently **not supported**: -* Server rendering. * Some experimental syntax extensions (e.g. decorators). * CSS Modules. * Importing LESS or Sass directly ([but you still can use them](https://github.com/facebookincubator/create-react-app/blob/master/packages/react-scripts/template/README.md#adding-a-css-preprocessor-sass-less-etc)). diff --git a/packages/react-scripts/template/README.md b/packages/react-scripts/template/README.md index 147c9280e24..6b63afe2c6c 100644 --- a/packages/react-scripts/template/README.md +++ b/packages/react-scripts/template/README.md @@ -76,6 +76,7 @@ You can find the most recent version of this guide [here](https://github.com/fac - [S3 and CloudFront](#s3-and-cloudfront) - [Surge](#surge) - [Advanced Configuration](#advanced-configuration) +- [Server-side Rendering](#server-side-rendering) - [Troubleshooting](#troubleshooting) - [`npm start` doesn’t detect changes](#npm-start-doesnt-detect-changes) - [`npm test` hangs on macOS Sierra](#npm-test-hangs-on-macos-sierra) @@ -1562,6 +1563,28 @@ HTTPS | :white_check_mark: | :x: | When set to `true`, Create React App will run PUBLIC_URL | :x: | :white_check_mark: | Create React App assumes your application is hosted at the serving web server's root or a subpath as specified in [`package.json` (`homepage`)](#building-for-relative-paths). Normally, Create React App ignores the hostname. You may use this variable to force assets to be referenced verbatim to the url you provide (hostname included). This may be particularly useful when using a CDN to host your application. CI | :large_orange_diamond: | :white_check_mark: | When set to `true`, Create React App treats warnings as failures in the build. It also makes the test runner non-watching. Most CIs set this flag by default. +## Server-side Rendering + +Create React App has limited support for server-side rendering. The production build is generated as a [UMD module](http://davidbcalhoun.com/2014/what-is-amd-commonjs-and-umd/), which means it can be loaded as a CommonsJS module, an AMD module, or as a global variable. The AMD module name and global variable name is derived from the project name in `package.json`, by turning it into a camel-cased version. For example, 'my-app' becomes 'myApp', and '@org/our-app' becomes 'orgOurApp'. This module can then be used to render static markup on the server. + +Since the production build generates files with a hash in the name, before you can load the bundle you first need to look up the name in `asset-manifest.json`. For example, in a NodeJS app: + +```js +const manifest = require('./build/asset-manifest.json'); +const bundleName = manifest['main.js']; +``` + +When you generate a project with `create-react-app`, `src/index.js` includes an example rendering function as the default export. In a NodeJS app, you might load the bundle and call the render function as following. You then would need to build the final, complete HTML page and send it to the client. + +```js +const renderApp = require(`./build/${bundleName}`).default; +const bodyHtml = renderApp(); +``` + +You can change the render function however you like, e.g. to add Redux or react-router, and pass any parameters to the render function that you need. Please keep in mind that server-side rendering is not a primary goal of Create React App, and only the out-of-the-box configuration is supported. In particular, if you are using code-splitting then you will have to eject since the Webpack target is "web", which won't work on the server chunks are loaded with JSONP. You'll need to generate a [second Webpack config](https://webpack.js.org/concepts/targets/#multiple-targets) with e.g. a "node" target. + +If you're not interested in server-side rendering, feel free to delete the render function from `src/index.js`. + ## Troubleshooting ### `npm start` doesn’t detect changes From 9a881422263f69d4f3fa48613d2fdad12aa29b3d Mon Sep 17 00:00:00 2001 From: Rory Hunter Date: Wed, 22 Mar 2017 16:31:30 +0000 Subject: [PATCH 5/6] Node v4 e2e fix --- tasks/e2e-server-render.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tasks/e2e-server-render.sh b/tasks/e2e-server-render.sh index 6d14d9b1175..19dc6ce3c9f 100755 --- a/tasks/e2e-server-render.sh +++ b/tasks/e2e-server-render.sh @@ -148,8 +148,13 @@ create_react_app --scripts-version="$scripts_path" test-app # ****************************************************************************** verify_bundle() { + local oldpwd="$PWD" bundle_name=$(echo $PWD/build/static/js/main.*.js) + + cd "$root_path" node "$root_path/tasks/test-bundle.js" "$bundle_name" + + cd "$oldpwd" } # Enter the app directory From a6b6bdac92665ae4d2e493a8e22c147ce299f58d Mon Sep 17 00:00:00 2001 From: Rory Hunter Date: Thu, 23 Mar 2017 16:05:49 +0000 Subject: [PATCH 6/6] WIP - parse src/index.js and look for exported functions Start implementing intelligent server bundling by parsing the app's entrypoint and looking for an exported function. --- packages/react-scripts/scripts/build.js | 38 +++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/packages/react-scripts/scripts/build.js b/packages/react-scripts/scripts/build.js index fe4ec959d4d..ec5fb164ff8 100644 --- a/packages/react-scripts/scripts/build.js +++ b/packages/react-scripts/scripts/build.js @@ -27,6 +27,7 @@ process.on('unhandledRejection', err => { require('dotenv').config({ silent: true }); const chalk = require('chalk'); +const espree = require('espree'); const fs = require('fs-extra'); const path = require('path'); const url = require('url'); @@ -69,12 +70,49 @@ function printErrors(summary, errors) { }); } +function bundleExportsFunction() { + const appIndexJs = fs.readFileSync(paths.appIndexJs, 'utf8'); + + const parserConfig = { + loc: true, + ecmaVersion: 2017, + sourceType: 'module', + ecmaFeatures: { + jsx: true, + impliedStrict: true, + experimentalObjectRestSpread: true, + }, + }; + + let ast; + try { + ast = espree.parse(appIndexJs, parserConfig); + } catch (err) { + // TODO: It would be super-awesome to use the same formatting as + // Webpack. Need to track down where that comes from. + throw `Failed to parse ${paths.appIndexJs}: ${err.message} at ${err.lineNumber}:${err.column}`; + } + + const exportNodes = ast.body.filter( + node => + node.type === 'ExportDefaultDeclaration' || + (node.type === 'ExportNamedDeclaration' && + node.declaration.id.name === 'render') + ); + + return exportNodes.length > 0; +} + // Create the production build and print the deployment instructions. function build(previousFileSizes) { console.log('Creating an optimized production build...'); let compiler; try { + if (bundleExportsFunction()) { + console.log('Building server bundle...'); + } + compiler = webpack(config); } catch (err) { printErrors('Failed to compile.', [err]);