From eaf57e9f15fbeee55ce01214f637fe048fd2fbdd Mon Sep 17 00:00:00 2001 From: Sam Seay Date: Wed, 10 Jul 2024 17:23:17 +0800 Subject: [PATCH] Create a separate bundle for cart and checkout (#48010) --- .../bin/add-split-chunk-dependencies.js | 75 ++++++++ .../woocommerce-blocks/bin/webpack-configs.js | 164 ++++++++++++++++-- .../woocommerce-blocks/bin/webpack-entries.js | 43 +++-- plugins/woocommerce-blocks/webpack.config.js | 7 + .../changelog/48010-dev-cart-checkout-bundle | 4 + .../src/Blocks/AssetsController.php | 29 +++- 6 files changed, 290 insertions(+), 32 deletions(-) create mode 100644 plugins/woocommerce-blocks/bin/add-split-chunk-dependencies.js create mode 100644 plugins/woocommerce/changelog/48010-dev-cart-checkout-bundle diff --git a/plugins/woocommerce-blocks/bin/add-split-chunk-dependencies.js b/plugins/woocommerce-blocks/bin/add-split-chunk-dependencies.js new file mode 100644 index 00000000000..e1a65c85c49 --- /dev/null +++ b/plugins/woocommerce-blocks/bin/add-split-chunk-dependencies.js @@ -0,0 +1,75 @@ +// The Dependency Extraction Webpack Plugin does not add split chunks as dependencies +// to the .asset.php files that list dependencies. In the past we manually enqueued +// those dependencies in PHP. +// For every generated .asset.php file in the whole bundle, this plugin prefixes the +// list of dependencies with the handles of split chunks that were generated in the build. + +// e.g. if your bundle has a vendors script called foo-vendors then for every entry-point +// that has a .asset.php file generated by Dependency Extraction Webpack Plugin +// the plugin will edit that file to include foo-vendors as a listed dependency. + +// This means for any split chunk you build you'll only need to register it in PHP, but all +// files that depend on it will automatically include it as a dependency. +class AddSplitChunkDependencies { + apply( compiler ) { + compiler.hooks.thisCompilation.tap( + 'AddStableChunksToAssets', + ( compilation, callback ) => { + compilation.hooks.processAssets.tap( + { + name: 'AddStableChunksToAssets', + stage: compiler.webpack.Compilation + .PROCESS_ASSETS_STAGE_ANALYSE, + }, + () => { + const { chunks } = compilation; + + const splitChunks = chunks.filter( ( chunk ) => { + return chunk?.chunkReason?.includes( 'split' ); + } ); + + // find files that have an asset.php file + const chunksToAddSplitsTo = chunks.filter( + ( chunk ) => { + return ( + ! chunk?.chunkReason?.includes( 'split' ) && + chunk.files.find( ( file ) => + file.endsWith( 'asset.php' ) + ) + ); + } + ); + + for ( const chunk of chunksToAddSplitsTo ) { + const assetFile = chunk.files.find( ( file ) => + file.endsWith( 'asset.php' ) + ); + + const assetFileContent = compilation.assets[ + assetFile + ] + .source() + .toString(); + + const extraDependencies = splitChunks + .map( ( c ) => `'${ c.name }'` ) + .join( ', ' ); + + const updatedFileContent = assetFileContent.replace( + /('dependencies'\s*=>\s*array\s*\(\s*)([^)]*)\)/, + `$1${ extraDependencies }, $2)` + ); + + compilation.assets[ assetFile ] = { + source: () => updatedFileContent, + size: () => updatedFileContent.length, + }; + } + } + ); + } + ); + } +} + +module.exports = AddSplitChunkDependencies; diff --git a/plugins/woocommerce-blocks/bin/webpack-configs.js b/plugins/woocommerce-blocks/bin/webpack-configs.js index 6ad91380df0..50032521ce0 100644 --- a/plugins/woocommerce-blocks/bin/webpack-configs.js +++ b/plugins/woocommerce-blocks/bin/webpack-configs.js @@ -28,6 +28,7 @@ const { getProgressBarPluginConfig, getCacheGroups, } = require( './webpack-helpers' ); +const AddSplitChunkDependencies = require( './add-split-chunk-dependencies' ); const isProduction = NODE_ENV === 'production'; @@ -330,17 +331,6 @@ const getFrontConfig = ( options = {} ) => { // @see https://github.com/Automattic/jetpack/pull/20926 chunkFilename: `[name]-frontend${ fileSuffix }.js?ver=[contenthash]`, filename: ( pathData ) => { - // blocksCheckout and blocksComponents were moved from core bundle, - // retain their filenames to avoid breaking translations. - if ( - pathData.chunk.name === 'blocksCheckout' || - pathData.chunk.name === 'blocksComponents' - ) { - return `${ paramCase( - pathData.chunk.name - ) }${ fileSuffix }.js`; - } - return `[name]-frontend${ fileSuffix }.js`; }, uniqueName: 'webpackWcBlocksFrontendJsonp', @@ -395,9 +385,10 @@ const getFrontConfig = ( options = {} ) => { minSize: 200000, automaticNameDelimiter: '--', cacheGroups: { - commons: { + vendor: { test: /[\\/]node_modules[\\/]/, - name: 'wc-blocks-vendors', + // Note that filenames are suffixed with `frontend` so the generated file is `wc-blocks-frontend-vendors-frontend`. + name: 'wc-blocks-frontend-vendors', chunks: ( chunk ) => { return ( chunk.name !== 'product-button-interactivity' @@ -431,6 +422,7 @@ const getFrontConfig = ( options = {} ) => { bundleAnalyzerReportTitle: 'Frontend', } ), new ProgressBarPlugin( getProgressBarPluginConfig( 'Frontend' ) ), + new AddSplitChunkDependencies(), ], resolve: { ...resolve, @@ -986,6 +978,151 @@ const getInteractivityAPIConfig = ( options = {} ) => { }; }; +const getCartAndCheckoutFrontendConfig = ( options = {} ) => { + let { fileSuffix } = options; + const { alias, resolvePlugins = [] } = options; + fileSuffix = fileSuffix ? `-${ fileSuffix }` : ''; + + const resolve = alias + ? { + alias, + plugins: resolvePlugins, + } + : { + plugins: resolvePlugins, + }; + return { + entry: getEntryConfig( + 'cartAndCheckoutFrontend', + options.exclude || [] + ), + output: { + devtoolNamespace: 'wc', + path: path.resolve( __dirname, '../build/' ), + // This is a cache busting mechanism which ensures that the script is loaded via the browser with a ?ver=hash + // string. The hash is based on the built file contents. + // @see https://github.com/webpack/webpack/issues/2329 + // Using the ?ver string is needed here so the filename does not change between builds. The WordPress + // i18n system relies on the hash of the filename, so changing that frequently would result in broken + // translations which we must avoid. + // @see https://github.com/Automattic/jetpack/pull/20926 + chunkFilename: `[name]-frontend${ fileSuffix }.js?ver=[contenthash]`, + filename: ( pathData ) => { + // blocksCheckout and blocksComponents were moved from core bundle, + // retain their filenames to avoid breaking translations. + if ( + pathData.chunk.name === 'blocksCheckout' || + pathData.chunk.name === 'blocksComponents' + ) { + return `${ paramCase( + pathData.chunk.name + ) }${ fileSuffix }.js`; + } + + return `[name]-frontend${ fileSuffix }.js`; + }, + uniqueName: 'webpackWcBlocksCartCheckoutFrontendJsonp', + library: [ 'wc', '[name]' ], + }, + module: { + rules: [ + { + test: /\.(j|t)sx?$/, + exclude: /node_modules/, + use: { + loader: 'babel-loader', + options: { + presets: [ + [ + '@wordpress/babel-preset-default', + { + modules: false, + targets: { + browsers: [ + 'extends @wordpress/browserslist-config', + ], + }, + }, + ], + ], + plugins: [ + isProduction + ? require.resolve( + 'babel-plugin-transform-react-remove-prop-types' + ) + : false, + '@babel/plugin-proposal-optional-chaining', + '@babel/plugin-proposal-class-properties', + ].filter( Boolean ), + cacheDirectory: true, + }, + }, + }, + { + test: /\.s[c|a]ss$/, + use: { + loader: 'ignore-loader', + }, + }, + ], + }, + optimization: { + concatenateModules: + isProduction && ! process.env.WP_BUNDLE_ANALYZER, + splitChunks: { + minSize: 200000, + automaticNameDelimiter: '--', + cacheGroups: { + commons: { + test: /[\\/]node_modules[\\/]/, + name: 'wc-cart-checkout-vendors', + chunks: 'all', + enforce: true, + }, + base: { + // A refined include blocks and settings that are shared between cart and checkout that produces the smallest possible bundle. + test: /assets[\\/]js[\\/](settings|previews|base|data|utils|blocks[\\/]cart-checkout-shared|icons)|packages[\\/](checkout|components)|atomic[\\/]utils/, + name: 'wc-cart-checkout-base', + chunks: 'all', + enforce: true, + }, + ...getCacheGroups(), + }, + }, + minimizer: [ + new TerserPlugin( { + parallel: true, + terserOptions: { + output: { + comments: /translators:/i, + }, + compress: { + passes: 2, + }, + mangle: { + reserved: [ '__', '_n', '_nx', '_x' ], + }, + }, + extractComments: false, + } ), + ], + }, + plugins: [ + ...getSharedPlugins( { + bundleAnalyzerReportTitle: 'Cart & Checkout Frontend', + } ), + new ProgressBarPlugin( + getProgressBarPluginConfig( 'Cart & Checkout Frontend' ) + ), + new AddSplitChunkDependencies(), + ], + resolve: { + ...resolve, + extensions: [ '.js', '.ts', '.tsx' ], + }, + }; +}; + module.exports = { getCoreConfig, getFrontConfig, @@ -995,4 +1132,5 @@ module.exports = { getSiteEditorConfig, getStylingConfig, getInteractivityAPIConfig, + getCartAndCheckoutFrontendConfig, }; diff --git a/plugins/woocommerce-blocks/bin/webpack-entries.js b/plugins/woocommerce-blocks/bin/webpack-entries.js index 76256a9a054..be936fafb02 100644 --- a/plugins/woocommerce-blocks/bin/webpack-entries.js +++ b/plugins/woocommerce-blocks/bin/webpack-entries.js @@ -25,9 +25,7 @@ const blocks = { }, 'attribute-filter': {}, breadcrumbs: {}, - cart: {}, 'catalog-sorting': {}, - checkout: {}, 'coming-soon': {}, 'customer-account': {}, 'featured-category': { @@ -43,10 +41,6 @@ const blocks = { customDir: 'classic-template', }, 'classic-shortcode': {}, - 'mini-cart': {}, - 'mini-cart-contents': { - customDir: 'mini-cart/mini-cart-contents', - }, 'store-notices': {}, 'page-content-wrapper': {}, 'price-filter': {}, @@ -169,12 +163,22 @@ const blocks = { }, }; +// Intentional separation of cart and checkout entry points to allow for better code splitting. +const cartAndCheckoutBlocks = { + cart: {}, + checkout: {}, + 'mini-cart': {}, + 'mini-cart-contents': { + customDir: 'mini-cart/mini-cart-contents', + }, +}; + // Returns the entries for each block given a relative path (ie: `index.js`, // `**/*.scss`...). // It also filters out elements with undefined props and experimental blocks. -const getBlockEntries = ( relativePath ) => { +const getBlockEntries = ( relativePath, blockEntries = blocks ) => { return Object.fromEntries( - Object.entries( blocks ) + Object.entries( blockEntries ) .map( ( [ blockCode, config ] ) => { const filePaths = glob.sync( `./assets/js/blocks/${ config.customDir || blockCode }/` + @@ -206,7 +210,10 @@ const entries = { './assets/js/atomic/blocks/product-elements/product-details/index.tsx', 'add-to-cart-form': './assets/js/atomic/blocks/product-elements/add-to-cart-form/index.tsx', - ...getBlockEntries( '{index,block,frontend}.{t,j}s{,x}' ), + ...getBlockEntries( '{index,block,frontend}.{t,j}s{,x}', { + ...blocks, + ...cartAndCheckoutBlocks, + } ), // Interactivity component styling 'wc-interactivity-checkbox-list': @@ -239,17 +246,14 @@ const entries = { 'wc-blocks': './assets/js/index.js', // Blocks - ...getBlockEntries( 'index.{t,j}s{,x}' ), + ...getBlockEntries( 'index.{t,j}s{,x}', { + ...blocks, + ...cartAndCheckoutBlocks, + } ), }, frontend: { reviews: './assets/js/blocks/reviews/frontend.ts', ...getBlockEntries( 'frontend.{t,j}s{,x}' ), - - blocksCheckout: './packages/checkout/index.js', - blocksComponents: './packages/components/index.ts', - - 'mini-cart-component': - './assets/js/blocks/mini-cart/component-frontend.tsx', 'product-button-interactivity': './assets/js/atomic/blocks/product-elements/button/frontend.tsx', }, @@ -273,6 +277,13 @@ const entries = { 'wc-blocks-classic-template-revert-button': './assets/js/templates/revert-button/index.tsx', }, + cartAndCheckoutFrontend: { + ...getBlockEntries( 'frontend.{t,j}s{,x}', cartAndCheckoutBlocks ), + blocksCheckout: './packages/checkout/index.js', + blocksComponents: './packages/components/index.ts', + 'mini-cart-component': + './assets/js/blocks/mini-cart/component-frontend.tsx', + }, }; const getEntryConfig = ( type = 'main', exclude = [] ) => { diff --git a/plugins/woocommerce-blocks/webpack.config.js b/plugins/woocommerce-blocks/webpack.config.js index ddb7942d125..d1a62c1e3fa 100644 --- a/plugins/woocommerce-blocks/webpack.config.js +++ b/plugins/woocommerce-blocks/webpack.config.js @@ -11,6 +11,7 @@ const { getSiteEditorConfig, getStylingConfig, getInteractivityAPIConfig, + getCartAndCheckoutFrontendConfig, } = require( './bin/webpack-configs.js' ); // Only options shared between all configs should be defined here. @@ -34,6 +35,11 @@ const sharedConfig = { devtool: NODE_ENV === 'development' ? 'source-map' : false, }; +const CartAndCheckoutFrontendConfig = { + ...sharedConfig, + ...getCartAndCheckoutFrontendConfig( { alias: getAlias() } ), +}; + // Core config for shared libraries. const CoreConfig = { ...sharedConfig, @@ -95,6 +101,7 @@ const SiteEditorConfig = { }; module.exports = [ + CartAndCheckoutFrontendConfig, CoreConfig, MainConfig, FrontendConfig, diff --git a/plugins/woocommerce/changelog/48010-dev-cart-checkout-bundle b/plugins/woocommerce/changelog/48010-dev-cart-checkout-bundle new file mode 100644 index 00000000000..33c230db8f1 --- /dev/null +++ b/plugins/woocommerce/changelog/48010-dev-cart-checkout-bundle @@ -0,0 +1,4 @@ +Significance: minor +Type: dev + +Create a separate JS cart and checkout JavaScript bundle to improve performance. \ No newline at end of file diff --git a/plugins/woocommerce/src/Blocks/AssetsController.php b/plugins/woocommerce/src/Blocks/AssetsController.php index 01cd1228a9e..1bb55c0f0c1 100644 --- a/plugins/woocommerce/src/Blocks/AssetsController.php +++ b/plugins/woocommerce/src/Blocks/AssetsController.php @@ -40,6 +40,7 @@ final class AssetsController { add_action( 'admin_enqueue_scripts', array( $this, 'update_block_style_dependencies' ), 20 ); add_action( 'wp_enqueue_scripts', array( $this, 'update_block_settings_dependencies' ), 100 ); add_action( 'admin_enqueue_scripts', array( $this, 'update_block_settings_dependencies' ), 100 ); + add_filter( 'js_do_concat', array( $this, 'skip_boost_minification_for_cart_checkout' ), 10, 2 ); } /** @@ -62,9 +63,14 @@ final class AssetsController { // The price package is shared externally so has no blocks prefix. $this->api->register_script( 'wc-price-format', 'assets/client/blocks/price-format.js', array(), false ); - $this->api->register_script( 'wc-blocks-vendors-frontend', $this->api->get_block_asset_build_path( 'wc-blocks-vendors-frontend' ), array(), false ); - $this->api->register_script( 'wc-blocks-checkout', 'assets/client/blocks/blocks-checkout.js', array( 'wc-blocks-vendors-frontend' ) ); - $this->api->register_script( 'wc-blocks-components', 'assets/client/blocks/blocks-components.js', array( 'wc-blocks-vendors-frontend' ) ); + // Vendor scripts for blocks frontends (not including cart and checkout). + $this->api->register_script( 'wc-blocks-frontend-vendors', $this->api->get_block_asset_build_path( 'wc-blocks-frontend-vendors-frontend' ), array(), false ); + + // Cart and checkout frontend scripts. + $this->api->register_script( 'wc-cart-checkout-vendors', $this->api->get_block_asset_build_path( 'wc-cart-checkout-vendors-frontend' ), array(), false ); + $this->api->register_script( 'wc-cart-checkout-base', $this->api->get_block_asset_build_path( 'wc-cart-checkout-base-frontend' ), array(), false ); + $this->api->register_script( 'wc-blocks-checkout', 'assets/client/blocks/blocks-checkout.js' ); + $this->api->register_script( 'wc-blocks-components', 'assets/client/blocks/blocks-components.js' ); // Register the interactivity components here for now. $this->api->register_script( 'wc-interactivity-dropdown', 'assets/client/blocks/wc-interactivity-dropdown.js', array() ); @@ -253,6 +259,23 @@ final class AssetsController { return $src; } + /** + * Skip Jetpack Boost minification on older versions of Jetpack Boost where it causes issues. + * + * @param mixed $do_concat Whether to concatenate the script or not. + * @param mixed $handle The script handle. + * @return mixed + */ + public function skip_boost_minification_for_cart_checkout( $do_concat, $handle ) { + $boost_is_outdated = defined( 'JETPACK_BOOST_VERSION' ) && version_compare( JETPACK_BOOST_VERSION, '3.4.2', '<' ); + $scripts_to_ignore = [ + 'wc-cart-checkout-vendors', + 'wc-cart-checkout-base', + ]; + + return $boost_is_outdated && in_array( $handle, $scripts_to_ignore, true ) ? false : $do_concat; + } + /** * Add body classes to the frontend and within admin. *