Skip to content

WIP: differential serving allow modern ES2015 and legacy ES5 build #6590

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 15 commits into from
Closed
21 changes: 19 additions & 2 deletions packages/babel-preset-react-app/create.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ const validateBoolOption = (name, value, defaultValue) => {
return value;
};

const legacyTargets = { ie: 9 };
const modernTargets = { esmodules: true };

module.exports = function(api, opts, env) {
if (!opts) {
opts = {};
Expand Down Expand Up @@ -47,6 +50,13 @@ module.exports = function(api, opts, env) {
true
);

var isModern = validateBoolOption('modern', opts.modern, false);
var shouldBuildModern = validateBoolOption(
'shouldBuildModernAndLegacy',
opts.shouldBuildModernAndLegacy,
false
);

var absoluteRuntimePath = undefined;
if (useAbsoluteRuntime) {
absoluteRuntimePath = path.dirname(
Expand Down Expand Up @@ -79,8 +89,15 @@ module.exports = function(api, opts, env) {
// Latest stable ECMAScript features
require('@babel/preset-env').default,
{
// Allow importing @babel/polyfill in entrypoint and use browserlist to select polyfills
useBuiltIns: 'entry',
// When building normal we take the build we're in being modern or legacy
// If not we respect the users browserslist.
targets: shouldBuildModern
? isModern
? modernTargets
: legacyTargets
: undefined,
ignoreBrowserslistConfig: shouldBuildModern,
useBuiltIns: isModern ? 'usage' : 'entry',
// Do not transform modules to CJS
modules: false,
// Exclude transforms that make all code slower
Expand Down
22 changes: 16 additions & 6 deletions packages/babel-preset-react-app/dependencies.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ const validateBoolOption = (name, value, defaultValue) => {
return value;
};

const legacyTargets = { ie: 9 };
const modernTargets = { esmodules: true };

module.exports = function(api, opts) {
if (!opts) {
opts = {};
Expand Down Expand Up @@ -60,6 +63,13 @@ module.exports = function(api, opts) {
);
}

var isModern = validateBoolOption('modern', opts.modern, false);
var shouldBuildModern = validateBoolOption(
'shouldBuildModernAndLegacy',
opts.shouldBuildModernAndLegacy,
false
);

return {
// Babel assumes ES Modules, which isn't safe until CommonJS
// dies. This changes the behavior to assume CommonJS unless
Expand All @@ -86,15 +96,15 @@ module.exports = function(api, opts) {
{
// We want Create React App to be IE 9 compatible until React itself
// no longer works with IE 9
targets: {
ie: 9,
},
targets: shouldBuildModern
? isModern
? modernTargets
: legacyTargets
: undefined,
// Users cannot override this behavior because this Babel
// configuration is highly tuned for ES5 support
ignoreBrowserslistConfig: true,
// If users import all core-js they're probably not concerned with
// bundle size. We shouldn't rely on magic to try and shrink it.
useBuiltIns: false,
useBuiltIns: 'entry',
// Do not transform modules to CJS
modules: false,
// Exclude transforms that make all code slower
Expand Down
71 changes: 71 additions & 0 deletions packages/react-dev-utils/HtmlWebpackEsModulesPlugin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
'use strict';

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const fs = require('fs-extra');

const ID = 'html-webpack-esmodules-plugin';

const safariFix = `(function(){var d=document;var c=d.createElement('script');if(!('noModule' in c)&&'onbeforeload' in c){var s=!1;d.addEventListener('beforeload',function(e){if(e.target===c){s=!0}else if(!e.target.hasAttribute('nomodule')||!s){return}e.preventDefault()},!0);c.type='module';c.src='.';d.head.appendChild(c);c.remove()}}())`;

class HtmlWebpackEsmodulesPlugin {
constructor() {}

apply(compiler) {
compiler.hooks.compilation.tap(ID, compilation => {
HtmlWebpackPlugin.getHooks(compilation).alterAssetTagGroups.tapAsync(
ID,
({ plugin, bodyTags: body }, cb) => {
const targetDir = compiler.options.output.path;
// get stats, write to disk
const htmlName = path.basename(plugin.options.filename);
// Watch out for output files in sub directories
const htmlPath = path.dirname(plugin.options.filename);
const tempFilename = path.join(
targetDir,
htmlPath,
`assets-${htmlName}.json`
);

if (!fs.existsSync(tempFilename)) {
fs.mkdirpSync(path.dirname(tempFilename));
const newBody = body.filter(
a => a.tagName === 'script' && a.attributes
);
newBody.forEach(a => (a.attributes.nomodule = ''));
fs.writeFileSync(tempFilename, JSON.stringify(newBody));
return cb();
}

const legacyAssets = JSON.parse(
fs.readFileSync(tempFilename, 'utf-8')
);
// TODO: to discuss, an improvement would be to
// Inject these into the head tag together with the
// Safari script.
body.forEach(tag => {
if (tag.tagName === 'script' && tag.attributes) {
tag.attributes.type = 'module';
}
});

body.push({
tagName: 'script',
closeTag: true,
innerHTML: safariFix,
});

body.push(...legacyAssets);
fs.removeSync(tempFilename);
cb();
}
);

HtmlWebpackPlugin.getHooks(compilation).beforeEmit.tap(ID, data => {
data.html = data.html.replace(/\snomodule="">/g, ' nomodule>');
});
});
}
}

module.exports = HtmlWebpackEsmodulesPlugin;
32 changes: 32 additions & 0 deletions packages/react-dev-utils/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,38 @@ module.exports = {
};
```

#### `new HtmlWebpackEsModulesPlugin()`

This Webpack plugin allows you to build two builds, the first one a legacy build and the
second being a modern one.
This plugin is used to use the first build as a buffer and writes it to a temporairy file,
then when the second build completes it merges the two and adds a shim to support safari.

This way of working relies on [module-nomodule differential serving](https://jakearchibald.com/2017/es-modules-in-browsers/).

```js
var path = require('path');
var HtmlWebpackPlugin = require('html-webpack-plugin');
var HtmlWebpackEsModulesPlugin = require('react-dev-utils/HtmlWebpackEsModulesPlugin');

module.exports = {
output: {
// ...
},
// ...
plugins: [
// Generates an `index.html` file with the <script> injected.
new HtmlWebpackPlugin({
inject: true,
template: path.resolve('public/index.html'),
}),
new HtmlWebpackEsModulesPlugin(),
// ...
],
// ...
};
```

#### `new InlineChunkHtmlPlugin(htmlWebpackPlugin: HtmlWebpackPlugin, tests: Regex[])`

This Webpack plugin inlines script chunks into `index.html`.<br>
Expand Down
1 change: 1 addition & 0 deletions packages/react-dev-utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"getCSSModuleLocalIdent.js",
"getProcessForPort.js",
"globby.js",
"HtmlWebpackEsModulesPlugin.js",
"ignoredFiles.js",
"immer.js",
"InlineChunkHtmlPlugin.js",
Expand Down
32 changes: 25 additions & 7 deletions packages/react-scripts/config/webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const getClientEnvironment = require('./env');
const ModuleNotFoundPlugin = require('react-dev-utils/ModuleNotFoundPlugin');
const ForkTsCheckerWebpackPlugin = require('react-dev-utils/ForkTsCheckerWebpackPlugin');
const typescriptFormatter = require('react-dev-utils/typescriptFormatter');
const HtmlWebpackEsModulesPlugin = require('react-dev-utils/HtmlWebpackEsModulesPlugin');
// @remove-on-eject-begin
const getCacheIdentifier = require('react-dev-utils/getCacheIdentifier');
// @remove-on-eject-end
Expand All @@ -52,7 +53,10 @@ const sassModuleRegex = /\.module\.(scss|sass)$/;

// This is the production and development configuration.
// It is focused on developer experience, fast rebuilds, and a minimal bundle.
module.exports = function(webpackEnv) {
module.exports = function(
webpackEnv,
{ shouldBuildModernAndLegacy, isModernOutput } = {}
) {
const isEnvDevelopment = webpackEnv === 'development';
const isEnvProduction = webpackEnv === 'production';

Expand Down Expand Up @@ -161,11 +165,15 @@ module.exports = function(webpackEnv) {
// There will be one main bundle, and one file per asynchronous chunk.
// In development, it does not produce real files.
filename: isEnvProduction
? 'static/js/[name].[contenthash:8].js'
? `static/js/[name].[contenthash:8]${
isModernOutput ? '.modern' : ''
}.js`
: isEnvDevelopment && 'static/js/bundle.js',
// There are also additional JS chunk files if you use code splitting.
chunkFilename: isEnvProduction
? 'static/js/[name].[contenthash:8].chunk.js'
? `static/js/[name].[contenthash:8]${
isModernOutput ? '.modern' : ''
}.chunk.js`
: isEnvDevelopment && 'static/js/[name].chunk.js',
// We inferred the "public path" (such as / or /my-project) from homepage.
// We use "/" in development.
Expand Down Expand Up @@ -194,7 +202,7 @@ module.exports = function(webpackEnv) {
ecma: 8,
},
compress: {
ecma: 5,
ecma: isModernOutput ? 6 : 5,
warnings: false,
// Disabled because of an issue with Uglify breaking seemingly valid code:
// https://github.com/facebook/create-react-app/issues/2376
Expand All @@ -211,7 +219,7 @@ module.exports = function(webpackEnv) {
safari10: true,
},
output: {
ecma: 5,
ecma: isModernOutput ? 6 : 5,
comments: false,
// Turned on because emoji and regex is not minified properly using default
// https://github.com/facebook/create-react-app/issues/2488
Expand Down Expand Up @@ -353,7 +361,12 @@ module.exports = function(webpackEnv) {
// @remove-on-eject-begin
babelrc: false,
configFile: false,
presets: [require.resolve('babel-preset-react-app')],
presets: [
[
require.resolve('babel-preset-react-app'),
{ modern: isModernOutput, shouldBuildModernAndLegacy },
],
],
// Make sure we have a unique cache identifier, erring on the
// side of caution.
// We remove this when the user ejects because the default
Expand Down Expand Up @@ -404,7 +417,11 @@ module.exports = function(webpackEnv) {
presets: [
[
require.resolve('babel-preset-react-app/dependencies'),
{ helpers: true },
{
helpers: true,
modern: isModernOutput,
shouldBuildModernAndLegacy,
},
],
],
cacheDirectory: true,
Expand Down Expand Up @@ -542,6 +559,7 @@ module.exports = function(webpackEnv) {
: undefined
)
),
shouldBuildModernAndLegacy && new HtmlWebpackEsModulesPlugin(),
// Inlines the webpack runtime script. This script is too small to warrant
// a network request.
isEnvProduction &&
Expand Down
15 changes: 12 additions & 3 deletions packages/react-scripts/scripts/build.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,17 @@ if (!checkRequiredFiles([paths.appHtml, paths.appIndexJs])) {
// Process CLI arguments
const argv = process.argv.slice(2);
const writeStatsJson = argv.indexOf('--stats') !== -1;
const buildModern = argv.indexOf('--modern') !== -1;

// Generate configuration
const config = configFactory('production');
const modernConfig = configFactory('production', {
shouldBuildModernAndLegacy: buildModern,
isModernOutput: true,
});
const leagcyConfig = configFactory('production', {
shouldBuildModernAndLegacy: buildModern,
isModernOutput: false,
});

// We require that you explicitly set browsers and do not fall back to
// browserslist defaults.
Expand Down Expand Up @@ -116,7 +124,7 @@ checkBrowsers(paths.appPath, isInteractive)

const appPackage = require(paths.appPackageJson);
const publicUrl = paths.publicUrl;
const publicPath = config.output.publicPath;
const publicPath = leagcyConfig.output.publicPath;
const buildFolder = path.relative(process.cwd(), paths.appBuild);
printHostingInstructions(
appPackage,
Expand All @@ -142,8 +150,9 @@ checkBrowsers(paths.appPath, isInteractive)
// Create the production build and print the deployment instructions.
function build(previousFileSizes) {
console.log('Creating an optimized production build...');
const configs = [leagcyConfig, buildModern && modernConfig].filter(Boolean);

const compiler = webpack(config);
const compiler = webpack(configs);
return new Promise((resolve, reject) => {
compiler.run((err, stats) => {
let messages;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ const immer = require('react-dev-utils/immer').produce;
const globby = require('react-dev-utils/globby').sync;

function writeJson(fileName, object) {
fs.writeFileSync(fileName, JSON.stringify(object, null, 2).replace(/\n/g, os.EOL) + os.EOL);
fs.writeFileSync(
fileName,
JSON.stringify(object, null, 2).replace(/\n/g, os.EOL) + os.EOL
);
}

function verifyNoTypeScript() {
Expand Down
2 changes: 1 addition & 1 deletion packages/react-scripts/template/src/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,6 @@ const App = () => {
</header>
</div>
);
}
};

export default App;