tailwindcss icon indicating copy to clipboard operation
tailwindcss copied to clipboard

Escaping underscores in JavaScript contexts can cause mismatched class selectors

Open n18l opened this issue 2 years ago • 10 comments

What version of Tailwind CSS are you using?

v3.1.6

What build tool (or framework if it abstracts the build tool) are you using?

Create React App v5.0.1

What version of Node.js are you using?

v16.15.0

What browser are you using?

Observed in Firefox 103 and Chrome 103

What operating system are you using?

macOS 12.4

Reproduction URL

https://github.com/nbrombal/tailwind-escape-conflict

Describe your issue

Tailwind's method of escaping underscores within arbitrary values conflicts with JavaScript when Tailwind classes are applied in a JavaScript context. For example, when using Tailwind classes in JSX as part of a ternary expression, or as a function argument for the popular classnames library. This can lead to the HTML that is ultimately rendered to the page not matching the CSS that Tailwind generates.

My specific situation is one where I want to use underscores in the content attribute of a pseudo-selector. In the following examples, expand_more is the name of an icon in the Material Symbols icon font, and the font-symbols utility applies that icon font so its ligature feature will translate the name into the icon.

I'm not the first person to have run into this (that would be @FelixZY in this discussion), but I don't believe the cause of this edge case has been articulated until now.

Plain String, single escaped (working as intended)

The JSX source contains a simple string for the className prop:

<span className='before:font-symbols before:content-["expand\_more"]' />

✓ The generated HTML class is as expected.

<span class="before:font-symbols before:content-[&quot;expand\_more&quot;]"></span>

✓ The generated CSS rule is as expected.

.before\:content-\[\"expand\\_more\"\]::before {
  --tw-content: "expand_more";
  content: var(--tw-content);
}

JavaScript String, single escaped

The JSX source uses a string in a JavaScript context (note that this runs afoul of my ESLint/Prettier config, YMMV):

/* eslint-disable no-useless-escape, prettier/prettier */
<span className={'before:font-symbols before:content-["expand\_more"]'} />

✗ Because of the JS context, the slash has been removed from the HTML class.

<span class="before:font-symbols before:content-[&quot;expand_more&quot;]"></span>

✓ The generated CSS rule is as expected, but of course it now doesn't match our slash-less HTML class.

.before\:content-\[\"expand\\_more\"\]::before {
  --tw-content: "expand_more";
  content: var(--tw-content);
}

JavaScript string, double escaped

The JSX source uses a string in a JavaScript context:

<span className={'before:font-symbols before:content-["expand\\_more"]'} />

✓ The generated HTML class is now as expected since we escaped our escape character.

<span class="before:font-symbols before:content-[&quot;expand\_more&quot;]"></span>

✗ However, the generated CSS rule now incorporates the extra slashes, so it doesn't match the HTML class.

.before\:content-\[\"expand\\\\_more\"\]::before {
  --tw-content: "expand\_more";
  content: var(--tw-content);
}

This could probably be resolved by having Tailwind use a different method to escape underscores that doesn't match the way JavaScript does it. I've struggled to come up with an alternative to suggest because any option risks breaking existing content strings, but for simplicity's sake I lean towards suggesting a single underscore to represent a space (which is the current behavior), and a double underscore to represent an underscore.

n18l avatar Jul 18 '22 17:07 n18l

This has to be fixed, I spent countless hours trying to solve this problem!

jean343 avatar Jul 26 '22 13:07 jean343

I did a little digging to satisfy my curiosity and found the location of the underscore replacement logic, just to ease the burden of anyone that may want to dig in further.

I'd be happy to open a PR, but preferably not before there's been some discussion on the best solution.

n18l avatar Jul 26 '22 17:07 n18l

Not 100% sure if it covers exactly the same issue but I also noticed that classes are not generated correctly using Vue templates:

class="[&_.card\_\_footer]:!pb-0"

will produce

.\[\&_\.card\\_\\_footer\]\:\!pb-0 .card__footer {
  padding-bottom: 0px !important;
}

but the generated HTML is

class="[&_.card__footer]:!pb-0"

Consequently, the \ is escaped again in the generated style class and the styles should rather be like this (note the difference between "card" and "footer"):

.\[\&_\.card\_\_footer\]\:\!pb-0 .card__footer {
  padding-bottom: 0px !important;
}

jaulz avatar Jul 27 '22 09:07 jaulz

Could you provide a screenshot or more details about how the internal error message shows up?

