Skip to content

Commit 3f0c6eb

Browse files
committed
fix(vite): fallback dynamic wxml arbitrary classes
1 parent cbead4c commit 3f0c6eb

3 files changed

Lines changed: 153 additions & 8 deletions

File tree

e2e/projectTest.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -63,21 +63,23 @@ function formatClassListSnapshot(classList: string[]) {
6363
const SUSPICIOUS_CLASS_FRAGMENT_RE = /\b[\w-]+-\s+[a-z0-9#]/gi
6464
const SUSPICIOUS_XL_FRAGMENT_RE = /\b\d+xl\s+\d+xl\b/gi
6565

66-
function countSuspiciousClassFragments(wxml: string) {
67-
let count = 0
66+
function collectSuspiciousClassFragments(wxml: string) {
67+
const matches: string[] = []
6868
const patterns = [
6969
SUSPICIOUS_CLASS_FRAGMENT_RE,
7070
SUSPICIOUS_XL_FRAGMENT_RE,
7171
]
7272

7373
for (const pattern of patterns) {
74-
const matches = wxml.match(pattern)
75-
if (matches) {
76-
count += matches.length
77-
}
74+
pattern.lastIndex = 0
75+
matches.push(...(wxml.match(pattern) ?? []))
7876
}
7977

80-
return count
78+
return [...new Set(matches)]
79+
}
80+
81+
function countSuspiciousClassFragments(wxml: string) {
82+
return collectSuspiciousClassFragments(wxml).length
8183
}
8284

8385
async function captureStablePageWxml(
@@ -225,6 +227,15 @@ async function runProjectTest(entry: ProjectEntry, options: ProjectTestOptions)
225227
logE2EError('Failed to format WXML for %s', entry.projectPath)
226228
}
227229

230+
const suspiciousFragments = collectSuspiciousClassFragments(wxml)
231+
if (suspiciousFragments.length > 0) {
232+
logE2EError(
233+
'[e2e] suspicious class fragments detected in %s/page.wxml: %s',
234+
entry.name,
235+
suspiciousFragments.join(', '),
236+
)
237+
}
238+
228239
await expectProjectSnapshot(options.suite, entry.name, 'page.wxml', wxml)
229240
}
230241

packages/weapp-tailwindcss/src/bundlers/vite/generate-bundle.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import type { BundleSnapshot, EntryType } from './bundle-state'
55
import type { CreateJsHandlerOptions, InternalUserDefinedOptions, LinkedJsModuleResult } from '@/types'
66
import path from 'node:path'
77
import process from 'node:process'
8+
import { logger } from '@weapp-tailwindcss/logger'
9+
import { splitCode } from '@weapp-tailwindcss/shared/extractors'
810
import { getRuntimeClassSetSignature } from '@/tailwindcss/runtime/cache'
911
import { createUniAppXAssetTask } from '@/uni-app-x'
1012
import { processCachedTask } from '../shared/cache'
@@ -148,6 +150,37 @@ function canShareCssTransformResult(rawSource: string) {
148150
return !rawSource.includes('@import') && !rawSource.includes('url(')
149151
}
150152

153+
const MUSTACHE_EXPRESSION_RE = /\{\{[\s\S]*?\}\}/g
154+
const QUOTED_LITERAL_RE = /'([^']*)'|"([^"]*)"|`([^`]*)`/g
155+
156+
function isArbitraryValueCandidate(candidate: string) {
157+
return candidate.includes('[') && candidate.includes(']')
158+
}
159+
160+
function collectUnescapedDynamicCandidates(
161+
source: string,
162+
) {
163+
const matches = new Set<string>()
164+
165+
for (const expression of source.match(MUSTACHE_EXPRESSION_RE) ?? []) {
166+
QUOTED_LITERAL_RE.lastIndex = 0
167+
let quoted = QUOTED_LITERAL_RE.exec(expression)
168+
while (quoted !== null) {
169+
const literal = quoted[1] ?? quoted[2] ?? quoted[3] ?? ''
170+
for (const candidate of splitCode(literal, true)) {
171+
const normalized = candidate.trim()
172+
if (!normalized || !isArbitraryValueCandidate(normalized)) {
173+
continue
174+
}
175+
matches.add(normalized)
176+
}
177+
quoted = QUOTED_LITERAL_RE.exec(expression)
178+
}
179+
}
180+
181+
return [...matches]
182+
}
183+
151184
export function createGenerateBundleHook(context: GenerateBundleContext) {
152185
const state = createBundleBuildState()
153186
const cssHandlerOptionsCache = new Map<string, {
@@ -339,7 +372,28 @@ export function createGenerateBundleHook(context: GenerateBundleContext) {
339372
},
340373
async transform() {
341374
const start = performance.now()
342-
const transformed = await templateHandler(rawSource, defaultTemplateHandlerOptions)
375+
let transformed = await templateHandler(rawSource, defaultTemplateHandlerOptions)
376+
let unresolvedDynamicCandidates = collectUnescapedDynamicCandidates(transformed)
377+
378+
if (unresolvedDynamicCandidates.length > 0) {
379+
logger.warn(
380+
'检测到 WXML 动态类名未完成转译,已回退到完整 runtimeSet 重试: %s -> %O',
381+
file,
382+
unresolvedDynamicCandidates,
383+
)
384+
const fullRuntimeSet = await context.ensureRuntimeClassSet(true)
385+
transformed = await templateHandler(rawSource, {
386+
runtimeSet: fullRuntimeSet,
387+
})
388+
unresolvedDynamicCandidates = collectUnescapedDynamicCandidates(transformed)
389+
if (unresolvedDynamicCandidates.length > 0) {
390+
logger.warn(
391+
'WXML 动态类名在完整 runtimeSet 重试后仍未完成转译: %s -> %O',
392+
file,
393+
unresolvedDynamicCandidates,
394+
)
395+
}
396+
}
343397
metrics.html.elapsed += measureElapsed(start)
344398
metrics.html.transformed++
345399
onUpdate(file, rawSource, transformed)

packages/weapp-tailwindcss/test/bundlers/vite-plugin.bundle.unit.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ import type { Plugin, ResolvedConfig } from 'vite'
33
import type { CreateJsHandlerOptions } from '@/types'
44
import { readFile } from 'node:fs/promises'
55
import path from 'node:path'
6+
import { MappingChars2String } from '@weapp-core/escape'
67
import { beforeEach, describe, expect, it, vi } from 'vitest'
78
import { createJsHandler } from '@/js'
89
import { replaceWxml } from '@/wxml'
10+
import { createTemplateHandler } from '@/wxml/utils'
911
import {
1012
createContext,
1113
createRollupAsset,
@@ -41,6 +43,7 @@ async function loadIssue814Fixture() {
4143
describe('bundlers/vite UnifiedViteWeappTailwindcssPlugin bundle', () => {
4244
beforeEach(() => {
4345
vi.resetModules()
46+
vi.doUnmock('@/bundlers/vite/incremental-runtime-class-set')
4447
resetVitePluginTestContext()
4548
})
4649

@@ -187,6 +190,83 @@ const trace = "at App.vue:4"
187190
expect(currentContext.twPatcher.getClassSetSync).toHaveBeenCalledTimes(2)
188191
}, TEST_TIMEOUT_MS)
189192

193+
it('warns and falls back to full runtime set when bundle runtime set misses dynamic arbitrary candidates in wxml', async () => {
194+
const fallbackRuntimeSet = new Set([
195+
'bg-[#68c828]',
196+
'text-[100px]',
197+
'text-[#123456]',
198+
'w-[323px]',
199+
'h-[30px]',
200+
'h-[45px]',
201+
])
202+
const incompleteRuntimeSet = new Set([
203+
'bg-[#68c828]',
204+
'text-[100px]',
205+
'text-[#123456]',
206+
'w-[323px]',
207+
])
208+
const syncMock = vi.fn(async () => incompleteRuntimeSet)
209+
const resetMock = vi.fn(async () => undefined)
210+
vi.doMock('@/bundlers/vite/incremental-runtime-class-set', () => ({
211+
createBundleRuntimeClassSetManager: () => ({
212+
sync: syncMock,
213+
reset: resetMock,
214+
}),
215+
}))
216+
217+
const jsHandler = createJsHandler({
218+
escapeMap: MappingChars2String,
219+
staleClassNameFallback: false,
220+
tailwindcssMajorVersion: 4,
221+
})
222+
const templateHandler = createTemplateHandler({
223+
escapeMap: MappingChars2String,
224+
jsHandler,
225+
})
226+
227+
setCurrentContext(createContext({
228+
templateHandler,
229+
jsHandler,
230+
twPatcher: {
231+
patch: vi.fn(),
232+
getClassSet: vi.fn(async () => fallbackRuntimeSet),
233+
getClassSetSync: vi.fn(() => fallbackRuntimeSet),
234+
extract: vi.fn(async () => ({ classSet: fallbackRuntimeSet })),
235+
majorVersion: 4,
236+
},
237+
}))
238+
239+
const UnifiedViteWeappTailwindcssPlugin = await loadUnifiedVitePlugin()
240+
const plugins = UnifiedViteWeappTailwindcssPlugin()
241+
const postPlugin = plugins?.find(plugin => plugin.name === 'weapp-tailwindcss:adaptor:post') as Plugin
242+
expect(postPlugin).toBeTruthy()
243+
244+
await (postPlugin.configResolved as any)?.call(postPlugin, {
245+
command: 'build',
246+
root: process.cwd(),
247+
css: { postcss: { plugins: [] } },
248+
build: { outDir: 'dist' },
249+
} as ResolvedConfig)
250+
251+
const rawWxml = '<view class="bg-[#68c828] text-[100px] text-[#123456] w-[323px] {{true?\'h-[30px]\':\'h-[45px]\'}}">111</view>'
252+
const bundle = {
253+
'pages/index/index.wxml': {
254+
...createRollupAsset(rawWxml),
255+
fileName: 'pages/index/index.wxml',
256+
} satisfies OutputAsset,
257+
}
258+
259+
const generateBundle = postPlugin.generateBundle as any
260+
await generateBundle?.call(postPlugin, {} as any, bundle)
261+
262+
expect(syncMock).toHaveBeenCalledTimes(1)
263+
const transformed = (bundle['pages/index/index.wxml'] as OutputAsset).source.toString()
264+
expect(transformed).toContain('h-_b30px_B')
265+
expect(transformed).toContain('h-_b45px_B')
266+
expect(transformed).not.toContain('h-[30px]')
267+
expect(transformed).not.toContain('h-[45px]')
268+
}, TEST_TIMEOUT_MS)
269+
190270
it('refreshes runtime class set when only comment-carried class candidates change', async () => {
191271
const UnifiedViteWeappTailwindcssPlugin = await loadUnifiedVitePlugin()
192272
const runtimeSets = [

0 commit comments

Comments
 (0)