Le filtre tailwind_merge arbitre les classes Tailwind qui se contredisent : la dernière du groupe gagne. Il ne peut rien pour celles qui n'arrivent jamais jusqu'à lui, et c'est ce deuxième cas qui a fait afficher les alertes d'erreur de ce blog en gris pendant un moment.
Des alertes d'erreur en gris
Pendant un moment, sur ce blog, les alertes d'erreur se sont affichées en gris neutre au lieu de rouge. Le composant avait bien une variante destructive, elle était bien passée à l'appel, et elle n'arrivait jamais dans le HTML.
Le coupable n'était pas tailwind_merge. C'était un étage au-dessus, et le filtre ne pouvait rien y faire : les classes rouges n'existaient pas encore quand il a rendu son arbitrage.
Ce billet fait donc deux choses. Il explique ce que tailwind_merge résout, ce qui est simple et net. Puis il montre le problème voisin qu'on lui attribue souvent à tort, avec les deux fois où ce blog s'est fait avoir.
Le conflit que le filtre tranche
Un composant Twig réutilisable a des classes par défaut, et l'appelant veut parfois les surcharger.
<button class="p-4 text-gray-500 {{ attributes.render('class') }}">Appelé avec class: 'p-2 text-blue-600', ce gabarit produit class="p-4 text-gray-500 p-2 text-blue-600". Les deux paddings sont là, et le navigateur ne tranche pas selon l'ordre du HTML : il applique celui dont la règle CSS arrive en dernier dans la feuille générée par Tailwind. L'ordre du HTML n'a aucune influence.
C'est le pire genre de défaut. Rien ne casse, rien ne remonte dans la console, et le bouton a le mauvais espacement jusqu'à ce que quelqu'un le remarque.
Le filtre supprime l'ambiguïté en gardant, pour chaque groupe de propriétés, la dernière classe écrite :
{{ ('p-4 text-gray-500 ' ~ attributes.render('class'))|tailwind_merge }}Sortie : p-2 text-blue-600. La règle tient en une phrase, et c'est toute sa valeur : la dernière du groupe gagne, quel que soit l'ordre du CSS.
Ce savoir sur les groupes est encodé dans la bibliothèque. Elle sait que p- et px- se marchent dessus, que text-gray-500 et text-blue-600 visent la même propriété, que rounded-lg et rounded-none s'excluent. Sur ce dépôt, le filtre est appelé 21 fois, à chaque fois selon le même motif : classes de base, puis classes de l'appelant, puis |tailwind_merge.
Le bundle qui le fournit est tales-from-a-dev/twig-tailwind-extra, qui emballe tailwind-merge-php.
Le problème d'à côté, celui qui ne se voit pas
Quand un composant a plus de deux ou trois variations, on ne les écrit pas à la main : on décrit une base et des variantes, et une fonction assemble. C'est le rôle de html_cva, fourni par twig/html-extra.
Sa signature est html_cva(base, variants, compoundVariants, defaultVariant). Les trois derniers sont des arguments nommés. C'est là que ça se joue, et je m'y suis fait prendre deux fois.
{# templates/components/Content/PostCardArticle.html.twig #}
{% set card = html_cva(
'group block',
variants: {
size: {
default: 'py-6 md:py-7',
compact: '',
},
},
) %}La forme correcte est celle-ci. La forme fautive emboîtait le tout dans un objet de configuration, html_cva('group block', { variants: { … } }), ce qui a l'air parfaitement raisonnable et ne lève strictement rien.
Ce qui se passe alors : la fonction enregistre un groupe de variantes qui s'appelle littéralement variants, un groupe que personne ne demandera jamais. L'appel apply({ size: 'default' }) cherche un groupe size, ne le trouve pas, et rend la base toute seule. Le padding py-6 md:py-7 n'a jamais été appliqué.
Le commentaire posé dans le gabarit d'alerte raconte la même histoire, en pire, parce qu'elle est visible par le lecteur :
{# templates/components/UI/Feedback/Alert.html.twig
`variants` = argument NOMMÉ de html_cva (pas la clé d'un objet de config) : sans ça, la
variante `destructive` n'était jamais appliquée et les alertes d'erreur s'affichaient en
neutre. Cf. PostCardArticle pour le même correctif. #}Une alerte d'erreur en gris ne ressemble pas à un bug. Elle ressemble à une alerte.
Pourquoi le filtre ne pouvait rien
Le rapprochement des deux moitiés de ce billet tient en une observation.
tailwind_merge reçoit une chaîne de classes et en supprime les contradictions. Dans le cas fautif, la chaîne qui lui arrivait ne contenait aucune contradiction : elle contenait group block, et rien d'autre. Il a fait exactement son travail sur ce qu'on lui a donné, et il l'a bien fait.
Un arbitre ne peut trancher qu'entre les classes qui se présentent. C'est un point qui vaut au-delà de Tailwind : un outil qui nettoie une sortie ne dit jamais que l'entrée était incomplète. Il rend une sortie propre, ce qui est précisément ce qui rend le défaut invisible.
J'en ai tiré une petite discipline qui coûte peu. Quand un composant à variantes ne réagit pas, avant de suspecter le filtre ou la purge de Tailwind, on regarde ce que apply() a réellement produit. Si la sortie est exactement la base, sans un seul mot de plus, la variante n'a jamais été enregistrée.
Le motif à garder
Pour un composant qui accepte des classes de l'extérieur, la structure ne change pas :
{{ ('classes-de-base ' ~ attributes.render('class'))|tailwind_merge }}Base d'abord, personnalisation ensuite, filtre en bout de chaîne. Et pour un composant à variantes, la même chose une marche plus haut :
{{ card.apply({ size: size }, class|default(''))|tailwind_merge }}Le gain réel n'est pas la brièveté du gabarit. C'est que le composant n'a plus besoin d'une propriété déclarée pour chaque variation imaginable. On écrit des défauts raisonnables, on laisse l'appelant passer ce qui diffère, et le filtre range derrière.
Reste que ce confort déplace la question plutôt qu'il ne la supprime. Un composant qui accepte n'importe quelle classe accepte aussi qu'on le déforme, et il n'existe aucun filtre pour arbitrer entre ce qu'on avait le droit de surcharger et ce qu'on aurait dû laisser tranquille. Quelle part de vos composants sont encore des composants, et quelle part sont devenus des div avec une valeur par défaut ?
Ce billet est publié sous licence Creative Commons BY-NC-SA 4.0 (attribution, pas d'usage commercial, partage dans les mêmes conditions).