From 7cbbf8cd8472552e3d373dfd411f2e866752062a Mon Sep 17 00:00:00 2001 From: Tomas Madariaga Date: Tue, 26 May 2026 16:08:07 +0200 Subject: [PATCH 1/4] feat(packages/sui-bundler): add plainCssPackages config to bypass sass-loader Adds a new `plainCssPackages` option to `sui-bundler` config that allows projects to specify npm packages whose CSS files should skip the sass-loader pipeline entirely. Packages that ship pre-compiled CSS (e.g. Tailwind CSS v4 output) use modern CSS syntax (@layer, @property, nested rules) that Dart Sass cannot parse. This causes `Error: expected ";"` at build time. Usage in package.json: ```json { "config": { "sui-bundler": { "plainCssPackages": ["@adv-mt/ui", "@adv-mt/theme"] } } } ``` CSS files from listed packages are processed only by css-loader (+ MiniCssExtractPlugin or style-loader), bypassing postcss-loader and sass-loader. Co-Authored-By: Claude Opus 4.6 --- .../sui-bundler/shared/module-rules-sass.js | 21 ++++++++++++- .../sui-bundler/webpack.config.client.dev.js | 31 ++----------------- packages/sui-bundler/webpack.config.dev.js | 17 ++++++++++ packages/sui-bundler/webpack.config.lib.js | 2 +- packages/sui-bundler/webpack.config.prod.js | 2 +- 5 files changed, 42 insertions(+), 31 deletions(-) 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/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..b9b8bcbe5 100644 --- a/packages/sui-bundler/webpack.config.prod.js +++ b/packages/sui-bundler/webpack.config.prod.js @@ -106,7 +106,7 @@ const webpackConfig = { module: { rules: cleanList([ createCompilerRules({supportLegacyBrowsers}), - sassRules, + ...(Array.isArray(sassRules) ? sassRules : [sassRules]), when(config['externals-manifest'], () => manifestLoaderRules(config['externals-manifest'])) ]) }, From 28e32c50303eefe93725c3a535322090eb07e535 Mon Sep 17 00:00:00 2001 From: Tomas Madariaga Date: Thu, 25 Jun 2026 13:13:56 +0200 Subject: [PATCH 2/4] feat(packages/sui-bundler): add cssInAppStyles config to force CSS into AppStyles chunk Prevents webpack from splitting CSS of configured packages into separate async chunks, ensuring SSR injects them on first render (no FOUC). Co-Authored-By: Claude Opus 4.6 --- .../shared/plain-css-split-chunks.js | 25 +++++++++++++++++++ packages/sui-bundler/webpack.config.prod.js | 4 ++- 2 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 packages/sui-bundler/shared/plain-css-split-chunks.js 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..360a3642a --- /dev/null +++ b/packages/sui-bundler/shared/plain-css-split-chunks.js @@ -0,0 +1,25 @@ +const {config} = require('./index') + +const cssInAppStyles = config.cssInAppStyles || [] + +exports.plainCssSplitChunks = () => { + if (!cssInAppStyles.length) return {} + + const testRegex = new RegExp( + `[\\\\/]node_modules[\\\\/](${cssInAppStyles.map(p => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})[\\\\/]` + ) + + return { + splitChunks: { + cacheGroups: { + plainCssInEntry: { + type: 'css/mini-extract', + test: testRegex, + name: 'AppStyles', + chunks: 'all', + enforce: true + } + } + } + } +} diff --git a/packages/sui-bundler/webpack.config.prod.js b/packages/sui-bundler/webpack.config.prod.js index b9b8bcbe5..052125682 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') @@ -65,7 +66,8 @@ const webpackConfig = { checkWasmTypes: false, minimize: true, minimizer: [minifyJs({extractComments, sourceMap}), minifyCss()].filter(Boolean), - runtimeChunk: true + runtimeChunk: true, + ...plainCssSplitChunks() }, cache: { type: 'filesystem', From 04ce4194cf6ff971bacfb309c8bf7efa61d9c894 Mon Sep 17 00:00:00 2001 From: Tomas Madariaga Date: Fri, 26 Jun 2026 10:26:01 +0200 Subject: [PATCH 3/4] fix(packages/sui-bundler): use webpack plugin to move CSS into AppStyles chunk Replace splitChunks cacheGroups approach with a webpack plugin that moves css/mini-extract modules from configured packages into the AppStyles chunk at afterOptimizeChunks. More reliable regardless of chunk size thresholds. Co-Authored-By: Claude Opus 4.6 --- .../shared/plain-css-split-chunks.js | 50 +++++++++---- .../test/server/plainCssSplitChunksSpec.js | 74 +++++++++++++++++++ packages/sui-bundler/webpack.config.prod.js | 4 +- 3 files changed, 113 insertions(+), 15 deletions(-) create mode 100644 packages/sui-bundler/test/server/plainCssSplitChunksSpec.js diff --git a/packages/sui-bundler/shared/plain-css-split-chunks.js b/packages/sui-bundler/shared/plain-css-split-chunks.js index 360a3642a..b2ff7e99e 100644 --- a/packages/sui-bundler/shared/plain-css-split-chunks.js +++ b/packages/sui-bundler/shared/plain-css-split-chunks.js @@ -5,21 +5,45 @@ const cssInAppStyles = config.cssInAppStyles || [] exports.plainCssSplitChunks = () => { if (!cssInAppStyles.length) return {} - const testRegex = new RegExp( - `[\\\\/]node_modules[\\\\/](${cssInAppStyles.map(p => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})[\\\\/]` - ) - return { - splitChunks: { - cacheGroups: { - plainCssInEntry: { - type: 'css/mini-extract', - test: testRegex, - name: 'AppStyles', - chunks: 'all', - enforce: true + 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.prod.js b/packages/sui-bundler/webpack.config.prod.js index 052125682..5f0f85b98 100644 --- a/packages/sui-bundler/webpack.config.prod.js +++ b/packages/sui-bundler/webpack.config.prod.js @@ -66,8 +66,7 @@ const webpackConfig = { checkWasmTypes: false, minimize: true, minimizer: [minifyJs({extractComments, sourceMap}), minifyCss()].filter(Boolean), - runtimeChunk: true, - ...plainCssSplitChunks() + runtimeChunk: true }, cache: { type: 'filesystem', @@ -75,6 +74,7 @@ const webpackConfig = { compression: false }, plugins: cleanList([ + ...(plainCssSplitChunks().plugins || []), new webpack.ProvidePlugin({ process: 'process/browser.js' }), From 7f56eee81ea64a29bc1a8e45283d89cd0692c867 Mon Sep 17 00:00:00 2001 From: Tomas Madariaga Date: Fri, 26 Jun 2026 10:28:57 +0200 Subject: [PATCH 4/4] docs(packages/sui-bundler): document plainCssPackages and cssInAppStyles config Co-Authored-By: Claude Opus 4.6 --- packages/sui-bundler/README.md | 6 ++++++ 1 file changed, 6 insertions(+) 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 } }