Skip to content

Commit daf10f9

Browse files
committed
implement fallback plugins
Whenever an arbitrary value results in css from multiple plugins we first try to resolve a falback plugin. The fallback mechanism works like this: - If A has type `any` and B has type `color`, then B should win. > This is because `A` will match *anything*, but the more precise type should win instead. E.g.: `backgroundColor` has the type `any` so `bg-[100px_200px]` would match both the `backgroundColor` and `backgroundSize` but `backgroundSize` matched because of a specific type and not because of the `any` type. - If A has type `length` and B has type `[length, { disambiguate: true }]`, then B should win. > This is because `B` marked the `length` as the plugin that should win in case a clash happens.
1 parent b15bde1 commit daf10f9

File tree

3 files changed

+148
-61
lines changed

3 files changed

+148
-61
lines changed

src/lib/generateRules.js

+114-47
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import selectorParser from 'postcss-selector-parser'
33
import parseObjectStyles from '../util/parseObjectStyles'
44
import isPlainObject from '../util/isPlainObject'
55
import prefixSelector from '../util/prefixSelector'
6-
import { updateAllClasses } from '../util/pluginUtils'
6+
import { updateAllClasses, typeMap } from '../util/pluginUtils'
77
import log from '../util/log'
88
import * as sharedState from './sharedState'
99
import { formatVariantSelector, finalizeSelector } from '../util/formatVariantSelector'
@@ -535,68 +535,135 @@ function* resolveMatches(candidate, context, original = candidate) {
535535
}
536536

537537
if (matchesPerPlugin.length > 0) {
538-
typesByMatches.set(matchesPerPlugin, sort.options?.type)
538+
let matchingTypes = (sort.options?.types ?? [])
539+
.map(({ type }) => type)
540+
// Only track the types for this plugin that resulted in some result
541+
.filter((type) => {
542+
return Boolean(
543+
typeMap[type](modifier, sort.options, {
544+
tailwindConfig: context.tailwindConfig,
545+
})
546+
)
547+
})
548+
549+
if (matchingTypes.length > 0) {
550+
typesByMatches.set(matchesPerPlugin, matchingTypes)
551+
}
552+
539553
matches.push(matchesPerPlugin)
540554
}
541555
}
542556

