From e456eb07cfca9570061c7ee04152954c04de3b53 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Thu, 29 Oct 2026 16:36:40 +0100 Subject: [PATCH 1/3] chore: prototype a per-module build with per-component CSS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Not intended to merge, kept for reference. See the rejected-alternative fold in https://github.com/mll-lab/react-components/pull/338. 🤖 Generated with Claude Code --- .gitignore | 1 + babel.config.js | 1 + package.json | 9 +++- rollup.config.js | 107 ++++++++++++++++++++++++++++--------- scripts/antd-styles.cjs | 97 +++++++++++++++++++++++++++++++++ src/Autocomplete/index.tsx | 8 +-- src/DatePicker/index.tsx | 1 - src/Grid/index.tsx | 8 ++- src/antd-variables.less | 17 ++++++ src/antd.less | 20 +------ src/index.ts | 3 -- 11 files changed, 217 insertions(+), 55 deletions(-) create mode 100644 scripts/antd-styles.cjs create mode 100644 src/antd-variables.less diff --git a/.gitignore b/.gitignore index d78013c7..a92163fa 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ types node_modules storybook-static *.log +styles diff --git a/babel.config.js b/babel.config.js index aef2b2f3..0ab01489 100644 --- a/babel.config.js +++ b/babel.config.js @@ -13,6 +13,7 @@ module.exports = { { libraryName: 'antd', style: false, + libraryDirectory: 'es', }, ], ], diff --git a/package.json b/package.json index f5d3caae..2be94967 100644 --- a/package.json +++ b/package.json @@ -8,13 +8,18 @@ "files": [ "cjs", "esm", + "styles", "types" ], + "sideEffects": [ + "**/*.css" + ], "scripts": { - "build:clean": "rimraf cjs esm types", + "build:clean": "rimraf cjs esm styles types", + "build:styles": "node scripts/antd-styles.cjs", "build:es": "rollup --config --bundleConfigAsCjs", "build:ts": "tsc --declaration --declarationDir types --emitDeclarationOnly", - "build": "yarn run build:clean && yarn run build:es && yarn run build:ts", + "build": "yarn run build:clean && yarn run build:styles && yarn run build:es && yarn run build:ts", "validate": "yarn run lint && yarn run typecheck && yarn run test && yarn run test:storybook", "test": "jest", "test:watch": "jest --watch", diff --git a/rollup.config.js b/rollup.config.js index a2c0ee6d..e638ea79 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -1,45 +1,95 @@ +import path from 'path'; + import { babel } from '@rollup/plugin-babel'; import commonjs from '@rollup/plugin-commonjs'; import resolve from '@rollup/plugin-node-resolve'; import typescript from '@rollup/plugin-typescript'; import peerDepsExternal from 'rollup-plugin-peer-deps-external'; -import styles from 'rollup-plugin-styles'; // eslint-disable-next-line import/extensions import pkg from './package.json'; +const { + BASE, + hasStyleEntry, + styleClosure, +} = require('./scripts/antd-styles.cjs'); + +const stylesDir = path.resolve('styles'); +const dependencyNames = Object.keys(pkg.dependencies); + +const isDependency = (id) => + /^@babel\/runtime|^rc-picker/.test(id) || + dependencyNames.some((name) => id === name || id.startsWith(`${name}/`)); + +// Imports the stylesheets of the antd components a module uses, so that bundlers only ship those +const antdStyleImports = () => ({ + name: 'antd-style-imports', + resolveId(source) { + return source.startsWith(stylesDir) ? { id: source, external: true } : null; + }, + renderChunk(code, chunk) { + const toStyles = path.posix.relative( + path.posix.dirname(chunk.fileName), + '../styles', + ); + return { + code: code.split(`${stylesDir}/`).join(`${toStyles}/`), + map: null, + }; + }, + transform(code, id) { + if (id.includes('node_modules')) return null; + const used = new Set( + [...code.matchAll(/from ['"]antd\/(?:es|lib)\/([a-z-]+)/g)] + .map((match) => match[1]) + .filter(hasStyleEntry), + ); + if (used.size === 0) return null; + const stylesheets = new Set([BASE]); + used.forEach((name) => + styleClosure(name).forEach((s) => stylesheets.add(s)), + ); + const imports = [...stylesheets].map( + (s) => `import '${stylesDir}/${s}.css';`, + ); + return { code: `${imports.join('\n')}\n${code}`, map: null }; + }, +}); + +const toEs = (id) => id.replace(/^antd\/lib\//, 'antd/es/'); +const toLib = (id) => id.replace(/^antd\/es\//, 'antd/lib/'); + +const output = { + preserveModules: true, + preserveModulesRoot: 'src', + sourcemap: true, + assetFileNames: '[name][extname]', +}; + export default { input: 'src/index.ts', output: [ { - file: pkg.main, + ...output, + dir: 'cjs', format: 'cjs', - sourcemap: true, // https://rollupjs.org/configuration-options/#output-interop // Necessary in order for jest in consumers to work interop: 'auto', - assetFileNames: '[name][extname]', + exports: 'named', + paths: toLib, }, { - file: pkg.module, + ...output, + dir: 'esm', format: 'esm', - sourcemap: true, - assetFileNames: '[name][extname]', + paths: toEs, }, ], - // this resolves to window in the browser, thus enabling caching in the antd code below - // (!) `this` has been rewritten to `undefined` - // https://rollupjs.org/guide/en/#error-this-is-undefined - // node_modules/antd/es/affix/index.js - // 7: import _typeof from "@babel/runtime/helpers/esm/typeof"; - // 8: - // 9: var __decorate = this && this.__decorate || function (decorators, target, key, desc) { - context: 'this', - - // https://www.npmjs.com/package/@rollup/plugin-babel#babelhelpers - external: [/@babel\/runtime/], + external: isDependency, plugins: [ peerDepsExternal(), @@ -48,17 +98,24 @@ export default { typescript({ exclude: '**/*.(test|stories).(ts|tsx)', }), - styles({ - mode: ['extract', 'index.css'], - // Keeps url('#linearGradient-1') of antd's Empty instead of emitting it as an asset - url: false, - less: { - javascriptEnabled: true, - }, + // The TS plugin leaves `import { X } from 'antd'` alone, this maps it to `antd/es/x` + babel({ + babelHelpers: 'bundled', + extensions: ['.ts', '.tsx'], + include: 'src/**', + babelrc: false, + configFile: false, + plugins: [ + [ + 'import', + { libraryName: 'antd', libraryDirectory: 'es', style: false }, + ], + ], }), babel({ // https://www.npmjs.com/package/@rollup/plugin-babel#babelhelpers babelHelpers: 'runtime', }), + antdStyleImports(), ], }; diff --git a/scripts/antd-styles.cjs b/scripts/antd-styles.cjs new file mode 100644 index 00000000..2349984f --- /dev/null +++ b/scripts/antd-styles.cjs @@ -0,0 +1,97 @@ +const fs = require('fs'); +const path = require('path'); + +const root = path.resolve(__dirname, '..'); +const antdEs = path.join(root, 'node_modules/antd/es'); + +const BASE = 'base'; + +function hasStylesheet(component) { + return fs.existsSync(path.join(antdEs, component, 'style/index.less')); +} + +function componentsWithStyles() { + return fs.readdirSync(antdEs).filter(hasStylesheet); +} + +function hasStyleEntry(component) { + return fs.existsSync(path.join(antdEs, component, 'style/index.js')); +} + +function directDependencies(component) { + const source = fs.readFileSync( + path.join(antdEs, component, 'style/index.js'), + 'utf8', + ); + return [...source.matchAll(/^import '\.\.\/\.\.\/([a-z-]+)\/style';/gm)].map( + (match) => match[1], + ); +} + +/** Dependencies first, so that the cascade matches the order of the full stylesheet. */ +function styleClosure(component) { + const ordered = []; + const visited = new Set(); + const visit = (name) => { + if (visited.has(name) || !hasStyleEntry(name)) return; + visited.add(name); + directDependencies(name).forEach(visit); + if (hasStylesheet(name)) ordered.push(name); + }; + visit(component); + return [BASE, ...ordered]; +} + +function lessEntry(name) { + const target = + name === BASE + ? 'antd/es/style/index.less' + : `antd/es/${name}/style/index.less`; + return [ + "@import 'antd/es/style/themes/compact.less';", + `@import (multiple) '${path.join(root, 'src/antd-variables.less')}';`, + `@import '${path.join(root, 'node_modules', target)}';`, + `@import (multiple) '${path.join(root, 'src/antd-variables.less')}';`, + ].join('\n'); +} + +async function buildStyles(outDir) { + const less = require('less'); + const postcss = require('postcss'); + const cssnano = require('cssnano'); + fs.rmSync(outDir, { recursive: true, force: true }); + fs.mkdirSync(outDir, { recursive: true }); + const sizes = {}; + for (const name of [BASE, ...componentsWithStyles()]) { + const { css } = await less.render(lessEntry(name), { + javascriptEnabled: true, + filename: path.join(root, 'src', `${name}.less`), + paths: [path.join(root, 'node_modules')], + plugins: [], + }); + const minified = await postcss([cssnano({ preset: 'default' })]).process( + css, + { from: undefined }, + ); + fs.writeFileSync(path.join(outDir, `${name}.css`), minified.css); + sizes[name] = minified.css.length; + } + return sizes; +} + +module.exports = { + BASE, + componentsWithStyles, + hasStyleEntry, + styleClosure, + buildStyles, +}; + +if (require.main === module) { + buildStyles(path.join(root, 'styles')).then((sizes) => { + const total = Object.values(sizes).reduce((a, b) => a + b, 0); + console.log( + `built ${Object.keys(sizes).length} stylesheets, ${total} bytes`, + ); + }); +} diff --git a/src/Autocomplete/index.tsx b/src/Autocomplete/index.tsx index 4cc03e30..bfd61b7d 100644 --- a/src/Autocomplete/index.tsx +++ b/src/Autocomplete/index.tsx @@ -1,4 +1,4 @@ -export { - AutoComplete as Autocomplete, - AutoCompleteProps as AutocompleteProps, -} from 'antd'; +import AntdAutoComplete from 'antd/es/auto-complete'; + +export const Autocomplete: typeof AntdAutoComplete = AntdAutoComplete; +export type { AutoCompleteProps as AutocompleteProps } from 'antd/es/auto-complete'; diff --git a/src/DatePicker/index.tsx b/src/DatePicker/index.tsx index 850243de..d2f2a132 100644 --- a/src/DatePicker/index.tsx +++ b/src/DatePicker/index.tsx @@ -5,7 +5,6 @@ import generatePicker, { PickerTimeProps as AntdPickerTimeProps, RangePickerProps as AntdRangePickerProps, } from 'antd/es/date-picker/generatePicker'; -import 'antd/es/date-picker/style/index'; import { format as formatDate, parse as parseDate, diff --git a/src/Grid/index.tsx b/src/Grid/index.tsx index 8ec5d69a..8f27e777 100644 --- a/src/Grid/index.tsx +++ b/src/Grid/index.tsx @@ -1,3 +1,7 @@ -import 'antd/es/grid/style/index'; +import AntdCol from 'antd/es/col'; +import AntdRow from 'antd/es/row'; -export { Row, Col, RowProps } from 'antd'; +export type { RowProps } from 'antd/es/row'; + +export const Row: typeof AntdRow = AntdRow; +export const Col: typeof AntdCol = AntdCol; diff --git a/src/antd-variables.less b/src/antd-variables.less new file mode 100644 index 00000000..032a6ec8 --- /dev/null +++ b/src/antd-variables.less @@ -0,0 +1,17 @@ +@ant-prefix: mll-ant; + +// The default is barely visible, darken it enough to be readable +@disabled-color: fade(#000, 85%); + +// See node_modules/antd/lib/style/themes/compact.less for size variables + +// Makes some components like tables even more dense, the default line height is excessive +@line-height-base: 1.337; + +// Table +@table-padding-vertical: 4px; +@table-padding-horizontal: 4px; +@table-padding-vertical-md: 2px; +@table-padding-horizontal-md: 2px; +@table-padding-vertical-sm: 1px; +@table-padding-horizontal-sm: 1px; diff --git a/src/antd.less b/src/antd.less index dc5850bf..d151ab74 100644 --- a/src/antd.less +++ b/src/antd.less @@ -1,20 +1,4 @@ @import '~antd/lib/style/themes/compact.less'; +@import (multiple) './antd-variables.less'; @import '~antd/dist/antd.less'; - -@ant-prefix: mll-ant; - -// The default is barely visible, darken it enough to be readable -@disabled-color: fade(#000, 85%); - -// See node_modules/antd/lib/style/themes/compact.less for size variables - -// Makes some components like tables even more dense, the default line height is excessive -@line-height-base: 1.337; - -// Table -@table-padding-vertical: 4px; -@table-padding-horizontal: 4px; -@table-padding-vertical-md: 2px; -@table-padding-horizontal-md: 2px; -@table-padding-vertical-sm: 1px; -@table-padding-horizontal-sm: 1px; +@import (multiple) './antd-variables.less'; diff --git a/src/index.ts b/src/index.ts index b1c09e40..04985ba1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,3 @@ -// eslint-disable-next-line @mll-lab/no-global-styles -import './antd.less'; - // Global configuration export * from './theme'; export * from './Provider'; From 134128c091bc2217c09196cab013280b44f002fe Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Fri, 30 Oct 2026 08:42:41 +0100 Subject: [PATCH 2/3] chore: keep bundled dependencies out of node_modules folders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit npm pack drops every node_modules directory, so the packed prototype missed tslib, react-hotkeys and the other bundled modules. 🤖 Generated with Claude Code --- rollup.config.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/rollup.config.js b/rollup.config.js index e638ea79..d92ee555 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -65,6 +65,8 @@ const output = { preserveModulesRoot: 'src', sourcemap: true, assetFileNames: '[name][extname]', + // npm pack drops every node_modules directory, so bundled dependencies need another folder name + entryFileNames: (chunk) => `${chunk.name.replace(/node_modules/g, 'vendor')}.js`, }; export default { From 87d3d3e044b286f10edf75a0565f21ffc7f19990 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Fri, 30 Oct 2026 08:56:04 +0100 Subject: [PATCH 3/3] chore: keep the scripts that measured the prototype in a consumer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit limes-build.sh builds the consumer with Rspack stats, route-load.py sums what each route's first page load needs. 🤖 Generated with Claude Code --- scripts/limes-build.sh | 9 +++++++++ scripts/route-load.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100755 scripts/limes-build.sh create mode 100644 scripts/route-load.py diff --git a/scripts/limes-build.sh b/scripts/limes-build.sh new file mode 100755 index 00000000..7e891c6c --- /dev/null +++ b/scripts/limes-build.sh @@ -0,0 +1,9 @@ +#!/bin/bash +set -Eeuo pipefail +# Tag-like production build of the Limes worktree with Rspack stats written to /tmp/per-module-scripts/stats-