From 351a4f2e060d40352abfba104e38c2565bfcc0de Mon Sep 17 00:00:00 2001 From: ice breaker <1324318532@qq.com> Date: Tue, 21 Jul 2026 11:21:45 +0800 Subject: [PATCH 01/13] fix(postcss): remove empty mini-program at-rules --- .changeset/clean-empty-media-rules.md | 6 ++++ .../src/compat/mini-program-css/index.ts | 1 + .../mini-program-css/prune-generated.ts | 8 ++--- .../compat/mini-program-css/root-cleanups.ts | 13 +++++-- packages/postcss/src/index.ts | 1 + packages/postcss/src/plugins/post.ts | 2 ++ .../postcss/src/vite-css-rules/containment.ts | 3 +- .../postcss/src/vite-css-rules/coverage.ts | 9 +---- .../src/vite-css-rules/mini-program.ts | 2 +- .../test/mini-program-generated-css.test.ts | 18 ++++++++++ .../vite/generate-bundle/final-css-assets.ts | 14 ++++++-- .../vite/processed-css-assets/cleanup.ts | 16 ++------- .../bundlers/vite-plugin.bundle.unit.test.ts | 36 +++++++++++++++++++ 13 files changed, 95 insertions(+), 34 deletions(-) create mode 100644 .changeset/clean-empty-media-rules.md 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/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/prune-generated.ts b/packages/postcss/src/compat/mini-program-css/prune-generated.ts index f130708da..52ced8a74 100644 --- a/packages/postcss/src/compat/mini-program-css/prune-generated.ts +++ b/packages/postcss/src/compat/mini-program-css/prune-generated.ts @@ -11,7 +11,7 @@ import { isPseudoContentInitRule, usesTwContentVariable, } from './predicates' -import { removeSpecificityPlaceholders, removeTailwindContainerMaxWidthMediaRules, removeTailwindContainerWidthRules, removeUnsupportedModernColorDeclarations } from './root-cleanups' +import { removeEmptyAtRules, removeSpecificityPlaceholders, removeTailwindContainerMaxWidthMediaRules, removeTailwindContainerWidthRules, removeUnsupportedModernColorDeclarations } from './root-cleanups' import { getRuleSelectors, isMiniProgramNativeElementSelector, isUnsupportedBrowserPreflightSelector, MINI_PROGRAM_ELEMENT_SCOPE_SELECTOR, MINI_PROGRAM_ELEMENT_SCOPE_SELECTORS } from './selectors' const DEFAULT_WEAPP_VARIABLE_SCOPE = 'page,.tw-root,wx-root-portal-content,:host' @@ -310,11 +310,7 @@ export function pruneMiniProgramGeneratedCss( ensureMiniProgramElementContentInit(root) } - root.walkAtRules((atRule) => { - if (!atRule.nodes || atRule.nodes.length === 0) { - atRule.remove() - } - }) + removeEmptyAtRules(root) return root.toString() } 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..ed9a8ade9 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,20 @@ function isEffectivelyEmptyContainer(container: postcss.Container) { } export function removeEmptyAtRules(root: postcss.Root) { + const atRules: postcss.AtRule[] = [] root.walkAtRules((atRule) => { - if (isEffectivelyEmptyContainer(atRule)) { + atRules.push(atRule) + }) + + let removed = 0 + for (let index = atRules.length - 1; index >= 0; index--) { + const atRule = atRules[index] + if (atRule?.parent && isEffectivelyEmptyContainer(atRule)) { atRule.remove() + removed++ } - }) + } + return removed } function removeEmptyAtRuleAncestors(parent: postcss.Container | undefined) { 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/src/plugins/post.ts b/packages/postcss/src/plugins/post.ts index 7ccc6a3eb..0867de155 100644 --- a/packages/postcss/src/plugins/post.ts +++ b/packages/postcss/src/plugins/post.ts @@ -2,6 +2,7 @@ import type { Declaration, Plugin, PluginCreator, Root, Rule } from 'postcss' import type { IStyleHandlerOptions } from '../types' import { defu } from '@weapp-tailwindcss/shared' +import { removeEmptyAtRules } from '../compat/mini-program-css/root-cleanups' import { getRuleSelectors, isMiniProgramThemeScopeSelector, MINI_PROGRAM_ELEMENT_SCOPE_SELECTOR } from '../compat/mini-program-css/selectors' import { normalizeMiniProgramPrefixedDeclaration, removeUnsupportedMiniProgramPrefixedAtRule } from '../compat/mini-program-prefixes' import { normalizeTailwindcssRpxDeclaration } from '../compat/tailwindcss-rpx' @@ -226,6 +227,7 @@ const postcssWeappTailwindcssPostPlugin: PostcssWeappTailwindcssRenamePlugin = ( if (shouldInjectTailwindcssV4Defaults || (opts.majorVersion === 4 && usesTailwindcssV4ContentVariable(root))) { injectMissingTailwindcssV4Defaults(root) } + removeEmptyAtRules(root) } if (enableMainChunkTransforms) { diff --git a/packages/postcss/src/vite-css-rules/containment.ts b/packages/postcss/src/vite-css-rules/containment.ts index 4f1ab2794..5a55924c1 100644 --- a/packages/postcss/src/vite-css-rules/containment.ts +++ b/packages/postcss/src/vite-css-rules/containment.ts @@ -1,5 +1,6 @@ +import { removeEmptyAtRules } from '../compat/mini-program-css/root-cleanups' import { postcss } from '../postcss-runtime' -import { isCssRuleCoveredByDeclarations, removeEmptyAtRules } from './coverage' +import { isCssRuleCoveredByDeclarations } from './coverage' import { collectCssRuleContentKeys, collectCssRuleDeclarationKeyMap, collectNormalizedCssNodes, getCssRuleContentKey, normalizeCssForContainment } from './structure' export function filterExistingCssRules(baseCss: string, css: string) { diff --git a/packages/postcss/src/vite-css-rules/coverage.ts b/packages/postcss/src/vite-css-rules/coverage.ts index d02e69777..a5b2468c2 100644 --- a/packages/postcss/src/vite-css-rules/coverage.ts +++ b/packages/postcss/src/vite-css-rules/coverage.ts @@ -1,3 +1,4 @@ +import { removeEmptyAtRules } from '../compat/mini-program-css/root-cleanups' import { postcss } from '../postcss-runtime' import { collectCssRuleDeclarationKeys, collectCssRuleDeclarationRecords, collectCssRuleDeclarations, getCssRuleStructuralKey, isCoveredByBaseVarFallbackDeclaration, isEquivalentVarFallbackDeclaration, normalizeCssDeclarationKey, normalizeCssForContainment, parseVarReferenceValue } from './structure' @@ -170,11 +171,3 @@ export function mergeCoveredCssRuleDeclarations(baseCss: string, css: string) { return { baseCss, css, changed: false } } } - -export function removeEmptyAtRules(root: postcss.Root) { - root.walkAtRules((atRule) => { - if (atRule.nodes && atRule.nodes.every(node => node.type === 'comment')) { - atRule.remove() - } - }) -} diff --git a/packages/postcss/src/vite-css-rules/mini-program.ts b/packages/postcss/src/vite-css-rules/mini-program.ts index 598bcf591..2aae89a8b 100644 --- a/packages/postcss/src/vite-css-rules/mini-program.ts +++ b/packages/postcss/src/vite-css-rules/mini-program.ts @@ -1,7 +1,7 @@ import type { Node, Selector } from 'postcss-selector-parser' import selectorParser from 'postcss-selector-parser' +import { removeEmptyAtRules } from '../compat/mini-program-css/root-cleanups' import { postcss } from '../postcss-runtime' -import { removeEmptyAtRules } from './coverage' import { collectCssRuleDeclarationRecords, collectCssRuleDeclarations, getCssRuleStructuralKeyWithSelectorKey, MINI_PROGRAM_PREFLIGHT_SELECTOR_KEY, MINI_PROGRAM_PREFLIGHT_SELECTOR_KEYS, MINI_PROGRAM_THEME_SCOPE_SELECTOR_KEY, MINI_PROGRAM_THEME_SCOPE_SELECTOR_KEYS, normalizeCssDeclarationKey } from './structure' function normalizeSimpleMiniProgramSelectorNode(node: Node) { diff --git a/packages/postcss/test/mini-program-generated-css.test.ts b/packages/postcss/test/mini-program-generated-css.test.ts index c44e6c1b1..60e47045b 100644 --- a/packages/postcss/test/mini-program-generated-css.test.ts +++ b/packages/postcss/test/mini-program-generated-css.test.ts @@ -107,6 +107,24 @@ describe('mini-program generated css cleanup', () => { expect(css).not.toContain('box-sizing:border-box') }) + it('removes empty conditional at-rules from non-main mini-program chunks', async () => { + const styleHandler = createStyleHandler({ + majorVersion: 4, + }) + const { css } = await styleHandler([ + '@media (prefers-color-scheme: light) {}', + '@media (prefers-color-scheme: dark) { /* removed declarations */ }', + '@media screen { @supports (display: grid) {} }', + '.keep{color:red}', + ].join('\n'), { + isMainChunk: false, + }) + + 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/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..fbb36212c 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 { removeCommentOnlyAtRules } from '../processed-css-assets/cleanup' import { applyViteAssetEmissionPlan } from './asset-emission-plan' function readAssetSource(output: OutputAsset) { @@ -53,8 +54,9 @@ export async function finalizeMiniProgramCssAssets( if (rawSource.trim().length === 0) { continue } + const structurallyCleanSource = removeCommentOnlyAtRules(rawSource) if (options.lastCssResultByFile?.has(file)) { - const outputCss = stripMiniProgramCssSpecificityPlaceholders(rawSource) + const outputCss = stripMiniProgramCssSpecificityPlaceholders(structurallyCleanSource) if (outputCss !== rawSource) { plan.write(file, outputCss) writeTargets.set(file, output) @@ -66,11 +68,19 @@ export async function finalizeMiniProgramCssAssets( continue } if (!shouldFinalizeMiniProgramCssAsset(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 } const cssHandlerOptions = options.getCssHandlerOptions(file) - const { css } = await options.styleHandler(rawSource, { + const { css } = await options.styleHandler(structurallyCleanSource, { ...cssHandlerOptions, autoprefixer: false, cssOptions: { 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..20552ffec 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,7 +1,7 @@ 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 { appendCss, collectImportedStyleFiles, createCssAssetPipelineContext, getAssetFile, isStyleImportRequest, readAssetSource } from './markers-imports' @@ -76,19 +76,7 @@ export function removeCommentOnlyAtRules(css: string) { } try { const root = postcss.parse(css) - let changed = false - root.walkAtRules((atRule) => { - if (!atRule.nodes || atRule.nodes.length === 0) { - return - } - const hasCss = atRule.nodes.some(node => node.type !== 'comment') - if (hasCss) { - return - } - atRule.remove() - changed = true - }) - return changed ? root.toString() : css + return removeEmptyAtRules(root) > 0 ? root.toString() : css } catch { return css 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..bcd691ea2 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,42 @@ 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('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' From 14d099cd9502d6e7a7a7a0cab267457f63b4a0ce Mon Sep 17 00:00:00 2001 From: ice breaker <1324318532@qq.com> Date: Tue, 21 Jul 2026 12:03:21 +0800 Subject: [PATCH 02/13] perf(postcss): avoid redundant empty at-rule scans --- .../compat/mini-program-css/root-cleanups.ts | 22 ++++++++++--------- packages/postcss/src/plugins/post.ts | 4 +++- .../vite/processed-css-assets/cleanup.ts | 2 +- 3 files changed, 16 insertions(+), 12 deletions(-) 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 ed9a8ade9..36f607cfd 100644 --- a/packages/postcss/src/compat/mini-program-css/root-cleanups.ts +++ b/packages/postcss/src/compat/mini-program-css/root-cleanups.ts @@ -80,19 +80,21 @@ function isEffectivelyEmptyContainer(container: postcss.Container) { } export function removeEmptyAtRules(root: postcss.Root) { - const atRules: postcss.AtRule[] = [] - root.walkAtRules((atRule) => { - atRules.push(atRule) - }) - let removed = 0 - for (let index = atRules.length - 1; index >= 0; index--) { - const atRule = atRules[index] - if (atRule?.parent && isEffectivelyEmptyContainer(atRule)) { - atRule.remove() - removed++ + 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 } diff --git a/packages/postcss/src/plugins/post.ts b/packages/postcss/src/plugins/post.ts index 0867de155..eb5013994 100644 --- a/packages/postcss/src/plugins/post.ts +++ b/packages/postcss/src/plugins/post.ts @@ -227,7 +227,9 @@ const postcssWeappTailwindcssPostPlugin: PostcssWeappTailwindcssRenamePlugin = ( if (shouldInjectTailwindcssV4Defaults || (opts.majorVersion === 4 && usesTailwindcssV4ContentVariable(root))) { injectMissingTailwindcssV4Defaults(root) } - removeEmptyAtRules(root) + if (!enableMainChunkTransforms) { + removeEmptyAtRules(root) + } } if (enableMainChunkTransforms) { 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 20552ffec..8173cf850 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 @@ -71,7 +71,7 @@ export function restoreCssImportAtRules(source: string, filtered: string, file?: } export function removeCommentOnlyAtRules(css: string) { - if (!css.includes('@')) { + if (!css.includes('@') || !/@[a-z-]+\b[^{};]*\{(?:\s|\/\*[\s\S]*?\*\/)*\}/i.test(css)) { return css } try { From 511f35a33deedd2db102b05d7fab7f773c56e630 Mon Sep 17 00:00:00 2001 From: ice breaker <1324318532@qq.com> Date: Tue, 21 Jul 2026 12:10:32 +0800 Subject: [PATCH 03/13] perf(postcss): clean empty at-rules in lifecycle --- packages/postcss/src/plugins/post.ts | 16 ++++++---------- .../vite/generate-bundle/final-css-assets.ts | 5 +++-- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/packages/postcss/src/plugins/post.ts b/packages/postcss/src/plugins/post.ts index eb5013994..4dac29c38 100644 --- a/packages/postcss/src/plugins/post.ts +++ b/packages/postcss/src/plugins/post.ts @@ -2,7 +2,6 @@ import type { Declaration, Plugin, PluginCreator, Root, Rule } from 'postcss' import type { IStyleHandlerOptions } from '../types' import { defu } from '@weapp-tailwindcss/shared' -import { removeEmptyAtRules } from '../compat/mini-program-css/root-cleanups' import { getRuleSelectors, isMiniProgramThemeScopeSelector, MINI_PROGRAM_ELEMENT_SCOPE_SELECTOR } from '../compat/mini-program-css/selectors' import { normalizeMiniProgramPrefixedDeclaration, removeUnsupportedMiniProgramPrefixedAtRule } from '../compat/mini-program-prefixes' import { normalizeTailwindcssRpxDeclaration } from '../compat/tailwindcss-rpx' @@ -227,13 +226,10 @@ const postcssWeappTailwindcssPostPlugin: PostcssWeappTailwindcssRenamePlugin = ( if (shouldInjectTailwindcssV4Defaults || (opts.majorVersion === 4 && usesTailwindcssV4ContentVariable(root))) { injectMissingTailwindcssV4Defaults(root) } - if (!enableMainChunkTransforms) { - removeEmptyAtRules(root) - } } - if (enableMainChunkTransforms) { - p.AtRuleExit = (atRule) => { + p.AtRuleExit = (atRule) => { + if (enableMainChunkTransforms) { removeUnsupportedMiniProgramPrefixedAtRule(atRule) /** * @description 移除 property @@ -244,10 +240,10 @@ const postcssWeappTailwindcssPostPlugin: PostcssWeappTailwindcssRenamePlugin = ( } atRule.remove() } - /** - * 清除空节点 - */ - atRule.nodes?.length === 0 && atRule.remove() + } + + if (atRule.nodes?.every(node => node.type === 'comment')) { + atRule.remove() } } return p 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 fbb36212c..77a8e6cf1 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 @@ -54,8 +54,8 @@ export async function finalizeMiniProgramCssAssets( if (rawSource.trim().length === 0) { continue } - const structurallyCleanSource = removeCommentOnlyAtRules(rawSource) if (options.lastCssResultByFile?.has(file)) { + const structurallyCleanSource = removeCommentOnlyAtRules(rawSource) const outputCss = stripMiniProgramCssSpecificityPlaceholders(structurallyCleanSource) if (outputCss !== rawSource) { plan.write(file, outputCss) @@ -68,6 +68,7 @@ export async function finalizeMiniProgramCssAssets( continue } if (!shouldFinalizeMiniProgramCssAsset(rawSource)) { + const structurallyCleanSource = removeCommentOnlyAtRules(rawSource) if (structurallyCleanSource !== rawSource) { plan.write(file, structurallyCleanSource) writeTargets.set(file, output) @@ -80,7 +81,7 @@ export async function finalizeMiniProgramCssAssets( } const cssHandlerOptions = options.getCssHandlerOptions(file) - const { css } = await options.styleHandler(structurallyCleanSource, { + const { css } = await options.styleHandler(rawSource, { ...cssHandlerOptions, autoprefixer: false, cssOptions: { From ccab807ef1fb84c8ea575a261969594b1644ebca Mon Sep 17 00:00:00 2001 From: ice breaker <1324318532@qq.com> Date: Tue, 21 Jul 2026 13:11:40 +0800 Subject: [PATCH 04/13] fix(postcss): preserve incremental at-rule placeholders --- .../compat/mini-program-css/prune-generated.ts | 8 ++++++-- packages/postcss/src/plugins/post.ts | 4 ---- .../postcss/src/vite-css-rules/containment.ts | 3 +-- packages/postcss/src/vite-css-rules/coverage.ts | 9 ++++++++- .../postcss/src/vite-css-rules/mini-program.ts | 2 +- .../test/mini-program-generated-css.test.ts | 11 +++-------- .../vite/processed-css-assets/cleanup.ts | 16 ++++++++++++++-- 7 files changed, 33 insertions(+), 20 deletions(-) diff --git a/packages/postcss/src/compat/mini-program-css/prune-generated.ts b/packages/postcss/src/compat/mini-program-css/prune-generated.ts index 52ced8a74..f130708da 100644 --- a/packages/postcss/src/compat/mini-program-css/prune-generated.ts +++ b/packages/postcss/src/compat/mini-program-css/prune-generated.ts @@ -11,7 +11,7 @@ import { isPseudoContentInitRule, usesTwContentVariable, } from './predicates' -import { removeEmptyAtRules, removeSpecificityPlaceholders, removeTailwindContainerMaxWidthMediaRules, removeTailwindContainerWidthRules, removeUnsupportedModernColorDeclarations } from './root-cleanups' +import { removeSpecificityPlaceholders, removeTailwindContainerMaxWidthMediaRules, removeTailwindContainerWidthRules, removeUnsupportedModernColorDeclarations } from './root-cleanups' import { getRuleSelectors, isMiniProgramNativeElementSelector, isUnsupportedBrowserPreflightSelector, MINI_PROGRAM_ELEMENT_SCOPE_SELECTOR, MINI_PROGRAM_ELEMENT_SCOPE_SELECTORS } from './selectors' const DEFAULT_WEAPP_VARIABLE_SCOPE = 'page,.tw-root,wx-root-portal-content,:host' @@ -310,7 +310,11 @@ export function pruneMiniProgramGeneratedCss( ensureMiniProgramElementContentInit(root) } - removeEmptyAtRules(root) + root.walkAtRules((atRule) => { + if (!atRule.nodes || atRule.nodes.length === 0) { + atRule.remove() + } + }) return root.toString() } diff --git a/packages/postcss/src/plugins/post.ts b/packages/postcss/src/plugins/post.ts index 4dac29c38..b5fbc00dc 100644 --- a/packages/postcss/src/plugins/post.ts +++ b/packages/postcss/src/plugins/post.ts @@ -241,10 +241,6 @@ const postcssWeappTailwindcssPostPlugin: PostcssWeappTailwindcssRenamePlugin = ( atRule.remove() } } - - if (atRule.nodes?.every(node => node.type === 'comment')) { - atRule.remove() - } } return p } diff --git a/packages/postcss/src/vite-css-rules/containment.ts b/packages/postcss/src/vite-css-rules/containment.ts index 5a55924c1..4f1ab2794 100644 --- a/packages/postcss/src/vite-css-rules/containment.ts +++ b/packages/postcss/src/vite-css-rules/containment.ts @@ -1,6 +1,5 @@ -import { removeEmptyAtRules } from '../compat/mini-program-css/root-cleanups' import { postcss } from '../postcss-runtime' -import { isCssRuleCoveredByDeclarations } from './coverage' +import { isCssRuleCoveredByDeclarations, removeEmptyAtRules } from './coverage' import { collectCssRuleContentKeys, collectCssRuleDeclarationKeyMap, collectNormalizedCssNodes, getCssRuleContentKey, normalizeCssForContainment } from './structure' export function filterExistingCssRules(baseCss: string, css: string) { diff --git a/packages/postcss/src/vite-css-rules/coverage.ts b/packages/postcss/src/vite-css-rules/coverage.ts index a5b2468c2..d02e69777 100644 --- a/packages/postcss/src/vite-css-rules/coverage.ts +++ b/packages/postcss/src/vite-css-rules/coverage.ts @@ -1,4 +1,3 @@ -import { removeEmptyAtRules } from '../compat/mini-program-css/root-cleanups' import { postcss } from '../postcss-runtime' import { collectCssRuleDeclarationKeys, collectCssRuleDeclarationRecords, collectCssRuleDeclarations, getCssRuleStructuralKey, isCoveredByBaseVarFallbackDeclaration, isEquivalentVarFallbackDeclaration, normalizeCssDeclarationKey, normalizeCssForContainment, parseVarReferenceValue } from './structure' @@ -171,3 +170,11 @@ export function mergeCoveredCssRuleDeclarations(baseCss: string, css: string) { return { baseCss, css, changed: false } } } + +export function removeEmptyAtRules(root: postcss.Root) { + root.walkAtRules((atRule) => { + if (atRule.nodes && atRule.nodes.every(node => node.type === 'comment')) { + atRule.remove() + } + }) +} diff --git a/packages/postcss/src/vite-css-rules/mini-program.ts b/packages/postcss/src/vite-css-rules/mini-program.ts index 2aae89a8b..598bcf591 100644 --- a/packages/postcss/src/vite-css-rules/mini-program.ts +++ b/packages/postcss/src/vite-css-rules/mini-program.ts @@ -1,7 +1,7 @@ import type { Node, Selector } from 'postcss-selector-parser' import selectorParser from 'postcss-selector-parser' -import { removeEmptyAtRules } from '../compat/mini-program-css/root-cleanups' import { postcss } from '../postcss-runtime' +import { removeEmptyAtRules } from './coverage' import { collectCssRuleDeclarationRecords, collectCssRuleDeclarations, getCssRuleStructuralKeyWithSelectorKey, MINI_PROGRAM_PREFLIGHT_SELECTOR_KEY, MINI_PROGRAM_PREFLIGHT_SELECTOR_KEYS, MINI_PROGRAM_THEME_SCOPE_SELECTOR_KEY, MINI_PROGRAM_THEME_SCOPE_SELECTOR_KEYS, normalizeCssDeclarationKey } from './structure' function normalizeSimpleMiniProgramSelectorNode(node: Node) { diff --git a/packages/postcss/test/mini-program-generated-css.test.ts b/packages/postcss/test/mini-program-generated-css.test.ts index 60e47045b..ad5bcb413 100644 --- a/packages/postcss/test/mini-program-generated-css.test.ts +++ b/packages/postcss/test/mini-program-generated-css.test.ts @@ -107,18 +107,13 @@ describe('mini-program generated css cleanup', () => { expect(css).not.toContain('box-sizing:border-box') }) - it('removes empty conditional at-rules from non-main mini-program chunks', async () => { - const styleHandler = createStyleHandler({ - majorVersion: 4, - }) - const { css } = await styleHandler([ + 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'), { - isMainChunk: false, - }) + ].join('\n'), { isTailwindcssV4: true }) expect(css).not.toContain('@media') expect(css).not.toContain('@supports') 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 8173cf850..45dffcb38 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,7 +1,7 @@ import type { OutputAsset, OutputBundle } from 'rollup' import type { CollectViteProcessedCssAssetOptions } from './markers-imports' import type { InternalUserDefinedOptions } from '@/types' -import { isMiniProgramLocalCssImportRequest, parseTailwindCssDirectiveRequest, postcss, removeEmptyAtRules } from '@weapp-tailwindcss/postcss' +import { isMiniProgramLocalCssImportRequest, parseTailwindCssDirectiveRequest, postcss } from '@weapp-tailwindcss/postcss' import path from 'pathe' import { normalizeOutputPathKey } from '../../shared/module-graph' import { appendCss, collectImportedStyleFiles, createCssAssetPipelineContext, getAssetFile, isStyleImportRequest, readAssetSource } from './markers-imports' @@ -76,7 +76,19 @@ export function removeCommentOnlyAtRules(css: string) { } try { const root = postcss.parse(css) - return removeEmptyAtRules(root) > 0 ? root.toString() : css + let changed = false + let passChanged = true + while (passChanged) { + passChanged = false + root.walkAtRules((atRule) => { + if (!atRule.nodes || atRule.nodes.length === 0 || atRule.nodes.every(node => node.type === 'comment')) { + atRule.remove() + changed = true + passChanged = true + } + }) + } + return changed ? root.toString() : css } catch { return css From f9f284fd1a019a00b4f566792e0932b8d87b8791 Mon Sep 17 00:00:00 2001 From: ice breaker <1324318532@qq.com> Date: Tue, 21 Jul 2026 13:52:53 +0800 Subject: [PATCH 05/13] fix(vite): preserve cached css during hmr --- .../vite/generate-bundle/final-css-assets.ts | 9 +++++-- .../vite/generate-bundle/finalize/bundle.ts | 1 + .../bundlers/vite-plugin.bundle.unit.test.ts | 26 +++++++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) 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 77a8e6cf1..725ac5fda 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 @@ -31,6 +31,7 @@ export async function finalizeMiniProgramCssAssets( onUpdate: GenerateBundleContext['opts']['onUpdate'] recordCssAssetResult: GenerateBundleContext['recordCssAssetResult'] styleHandler: GenerateBundleContext['opts']['styleHandler'] + useIncrementalMode?: boolean | undefined debug?: GenerateBundleContext['debug'] }, ) { @@ -55,7 +56,9 @@ export async function finalizeMiniProgramCssAssets( continue } if (options.lastCssResultByFile?.has(file)) { - const structurallyCleanSource = removeCommentOnlyAtRules(rawSource) + const structurallyCleanSource = options.useIncrementalMode + ? rawSource + : removeCommentOnlyAtRules(rawSource) const outputCss = stripMiniProgramCssSpecificityPlaceholders(structurallyCleanSource) if (outputCss !== rawSource) { plan.write(file, outputCss) @@ -68,7 +71,9 @@ export async function finalizeMiniProgramCssAssets( continue } if (!shouldFinalizeMiniProgramCssAsset(rawSource)) { - const structurallyCleanSource = removeCommentOnlyAtRules(rawSource) + const structurallyCleanSource = options.useIncrementalMode + ? rawSource + : removeCommentOnlyAtRules(rawSource) if (structurallyCleanSource !== rawSource) { plan.write(file, structurallyCleanSource) writeTargets.set(file, output) 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/test/bundlers/vite-plugin.bundle.unit.test.ts b/packages/weapp-tailwindcss/test/bundlers/vite-plugin.bundle.unit.test.ts index bcd691ea2..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 @@ -15285,6 +15285,32 @@ page { 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' From 946bc1a926e50060b0806d7a584767eb7c15cf42 Mon Sep 17 00:00:00 2001 From: ice breaker <1324318532@qq.com> Date: Tue, 21 Jul 2026 14:14:11 +0800 Subject: [PATCH 06/13] fix(postcss): preserve incremental media containers --- .../compat/mini-program-css/finalize-options.ts | 7 +++++++ .../src/compat/mini-program-css/finalize.ts | 11 ++++++++++- packages/postcss/test/mini-program-css.test.ts | 17 +++++++++++++++++ .../generation-helpers/preflight.ts | 9 ++++++++- .../shared/generator-css/pipeline/context.ts | 1 + .../generator-css/pipeline/ordered-output.ts | 1 + .../test/bundlers/generator-css.unit.test.ts | 10 ++++++++++ 7 files changed, 54 insertions(+), 2 deletions(-) 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/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/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/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([ From 8b87235f9f982775c504b975fe7ba7e13d9fa638 Mon Sep 17 00:00:00 2001 From: ice breaker <1324318532@qq.com> Date: Tue, 21 Jul 2026 14:25:06 +0800 Subject: [PATCH 07/13] fix(postcss): remove empty media after selector cleanup --- packages/postcss/src/plugins/post.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/postcss/src/plugins/post.ts b/packages/postcss/src/plugins/post.ts index b5fbc00dc..48668ed3b 100644 --- a/packages/postcss/src/plugins/post.ts +++ b/packages/postcss/src/plugins/post.ts @@ -2,6 +2,7 @@ import type { Declaration, Plugin, PluginCreator, Root, Rule } from 'postcss' import type { IStyleHandlerOptions } from '../types' import { defu } from '@weapp-tailwindcss/shared' +import { removeEmptyAtRules } from '../compat/mini-program-css/root-cleanups' import { getRuleSelectors, isMiniProgramThemeScopeSelector, MINI_PROGRAM_ELEMENT_SCOPE_SELECTOR } from '../compat/mini-program-css/selectors' import { normalizeMiniProgramPrefixedDeclaration, removeUnsupportedMiniProgramPrefixedAtRule } from '../compat/mini-program-prefixes' import { normalizeTailwindcssRpxDeclaration } from '../compat/tailwindcss-rpx' @@ -226,6 +227,9 @@ const postcssWeappTailwindcssPostPlugin: PostcssWeappTailwindcssRenamePlugin = ( if (shouldInjectTailwindcssV4Defaults || (opts.majorVersion === 4 && usesTailwindcssV4ContentVariable(root))) { injectMissingTailwindcssV4Defaults(root) } + if (enableMainChunkTransforms) { + removeEmptyAtRules(root) + } } p.AtRuleExit = (atRule) => { From 17f1f07c669a0e1e3a4e2ad0fd678b55be16dae2 Mon Sep 17 00:00:00 2001 From: ice breaker <1324318532@qq.com> Date: Tue, 21 Jul 2026 15:30:36 +0800 Subject: [PATCH 08/13] fix(vite): preserve css import at-rules during cleanup --- .../src/bundlers/vite/processed-css-assets/cleanup.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 45dffcb38..001b42102 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 @@ -81,7 +81,7 @@ export function removeCommentOnlyAtRules(css: string) { while (passChanged) { passChanged = false root.walkAtRules((atRule) => { - if (!atRule.nodes || atRule.nodes.length === 0 || atRule.nodes.every(node => node.type === 'comment')) { + if (atRule.nodes && (atRule.nodes.length === 0 || atRule.nodes.every(node => node.type === 'comment'))) { atRule.remove() changed = true passChanged = true From 3c8789fc85dfad707d9d8f535e56ce1fd51af8d3 Mon Sep 17 00:00:00 2001 From: ice breaker <1324318532@qq.com> Date: Tue, 21 Jul 2026 16:20:56 +0800 Subject: [PATCH 09/13] fix(vite): avoid incremental css append in framework watch --- .../vite/shared/create-framework-plugins-runtime.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/weapp-tailwindcss/src/bundlers/vite/shared/create-framework-plugins-runtime.ts b/packages/weapp-tailwindcss/src/bundlers/vite/shared/create-framework-plugins-runtime.ts index cd9612954..12d8158d0 100644 --- a/packages/weapp-tailwindcss/src/bundlers/vite/shared/create-framework-plugins-runtime.ts +++ b/packages/weapp-tailwindcss/src/bundlers/vite/shared/create-framework-plugins-runtime.ts @@ -361,10 +361,15 @@ ${previousTracedCss}` const cssHandlerOptions = { ...sourceCssHandlerOptions, isMainChunk: outputCssHandlerOptions.isMainChunk } const transientCssSource = transientAutoCssSources.get(file) ?? (hasTailwindRootDirectives(generatorTransformCode, { importFallback: currentGeneratorOptions.importFallback }) || hasTailwindSourceDirectives(generatorTransformCode, { importFallback: currentGeneratorOptions.importFallback }) || hasTailwindApplyDirective(generatorTransformCode) ? { base: path.dirname(path.resolve(file)), css: generatorTransformCode, file: path.resolve(file) } : void 0) const shouldDeferEmptyScopedCssSource = transientCssSource == null && (frameworkCssPipelineStrategy?.shouldDeferEmptyScopedCssSource?.({ ...cssPipelineContext, cssHandlerOptions, generatorCode: generatorTransformCode }) ?? true) - const previousCss = pendingHmrChange && !forceFullHmrCssRegeneration ? cleanGeneratedCssByFile.get(fileKey) : void 0 + const shouldBypassIncrementalCssAppend = pendingHmrChange !== void 0 + && !currentGeneratorBranch.isWeb + && (opts.appType === 'taro' || opts.appType === 'uni-app-vite') + const previousCss = pendingHmrChange && !forceFullHmrCssRegeneration && !shouldBypassIncrementalCssAppend + ? cleanGeneratedCssByFile.get(fileKey) + : void 0 const previousGeneratorCss = previousCss && !currentGeneratorBranch.isWeb ? normalizeMiniProgramGeneratorCssSource(previousCss, outputFile) : previousCss const hmrDebugState = hmrCandidateState.snapshotDebugState() - const generated = await hmrTimingRecorder.measure(`generateCss.${resolvedConfig?.command ?? 'unknown'}`, () => generateTailwindV4Css({ opts, runtimeState, runtime, rawSource: generatorTransformCode, file, outputFile, cssHandlerOptions, cssUserHandlerOptions: transformCssHandlerOptions.getCssUserHandlerOptions(requestFile), cssSources: transientCssSource ? [transientCssSource] : void 0, getSourceCandidatesForEntries, generatorPlatform: resolveGeneratorPlatform(), styleHandler, debug, previousCss: previousGeneratorCss, previousClassSet: pendingHmrChange && !forceFullHmrCssRegeneration ? generatedClassSetByFile.get(fileKey) : void 0, deferEmptyScopedCssSource: shouldDeferEmptyScopedCssSource, deferCssAdaptation: !currentGeneratorBranch.isWeb && !shouldAdaptFrameworkWatchCss(), disableSourceScan: false, cssStage: hookContext?.cssStage, restoreLocalCssImports: !currentGeneratorBranch.isWeb }), { file, memoryDebug: { cleanCacheHit: cleanGeneratedCssByFile.has(fileKey), forceFullHmrCssRegeneration, ...hmrDebugState, pendingResolved: pendingHmrChange !== void 0, runtimeCandidates: runtime.size, target: currentGeneratorOptions.target } }) + const generated = await hmrTimingRecorder.measure(`generateCss.${resolvedConfig?.command ?? 'unknown'}`, () => generateTailwindV4Css({ opts, runtimeState, runtime, rawSource: generatorTransformCode, file, outputFile, cssHandlerOptions, cssUserHandlerOptions: transformCssHandlerOptions.getCssUserHandlerOptions(requestFile), cssSources: transientCssSource ? [transientCssSource] : void 0, getSourceCandidatesForEntries, generatorPlatform: resolveGeneratorPlatform(), styleHandler, debug, previousCss: previousGeneratorCss, previousClassSet: pendingHmrChange && !forceFullHmrCssRegeneration && !shouldBypassIncrementalCssAppend ? generatedClassSetByFile.get(fileKey) : void 0, deferEmptyScopedCssSource: shouldDeferEmptyScopedCssSource, deferCssAdaptation: !currentGeneratorBranch.isWeb && !shouldAdaptFrameworkWatchCss(), disableSourceScan: false, cssStage: hookContext?.cssStage, restoreLocalCssImports: !currentGeneratorBranch.isWeb }), { file, memoryDebug: { cleanCacheHit: cleanGeneratedCssByFile.has(fileKey), forceFullHmrCssRegeneration, ...hmrDebugState, pendingResolved: pendingHmrChange !== void 0, runtimeCandidates: runtime.size, target: currentGeneratorOptions.target } }) if (!generated) { if (pendingHmrChange) { hmrCandidateState.finishTarget(file) From 0b6084145b6c88b5230d15a4167ef17ae3d4c88b Mon Sep 17 00:00:00 2001 From: ice breaker <1324318532@qq.com> Date: Tue, 21 Jul 2026 16:45:58 +0800 Subject: [PATCH 10/13] fix(vite): regenerate framework css during watch replay --- .../vite/generate-bundle/remembered-css-replay.ts | 3 ++- .../vite/shared/create-framework-plugins-runtime.ts | 9 ++------- .../test/bundlers/vite-plugin.bundle.unit.test.ts | 8 ++++---- 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/packages/weapp-tailwindcss/src/bundlers/vite/generate-bundle/remembered-css-replay.ts b/packages/weapp-tailwindcss/src/bundlers/vite/generate-bundle/remembered-css-replay.ts index 5fb763c15..d52164a50 100644 --- a/packages/weapp-tailwindcss/src/bundlers/vite/generate-bundle/remembered-css-replay.ts +++ b/packages/weapp-tailwindcss/src/bundlers/vite/generate-bundle/remembered-css-replay.ts @@ -195,7 +195,8 @@ export async function processRememberedCssReplay(options: ProcessRememberedCssRe const cssSourceChanged = changedCssFiles.has(outputFile) || changedCssFiles.has(sourceFile) || (previousRawSourceHash != null && previousRawSourceHash !== rawSourceHash) - const previousCss = useIncrementalMode && !cssSourceChanged && getLastCssSourceHash(lastCssSourceHashByFile, outputFile) === cssRuntimeAffectingHash + const canAppendIncrementalCss = opts.appType !== 'taro' && opts.appType !== 'uni-app-vite' + const previousCss = useIncrementalMode && canAppendIncrementalCss && !cssSourceChanged && getLastCssSourceHash(lastCssSourceHashByFile, outputFile) === cssRuntimeAffectingHash ? getLastCssResult(lastCssResultByFile, outputFile) : undefined const allRememberedSignaturesFresh = rememberedKeys.length > 0 diff --git a/packages/weapp-tailwindcss/src/bundlers/vite/shared/create-framework-plugins-runtime.ts b/packages/weapp-tailwindcss/src/bundlers/vite/shared/create-framework-plugins-runtime.ts index 12d8158d0..cd9612954 100644 --- a/packages/weapp-tailwindcss/src/bundlers/vite/shared/create-framework-plugins-runtime.ts +++ b/packages/weapp-tailwindcss/src/bundlers/vite/shared/create-framework-plugins-runtime.ts @@ -361,15 +361,10 @@ ${previousTracedCss}` const cssHandlerOptions = { ...sourceCssHandlerOptions, isMainChunk: outputCssHandlerOptions.isMainChunk } const transientCssSource = transientAutoCssSources.get(file) ?? (hasTailwindRootDirectives(generatorTransformCode, { importFallback: currentGeneratorOptions.importFallback }) || hasTailwindSourceDirectives(generatorTransformCode, { importFallback: currentGeneratorOptions.importFallback }) || hasTailwindApplyDirective(generatorTransformCode) ? { base: path.dirname(path.resolve(file)), css: generatorTransformCode, file: path.resolve(file) } : void 0) const shouldDeferEmptyScopedCssSource = transientCssSource == null && (frameworkCssPipelineStrategy?.shouldDeferEmptyScopedCssSource?.({ ...cssPipelineContext, cssHandlerOptions, generatorCode: generatorTransformCode }) ?? true) - const shouldBypassIncrementalCssAppend = pendingHmrChange !== void 0 - && !currentGeneratorBranch.isWeb - && (opts.appType === 'taro' || opts.appType === 'uni-app-vite') - const previousCss = pendingHmrChange && !forceFullHmrCssRegeneration && !shouldBypassIncrementalCssAppend - ? cleanGeneratedCssByFile.get(fileKey) - : void 0 + const previousCss = pendingHmrChange && !forceFullHmrCssRegeneration ? cleanGeneratedCssByFile.get(fileKey) : void 0 const previousGeneratorCss = previousCss && !currentGeneratorBranch.isWeb ? normalizeMiniProgramGeneratorCssSource(previousCss, outputFile) : previousCss const hmrDebugState = hmrCandidateState.snapshotDebugState() - const generated = await hmrTimingRecorder.measure(`generateCss.${resolvedConfig?.command ?? 'unknown'}`, () => generateTailwindV4Css({ opts, runtimeState, runtime, rawSource: generatorTransformCode, file, outputFile, cssHandlerOptions, cssUserHandlerOptions: transformCssHandlerOptions.getCssUserHandlerOptions(requestFile), cssSources: transientCssSource ? [transientCssSource] : void 0, getSourceCandidatesForEntries, generatorPlatform: resolveGeneratorPlatform(), styleHandler, debug, previousCss: previousGeneratorCss, previousClassSet: pendingHmrChange && !forceFullHmrCssRegeneration && !shouldBypassIncrementalCssAppend ? generatedClassSetByFile.get(fileKey) : void 0, deferEmptyScopedCssSource: shouldDeferEmptyScopedCssSource, deferCssAdaptation: !currentGeneratorBranch.isWeb && !shouldAdaptFrameworkWatchCss(), disableSourceScan: false, cssStage: hookContext?.cssStage, restoreLocalCssImports: !currentGeneratorBranch.isWeb }), { file, memoryDebug: { cleanCacheHit: cleanGeneratedCssByFile.has(fileKey), forceFullHmrCssRegeneration, ...hmrDebugState, pendingResolved: pendingHmrChange !== void 0, runtimeCandidates: runtime.size, target: currentGeneratorOptions.target } }) + const generated = await hmrTimingRecorder.measure(`generateCss.${resolvedConfig?.command ?? 'unknown'}`, () => generateTailwindV4Css({ opts, runtimeState, runtime, rawSource: generatorTransformCode, file, outputFile, cssHandlerOptions, cssUserHandlerOptions: transformCssHandlerOptions.getCssUserHandlerOptions(requestFile), cssSources: transientCssSource ? [transientCssSource] : void 0, getSourceCandidatesForEntries, generatorPlatform: resolveGeneratorPlatform(), styleHandler, debug, previousCss: previousGeneratorCss, previousClassSet: pendingHmrChange && !forceFullHmrCssRegeneration ? generatedClassSetByFile.get(fileKey) : void 0, deferEmptyScopedCssSource: shouldDeferEmptyScopedCssSource, deferCssAdaptation: !currentGeneratorBranch.isWeb && !shouldAdaptFrameworkWatchCss(), disableSourceScan: false, cssStage: hookContext?.cssStage, restoreLocalCssImports: !currentGeneratorBranch.isWeb }), { file, memoryDebug: { cleanCacheHit: cleanGeneratedCssByFile.has(fileKey), forceFullHmrCssRegeneration, ...hmrDebugState, pendingResolved: pendingHmrChange !== void 0, runtimeCandidates: runtime.size, target: currentGeneratorOptions.target } }) if (!generated) { if (pendingHmrChange) { hmrCandidateState.finishTarget(file) 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 ca58ac800..65d58a2ac 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 @@ -7531,7 +7531,7 @@ module.exports = { expect(viteProcessedCssAssetResults.get(subSourceFile)?.injectIntoMain).toBe(false) }, TEST_TIMEOUT_MS) - it('passes previous css to unchanged remembered vite css replay', async () => { + it('regenerates complete css for unchanged uni-app vite css replay', async () => { const generateCalls: Array<{ rawSource: string previousCss?: string | undefined @@ -7544,8 +7544,8 @@ module.exports = { rawSource: options.rawSource, previousCss: options.previousCss, }) - const css = options.previousCss - ? `${options.previousCss}\n.tw-replay-next{display:flex}` + const css = generateCalls.length > 1 + ? '.tw-replay-base{display:block}\n.tw-replay-next{display:flex}' : '.tw-replay-base{display:block}' return { css, @@ -7664,7 +7664,7 @@ module.exports = { expect(generateCalls).toHaveLength(2) expect(generateCalls[0]?.previousCss).toBeUndefined() - expect(generateCalls[1]?.previousCss).toBe(firstCss) + expect(generateCalls[1]?.previousCss).toBeUndefined() expect(pruneViteCssCaches).toHaveBeenCalledTimes(1) const firstPruneOptions = pruneViteCssCaches.mock.calls.at(0)?.[0] expect(firstPruneOptions.activeFiles.has('pages/index/index.wxss')).toBe(true) From c85234960ab59bdda359e2ab7f6be197f9418979 Mon Sep 17 00:00:00 2001 From: ice breaker <1324318532@qq.com> Date: Tue, 21 Jul 2026 17:11:09 +0800 Subject: [PATCH 11/13] fix(vite): separate incremental and final at-rule cleanup --- .../vite/generate-bundle/final-css-assets.ts | 6 ++-- .../generate-bundle/remembered-css-replay.ts | 3 +- .../vite/processed-css-assets/cleanup.ts | 33 ++++++++++++------- .../bundlers/vite-plugin.bundle.unit.test.ts | 8 ++--- 4 files changed, 29 insertions(+), 21 deletions(-) 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 725ac5fda..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,7 +6,7 @@ import { stripMiniProgramCssSpecificityPlaceholders, } from '@/bundlers/shared/css-cleanup' import { AssetEmissionPlan } from '@/compiler' -import { removeCommentOnlyAtRules } from '../processed-css-assets/cleanup' +import { removeEmptyCssAtRules } from '../processed-css-assets/cleanup' import { applyViteAssetEmissionPlan } from './asset-emission-plan' function readAssetSource(output: OutputAsset) { @@ -58,7 +58,7 @@ export async function finalizeMiniProgramCssAssets( if (options.lastCssResultByFile?.has(file)) { const structurallyCleanSource = options.useIncrementalMode ? rawSource - : removeCommentOnlyAtRules(rawSource) + : removeEmptyCssAtRules(rawSource) const outputCss = stripMiniProgramCssSpecificityPlaceholders(structurallyCleanSource) if (outputCss !== rawSource) { plan.write(file, outputCss) @@ -73,7 +73,7 @@ export async function finalizeMiniProgramCssAssets( if (!shouldFinalizeMiniProgramCssAsset(rawSource)) { const structurallyCleanSource = options.useIncrementalMode ? rawSource - : removeCommentOnlyAtRules(rawSource) + : removeEmptyCssAtRules(rawSource) if (structurallyCleanSource !== rawSource) { plan.write(file, structurallyCleanSource) writeTargets.set(file, output) diff --git a/packages/weapp-tailwindcss/src/bundlers/vite/generate-bundle/remembered-css-replay.ts b/packages/weapp-tailwindcss/src/bundlers/vite/generate-bundle/remembered-css-replay.ts index d52164a50..5fb763c15 100644 --- a/packages/weapp-tailwindcss/src/bundlers/vite/generate-bundle/remembered-css-replay.ts +++ b/packages/weapp-tailwindcss/src/bundlers/vite/generate-bundle/remembered-css-replay.ts @@ -195,8 +195,7 @@ export async function processRememberedCssReplay(options: ProcessRememberedCssRe const cssSourceChanged = changedCssFiles.has(outputFile) || changedCssFiles.has(sourceFile) || (previousRawSourceHash != null && previousRawSourceHash !== rawSourceHash) - const canAppendIncrementalCss = opts.appType !== 'taro' && opts.appType !== 'uni-app-vite' - const previousCss = useIncrementalMode && canAppendIncrementalCss && !cssSourceChanged && getLastCssSourceHash(lastCssSourceHashByFile, outputFile) === cssRuntimeAffectingHash + const previousCss = useIncrementalMode && !cssSourceChanged && getLastCssSourceHash(lastCssSourceHashByFile, outputFile) === cssRuntimeAffectingHash ? getLastCssResult(lastCssResultByFile, outputFile) : undefined const allRememberedSignaturesFresh = rememberedKeys.length > 0 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 001b42102..7a03143c8 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,7 +1,7 @@ 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 { appendCss, collectImportedStyleFiles, createCssAssetPipelineContext, getAssetFile, isStyleImportRequest, readAssetSource } from './markers-imports' @@ -77,17 +77,13 @@ export function removeCommentOnlyAtRules(css: string) { try { const root = postcss.parse(css) let changed = false - let passChanged = true - while (passChanged) { - passChanged = false - root.walkAtRules((atRule) => { - if (atRule.nodes && (atRule.nodes.length === 0 || atRule.nodes.every(node => node.type === 'comment'))) { - atRule.remove() - changed = true - passChanged = true - } - }) - } + root.walkAtRules((atRule) => { + if (!atRule.nodes || atRule.nodes.length === 0 || atRule.nodes.some(node => node.type !== 'comment')) { + return + } + atRule.remove() + changed = true + }) return changed ? root.toString() : css } catch { @@ -95,6 +91,19 @@ export function removeCommentOnlyAtRules(css: string) { } } +export function removeEmptyCssAtRules(css: string) { + if (!css.includes('@') || !/@[a-z-]+\b[^{};]*\{(?:\s|\/\*[\s\S]*?\*\/)*\}/i.test(css)) { + return css + } + try { + const root = postcss.parse(css) + return removeEmptyAtRules(root) > 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/test/bundlers/vite-plugin.bundle.unit.test.ts b/packages/weapp-tailwindcss/test/bundlers/vite-plugin.bundle.unit.test.ts index 65d58a2ac..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 @@ -7531,7 +7531,7 @@ module.exports = { expect(viteProcessedCssAssetResults.get(subSourceFile)?.injectIntoMain).toBe(false) }, TEST_TIMEOUT_MS) - it('regenerates complete css for unchanged uni-app vite css replay', async () => { + it('passes previous css to unchanged remembered vite css replay', async () => { const generateCalls: Array<{ rawSource: string previousCss?: string | undefined @@ -7544,8 +7544,8 @@ module.exports = { rawSource: options.rawSource, previousCss: options.previousCss, }) - const css = generateCalls.length > 1 - ? '.tw-replay-base{display:block}\n.tw-replay-next{display:flex}' + const css = options.previousCss + ? `${options.previousCss}\n.tw-replay-next{display:flex}` : '.tw-replay-base{display:block}' return { css, @@ -7664,7 +7664,7 @@ module.exports = { expect(generateCalls).toHaveLength(2) expect(generateCalls[0]?.previousCss).toBeUndefined() - expect(generateCalls[1]?.previousCss).toBeUndefined() + expect(generateCalls[1]?.previousCss).toBe(firstCss) expect(pruneViteCssCaches).toHaveBeenCalledTimes(1) const firstPruneOptions = pruneViteCssCaches.mock.calls.at(0)?.[0] expect(firstPruneOptions.activeFiles.has('pages/index/index.wxss')).toBe(true) From a8e65a5f0048e127f462641fdb777c36a72a8116 Mon Sep 17 00:00:00 2001 From: ice breaker <1324318532@qq.com> Date: Tue, 21 Jul 2026 18:53:44 +0800 Subject: [PATCH 12/13] fix(postcss): avoid regex backtracking during at-rule cleanup --- .../compat/mini-program-css/root-cleanups.ts | 11 +++++ packages/postcss/src/handler.ts | 14 +++++++ packages/postcss/src/plugins/post.ts | 12 +++--- packages/postcss/test/post.test.ts | 31 ++++++++++++++ .../vite/processed-css-assets/cleanup.ts | 18 +++++++-- .../vite-processed-css-cleanup.unit.test.ts | 40 +++++++++++++++++++ 6 files changed, 116 insertions(+), 10 deletions(-) create mode 100644 packages/weapp-tailwindcss/test/bundlers/vite-processed-css-cleanup.unit.test.ts 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 36f607cfd..ebe2df3ac 100644 --- a/packages/postcss/src/compat/mini-program-css/root-cleanups.ts +++ b/packages/postcss/src/compat/mini-program-css/root-cleanups.ts @@ -98,6 +98,17 @@ export function removeEmptyAtRules(root: postcss.Root) { return removed } +export function removeEmptyBlockAtRules(root: postcss.Root) { + let removed = 0 + root.walkAtRules((atRule) => { + if (atRule.nodes?.length === 0) { + atRule.remove() + removed++ + } + }) + return removed +} + function removeEmptyAtRuleAncestors(parent: postcss.Container | undefined) { while (parent?.type === 'atrule' && isEffectivelyEmptyContainer(parent)) { const nextParent = parent.parent 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/plugins/post.ts b/packages/postcss/src/plugins/post.ts index 48668ed3b..7ccc6a3eb 100644 --- a/packages/postcss/src/plugins/post.ts +++ b/packages/postcss/src/plugins/post.ts @@ -2,7 +2,6 @@ import type { Declaration, Plugin, PluginCreator, Root, Rule } from 'postcss' import type { IStyleHandlerOptions } from '../types' import { defu } from '@weapp-tailwindcss/shared' -import { removeEmptyAtRules } from '../compat/mini-program-css/root-cleanups' import { getRuleSelectors, isMiniProgramThemeScopeSelector, MINI_PROGRAM_ELEMENT_SCOPE_SELECTOR } from '../compat/mini-program-css/selectors' import { normalizeMiniProgramPrefixedDeclaration, removeUnsupportedMiniProgramPrefixedAtRule } from '../compat/mini-program-prefixes' import { normalizeTailwindcssRpxDeclaration } from '../compat/tailwindcss-rpx' @@ -227,13 +226,10 @@ const postcssWeappTailwindcssPostPlugin: PostcssWeappTailwindcssRenamePlugin = ( if (shouldInjectTailwindcssV4Defaults || (opts.majorVersion === 4 && usesTailwindcssV4ContentVariable(root))) { injectMissingTailwindcssV4Defaults(root) } - if (enableMainChunkTransforms) { - removeEmptyAtRules(root) - } } - p.AtRuleExit = (atRule) => { - if (enableMainChunkTransforms) { + if (enableMainChunkTransforms) { + p.AtRuleExit = (atRule) => { removeUnsupportedMiniProgramPrefixedAtRule(atRule) /** * @description 移除 property @@ -244,6 +240,10 @@ const postcssWeappTailwindcssPostPlugin: PostcssWeappTailwindcssRenamePlugin = ( } atRule.remove() } + /** + * 清除空节点 + */ + atRule.nodes?.length === 0 && atRule.remove() } } return p 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/vite/processed-css-assets/cleanup.ts b/packages/weapp-tailwindcss/src/bundlers/vite/processed-css-assets/cleanup.ts index 7a03143c8..374b7bfe3 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 @@ -71,14 +71,18 @@ export function restoreCssImportAtRules(source: string, filtered: string, file?: } export function removeCommentOnlyAtRules(css: string) { - if (!css.includes('@') || !/@[a-z-]+\b[^{};]*\{(?:\s|\/\*[\s\S]*?\*\/)*\}/i.test(css)) { + if (!css.includes('@') || !css.includes('{')) { return css } try { const root = postcss.parse(css) let changed = false root.walkAtRules((atRule) => { - if (!atRule.nodes || atRule.nodes.length === 0 || atRule.nodes.some(node => node.type !== 'comment')) { + if (!atRule.nodes || atRule.nodes.length === 0) { + return + } + const hasCss = atRule.nodes.some(node => node.type !== 'comment') + if (hasCss) { return } atRule.remove() @@ -92,12 +96,18 @@ export function removeCommentOnlyAtRules(css: string) { } export function removeEmptyCssAtRules(css: string) { - if (!css.includes('@') || !/@[a-z-]+\b[^{};]*\{(?:\s|\/\*[\s\S]*?\*\/)*\}/i.test(css)) { + if (!css.includes('@') || !css.includes('{')) { return css } try { const root = postcss.parse(css) - return removeEmptyAtRules(root) > 0 ? root.toString() : 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 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..9360a4706 --- /dev/null +++ b/packages/weapp-tailwindcss/test/bundlers/vite-processed-css-cleanup.unit.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' +import { removeCommentOnlyAtRules, removeEmptyCssAtRules } from '@/bundlers/vite/processed-css-assets/cleanup' + +describe('vite processed css cleanup', () => { + 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') + }) +}) From 22c7ec6b0be55206b9a45fe40c89ade40c9b6941 Mon Sep 17 00:00:00 2001 From: ice breaker <1324318532@qq.com> Date: Tue, 21 Jul 2026 19:19:20 +0800 Subject: [PATCH 13/13] perf(vite): gate empty at-rule parsing with linear scan --- .../vite/processed-css-assets/cleanup.ts | 5 +- .../processed-css-assets/empty-at-rule.ts | 104 ++++++++++++++++++ .../vite-processed-css-cleanup.unit.test.ts | 8 ++ 3 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 packages/weapp-tailwindcss/src/bundlers/vite/processed-css-assets/empty-at-rule.ts 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 374b7bfe3..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 @@ -4,6 +4,7 @@ import type { InternalUserDefinedOptions } from '@/types' 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('@') || !css.includes('{')) { + if (!hasEmptyAtRuleBlockCandidate(css)) { return css } try { @@ -96,7 +97,7 @@ export function removeCommentOnlyAtRules(css: string) { } export function removeEmptyCssAtRules(css: string) { - if (!css.includes('@') || !css.includes('{')) { + if (!hasEmptyAtRuleBlockCandidate(css)) { return css } try { 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/vite-processed-css-cleanup.unit.test.ts b/packages/weapp-tailwindcss/test/bundlers/vite-processed-css-cleanup.unit.test.ts index 9360a4706..2ea3fbedd 100644 --- 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 @@ -1,7 +1,15 @@ 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 {',