Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changepacks/changepack_log_css_snapshot.json
Original file line number Diff line number Diff line change
@@ -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"
}
51 changes: 51 additions & 0 deletions packages/plugin-utils/src/import-graph.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions packages/plugin-utils/src/import-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>()
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
Expand Down
2 changes: 2 additions & 0 deletions packages/plugin-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export {
type ComputeFileReachOptions,
computeFileRoutes,
type ComputeFileRoutesOptions,
computeReachableFiles,
type ComputeReachableFilesOptions,
createModuleResolver,
type CreateModuleResolverOptions,
listSourceFiles,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
194 changes: 183 additions & 11 deletions packages/rsbuild-plugin/src/__tests__/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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()
Expand All @@ -291,11 +296,6 @@ describe('DevupUIRsbuildPlugin', () => {
expect.any(Function),
)

expect(
transform.mock.calls[0][1]({
code: ``,
}),
).toBe('')
codeExtractSpy.mockReturnValue(
createCodeExtractResult({
code: '<div></div>',
Expand Down Expand Up @@ -607,12 +607,14 @@ const App = () => <Box></Box>`,
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)
Expand Down Expand Up @@ -710,4 +712,174 @@ const App = () => <Box></Box>`,
expect((cfgArr.tools.rspack as unknown[]).length).toBe(2)
})
})

describe('stylesheets built too early', () => {
let getCssSpy: ReturnType<typeof spyOn>
let readFileSyncSpy: ReturnType<typeof spyOn>
let computeReachableFilesSpy: ReturnType<typeof spyOn>

afterEach(() => {
getCssSpy.mockRestore()
readFileSyncSpy.mockRestore()
computeReachableFilesSpy.mockRestore()
existsSyncSpy.mockReturnValue(false)
})

async function setup(options: Parameters<typeof DevupUI>[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<string, (...args: unknown[]) => 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)
})
})
})
Loading
Loading