diff --git a/packages/sui-bundler/README.md b/packages/sui-bundler/README.md index dedc5fd42..81e43dabb 100644 --- a/packages/sui-bundler/README.md +++ b/packages/sui-bundler/README.md @@ -184,6 +184,10 @@ This tool works with zero configuration out the box but you could use some confi `disableTypeScriptLoader`: Flag to determine if typescript loader should be used. (default: `false`) +`plainCssPackages`: Array of npm package names whose `.css` files should bypass `@s-ui/sass-loader` and `postcss-loader`. Use this for packages that ship pre-compiled CSS (e.g. Tailwind CSS v4 output) that would cause Sass parse errors. (default: `[]`) + +`cssInAppStyles`: Array of npm package names whose CSS modules should be forced into the `AppStyles` chunk. This prevents webpack's `splitChunks` from extracting them into separate async chunks that `@s-ui/ssr` doesn't know about — avoiding Flash of Unstyled Content. (default: `[]`) + ```json { "config": { @@ -202,6 +206,8 @@ This tool works with zero configuration out the box but you could use some confi "dev": "cheap-module-eval-source-map", "prod": "hidden-source-map" }, + "plainCssPackages": ["@adv-mt/ui", "@adv-mt/theme"], + "cssInAppStyles": ["@adv-mt/ui", "@adv-mt/theme"], "disableTypeScriptLoader": true // Only if you want to disable typescript loader } } diff --git a/packages/sui-bundler/shared/module-rules-sass.js b/packages/sui-bundler/shared/module-rules-sass.js index 5379db53b..72ea5602d 100644 --- a/packages/sui-bundler/shared/module-rules-sass.js +++ b/packages/sui-bundler/shared/module-rules-sass.js @@ -2,8 +2,17 @@ const MiniCssExtractPlugin = require('mini-css-extract-plugin') const {cleanList, config, when, isTailwindEnabled} = require('./index') -module.exports = { +const plainCssPackages = config.plainCssPackages || [] + +const plainCssExclude = plainCssPackages.length + ? new RegExp( + `node_modules[\\\\/](${plainCssPackages.map(p => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})[\\\\/]` + ) + : null + +const sassRule = { test: /(\.css|\.scss)$/, + ...(plainCssExclude ? {exclude: plainCssExclude} : {}), use: cleanList([ MiniCssExtractPlugin.loader, require.resolve('css-loader'), @@ -29,3 +38,13 @@ module.exports = { require.resolve('@s-ui/sass-loader') ]) } + +const plainCssRule = plainCssExclude + ? { + test: /\.css$/, + include: plainCssExclude, + use: [MiniCssExtractPlugin.loader, require.resolve('css-loader')] + } + : null + +module.exports = plainCssRule ? [sassRule, plainCssRule] : sassRule diff --git a/packages/sui-bundler/shared/plain-css-split-chunks.js b/packages/sui-bundler/shared/plain-css-split-chunks.js new file mode 100644 index 000000000..b2ff7e99e --- /dev/null +++ b/packages/sui-bundler/shared/plain-css-split-chunks.js @@ -0,0 +1,49 @@ +const {config} = require('./index') + +const cssInAppStyles = config.cssInAppStyles || [] + +exports.plainCssSplitChunks = () => { + if (!cssInAppStyles.length) return {} + + return { + plugins: [ + { + apply(compiler) { + const pkgPattern = cssInAppStyles.map(p => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|') + const pkgRegex = new RegExp(`[\\\\/]node_modules[\\\\/](${pkgPattern})[\\\\/]`) + + compiler.hooks.thisCompilation.tap('CssInAppStyles', compilation => { + compilation.hooks.afterOptimizeChunks.tap('CssInAppStyles', chunks => { + let appStylesChunk = null + for (const chunk of chunks) { + if (chunk.name === 'AppStyles') { + appStylesChunk = chunk + break + } + } + if (!appStylesChunk) return + + for (const chunk of chunks) { + if (chunk === appStylesChunk) continue + const modulesToMove = [] + for (const module of compilation.chunkGraph.getChunkModulesIterable(chunk)) { + if (module.type !== 'css/mini-extract') continue + const name = module.nameForCondition && module.nameForCondition() + if (name && pkgRegex.test(name)) { + modulesToMove.push(module) + } + } + for (const module of modulesToMove) { + compilation.chunkGraph.disconnectChunkAndModule(chunk, module) + if (!compilation.chunkGraph.isModuleInChunk(module, appStylesChunk)) { + compilation.chunkGraph.connectChunkAndModule(appStylesChunk, module) + } + } + } + }) + }) + } + } + ] + } +} diff --git a/packages/sui-bundler/test/server/plainCssSplitChunksSpec.js b/packages/sui-bundler/test/server/plainCssSplitChunksSpec.js new file mode 100644 index 000000000..5780307be --- /dev/null +++ b/packages/sui-bundler/test/server/plainCssSplitChunksSpec.js @@ -0,0 +1,74 @@ +const {expect} = require('chai') +const path = require('path') + +const CONFIG_PATH = require.resolve('../../shared/config.js') +const INDEX_PATH = require.resolve('../../shared/index.js') +const MODULE_PATH = require.resolve('../../shared/plain-css-split-chunks.js') + +function loadWithConfig(cssInAppStyles = []) { + delete require.cache[CONFIG_PATH] + delete require.cache[INDEX_PATH] + delete require.cache[MODULE_PATH] + + require.cache[CONFIG_PATH] = { + id: CONFIG_PATH, + filename: CONFIG_PATH, + loaded: true, + exports: { + config: {cssInAppStyles}, + extractComments: false, + sourceMap: false, + supportLegacyBrowsers: true, + cacheDirectory: path.resolve(process.cwd(), '.sui/cache') + } + } + + return require('../../shared/plain-css-split-chunks.js') +} + +describe('plainCssSplitChunks', () => { + afterEach(() => { + delete require.cache[CONFIG_PATH] + delete require.cache[INDEX_PATH] + delete require.cache[MODULE_PATH] + }) + + it('returns empty object when cssInAppStyles is not configured', () => { + const {plainCssSplitChunks} = loadWithConfig([]) + expect(plainCssSplitChunks()).to.deep.equal({}) + }) + + it('returns plugins array with one plugin when cssInAppStyles has packages', () => { + const {plainCssSplitChunks} = loadWithConfig(['@adv-mt/ui', '@adv-mt/theme']) + const result = plainCssSplitChunks() + + expect(result).to.have.property('plugins') + expect(result.plugins).to.be.an('array').with.lengthOf(1) + }) + + it('plugin has an apply method', () => { + const {plainCssSplitChunks} = loadWithConfig(['@adv-mt/ui']) + const result = plainCssSplitChunks() + + expect(result.plugins[0]).to.have.property('apply') + expect(result.plugins[0].apply).to.be.a('function') + }) + + it('plugin taps into thisCompilation hook', () => { + const {plainCssSplitChunks} = loadWithConfig(['@adv-mt/ui']) + const result = plainCssSplitChunks() + + const taps = [] + const fakeCompiler = { + hooks: { + thisCompilation: { + tap: (name, fn) => taps.push({name, fn}) + } + } + } + + result.plugins[0].apply(fakeCompiler) + expect(taps).to.have.lengthOf(1) + expect(taps[0].name).to.equal('CssInAppStyles') + }) +}) diff --git a/packages/sui-bundler/webpack.config.client.dev.js b/packages/sui-bundler/webpack.config.client.dev.js index 6e1bc93d5..de9cf0a17 100644 --- a/packages/sui-bundler/webpack.config.client.dev.js +++ b/packages/sui-bundler/webpack.config.client.dev.js @@ -9,9 +9,10 @@ const MiniCssExtractPlugin = require('mini-css-extract-plugin') const HtmlWebpackInjectAttributesPlugin = require('html-webpack-inject-attributes-plugin') const {withHydrationOverlayWebpack} = require('@builder.io/react-hydration-overlay/webpack') -const {envVars, MAIN_ENTRY_POINT, config, cleanList, when, isTailwindEnabled} = require('./shared/index.js') +const {envVars, MAIN_ENTRY_POINT, config, cleanList, when} = require('./shared/index.js') const definePlugin = require('./shared/define.js') const manifestLoaderRules = require('./shared/module-rules-manifest-loader.js') +const sassRules = require('./shared/module-rules-sass.js') const createSVGSpritemapPlugin = require('./shared/svg-spritemap') const {aliasFromConfig, defaultAlias} = require('./shared/resolve-alias.js') const {supportLegacyBrowsers, cacheDirectory} = require('./shared/config.js') @@ -96,33 +97,7 @@ const webpackConfig = { module: { rules: cleanList([ createCompilerRules({supportLegacyBrowsers, isDevelopment: true}), - { - test: /(\.css|\.scss)$/, - use: cleanList([ - MiniCssExtractPlugin.loader, - require.resolve('css-loader'), - when(config['externals-manifest'], () => ({ - loader: 'externals-manifest-loader', - options: { - manifestURL: config['externals-manifest'] - } - })), - { - loader: require.resolve('postcss-loader'), - options: { - postcssOptions: { - plugins: [ - ...(isTailwindEnabled() ? [require('tailwindcss')()] : []), - require('autoprefixer')({ - overrideBrowserslist: config.targets - }) - ] - } - } - }, - require.resolve('@s-ui/sass-loader') - ]) - }, + ...(Array.isArray(sassRules) ? sassRules : [sassRules]), when(config['externals-manifest'], () => manifestLoaderRules(config['externals-manifest'])) ]) }, diff --git a/packages/sui-bundler/webpack.config.dev.js b/packages/sui-bundler/webpack.config.dev.js index 3b262f382..0622bf606 100644 --- a/packages/sui-bundler/webpack.config.dev.js +++ b/packages/sui-bundler/webpack.config.dev.js @@ -14,6 +14,13 @@ const {supportLegacyBrowsers, cacheDirectory} = require('./shared/config.js') const {resolveLoader} = require('./shared/resolve-loader.js') const createCompilerRules = require('./shared/module-rules-compiler.js') +const plainCssPackages = config.plainCssPackages || [] +const plainCssExclude = plainCssPackages.length + ? new RegExp( + `node_modules[\\\\/](${plainCssPackages.map(p => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})[\\\\/]` + ) + : null + const outputPath = path.join(process.cwd(), 'dist') const {CI = false} = process.env @@ -90,6 +97,7 @@ const webpackConfig = { createCompilerRules({supportLegacyBrowsers, isDevelopment: true}), { test: /(\.css|\.scss)$/, + ...(plainCssExclude ? {exclude: plainCssExclude} : {}), use: cleanList([ require.resolve('style-loader'), when(config['externals-manifest'], () => ({ @@ -115,6 +123,15 @@ const webpackConfig = { require.resolve('@s-ui/sass-loader') ]) }, + ...(plainCssExclude + ? [ + { + test: /\.css$/, + include: plainCssExclude, + use: [require.resolve('style-loader'), require.resolve('css-loader')] + } + ] + : []), when(config['externals-manifest'], () => manifestLoaderRules(config['externals-manifest'])) ]) }, diff --git a/packages/sui-bundler/webpack.config.lib.js b/packages/sui-bundler/webpack.config.lib.js index c992196aa..ba7b18ae3 100644 --- a/packages/sui-bundler/webpack.config.lib.js +++ b/packages/sui-bundler/webpack.config.lib.js @@ -60,7 +60,7 @@ module.exports = ({chunkCss} = {}) => { definePlugin() ]), module: { - rules: [createCompilerRules({supportLegacyBrowsers}), sassRules] + rules: [createCompilerRules({supportLegacyBrowsers}), ...(Array.isArray(sassRules) ? sassRules : [sassRules])] } } } diff --git a/packages/sui-bundler/webpack.config.prod.js b/packages/sui-bundler/webpack.config.prod.js index e7c91f191..5f0f85b98 100644 --- a/packages/sui-bundler/webpack.config.prod.js +++ b/packages/sui-bundler/webpack.config.prod.js @@ -10,6 +10,7 @@ const MiniCssExtractPlugin = require('mini-css-extract-plugin') const InlineChunkHtmlPlugin = require('./shared/inline-chunk-html-plugin.js') const {when, cleanList, envVars, MAIN_ENTRY_POINT, config} = require('./shared/index.js') +const {plainCssSplitChunks} = require('./shared/plain-css-split-chunks.js') const {aliasFromConfig} = require('./shared/resolve-alias.js') const {extractComments, sourceMap, supportLegacyBrowsers, cacheDirectory} = require('./shared/config.js') const {resolveLoader} = require('./shared/resolve-loader.js') @@ -73,6 +74,7 @@ const webpackConfig = { compression: false }, plugins: cleanList([ + ...(plainCssSplitChunks().plugins || []), new webpack.ProvidePlugin({ process: 'process/browser.js' }), @@ -106,7 +108,7 @@ const webpackConfig = { module: { rules: cleanList([ createCompilerRules({supportLegacyBrowsers}), - sassRules, + ...(Array.isArray(sassRules) ? sassRules : [sassRules]), when(config['externals-manifest'], () => manifestLoaderRules(config['externals-manifest'])) ]) },