dcastil/tailwind-merge

Class getting stripped out unnecessarily

brandonmcconnell opened this issue · 12 comments

I have this, roughly—

let computedClasses = cls(
  'inline-block text-base text-left font-semibold leading-tight',
  'text-xl'
);

console.log(computedClasses); // -> 'inline-block text-left font-semibold text-xl'

I can't figure out why leading-tight is getting removed.

Here is my implementation of cls:

import clsx, { type ClassValue } from 'clsx';
import { extendTailwindMerge, validators } from 'tailwind-merge';
import { spreadKeys } from '@carevoyance/calculations';

const trbl = ['t', 'r', 'b', 'l'];
const xy = ['x', 'y'];
const dirs = { trbl, xy, all: [...trbl, ...xy] };

const twMerge = extendTailwindMerge({
  classGroups: {
    shadow: [{
      shadow: [{
        ...spreadKeys(dirs.trbl, ['', 'inner', validators.isTshirtSize]),
        border: [
          '',
          validators.isNumber,
          validators.isArbitraryLength,
          spreadKeys(dirs.all, [validators.isNumber, validators.isArbitraryLength]),
        ],
      }],
    }],
  },
});

export const cls = (...inputs: ClassValue[]) => twMerge(clsx(...inputs));

Thanks for all the help, as always 🙏🏼

Hey @brandonmcconnell! 👋

This is because text-xl also sets line-height (line-height: 1.75rem; to be exact). If you want to preserve leading-tight, you need to set it after text-xl.

It's similar to how px-4 p-5 gets reduced to p-5 because you want to override the paddings set by px-4 but p-5 px-4 stays intact because tailwind-merge sees that as a refinement to p-5.

But I know that this is confusing. There were already several issues about this exact problem. Not many people know that Tailwind CSS is doing this. 🤔

@dcastil Thanks for clarifying that! I believe—though I could be wrong—if you have both a font-size utility like text-xs and a line-height utility like leading-tight, leading-_ will override the line-height regardless of the order (in pure TailwindCSS).

It would be great if tailwind-merge offered a setting to allow utilities like these and those you mentioned to not be considered conflicts and to allow TailwindCSS to handle their overrides naturally.

Also, if they're considered conflicts and I set leading-x after text-x, does that mean that leading would be prioritized and the text utility would be removed instead, making it difficult/impossible to have both?

If you put leading-x after text-x, both stay in there since you want to override the line-height of text-x but keep its font-size. The override happens through the CSS order in that case.

But e.g. think of a component that defines a line-height with leading-x inside and then you want to do <MyComponent className="text-xl" /> (which then results in twMerge('… leading-x', props.className)). The dev might want to override leading-x here with the line-height in text-xl, so tailwind-merge needs to remove that.

But you can change this behavior by the way! You can remove this conflict from the config (this one).

const twMerge = extendTailwindMerge({ /* your config */ }, config => {
    delete config.conflictingClassGroups['font-size']
    return config
})

@dcastil Thanks!

@brandonmcconnell I'm closing this issue as resolved. Let me know if your issue persists. 😊

@dcastil Just wanted to confirm— using delete config.conflictingClassGroups['font-size'] will still remove conflicting font-size utilities of the same type, correct? For exmaple, twMerge('text-lg text-sm') would still compile to text-sm, right?

Yes! config.conflictingClassGroups['font-size'] only holds the information that font-size classes clash with line-height classes. Classes from the same group always conflict with each other.

@dcastil Awesome, thanks!

Mentioned this issue in #226 (reply in thread).

Simple TypeScript transformer which spits something out on the console (process.env.NODE_ENV === "development" only) when a class is dropped.

import clsx, { type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";

const missingWords = (before: string, after: string) => {
  const afterArr = after.split(" ");
  return before
    .split(" ")
    .filter((w) => !afterArr.includes(w))
    .join(" ");
};

const twM = (...inputs: ClassValue[]) => {
  const joinedClasses = clsx(...inputs);
  const mergedClasses = twMerge(joinedClasses);
  if (
    process.env.NODE_ENV === "development" &&
    joinedClasses !== mergedClasses
  ) {
    console.trace(
      `twM: "${joinedClasses}" lost "${missingWords(
        joinedClasses,
        mergedClasses
      )}"`
    );
  }

  return mergedClasses;
};

export default twM;

Sample output:
image


If you want a shorter stack trace, you can use this approach, but you won't be able to click through to the source file (or at least I haven't been able to figure out how):

const getCaller = () => {
  const err = Error("");
  const callerLine = err?.stack?.split("\n")[4];
  return callerLine?.trim().replace(/^at /, "from ");
};

Replace the console.trace call with:

    console.log(
      `twM: "${joinedClasses}" lost "${missingWords(
        joinedClasses,
        mergedClasses
      )}"
      ${getCaller() || ""}`
    );

Sample output:
image

getCaller based on https://stackoverflow.com/questions/1340872/how-to-get-javascript-caller-function-line-number-and-caller-source-url#answer-3806596