From fc846c23f81ab5ef461b3ccee2c8c34662abae4f Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Mon, 20 Mar 2023 18:10:00 -0400 Subject: [PATCH] Generate sourcemaps for production build artifacts --- scripts/rollup/build.js | 168 +++++++++++++++++++---- scripts/rollup/plugins/closure-plugin.js | 22 ++- 2 files changed, 154 insertions(+), 36 deletions(-) diff --git a/scripts/rollup/build.js b/scripts/rollup/build.js index f49031b3d8018..4e5c23951956b 100644 --- a/scripts/rollup/build.js +++ b/scripts/rollup/build.js @@ -10,7 +10,10 @@ const replace = require('@rollup/plugin-replace'); const stripBanner = require('rollup-plugin-strip-banner'); const chalk = require('chalk'); const resolve = require('@rollup/plugin-node-resolve').nodeResolve; +const MagicString = require('magic-string'); +const remapping = require('@ampproject/remapping'); const fs = require('fs'); +const path = require('path'); const argv = require('minimist')(process.argv.slice(2)); const Modules = require('./modules'); const Bundles = require('./bundles'); @@ -148,6 +151,7 @@ function getBabelConfig( presets: [], plugins: [...babelPlugins], babelHelpers: 'bundled', + sourcemap: false, }; if (isDevelopment) { options.plugins.push( @@ -382,6 +386,29 @@ function getPlugins( const {isUMDBundle, shouldStayReadable} = getBundleTypeFlags(bundleType); + const needsMinifiedByClosure = isProduction && bundleType !== ESM_PROD; + + // Any other packages that should specifically _not_ have sourcemaps + const sourcemapPackageExcludes = [ + // Having `//#sourceMappingUrl` in this file breaks `ReactDevToolsHooksIntegration-test.js`, + // and this is an internal + 'react-debug-tools', + ]; + + // Only generate sourcemaps for true "production" build artifacts + // that will be used by bundlers, such as `react-dom.production.min.js`. + // UMD and "profiling" builds are rarely used and not worth having sourcemaps. + const needsSourcemaps = + needsMinifiedByClosure && + !isProfiling && + !isUMDBundle && + !sourcemapPackageExcludes.includes(entry) && + !shouldStayReadable; + + // For builds with sourcemaps, capture the minified code Closure generated + // so it can be used to help construct the final sourcemap contents. + let chunkCodeAfterClosureCompiler = undefined; + return [ // Keep dynamic imports as externals dynamicImports(), @@ -391,7 +418,7 @@ function getPlugins( const transformed = flowRemoveTypes(code); return { code: transformed.toString(), - map: transformed.generateMap(), + map: null, }; }, }, @@ -420,6 +447,7 @@ function getPlugins( ), // Remove 'use strict' from individual source files. { + name: "remove 'use strict'", transform(source) { return source.replace(/['"]use strict["']/g, ''); }, @@ -441,35 +469,44 @@ function getPlugins( isUMDBundle && entry === 'react-art' && commonjs(), // Apply dead code elimination and/or minification. // closure doesn't yet support leaving ESM imports intact - isProduction && - bundleType !== ESM_PROD && - closure({ - compilation_level: 'SIMPLE', - language_in: 'ECMASCRIPT_2020', - language_out: - bundleType === NODE_ES2015 - ? 'ECMASCRIPT_2020' - : bundleType === BROWSER_SCRIPT - ? 'ECMASCRIPT5' - : 'ECMASCRIPT5_STRICT', - emit_use_strict: - bundleType !== BROWSER_SCRIPT && - bundleType !== ESM_PROD && - bundleType !== ESM_DEV, - env: 'CUSTOM', - warning_level: 'QUIET', - apply_input_source_maps: false, - use_types_for_optimization: false, - process_common_js_modules: false, - rewrite_polyfills: false, - inject_libraries: false, - allow_dynamic_import: true, - - // Don't let it create global variables in the browser. - // https://github.com/facebook/react/issues/10909 - assume_function_wrapper: !isUMDBundle, - renaming: !shouldStayReadable, - }), + needsMinifiedByClosure && + closure( + { + compilation_level: 'SIMPLE', + language_in: 'ECMASCRIPT_2020', + language_out: + bundleType === NODE_ES2015 + ? 'ECMASCRIPT_2020' + : bundleType === BROWSER_SCRIPT + ? 'ECMASCRIPT5' + : 'ECMASCRIPT5_STRICT', + emit_use_strict: + bundleType !== BROWSER_SCRIPT && + bundleType !== ESM_PROD && + bundleType !== ESM_DEV, + env: 'CUSTOM', + warning_level: 'QUIET', + source_map_include_content: true, + use_types_for_optimization: false, + process_common_js_modules: false, + rewrite_polyfills: false, + inject_libraries: false, + allow_dynamic_import: true, + + // Don't let it create global variables in the browser. + // https://github.com/facebook/react/issues/10909 + assume_function_wrapper: !isUMDBundle, + renaming: !shouldStayReadable, + }, + {needsSourcemaps} + ), + needsSourcemaps && { + name: 'chunk-after-closure', + renderChunk(code, config, options) { + // Side effect - grab the code as Closure mangled it + chunkCodeAfterClosureCompiler = code; + }, + }, // Add the whitespace back if necessary. shouldStayReadable && prettier({ @@ -480,6 +517,7 @@ function getPlugins( }), // License and haste headers, top-level `if` blocks. { + name: 'license-and-headers', renderChunk(source) { return Wrappers.wrapBundle( source, @@ -491,6 +529,76 @@ function getPlugins( ); }, }, + needsSourcemaps && { + name: 'generate-prod-bundle-sourcemaps', + async renderChunk(codeAfterLicense, chunk, options, meta) { + // We want to generate a sourcemap that shows the production bundle source + // as it existed before Closure Compiler minified that chunk. + // We also need to apply any license/wrapper text adjustments to that + // sourcemap, so that the mapped locations line up correctly. + + // We can split the final chunk code to figure out what got added around + // the code from the Closure step. + const [licensePrefix, licensePostfix] = codeAfterLicense.split( + chunkCodeAfterClosureCompiler + ); + + const transformedSource = new MagicString( + chunkCodeAfterClosureCompiler + ); + + // Apply changes so we can generate a sourcemap for this step + if (licensePrefix) { + transformedSource.prepend(licensePrefix); + } + + if (licensePostfix) { + transformedSource.append(licensePostfix); + } + + // Use a path like `node_modules/react/cjs/react.production.min.js.map` for the sourcemap file + const finalSourcemapPath = options.file.replace('.js', '.js.map'); + const finalSourcemapFilename = path.basename(finalSourcemapPath); + + // Read the sourcemap that Closure wrote to disk + const sourcemapAfterClosure = JSON.parse( + fs.readFileSync(finalSourcemapPath, 'utf8') + ); + + // CC generated a file list that only contains the tempfile name. + // Replace that with a more meaningful "source" name for this bundle. + sourcemapAfterClosure.sources = [filename]; + sourcemapAfterClosure.file = filename; + + // Create an additional sourcemap adjusted for the license header contents + const mapAfterLicense = transformedSource.generateMap({ + file: filename, + includeContent: true, + hires: true, + }); + + // Merge the Closure sourcemap and the with-license sourcemap together + const finalCombinedSourcemap = remapping( + [mapAfterLicense, sourcemapAfterClosure], + () => null + ); + + // Overwrite the Closure-generated file with the final combined sourcemap + fs.writeFileSync( + finalSourcemapPath, + JSON.stringify(finalCombinedSourcemap) + ); + + // Add the sourcemap URL to the actual bundle, so that tools pick it up + const sourceWithMappingUrl = + codeAfterLicense + `\n//# sourceMappingURL=${finalSourcemapFilename}`; + + return { + code: sourceWithMappingUrl, + map: null, + }; + }, + }, // Record bundle size. sizes({ getSize: (size, gzip) => { diff --git a/scripts/rollup/plugins/closure-plugin.js b/scripts/rollup/plugins/closure-plugin.js index 62eba8e687800..5bb2ffb8b30be 100644 --- a/scripts/rollup/plugins/closure-plugin.js +++ b/scripts/rollup/plugins/closure-plugin.js @@ -19,15 +19,25 @@ function compile(flags) { }); } -module.exports = function closure(flags = {}) { +module.exports = function closure(flags = {}, {needsSourcemaps}) { return { name: 'scripts/rollup/plugins/closure-plugin', - async renderChunk(code) { + async renderChunk(code, chunk, options) { const inputFile = tmp.fileSync(); - const tempPath = inputFile.name; - flags = Object.assign({}, flags, {js: tempPath}); - await writeFileAsync(tempPath, code, 'utf8'); - const compiledCode = await compile(flags); + + // Use a path like `node_modules/react/cjs/react.production.min.js.map` for the sourcemap file + const sourcemapPath = options.file.replace('.js', '.js.map'); + + // Tell Closure what JS source file to read, and optionally what sourcemap file to write + const finalFlags = { + ...flags, + js: inputFile.name, + ...(needsSourcemaps && {create_source_map: sourcemapPath}), + }; + + await writeFileAsync(inputFile.name, code, 'utf8'); + const compiledCode = await compile(finalFlags); + inputFile.removeCallback(); return {code: compiledCode}; },