From 71a59846aeaf0d8f297f3db687a8bddc07da0ffa Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 1 Oct 2026 02:44:05 +0900 Subject: [PATCH] fix(webpack-plugin,rsbuild-plugin): keep every extracted style in the built stylesheets A stylesheet module is built on its first import, and the shared base is read from disk past the plugin, so styles extracted later were missing from webpack and Rsbuild output; Rsbuild also served a module-wide string kept only when it grew. The files the entries reach are now extracted before the build, the base on disk is kept current, and a compilation whose stylesheets changed is run once more, writing only the complete result. Rsbuild serves each stylesheet from the current sheet. Refs #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .changepacks/changepack_log_css_snapshot.json | 9 + .../plugin-utils/src/import-graph.test.ts | 51 +++++ packages/plugin-utils/src/import-graph.ts | 37 ++++ packages/plugin-utils/src/index.ts | 2 + .../src/__tests__/checkout-isolation.test.ts | 2 + .../src/__tests__/plugin.test.ts | 194 +++++++++++++++++- packages/rsbuild-plugin/src/plugin.ts | 143 +++++++++++-- .../src/__tests__/css-loader.test.ts | 22 ++ .../src/__tests__/plugin.test.ts | 178 +++++++++++++--- packages/webpack-plugin/src/css-loader.ts | 10 +- packages/webpack-plugin/src/plugin.ts | 120 ++++++++--- packages/webpack-plugin/src/served-css.ts | 18 ++ 12 files changed, 709 insertions(+), 77 deletions(-) create mode 100644 .changepacks/changepack_log_css_snapshot.json create mode 100644 packages/webpack-plugin/src/served-css.ts diff --git a/.changepacks/changepack_log_css_snapshot.json b/.changepacks/changepack_log_css_snapshot.json new file mode 100644 index 000000000..a1c52eeec --- /dev/null +++ b/.changepacks/changepack_log_css_snapshot.json @@ -0,0 +1,9 @@ +{ + "changes": { + "packages/webpack-plugin/package.json": "Patch", + "packages/rsbuild-plugin/package.json": "Patch", + "packages/plugin-utils/package.json": "Patch" + }, + "note": "Webpack and Rsbuild builds hold every style in their stylesheets: a stylesheet module is built on its first import, so styles extracted later (the global styles of files webpack or Rsbuild reached late, often outside `src`) were missing from the output. The files the entries reach under `src` are now extracted before the build, the shared base on disk is kept current, and when a stylesheet still changed during the build it is compiled once more, writing only the complete result. Rsbuild serves each stylesheet from the current sheet instead of a module-wide string kept only when it grew, which left edits that shortened the CSS and later builds in the same process stale. plugin-utils adds computeReachableFiles", + "date": "2026-09-30T00:00:00.000Z" +} diff --git a/packages/plugin-utils/src/import-graph.test.ts b/packages/plugin-utils/src/import-graph.test.ts index cc15f2346..d99a4fca4 100644 --- a/packages/plugin-utils/src/import-graph.test.ts +++ b/packages/plugin-utils/src/import-graph.test.ts @@ -17,11 +17,62 @@ import { computeCompiledFiles, computeFileReach, computeFileRoutes, + computeReachableFiles, createModuleResolver, planAtomHoist, runImportGraphCli, } from './import-graph' +describe('computeReachableFiles', () => { + let tempRoot: string + let srcDir: string + + beforeEach(() => { + tempRoot = mkdtempSync(join(tmpdir(), 'devup-ui-reachable-files-')) + srcDir = join(tempRoot, 'src') + }) + + afterEach(() => { + rmSync(tempRoot, { recursive: true, force: true }) + }) + + function writeFixture(path: string, code: string): void { + const filePath = join(tempRoot, path) + mkdirSync(dirname(filePath), { recursive: true }) + writeFileSync(filePath, code) + } + + it('follows static and dynamic imports from the entries only', () => { + writeFixture('src/index.tsx', "import './a'\nimport('./lazy')\n") + writeFixture('src/a.tsx', "import './b'\n") + writeFixture('src/b.tsx', 'export const b = 1\n') + writeFixture('src/lazy.tsx', "import './b'\n") + writeFixture('src/unused.stories.tsx', "import './a'\n") + + expect( + computeReachableFiles({ srcDir, entries: [join(srcDir, 'index')] }), + ).toEqual( + ['a.tsx', 'b.tsx', 'index.tsx', 'lazy.tsx'].map((file) => + join(srcDir, file), + ), + ) + }) + + it('reaches nothing from entries outside the source directory', () => { + writeFixture('src/a.tsx', 'export const a = 1\n') + writeFixture('other/entry.tsx', "import '../src/a'\n") + const graph = buildStaticImportGraph(srcDir) + + expect( + computeReachableFiles({ + srcDir, + entries: [join(tempRoot, 'other/entry.tsx'), join(srcDir, 'missing')], + graph, + }), + ).toEqual([]) + }) +}) + describe('buildCanonicalMap', () => { let tempRoot: string let cwd: string diff --git a/packages/plugin-utils/src/import-graph.ts b/packages/plugin-utils/src/import-graph.ts index aa55a9bc8..b221bbc1e 100644 --- a/packages/plugin-utils/src/import-graph.ts +++ b/packages/plugin-utils/src/import-graph.ts @@ -336,6 +336,43 @@ export function computeCompiledFiles( return [...compiled].map((file) => toPosixRelative(cwd, file)).sort() } +export interface ComputeReachableFilesOptions { + srcDir: string + tsconfigPath?: string + /** The bundler's entry modules, as absolute paths with or without extension. */ + entries: string[] + /** pre-built graph from `buildStaticImportGraph` to skip the file scan. */ + graph?: StaticImportGraph +} + +/** + * The source files under `srcDir` a bundler compiles from `entries`: their + * closure over static and dynamic `import()` edges, as absolute paths in the + * graph's order. Extracting them before bundling fills the shared stylesheet + * without the styles of files no entry imports. + */ +export function computeReachableFiles( + opts: ComputeReachableFilesOptions, +): string[] { + const { files, fileSet, staticImports, dynamicImports } = + opts.graph ?? buildStaticImportGraph(opts.srcDir, opts.tsconfigPath) + const queue = opts.entries + .map((entry) => resolveFile(resolve(entry))) + .filter( + (entry): entry is string => entry !== undefined && fileSet.has(entry), + ) + const reached = new Set() + for (let index = 0; index < queue.length; index += 1) { + const file = queue[index] + if (reached.has(file)) continue + reached.add(file) + for (const imports of [staticImports, dynamicImports]) { + for (const target of imports.get(file) ?? []) queue.push(target) + } + } + return files.filter((file) => reached.has(file)) +} + export interface ComputeFileReachOptions { srcDir: string tsconfigPath?: string diff --git a/packages/plugin-utils/src/index.ts b/packages/plugin-utils/src/index.ts index b66b07abb..385a2dc2b 100644 --- a/packages/plugin-utils/src/index.ts +++ b/packages/plugin-utils/src/index.ts @@ -9,6 +9,8 @@ export { type ComputeFileReachOptions, computeFileRoutes, type ComputeFileRoutesOptions, + computeReachableFiles, + type ComputeReachableFilesOptions, createModuleResolver, type CreateModuleResolverOptions, listSourceFiles, diff --git a/packages/rsbuild-plugin/src/__tests__/checkout-isolation.test.ts b/packages/rsbuild-plugin/src/__tests__/checkout-isolation.test.ts index b57d9ef2a..7c61a4591 100644 --- a/packages/rsbuild-plugin/src/__tests__/checkout-isolation.test.ts +++ b/packages/rsbuild-plugin/src/__tests__/checkout-isolation.test.ts @@ -69,6 +69,8 @@ async function extractedCssDirIn(checkout: string) { await plugin.setup({ transform, modifyRsbuildConfig: mock(), + modifyRspackConfig: mock(), + onBeforeBuild: mock(), } as unknown as RsbuildSetupContext) codeExtractSpy.mockClear() diff --git a/packages/rsbuild-plugin/src/__tests__/plugin.test.ts b/packages/rsbuild-plugin/src/__tests__/plugin.test.ts index 3fa5b9fd4..1a4393a97 100644 --- a/packages/rsbuild-plugin/src/__tests__/plugin.test.ts +++ b/packages/rsbuild-plugin/src/__tests__/plugin.test.ts @@ -42,6 +42,9 @@ function createSetupContext( return { transform: mock(), modifyRsbuildConfig: mock(), + modifyRspackConfig: mock(), + onBeforeBuild: mock(), + context: { rootPath: process.cwd() }, renderChunk: mock(), generateBundle: mock(), closeBundle: mock(), @@ -261,15 +264,17 @@ describe('DevupUIRsbuildPlugin', () => { expect.any(Function), ) + const getCssSpy = spyOn(wasm, 'getCss').mockReturnValue('file css') expect( transform.mock.calls[0][1]({ - code: ` - .devup-ui-1 { - color: red; - } - `, + code: '/* placeholder */', + resourcePath: resolve('df', 'devup-ui', 'devup-ui-1.css'), + environment: { name: 'web' }, }), - ).toBe('') + ).toBe('file css') + // A file's stylesheet imports the shared base + expect(getCssSpy).toHaveBeenCalledWith(1, true) + getCssSpy.mockRestore() }) it('should transform code', async () => { const plugin = DevupUI() @@ -291,11 +296,6 @@ describe('DevupUIRsbuildPlugin', () => { expect.any(Function), ) - expect( - transform.mock.calls[0][1]({ - code: ``, - }), - ).toBe('') codeExtractSpy.mockReturnValue( createCodeExtractResult({ code: '
', @@ -607,12 +607,14 @@ const App = () => `, const servedChunk = transform.mock.calls[0][1]({ code: '', resourcePath: resolve('df', 'devup-ui', 'devup-ui-3.css'), + environment: { name: 'web' }, }) expect(servedChunk).toBe('CSS_FOR_3') expect(getCssSpy).toHaveBeenCalledWith(3, false) const servedBase = transform.mock.calls[0][1]({ code: '', resourcePath: resolve('df', 'devup-ui', 'devup-ui.css'), + environment: { name: 'web' }, }) expect(servedBase).toBe('CSS_FOR_null') expect(getCssSpy).toHaveBeenCalledWith(null, false) @@ -710,4 +712,174 @@ const App = () => `, expect((cfgArr.tools.rspack as unknown[]).length).toBe(2) }) }) + + describe('stylesheets built too early', () => { + let getCssSpy: ReturnType + let readFileSyncSpy: ReturnType + let computeReachableFilesSpy: ReturnType + + afterEach(() => { + getCssSpy.mockRestore() + readFileSyncSpy.mockRestore() + computeReachableFilesSpy.mockRestore() + existsSyncSpy.mockReturnValue(false) + }) + + async function setup(options: Parameters[0] = {}) { + getCssSpy = spyOn(wasm, 'getCss').mockReturnValue('before') + readFileSyncSpy = spyOn(fs, 'readFileSync').mockReturnValue('source') + computeReachableFilesSpy = spyOn( + pluginUtils, + 'computeReachableFiles', + ).mockReturnValue([resolve('src', 'App.tsx')]) + codeExtractSpy.mockReturnValue(createCodeExtractResult()) + const transform = mock() + const onBeforeBuild = mock() + const modifyRspackConfig = mock() + await DevupUI(options).setup( + createSetupContext({ transform, onBeforeBuild, modifyRspackConfig }), + ) + const config: { plugins?: { apply(compiler: unknown): void }[] } = {} + modifyRspackConfig.mock.calls[0][0](config, { + environment: { name: 'web' }, + }) + const taps: Record unknown> = {} + const tap = + (hook: string) => (_: unknown, fn: (...args: unknown[]) => unknown) => { + taps[hook] = fn + } + const compiler = { + watchMode: false, + rspack: { Compilation: { PROCESS_ASSETS_STAGE_REPORT: 5000 } }, + hooks: { + run: { tap: tap('run') }, + thisCompilation: { tap: tap('start') }, + }, + } + config.plugins![0]!.apply(compiler) + const compilation = { + assets: { 'index.js': {} }, + deleteAsset: mock(), + hooks: { + finishModules: { tap: tap('finishModules') }, + processAssets: { tap: tap('processAssets') }, + needAdditionalPass: { tap: tap('needAdditionalPass') }, + }, + } + const serve = (resourcePath: string) => + transform.mock.calls[0][1]({ + resourcePath, + environment: { name: 'web' }, + }) + return { onBeforeBuild, compiler, compilation, taps, serve } + } + + it('extracts the files the entries reach before building', async () => { + const { onBeforeBuild } = await setup({ atomHoist: undefined }) + codeExtractSpy.mockClear() + onBeforeBuild.mock.calls[0][0]({ + environments: { + web: { + entry: { + a: './src/a.tsx', + b: ['./src/b.tsx'], + c: { import: './src/c.tsx' }, + d: { import: ['./src/d.tsx'] }, + }, + }, + }, + }) + expect(computeReachableFilesSpy).toHaveBeenCalledWith({ + srcDir: resolve(process.cwd(), 'src'), + tsconfigPath: resolve(process.cwd(), 'tsconfig.json'), + entries: ['a', 'b', 'c', 'd'].map((name) => + resolve(process.cwd(), `./src/${name}.tsx`), + ), + }) + expect(codeExtractSpy).toHaveBeenCalledWith( + resolve('src', 'App.tsx'), + 'source', + '@devup-ui/react', + expect.stringMatching(/^\.\//), + false, + false, + true, + expect.anything(), + ) + + // an extraction error is reported by the transform of that file + codeExtractSpy.mockImplementation(() => { + throw new Error('boom') + }) + expect(() => + onBeforeBuild.mock.calls[0][0]({ environments: {} }), + ).not.toThrow() + }) + + it('extracts under posix names in atom mode', async () => { + const { onBeforeBuild } = await setup({ atomHoist: 2 }) + codeExtractSpy.mockClear() + onBeforeBuild.mock.calls[0][0]({ environments: {} }) + expect(codeExtractSpy.mock.calls[0]![0]).toBe( + resolve('src', 'App.tsx').replaceAll('\\', '/'), + ) + }) + + it('compiles once more, writing no file, when a stylesheet changed', async () => { + const { compiler, compilation, taps, serve } = await setup() + writeFileSyncSpy.mockClear() + taps.start!(compilation) + // the shared base is written first, as the CSS loaders read it from disk + expect(writeFileSyncSpy).toHaveBeenCalledWith( + resolve('df', 'devup-ui', 'devup-ui.css'), + 'before', + 'utf-8', + ) + serve(resolve('df', 'devup-ui', 'devup-ui-1.css')) + getCssSpy.mockReturnValue('after') + taps.finishModules!() + taps.processAssets!() + expect(compilation.deleteAsset).toHaveBeenCalledWith('index.js') + writeFileSyncSpy.mockClear() + expect(taps.needAdditionalPass!()).toBe(true) + expect(writeFileSyncSpy).toHaveBeenCalledWith( + resolve('df', 'devup-ui', 'devup-ui-1.css'), + 'after', + 'utf-8', + ) + expect(writeFileSyncSpy).toHaveBeenCalledWith( + resolve('df', 'devup-ui', 'devup-ui.css'), + 'after', + 'utf-8', + ) + + // one more pass per run at most + compilation.deleteAsset.mockClear() + taps.processAssets!() + expect(compilation.deleteAsset).not.toHaveBeenCalled() + expect(taps.needAdditionalPass!()).toBe(false) + taps.run!() + expect(taps.needAdditionalPass!()).toBe(true) + + // the dev server rebuilds through the files the transforms write + compiler.watchMode = true + taps.start!(compilation) + taps.finishModules!() + expect(taps.needAdditionalPass!()).toBe(false) + }) + + it('keeps the pass when every stylesheet is current', async () => { + const { compilation, taps, serve } = await setup() + existsSyncSpy.mockReturnValue(true) + readFileSyncSpy.mockReturnValue('before') + writeFileSyncSpy.mockClear() + taps.start!(compilation) + expect(writeFileSyncSpy).not.toHaveBeenCalled() + serve(resolve('df', 'devup-ui', 'devup-ui-1.css')) + taps.finishModules!() + taps.processAssets!() + expect(compilation.deleteAsset).not.toHaveBeenCalled() + expect(taps.needAdditionalPass!()).toBe(false) + }) + }) }) diff --git a/packages/rsbuild-plugin/src/plugin.ts b/packages/rsbuild-plugin/src/plugin.ts index 990a4cd2a..47d4f2f3f 100644 --- a/packages/rsbuild-plugin/src/plugin.ts +++ b/packages/rsbuild-plugin/src/plugin.ts @@ -1,10 +1,11 @@ -import { existsSync } from 'node:fs' +import { existsSync, readFileSync, writeFileSync } from 'node:fs' import { mkdir, writeFile } from 'node:fs/promises' import { basename, dirname, join, relative, resolve } from 'node:path' import { buildCanonicalMap, computeFileReach, + computeReachableFiles, createCompatTypes, createModuleResolver, createNodeModulesExcludeRegex, @@ -30,7 +31,9 @@ import { setModuleResolver, setPrefix, } from '@devup-ui/wasm' -import type { RsbuildPlugin } from '@rsbuild/core' +import type { RsbuildPlugin, Rspack } from '@rsbuild/core' + +const PLUGIN_NAME = 'devup-ui-rsbuild-plugin' export interface DevupUIRsbuildPluginOptions { package: string @@ -65,8 +68,6 @@ export interface DevupUIRsbuildPluginOptions { importAliases?: ImportAliases } -let globalCss = '' - async function writeDataFiles( options: Omit< DevupUIRsbuildPluginOptions, @@ -103,6 +104,16 @@ async function writeDataFiles( ]) } +/** + * Write `css` to the stylesheet file at `path` unless it holds it already. The + * CSS loaders read the shared base from disk when a file's stylesheet imports + * it, so it must hold every style extracted so far. + */ +function writeChanged(path: string, css: string) { + if (!existsSync(path) || readFileSync(path, 'utf-8') !== css) + writeFileSync(path, css, 'utf-8') +} + export const DevupUI = ({ include = [], package: libPackage = '@devup-ui/react', @@ -121,7 +132,7 @@ export const DevupUI = ({ const importAliases = mergeImportAliases(userImportAliases) return { - name: 'devup-ui-rsbuild-plugin', + name: PLUGIN_NAME, async setup(api) { setDebug(debug) if (prefix) { @@ -190,23 +201,123 @@ export const DevupUI = ({ } } + // Extract the source files under `src` that the entries reach, in path + // order, the same way the transform does, so that a stylesheet built on + // its first import already holds the styles of every one. Best-effort: + // a stylesheet still missing styles is rebuilt by another pass. + api.onBeforeBuild(({ environments }) => { + try { + const root = api.context.rootPath + const entries = Object.values(environments).flatMap(({ entry }) => + Object.values(entry).flatMap((value) => + (typeof value === 'object' && !Array.isArray(value) + ? [value.import].flat() + : [value].flat() + ).map((request) => resolve(root, request)), + ), + ) + for (const file of computeReachableFiles({ + srcDir: resolve(root, 'src'), + tsconfigPath: resolve(root, 'tsconfig.json'), + entries, + })) { + let extractCssDir = relative(dirname(file), cssDir).replaceAll( + '\\', + '/', + ) + if (!extractCssDir.startsWith('./')) + extractCssDir = `./${extractCssDir}` + codeExtract( + atomMode ? file.replaceAll('\\', '/') : file, + readFileSync(file, 'utf-8'), + libPackage, + extractCssDir, + singleCss, + atomMode, + !atomMode, + importAliases, + ) + } + } catch { + // The transform reports the error of the file it cannot extract + } + }) + + const servedCss = new Map>() + const stylesheet = (resourcePath: string) => + // A file's stylesheet imports the shared base, except in atom mode, + // where the entry code imports the base itself so that hoisted atoms + // load once (the injected splitChunks cacheGroup, see + // modifyRsbuildConfig, emits the base once) + getCss(getFileNumByFilename(basename(resourcePath)), !atomMode) + api.transform( { test: cssDir, }, - ({ resourcePath }) => { - // Non-atom: keep the existing single-string behavior (no regression). - if (!atomMode) return globalCss - // Atom mode: serve the route-specific chunk and have it @import the - // shared base (devup-ui.css) so hoisted atoms load ONCE and are not - // inlined per chunk. The base file itself imports nothing. - // Route chunk and base are SEPARATE modules (the transformed entry - // code imports both via import_main_css); the injected splitChunks - // cacheGroup (see modifyRsbuildConfig) emits the base once. - return getCss(getFileNumByFilename(basename(resourcePath)), false) + ({ resourcePath, environment }) => { + const css = stylesheet(resourcePath) + servedCss.get(environment.name)?.set(resourcePath, css) + return css }, ) + // A stylesheet module is built on its first import, which can come before + // the modules whose styles it holds are extracted. When one was, compile + // once more: every module is extracted by then. The dev server rebuilds it + // through the stylesheet files the transforms write instead. + api.modifyRspackConfig((config, { environment }) => { + config.plugins ??= [] + config.plugins.push({ + apply(compiler: Rspack.Compiler) { + let passes = 0 + compiler.hooks.run.tap(PLUGIN_NAME, () => { + passes = 0 + }) + compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation) => { + const served = new Map() + servedCss.set(environment.name, served) + const basePath = join(cssDir, 'devup-ui.css') + const base = stylesheet(basePath) + writeChanged(basePath, base) + let stale: string[] = [] + compilation.hooks.finishModules.tap(PLUGIN_NAME, () => { + if (compiler.watchMode) return + const changed = new Set( + [...served] + .filter(([path, css]) => stylesheet(path) !== css) + .map(([path]) => path), + ) + // A file's stylesheet `@import`s the shared base, which the CSS + // loaders read from disk without passing through this plugin + if (stylesheet(basePath) !== base) changed.add(basePath) + stale = [...changed] + }) + // The next pass writes the build; this one writes none of its files + compilation.hooks.processAssets.tap( + { + name: PLUGIN_NAME, + stage: + compiler.rspack.Compilation.PROCESS_ASSETS_STAGE_REPORT, + }, + () => { + if (stale.length === 0 || passes > 0) return + for (const name of Object.keys(compilation.assets)) + compilation.deleteAsset(name) + }, + ) + compilation.hooks.needAdditionalPass.tap(PLUGIN_NAME, () => { + if (stale.length === 0 || passes > 0) return false + passes += 1 + for (const path of stale) + writeFileSync(path, stylesheet(path), 'utf-8') + return true + }) + }) + }, + }) + }) + api.modifyRsbuildConfig((config) => { const theme = getDefaultTheme() if (theme) { @@ -283,7 +394,6 @@ export const DevupUI = ({ : resourcePath const { code: retCode, - css = '', map, cssFile, updatedBaseStyle, @@ -312,7 +422,6 @@ export const DevupUI = ({ } if (cssFile) { - if (globalCss.length < css.length) globalCss = css promises.push( writeFile( join(cssDir, basename(cssFile)), diff --git a/packages/webpack-plugin/src/__tests__/css-loader.test.ts b/packages/webpack-plugin/src/__tests__/css-loader.test.ts index 5e5f80fee..cff741693 100644 --- a/packages/webpack-plugin/src/__tests__/css-loader.test.ts +++ b/packages/webpack-plugin/src/__tests__/css-loader.test.ts @@ -12,6 +12,7 @@ import { } from 'bun:test' import devupUICssLoader from '../css-loader' +import { servedCss } from '../served-css' type CodeExtractResult = ReturnType type CssLoaderThis = ThisParameterType @@ -34,12 +35,15 @@ function createCodeExtractResult( function createCssLoaderContext( resourcePath: string, callback: ReturnType, + compilation?: object, ): CssLoaderThis { return { callback, + cacheable: mock(), addContextDependency: mock(), resourcePath, getOptions: () => ({ watch: resourcePath.includes('devup-ui') }), + _compilation: compilation, } as unknown as CssLoaderThis } @@ -108,4 +112,22 @@ describe('devupUICssLoader', () => { expect(getCssSpy).toBeCalledTimes(1) }) + + it('records the stylesheet it serves in its compilation, uncached', () => { + const callback = mock() + const compilation = {} + getCssSpy.mockReturnValue('base css') + const context = createCssLoaderContext( + '/df/devup-ui/devup-ui.css', + callback, + compilation, + ) + devupUICssLoader.bind(context)(Buffer.from(''), '') + + expect(context.cacheable).toHaveBeenCalledWith(false) + expect(servedCss(compilation)).toEqual( + new Map([['/df/devup-ui/devup-ui.css', 'base css']]), + ) + expect(servedCss(compilation)).toBe(servedCss(compilation)) + }) }) diff --git a/packages/webpack-plugin/src/__tests__/plugin.test.ts b/packages/webpack-plugin/src/__tests__/plugin.test.ts index 2692ead30..524e755bd 100644 --- a/packages/webpack-plugin/src/__tests__/plugin.test.ts +++ b/packages/webpack-plugin/src/__tests__/plugin.test.ts @@ -17,19 +17,28 @@ import { import type { Compiler } from 'webpack' import { DevupUIWebpackPlugin } from '../plugin' +import { servedCss } from '../served-css' type CodeExtractResult = ReturnType interface MockCompiler { options: { module: { rules: unknown[] } plugins: unknown[] + entry?: unknown + context?: string + } + watchMode?: boolean + webpack: { + DefinePlugin: ReturnType + Compilation: { PROCESS_ASSETS_STAGE_REPORT: number } } - webpack: { DefinePlugin: ReturnType } hooks: { watchRun: { tapPromise: ReturnType } beforeRun: { tapPromise: ReturnType } done: { tapPromise: ReturnType } afterCompile: { tap: ReturnType } + run: { tap: ReturnType } + thisCompilation: { tap: ReturnType } } } @@ -136,6 +145,7 @@ function createCompiler(): MockCompiler { }, webpack: { DefinePlugin: mock(), + Compilation: { PROCESS_ASSETS_STAGE_REPORT: 5000 }, }, hooks: { watchRun: { @@ -150,6 +160,12 @@ function createCompiler(): MockCompiler { afterCompile: { tap: mock(), }, + run: { + tap: mock(), + }, + thisCompilation: { + tap: mock(), + }, }, } } @@ -459,7 +475,7 @@ describe('devupUIWebpackPlugin', () => { let importCanonicalMapSpy: ReturnType let importFileRoutesSpy: ReturnType let setAtomHoistSpy: ReturnType - let listSourceFilesSpy: ReturnType + let computeReachableFilesSpy: ReturnType beforeEach(() => { buildCanonicalMapSpy = spyOn( @@ -478,9 +494,9 @@ describe('devupUIWebpackPlugin', () => { ) setAtomHoistSpy = spyOn(wasm, 'setAtomHoist').mockReturnValue(undefined) // Default: no source files so pre-warm is a no-op unless a test opts in. - listSourceFilesSpy = spyOn( + computeReachableFilesSpy = spyOn( pluginUtils, - 'listSourceFiles', + 'computeReachableFiles', ).mockReturnValue([]) }) @@ -490,7 +506,7 @@ describe('devupUIWebpackPlugin', () => { importCanonicalMapSpy.mockRestore() importFileRoutesSpy.mockRestore() setAtomHoistSpy.mockRestore() - listSourceFilesSpy.mockRestore() + computeReachableFilesSpy.mockRestore() }) it('runs single-importer collapse even when atomHoist is unset (always-on), without hoisting', () => { @@ -511,15 +527,15 @@ describe('devupUIWebpackPlugin', () => { expect(importFileRoutesSpy).not.toHaveBeenCalled() }) - it('pre-warms the extractor over all source files in build mode when collapse is active', () => { - // Without pre-warm, webpack builds each shared devup-ui-N.css ONCE at - // first import — before later bucket members are extracted — so their - // atoms are dropped. Pre-warming the sheet makes getCss(N) complete from - // the first css-loader call. + it('pre-warms the extractor over the files the entries reach in build mode', () => { + // Without pre-warm, webpack builds each shared stylesheet ONCE at first + // import — before later files are extracted — so their styles are + // dropped. Pre-warming the sheet makes getCss complete from the first + // css-loader call. buildCanonicalMapSpy.mockReturnValue({ 'src/child.tsx': 'src/parent.tsx', }) - listSourceFilesSpy.mockReturnValue([ + computeReachableFilesSpy.mockReturnValue([ resolve(process.cwd(), 'src', 'parent.tsx'), resolve(process.cwd(), 'src', 'child.tsx'), ]) @@ -532,8 +548,21 @@ describe('devupUIWebpackPlugin', () => { package: '@devup-ui/react', singleCss: true, }) - plugin.apply(asCompiler(createCompiler())) - expect(listSourceFilesSpy).toHaveBeenCalled() + const compiler = createCompiler() + compiler.options.entry = { + main: { import: ['./src/parent', './src/other'] }, + worker: {}, + } + compiler.options.context = '/project' + plugin.apply(asCompiler(compiler)) + expect(computeReachableFilesSpy).toHaveBeenCalledWith({ + srcDir: resolve(process.cwd(), 'src'), + tsconfigPath: resolve(process.cwd(), 'tsconfig.json'), + entries: [ + resolve('/project', './src/parent'), + resolve('/project', './src/other'), + ], + }) // The loader's resolver is registered before the first extraction, so // imported constants inline as they do in the loader expect(setModuleResolverSpy.mock.invocationCallOrder[0]).toBeLessThan( @@ -570,22 +599,21 @@ describe('devupUIWebpackPlugin', () => { ) }) - it('skips pre-warm when the canonical map is empty (no collapse, no race)', () => { + it('pre-warms without collapse, from no entries when they are a function', () => { buildCanonicalMapSpy.mockReturnValue({}) - listSourceFilesSpy.mockReturnValue([ - resolve(process.cwd(), 'src', 'a.tsx'), - ]) - readFileSyncSpy.mockReturnValue('source') - const plugin = new DevupUIWebpackPlugin({}) - plugin.apply(asCompiler(createCompiler())) - expect(codeExtractSpy).not.toHaveBeenCalled() + const compiler = createCompiler() + compiler.options.entry = () => ({}) + new DevupUIWebpackPlugin({}).apply(asCompiler(compiler)) + expect(computeReachableFilesSpy).toHaveBeenCalledWith( + expect.objectContaining({ entries: [] }), + ) }) it('skips pre-warm in watch mode (race only affects one-shot builds)', () => { buildCanonicalMapSpy.mockReturnValue({ 'src/child.tsx': 'src/parent.tsx', }) - listSourceFilesSpy.mockReturnValue([ + computeReachableFilesSpy.mockReturnValue([ resolve(process.cwd(), 'src', 'parent.tsx'), ]) readFileSyncSpy.mockReturnValue('source') @@ -598,7 +626,7 @@ describe('devupUIWebpackPlugin', () => { buildCanonicalMapSpy.mockReturnValue({ 'src/child.tsx': 'src/parent.tsx', }) - listSourceFilesSpy.mockReturnValue([ + computeReachableFilesSpy.mockReturnValue([ resolve(process.cwd(), 'src', 'parent.tsx'), ]) readFileSyncSpy.mockReturnValue('source') @@ -682,4 +710,108 @@ describe('devupUIWebpackPlugin', () => { expect(compiler.options.module.rules.length).toBeGreaterThan(0) }) }) + + describe('another pass for stylesheets built too early', () => { + function compile(watch = false) { + const compiler = createCompiler() + const plugin = new DevupUIWebpackPlugin({ watch }) + plugin.apply(asCompiler(compiler)) + const taps: Record unknown> = {} + const tap = + (hook: string) => (_: unknown, fn: (...args: unknown[]) => unknown) => { + taps[hook] = fn + } + const compilation = { + assets: { 'main.js': {}, 'main.css': {} }, + deleteAsset: mock(), + hooks: { + finishModules: { tap: tap('finishModules') }, + processAssets: { tap: tap('processAssets') }, + needAdditionalPass: { tap: tap('needAdditionalPass') }, + }, + } + const run = compiler.hooks.run.tap.mock.calls[0]?.[1] as () => void + const start = () => { + ;( + compiler.hooks.thisCompilation.tap.mock.calls[0]![1] as ( + compilation: object, + ) => void + )(compilation) + } + return { compiler, plugin, compilation, taps, run, start } + } + + it('taps nothing in watch mode', () => { + const { compiler } = compile(true) + expect(compiler.hooks.run.tap).not.toHaveBeenCalled() + expect(compiler.hooks.thisCompilation.tap).not.toHaveBeenCalled() + }) + + it('writes the shared base to disk when it does not hold it yet', () => { + getCssSpy.mockReturnValue('base') + existsSyncSpy.mockReturnValue(true) + readFileSyncSpy.mockReturnValue('base') + const { plugin, start } = compile() + writeFileSyncSpy.mockClear() + start() + expect(writeFileSyncSpy).not.toHaveBeenCalled() + + readFileSyncSpy.mockReturnValue('older') + start() + expect(writeFileSyncSpy).toHaveBeenCalledWith( + join(plugin.options.cssDir, 'devup-ui.css'), + 'base', + 'utf-8', + ) + }) + + it('compiles once more, writing no file, when a stylesheet changed', () => { + getCssSpy.mockReturnValue('before') + const { compiler, compilation, plugin, taps, run, start } = compile() + start() + servedCss(compilation).set('/df/devup-ui/devup-ui-1.css', 'before') + getCssSpy.mockReturnValue('after') + taps.finishModules!() + taps.processAssets!() + expect(compilation.deleteAsset).toHaveBeenCalledWith('main.js') + expect(compilation.deleteAsset).toHaveBeenCalledWith('main.css') + writeFileSyncSpy.mockClear() + expect(taps.needAdditionalPass!()).toBe(true) + expect(writeFileSyncSpy).toHaveBeenCalledWith( + '/df/devup-ui/devup-ui-1.css', + 'after', + 'utf-8', + ) + expect(writeFileSyncSpy).toHaveBeenCalledWith( + join(plugin.options.cssDir, 'devup-ui.css'), + 'after', + 'utf-8', + ) + + // one more pass per run at most + compilation.deleteAsset.mockClear() + taps.processAssets!() + expect(compilation.deleteAsset).not.toHaveBeenCalled() + expect(taps.needAdditionalPass!()).toBeUndefined() + run() + expect(taps.needAdditionalPass!()).toBe(true) + + // a watching compiler rebuilds through the files the loaders write + compiler.watchMode = true + start() + taps.finishModules!() + expect(taps.needAdditionalPass!()).toBeUndefined() + }) + + it('keeps the pass when every stylesheet is current', () => { + getCssSpy.mockReturnValue('same') + const { compilation, taps, start } = compile() + start() + servedCss(compilation).set('/df/devup-ui/devup-ui-1.css', 'same') + taps.finishModules!() + taps.processAssets!() + expect(compilation.deleteAsset).not.toHaveBeenCalled() + expect(taps.needAdditionalPass!()).toBeUndefined() + }) + }) }) diff --git a/packages/webpack-plugin/src/css-loader.ts b/packages/webpack-plugin/src/css-loader.ts index bc6344070..450d9e671 100644 --- a/packages/webpack-plugin/src/css-loader.ts +++ b/packages/webpack-plugin/src/css-loader.ts @@ -2,8 +2,14 @@ import { getFileNumByFilename } from '@devup-ui/plugin-utils' import { getCss } from '@devup-ui/wasm' import type { RawLoaderDefinitionFunction } from 'webpack' +import { servedCss } from './served-css' + const devupUICssLoader: RawLoaderDefinitionFunction = function (_, map, meta) { - const fileNum = getFileNumByFilename(this.resourcePath) - this.callback(null, getCss(fileNum, true), map, meta) + // The stylesheet comes from every extracted module, not from this file + this.cacheable(false) + const css = getCss(getFileNumByFilename(this.resourcePath), true) + if (this._compilation) + servedCss(this._compilation).set(this.resourcePath, css) + this.callback(null, css, map, meta) } export default devupUICssLoader diff --git a/packages/webpack-plugin/src/plugin.ts b/packages/webpack-plugin/src/plugin.ts index 54d0713a9..83b2c2264 100644 --- a/packages/webpack-plugin/src/plugin.ts +++ b/packages/webpack-plugin/src/plugin.ts @@ -6,13 +6,14 @@ import { dirname, join, relative, resolve } from 'node:path' import { buildCanonicalMap, computeFileReach, + computeReachableFiles, createCompatTypes, createModuleResolver, createNodeModulesExcludeRegex, createThemeInterfaceArgs, type CustomShorthands, + getFileNumByFilename, type ImportAliases, - listSourceFiles, loadDevupConfigSync, mergeImportAliases, planAtomHoist, @@ -37,6 +38,8 @@ import { } from '@devup-ui/wasm' import { type Compiler } from 'webpack' +import { servedCss } from './served-css' + export interface DevupUIWebpackPluginOptions { package: string cssDir: string @@ -144,14 +147,16 @@ export class DevupUIWebpackPlugin { } /** - * Extract every source file under `src` into the shared WASM sheet so that a - * later `getCss(fileNum)` call returns the COMPLETE bucket (all collapsed - * members), not just the first member webpack happened to build. Mirrors the - * loader's `codeExtract` call (same filename keying + options) so re-extraction - * during compilation is idempotent. Best-effort: extraction errors are - * swallowed so a single bad file never breaks the build. + * Extract the source files under `src` that `entries` reach into the shared + * WASM sheet, in path order, so that a stylesheet built on its first import + * holds the styles of every one (all collapsed members of a bucket, and the + * shared base), not just those of the modules webpack happened to build + * first. Mirrors the loader's `codeExtract` call (same filename keying + + * options) so re-extraction during compilation is idempotent. Best-effort: + * extraction errors are swallowed so a single bad file never breaks the + * build, and a stylesheet still missing styles is rebuilt by another pass. */ - private prewarmExtractor() { + private prewarmExtractor(entries: string[]) { try { const cwd = process.cwd() // The same resolver as the loader's, so imported constants and @@ -161,8 +166,11 @@ export class DevupUIWebpackPlugin { toId: (path) => relative(cwd, path).replaceAll('\\', '/'), }), ) - const srcDir = resolve(cwd, 'src') - for (const file of listSourceFiles(srcDir)) { + for (const file of computeReachableFiles({ + srcDir: resolve(cwd, 'src'), + tsconfigPath: resolve(cwd, 'tsconfig.json'), + entries, + })) { const relativePath = relative(cwd, file).replaceAll('\\', '/') let relCssDir = relative(dirname(file), this.options.cssDir).replaceAll( '\\', @@ -234,12 +242,11 @@ export class DevupUIWebpackPlugin { // merge into that importer's bucket, deduplicating their identical atoms. // The canonical map is built + imported unconditionally; only atom HOISTING // composes on top when `atomHoist` is set. Mirrors next-plugin's pre-pass. - let canonicalMap: Record = {} try { const srcDir = resolve(process.cwd(), 'src') const tsconfigPath = resolve(process.cwd(), 'tsconfig.json') const cwd = process.cwd() - canonicalMap = buildCanonicalMap({ + const canonicalMap = buildCanonicalMap({ srcDir, tsconfigPath, cwd, @@ -269,19 +276,24 @@ export class DevupUIWebpackPlugin { // hoisting stays off. } - // Pre-warm the extractor so the css-loader serves COMPLETE bucket CSS. + // Pre-warm the extractor so the css-loader serves COMPLETE CSS. // - // Under collapse, several source files share ONE devup-ui-N.css (the - // importer's bucket). The css-loader serves `getCss(N, true)`, but webpack - // builds that shared .css module ONCE — at the FIRST import resolution, - // before the bucket's other members have been extracted — so their atoms - // would be dropped. Turbopack avoids this via its idle coordinator; webpack - // has no such re-serve, so we extract every source file up front (single - // shared WASM instance) to populate the bucket fully BEFORE any css-loader - // runs. Re-extraction by the per-file loader is then idempotent (set-based - // atom dedup). Only needed for one-shot builds when collapse is active. - if (!this.options.watch && Object.keys(canonicalMap).length > 0) { - this.prewarmExtractor() + // Webpack builds a stylesheet module ONCE, at its FIRST import: the shared + // base (global styles of every file) and, under collapse, a bucket's + // devup-ui-N.css shared by several source files. Files extracted after that + // would be missing, so the files the entries reach are extracted up front + // (single shared WASM instance). Re-extraction by the per-file loader is + // then idempotent (set-based atom dedup). Watch mode rebuilds stylesheets + // through the files the loaders write instead. + if (!this.options.watch) { + const { entry, context = process.cwd() } = compiler.options + this.prewarmExtractor( + typeof entry === 'function' + ? [] + : Object.values(entry ?? {}).flatMap(({ import: requests = [] }) => + requests.map((request) => resolve(context, request)), + ), + ) } if (this.options.watch) { @@ -309,6 +321,66 @@ export class DevupUIWebpackPlugin { }), ) if (!this.options.watch) { + // A stylesheet module is built on its first import, which can come before + // the modules whose styles it holds are extracted. When one was, compile + // once more: every module is extracted by then. Watch mode rebuilds it + // through the stylesheet files the loaders write instead. + let passes = 0 + compiler.hooks.run.tap('DevupUIWebpackPlugin', () => { + passes = 0 + }) + compiler.hooks.thisCompilation.tap( + 'DevupUIWebpackPlugin', + (compilation) => { + const basePath = join(this.options.cssDir, 'devup-ui.css') + const base = getCss(null, true) + // A file's stylesheet `@import`s the shared base, which the CSS + // loaders read from disk without passing through this plugin, so it + // must hold every style extracted so far + if (!existsSync(basePath) || readFileSync(basePath, 'utf-8') !== base) + writeFileSync(basePath, base, 'utf-8') + let stale: string[] = [] + compilation.hooks.finishModules.tap('DevupUIWebpackPlugin', () => { + if (compiler.watchMode) return + const changed = new Set( + [...servedCss(compilation)] + .filter( + ([path, css]) => + getCss(getFileNumByFilename(path), true) !== css, + ) + .map(([path]) => path), + ) + if (getCss(null, true) !== base) changed.add(basePath) + stale = [...changed] + }) + // The next pass writes the build; this one writes none of its files + compilation.hooks.processAssets.tap( + { + name: 'DevupUIWebpackPlugin', + stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_REPORT, + }, + () => { + if (stale.length === 0 || passes > 0) return + for (const name of Object.keys(compilation.assets)) + compilation.deleteAsset(name) + }, + ) + compilation.hooks.needAdditionalPass.tap( + 'DevupUIWebpackPlugin', + () => { + if (stale.length === 0 || passes > 0) return undefined + passes += 1 + for (const path of stale) + writeFileSync( + path, + getCss(getFileNumByFilename(path), true), + 'utf-8', + ) + return true + }, + ) + }, + ) compiler.hooks.done.tapPromise('DevupUIWebpackPlugin', async (stats) => { if (!stats.hasErrors()) { // write css file diff --git a/packages/webpack-plugin/src/served-css.ts b/packages/webpack-plugin/src/served-css.ts new file mode 100644 index 000000000..c04ca03fd --- /dev/null +++ b/packages/webpack-plugin/src/served-css.ts @@ -0,0 +1,18 @@ +declare global { + var __devupUiServedCss: WeakMap> | undefined +} + +/** + * The stylesheet each devup-ui CSS module of `compilation` was built with, by + * resource path. The plugin and its loaders load as separate modules, so the + * record lives on `globalThis`. + */ +export function servedCss(compilation: object): Map { + globalThis.__devupUiServedCss ??= new WeakMap() + let served = globalThis.__devupUiServedCss.get(compilation) + if (!served) { + served = new Map() + globalThis.__devupUiServedCss.set(compilation, served) + } + return served +}