Sorry, not sure what you're referring to. I haven't seen any error messages related to or caused by this; it's simply a case of unexpected output.

Not 100% sure if it covers exactly the same issue but I also noticed that classes are not generated correctly using Vue templates

They may not be exactly the same, but I do think these are related! Both of our examples are registering an arbitrary string—yours is a variant, and mine is a value—which gets run through the normalize function where the \_ transformation is handled.

n18l avatar Jul 27 '22 15:07 n18l

I think the double underscore idea is a good one. This will be a breaking change unfortunately but when we resolve it we can put the new behavior behind a flag so people can use it before v4 comes out some time in the future.

adamwathan avatar Aug 08 '22 19:08 adamwathan

Quick follow-up, one thing @thecrypticace pointed out to me that is a problem with the double underscore solution is that a triple underscore would become ambiguous:

a___b => "a_ b" or "a _b"?

Any other ideas?

adamwathan avatar Aug 08 '22 20:08 adamwathan

One solution in JSX is to use String.raw when you need to escape an underscore in a class, for example:

<span className={String.raw`before:font-symbols before:content-["expand\_more"]`} />

This works as expected, because JS won't process the escape and preserves the \_ in the string.

The more I think about this the more I'm becoming convinced there's not really an amazing answer here and we probably just need to live with the current behavior and accept that there's some edge-cases you need to work around sometimes.

Going to leave this open for a couple more days in case anyone wants to collect some more examples here of situations where they have run into this so we can provide recommendations on how we would handle those situations and so that this issue can serve as a useful reference for anyone who runs into this in the future, but unfortunately don't think we will end up acting on this unless we hear about it a lot more from a wider group in the community.

adamwathan avatar Aug 08 '22 20:08 adamwathan

The ambiguity of double underscores is a good catch. The only other idea I had was choosing some other escape character than backslash, but which character proved to be a tough choice given the context.

In front-end dev land the ones that immediately come to mind are ampersands (HTML) and percentage signs (URL/URI). But those both typically precede actual character entity codes, which is probably a recipe for confusion. Ampersands in particular are a bad idea because they have a meaning within the context of pre-processed CSS already.

It's probably better to go with something wholly unique (or entirely tangential to JS, at least) for clarity, like a caret:

a__b => a  b
a^_^_b => a__b
a^__b => a_ b
a_^_b => a _b

I think the Windows command line uses carets, so there's "precedence" in a programming sense, but it would of course never overlap with Tailwind in a conflicting way.

n18l avatar Aug 08 '22 20:08 n18l

Honestly, I like @nbrombal 's idea of ^_ as an alternative to \_. Since \ is so widely used for generic escapes as part of the language itself, it becomes a bit confusing to use it as a "feature" in a lib too - as this issue has proven. It's problematic when the number of escape characters need to be changed depending on the context and ^_ seems like a way to avoid this.

FelixZY avatar Aug 08 '22 21:08 FelixZY

I just wanted to follow up here and say that the String.raw workaround seems like a perfectly good solution (at least for my use case) given how much of an edge-case this appears to be. I don't know that I ever would have stumbled onto it myself.

@adamwathan Do you think this is too niche to mention it in the official docs? If not, I'm happy to open a PR to that effect over in the tailwindcss.com repo.

n18l avatar Aug 25 '22 17:08 n18l

@nbrombal Added a quick note here: https://github.com/tailwindlabs/tailwindcss.com/commit/3ef481af5242ca86777d4a1e1ff5630a5bb8efdb

Glad to hear the String.raw solution is sufficient, going to close this one then with that as the recommendation 👍

adamwathan avatar Sep 02 '22 10:09 adamwathan

@adamwathan are you also aware of any fix for the Vue template like I mentioned above?

jaulz avatar Sep 02 '22 11:09 jaulz

@jaulz I happened to stumble upon the same issue just now, and figured that using a computed property or variable alongside a data attribute could work as well.

Here's an example:

<!-- Template -->
<span
    class="after:content-[attr(warn)] after:text-[#c29485]"
    :warn="warnText"
  >
    "Content"
 <span />

We bind the value of the warn data attribute to a computed property whose return value is a string:

// Script
const warnText = computed(() => {
  return newAmount.value == 0 ? "Can't be 0" : ''
})

This allows us to use special characters while also having dynamic content.

This is more of a workaround, but I hope it helps.

GeorgeDaris avatar Jul 13 '23 16:07 GeorgeDaris