543557
if (isArbitraryValue(modifier)) {
544-
// When generated arbitrary values are ambiguous, we can't know
545-
// which to pick so don't generate any utilities for them
546558
if (matches.length > 1) {
547-
let typesPerPlugin = matches.map((match) => new Set([...(typesByMatches.get(match) ?? [])]))
559+
// Partition plugins in 2 categories so that we can start searching in the plugins that
560+
// don't have `any` as a type first.
561+
let [withAny, withoutAny] = matches.reduce(
562+
(group, plugin) => {
563+
let hasAnyType = plugin.some(([{ options }]) =>
564+
options.types.some(({ type }) => type === 'any')
565+
)
548566

549-
// Remove duplicates, so that we can detect proper unique types for each plugin.
550-
for (let pluginTypes of typesPerPlugin) {
551-
for (let type of pluginTypes) {
552-
let removeFromOwnGroup = false
567+
if (hasAnyType) {
568+
group[0].push(plugin)
569+
} else {
570+
group[1].push(plugin)
571+
}
572+
return group
573+
},
574+
[[], []]
575+
)
553576

554-
for (let otherGroup of typesPerPlugin) {
555-
if (pluginTypes === otherGroup) continue
577+
function findFallback(matches) {
578+
// If only a single plugin matches, let's take that one
579+
if (matches.length === 1) {
580+
return matches[0]
581+
}
556582

557-
if (otherGroup.has(type)) {
558-
otherGroup.delete(type)
559-
removeFromOwnGroup = true
583+
// Otherwise, find the plugin that creates a valid rule given the arbitrary value, and
584+
// also has the correct type which disambiguates the plugin in case of clashes.
585+
return matches.find((rules) => {
586+
let matchingTypes = typesByMatches.get(rules)
587+
return rules.some(([{ options }, rule]) => {
588+
if (!isParsableNode(rule)) {
589+
return false
560590
}
561-
}
562591

563-
if (removeFromOwnGroup) pluginTypes.delete(type)
564-
}
592+
return options.types.some(
593+
({ type, disambiguate }) => matchingTypes.includes(type) && disambiguate
594+
)
595+
})
596+
})
565597
}
566598

567-
let messages = []
568-
569-
for (let [idx, group] of typesPerPlugin.entries()) {
570-
for (let type of group) {
571-
let rules = matches[idx]
572-
.map(([, rule]) => rule)
573-
.flat()
574-
.map((rule) =>
575-
rule
576-
.toString()
577-
.split('\n')
578-
.slice(1, -1) // Remove selector and closing '}'
579-
.map((line) => line.trim())
580-
.map((x) => ` ${x}`) // Re-indent
581-
.join('\n')
582-
)
583-
.join('\n\n')
599+
// Try to find a fallback plugin, because we already know that multiple plugins matched for
600+
// the given arbitrary value.
601+
let fallback = findFallback(withoutAny) ?? findFallback(withAny)
602+
if (fallback) {
603+
matches = [fallback]
604+
}
584605

585-
messages.push(
586-
` Use \`${candidate.replace('[', `[${type}:`)}\` for \`${rules.trim()}\``
587-
)
588-
break
606+
// We couldn't find a fallback plugin which means that there are now multiple plugins that
607+
// generated css for the current candidate. This means that the result is ambiguous and this
608+
// should not happen. We won't generate anything right now, so let's report this to the user
609+
// by logging some options about what they can do.
610+
else {
611+
let typesPerPlugin = matches.map(
612+
(match) => new Set([...(typesByMatches.get(match) ?? [])])
613+
)
614+
615+
// Remove duplicates, so that we can detect proper unique types for each plugin.
616+
for (let pluginTypes of typesPerPlugin) {
617+
for (let type of pluginTypes) {
618+
let removeFromOwnGroup = false
619+
620+
for (let otherGroup of typesPerPlugin) {
621+
if (pluginTypes === otherGroup) continue
622+
623+
if (otherGroup.has(type)) {
624+
otherGroup.delete(type)
625+
removeFromOwnGroup = true
626+
}
627+
}
628+
629+
if (removeFromOwnGroup) pluginTypes.delete(type)
630+
}
589631
}
590-
}
591632

592-
log.warn([
593-
`The class \`${candidate}\` is ambiguous and matches multiple utilities.`,
594-
...messages,
595-
`If this is content and not a class, replace it with \`${candidate
596-
.replace('[', '[')
597-
.replace(']', ']')}\` to silence this warning.`,
598-
])
599-
continue
633+
let messages = []
634+
635+
for (let [idx, group] of typesPerPlugin.entries()) {
636+
for (let type of group) {
637+
let rules = matches[idx]
638+
.map(([, rule]) => rule)
639+
.flat()
640+
.map((rule) =>
641+
rule
642+
.toString()
643+
.split('\n')
644+
.slice(1, -1) // Remove selector and closing '}'
645+
.map((line) => line.trim())
646+
.map((x) => ` ${x}`) // Re-indent
647+
.join('\n')
648+
)
649+
.join('\n\n')
650+
651+
messages.push(
652+
` Use \`${candidate.replace('[', `[${type}:`)}\` for \`${rules.trim()}\``
653+
)
654+
break
655+
}
656+
}
657+
658+
log.warn([
659+
`The class \`${candidate}\` is ambiguous and matches multiple utilities.`,
660+
...messages,
661+
`If this is content and not a class, replace it with \`${candidate
662+
.replace('[', '[')
663+
.replace(']', ']')}\` to silence this warning.`,
664+
])
665+
continue
666+
}
600667
}
601668

602669
matches = matches.map((list) => list.filter((match) => isParsableNode(match[1])))

src/lib/setupContextUtils.js

+32-12
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,20 @@ function prefix(context, selector) {
3030
return typeof prefix === 'function' ? prefix(selector) : prefix + selector
3131
}
3232

33+
function normalizeOptionTypes({ type = 'any', ...options }) {
34+
let types = [].concat(type)
35+
36+
return {
37+
...options,
38+
types: types.map((type) => {
39+
if (Array.isArray(type)) {
40+
return { type: type[0], ...type[1] }
41+
}
42+
return { type, disambiguate: false }
43+
}),
44+
}
45+
}
46+
3347
function parseVariantFormatString(input) {
3448
if (input.includes('{')) {
3549
if (!isBalanced(input)) throw new Error(`Your { and } are unbalanced.`)
@@ -346,7 +360,7 @@ function buildPluginApi(tailwindConfig, context, { variantList, variantMap, offs
346360
respectImportant: true,
347361
}
348362

349-
options = { ...defaultOptions, ...options }
363+
options = normalizeOptionTypes({ ...defaultOptions, ...options })
350364

351365
let offset = offsets.create('utilities')
352366

@@ -357,16 +371,24 @@ function buildPluginApi(tailwindConfig, context, { variantList, variantMap, offs
357371
classList.add([prefixedIdentifier, options])
358372

359373
function wrapped(modifier, { isOnlyPlugin }) {
360-
let { type = 'any' } = options
361-
type = [].concat(type)
362-
let [value, coercedType] = coerceValue(type, modifier, options, tailwindConfig)
374+
let [value, coercedType] = coerceValue(options.types, modifier, options, tailwindConfig)
363375

364376
if (value === undefined) {
365377
return []
366378
}
367379

368-
if (!type.includes(coercedType) && !isOnlyPlugin) {
369-
return []
380+
if (!options.types.some(({ type }) => type === coercedType)) {
381+
if (isOnlyPlugin) {
382+
log.warn([
383+
`Unnecessary typehint \`${coercedType}\` in \`${identifier}-${modifier}\`.`,
384+
`You can safely update it to \`${identifier}-${modifier.replace(
385+
coercedType + ':',
386+
''
387+
)}\`.`,
388+
])
389+
} else {
390+
return []
391+
}
370392
}
371393

372394
if (!isValidArbitraryValue(value)) {
@@ -398,7 +420,7 @@ function buildPluginApi(tailwindConfig, context, { variantList, variantMap, offs
398420
respectImportant: false,
399421
}
400422

401-
options = { ...defaultOptions, ...options }
423+
options = normalizeOptionTypes({ ...defaultOptions, ...options })
402424

403425
let offset = offsets.create('components')
404426

@@ -409,15 +431,13 @@ function buildPluginApi(tailwindConfig, context, { variantList, variantMap, offs
409431
classList.add([prefixedIdentifier, options])
410432

411433
function wrapped(modifier, { isOnlyPlugin }) {
412-
let { type = 'any' } = options
413-
type = [].concat(type)
414-
let [value, coercedType] = coerceValue(type, modifier, options, tailwindConfig)
434+
let [value, coercedType] = coerceValue(options.types, modifier, options, tailwindConfig)
415435

416436
if (value === undefined) {
417437
return []
418438
}
419439

420-
if (!type.includes(coercedType)) {
440+
if (!options.types.some(({ type }) => type === coercedType)) {
421441
if (isOnlyPlugin) {
422442
log.warn([
423443
`Unnecessary typehint \`${coercedType}\` in \`${identifier}-${modifier}\`.`,
@@ -734,7 +754,7 @@ function registerPlugins(plugins, context) {
734754
]
735755
}
736756

737-
if ([].concat(options?.type).includes('color')) {
757+
if (options.types.some(({ type }) => type === 'color')) {
738758
classes = [
739759
...classes,
740760
...classes.flatMap((cls) =>

src/util/pluginUtils.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ function guess(validate) {
146146
}
147147
}
148148

149-
let typeMap = {
149+
export let typeMap = {
150150
any: asValue,
151151
color: asColor,
152152
url: guess(url),
@@ -195,7 +195,7 @@ export function coerceValue(types, modifier, options, tailwindConfig) {
195195
}
196196

197197
// Find first matching type
198-
for (let type of [].concat(types)) {
198+
for (let { type } of types) {
199199
let result = typeMap[type](modifier, options, { tailwindConfig })
200200
if (result !== undefined) return [result, type]
201201
}

0 commit comments

Comments
 (0)