Files
mostlymatter/webapp/channels/webpack.config.js
Harrison Healey 6af8c2f83e Fix multiple copies of utility classes being loaded by the app (#28802)
* Fix multiple copies of utility classes being loaded by the app

When using SASS's at-import rule for a file containing CSS classes, the
entirity of the imported file is included into that chunk of the JS
bundle. This leads to those class definitions being duplicated multiple
times in the code loaded by the browser. That doesn't happen for mixins,
functions, or SASS variables which are evaluated at compile time.

To prevent those classes from being repeatedly defined, we need to not
import them into multiple bundles. We already load the utility classes
into the root bundle, so we just need to make sure not to import
sass/utils/_module.scss, sass/utils/_flex.scss,
sass/utils/_animations.scss, or sass/utils/_modifiers.scss into any
CSS files which are imported into individual components.

* Canonize import paths for SCSS mixins and functions

We previously imported some .scss files as `src/sass/...` and others as
`sass/...`. This was allowed becuase sass-loader's
`sassOptions.includePaths` included both `src` and `src/sass`. I've made
that more consistent now and removed the redundant include path.
2024-10-18 10:50:26 -04:00

469 строки
16 KiB
JavaScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/* eslint-disable no-console, no-process-env */
const path = require('path');
const url = require('url');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const ExternalTemplateRemotesPlugin = require('external-remotes-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const webpack = require('webpack');
const {ModuleFederationPlugin} = require('webpack').container;
const WebpackPwaManifest = require('webpack-pwa-manifest');
// const {BundleAnalyzerPlugin} = require('webpack-bundle-analyzer');
const packageJson = require('./package.json');
const NPM_TARGET = process.env.npm_lifecycle_event;
const targetIsRun = NPM_TARGET?.startsWith('run');
const targetIsStats = NPM_TARGET === 'stats';
const targetIsDevServer = NPM_TARGET?.startsWith('dev-server');
const targetIsEslint = NPM_TARGET?.startsWith('check') || NPM_TARGET === 'fix' || process.env.VSCODE_CWD;
const DEV = targetIsRun || targetIsStats || targetIsDevServer;
const STANDARD_EXCLUDE = [
/node_modules/,
];
let publicPath = '/static/';
// Allow overriding the publicPath in dev from the exported SiteURL.
if (DEV) {
const siteURL = process.env.MM_SERVICESETTINGS_SITEURL || '';
if (siteURL) {
publicPath = path.join(new url.URL(siteURL).pathname, 'static') + '/';
}
}
// Track the build time so that we can bust any caches that may have incorrectly cached remote_entry.js from before we
// started setting Cache-Control: no-cache for that file on the server. This can be removed in 2024 after those cached
// entries are guaranteed to have expired.
const buildTimestamp = Date.now();
var config = {
entry: ['./src/root.tsx'],
output: {
publicPath,
filename: '[name].[contenthash].js',
chunkFilename: '[name].[contenthash].js',
assetModuleFilename: 'files/[contenthash][ext]',
clean: true,
},
module: {
rules: [
{
test: /\.(js|jsx|ts|tsx)$/,
exclude: STANDARD_EXCLUDE,
use: {
loader: 'babel-loader',
options: {
cacheDirectory: true,
// Babel configuration is in .babelrc because jest requires it to be there.
},
},
},
{
test: /\.json$/,
include: [
path.resolve(__dirname, 'src/i18n'),
],
exclude: [/en\.json$/],
type: 'asset/resource',
generator: {
filename: 'i18n/[name].[contenthash].json',
},
},
{
test: /\.(css|scss)$/,
exclude: /\/highlight\.js\//,
use: [
DEV ? 'style-loader' : MiniCssExtractPlugin.loader,
{
loader: 'css-loader',
},
],
},
{
test: /\.scss$/,
use: [
{
loader: 'sass-loader',
options: {
sassOptions: {
includePaths: ['src/sass'],
},
},
},
],
},
{
test: /\.(png|eot|tiff|svg|woff2|woff|ttf|gif|mp3|jpg)$/,
type: 'asset/resource',
use: [
{
loader: 'image-webpack-loader',
options: {},
},
],
},
{
test: /\.apng$/,
type: 'asset/resource',
},
{
test: /\/highlight\.js\/.*\.css$/,
type: 'asset/resource',
},
],
},
resolve: {
modules: [
'node_modules',
'./src',
],
alias: {
'mattermost-redux/test': 'packages/mattermost-redux/test',
'mattermost-redux': 'packages/mattermost-redux/src',
'@mui/styled-engine': '@mui/styled-engine-sc',
// This alias restricts single version of styled components across all packages
'styled-components': path.resolve(__dirname, '..', 'node_modules', 'styled-components'),
},
extensions: ['.ts', '.tsx', '.js', '.jsx'],
fallback: {
crypto: require.resolve('crypto-browserify'),
stream: require.resolve('stream-browserify'),
buffer: require.resolve('buffer/'),
},
},
performance: {
hints: 'warning',
},
target: 'web',
plugins: [
new webpack.ProvidePlugin({
process: 'process/browser',
}),
new MiniCssExtractPlugin({
filename: '[name].[contenthash].css',
chunkFilename: '[name].[contenthash].css',
}),
new HtmlWebpackPlugin({
filename: 'root.html',
inject: 'head',
template: 'src/root.html',
scriptLoading: 'blocking',
meta: {
csp: {
'http-equiv': 'Content-Security-Policy',
content: generateCSP(),
},
},
}),
new CopyWebpackPlugin({
patterns: [
{from: 'src/images/emoji', to: 'emoji'},
{from: 'src/images/img_trans.gif', to: 'images'},
{from: 'src/images/logo-email.png', to: 'images'},
{from: 'src/images/favicon', to: 'images/favicon'},
{from: 'src/images/appIcons.png', to: 'images'},
{from: 'src/images/logo-email.png', to: 'images'},
{from: 'src/images/browser-icons', to: 'images/browser-icons'},
{from: 'src/images/cloud', to: 'images'},
{from: 'src/images/welcome_illustration_new.png', to: 'images'},
{from: 'src/images/logo_email_blue.png', to: 'images'},
{from: 'src/images/logo_email_dark.png', to: 'images'},
{from: 'src/images/logo_email_gray.png', to: 'images'},
{from: 'src/images/forgot_password_illustration.png', to: 'images'},
{from: 'src/images/invite_illustration.png', to: 'images'},
{from: 'src/images/channel_icon.png', to: 'images'},
{from: 'src/images/c_avatar.png', to: 'images'},
{from: 'src/images/c_download.png', to: 'images'},
{from: 'src/images/c_socket.png', to: 'images'},
{from: 'src/images/admin-onboarding-background.jpg', to: 'images'},
{from: 'src/images/cloud-laptop.png', to: 'images'},
{from: 'src/images/cloud-laptop-error.png', to: 'images'},
{from: 'src/images/cloud-laptop-warning.png', to: 'images'},
{from: 'src/images/cloud-upgrade-person-hand-to-face.png', to: 'images'},
{from: 'src/images/payment_processing.png', to: 'images'},
{from: 'src/images/purchase_alert.png', to: 'images'},
{from: '../node_modules/pdfjs-dist/cmaps', to: 'cmaps'},
{from: 'src/components/initial_loading_screen/initial_loading_screen.css', to: 'css'},
],
}),
// Generate manifest.json, honouring any configured publicPath. This also handles injecting
// <link rel="apple-touch-icon" ... /> and <meta name="apple-*" ... /> tags into root.html.
new WebpackPwaManifest({
name: 'Mattermost',
short_name: 'Mattermost',
start_url: '..',
description: 'Mattermost is an open source, self-hosted Slack-alternative',
background_color: '#ffffff',
inject: true,
ios: true,
fingerprints: false,
orientation: 'any',
filename: 'manifest.json',
icons: [{
src: path.resolve('src/images/favicon/android-chrome-192x192.png'),
type: 'image/png',
sizes: '192x192',
}, {
src: path.resolve('src/images/favicon/apple-touch-icon-120x120.png'),
type: 'image/png',
sizes: '120x120',
ios: true,
}, {
src: path.resolve('src/images/favicon/apple-touch-icon-144x144.png'),
type: 'image/png',
sizes: '144x144',
ios: true,
}, {
src: path.resolve('src/images/favicon/apple-touch-icon-152x152.png'),
type: 'image/png',
sizes: '152x152',
ios: true,
}, {
src: path.resolve('src/images/favicon/apple-touch-icon-57x57.png'),
type: 'image/png',
sizes: '57x57',
ios: true,
}, {
src: path.resolve('src/images/favicon/apple-touch-icon-60x60.png'),
type: 'image/png',
sizes: '60x60',
ios: true,
}, {
src: path.resolve('src/images/favicon/apple-touch-icon-72x72.png'),
type: 'image/png',
sizes: '72x72',
ios: true,
}, {
src: path.resolve('src/images/favicon/apple-touch-icon-76x76.png'),
type: 'image/png',
sizes: '76x76',
ios: true,
}, {
src: path.resolve('src/images/favicon/favicon-16x16.png'),
type: 'image/png',
sizes: '16x16',
}, {
src: path.resolve('src/images/favicon/favicon-32x32.png'),
type: 'image/png',
sizes: '32x32',
}, {
src: path.resolve('src/images/favicon/favicon-96x96.png'),
type: 'image/png',
sizes: '96x96',
}],
}),
// Disabling this plugin until we come up with better bundle analysis ci
// new BundleAnalyzerPlugin({
// analyzerMode: 'disabled',
// generateStatsFile: true,
// statsFilename: 'bundlestats.json',
// }),
],
};
function generateCSP() {
let csp = 'script-src \'self\' cdn.rudderlabs.com/ js.stripe.com/v3';
if (DEV) {
// react-hot-loader and development source maps require eval
csp += ' \'unsafe-eval\'';
}
return csp;
}
async function initializeModuleFederation() {
function makeSharedModules(packageNames, singleton) {
const sharedObject = {};
for (const packageName of packageNames) {
const version = packageJson.dependencies[packageName];
sharedObject[packageName] = {
// Ensure only one copy of this package is ever loaded
singleton,
// Setting this to true causes the app to error out if the required version is not satisfied
strictVersion: singleton,
// Set these to match the specific version that the web app includes
requiredVersion: singleton ? version : undefined,
version,
};
}
return sharedObject;
}
async function getRemoteContainers() {
const products = [];
const remotes = {};
for (const product of products) {
remotes[product.name] = `${product.name}@[window.basename]/static/products/${product.name}/remote_entry.js?bt=${buildTimestamp}`;
}
return {remotes};
}
const {remotes} = await getRemoteContainers();
const moduleFederationPluginOptions = {
name: 'mattermost_webapp',
remotes,
shared: [
// Shared modules will be made available to other containers (ie products and plugins using module federation).
// To allow for better sharing, containers shouldn't require exact versions of packages like the web app does.
// Other containers will use these shared modules if their required versions match. If they don't match, the
// version packaged with the container will be used.
makeSharedModules([
'@mattermost/client',
'@mattermost/types',
'luxon',
], false),
// Other containers will be forced to use the exact versions of shared modules that the web app provides.
makeSharedModules([
'history',
'react',
'react-beautiful-dnd',
'react-bootstrap',
'react-dom',
'react-intl',
'react-redux',
'react-router-dom',
'styled-components',
], true),
],
};
// Desktop specific code for remote module loading
moduleFederationPluginOptions.exposes = {
'./app': 'components/app',
'./store': 'stores/redux_store',
'./styles': './src/sass/styles.scss',
'./registry': 'module_registry',
};
moduleFederationPluginOptions.filename = `remote_entry.js?bt=${buildTimestamp}`;
config.plugins.push(new ModuleFederationPlugin(moduleFederationPluginOptions));
// Add this plugin to perform the substitution of window.basename when loading remote containers
config.plugins.push(new ExternalTemplateRemotesPlugin());
config.plugins.push(new webpack.DefinePlugin({
REMOTE_CONTAINERS: JSON.stringify(remotes),
}));
}
if (DEV) {
// Development mode configuration
config.mode = 'development';
config.devtool = 'eval-cheap-module-source-map';
} else {
// Production mode configuration
config.mode = 'production';
config.devtool = 'source-map';
}
const env = {};
if (DEV) {
env.PUBLIC_PATH = JSON.stringify(publicPath);
} else {
env.NODE_ENV = JSON.stringify('production');
}
config.plugins.push(new webpack.DefinePlugin({
'process.env': env,
}));
if (targetIsDevServer) {
const proxyToServer = {
logLevel: 'silent',
target: process.env.MM_SERVICESETTINGS_SITEURL ?? 'http://localhost:8065',
xfwd: true,
};
config = {
...config,
devtool: 'eval-cheap-module-source-map',
devServer: {
liveReload: true,
proxy: {
// Forward these requests to the server
'/api': {
...proxyToServer,
ws: true,
},
'/plugins': proxyToServer,
'/static/plugins': proxyToServer,
},
port: 9005,
devMiddleware: {
writeToDisk: false,
},
historyApiFallback: {
index: '/static/root.html',
},
},
performance: false,
optimization: {
...config.optimization,
splitChunks: false,
},
resolve: {
...config.resolve,
alias: {
...config.resolve.alias,
'react-dom': '@hot-loader/react-dom',
},
},
};
}
// Export PRODUCTION_PERF_DEBUG=1 when running webpack to enable support for the react profiler
// even while generating production code. (Performance testing development code is typically
// not helpful.)
// See https://reactjs.org/blog/2018/09/10/introducing-the-react-profiler.html and
// https://gist.github.com/bvaughn/25e6233aeb1b4f0cdb8d8366e54a3977
if (process.env.PRODUCTION_PERF_DEBUG) {
console.log('Enabling production performance debug settings'); //eslint-disable-line no-console
config.resolve.alias['react-dom'] = 'react-dom/profiling';
config.resolve.alias['schedule/tracing'] = 'schedule/tracing-profiling';
config.optimization = {
// Skip minification to make the profiled data more useful.
minimize: false,
};
}
if (targetIsEslint) {
// ESLint can't handle setting an async config, so just skip the async part
module.exports = config;
} else {
module.exports = async () => {
// Do this asynchronously so we can determine whether which remote modules are available
await initializeModuleFederation();
return config;
};
}