From b1dc94650b343eb8c8c6606a6b999521157d6bd2 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 16 Oct 2026 05:53:32 +0900 Subject: [PATCH 1/3] 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/3] 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() From 9f7227be1222c27258ac0e127b1943d42640dce5 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sat, 17 Oct 2026 05:25:31 +0900 Subject: [PATCH 3/3] fix(extractor): preserve leading JSX compiler pragmas Refs #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../changepack_log_keep_jsx_pragmas.json | 8 + .../extractor/src/emotion_namespace/output.rs | 3 +- libs/extractor/src/import_alias_visit.rs | 3 + libs/extractor/src/jsx_import_source.rs | 101 +++++++ libs/extractor/src/jsx_pragmas.rs | 103 +++++++ libs/extractor/src/lib.rs | 50 +++- libs/extractor/src/pragma_annotation_tests.rs | 58 ++++ libs/extractor/src/pragma_path_tests.rs | 260 ++++++++++++++++++ .../src/pragma_path_tests/support.rs | 82 ++++++ libs/extractor/src/pragma_tests.rs | 211 ++++++++++++++ ...e_pragma_header_keeps_directive_order.snap | 13 + .../__regression__/jsx-pragmas.bun.ts | 67 +++++ packages/bun-plugin/package.json | 2 +- 13 files changed, 949 insertions(+), 12 deletions(-) create mode 100644 .changepacks/changepack_log_keep_jsx_pragmas.json create mode 100644 libs/extractor/src/jsx_import_source.rs create mode 100644 libs/extractor/src/jsx_pragmas.rs create mode 100644 libs/extractor/src/pragma_annotation_tests.rs create mode 100644 libs/extractor/src/pragma_path_tests.rs create mode 100644 libs/extractor/src/pragma_path_tests/support.rs create mode 100644 libs/extractor/src/pragma_tests.rs create mode 100644 libs/extractor/src/snapshots/extractor__pragma_tests__complete_pragma_header_keeps_directive_order.snap create mode 100644 packages/bun-plugin/__regression__/jsx-pragmas.bun.ts diff --git a/.changepacks/changepack_log_keep_jsx_pragmas.json b/.changepacks/changepack_log_keep_jsx_pragmas.json new file mode 100644 index 000000000..84ea0ef4c --- /dev/null +++ b/.changepacks/changepack_log_keep_jsx_pragmas.json @@ -0,0 +1,8 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch", + "packages/bun-plugin/package.json": "Patch" + }, + "note": "Keep leading JSX compiler pragmas through extraction, styled lowering, namespace rewrites and generated stylesheets with correct source maps. Enabled Emotion aliases keep the React JSX import-source compatibility bridge; other sources and opted-out Emotion are preserved. No new build errors.", + "date": "2026-10-17T00:00:00.000Z" +} diff --git a/libs/extractor/src/emotion_namespace/output.rs b/libs/extractor/src/emotion_namespace/output.rs index 6fcb7df6e..436afa3f4 100644 --- a/libs/extractor/src/emotion_namespace/output.rs +++ b/libs/extractor/src/emotion_namespace/output.rs @@ -18,13 +18,14 @@ impl Rewritten<'_> { /// Generated from the rewritten module, so the map points at `source`. pub(crate) fn output(&self) -> ExtractOutput { let allocator = Allocator::default(); - let program = Parser::new( + let mut program = Parser::new( &allocator, self.code, SourceType::from_path(self.filename).unwrap_or_default(), ) .parse() .program; + crate::jsx_pragmas::HeaderPragmas::capture(&program).reanchor(&mut program); let result = Codegen::new() .with_options(CodegenOptions { source_map_path: Some(PathBuf::from(self.filename)), diff --git a/libs/extractor/src/import_alias_visit.rs b/libs/extractor/src/import_alias_visit.rs index 0a8afbc2e..f275230c9 100644 --- a/libs/extractor/src/import_alias_visit.rs +++ b/libs/extractor/src/import_alias_visit.rs @@ -371,6 +371,9 @@ pub fn transform_import_aliases_with_edits<'a>( // Collect import transformations let mut transformations: Vec<(usize, usize, String)> = Vec::new(); + if import_aliases.contains_key("@emotion/react") { + transformations.extend(crate::jsx_import_source::emotion_replacements(&program)); + } let mut numbers = LibraryNumbers::default(); for stmt in &program.body { diff --git a/libs/extractor/src/jsx_import_source.rs b/libs/extractor/src/jsx_import_source.rs new file mode 100644 index 000000000..156ae78b3 --- /dev/null +++ b/libs/extractor/src/jsx_import_source.rs @@ -0,0 +1,101 @@ +use oxc_ast::ast::Program; +use oxc_span::GetSpan; + +/// Keep the enabled Emotion alias on React after the bundler lowers JSX. +pub(crate) fn emotion_replacements(program: &Program<'_>) -> Vec<(usize, usize, String)> { + let header_end = program + .body + .first() + .map_or(program.span.end, |body| body.span().start); + let mut replacements = Vec::new(); + for comment in &program.comments { + if comment.span.end > header_end { + continue; + } + let span = comment.content_span(); + let original = span.source_text(program.source_text); + let text = original.trim_start_matches('*'); + for (at, _) in text.match_indices("@jsxImportSource") { + if at > 0 && !text[..at].ends_with(char::is_whitespace) { + continue; + } + let rest = &text[at + "@jsxImportSource".len()..]; + let value = rest.trim_start(); + if value.len() == rest.len() { + continue; + } + if let Some(after) = value.strip_prefix("@emotion/react") + && after.chars().next().is_none_or(char::is_whitespace) + { + let start = span.start as usize + original.len() - text.len() + + at + + "@jsxImportSource".len() + + rest.len() + - value.len(); + replacements.push((start, start + "@emotion/react".len(), "react".into())); + } + } + } + replacements +} + +#[cfg(test)] +mod tests { + use crate::{ImportAlias, import_alias_visit::transform_import_aliases_with_edits}; + use rstest::rstest; + use std::collections::HashMap; + + #[rstest] + #[case( + "/** @jsxImportSource @emotion/react */", + "/** @jsxImportSource react */" + )] + #[case("/*@jsxImportSource\t@emotion/react*/", "/*@jsxImportSource\treact*/")] + #[case("// @jsxImportSource @emotion/react", "// @jsxImportSource react")] + #[case( + "/* @jsxImportSource @emotion/reactive */", + "/* @jsxImportSource @emotion/reactive */" + )] + #[case( + "/* not@jsxImportSource @emotion/react */", + "/* not@jsxImportSource @emotion/react */" + )] + #[case( + "/* @jsxImportSource@emotion/react */", + "/* @jsxImportSource@emotion/react */" + )] + #[case( + "/* @jsxImportSource @emotion/react more */", + "/* @jsxImportSource react more */" + )] + #[case( + "'use client'; // @jsxImportSource @emotion/react", + "'use client'; // @jsxImportSource react" + )] + #[case( + "/* text*@jsxImportSource @emotion/react */", + "/* text*@jsxImportSource @emotion/react */" + )] + fn emotion_source_rewrite_uses_exact_comment_tokens( + #[case] header: &str, + #[case] expected: &str, + ) { + let source = format!( + "{header}\nexport const anchor=1;\n/** @jsxImportSource @emotion/react */\nexport const later=2;" + ); + + let (rewritten, _) = transform_import_aliases_with_edits( + &source, + "pragma.tsx", + "@devup-ui/react", + &HashMap::from([("@emotion/react".into(), ImportAlias::NamedToNamed)]), + ); + + assert_eq!( + rewritten, + format!( + "{expected}\nexport const anchor=1;\n/** @jsxImportSource @emotion/react */\nexport const later=2;" + ) + ); + } +} diff --git a/libs/extractor/src/jsx_pragmas.rs b/libs/extractor/src/jsx_pragmas.rs new file mode 100644 index 000000000..cf75a5edc --- /dev/null +++ b/libs/extractor/src/jsx_pragmas.rs @@ -0,0 +1,103 @@ +//! Keep file-level JSX compiler instructions attached to emitted program nodes. +use oxc_allocator::Allocator; +use oxc_ast::{ + Comment, CommentContent, CommentPosition, + ast::{Program, Statement}, +}; +use oxc_parser::Parser; +use oxc_span::{GetSpan, SourceType, Span}; + +/// The original header ends at the first body statement, not the first directive. +pub(crate) struct HeaderPragmas { + end: u32, +} + +impl HeaderPragmas { + pub(crate) fn capture(program: &Program<'_>) -> Self { + Self { + end: program + .body + .first() + .map_or(program.span.end, |node| node.span().start), + } + } + + fn contains(&self, comment: &Comment, source: &str) -> bool { + comment.span.end <= self.end + && comment + .content_span() + .source_text(source) + .trim_start_matches('*') + .split_whitespace() + .any(|token| { + matches!( + token, + "@jsx" | "@jsxFrag" | "@jsxRuntime" | "@jsxImportSource" + ) + }) + } + + /// Only synthetic import spans may change; authored node spans remain map inputs. + pub(crate) fn reanchor(&self, program: &mut Program<'_>) { + let Some(first) = program + .comments + .iter() + .find(|comment| self.contains(comment, program.source_text)) + else { + return; + }; + // Anchor zero prints before the hashbang. A comment-content offset cannot + // collide with a real statement, and is safe for a generated import. + if let Some(Statement::ImportDeclaration(import)) = program.body.first_mut() + && import.span.start == 0 + { + import.span = Span::empty(first.content_span().start); + } + let body_anchor = program + .body + .first() + .map_or(program.span.end, |node| node.span().start); + for comment in &mut program.comments { + if self.contains(comment, program.source_text) { + comment.attached_to = program + .directives + .iter() + .find(|directive| comment.span.end <= directive.span.start) + .map_or(body_anchor, |directive| directive.span.start); + comment.position = CommentPosition::Leading; + comment.set_followed_by_newline(true); + // The authored node retains its annotation flag; header comments + // must print normally rather than through Oxc's annotation store. + if comment.is_pure() || comment.is_no_side_effects() { + comment.content = CommentContent::None; + } + } + } + } +} + +/// A collected stylesheet has new source; copy its authored instructions into +/// that intermediate source, before Oxc generates both the output and its map. +pub(crate) fn stylesheet_source(source: &str, generated: String, filename: &str) -> String { + if generated.is_empty() || !source.contains("@jsx") { + return generated; + } + let allocator = Allocator::default(); + let program = Parser::new( + &allocator, + source, + SourceType::from_path(filename).unwrap_or_default(), + ) + .parse() + .program; + let header = HeaderPragmas::capture(&program); + let mut output = String::new(); + for comment in &program.comments { + if header.contains(comment, source) { + output.push_str(comment.span.source_text(source)); + output.push('\n'); + } + } + output.push_str(&generated); + output +} diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 732ede591..8479d8fed 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -10,8 +10,16 @@ mod gen_class_name; mod gen_style; mod import_alias_visit; mod imported_constants; +mod jsx_import_source; +mod jsx_pragmas; mod module_loader; mod mutations; +#[cfg(test)] +mod pragma_annotation_tests; +#[cfg(test)] +mod pragma_path_tests; +#[cfg(test)] +mod pragma_tests; mod prop_modify_utils; #[cfg(test)] mod responsive_selector_tests; @@ -359,8 +367,11 @@ fn extract_source( || transformed_code.contains(STYLEX_PACKAGE); if !has_relevant_import { + let pragma_rewritten = alias_edits + .iter() + .any(|(start, end, _)| &namespace_code[*start..*end] == "@emotion/react"); // skip if not using package - if namespace_edits.is_empty() { + if namespace_edits.is_empty() && !pragma_rewritten { return Ok(ExtractOutput { styles: FxHashSet::default(), code: namespace_code.into_owned(), @@ -370,12 +381,24 @@ fn extract_source( }); } let (source, earlier_edits) = evaluated.unwrap_or((code, &[])); - let edits: Vec<&[import_alias_visit::Edit]> = std::iter::once(namespace_edits.as_slice()) - .chain(earlier_edits.iter().copied()) - .collect(); + let edits: Vec<&[import_alias_visit::Edit]> = [ + if pragma_rewritten { + alias_edits.as_slice() + } else { + &[] + }, + namespace_edits.as_slice(), + ] + .into_iter() + .chain(earlier_edits.iter().copied()) + .collect(); let mut output = emotion_namespace::Rewritten { filename, - code: &namespace_code, + code: if pragma_rewritten { + &transformed_code + } else { + &namespace_code + }, source, edits: &edits, } @@ -411,13 +434,13 @@ fn extract_source( &referenced, )? }; - let code = vanilla_extract::collected_styles_to_code_with_keyframes( + let generated = vanilla_extract::collected_styles_to_code_with_keyframes( &collected, &option.package, &keyframes_names, ); - Some(if code.is_empty() { - code + let generated = if generated.is_empty() { + generated } else { imports .kept_imports @@ -425,9 +448,14 @@ fn extract_source( .map(|specifier| { format!("import {};\n", vanilla_extract::json_string(specifier)) }) - .chain([code]) + .chain([generated]) .collect() - }) + }; + Some(jsx_pragmas::stylesheet_source( + &transformed_code, + generated, + filename, + )) } // A stylesheet another one imports must give its own values, and an // import cycle read too early fails as it does in ES modules, so both @@ -487,6 +515,7 @@ fn extract_source( if fatal_error { return Err("Parser panicked".into()); } + let header_pragmas = jsx_pragmas::HeaderPragmas::capture(&program); let inlined = if processed_code.is_none() { imported_constants::inline_constants( &oxc_ast::builder::AstBuilder::new(&allocator), @@ -590,6 +619,7 @@ fn extract_source( } else { CodegenOptions::default() }; + header_pragmas.reanchor(&mut program); let result = Codegen::new().with_options(codegen_options).build(&program); // A stylesheet's output is generated, so its map stays on that code let map = result.map.map(|map| { diff --git a/libs/extractor/src/pragma_annotation_tests.rs b/libs/extractor/src/pragma_annotation_tests.rs new file mode 100644 index 000000000..4fbd78d53 --- /dev/null +++ b/libs/extractor/src/pragma_annotation_tests.rs @@ -0,0 +1,58 @@ +use crate::{ExtractOption, extract, extract_without_source_map}; +use css::{class_map::reset_class_map, file_map::reset_file_map}; +use oxc_allocator::Allocator; +use oxc_ast::ast::{Expression, Statement}; +use oxc_parser::Parser; +use oxc_span::SourceType; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case("#__PURE__", "bootstrap();")] +#[case("@__NO_SIDE_EFFECTS__", "function bootstrap() {}")] +#[serial] +fn annotated_header_keeps_its_jsx_instruction_and_node_semantics( + #[case] annotation: &str, + #[case] statement: &str, + #[values(true, false)] source_map: bool, +) { + let source = format!( + "/* {annotation}\n * @jsx h\n */\n{statement}\nimport {{css}} from '@devup-ui/react';\nexport const cls=css({{width:'401px'}});\nexport const anchor=1;" + ); + reset_class_map(); + reset_file_map(); + + let output = if source_map { + extract("annotation.tsx", &source, ExtractOption::default()) + } else { + extract_without_source_map("annotation.tsx", &source, ExtractOption::default()) + } + .unwrap_or_else(|error| panic!("{error}")); + + assert_eq!(output.code.matches("@jsx h").count(), 1, "{}", output.code); + let first_import = output + .code + .find("import ") + .unwrap_or_else(|| panic!("missing stylesheet import")); + assert!( + output.code[..first_import].contains("@jsx h"), + "{}", + output.code + ); + let allocator = Allocator::default(); + let parsed = Parser::new(&allocator, &output.code, SourceType::tsx()).parse(); + assert_eq!(parsed.diagnostics.len(), 0, "{}", output.code); + let annotation_survives = parsed.program.body.iter().any(|statement| match statement { + Statement::ExpressionStatement(statement) => { + matches!(&statement.expression, Expression::CallExpression(call) if call.pure) + } + Statement::FunctionDeclaration(function) => function.pure, + _ => false, + }); + assert!(annotation_survives, "{}", output.code); + if source_map { + crate::pragma_path_tests::assert_anchor_map(&source, &output); + } else { + assert_eq!(output.map, None); + } +} diff --git a/libs/extractor/src/pragma_path_tests.rs b/libs/extractor/src/pragma_path_tests.rs new file mode 100644 index 000000000..1ddda13bd --- /dev/null +++ b/libs/extractor/src/pragma_path_tests.rs @@ -0,0 +1,260 @@ +mod support; + +pub(super) use support::assert_anchor_map; +use support::{PRAGMAS, assert_header, reset}; + +use crate::{ExtractOption, ImportAlias, ResolvedModule, extract, extract_with_modules}; +use oxc_allocator::Allocator; +use oxc_parser::Parser; +use oxc_span::SourceType; +use rstest::rstest; +use serial_test::serial; +use std::collections::HashMap; + +#[rstest] +#[case("ts")] +#[case("tsx")] +#[serial] +fn styled_headers_survive_when_helper_imports_are_inserted(#[case] extension: &str) { + // Given: headers on either side of the directive and a compiled styled renderer. + reset(); + let filename = format!("styled-pragma.{extension}"); + let source = format!( + "{}\n'use client';\n{}\nimport {{styled}} from '@devup-ui/react';\nexport const Card=styled('div', {{width:'407px'}});\nexport const anchor=1;", + PRAGMAS[0], + PRAGMAS[1..].join("\n") + ); + // When: extraction inserts the renderer helper and stylesheet import. + let output = extract(&filename, &source, ExtractOption::default()) + .unwrap_or_else(|error| panic!("{error}")); + // Then: the headers and directive remain ahead of all module body statements. + assert_header(&filename, &output, &PRAGMAS); + assert!( + output + .code + .find(PRAGMAS[0]) + .unwrap_or_else(|| panic!("missing JSX pragma")) + < output + .code + .find("use client") + .unwrap_or_else(|| panic!("missing use client directive")) + ); + assert!( + output + .code + .find("use client") + .unwrap_or_else(|| panic!("missing use client directive")) + < output + .code + .find(PRAGMAS[1]) + .unwrap_or_else(|| panic!("missing JSX fragment pragma")) + ); + assert_anchor_map(&source, &output); +} + +#[test] +#[serial] +fn namespace_headers_survive_when_no_target_import_remains() { + // Given: a namespace alias that normalization removes without generating styles. + reset(); + let source = format!( + "{}\n'use client';\nimport * as E from '@emotion/css';\nconst N=E;\nexport const anchor=1;", + PRAGMAS.join("\n") + ); + let options = ExtractOption { + import_aliases: HashMap::from([("@emotion/css".into(), ImportAlias::NamedToNamed)]), + ..ExtractOption::default() + }; + // When: the namespace-only output seam emits the rewritten module. + let output = + extract("namespace-pragma.tsx", &source, options).unwrap_or_else(|error| panic!("{error}")); + // Then: headers precede the surviving module with original-source mappings. + assert_header("namespace-pragma.tsx", &output, &PRAGMAS); + assert!(output.css_file.is_none()); + assert_anchor_map(&source, &output); +} + +#[rstest] +#[case("ts")] +#[case("js")] +#[serial] +fn stylesheet_headers_survive_when_source_is_synthesized( + #[case] extension: &str, + #[values(true, false)] emotion_enabled: bool, +) { + // Given: a stylesheet with an authored pragma and a retained side-effect import. + reset(); + let filename = format!("pragma.css.{extension}"); + let source = format!( + "{}\nimport './reset.css';\nimport {{style}} from '@vanilla-extract/css';\nexport const cls=style({{width:'409px'}});", + PRAGMAS.join("\n") + ); + let mut aliases = HashMap::from([("@vanilla-extract/css".into(), ImportAlias::NamedToNamed)]); + if emotion_enabled { + aliases.insert("@emotion/react".into(), ImportAlias::NamedToNamed); + } + let options = ExtractOption { + import_aliases: aliases, + ..ExtractOption::default() + }; + // When: collection replaces the authored stylesheet with generated exports. + let output = extract(&filename, &source, options).unwrap_or_else(|error| panic!("{error}")); + // Then: headers precede retained imports and the map still targets generated source. + let expected = PRAGMAS.map(|pragma| { + if emotion_enabled && pragma == PRAGMAS[3] { + "/** @jsxImportSource react */" + } else { + pragma + } + }); + assert_header(&filename, &output, &expected); + assert!(output.code.contains("./reset.css")); + assert!(!output.styles.is_empty()); + let map = oxc_sourcemap::SourceMap::from_json_string( + output + .map + .as_deref() + .unwrap_or_else(|| panic!("missing source map")), + ) + .unwrap_or_else(|error| panic!("{error}")); + let generated = map + .get_source_content(0) + .unwrap_or_else(|| panic!("missing generated source content")); + assert!(generated.contains(PRAGMAS[0])); + assert!(!generated.contains("@vanilla-extract/css")); +} + +#[rstest] +#[case("")] +#[case("'use client';\n")] +#[serial] +fn headers_survive_when_the_only_import_is_consumed(#[case] directive: &str) { + // Given: a hashbang and pragmas surrounding an optional directive. + reset(); + let source = format!( + "#!/usr/bin/env node\n{}\n{directive}{}\nimport {{css}} from '@devup-ui/react';", + PRAGMAS[0], + PRAGMAS[1..].join("\n") + ); + // When: extraction removes the only body statement. + let output = extract("empty-pragma.ts", &source, ExtractOption::default()) + .unwrap_or_else(|error| panic!("{error}")); + // Then: hashbang is first and the remaining directive prologue is intact. + assert!(output.code.starts_with("#!/usr/bin/env node\n")); + assert_header("empty-pragma.ts", &output, &PRAGMAS); + let allocator = Allocator::default(); + let parsed = Parser::new(&allocator, &output.code, SourceType::ts()).parse(); + assert_eq!( + parsed.program.directives.len(), + usize::from(!directive.is_empty()) + ); + assert!(parsed.program.body.is_empty()); +} + +#[test] +#[serial] +fn hashbang_remains_first_when_a_synthetic_import_takes_the_header() { + // Given: a hashbang before the pragma attached to a compiled API import. + reset(); + let source = format!( + "#!/usr/bin/env node\n{}\n'use client';\n{}\nimport {{css}} from '@devup-ui/react';\nexport const cls=css({{width:'419px'}});\nexport const anchor=1;", + PRAGMAS[0], + PRAGMAS[1..].join("\n") + ); + // When: the consumed import is replaced by a synthetic stylesheet import. + let output = extract("hashbang-pragma.ts", &source, ExtractOption::default()) + .unwrap_or_else(|error| panic!("{error}")); + // Then: Oxc accounts for header emission after the hashbang in its mappings. + assert!(output.code.starts_with("#!/usr/bin/env node\n")); + assert_header("hashbang-pragma.ts", &output, &PRAGMAS); + assert_anchor_map(&source, &output); +} + +#[rstest] +#[case(PRAGMAS.join("\n"))] +#[case(String::new())] +#[serial] +fn headers_survive_when_imported_constants_require_evaluation(#[case] header: String) { + // Given: a style value that evaluates a local function with an imported constant. + reset(); + let source = format!( + "{header}\nimport {{css}} from '@devup-ui/react';\nimport {{SIZE}} from './size';\nfunction twice(n){{return n*2}}\nexport const cls=css({{width:twice(SIZE)+'px'}});\nexport const anchor=1;" + ); + let resolver = |specifier: &str, _: &str| { + (specifier == "./size").then(|| ResolvedModule { + path: "/pragma-size.ts".into(), + code: "export const SIZE=5;".into(), + }) + }; + // When: extraction re-enters after build-time evaluation rewrites the call. + let output = extract_with_modules( + "/pragma-app.ts", + &source, + ExtractOption::default(), + true, + &resolver, + ) + .unwrap_or_else(|error| panic!("{error}")); + // Then: headers are emitted once and the surviving declaration maps to authored input. + if !header.is_empty() { + assert_header("/pragma-app.ts", &output, &PRAGMAS); + } + assert_anchor_map(&source, &output); + assert!(!output.styles.is_empty()); +} + +#[rstest] +#[case("", "export const x=1;")] +#[case("/** @jsx h */", "")] +#[case("const value='@jsx';", "export const x=1;")] +fn stylesheet_generation_is_unchanged_when_no_header_is_carried( + #[case] source: &str, + #[case] generated: &str, +) { + // Given: empty generation or source without an actual header pragma. + // When: constructing the stylesheet intermediate source. + let output = crate::jsx_pragmas::stylesheet_source(source, generated.into(), "empty.css.ts"); + // Then: generated bytes retain their existing contract. + assert_eq!(output, generated); +} + +#[test] +#[serial] +fn body_comments_and_near_misses_stay_out_of_the_header() { + // Given: an exact compact pragma, similar tokens, strings, and a body comment. + reset(); + let source = "/**@jsx h*/\n/* @jsxFactory nope */\n/* @jsxFragExtra nope */\n/* @jsxRuntimeExtra nope */\n/* @jsxImportSourceExtra nope */\n/* @jsx* nope */\nimport {css} from '@devup-ui/react';\nexport const text='@jsxImportSource fake';\n/** @jsxFrag BodyFragment */\nexport const cls=css({width:'421px'});"; + // When: extraction removes the import that anchored the header comments. + let output = extract("negative-pragma.ts", source, ExtractOption::default()) + .unwrap_or_else(|error| panic!("{error}")); + // Then: only the exact authored header directive is hoisted. + assert_header("negative-pragma.ts", &output, &["/**@jsx h*/"]); + let first_import = output + .code + .find("import ") + .unwrap_or_else(|| panic!("missing generated import")); + for near_miss in [ + "@jsxFactory", + "@jsxFragExtra", + "@jsxRuntimeExtra", + "@jsxImportSourceExtra", + "@jsx*", + ] { + assert!( + !output.code[..first_import].contains(near_miss), + "{}", + output.code + ); + } + assert!(!output.code[..first_import].contains("fake")); + assert!( + output + .code + .find("BodyFragment") + .unwrap_or_else(|| panic!("missing body fragment comment")) + > output + .code + .find("export const text") + .unwrap_or_else(|| panic!("missing text export")) + ); +} diff --git a/libs/extractor/src/pragma_path_tests/support.rs b/libs/extractor/src/pragma_path_tests/support.rs new file mode 100644 index 000000000..511a6858e --- /dev/null +++ b/libs/extractor/src/pragma_path_tests/support.rs @@ -0,0 +1,82 @@ +use crate::ExtractOutput; +use css::{class_map::reset_class_map, file_map::reset_file_map}; +use oxc_allocator::Allocator; +use oxc_parser::Parser; +use oxc_span::{GetSpan, SourceType}; + +pub(super) const PRAGMAS: [&str; 4] = [ + "/** @jsx h */", + "// @jsxFrag Fragment", + "/* @jsxRuntime automatic */", + "/** @jsxImportSource @emotion/react */", +]; + +pub(super) fn reset() { + reset_class_map(); + reset_file_map(); +} + +pub(super) fn assert_header(filename: &str, output: &ExtractOutput, pragmas: &[&str]) { + let allocator = Allocator::default(); + let parsed = Parser::new( + &allocator, + &output.code, + SourceType::from_path(filename).unwrap_or_else(|error| panic!("{error}")), + ) + .parse(); + assert!(parsed.diagnostics.is_empty(), "{}", output.code); + let end = parsed + .program + .body + .first() + .map_or(output.code.len(), |node| { + usize::try_from(node.span().start).unwrap_or_else(|error| panic!("{error}")) + }); + for pragma in pragmas { + assert_eq!(output.code.matches(pragma).count(), 1, "{}", output.code); + assert!(output.code[..end].contains(pragma), "{}", output.code); + } +} + +pub(crate) fn assert_anchor_map(source: &str, output: &ExtractOutput) { + let map = oxc_sourcemap::SourceMap::from_json_string( + output + .map + .as_deref() + .unwrap_or_else(|| panic!("missing source map")), + ) + .unwrap_or_else(|error| panic!("{error}")); + assert_eq!(map.get_source_content(0), Some(source)); + let (line, text) = output + .code + .lines() + .enumerate() + .find(|(_, text)| text.contains("anchor")) + .unwrap_or_else(|| panic!("missing generated anchor line")); + let column = text + .find("anchor") + .unwrap_or_else(|| panic!("missing generated anchor column")); + let token = map + .get_tokens() + .find(|token| { + usize::try_from(token.get_dst_line()).unwrap_or_else(|error| panic!("{error}")) == line + && usize::try_from(token.get_dst_col()).unwrap_or_else(|error| panic!("{error}")) + == column + }) + .unwrap_or_else(|| panic!("missing anchor source-map token")); + let (original_line, original) = source + .lines() + .enumerate() + .find(|(_, text)| text.contains("anchor")) + .unwrap_or_else(|| panic!("missing original anchor line")); + assert_eq!( + usize::try_from(token.get_src_line()).unwrap_or_else(|error| panic!("{error}")), + original_line + ); + assert_eq!( + usize::try_from(token.get_src_col()).unwrap_or_else(|error| panic!("{error}")), + original + .find("anchor") + .unwrap_or_else(|| panic!("missing original anchor column")) + ); +} diff --git a/libs/extractor/src/pragma_tests.rs b/libs/extractor/src/pragma_tests.rs new file mode 100644 index 000000000..f6617bb8e --- /dev/null +++ b/libs/extractor/src/pragma_tests.rs @@ -0,0 +1,211 @@ +use crate::{ExtractOption, ImportAlias, extract, extract_without_source_map}; +use css::{class_map::reset_class_map, file_map::reset_file_map}; +use oxc_allocator::Allocator; +use oxc_parser::Parser; +use oxc_span::{GetSpan, SourceType}; +use rstest::rstest; +use serial_test::serial; +use std::collections::HashMap; + +#[rstest] +#[serial] +fn leading_jsx_pragmas_survive_removed_imports( + #[values("js", "jsx", "ts", "tsx")] extension: &str, + #[values( + "/** @jsx createElement */", + "/** @jsxFrag Fragment */", + "/** @jsxRuntime classic */", + "/** @jsxImportSource custom-jsx */" + )] + pragma: &str, + #[values("// ordinary comment\n", "/*! License */\n")] leading: &str, + #[values("before", "after", "none")] directive: &str, + #[values(true, false)] source_map: bool, +) { + let filename = format!("pragma.{extension}"); + let header = match directive { + "before" => format!("{leading}'use client';\n{pragma}\n"), + "after" => format!("{leading}{pragma}\n'use client';\n"), + _ => format!("{leading}{pragma}\n"), + }; + let source = format!( + "{header}import {{css}} from '@devup-ui/react';\nexport const cls=css({{width:'401px'}});\nexport const anchor=1;" + ); + reset_class_map(); + reset_file_map(); + + let output = if source_map { + extract(&filename, &source, ExtractOption::default()) + } else { + extract_without_source_map(&filename, &source, ExtractOption::default()) + } + .unwrap_or_else(|error| panic!("{error}")); + + let arena = Allocator::default(); + let parsed = Parser::new( + &arena, + &output.code, + SourceType::from_path(&filename).unwrap_or_else(|error| panic!("{error}")), + ) + .parse(); + assert_eq!(parsed.diagnostics.len(), 0, "{}", output.code); + let first_statement = parsed.program.body[0].span().start as usize; + assert!( + output.code[..first_statement].contains(pragma), + "{}", + output.code + ); + assert_eq!( + parsed.program.directives.len(), + usize::from(directive != "none") + ); + if let Some(map) = output.map { + let map = oxc_sourcemap::SourceMap::from_json_string(&map) + .unwrap_or_else(|error| panic!("{error}")); + assert_eq!(map.get_source_content(0), Some(source.as_str())); + let (line, generated) = output + .code + .lines() + .enumerate() + .find(|(_, text)| text.contains("anchor")) + .unwrap_or_else(|| panic!("missing generated anchor line")); + let column = generated + .find("anchor") + .unwrap_or_else(|| panic!("missing generated anchor column")); + let token = map + .get_tokens() + .find(|token| { + token.get_dst_line() as usize == line && token.get_dst_col() as usize == column + }) + .unwrap_or_else(|| panic!("missing anchor source-map token")); + assert_eq!( + (token.get_src_line() as usize, token.get_src_col()), + (header.lines().count() + 2, 13) + ); + } else { + assert!(!source_map); + } +} + +#[rstest] +#[serial] +fn jsx_import_source_keeps_the_enabled_emotion_compatibility_bridge( + #[values("@emotion/react", "react", "preact", "custom")] import_source: &str, + #[values(true, false)] emotion_enabled: bool, +) { + let source = format!( + "/** @jsxImportSource {import_source} */\nimport {{css}} from '@devup-ui/react';\nexport const cls=css({{width:'401px'}});\nexport const anchor=1;" + ); + reset_class_map(); + reset_file_map(); + let aliases = if emotion_enabled { + HashMap::from([("@emotion/react".into(), ImportAlias::NamedToNamed)]) + } else { + HashMap::new() + }; + + let output = extract( + "pragma.tsx", + &source, + ExtractOption { + import_aliases: aliases, + ..ExtractOption::default() + }, + ) + .unwrap_or_else(|error| panic!("{error}")); + + let expected = if emotion_enabled && import_source == "@emotion/react" { + "react" + } else { + import_source + }; + assert!( + output + .code + .starts_with(&format!("/** @jsxImportSource {expected} */\n")), + "{}", + output.code + ); + let map = oxc_sourcemap::SourceMap::from_json_string( + output + .map + .as_deref() + .unwrap_or_else(|| panic!("missing source map")), + ) + .unwrap_or_else(|error| panic!("{error}")); + assert_eq!(map.get_source_content(0), Some(source.as_str())); +} + +#[test] +#[serial] +fn opted_out_emotion_only_file_is_unchanged() { + let source = "/** @jsxImportSource @emotion/react */\nimport {css} from '@emotion/react';\nexport const cls=css({color:'red'});"; + + let output = extract("pragma.tsx", source, ExtractOption::default()) + .unwrap_or_else(|error| panic!("{error}")); + + assert_eq!(output.code, source); + assert_eq!(output.map, None); +} + +#[rstest] +#[serial] +fn enabled_emotion_pragma_is_rewritten_without_a_devup_import( + #[values(true, false)] namespace: bool, + #[values(true, false)] source_map: bool, +) { + let namespace_code = if namespace { + "import * as E from '@emotion/css'; const N=E;\n" + } else { + "" + }; + let source = + format!("/** @jsxImportSource @emotion/react */ {namespace_code}export const anchor=1;"); + reset_class_map(); + reset_file_map(); + let options = ExtractOption { + import_aliases: HashMap::from([ + ("@emotion/react".into(), ImportAlias::NamedToNamed), + ("@emotion/css".into(), ImportAlias::NamedToNamed), + ]), + ..ExtractOption::default() + }; + + let output = if source_map { + extract("pragma.tsx", &source, options) + } else { + extract_without_source_map("pragma.tsx", &source, options) + } + .unwrap_or_else(|error| panic!("{error}")); + + assert!( + output.code.starts_with("/** @jsxImportSource react */\n"), + "{}", + output.code + ); + if source_map { + crate::pragma_path_tests::assert_anchor_map(&source, &output); + } else { + assert_eq!(output.map, None); + } +} + +#[test] +#[serial] +fn complete_pragma_header_keeps_directive_order() { + let source = "/*! License */\n/** @jsxRuntime classic */\n'use client';\n/** @jsx createElement */\n/** @jsxFrag Fragment */\n/** @jsxImportSource custom-jsx */\nimport {Box,css} from '@devup-ui/react';\nexport const cls=css({width:'401px'});\nexport const box=;"; + reset_class_map(); + reset_file_map(); + + let output = extract( + "pragma.tsx", + source, + ExtractOption { + single_css: true, + ..ExtractOption::default() + }, + ) + .unwrap_or_else(|error| panic!("{error}")); + + insta::assert_snapshot!(output.code); +} diff --git a/libs/extractor/src/snapshots/extractor__pragma_tests__complete_pragma_header_keeps_directive_order.snap b/libs/extractor/src/snapshots/extractor__pragma_tests__complete_pragma_header_keeps_directive_order.snap new file mode 100644 index 000000000..847f87d7b --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__pragma_tests__complete_pragma_header_keeps_directive_order.snap @@ -0,0 +1,13 @@ +--- +source: libs/extractor/src/pragma_tests.rs +expression: output.code +--- +/*! License */ +/** @jsxRuntime classic */ +"use client"; +/** @jsx createElement */ +/** @jsxFrag Fragment */ +/** @jsxImportSource custom-jsx */ +import "@devup-ui/react/devup-ui.css"; +export const cls = "a"; +export const box =
; diff --git a/packages/bun-plugin/__regression__/jsx-pragmas.bun.ts b/packages/bun-plugin/__regression__/jsx-pragmas.bun.ts new file mode 100644 index 000000000..6773acdf9 --- /dev/null +++ b/packages/bun-plugin/__regression__/jsx-pragmas.bun.ts @@ -0,0 +1,67 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +import { expect, it } from 'bun:test' + +const registerEntry = resolve(import.meta.dir, '..', 'dist', 'register.mjs') + +it('honors classic JSX pragmas after extracting direct Devup imports', () => { + const cwd = mkdtempSync(join(tmpdir(), 'devup-jsx-pragmas-')) + try { + writeFileSync(join(cwd, 'bunfig.toml'), '') + writeFileSync( + join(cwd, 'consumer.tsx'), + `/** @jsxRuntime classic */ +/** @jsx createElement */ +import {Box,css} from '@devup-ui/react'; +function createElement(type,props){return {type,props};} +export const cls=css({width:'401px'}); +export const box=;`, + ) + writeFileSync( + join(cwd, 'check.ts'), + `import { expect } from 'bun:test' +import { DevupUI } from ${JSON.stringify(registerEntry.replaceAll('\\', '/'))} + +const artifacts = [] +for (const attempt of [0, 1]) { + const result = await Bun.build({ + entrypoints: ['./consumer.tsx'], + outdir: './out', + target: 'bun', + plugins: [DevupUI()], + }) + expect(result.success, result.logs.map(String).join('\\n')).toBe(true) + const js = result.outputs.find(output => output.path.endsWith('.js')) + const css = result.outputs.find(output => output.path.endsWith('.css')) + expect(js).toBeDefined() + expect(css).toBeDefined() + const code = await js.text() + const styles = (await css.text()).replace(/\\s+/g, '') + expect(code).not.toMatch(/react\\/jsx|Cannot run on the runtime|@devup-ui\\/react/) + expect(styles).toContain('width:401px') + expect(styles).toContain('width:403px') + const { box, cls } = await import(js.path + '?attempt=' + attempt) + expect(box.type).toBe('div') + expect(typeof box.props.className).toBe('string') + expect(typeof cls).toBe('string') + artifacts.push([code, styles]) +} +expect(artifacts[0]).toEqual(artifacts[1]) +`, + ) + const result = Bun.spawnSync([process.execPath, 'run', 'check.ts'], { + cwd, + stdout: 'pipe', + stderr: 'pipe', + env: { ...process.env, BUN_RUNTIME_TRANSPILER_CACHE_PATH: '0' }, + }) + expect( + result.exitCode, + result.stdout.toString() + result.stderr.toString(), + ).toBe(0) + } finally { + rmSync(cwd, { recursive: true, force: true }) + } +}) diff --git a/packages/bun-plugin/package.json b/packages/bun-plugin/package.json index 0542861cf..43b088da0 100644 --- a/packages/bun-plugin/package.json +++ b/packages/bun-plugin/package.json @@ -22,7 +22,7 @@ "scripts": { "lint": "eslint", "build": "bun ../../node_modules/@typescript/native/bin/tsc && bun build --target node src/index.cjs.ts --production --env=disable --outfile dist/index.cjs --format cjs --packages external && bun build --target node src/index.ts --production --env=disable --outfile dist/index.mjs --format esm --packages external && bun build --target node src/register.ts --production --env=disable --outfile dist/register.cjs --format cjs --packages external && bun build --target node src/register.ts --production --env=disable --outfile dist/register.mjs --format esm --packages external", - "test:regression": "cd __regression__ && bun test ./preload-race.bun.ts ./worktree-isolation.bun.ts ./css-emit.bun.ts" + "test:regression": "cd __regression__ && bun test ./preload-race.bun.ts ./worktree-isolation.bun.ts ./css-emit.bun.ts ./jsx-pragmas.bun.ts" }, "publishConfig": { "access": "public"