diff --git a/.changeset/clean-empty-media-rules.md b/.changeset/clean-empty-media-rules.md new file mode 100644 index 000000000..843943314 --- /dev/null +++ b/.changeset/clean-empty-media-rules.md @@ -0,0 +1,6 @@ +--- +"@weapp-tailwindcss/postcss": patch +"weapp-tailwindcss": patch +--- + +修复小程序样式在非主样式块、缓存产物与嵌套条件规则中残留空 `@media`、`@supports` 等块级 at-rule,避免生成的 WXSS 因空媒体查询触发编译错误。 diff --git a/packages/postcss/src/compat/mini-program-css/finalize-options.ts b/packages/postcss/src/compat/mini-program-css/finalize-options.ts index b96c4b455..d3e234beb 100644 --- a/packages/postcss/src/compat/mini-program-css/finalize-options.ts +++ b/packages/postcss/src/compat/mini-program-css/finalize-options.ts @@ -4,6 +4,13 @@ export interface FinalizeMiniProgramCssOptions { cssPreflight?: CssPreflightOptions | undefined cssSelectorReplacement?: CssSelectorReplacement | undefined isTailwindcssV4?: boolean | undefined + /** + * 是否递归移除子规则被清理而变空的父级条件规则。 + * + * 增量生成的 CSS 可能只包含条件规则中的新片段,父级容器由已缓存产物提供, + * 此时应保留占位容器,避免后续追加 CSS 时丢失层级。 + */ + removeEmptyAtRuleAncestors?: boolean | undefined /** * 是否为 Tailwind CSS v4 渐变工具类生成小程序字面量兜底。 */ diff --git a/packages/postcss/src/compat/mini-program-css/finalize.ts b/packages/postcss/src/compat/mini-program-css/finalize.ts index 6386f9389..a7714be66 100644 --- a/packages/postcss/src/compat/mini-program-css/finalize.ts +++ b/packages/postcss/src/compat/mini-program-css/finalize.ts @@ -80,7 +80,16 @@ function finalizeMiniProgramCssRoot(root: postcss.Root, options: FinalizeMiniPro const themeRule = collectThemeVariableRule(root, options) const hoistedRules = themeRule ? [...preflightRules, themeRule] : preflightRules insertHoistedRules(root, mergeEquivalentHoistedRules(hoistedRules), hoistAnchor) - removeEmptyAtRules(root) + if (options.removeEmptyAtRuleAncestors !== false) { + removeEmptyAtRules(root) + } + else { + root.walkAtRules((atRule) => { + if (atRule.nodes?.length === 0) { + atRule.remove() + } + }) + } } export function hoistTailwindPreflightBase(css: string) { diff --git a/packages/postcss/src/compat/mini-program-css/index.ts b/packages/postcss/src/compat/mini-program-css/index.ts index 9dc05cfaf..60aac6e1f 100644 --- a/packages/postcss/src/compat/mini-program-css/index.ts +++ b/packages/postcss/src/compat/mini-program-css/index.ts @@ -17,5 +17,6 @@ export { } from './prune-generated' export { hasMiniProgramCssSpecificityPlaceholders, + removeEmptyAtRules, stripMiniProgramCssSpecificityPlaceholders, } from './root-cleanups' diff --git a/packages/postcss/src/compat/mini-program-css/root-cleanups.ts b/packages/postcss/src/compat/mini-program-css/root-cleanups.ts index 36e4cab12..ebe2df3ac 100644 --- a/packages/postcss/src/compat/mini-program-css/root-cleanups.ts +++ b/packages/postcss/src/compat/mini-program-css/root-cleanups.ts @@ -80,11 +80,33 @@ function isEffectivelyEmptyContainer(container: postcss.Container) { } export function removeEmptyAtRules(root: postcss.Root) { + let removed = 0 + const visit = (container: postcss.Container) => { + for (const node of [...(container.nodes ?? [])]) { + if (!('nodes' in node) || node.nodes === undefined) { + continue + } + visit(node) + if (node.type === 'atrule' && node.parent && isEffectivelyEmptyContainer(node)) { + node.remove() + removed++ + } + } + } + + visit(root) + return removed +} + +export function removeEmptyBlockAtRules(root: postcss.Root) { + let removed = 0 root.walkAtRules((atRule) => { - if (isEffectivelyEmptyContainer(atRule)) { + if (atRule.nodes?.length === 0) { atRule.remove() + removed++ } }) + return removed } function removeEmptyAtRuleAncestors(parent: postcss.Container | undefined) { diff --git a/packages/postcss/src/handler.ts b/packages/postcss/src/handler.ts index 93ffa903d..01be9fbe5 100644 --- a/packages/postcss/src/handler.ts +++ b/packages/postcss/src/handler.ts @@ -6,6 +6,7 @@ import { defuOverrideArray } from '@weapp-tailwindcss/shared' import { LRUCache } from 'lru-cache' import postcss from 'postcss' import { protectDynamicColorMixAlpha } from './compat/color-mix' +import { removeEmptyBlockAtRules } from './compat/mini-program-css/root-cleanups' import { probeFeatures, signalToCacheKey } from './content-probe' import { getDefaultOptions } from './defaults' import { fingerprintOptions } from './fingerprint' @@ -128,6 +129,19 @@ export function createStyleHandler(options?: Partial): Sty ).async().then((result) => { const styleBranch = resolvePostcssFrameworkProfile(resolvedOptions) let finalResult = styleBranch.postprocess(result, resolvedOptions) + if (resolvedOptions.isMainChunk !== false && finalResult.root) { + let removed = 0 + let removedTotal = 0 + do { + removed = removeEmptyBlockAtRules(finalResult.root) + removedTotal += removed + } while (removed > 0) + if (removedTotal > 0) { + const nextResult = finalResult.root.toResult(finalResult.opts) + nextResult.messages.push(...finalResult.messages) + finalResult = nextResult + } + } if (protectedColorMix) { const restoredCss = protectedColorMix.restore(finalResult.css) if (restoredCss !== finalResult.css) { diff --git a/packages/postcss/src/index.ts b/packages/postcss/src/index.ts index e2bae8908..4233764c0 100644 --- a/packages/postcss/src/index.ts +++ b/packages/postcss/src/index.ts @@ -21,6 +21,7 @@ export { hoistTailwindPreflightBase, normalizeMiniProgramGeneratedCssForPostcss, pruneMiniProgramGeneratedCss, + removeEmptyAtRules, removeUnsupportedAtSupports, removeUnsupportedCascadeLayers, removeUnsupportedMiniProgramAtRules, diff --git a/packages/postcss/test/mini-program-css.test.ts b/packages/postcss/test/mini-program-css.test.ts index d48097455..0eb4dd7f7 100644 --- a/packages/postcss/test/mini-program-css.test.ts +++ b/packages/postcss/test/mini-program-css.test.ts @@ -681,6 +681,23 @@ describe('mini-program css cleanup', () => { expect(css).not.toContain('margin') }) + it('keeps incremental at-rule ancestors when recursive cleanup is disabled', () => { + const css = finalizeMiniProgramCss('@media screen{/* incremental placeholder */}', { + cssPreflight: false, + removeEmptyAtRuleAncestors: false, + }) + + expect(css).toBe('@media screen{/* incremental placeholder */}') + }) + + it('recursively removes empty at-rule ancestors for complete css output', () => { + const css = finalizeMiniProgramCss('@media screen{@supports (display:grid){}}', { + cssPreflight: false, + }) + + expect(css).toBe('') + }) + it('prunes browser-only generated css while preserving useful mini-program selectors', () => { const css = pruneMiniProgramGeneratedCss([ '/* #ifdef MP-WEIXIN */', diff --git a/packages/postcss/test/mini-program-generated-css.test.ts b/packages/postcss/test/mini-program-generated-css.test.ts index c44e6c1b1..ad5bcb413 100644 --- a/packages/postcss/test/mini-program-generated-css.test.ts +++ b/packages/postcss/test/mini-program-generated-css.test.ts @@ -107,6 +107,19 @@ describe('mini-program generated css cleanup', () => { expect(css).not.toContain('box-sizing:border-box') }) + it('removes empty conditional at-rules from generated mini-program css', () => { + const css = finalizeMiniProgramCss([ + '@media (prefers-color-scheme: light) {}', + '@media (prefers-color-scheme: dark) { /* removed declarations */ }', + '@media screen { @supports (display: grid) {} }', + '.keep{color:red}', + ].join('\n'), { isTailwindcssV4: true }) + + expect(css).not.toContain('@media') + expect(css).not.toContain('@supports') + expect(css).toContain('.keep{color:red}') + }) + it('preserves user page custom properties that use Tailwind v4 theme namespaces', async () => { const styleHandler = createStyleHandler({ majorVersion: 4, diff --git a/packages/postcss/test/post.test.ts b/packages/postcss/test/post.test.ts index 7f1a5f820..9d77e6d6d 100644 --- a/packages/postcss/test/post.test.ts +++ b/packages/postcss/test/post.test.ts @@ -1,4 +1,5 @@ import postcss from 'postcss' +import { createStyleHandler } from '@/handler' import { postcssWeappTailwindcssPostPlugin } from '@/plugins/post' describe('postcss post plugin', () => { @@ -26,4 +27,34 @@ describe('postcss post plugin', () => { ]).process(rawCode) expect(css).toMatchSnapshot() }) + + it('preserves standalone conditional placeholders for incremental css assembly', async () => { + const input = '@media (min-width: 64rem) { /* incremental placeholder */ }' + const result = await postcss([ + postcssWeappTailwindcssPostPlugin({ + isMainChunk: true, + }), + ]).process(input, { from: undefined }) + + expect(result.css).toBe(input) + }) + + it('removes nested empty blocks after the postcss lifecycle completes', async () => { + const styleHandler = createStyleHandler({ + isMainChunk: true, + }) + const { css } = await styleHandler('@media (min-width: 64rem) { @supports (display: grid) {} }') + + expect(css).toBe('') + }) + + it('keeps comment-only incremental placeholders after postprocessing', async () => { + const input = '@media (min-width: 64rem) { /* incremental placeholder */ }' + const styleHandler = createStyleHandler({ + isMainChunk: true, + }) + const { css } = await styleHandler(input) + + expect(css).toBe(input) + }) }) diff --git a/packages/weapp-tailwindcss/src/bundlers/shared/generator-css/generation-helpers/preflight.ts b/packages/weapp-tailwindcss/src/bundlers/shared/generator-css/generation-helpers/preflight.ts index e43429086..f86dbbecf 100644 --- a/packages/weapp-tailwindcss/src/bundlers/shared/generator-css/generation-helpers/preflight.ts +++ b/packages/weapp-tailwindcss/src/bundlers/shared/generator-css/generation-helpers/preflight.ts @@ -18,7 +18,12 @@ export function finalizeMiniProgramGeneratorCss( target: string, _majorVersion: number | undefined, cssPreflight: InternalUserDefinedOptions['cssPreflight'], - options: { injectPreflight?: boolean, preservePreflight?: boolean, styleOptions?: Partial | undefined } = {}, + options: { + injectPreflight?: boolean | undefined + preservePreflight?: boolean | undefined + removeEmptyAtRuleAncestors?: boolean | undefined + styleOptions?: Partial | undefined + } = {}, ) { if (!isMiniProgramGeneratorTarget(target)) { return css @@ -29,6 +34,7 @@ export function finalizeMiniProgramGeneratorCss( cssSelectorReplacement: options.styleOptions?.cssOptions?.cssSelectorReplacement ?? options.styleOptions?.cssSelectorReplacement, isTailwindcssV4: true, + removeEmptyAtRuleAncestors: options.removeEmptyAtRuleAncestors, tailwindcssV4GradientFallback: options.styleOptions?.cssOptions?.tailwindcssV4GradientFallback ?? options.styleOptions?.tailwindcssV4GradientFallback, }) @@ -48,6 +54,7 @@ export function finalizeMiniProgramGeneratorCss( cssSelectorReplacement: options.styleOptions?.cssOptions?.cssSelectorReplacement ?? options.styleOptions?.cssSelectorReplacement, isTailwindcssV4: true, + removeEmptyAtRuleAncestors: options.removeEmptyAtRuleAncestors, tailwindcssV4GradientFallback: options.styleOptions?.cssOptions?.tailwindcssV4GradientFallback ?? options.styleOptions?.tailwindcssV4GradientFallback, }) diff --git a/packages/weapp-tailwindcss/src/bundlers/shared/generator-css/pipeline/context.ts b/packages/weapp-tailwindcss/src/bundlers/shared/generator-css/pipeline/context.ts index 546c87f0d..ad00c100f 100644 --- a/packages/weapp-tailwindcss/src/bundlers/shared/generator-css/pipeline/context.ts +++ b/packages/weapp-tailwindcss/src/bundlers/shared/generator-css/pipeline/context.ts @@ -15,6 +15,7 @@ export interface GeneratorPipelineExecutionContext { options?: { injectPreflight?: boolean | undefined preservePreflight?: boolean | undefined + removeEmptyAtRuleAncestors?: boolean | undefined styleOptions?: Partial | undefined }, ) => string diff --git a/packages/weapp-tailwindcss/src/bundlers/shared/generator-css/pipeline/ordered-output.ts b/packages/weapp-tailwindcss/src/bundlers/shared/generator-css/pipeline/ordered-output.ts index 5ef6f8070..9a9eea958 100644 --- a/packages/weapp-tailwindcss/src/bundlers/shared/generator-css/pipeline/ordered-output.ts +++ b/packages/weapp-tailwindcss/src/bundlers/shared/generator-css/pipeline/ordered-output.ts @@ -21,6 +21,7 @@ export async function finalizeOrderedGeneratorCss( const css = incrementalCss.trim().length > 0 ? finalizeIncrementalGeneratorCss(options.previousCss, incrementalCss, generated.target, majorVersion, opts.cssPreflight, { injectPreflight: false, + removeEmptyAtRuleAncestors: false, styleOptions: generatorStyleOptions, }, generatorOptions.webCompat) : options.previousCss diff --git a/packages/weapp-tailwindcss/src/bundlers/vite/generate-bundle/final-css-assets.ts b/packages/weapp-tailwindcss/src/bundlers/vite/generate-bundle/final-css-assets.ts index ce411cd82..4eced3283 100644 --- a/packages/weapp-tailwindcss/src/bundlers/vite/generate-bundle/final-css-assets.ts +++ b/packages/weapp-tailwindcss/src/bundlers/vite/generate-bundle/final-css-assets.ts @@ -6,6 +6,7 @@ import { stripMiniProgramCssSpecificityPlaceholders, } from '@/bundlers/shared/css-cleanup' import { AssetEmissionPlan } from '@/compiler' +import { removeEmptyCssAtRules } from '../processed-css-assets/cleanup' import { applyViteAssetEmissionPlan } from './asset-emission-plan' function readAssetSource(output: OutputAsset) { @@ -30,6 +31,7 @@ export async function finalizeMiniProgramCssAssets( onUpdate: GenerateBundleContext['opts']['onUpdate'] recordCssAssetResult: GenerateBundleContext['recordCssAssetResult'] styleHandler: GenerateBundleContext['opts']['styleHandler'] + useIncrementalMode?: boolean | undefined debug?: GenerateBundleContext['debug'] }, ) { @@ -54,7 +56,10 @@ export async function finalizeMiniProgramCssAssets( continue } if (options.lastCssResultByFile?.has(file)) { - const outputCss = stripMiniProgramCssSpecificityPlaceholders(rawSource) + const structurallyCleanSource = options.useIncrementalMode + ? rawSource + : removeEmptyCssAtRules(rawSource) + const outputCss = stripMiniProgramCssSpecificityPlaceholders(structurallyCleanSource) if (outputCss !== rawSource) { plan.write(file, outputCss) writeTargets.set(file, output) @@ -66,6 +71,17 @@ export async function finalizeMiniProgramCssAssets( continue } if (!shouldFinalizeMiniProgramCssAsset(rawSource)) { + const structurallyCleanSource = options.useIncrementalMode + ? rawSource + : removeEmptyCssAtRules(rawSource) + if (structurallyCleanSource !== rawSource) { + plan.write(file, structurallyCleanSource) + writeTargets.set(file, output) + options.recordCssAssetResult?.(file, structurallyCleanSource) + options.onUpdate(file, rawSource, structurallyCleanSource) + options.debug?.('remove empty mini-program css at-rules: %s bytes=%d', file, structurallyCleanSource.length) + updated++ + } continue } diff --git a/packages/weapp-tailwindcss/src/bundlers/vite/generate-bundle/finalize/bundle.ts b/packages/weapp-tailwindcss/src/bundlers/vite/generate-bundle/finalize/bundle.ts index 18e05ba29..a0c1928db 100644 --- a/packages/weapp-tailwindcss/src/bundlers/vite/generate-bundle/finalize/bundle.ts +++ b/packages/weapp-tailwindcss/src/bundlers/vite/generate-bundle/finalize/bundle.ts @@ -209,6 +209,7 @@ export async function finalizeGenerateBundle(options: FinalizeGenerateBundleOpti onUpdate, recordCssAssetResult, styleHandler, + useIncrementalMode, }) recordTimingDetail('finalize.cssAssets', finalCssAssetsStartedAt) const webCompatStartedAt = performance.now() diff --git a/packages/weapp-tailwindcss/src/bundlers/vite/processed-css-assets/cleanup.ts b/packages/weapp-tailwindcss/src/bundlers/vite/processed-css-assets/cleanup.ts index 8389e75ab..d63861081 100644 --- a/packages/weapp-tailwindcss/src/bundlers/vite/processed-css-assets/cleanup.ts +++ b/packages/weapp-tailwindcss/src/bundlers/vite/processed-css-assets/cleanup.ts @@ -1,9 +1,10 @@ import type { OutputAsset, OutputBundle } from 'rollup' import type { CollectViteProcessedCssAssetOptions } from './markers-imports' import type { InternalUserDefinedOptions } from '@/types' -import { isMiniProgramLocalCssImportRequest, parseTailwindCssDirectiveRequest, postcss } from '@weapp-tailwindcss/postcss' +import { isMiniProgramLocalCssImportRequest, parseTailwindCssDirectiveRequest, postcss, removeEmptyAtRules } from '@weapp-tailwindcss/postcss' import path from 'pathe' import { normalizeOutputPathKey } from '../../shared/module-graph' +import { hasEmptyAtRuleBlockCandidate } from './empty-at-rule' import { appendCss, collectImportedStyleFiles, createCssAssetPipelineContext, getAssetFile, isStyleImportRequest, readAssetSource } from './markers-imports' import { isMiniProgramStyleOutputFile, isRootStyleOutputFile } from './style-files' @@ -71,7 +72,7 @@ export function restoreCssImportAtRules(source: string, filtered: string, file?: } export function removeCommentOnlyAtRules(css: string) { - if (!css.includes('@')) { + if (!hasEmptyAtRuleBlockCandidate(css)) { return css } try { @@ -95,6 +96,25 @@ export function removeCommentOnlyAtRules(css: string) { } } +export function removeEmptyCssAtRules(css: string) { + if (!hasEmptyAtRuleBlockCandidate(css)) { + return css + } + try { + const root = postcss.parse(css) + let removed = 0 + let passRemoved = 0 + do { + passRemoved = removeEmptyAtRules(root) + removed += passRemoved + } while (passRemoved > 0) + return removed > 0 ? root.toString() : css + } + catch { + return css + } +} + export function collectImportedBundleCssSources(bundle: OutputBundle, importedStyleFiles: Set) { if (importedStyleFiles.size === 0) { return [] diff --git a/packages/weapp-tailwindcss/src/bundlers/vite/processed-css-assets/empty-at-rule.ts b/packages/weapp-tailwindcss/src/bundlers/vite/processed-css-assets/empty-at-rule.ts new file mode 100644 index 000000000..6b0e172ca --- /dev/null +++ b/packages/weapp-tailwindcss/src/bundlers/vite/processed-css-assets/empty-at-rule.ts @@ -0,0 +1,104 @@ +function isAtRuleNameCharacter(code: number) { + return code === 45 + || (code >= 65 && code <= 90) + || (code >= 97 && code <= 122) +} + +function isCssWhitespace(code: number) { + return code === 9 || code === 10 || code === 12 || code === 13 || code === 32 +} + +function findAtRuleBlockStart(css: string, start: number) { + let parenthesisDepth = 0 + let quote = 0 + let squareBracketDepth = 0 + for (let index = start; index < css.length; index++) { + const code = css.charCodeAt(index) + if (quote !== 0) { + if (code === 92) { + index++ + } + else if (code === quote) { + quote = 0 + } + continue + } + if (code === 34 || code === 39) { + quote = code + continue + } + if (code === 92) { + index++ + continue + } + if (code === 47 && css.charCodeAt(index + 1) === 42) { + const commentEnd = css.indexOf('*/', index + 2) + if (commentEnd < 0) { + return -1 + } + index = commentEnd + 1 + continue + } + if (code === 40) { + parenthesisDepth++ + continue + } + if (code === 41 && parenthesisDepth > 0) { + parenthesisDepth-- + continue + } + if (code === 91) { + squareBracketDepth++ + continue + } + if (code === 93 && squareBracketDepth > 0) { + squareBracketDepth-- + continue + } + if (code === 123 && parenthesisDepth === 0 && squareBracketDepth === 0) { + return index + } + if ((code === 59 || code === 125) && parenthesisDepth === 0 && squareBracketDepth === 0) { + return -1 + } + } + return -1 +} + +function isEmptyAtRuleBody(css: string, blockStart: number) { + for (let index = blockStart + 1; index < css.length; index++) { + const code = css.charCodeAt(index) + if (isCssWhitespace(code)) { + continue + } + if (code === 47 && css.charCodeAt(index + 1) === 42) { + const commentEnd = css.indexOf('*/', index + 2) + if (commentEnd < 0) { + return false + } + index = commentEnd + 1 + continue + } + return code === 125 + } + return false +} + +export function hasEmptyAtRuleBlockCandidate(css: string) { + let searchFrom = 0 + while (searchFrom < css.length) { + const atRuleStart = css.indexOf('@', searchFrom) + if (atRuleStart < 0) { + return false + } + searchFrom = atRuleStart + 1 + if (!isAtRuleNameCharacter(css.charCodeAt(searchFrom))) { + continue + } + const blockStart = findAtRuleBlockStart(css, searchFrom + 1) + if (blockStart >= 0 && isEmptyAtRuleBody(css, blockStart)) { + return true + } + } + return false +} diff --git a/packages/weapp-tailwindcss/test/bundlers/generator-css.unit.test.ts b/packages/weapp-tailwindcss/test/bundlers/generator-css.unit.test.ts index 7b7ac370b..50763a9db 100644 --- a/packages/weapp-tailwindcss/test/bundlers/generator-css.unit.test.ts +++ b/packages/weapp-tailwindcss/test/bundlers/generator-css.unit.test.ts @@ -9899,6 +9899,16 @@ describe('bundlers/shared generator css', () => { expect(css).toContain('background-image:linear-gradient(to right, #06b6d4, #3b82f6)') }) + it('preserves incremental at-rule placeholders during generator finalization', async () => { + const { finalizeMiniProgramGeneratorCss } = await import('@/bundlers/shared/generator-css/generation-helpers') + const css = finalizeMiniProgramGeneratorCss('@media screen{/* incremental placeholder */}', 'weapp', 4, false, { + injectPreflight: false, + removeEmptyAtRuleAncestors: false, + }) + + expect(css).toBe('@media screen{/* incremental placeholder */}') + }) + it('does not inject Tailwind v4 mini-program preflight twice when generator css already has reset', async () => { const { finalizeMiniProgramGeneratorCss } = await import('@/bundlers/shared/generator-css/generation-helpers') const css = finalizeMiniProgramGeneratorCss([ diff --git a/packages/weapp-tailwindcss/test/bundlers/vite-plugin.bundle.unit.test.ts b/packages/weapp-tailwindcss/test/bundlers/vite-plugin.bundle.unit.test.ts index d33318a12..ca58ac800 100644 --- a/packages/weapp-tailwindcss/test/bundlers/vite-plugin.bundle.unit.test.ts +++ b/packages/weapp-tailwindcss/test/bundlers/vite-plugin.bundle.unit.test.ts @@ -15249,6 +15249,68 @@ page { expect(onUpdate).toHaveBeenCalledWith('cached.wxss', expect.any(String), css) }) + it('removes empty conditional at-rules from cached mini-program css assets', async () => { + const { finalizeMiniProgramCssAssets } = await import('@/bundlers/vite/generate-bundle/final-css-assets') + const styleHandler = vi.fn(async (code: string) => ({ css: code })) + const bundle = { + 'app.wxss': { + ...createRollupAsset([ + '@media (prefers-color-scheme: light) {}', + '@media (prefers-color-scheme: dark) { /* removed declarations */ }', + '@media screen { @supports (display: grid) {} }', + '.keep{color:red}', + ].join('\n')), + fileName: 'app.wxss', + }, + } + const onUpdate = vi.fn() + const recordCssAssetResult = vi.fn() + + await finalizeMiniProgramCssAssets(bundle, { + cssMatcher: file => file.endsWith('.wxss'), + getCssHandlerOptions: () => ({ isMainChunk: true } as any), + isWebGeneratorTarget: false, + lastCssResultByFile: new Map([['app.wxss', 'cached']]), + onUpdate, + recordCssAssetResult, + styleHandler, + }) + + const css = (bundle['app.wxss'] as OutputAsset).source.toString() + expect(css).not.toContain('@media') + expect(css).not.toContain('@supports') + expect(css).toContain('.keep{color:red}') + expect(styleHandler).not.toHaveBeenCalled() + expect(recordCssAssetResult).toHaveBeenCalledWith('app.wxss', css) + expect(onUpdate).toHaveBeenCalledWith('app.wxss', expect.any(String), css) + }) + + it('does not rewrite cached css assets during incremental finalization', async () => { + const { finalizeMiniProgramCssAssets } = await import('@/bundlers/vite/generate-bundle/final-css-assets') + const source = '@media (prefers-color-scheme: dark) {}\n.keep{color:red}' + const bundle = { + 'app.wxss': { + ...createRollupAsset(source), + fileName: 'app.wxss', + }, + } + const onUpdate = vi.fn() + + await finalizeMiniProgramCssAssets(bundle, { + cssMatcher: file => file.endsWith('.wxss'), + getCssHandlerOptions: () => ({ isMainChunk: true } as any), + isWebGeneratorTarget: false, + lastCssResultByFile: new Map([['app.wxss', 'cached']]), + onUpdate, + recordCssAssetResult: vi.fn(), + styleHandler: vi.fn(async (code: string) => ({ css: code })), + useIncrementalMode: true, + }) + + expect((bundle['app.wxss'] as OutputAsset).source.toString()).toBe(source) + expect(onUpdate).not.toHaveBeenCalled() + }) + it('logs css diffs when vite css diff debugging is enabled', async () => { const previousDebugCssDiff = process.env.WEAPP_TW_VITE_DEBUG_CSS_DIFF process.env.WEAPP_TW_VITE_DEBUG_CSS_DIFF = '1' diff --git a/packages/weapp-tailwindcss/test/bundlers/vite-processed-css-cleanup.unit.test.ts b/packages/weapp-tailwindcss/test/bundlers/vite-processed-css-cleanup.unit.test.ts new file mode 100644 index 000000000..2ea3fbedd --- /dev/null +++ b/packages/weapp-tailwindcss/test/bundlers/vite-processed-css-cleanup.unit.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import { removeCommentOnlyAtRules, removeEmptyCssAtRules } from '@/bundlers/vite/processed-css-assets/cleanup' +import { hasEmptyAtRuleBlockCandidate } from '@/bundlers/vite/processed-css-assets/empty-at-rule' + +describe('vite processed css cleanup', () => { + it('detects empty block at-rules with a linear precheck', () => { + expect(hasEmptyAtRuleBlockCandidate('@media screen { /* token */ .keep {} }')).toBe(false) + expect(hasEmptyAtRuleBlockCandidate('@media screen { @supports (display: grid) { /* removed */ } }')).toBe(true) + expect(hasEmptyAtRuleBlockCandidate('@supports (background: url(data:image/svg+xml;utf8,test)) {}')).toBe(true) + expect(hasEmptyAtRuleBlockCandidate('@custom "value;{}"; .keep {}')).toBe(false) + }) + + it('cleans empty at-rules without backtracking on comment-rich css', () => { + const source = [ + '@media screen {', + '/* generated token */'.repeat(25), + '.keep { color: red; }', + '}', + '@supports (display: grid) {}', + ].join('\n') + const startedAt = performance.now() + + const css = removeEmptyCssAtRules(source) + + expect(performance.now() - startedAt).toBeLessThan(1000) + expect(css).toContain('@media screen') + expect(css).toContain('.keep { color: red; }') + expect(css).not.toContain('@supports') + }) + + it('cleans comment-only at-rules without backtracking on comment-rich css', () => { + const source = [ + '@media screen {', + '/* generated token */'.repeat(25), + '.keep { color: red; }', + '}', + '@supports (display: grid) { /* removed declarations */ }', + ].join('\n') + const startedAt = performance.now() + + const css = removeCommentOnlyAtRules(source) + + expect(performance.now() - startedAt).toBeLessThan(1000) + expect(css).toContain('@media screen') + expect(css).toContain('.keep { color: red; }') + expect(css).not.toContain('@supports') + }) +})