Skip to content

Commit 12cae01

Browse files
committed
Run Closure on non-minified prod builds, too
In facebook#26446 we started publishing non-minified versions of our production build artifacts, along with source maps, for easier debugging of React when running in production mode. The way it's currently set up is that these builds are generated *before* Closure compiler has run. Which means it's missing many of the optimizations that are in the final build, like dead code elimination. This PR changes the build process to run Closure on the non-minified production builds, too, by moving the sourcemap generation to later in the pipeline. The non-minified builds will still preserve the original symbol names, and we'll use Prettier to add back whitespace. This is the exact same approach we've been using for years to generate production builds for Meta. The idea is that the only difference between the minified and non- minified builds is whitespace and symbol mangling. The semantic structure of the program should be identical. To implement this, I disabled symbol mangling when running Closure compiler. Then, in a later step, the symbols are mangled by Terser. This is when the source maps are generated.
1 parent c8a0350 commit 12cae01

File tree

4 files changed

+183
-158
lines changed

4 files changed

+183
-158
lines changed

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@
9494
"shelljs": "^0.8.5",
9595
"signedsource": "^2.0.0",
9696
"targz": "^1.0.1",
97+
"terser": "^5.30.3",
9798
"through2": "^3.0.1",
9899
"tmp": "^0.1.0",
99100
"typescript": "^3.7.5",

scripts/rollup/build.js

+127-124
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ const Packaging = require('./packaging');
2424
const {asyncRimRaf} = require('./utils');
2525
const codeFrame = require('@babel/code-frame');
2626
const Wrappers = require('./wrappers');
27+
const minify = require('terser').minify;
2728

2829
const RELEASE_CHANNEL = process.env.RELEASE_CHANNEL;
2930

