From b1dc94650b343eb8c8c6606a6b999521157d6bd2 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 16 Oct 2026 05:53:32 +0900 Subject: [PATCH 1/2] fix(next): reconstruct complete webpack styles after compiler resets Refs #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- ...ngepack_log_next_webpack_css_delivery.json | 9 + .../next-plugin/src/__tests__/plugin.test.ts | 3 + .../next-plugin/src/__tests__/prewarm.test.ts | 46 ++++ packages/next-plugin/src/plugin.ts | 22 ++ packages/next-plugin/src/prewarm.ts | 57 +++-- packages/plugin-utils/src/import-graph.ts | 5 +- packages/plugin-utils/src/index.ts | 1 + .../src/__tests__/plugin.test.ts | 88 +++++++ .../src/__tests__/production-prewarm.mjs | 231 ++++++++++++++++++ .../src/__tests__/production-prewarm.test.js | 46 ++++ packages/webpack-plugin/src/plugin.ts | 73 +++++- 11 files changed, 543 insertions(+), 38 deletions(-) create mode 100644 .changepacks/changepack_log_next_webpack_css_delivery.json create mode 100644 packages/webpack-plugin/src/__tests__/production-prewarm.mjs create mode 100644 packages/webpack-plugin/src/__tests__/production-prewarm.test.js diff --git a/.changepacks/changepack_log_next_webpack_css_delivery.json b/.changepacks/changepack_log_next_webpack_css_delivery.json new file mode 100644 index 000000000..44c7353c0 --- /dev/null +++ b/.changepacks/changepack_log_next_webpack_css_delivery.json @@ -0,0 +1,9 @@ +{ + "changes": { + "packages/next-plugin/package.json": "Patch", + "packages/webpack-plugin/package.json": "Patch", + "packages/plugin-utils/package.json": "Patch" + }, + "note": "Reconstruct the complete deterministic Next webpack stylesheet after each server, edge and client compiler reset, including accepted external package dependency closures, so delivered CSS matches rendered classes in per-file and single-CSS modes. Genuine production prewarm collection, read and extraction failures now fail the build instead of silently dropping styles; failed initialization releases its build session without leaking styles into unrelated builds.", + "date": "2026-10-16T00:00:00.000Z" +} diff --git a/packages/next-plugin/src/__tests__/plugin.test.ts b/packages/next-plugin/src/__tests__/plugin.test.ts index 55783c0c0..98f5eb37e 100644 --- a/packages/next-plugin/src/__tests__/plugin.test.ts +++ b/packages/next-plugin/src/__tests__/plugin.test.ts @@ -195,6 +195,7 @@ describe('DevupUINextPlugin', () => { expect(devupUIWebpackPluginSpy).toHaveBeenCalledWith({ cssDir: resolve('.next/cache', 'devup-ui_tmpBuildId'), + productionPrewarmFiles: [], }) }) @@ -222,6 +223,7 @@ describe('DevupUINextPlugin', () => { expect(devupUIWebpackPluginSpy).toHaveBeenCalledWith({ package: 'new-package', cssDir: resolve('.next/cache', 'devup-ui_tmpBuildId'), + productionPrewarmFiles: [], }) }) @@ -241,6 +243,7 @@ describe('DevupUINextPlugin', () => { expect(devupUIWebpackPluginSpy).toHaveBeenCalledWith({ package: 'new-package', cssDir: resolve('.next/cache', 'devup-ui_tmpBuildId'), + productionPrewarmFiles: [], }) expect(webpack).toHaveBeenCalled() }) diff --git a/packages/next-plugin/src/__tests__/prewarm.test.ts b/packages/next-plugin/src/__tests__/prewarm.test.ts index 9486a1a7c..1c2a5da48 100644 --- a/packages/next-plugin/src/__tests__/prewarm.test.ts +++ b/packages/next-plugin/src/__tests__/prewarm.test.ts @@ -119,4 +119,50 @@ describe('collectProductionPrewarmFiles', () => { }), ).toEqual(['src/app/page.tsx']) }) + + it('follows accepted barrels, relative cycles, lazy modules and importer-local accepted dependencies', () => { + // Given a package barrel with a nested dependency and a rejected sibling. + writePackage('design-system', './index.cjs', { + 'index.cjs': '', + 'index.mjs': 'export * from "./child.js"; import("./lazy.js")', + 'child.js': + 'export * from "./index.mjs"; import "@devup-ui/nested"; import "rejected"', + 'lazy.js': 'export const lazy = 1', + 'node_modules/@devup-ui/nested/package.json': '{"main":"index.js"}', + 'node_modules/@devup-ui/nested/index.js': 'export const nested = 1', + 'node_modules/rejected/package.json': '{"main":"index.js"}', + 'node_modules/rejected/index.js': 'export const rejected = 1', + }) + // When collecting the complete extraction set. + const files = collectProductionPrewarmFiles({ + cwd, + graph: makeGraph([], ['design-system']), + expectedBaseFiles: [], + libPackage: '@devup-ui/react', + include: ['design-system'], + }) + // Then the closure is complete, deduplicated and policy-filtered. + expect(files).toEqual([ + 'node_modules/design-system/child.js', + 'node_modules/design-system/index.mjs', + 'node_modules/design-system/lazy.js', + 'node_modules/design-system/node_modules/@devup-ui/nested/index.js', + ]) + }) + + it('propagates a genuine accepted-package collection error', () => { + // Given a malformed accepted package manifest. + writePackage('design-system', './index.js', { 'index.js': '' }) + writeFileSync(join(cwd, 'node_modules/design-system/package.json'), '{') + // When collecting it, then the build cannot silently omit its styles. + expect(() => + collectProductionPrewarmFiles({ + cwd, + graph: makeGraph([], ['design-system']), + expectedBaseFiles: [], + libPackage: '@devup-ui/react', + include: ['design-system'], + }), + ).toThrow() + }) }) diff --git a/packages/next-plugin/src/plugin.ts b/packages/next-plugin/src/plugin.ts index 69749e0d5..2b513ccb1 100644 --- a/packages/next-plugin/src/plugin.ts +++ b/packages/next-plugin/src/plugin.ts @@ -643,16 +643,38 @@ export function DevupUI( } const { webpack } = config + let productionPrewarmFiles: readonly string[] | undefined config.webpack = (config, _options) => { const { DevupUIWebpackPlugin } = loadWebpackPlugin() options.cssDir ??= resolve( _options.dev ? (options.distDir ?? 'df') : '.next/cache', `devup-ui_${_options.buildId}`, ) + if (!_options.dev && productionPrewarmFiles === undefined) { + const cwd = process.cwd() + const srcDir = resolve(cwd, 'src') + const tsconfigPath = resolve(cwd, 'tsconfig.json') + const graph = buildStaticImportGraph(srcDir, tsconfigPath) + productionPrewarmFiles = collectProductionPrewarmFiles({ + cwd, + graph, + expectedBaseFiles: computeCompiledFiles({ + cwd, + srcDir, + tsconfigPath, + graph, + }), + libPackage: options.package ?? '@devup-ui/react', + include: options.include ?? [], + }) + } config.plugins.push( new DevupUIWebpackPlugin({ ...options, watch: _options.dev, + productionPrewarmFiles: _options.dev + ? undefined + : productionPrewarmFiles, }), ) if (typeof webpack === 'function') return webpack(config, _options) diff --git a/packages/next-plugin/src/prewarm.ts b/packages/next-plugin/src/prewarm.ts index 03354ff5c..04a9cfd38 100644 --- a/packages/next-plugin/src/prewarm.ts +++ b/packages/next-plugin/src/prewarm.ts @@ -1,8 +1,11 @@ -import { existsSync } from 'node:fs' -import { createRequire } from 'node:module' -import { extname, join, relative, resolve } from 'node:path' +import { existsSync, readFileSync } from 'node:fs' +import { extname, isAbsolute, relative, resolve } from 'node:path' -import type { StaticImportGraph } from '@devup-ui/plugin-utils' +import { + createModuleResolver, + parseImports, + type StaticImportGraph, +} from '@devup-ui/plugin-utils' const EXTRACTABLE_EXTENSION = /\.(?:tsx?|jsx?|mjs)$/ @@ -52,13 +55,13 @@ export interface CollectProductionPrewarmFilesOptions { } /** - * Build the deterministic production extraction set used before Turbopack can - * request its first CSS module. + * Build the complete deterministic production extraction set before a bundler + * requests its first CSS module. * * `computeCompiledFiles` is intentionally route-aware, but a bundler can also * compile files hidden behind template imports / MDX and package entries that * live outside `srcDir`. Prewarming every extractable source file plus the - * external package entries accepted by the loader makes the first snapshot + * external package closures accepted by the loader makes the first snapshot * independent of Turbopack scheduling. Per-file mode still only imports chunks * that the bundler reaches; single-CSS mode intentionally contains the whole * application stylesheet. @@ -79,25 +82,29 @@ export function collectProductionPrewarmFiles({ files.add(toKey(resolvedCwd, filename)) } - const externalSpecifiers = new Set() - for (const specifiers of graph.externalImports?.values() ?? []) { - for (const specifier of specifiers) externalSpecifiers.add(specifier) - } - - const requireFromProject = createRequire(join(resolvedCwd, 'package.json')) - for (const specifier of [...externalSpecifiers].sort()) { - const packageName = packageNameFromSpecifier(specifier) - if (!packageName || !isPrewarmPackage(packageName, libPackage, include)) { - continue + const resolveModule = createModuleResolver({ cwd: resolvedCwd }) + const visited = new Set() + const queue = [...(graph.externalImports ?? [])].flatMap( + ([importer, specifiers]) => + [...specifiers].sort().map((specifier) => ({ importer, specifier })), + ) + for (const { importer, specifier } of queue) { + const local = specifier.startsWith('.') || isAbsolute(specifier) + if (!local) { + const packageName = packageNameFromSpecifier(specifier) + if (!packageName || !isPrewarmPackage(packageName, libPackage, include)) + continue } - - try { - const filename = preferEsmFile(requireFromProject.resolve(specifier)) - if (!EXTRACTABLE_EXTENSION.test(filename)) continue - files.add(toKey(resolvedCwd, filename)) - } catch { - // Resolution is best-effort, matching the static graph pre-pass. The - // loader remains the fallback for packages resolved only by Turbopack. + const module = resolveModule(specifier, importer) + if (!module) continue + const filename = preferEsmFile(module.path) + if (!EXTRACTABLE_EXTENSION.test(filename) || visited.has(filename)) continue + visited.add(filename) + files.add(toKey(resolvedCwd, filename)) + const source = + filename === module.path ? module.code : readFileSync(filename, 'utf-8') + for (const imported of parseImports(filename, source)) { + queue.push({ importer: filename, specifier: imported.specifier }) } } diff --git a/packages/plugin-utils/src/import-graph.ts b/packages/plugin-utils/src/import-graph.ts index 1602513f7..bc6600d89 100644 --- a/packages/plugin-utils/src/import-graph.ts +++ b/packages/plugin-utils/src/import-graph.ts @@ -639,7 +639,10 @@ export function listSourceFiles(srcDir: string): string[] { ) } -function parseImports(filename: string, source: string): ImportReference[] { +export function parseImports( + filename: string, + source: string, +): ImportReference[] { const astImports = parseImportsWithOxc(filename, source) if (astImports) return astImports return scanImports(source) diff --git a/packages/plugin-utils/src/index.ts b/packages/plugin-utils/src/index.ts index f90a0352f..f938bb323 100644 --- a/packages/plugin-utils/src/index.ts +++ b/packages/plugin-utils/src/index.ts @@ -15,6 +15,7 @@ export { createModuleResolver, type CreateModuleResolverOptions, listSourceFiles, + parseImports, planAtomHoist, type ResolvedModule, type StaticImportGraph, diff --git a/packages/webpack-plugin/src/__tests__/plugin.test.ts b/packages/webpack-plugin/src/__tests__/plugin.test.ts index b88664c70..29a8095c0 100644 --- a/packages/webpack-plugin/src/__tests__/plugin.test.ts +++ b/packages/webpack-plugin/src/__tests__/plugin.test.ts @@ -712,6 +712,94 @@ describe('devupUIWebpackPlugin', () => { } }) + it('uses normalized complete files instead of Next loader requests, including external files', () => { + // Given an authoritative set with duplicate spellings and an external file. + const seed = spyOn(wasm, 'seedFileMap').mockReturnValue(undefined) + const shorthands = spyOn(wasm, 'registerShorthands').mockReturnValue( + undefined, + ) + const plugin = new DevupUIWebpackPlugin({ + productionPrewarmFiles: [ + 'src/z.tsx', + resolve('node_modules/ui/a.tsx'), + './src/z.tsx', + 'src\\z.tsx', + ], + shorthands: { insetX: ['left', 'right'] }, + }) + const compiler = createCompiler() + compiler.options.entry = { main: { import: ['next-app-loader?query!'] } } + try { + // When the compiler applies after its ordinary configuration setup. + plugin.apply(asCompiler(compiler)) + // Then all files are extracted exactly once, in deterministic loader-ID order. + expect(codeExtractSpy.mock.calls.map(([filename]) => filename)).toEqual( + ['node_modules/ui/a.tsx', 'src/z.tsx'], + ) + expect(computeReachableFilesSpy).not.toHaveBeenCalled() + expect(seed).toHaveBeenCalledWith([ + 'node_modules/ui/a.tsx', + 'src/z.tsx', + ]) + expect(shorthands).toHaveBeenLastCalledWith({ + insetX: ['left', 'right'], + }) + } finally { + seed.mockRestore() + shorthands.mockRestore() + } + }) + + it.each([false, true])( + 'does not prewarm when the complete set is empty or watch=%s', + (watch) => { + // Given a reachable generic entry which an explicit set supersedes. + computeReachableFilesSpy.mockReturnValue([resolve('src/parent.tsx')]) + const plugin = new DevupUIWebpackPlugin({ + watch, + productionPrewarmFiles: watch ? ['src/parent.tsx'] : [], + }) + // When applying, then neither explicit watch files nor generic fallback are extracted. + plugin.apply(asCompiler(createCompiler())) + expect(codeExtractSpy).not.toHaveBeenCalled() + expect(computeReachableFilesSpy).not.toHaveBeenCalled() + }, + ) + + it.each(['canonical', 'numbering', 'read', 'extract'])( + 'propagates genuine %s errors for an explicit complete set', + (stage) => { + // Given a failing correctness-prewarm dependency. + const failure = new Error(stage) + const endBuild = mock() + const begin = spyOn(pluginUtils, 'beginBuild').mockReturnValue(endBuild) + const fail = () => { + throw failure + } + const numbering = spyOn( + pluginUtils, + 'collectNumberedFiles', + ).mockReturnValue([]) + if (stage === 'canonical') buildCanonicalMapSpy.mockImplementation(fail) + if (stage === 'numbering') numbering.mockImplementation(fail) + if (stage === 'read') readFileSyncSpy.mockImplementation(fail) + if (stage === 'extract') codeExtractSpy.mockImplementation(fail) + const plugin = new DevupUIWebpackPlugin({ + productionPrewarmFiles: ['src/parent.tsx'], + }) + try { + // When applying, then the original error reaches webpack instead of partial CSS. + expect(() => plugin.apply(asCompiler(createCompiler()))).toThrow( + failure, + ) + expect(endBuild).toHaveBeenCalledTimes(1) + } finally { + begin.mockRestore() + numbering.mockRestore() + } + }, + ) + it('pre-warms without collapse, from no entries when they are a function', () => { buildCanonicalMapSpy.mockReturnValue({}) const compiler = createCompiler() diff --git a/packages/webpack-plugin/src/__tests__/production-prewarm.mjs b/packages/webpack-plugin/src/__tests__/production-prewarm.mjs new file mode 100644 index 000000000..b1ffa6f3b --- /dev/null +++ b/packages/webpack-plugin/src/__tests__/production-prewarm.mjs @@ -0,0 +1,231 @@ +import assert from 'node:assert/strict' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join, relative } from 'node:path' + +import { codeExtract, exportFileMap, getCss } from '@devup-ui/wasm' + +import { DevupUI } from '../../../next-plugin/src/plugin.ts' +import { setWebpackPluginForTesting } from '../../../next-plugin/src/wasm.ts' +import { DevupUIWebpackPlugin } from '../plugin.ts' + +const originalCwd = process.cwd() +const fromNextPlugin = createRequire( + new URL('../../../next-plugin/package.json', import.meta.url), +) +const { webpack } = fromNextPlugin('next/dist/compiled/webpack/webpack') +const root = mkdtempSync(join(tmpdir(), 'devup-webpack-prewarm-')) +const compilers = new Set() +const failures = [] +function write(file, source) { + mkdirSync(dirname(join(root, file)), { recursive: true }) + writeFileSync(join(root, file), source) +} +function apply(options = {}, entry = 'next-app-loader?query!') { + const compiler = webpack({ + mode: 'production', + context: root, + entry, + optimization: { minimizer: [] }, + plugins: [new DevupUIWebpackPlugin(options)], + }) + compilers.add(compiler) + return compiler +} +async function close(compiler) { + try { + await new Promise((resolve, reject) => + compiler.close((error) => (error ? reject(error) : resolve())), + ) + } finally { + compilers.delete(compiler) + } +} +function extract(file, singleCss) { + return codeExtract( + file, + readFileSync(join(root, file), 'utf8'), + '@devup-ui/react', + `./${relative(dirname(join(root, file)), join(root, 'df/devup-ui')).replaceAll('\\', '/')}`, + singleCss, + false, + true, + {}, + ).code +} +function sheet() { + return [ + getCss(null, false), + ...Object.values(JSON.parse(exportFileMap())).map((number) => + getCss(number, false), + ), + ].join('\n') +} +try { + process.chdir(root) + write('package.json', '{"private":true}') + write('devup.json', '{}') + write( + 'src/page.tsx', + 'import { Box } from "@devup-ui/react"; export const page = ', + ) + write( + 'src/unreachable.tsx', + 'import { Box } from "@devup-ui/react"; export const unused = ', + ) + write( + 'node_modules/design-system/child.tsx', + 'import { Box } from "@devup-ui/react"; export const child = ', + ) + write('node_modules/design-system/package.json', '{"main":"index.js"}') + write('node_modules/design-system/index.js', 'export * from "./child"') + write('src/barrel.ts', 'export * from "design-system"') + if (process.env.DEVUP_PREWARM_CLEANUP_PROOF === '1') { + // Given outstanding compilers whose asynchronous shutdowns both fail. + for (const name of ['first', 'second']) { + const compiler = apply({ productionPrewarmFiles: [] }) + compiler.hooks.shutdown.tapAsync('cleanup-proof', (callback) => { + setImmediate(() => { + assert.equal(existsSync(root), true) + console.info(`shutdown:${name}`) + callback(new Error(`cleanup:${name}`)) + }) + }) + } + // When an assertion aborts the proof, finally must await both and preserve every error. + assert.fail('initialization-proof-failure') + } + // Given production Next callbacks, discovery supplies the same complete set without extracting. + setWebpackPluginForTesting({ DevupUIWebpackPlugin }) + const next = DevupUI({}, { include: ['design-system'] }) + const configured = ['server', 'edge-server', 'client'].map((name) => { + const config = next.webpack( + { plugins: [] }, + { buildId: 'proof', dev: false, name }, + ) + return config.plugins[0].options.productionPrewarmFiles + }) + assert.deepEqual(configured[0], [ + 'node_modules/design-system/child.tsx', + 'node_modules/design-system/index.js', + 'src/barrel.ts', + 'src/page.tsx', + 'src/unreachable.tsx', + ]) + assert.deepEqual(configured[1], configured[0]) + assert.deepEqual(configured[2], configured[0]) + assert.doesNotMatch(sheet(), /maroon|cyan|goldenrod/) + for (const singleCss of [false, true]) { + // Given the complete set, including an external file hidden by a Next query. + const productionPrewarmFiles = [ + 'src/page.tsx', + 'node_modules/design-system/child.tsx', + ] + let compiler = apply({ productionPrewarmFiles, singleCss }) + const css = sheet() + assert.match(css, /color:maroon/) + assert.match(css, /color:cyan/) + assert.doesNotMatch(css, /goldenrod/) + const code = productionPrewarmFiles.map((file) => extract(file, singleCss)) + await close(compiler) + // When the same engine is reset by another compiler with reordered duplicate paths. + compiler = apply({ + singleCss, + productionPrewarmFiles: [ + join(root, 'node_modules/design-system/child.tsx'), + './src/../src/page.tsx', + 'src/page.tsx', + 'src\\page.tsx', + ], + }) + // Then both CSS bytes and transformed class correspondence are identical. + assert.equal(sheet(), css) + assert.deepEqual( + productionPrewarmFiles.map((file) => extract(file, singleCss)), + code, + ) + await close(compiler) + } + // Given a generic webpack entry, unreachable source remains excluded. + let compiler = apply({}, './src/page.tsx') + assert.match(sheet(), /maroon/) + assert.doesNotMatch(sheet(), /cyan|goldenrod/) + await close(compiler) + // When the complete set is explicitly empty, then even that entry is not prewarmed. + compiler = apply({ productionPrewarmFiles: [] }, './src/page.tsx') + assert.doesNotMatch(sheet(), /maroon|cyan|goldenrod/) + await close(compiler) + compiler = apply({ watch: true, productionPrewarmFiles: ['src/page.tsx'] }) + assert.doesNotMatch(sheet(), /maroon/) + await close(compiler) + assert.throws(() => apply({ productionPrewarmFiles: ['missing.tsx'] })) + write( + 'bad.tsx', + 'import { Box } from "@devup-ui/react"; export const c = ', + ) + assert.throws(() => apply({ productionPrewarmFiles: ['bad.tsx'] })) + for (const invalid of ['missing.tsx', 'bad.tsx']) { + // Given a valid file sorted before the failing file, initialization partially extracts. + write( + 'a-valid.tsx', + 'import { Box } from "@devup-ui/react"; export const c = ', + ) + // When webpack itself throws, no compiler is returned for the caller to close. + assert.throws(() => + apply({ + prefix: 'failed', + shorthands: { insetX: ['left', 'right'] }, + atomHoist: 2, + productionPrewarmFiles: ['a-valid.tsx', invalid], + }), + ) + assert.match(sheet(), /chartreuse/) + assert.match(sheet(), /left:19px/) + // Then an unrelated generic build resets the partial styles and engine options. + compiler = apply({}, './src/page.tsx') + assert.match(sheet(), /maroon/) + assert.doesNotMatch(sheet(), /chartreuse|failed|left:19px/) + const cleanCode = codeExtract( + 'clean.tsx', + 'import { Box } from "@devup-ui/react"; export const c = ', + '@devup-ui/react', + './df/devup-ui', + false, + false, + true, + {}, + ).code + assert.doesNotMatch(cleanCode, /failed/) + assert.match(sheet(), /inset-x:23px/) + assert.doesNotMatch(sheet(), /left:23px/) + await close(compiler) + } + console.info('production prewarm proof passed') +} catch (error) { + failures.push(error) +} finally { + const results = await Promise.allSettled([...compilers].map(close)) + for (const result of results) { + if (result.status === 'rejected') failures.push(result.reason) + } + setWebpackPluginForTesting(undefined) + process.chdir(originalCwd) + try { + rmSync(root, { recursive: true, force: true }) + if (process.env.DEVUP_PREWARM_CLEANUP_PROOF === '1') + console.info('fixture-removed') + } catch (error) { + failures.push(error) + } +} +if (failures.length === 1) throw failures[0] +if (failures.length > 1) + throw new AggregateError(failures, 'Prewarm proof and cleanup failed') diff --git a/packages/webpack-plugin/src/__tests__/production-prewarm.test.js b/packages/webpack-plugin/src/__tests__/production-prewarm.test.js new file mode 100644 index 000000000..afc664bbe --- /dev/null +++ b/packages/webpack-plugin/src/__tests__/production-prewarm.test.js @@ -0,0 +1,46 @@ +import { spawnSync } from 'node:child_process' +import { join } from 'node:path' + +import { expect, it } from 'bun:test' + +it('reconstructs complete production sheets across compiler resets without leaking to another build', () => { + // Given an isolated real engine (other unit tests mock WASM exports). + // When applying and closing real webpack compilers sequentially. + const result = spawnSync( + process.execPath, + [join(import.meta.dir, 'production-prewarm.mjs')], + { + encoding: 'utf8', + }, + ) + // Then all adapter selection, reconstruction and isolation assertions pass. + expect(result.error).toBeUndefined() + expect(result.stdout + result.stderr).toContain( + 'production prewarm proof passed', + ) + expect(result.status).toBe(0) +}) + +it('awaits every outstanding shutdown before removing fixtures and preserves proof plus cleanup errors', () => { + // Given asynchronous failing shutdowns and a proof assertion failure. + // When the real-engine harness unwinds. + const result = spawnSync( + process.execPath, + [join(import.meta.dir, 'production-prewarm.mjs')], + { + encoding: 'utf8', + env: { ...process.env, DEVUP_PREWARM_CLEANUP_PROOF: '1' }, + }, + ) + // Then both callbacks finish with the fixture alive, and all three errors survive. + expect(result.error).toBeUndefined() + expect(result.status).toBe(1) + expect(result.stdout.trim().split(/\r?\n/)).toEqual([ + 'shutdown:first', + 'shutdown:second', + 'fixture-removed', + ]) + expect(result.stderr).toContain('initialization-proof-failure') + expect(result.stderr).toContain('cleanup:first') + expect(result.stderr).toContain('cleanup:second') +}) diff --git a/packages/webpack-plugin/src/plugin.ts b/packages/webpack-plugin/src/plugin.ts index 6830fbfdd..f311537ce 100644 --- a/packages/webpack-plugin/src/plugin.ts +++ b/packages/webpack-plugin/src/plugin.ts @@ -82,6 +82,8 @@ export interface DevupUIWebpackPluginOptions { * Set to `false` to disable specific aliases */ importAliases?: ImportAliases + /** Adapter-owned complete extraction set; undefined keeps generic entry reachability. */ + readonly productionPrewarmFiles?: readonly string[] } export class DevupUIWebpackPlugin { @@ -103,6 +105,7 @@ export class DevupUIWebpackPlugin { prefix, shorthands, atomHoist, + productionPrewarmFiles, importAliases: userImportAliases, }: Partial = {}) { registerShorthands(shorthands ?? {}) @@ -118,7 +121,21 @@ export class DevupUIWebpackPlugin { include, singleCss, prefix, + shorthands, atomHoist, + productionPrewarmFiles: + productionPrewarmFiles === undefined + ? undefined + : [ + ...new Set( + productionPrewarmFiles.map((file) => + relative( + process.cwd(), + resolve(process.cwd(), file.replaceAll('\\', '/')), + ).replaceAll('\\', '/'), + ), + ), + ].sort(), } this.sheetFile = join(this.options.distDir, 'sheet.json') @@ -164,9 +181,9 @@ export class DevupUIWebpackPlugin { * 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. + * options) so re-extraction during compilation is idempotent. Generic entry + * discovery remains best-effort; an adapter's complete set is authoritative + * and its read/extraction failures must fail the build. */ private prewarmExtractor(entries: string[]) { try { @@ -178,11 +195,21 @@ export class DevupUIWebpackPlugin { toId: (path) => relative(cwd, path).replaceAll('\\', '/'), }), ) - for (const file of computeReachableFiles({ - srcDir: resolve(cwd, 'src'), - tsconfigPath: resolve(cwd, 'tsconfig.json'), - entries, - })) { + const files = + this.options.productionPrewarmFiles?.map((file) => + resolve(cwd, file), + ) ?? + computeReachableFiles({ + srcDir: resolve(cwd, 'src'), + tsconfigPath: resolve(cwd, 'tsconfig.json'), + entries, + }) + if (this.options.productionPrewarmFiles !== undefined) { + seedFileNumbers({ seedFileMap }, [ + ...this.options.productionPrewarmFiles, + ]) + } + for (const file of files) { const relativePath = relative(cwd, file).replaceAll('\\', '/') let relCssDir = relative(dirname(file), this.options.cssDir).replaceAll( '\\', @@ -200,7 +227,8 @@ export class DevupUIWebpackPlugin { this.importAliases, ) } - } catch { + } catch (error) { + if (this.options.productionPrewarmFiles !== undefined) throw error // Best-effort warm-up; on failure the css-loader still serves whatever // atoms were extracted, matching pre-fix behavior. } @@ -210,9 +238,20 @@ export class DevupUIWebpackPlugin { // A build starts from its own options, not from what an earlier build in // this process left in the engine const endBuild = beginBuild({ resetBuildState }) - compiler.hooks.shutdown?.tap('DevupUIWebpackPlugin', endBuild) + try { + compiler.hooks.shutdown?.tap('DevupUIWebpackPlugin', endBuild) + this.initialize(compiler) + } catch (error) { + // Webpack cannot return the compiler when apply throws, so no caller can close it. + endBuild() + throw error + } + } + + private initialize(compiler: Compiler) { setDebug(this.options.debug) setPrefix(this.options.prefix ?? null) + registerShorthands(this.options.shorthands ?? {}) const existsDevup = existsSync(this.options.devupFile) // read devup.json if (!existsSync(this.options.distDir)) @@ -285,7 +324,12 @@ export class DevupUIWebpackPlugin { ) } } - } catch { + } catch (error) { + if ( + !this.options.watch && + this.options.productionPrewarmFiles !== undefined + ) + throw error // Best-effort; on failure canonical() is the identity (no merge) and atom // hoisting stays off. } @@ -305,7 +349,12 @@ export class DevupUIWebpackPlugin { toId: (path) => relative(cwd, path).replaceAll('\\', '/'), }), ) - } catch { + } catch (error) { + if ( + !this.options.watch && + this.options.productionPrewarmFiles !== undefined + ) + throw error // Best-effort; numbering falls back to arrival order. } // Pre-warm the extractor so the css-loader serves COMPLETE CSS. From 42cb0a4e9d9f1e5eb9b4f87bfbd4ff3a000183da Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 16 Oct 2026 14:44:05 +0900 Subject: [PATCH 2/2] fix(next): preserve generated CSS requests across warm webpack builds Refs #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- ...ngepack_log_next_webpack_css_delivery.json | 2 +- .../next-plugin/src/__tests__/plugin.test.ts | 6 +- .../__tests__/webpack-delivery-fixture.cjs | 80 +++++++++ .../src/__tests__/webpack-delivery-http.cjs | 156 ++++++++++++++++++ .../__tests__/webpack-delivery-http.test.js | 18 ++ .../__tests__/webpack-delivery-negative.cjs | 55 ++++++ .../src/__tests__/webpack-production.cjs | 101 ++++++++---- .../src/__tests__/webpack-production.test.js | 23 +++ packages/next-plugin/src/plugin.ts | 2 +- 9 files changed, 410 insertions(+), 33 deletions(-) create mode 100644 packages/next-plugin/src/__tests__/webpack-delivery-fixture.cjs create mode 100644 packages/next-plugin/src/__tests__/webpack-delivery-http.cjs create mode 100644 packages/next-plugin/src/__tests__/webpack-delivery-http.test.js create mode 100644 packages/next-plugin/src/__tests__/webpack-delivery-negative.cjs diff --git a/.changepacks/changepack_log_next_webpack_css_delivery.json b/.changepacks/changepack_log_next_webpack_css_delivery.json index 44c7353c0..d1d396944 100644 --- a/.changepacks/changepack_log_next_webpack_css_delivery.json +++ b/.changepacks/changepack_log_next_webpack_css_delivery.json @@ -4,6 +4,6 @@ "packages/webpack-plugin/package.json": "Patch", "packages/plugin-utils/package.json": "Patch" }, - "note": "Reconstruct the complete deterministic Next webpack stylesheet after each server, edge and client compiler reset, including accepted external package dependency closures, so delivered CSS matches rendered classes in per-file and single-CSS modes. Genuine production prewarm collection, read and extraction failures now fail the build instead of silently dropping styles; failed initialization releases its build session without leaking styles into unrelated builds.", + "note": "Reconstruct the complete deterministic Next webpack stylesheet after each server, edge and client compiler reset, including accepted external package dependency closures, so delivered CSS matches rendered classes in per-file and single-CSS modes. Keep production CSS request paths stable across build IDs so persistent webpack cache cannot bypass the CSS loader. Genuine production prewarm collection, read and extraction failures now fail the build instead of silently dropping styles; failed initialization releases its build session without leaking styles into unrelated builds.", "date": "2026-10-16T00:00:00.000Z" } diff --git a/packages/next-plugin/src/__tests__/plugin.test.ts b/packages/next-plugin/src/__tests__/plugin.test.ts index 98f5eb37e..ce47e44f7 100644 --- a/packages/next-plugin/src/__tests__/plugin.test.ts +++ b/packages/next-plugin/src/__tests__/plugin.test.ts @@ -194,7 +194,7 @@ describe('DevupUINextPlugin', () => { ret.webpack!(createWebpackConfig(), createWebpackContext()) expect(devupUIWebpackPluginSpy).toHaveBeenCalledWith({ - cssDir: resolve('.next/cache', 'devup-ui_tmpBuildId'), + cssDir: resolve('.next/cache', 'devup-ui'), productionPrewarmFiles: [], }) }) @@ -222,7 +222,7 @@ describe('DevupUINextPlugin', () => { expect(devupUIWebpackPluginSpy).toHaveBeenCalledWith({ package: 'new-package', - cssDir: resolve('.next/cache', 'devup-ui_tmpBuildId'), + cssDir: resolve('.next/cache', 'devup-ui'), productionPrewarmFiles: [], }) }) @@ -242,7 +242,7 @@ describe('DevupUINextPlugin', () => { expect(devupUIWebpackPluginSpy).toHaveBeenCalledWith({ package: 'new-package', - cssDir: resolve('.next/cache', 'devup-ui_tmpBuildId'), + cssDir: resolve('.next/cache', 'devup-ui'), productionPrewarmFiles: [], }) expect(webpack).toHaveBeenCalled() diff --git a/packages/next-plugin/src/__tests__/webpack-delivery-fixture.cjs b/packages/next-plugin/src/__tests__/webpack-delivery-fixture.cjs new file mode 100644 index 000000000..1d1ce8228 --- /dev/null +++ b/packages/next-plugin/src/__tests__/webpack-delivery-fixture.cjs @@ -0,0 +1,80 @@ +module.exports = function deliveryFixture(put) { + const singleCss = process.env.DEVUP_NEXT_SINGLE_CSS === '1' + put( + 'devup.json', + JSON.stringify({ theme: { colors: { default: { proof: '#123456' } } } }), + ) + put( + 'next.config.js', + ` + const { DevupUI } = require('@devup-ui/next-plugin'); + module.exports = DevupUI({ + experimental: { cpus: 1, webpackBuildWorker: true }, + transpilePackages: ['delivery-library'], + webpack(config, context) { + console.info('W65-TOPOLOGY ' + JSON.stringify({ pid: process.pid, threadId: require('node:worker_threads').threadId, isServer: context.isServer, nextRuntime: context.nextRuntime || 'client' })); + return config; + } + }, { singleCss: ${singleCss}, include: ['delivery-library'] }); + `, + ) + put( + 'src/app/layout.js', + ` + import { Box, globalCss } from '@devup-ui/react'; + globalCss({ body: { background: '#040', color: '#248' } }); + export default function Layout({ children }) { + return {children}; + } + `, + ) + put( + 'src/app/page.js', + ` + import { Box } from '@devup-ui/react'; + import { Child } from 'delivery-library'; + import Client from './client'; + export default function Page() { + return <>server-proofexternal-proof; + } + `, + ) + put( + 'src/app/client.js', + ` + 'use client'; + import { Box } from '@devup-ui/react'; + export default function Client() { return client-proof; } + `, + ) + put('src/app/client/page.js', `export { default } from '../client';`) + put( + 'src/pages/edge-page.js', + ` + import { Box, css } from '@devup-ui/react'; + export const config = { runtime: 'experimental-edge' }; + const edgePage = css({ color: 'goldenrod', padding: '19px' }); + export default function EdgePage() { return
edge-page-proof
; } + `, + ) + put( + 'node_modules/delivery-library/package.json', + JSON.stringify({ + name: 'delivery-library', + version: '1.0.0', + type: 'module', + main: './index.mjs', + }), + ) + put( + 'node_modules/delivery-library/index.mjs', + `export { Child } from './child.mjs';`, + ) + put( + 'node_modules/delivery-library/child.mjs', + ` + import { styled } from '@devup-ui/react'; + export const Child = styled('strong', { color: 'rebeccapurple', marginLeft: '17px' }); + `, + ) +} diff --git a/packages/next-plugin/src/__tests__/webpack-delivery-http.cjs b/packages/next-plugin/src/__tests__/webpack-delivery-http.cjs new file mode 100644 index 000000000..f954edf74 --- /dev/null +++ b/packages/next-plugin/src/__tests__/webpack-delivery-http.cjs @@ -0,0 +1,156 @@ +const assert = require('node:assert/strict') + +const colors = { + maroon: '#800000', + cyan: '#00ffff', + aqua: '#00ffff', + white: '#ffffff', + goldenrod: '#daa520', + rebeccapurple: '#663399', +} +function normalize(value) { + const lower = value.trim().toLowerCase() + if (colors[lower]) return colors[lower] + if (/^#[\da-f]{3}$/.test(lower)) + return '#' + [...lower.slice(1)].map((digit) => digit + digit).join('') + const rgb = lower.match( + /^rgba?\(\s*(\d+)[,\s]+(\d+)[,\s]+(\d+)(?:\s*[,/]\s*1)?\s*\)$/, + ) + return rgb + ? '#' + + rgb + .slice(1) + .map((part) => Number(part).toString(16).padStart(2, '0')) + .join('') + : lower +} +function rules(css, parse) { + const result = new Map() + parse(css).walkRules((rule) => { + for (const selector of rule.selectors) { + const declarations = result.get(selector) || new Map() + rule.walkDecls((decl) => + declarations.set(decl.prop, normalize(decl.value)), + ) + result.set(selector, declarations) + } + }) + return result +} +function declarationProof(parsed, selectors, expected) { + const declarations = new Map( + selectors.flatMap((selector) => [...(parsed.get(selector) || [])]), + ) + for (const [property, value] of Object.entries(expected)) + assert.equal( + declarations.get(property), + normalize(value), + `${selectors.join(' ')} missing/wrong ${property}; actual ${JSON.stringify([...declarations])}`, + ) + return [...declarations].sort(([a], [b]) => a.localeCompare(b)) +} +function elementProof(html, parsed, proof) { + const tag = [...html.matchAll(/<[a-z][^>]*>/gi)] + .map(([value]) => value) + .find((value) => value.includes(`id="${proof.id}"`)) + assert.ok(tag, `missing SSR element ${proof.id}`) + const classes = tag.match(/\bclass="([^"]+)"/)?.[1].split(/\s+/) + assert.ok(classes?.length, `missing actual classes for ${proof.id}`) + for (const name of classes) { + assert.match(name, /^[\w-]+$/) + assert.ok( + parsed.get(`.${name}`)?.size, + `${proof.id}: actual class .${name} has no linked declarations`, + ) + } + return { + id: proof.id, + classes: [...classes].sort(), + declarations: declarationProof( + parsed, + classes.map((name) => `.${name}`), + proof.expected, + ), + } +} +async function documentProof(base, parse, page) { + const response = await fetch(new URL(page.path, base), { + signal: AbortSignal.timeout(10000), + }) + assert.equal(response.status, 200, `${page.path} HTML status`) + assert.match(response.headers.get('content-type') || '', /text\/html/) + const html = await response.text() + const links = [...html.matchAll(/]*>/g)] + .map(([tag]) => tag) + .filter((tag) => /\brel="stylesheet"/.test(tag)) + .map((tag) => tag.match(/\bhref="([^"]+)"/)?.[1].replaceAll('&', '&')) + assert.ok(links.length > 0, `${page.path} must link CSS`) + const sheets = [] + for (const link of [...new Set(links)]) { + assert.ok(link, 'stylesheet link must have href') + const response = await fetch(new URL(link, base), { + signal: AbortSignal.timeout(10000), + }) + assert.equal(response.status, 200, `${page.path}: stylesheet ${link}`) + assert.match(response.headers.get('content-type') || '', /text\/css/) + const css = await response.text() + assert.ok(css.trim().length > 0, `${link} empty CSS`) + sheets.push({ url: link, css }) + } + const parsed = rules(sheets.map(({ css }) => css).join('\n'), parse) + const elements = [ + ...page.elements, + { id: 'layout-proof', expected: { color: 'white' } }, + ].map((proof) => elementProof(html, parsed, proof)) + const body = declarationProof(parsed, ['body'], { + background: '#040', + color: '#248', + }) + const theme = [...parsed.values()].some( + (declarations) => declarations.get('--proof') === '#123456', + ) + assert.ok(theme, `${page.path} linked theme --proof missing`) + return { path: page.path, elements, body, theme, sheets } +} +module.exports = async function deliveryHttp(base, parse) { + const pages = [ + { + path: '/', + elements: [ + { + id: 'server-proof', + expected: { background: 'maroon', padding: '13px' }, + }, + { id: 'client-proof', expected: { color: 'cyan' } }, + { + id: 'external-proof', + expected: { color: 'rebeccapurple', 'margin-left': '17px' }, + }, + ], + }, + { + path: '/client', + elements: [{ id: 'client-proof', expected: { color: 'cyan' } }], + }, + { + path: '/edge-page', + elements: [ + { + id: 'edge-page-proof', + expected: { color: 'goldenrod', padding: '19px' }, + }, + ], + }, + ] + const documents = [] + for (const page of pages) + documents.push(await documentProof(base, parse, page)) + console.info('W65-HTTP ' + JSON.stringify(documents)) + // Compare semantic stylesheet contents/class bindings, never Next build-id URLs/manifests. + return documents.map(({ sheets, ...document }) => ({ + ...document, + css: sheets.map(({ css }) => css).sort(), + })) +} +module.exports.rules = rules +module.exports.declarationProof = declarationProof diff --git a/packages/next-plugin/src/__tests__/webpack-delivery-http.test.js b/packages/next-plugin/src/__tests__/webpack-delivery-http.test.js new file mode 100644 index 000000000..d4a824994 --- /dev/null +++ b/packages/next-plugin/src/__tests__/webpack-delivery-http.test.js @@ -0,0 +1,18 @@ +import { spawnSync } from 'node:child_process' +import { join } from 'node:path' + +import { expect, it } from 'bun:test' + +it('rejects broken HTTP delivery boundaries in the native Node surface', () => { + // Given native Node fetch, isolated from the suite DOM preload. + // When real HTTP documents or their linked CSS violate delivery contracts. + const result = spawnSync( + 'node', + [join(import.meta.dir, 'webpack-delivery-negative.cjs')], + { encoding: 'utf8' }, + ) + // Then each rejection must identify the broken boundary, not a later error. + console.info(result.stdout + result.stderr) + expect(result.error).toBeUndefined() + expect(result.status).toBe(0) +}) diff --git a/packages/next-plugin/src/__tests__/webpack-delivery-negative.cjs b/packages/next-plugin/src/__tests__/webpack-delivery-negative.cjs new file mode 100644 index 000000000..5a1fa6cc1 --- /dev/null +++ b/packages/next-plugin/src/__tests__/webpack-delivery-negative.cjs @@ -0,0 +1,55 @@ +const assert = require('node:assert/strict') +const { once } = require('node:events') +const { createServer } = require('node:http') +const { createRequire } = require('node:module') +const fromNext = createRequire(require.resolve('next/package.json')) +const delivery = require('./webpack-delivery-http.cjs') +const failures = { + 'document-status': /HTML status/, + 'css-status': /stylesheet \/proof.css/, + 'empty-css': /empty CSS/, + 'unbound-class': /actual class \.proof has no linked declarations/, + 'wrong-value': /missing\/wrong background/, +} +async function main() { + for (const [failure, message] of Object.entries(failures)) { + const server = createServer((request, response) => { + const css = request.url === '/proof.css' + response.statusCode = + (css && failure === 'css-status') || + (!css && failure === 'document-status') + ? 404 + : 200 + response.setHeader('Content-Type', css ? 'text/css' : 'text/html') + response.end( + css + ? failure === 'empty-css' + ? '' + : failure === 'unbound-class' + ? '.other{background:maroon;padding:13px}' + : '.proof{background:cyan;padding:13px}' + : '
', + ) + }) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + try { + await assert.rejects( + delivery( + `http://127.0.0.1:${server.address().port}`, + fromNext('postcss').parse, + ), + message, + ) + console.info('W65-HTTP-REJECTION ' + failure) + } finally { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ) + } + } +} +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/packages/next-plugin/src/__tests__/webpack-production.cjs b/packages/next-plugin/src/__tests__/webpack-production.cjs index 76dd7f00a..379885aad 100644 --- a/packages/next-plugin/src/__tests__/webpack-production.cjs +++ b/packages/next-plugin/src/__tests__/webpack-production.cjs @@ -1,10 +1,13 @@ const assert = require('node:assert/strict') const { spawn } = require('node:child_process') const { + existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, + rmSync, + statSync, symlinkSync, writeFileSync, } = require('node:fs') @@ -48,41 +51,15 @@ function contents(directory, extension) { }) .join('\n') } -async function main() { +async function run() { let server try { - // Fixture shape and dependency junctions credit W64c's physical Next evidence. - for (const name of ['next', 'react', 'react-dom']) - link(name, dirname(fromNext.resolve(`${name}/package.json`))) - for (const name of ['react', 'next-plugin']) - link(`@devup-ui/${name}`, join(repository, 'packages', name)) - put( - 'package.json', - JSON.stringify({ name: 'w65-next-webpack', private: true }), - ) - put('devup.json', '{}') - put( - 'next.config.js', - `const { DevupUI } = require('@devup-ui/next-plugin'); module.exports = DevupUI({ experimental: { cpus: 1 } });`, - ) if (fault === 'build-timeout') { put( 'next.config.js', `require('node:child_process').spawn(process.execPath, [${JSON.stringify(join(__dirname, 'webpack-lifecycle-child.cjs'))}], { stdio: 'inherit' }); module.exports = async () => new Promise(() => {});`, ) } - put( - 'src/app/layout.js', - 'export default function Layout({ children }) { return {children}; }', - ) - put( - 'src/app/page.js', - `import { Box } from '@devup-ui/react'; export default function Page() { return server-proof; }`, - ) - put( - 'src/app/edge/route.js', - `import { css } from '@devup-ui/react'; export const runtime = 'edge'; const edge = css({ borderColor: 'goldenrod' }); export function GET() { return Response.json({ edge }); }`, - ) const build = spawn( process.execPath, [fromPlugin.resolve('next/dist/bin/next'), 'build', '--webpack'], @@ -179,7 +156,26 @@ async function main() { edge: edgeBody, }), ) - console.info('webpack production proof passed') + const delivery = require('./webpack-delivery-http.cjs') + const parse = fromNext('postcss').parse + const documents = await delivery(base, parse) + const emitted = delivery.rules( + contents(join(root, '.next/static/css'), '.css'), + parse, + ) + delivery.declarationProof( + emitted, + edgeBody.edge.split(/\s+/).map((name) => `.${name}`), + { 'border-color': 'goldenrod' }, + ) + console.info( + 'W65-API-DISK ' + + JSON.stringify({ + classes: edgeBody.edge, + scope: 'emitted CSS only; API has no HTML stylesheet contract', + }), + ) + return documents } finally { if ( server?.pid !== undefined && @@ -200,6 +196,55 @@ async function main() { // Only the external owner may remove root, after its entire tree has exited. } } +async function main() { + // Fixture shape and dependency junctions credit W64c's physical Next evidence. + for (const name of ['next', 'react', 'react-dom']) + link(name, dirname(fromNext.resolve(`${name}/package.json`))) + for (const name of ['react', 'next-plugin']) + link(`@devup-ui/${name}`, join(repository, 'packages', name)) + put( + 'package.json', + JSON.stringify({ name: 'w65-next-webpack', private: true }), + ) + put( + 'src/app/edge/route.js', + `import { css } from '@devup-ui/react'; export const runtime = 'edge'; const edge = css({ borderColor: 'goldenrod' }); export function GET() { return Response.json({ edge }); }`, + ) + require('./webpack-delivery-fixture.cjs')(put) + let baseline + for (const cache of fault ? ['cold'] : ['cold', 'cold-repeat', 'warm']) { + if (cache === 'cold-repeat') { + rmSync(join(root, '.next'), { recursive: true, force: true }) + rmSync(join(root, 'df'), { recursive: true, force: true }) + } + console.info('W65-CACHE ' + cache) + if (cache === 'warm') { + const directory = join(root, '.next/cache/webpack') + const files = readdirSync(directory, { recursive: true }) + .filter((name) => /\.pack(?:\.gz|\.br)?$/.test(name)) + .map((name) => ({ name, bytes: statSync(join(directory, name)).size })) + assert.ok( + files.some(({ bytes }) => bytes > 0), + 'warm run requires populated webpack cache', + ) + console.info('W65-WARM-CACHE ' + JSON.stringify(files)) + } else + assert.equal( + existsSync(join(root, '.next')), + false, + `${cache} must start without Next output/cache`, + ) + const actual = await run() + if (baseline) + assert.deepEqual( + actual, + baseline, + `${cache}: HTTP CSS/class correspondence changed`, + ) + else baseline = actual + } + console.info('webpack production proof passed') +} main() .then(() => process.exit(0)) .catch((error) => { diff --git a/packages/next-plugin/src/__tests__/webpack-production.test.js b/packages/next-plugin/src/__tests__/webpack-production.test.js index 3a3fe74ba..b1e3d773e 100644 --- a/packages/next-plugin/src/__tests__/webpack-production.test.js +++ b/packages/next-plugin/src/__tests__/webpack-production.test.js @@ -4,6 +4,29 @@ import { join } from 'node:path' import { expect, it } from 'bun:test' +for (const singleCss of ['0', '1']) + it(`delivers actual HTML-linked CSS through cold/repeat/warm webpack builds (singleCss=${singleCss})`, () => { + // Given identical physical server, edge, client and accepted-package fixtures. + // When real production documents and their stylesheet URLs are requested. + const result = spawnSync( + 'node', + [join(import.meta.dir, 'webpack-owned.cjs')], + { + encoding: 'utf8', + maxBuffer: 16 * 1024 * 1024, + env: { ...process.env, DEVUP_NEXT_SINGLE_CSS: singleCss }, + }, + ) + // Then the driver verifies class-bound declarations and cache stability. + console.info(result.stdout + result.stderr) + expect(result.error).toBeUndefined() + expect(result.status).toBe(0) + expect(result.stdout).toContain('webpack production proof passed') + expect(result.stdout).toContain('"event":"tree_exit"') + expect(result.stdout).toContain('"active":[]') + expect(result.stdout).toContain('"removed":true') + }, 130000) + for (const fault of [ '', 'build-timeout', diff --git a/packages/next-plugin/src/plugin.ts b/packages/next-plugin/src/plugin.ts index 2b513ccb1..f854ac1b2 100644 --- a/packages/next-plugin/src/plugin.ts +++ b/packages/next-plugin/src/plugin.ts @@ -648,7 +648,7 @@ export function DevupUI( const { DevupUIWebpackPlugin } = loadWebpackPlugin() options.cssDir ??= resolve( _options.dev ? (options.distDir ?? 'df') : '.next/cache', - `devup-ui_${_options.buildId}`, + _options.dev ? `devup-ui_${_options.buildId}` : 'devup-ui', ) if (!_options.dev && productionPrewarmFiles === undefined) { const cwd = process.cwd()