From 2c8a139a593e0294c3a6953d74b451bd05fdcfca Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Tue, 7 Nov 2023 13:59:27 -0500 Subject: [PATCH] Generate sourcemaps for production build artifacts (#26446) ## Summary This PR updates the Rollup build pipeline to generate sourcemaps for production build artifacts like `react-dom.production.min.js`. It requires the Rollup v3 changes that were just merged in #26442 . Sourcemaps are currently _only_ generated for build artifacts that are _truly_ "production" - no sourcemaps will be generated for development, profiling, UMD, or `shouldStayReadable` artifacts. The generated sourcemaps contain the bundled source contents right before that chunk was minified by Closure, and _not_ the original source files like `react-reconciler/src/*`. This better reflects the actual code that is running as part of the bundle, with all the feature flags and transformations that were applied to the source files to generate that bundle. The sourcemaps _do_ still show comments and original function names, thus improving debuggability for production usage. Fixes #20186 . This allows React users to actually debug a readable version of the React bundle in production scenarios. It also allows other tools like [Replay](https://replay.io) to do a better job inspecting the React source when stepping through. ## How did you test this change? - Generated numerous sourcemaps with various combinations of the React bundle selections - Viewed those sourcemaps in https://evanw.github.io/source-map-visualization/ and confirmed via the visualization that the generated mappings appear to be correct I've attached a set of production files + their sourcemaps here: [react-sourcemap-examples.zip](https://github.com/facebook/react/files/11023466/react-sourcemap-examples.zip) You can drag JS+sourcemap file pairs into https://evanw.github.io/source-map-visualization/ for viewing. Examples: - `react.production.min.js`: ![image](https://user-images.githubusercontent.com/1128784/226478247-e5cbdee0-83fd-4a19-bcf1-09961d3c7da4.png) - `react-dom.production.min.js`: ![image](https://user-images.githubusercontent.com/1128784/226478433-b5ccbf0f-8f68-42fe-9db9-9ecb97770d46.png) - `use-sync-external-store/with-selector.production.min.js`: ![image](https://user-images.githubusercontent.com/1128784/226478565-bc74699d-db14-4c39-9e2d-b775f8755561.png) --- scripts/rollup/build.js | 247 ++++++++++++++++------- scripts/rollup/plugins/closure-plugin.js | 22 +- scripts/rollup/wrappers.js | 9 + 3 files changed, 203 insertions(+), 75 deletions(-) diff --git a/scripts/rollup/build.js b/scripts/rollup/build.js index fe0158a9a477d..0851ea3b04441 100644 --- a/scripts/rollup/build.js +++ b/scripts/rollup/build.js @@ -11,6 +11,7 @@ const stripBanner = require('rollup-plugin-strip-banner'); const chalk = require('chalk'); const resolve = require('@rollup/plugin-node-resolve').nodeResolve; 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 +149,7 @@ function getBabelConfig( presets: [], plugins: [...babelPlugins], babelHelpers: 'bundled', + sourcemap: false, }; if (isDevelopment) { options.plugins.push( @@ -315,6 +317,45 @@ function isProfilingBundleType(bundleType) { } } +function getBundleTypeFlags(bundleType) { + const isUMDBundle = + bundleType === UMD_DEV || + bundleType === UMD_PROD || + bundleType === UMD_PROFILING; + const isFBWWWBundle = + bundleType === FB_WWW_DEV || + bundleType === FB_WWW_PROD || + bundleType === FB_WWW_PROFILING; + const isRNBundle = + bundleType === RN_OSS_DEV || + bundleType === RN_OSS_PROD || + bundleType === RN_OSS_PROFILING || + bundleType === RN_FB_DEV || + bundleType === RN_FB_PROD || + bundleType === RN_FB_PROFILING; + + const isFBRNBundle = + bundleType === RN_FB_DEV || + bundleType === RN_FB_PROD || + bundleType === RN_FB_PROFILING; + + const shouldStayReadable = isFBWWWBundle || isRNBundle || forcePrettyOutput; + + const shouldBundleDependencies = + bundleType === UMD_DEV || + bundleType === UMD_PROD || + bundleType === UMD_PROFILING; + + return { + isUMDBundle, + isFBWWWBundle, + isRNBundle, + isFBRNBundle, + shouldBundleDependencies, + shouldStayReadable, + }; +} + function forbidFBJSImports() { return { name: 'forbidFBJSImports', @@ -345,22 +386,30 @@ function getPlugins( const forks = Modules.getForks(bundleType, entry, moduleType, bundle); const isProduction = isProductionBundleType(bundleType); const isProfiling = isProfilingBundleType(bundleType); - const isUMDBundle = - bundleType === UMD_DEV || - bundleType === UMD_PROD || - bundleType === UMD_PROFILING; - const isFBWWWBundle = - bundleType === FB_WWW_DEV || - bundleType === FB_WWW_PROD || - bundleType === FB_WWW_PROFILING; - const isRNBundle = - bundleType === RN_OSS_DEV || - bundleType === RN_OSS_PROD || - bundleType === RN_OSS_PROFILING || - bundleType === RN_FB_DEV || - bundleType === RN_FB_PROD || - bundleType === RN_FB_PROFILING; - const shouldStayReadable = isFBWWWBundle || isRNBundle || forcePrettyOutput; + + const {isUMDBundle, shouldStayReadable} = getBundleTypeFlags(bundleType); + + const needsMinifiedByClosure = isProduction && bundleType !== ESM_PROD; + + // Any other packages that should specifically _not_ have sourcemaps + const sourcemapPackageExcludes = [ + // Having `//#sourceMappingUrl` for the `react-debug-tools` prod bundle breaks + // `ReactDevToolsHooksIntegration-test.js`, because it changes Node's generated + // stack traces and thus alters the hook name parsing behavior. + // Also, this is an internal-only package that doesn't need sourcemaps anyway + 'react-debug-tools', + ]; + + // Generate sourcemaps for true "production" build artifacts + // that will be used by bundlers, such as `react-dom.production.min.js`. + // Also include profiling builds as well. + // UMD builds are rarely used and not worth having sourcemaps. + const needsSourcemaps = + needsMinifiedByClosure && + !isUMDBundle && + !sourcemapPackageExcludes.includes(entry) && + !shouldStayReadable; + return [ // Keep dynamic imports as externals dynamicImports(), @@ -370,7 +419,7 @@ function getPlugins( const transformed = flowRemoveTypes(code); return { code: transformed.toString(), - map: transformed.generateMap(), + map: null, }; }, }, @@ -399,6 +448,7 @@ function getPlugins( ), // Remove 'use strict' from individual source files. { + name: "remove 'use strict'", transform(source) { return source.replace(/['"]use strict["']/g, ''); }, @@ -420,47 +470,9 @@ function getPlugins( // I'm going to port "art" to ES modules to avoid this problem. // Please don't enable this for anything else! 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, - }), - // Add the whitespace back if necessary. - shouldStayReadable && - prettier({ - parser: 'flow', - singleQuote: false, - trailingComma: 'none', - bracketSpacing: true, - }), // License and haste headers, top-level `if` blocks. { + name: 'license-and-headers', renderChunk(source) { return Wrappers.wrapBundle( source, @@ -472,6 +484,114 @@ function getPlugins( ); }, }, + // Apply dead code elimination and/or minification. + // closure doesn't yet support leaving ESM imports intact + 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} + ), + // Add the whitespace back if necessary. + shouldStayReadable && + prettier({ + parser: 'flow', + singleQuote: false, + trailingComma: 'none', + bracketSpacing: true, + }), + 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, rather than + // showing the "original" individual source files. This better shows + // what is actually running in the app. + + // 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); + const outputFolder = path.dirname(options.file); + + // Read the sourcemap that Closure wrote to disk + const sourcemapAfterClosure = JSON.parse( + fs.readFileSync(finalSourcemapPath, 'utf8') + ); + + // Represent the "original" bundle as a file with no `.min` in the name + const filenameWithoutMin = filename.replace('.min', ''); + // There's _one_ artifact where the incoming filename actually contains + // a folder name: "use-sync-external-store-shim/with-selector.production.js". + // The output path already has the right structure, but we need to strip this + // down to _just_ the JS filename. + const preMinifiedFilename = path.basename(filenameWithoutMin); + + // CC generated a file list that only contains the tempfile name. + // Replace that with a more meaningful "source" name for this bundle + // that represents "the bundled source before minification". + sourcemapAfterClosure.sources = [preMinifiedFilename]; + sourcemapAfterClosure.file = filename; + + // We'll write the pre-minified source to disk as a separate file. + // Because it sits on disk, there's no need to have it in the `sourcesContent` array. + // That also makes the file easier to read, and available for use by scripts. + // This should be the only file in the array. + const [preMinifiedBundleSource] = + sourcemapAfterClosure.sourcesContent; + + // Remove this entirely - we're going to write the file to disk instead. + delete sourcemapAfterClosure.sourcesContent; + + const preMinifiedBundlePath = path.join( + outputFolder, + preMinifiedFilename + ); + + // Write the original source to disk as a separate file + fs.writeFileSync(preMinifiedBundlePath, preMinifiedBundleSource); + + // Overwrite the Closure-generated file with the final combined sourcemap + fs.writeFileSync( + finalSourcemapPath, + JSON.stringify(sourcemapAfterClosure) + ); + + // 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) => { @@ -577,25 +697,14 @@ async function createBundle(bundle, bundleType) { const format = getFormat(bundleType); const packageName = Packaging.getPackageName(bundle.entry); - const isFBWWWBundle = - bundleType === FB_WWW_DEV || - bundleType === FB_WWW_PROD || - bundleType === FB_WWW_PROFILING; - - const isFBRNBundle = - bundleType === RN_FB_DEV || - bundleType === RN_FB_PROD || - bundleType === RN_FB_PROFILING; + const {isFBWWWBundle, isFBRNBundle, shouldBundleDependencies} = + getBundleTypeFlags(bundleType); let resolvedEntry = resolveEntryFork( require.resolve(bundle.entry), isFBWWWBundle || isFBRNBundle ); - const shouldBundleDependencies = - bundleType === UMD_DEV || - bundleType === UMD_PROD || - bundleType === UMD_PROFILING; const peerGlobals = Modules.getPeerGlobals(bundle.externals, bundleType); let externals = Object.keys(peerGlobals); if (!shouldBundleDependencies) { 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}; }, diff --git a/scripts/rollup/wrappers.js b/scripts/rollup/wrappers.js index aecb2bb1398f3..0b30e3b9298f6 100644 --- a/scripts/rollup/wrappers.js +++ b/scripts/rollup/wrappers.js @@ -192,6 +192,7 @@ ${source}`; /****************** FB_WWW_DEV ******************/ [FB_WWW_DEV](source, globalName, filename, moduleType) { return `/** + * @preserve ${license} * * @noflow @@ -212,6 +213,7 @@ ${source} /****************** FB_WWW_PROD ******************/ [FB_WWW_PROD](source, globalName, filename, moduleType) { return `/** + * @preserve ${license} * * @noflow @@ -226,6 +228,7 @@ ${source}`; /****************** FB_WWW_PROFILING ******************/ [FB_WWW_PROFILING](source, globalName, filename, moduleType) { return `/** + * @preserve ${license} * * @noflow @@ -240,6 +243,7 @@ ${source}`; /****************** RN_OSS_DEV ******************/ [RN_OSS_DEV](source, globalName, filename, moduleType) { return signFile(`/** + * @preserve ${license} * * @noflow @@ -261,6 +265,7 @@ ${source} /****************** RN_OSS_PROD ******************/ [RN_OSS_PROD](source, globalName, filename, moduleType) { return signFile(`/** + * @preserve ${license} * * @noflow @@ -276,6 +281,7 @@ ${source}`); /****************** RN_OSS_PROFILING ******************/ [RN_OSS_PROFILING](source, globalName, filename, moduleType) { return signFile(`/** + * @preserve ${license} * * @noflow @@ -291,6 +297,7 @@ ${source}`); /****************** RN_FB_DEV ******************/ [RN_FB_DEV](source, globalName, filename, moduleType) { return signFile(`/** + * @preserve ${license} * * @noflow @@ -311,6 +318,7 @@ ${source} /****************** RN_FB_PROD ******************/ [RN_FB_PROD](source, globalName, filename, moduleType) { return signFile(`/** + * @preserve ${license} * * @noflow @@ -325,6 +333,7 @@ ${source}`); /****************** RN_FB_PROFILING ******************/ [RN_FB_PROFILING](source, globalName, filename, moduleType) { return signFile(`/** + * @preserve ${license} * * @noflow