@@ -487,137 +488,58 @@ function getPlugins(
487488
);
488489
},
489490
},
490-
// License and haste headers for artifacts with sourcemaps
491-
// For artifacts with sourcemaps we apply these headers
492-
// before passing sources to the Closure compiler, which will be building sourcemaps
493-
needsSourcemaps && {
494-
name: 'license-and-signature-header-for-artifacts-with-sourcemaps',
495-
renderChunk(source) {
496-
return Wrappers.wrapWithLicenseHeader(
497-
source,
498-
bundleType,
499-
globalName,
500-
filename,
501-
moduleType
502-
);
503-
},
504-
},
505-
// Apply dead code elimination and/or minification.
506-
// closure doesn't yet support leaving ESM imports intact
491+
// For production builds, compile with Closure. We do this even for the
492+
// "non-minified" production builds because Closure is much better at
493+
// minification than what most applications use. During this step, we do
494+
// preserve the original symbol names, though, so the resulting code is
495+
// relatively readable.
496+
//
497+
// For the minified builds, the names will be mangled later.
498+
//
499+
// We don't bother with sourcemaps at this step. The sourcemaps we publish
500+
// are only for whitespace and symbol renaming; they don't map back to
501+
// before Closure was applied.
507502
needsMinifiedByClosure &&
508-
closure(
509-
{
510-
compilation_level: 'SIMPLE',
511-
language_in: 'ECMASCRIPT_2020',
512-
language_out:
513-
bundleType === NODE_ES2015
514-
? 'ECMASCRIPT_2020'
515-
: bundleType === BROWSER_SCRIPT
516-
? 'ECMASCRIPT5'
517-
: 'ECMASCRIPT5_STRICT',
518-
emit_use_strict:
519-
bundleType !== BROWSER_SCRIPT &&
520-
bundleType !== ESM_PROD &&
521-
bundleType !== ESM_DEV,
522-
env: 'CUSTOM',
523-
warning_level: 'QUIET',
524-
source_map_include_content: true,
525-
use_types_for_optimization: false,
526-
process_common_js_modules: false,
527-
rewrite_polyfills: false,
528-
inject_libraries: false,
529-
allow_dynamic_import: true,
530-
531-
// Don't let it create global variables in the browser.
532-
// https://github.com/facebook/react/issues/10909
533-
assume_function_wrapper: !isUMDBundle,
534-
renaming: !shouldStayReadable,
535-
},
536-
{needsSourcemaps}
537-
),
538-
// Add the whitespace back if necessary.
539-
shouldStayReadable &&
503+
closure({
504+
compilation_level: 'SIMPLE',
505+
language_in: 'ECMASCRIPT_2020',
506+
language_out:
507+
bundleType === NODE_ES2015
508+
? 'ECMASCRIPT_2020'
509+
: bundleType === BROWSER_SCRIPT
510+
? 'ECMASCRIPT5'
511+
: 'ECMASCRIPT5_STRICT',
512+
emit_use_strict:
513+
bundleType !== BROWSER_SCRIPT &&
514+
bundleType !== ESM_PROD &&
515+
bundleType !== ESM_DEV,
516+
env: 'CUSTOM',
517+
warning_level: 'QUIET',
518+
source_map_include_content: true,
519+
use_types_for_optimization: false,
520+
process_common_js_modules: false,
521+
rewrite_polyfills: false,
522+
inject_libraries: false,
523+
allow_dynamic_import: true,
524+
525+
// Don't let it create global variables in the browser.
526+
// https://github.com/facebook/react/issues/10909
527+
assume_function_wrapper: !isUMDBundle,
528+
529+
// Don't rename symbols (variable names, functions, etc). This will
530+
// be handled in a later step.
531+
renaming: false,
532+
}),
533+
needsMinifiedByClosure &&
534+
// Add the whitespace back
540535
prettier({
541536
parser: 'flow',
542537
singleQuote: false,
543538
trailingComma: 'none',
544539
bracketSpacing: true,
545540
}),
546-
needsSourcemaps && {
547-
name: 'generate-prod-bundle-sourcemaps',
548-
async renderChunk(minifiedCodeWithChangedHeader, chunk, options, meta) {
549-
// We want to generate a sourcemap that shows the production bundle source
550-
// as it existed before Closure Compiler minified that chunk, rather than
551-
// showing the "original" individual source files. This better shows
552-
// what is actually running in the app.
553-
554-
// Use a path like `node_modules/react/cjs/react.production.min.js.map` for the sourcemap file
555-
const finalSourcemapPath = options.file.replace('.js', '.js.map');
556-
const finalSourcemapFilename = path.basename(finalSourcemapPath);
557-
const outputFolder = path.dirname(options.file);
558-
559-
// Read the sourcemap that Closure wrote to disk
560-
const sourcemapAfterClosure = JSON.parse(
561-
fs.readFileSync(finalSourcemapPath, 'utf8')
562-
);
563-
564-
// Represent the "original" bundle as a file with no `.min` in the name
565-
const filenameWithoutMin = filename.replace('.min', '');
566-
// There's _one_ artifact where the incoming filename actually contains
567-
// a folder name: "use-sync-external-store-shim/with-selector.production.js".
568-
// The output path already has the right structure, but we need to strip this
569-
// down to _just_ the JS filename.
570-
const preMinifiedFilename = path.basename(filenameWithoutMin);
571-
572-
// CC generated a file list that only contains the tempfile name.
573-
// Replace that with a more meaningful "source" name for this bundle
574-
// that represents "the bundled source before minification".
575-
sourcemapAfterClosure.sources = [preMinifiedFilename];
576-
sourcemapAfterClosure.file = filename;
577-
578-
// All our code is considered "third-party" and should be ignored by default.
579-
sourcemapAfterClosure.ignoreList = [0];
580-
581-
// We'll write the pre-minified source to disk as a separate file.
582-
// Because it sits on disk, there's no need to have it in the `sourcesContent` array.
583-
// That also makes the file easier to read, and available for use by scripts.
584-
// This should be the only file in the array.
585-
const [preMinifiedBundleSource] =
586-
sourcemapAfterClosure.sourcesContent;
587-
588-
// Remove this entirely - we're going to write the file to disk instead.
589-
delete sourcemapAfterClosure.sourcesContent;
590-
591-
const preMinifiedBundlePath = path.join(
592-
outputFolder,
593-
preMinifiedFilename
594-
);
595-
596-
// Write the original source to disk as a separate file
597-
fs.writeFileSync(preMinifiedBundlePath, preMinifiedBundleSource);
598-
599-
// Overwrite the Closure-generated file with the final combined sourcemap
600-
fs.writeFileSync(
601-
finalSourcemapPath,
602-
JSON.stringify(sourcemapAfterClosure)
603-
);
604-
605-
// Add the sourcemap URL to the actual bundle, so that tools pick it up
606-
const sourceWithMappingUrl =
607-
minifiedCodeWithChangedHeader +
608-
`\n//# sourceMappingURL=${finalSourcemapFilename}`;
609-
610-
return {
611-
code: sourceWithMappingUrl,
612-
map: null,
613-
};
614-
},
615-
},
616-
// License and haste headers for artifacts without sourcemaps
617-
// Primarily used for FB-artifacts, which should preserve specific format of the header
618-
// Which potentially can be changed by Closure minification
619-
!needsSourcemaps && {
620-
name: 'license-and-signature-header-for-artifacts-without-sourcemaps',
541+
{
542+
name: 'license-and-signature-header',
621543
renderChunk(source) {
622544
return Wrappers.wrapWithLicenseHeader(
623545
source,
@@ -628,6 +550,87 @@ function getPlugins(
628550
);
629551
},
630552
},
553+
isProduction &&
554+
!shouldStayReadable && {
555+
name: 'mangle-symbol-names',
556+
async renderChunk(code, chunk, options, meta) {
557+
// Minify the code by mangling symbol names. We already ran Closure
558+
// on this code, so stuff like dead code elimination and inlining
559+
// has already happened. This step is purely to rename the symbols,
560+
// which we asked Closure to preserve.
561+
//
562+
// The only reason this is a separate step from Closure is so we
563+
// can publish non-mangled versions of the code for easier debugging
564+
// in production. We also publish sourcemaps that map back to the
565+
// non-mangled code (*not* the pre-Closure code).
566+
567+
const outputFolder = path.dirname(options.file);
568+
569+
// Represent the "original" bundle as a file with no `.min` in the name
570+
const filenameWithoutMin = filename.replace('.min', '');
571+
// There's _one_ artifact where the incoming filename actually contains
572+
// a folder name: "use-sync-external-store-shim/with-selector.production.js".
573+
// The output path already has the right structure, but we need to strip this
574+
// down to _just_ the JS filename.
575+
const preMinifiedFilename = path.basename(filenameWithoutMin);
576+
const preMinifiedBundlePath = path.join(
577+
outputFolder,
578+
preMinifiedFilename
579+
);
580+
581+
// Use a path like `node_modules/react/cjs/react.production.min.js.map` for the sourcemap file
582+
const finalSourcemapPath = options.file.replace('.js', '.js.map');
583+
const finalSourcemapFilename = path.basename(finalSourcemapPath);
584+
585+
const terserOptions = {
586+
// Don't bother compressing. Closure already did that.
587+
compress: false,
588+
// Mangle the symbol names.
589+
mangle: true,
590+
toplevel: true,
591+
};
592+
if (needsSourcemaps) {
593+
terserOptions.sourceMap = {
594+
// Used to set the `file` field in the sourcemap
595+
filename: filename,
596+
// Used to set `# sourceMappingURL=` in the compiled code
597+
url: finalSourcemapFilename,
598+
};
599+
}
600+
601+
const minifiedResult = await minify(
602+
{[preMinifiedFilename]: code},
603+
terserOptions
604+
);
605+
606+
// Create the directory if it doesn't already exist
607+
fs.mkdirSync(outputFolder, {recursive: true});
608+
609+
if (needsSourcemaps) {
610+
const sourcemapJSON = JSON.parse(minifiedResult.map);
611+
612+
// All our code is considered "third-party" and should be ignored
613+
// by default
614+
sourcemapJSON.ignoreList = [0];
615+
616+
// Write the sourcemap to disk
617+
fs.writeFileSync(
618+
finalSourcemapPath,
619+
JSON.stringify(sourcemapJSON)
620+
);
621+
}
622+
623+
// Write the original source to disk as a separate file
624+
fs.writeFileSync(preMinifiedBundlePath, code);
625+
626+
return {
627+
code: minifiedResult.code,
628+
// TODO: Maybe we should use Rollup's sourcemap feature instead
629+
// of writing it to disk manually?
630+
map: null,
631+
};
632+
},
633+
},
631634
// Record bundle size.
632635
sizes({
633636
getSize: (size, gzip) => {

scripts/rollup/plugins/closure-plugin.js

+1-5
Original file line numberDiff line numberDiff line change
@@ -19,20 +19,16 @@ function compile(flags) {
1919
});
2020
}
2121

22-
module.exports = function closure(flags = {}, {needsSourcemaps}) {
22+
module.exports = function closure(flags = {}) {
2323
return {
2424
name: 'scripts/rollup/plugins/closure-plugin',
2525
async renderChunk(code, chunk, options) {
2626
const inputFile = tmp.fileSync();
2727

28-
// Use a path like `node_modules/react/cjs/react.production.min.js.map` for the sourcemap file
29-
const sourcemapPath = options.file.replace('.js', '.js.map');
30-
3128
// Tell Closure what JS source file to read, and optionally what sourcemap file to write
3229
const finalFlags = {
3330
...flags,
3431
js: inputFile.name,
35-
...(needsSourcemaps && {create_source_map: sourcemapPath}),
3632
};
3733

3834
await writeFileAsync(inputFile.name, code, 'utf8');

0 commit comments

Comments
 (0)