Skip to content

Commit ea6cbf0

Browse files
committed
fix(tailwindcss-patch): preserve raw runtime class tokens
1 parent 905000b commit ea6cbf0

File tree

4 files changed

+156
-1
lines changed

4 files changed

+156
-1
lines changed

.changeset/late-ducks-juggle.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'tailwindcss-patch': patch
3+
---
4+
5+
Preserve original runtime candidate tokens when collecting the class set for Tailwind CSS v2/v3 projects, so shorthand and full hex arbitrary color classes remain distinct and only match when the exact source token is present.

packages/tailwindcss-patch/src/runtime/class-collector.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,15 @@ import path from 'pathe'
66
import { extractValidCandidates } from '../extraction/candidate-extractor'
77
import { isObject } from '../utils'
88

9+
function collectRuntimeCandidateKeys(context: TailwindcssRuntimeContext) {
10+
const candidateRuleCache = context.candidateRuleCache
11+
if (candidateRuleCache instanceof Map && candidateRuleCache.size > 0) {
12+
return candidateRuleCache.keys()
13+
}
14+
15+
return context.classCache.keys()
16+
}
17+
918
export function collectClassesFromContexts(
1019
contexts: TailwindcssRuntimeContext[],
1120
filter: (className: string) => boolean,
@@ -16,7 +25,7 @@ export function collectClassesFromContexts(
1625
continue
1726
}
1827

19-
for (const key of context.classCache.keys()) {
28+
for (const key of collectRuntimeCandidateKeys(context)) {
2029
const className = key.toString()
2130
if (filter(className)) {
2231
set.add(className)

packages/tailwindcss-patch/test/api.tailwindcss-patcher.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,81 @@ describe('TailwindcssPatcher', () => {
320320
expect(firstEntry?.values).toEqual(expect.arrayContaining(['foo', 'bar']))
321321
})
322322

323+
it.each([
324+
{
325+
name: 'only shorthand black token',
326+
rawCandidates: ['bg-[#000]'],
327+
normalizedClasses: ['bg-[#000000]'],
328+
expectedPresent: ['bg-[#000]'],
329+
expectedAbsent: ['bg-[#000000]'],
330+
},
331+
{
332+
name: 'only full black token',
333+
rawCandidates: ['bg-[#000000]'],
334+
normalizedClasses: ['bg-[#000000]'],
335+
expectedPresent: ['bg-[#000000]'],
336+
expectedAbsent: ['bg-[#000]'],
337+
},
338+
{
339+
name: 'both black tokens',
340+
rawCandidates: ['bg-[#000]', 'bg-[#000000]'],
341+
normalizedClasses: ['bg-[#000000]'],
342+
expectedPresent: ['bg-[#000]', 'bg-[#000000]'],
343+
expectedAbsent: [],
344+
},
345+
{
346+
name: 'red shorthand and full tokens',
347+
rawCandidates: ['bg-[#f00]', 'bg-[#ff0000]'],
348+
normalizedClasses: ['bg-[#ff0000]'],
349+
expectedPresent: ['bg-[#f00]', 'bg-[#ff0000]'],
350+
expectedAbsent: [],
351+
},
352+
{
353+
name: 'green shorthand and full tokens',
354+
rawCandidates: ['bg-[#0f0]', 'bg-[#00ff00]'],
355+
normalizedClasses: ['bg-[#00ff00]'],
356+
expectedPresent: ['bg-[#0f0]', 'bg-[#00ff00]'],
357+
expectedAbsent: [],
358+
},
359+
])('prefers raw runtime candidates for precise class set matching: $name', ({ rawCandidates, normalizedClasses, expectedPresent, expectedAbsent }) => {
360+
const patcher = new TailwindcssPatcher({
361+
apply: {
362+
overwrite: false,
363+
},
364+
cache: false,
365+
tailwindcss: {
366+
version: 3,
367+
},
368+
})
369+
370+
const candidateRuleCache = new Map<string, Set<any>>()
371+
for (const candidate of rawCandidates) {
372+
candidateRuleCache.set(candidate, new Set())
373+
}
374+
375+
const classCache = new Map<string, any>()
376+
for (const cls of normalizedClasses) {
377+
classCache.set(cls, [])
378+
}
379+
380+
vi.spyOn(patcher, 'getContexts').mockReturnValue([
381+
{
382+
candidateRuleCache,
383+
classCache,
384+
} as any,
385+
])
386+
387+
const result = patcher.getClassSetSync()
388+
389+
expect(result).toBeDefined()
390+
for (const token of expectedPresent) {
391+
expect(result?.has(token)).toBe(true)
392+
}
393+
for (const token of expectedAbsent) {
394+
expect(result?.has(token)).toBe(false)
395+
}
396+
})
397+
323398
it('treats legacy cache schema as miss to avoid cross-context pollution', () => {
324399
const cacheFile = path.join(tempDir, 'cache.json')
325400
fs.writeJSONSync(cacheFile, ['cached-class'])

packages/tailwindcss-patch/test/runtime.class-collector.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,23 @@ function createContext(classes: string[]) {
1515
} as any
1616
}
1717

18+
function createContextWithCandidateRuleCache(rawCandidates: string[], normalizedClasses: string[]) {
19+
const candidateRuleCache = new Map()
20+
for (const candidate of rawCandidates) {
21+
candidateRuleCache.set(candidate, new Set())
22+
}
23+
24+
const classCache = new Map()
25+
for (const cls of normalizedClasses) {
26+
classCache.set(cls, [])
27+
}
28+
29+
return {
30+
candidateRuleCache,
31+
classCache,
32+
} as any
33+
}
34+
1835
describe('collectClassesFromContexts', () => {
1936
it('aggregates class names respecting the filter', () => {
2037
const contexts = [createContext(['text-lg', '*', 'font-bold'])]
@@ -23,6 +40,55 @@ describe('collectClassesFromContexts', () => {
2340
expect(result.has('text-lg')).toBe(true)
2441
expect(result.has('*')).toBe(false)
2542
})
43+
44+
it.each([
45+
{
46+
name: 'keeps shorthand hex tokens when shorthand is the only source token',
47+
rawCandidates: ['bg-[#000]'],
48+
normalizedClasses: ['bg-[#000000]'],
49+
expectedPresent: ['bg-[#000]'],
50+
expectedAbsent: ['bg-[#000000]'],
51+
},
52+
{
53+
name: 'keeps full hex tokens when full hex is the only source token',
54+
rawCandidates: ['bg-[#000000]'],
55+
normalizedClasses: ['bg-[#000000]'],
56+
expectedPresent: ['bg-[#000000]'],
57+
expectedAbsent: ['bg-[#000]'],
58+
},
59+
{
60+
name: 'keeps shorthand and full hex tokens distinct when both appear',
61+
rawCandidates: ['bg-[#000]', 'bg-[#000000]'],
62+
normalizedClasses: ['bg-[#000000]'],
63+
expectedPresent: ['bg-[#000]', 'bg-[#000000]'],
64+
expectedAbsent: [],
65+
},
66+
{
67+
name: 'does not merge red shorthand and full hex tokens',
68+
rawCandidates: ['bg-[#f00]', 'bg-[#ff0000]'],
69+
normalizedClasses: ['bg-[#ff0000]'],
70+
expectedPresent: ['bg-[#f00]', 'bg-[#ff0000]'],
71+
expectedAbsent: [],
72+
},
73+
{
74+
name: 'does not merge green shorthand and full hex tokens',
75+
rawCandidates: ['bg-[#0f0]', 'bg-[#00ff00]'],
76+
normalizedClasses: ['bg-[#00ff00]'],
77+
expectedPresent: ['bg-[#0f0]', 'bg-[#00ff00]'],
78+
expectedAbsent: [],
79+
},
80+
])('$name', ({ rawCandidates, normalizedClasses, expectedPresent, expectedAbsent }) => {
81+
const contexts = [createContextWithCandidateRuleCache(rawCandidates, normalizedClasses)]
82+
const result = collectClassesFromContexts(contexts as any, () => true)
83+
84+
for (const token of expectedPresent) {
85+
expect(result.has(token)).toBe(true)
86+
}
87+
88+
for (const token of expectedAbsent) {
89+
expect(result.has(token)).toBe(false)
90+
}
91+
})
2692
})
2793

2894
describe('collectClassesFromTailwindV4', () => {

0 commit comments

Comments
 (0)