My Many Colored Ways

A personal site, by Kyle Halleman.

Fixing media queries in Outlook iOS

Recently I was testing an update to how we remove unused CSS in email templates at work. We switched from using uncss in a Gulp pipeline to using PurgeCSS in a custom Node script that used jsdom to inline the purged CSS into a style block in the head.

Naturally we needed to do a full regression. Which meant spending a lot of time in Litmus comparing the old templates to the new templates across a bunch of email clients.

An unintentional win

I started noticing that in Outlook for iOS, some old templates had the desktop view whereas the new templates used mobile styles. It’s not like we added new media queries: they’ve always been there. Why were they suddenly working, but only for some emails?

I chalked it up to some quirk. Getting Outlook to honor the mobile styles is a win. So I didn’t want to spend too much time questioning it.

An unintentional loss

Then I ran into a different issue with Outlook for iOS. Some padding at the top of the emails was missing in the new templates. Otherwise they looked the same. All our emails are minified, so I diffed this particular template after running both the old and new through Prettier.

Not much had changed. A 70px in a height attribute changed to 70. I switched it back in Litmus’s Builder tool—where you can view and edit the HTML of your email, previewing the updates in various email clients.

Adding back the px to the height didn’t make a difference. There were fewer media queries in the new templates because PurgeCSS was doing a better job at unused selector elimination. I confirmed the removed selectors were truly not in the email.

On the scent

Reading the minified CSS in Litmus was painful. So I copied out the CSS, ran it through Prettier, and pasted it back into the builder. Suddenly the mobile view for Outlook iOS worked. Why? It was the exact same CSS, just formatted.

Figuring it had something to do with the minification process, as well as media queries, I put back in the minified CSS and started to play around.

This is our starting point:

@media only screen and (min-width:700px){.supercool .selector{display:block}}@media only screen{body{padding:1em}}

Do you see why Outlook wouldn’t interpret this media query?

Neither did I.

I started with adding space around the media queries. I figured since other parts of the CSS looked like .supercool .selector{display:block} and worked, the selectors were not the culprits.

@media only screen and (min-width:700px) {.supercool .selector{display:block}} @media only screen {body{padding:1em}}

No dice.

What if I add just a little more space?

@media only screen and (min-width:700px) { .supercool .selector{display:block} } @media only screen { body{padding:1em} }

It worked! 🎉

But I didn’t want to rewrite the minifier we use. I had to figure out the minimal transformation to the minified CSS to make Outlook iOS recognize these media queries.

It turns out padding the start and end of the media query block by one space is all you need.

Now I have two problems

Some people, when confronted with a problem, think “I know, I’ll use regular expressions.” Now they have two problems.
Jamie Zawinski

Yes, my first inclination was to use a regular expression. If this had been HTML, then I would have reached for jsdom. But I’m not aware of any way to modify CSS like you would in jsdom—outside of using an AST like Prettier does to change the formatting.

I had a few assumptions that made writing this RegEx easier. First, all the media queries were together at the end of the style block. Second, there was only one style block in the document. Finally, all queries started the same: @media only screen.

After spending some time in the invaluable regex101, I arrived at a solution:

/@media only screen(?: |\(|\)|[a-z]|:|-|\d)*{((?:[^<@])+)}(?=@|<)/gm

Refactoring

But while writing this post I figured there was a more generic approach that made fewer assumptions. The problem I was having was stopping at the first }} encountered, not the last. This really stumped me because I was trying things like [^}}]. But that construct only matches a single character and not a sequence. After some searching, I found this answer on Stack Overflow. I had never heard of the un-greedy/lazy quantifier before. This was the key.

/@media(?: |\(|\)|[a-z]|:|-|\d)+{(.+?(?=}}))/gm

This still makes an assumption that there’s not more nesting going on (like an @supports in the media query block). But it works.

Given the following CSS:

@media only screen and (min-width:700px){.supercool .selector{display:block}}@media only screen{body{padding:1em}}

You will get this output:

@media only screen and (min-width:700px){ .supercool .selector{display:block} }@media only screen{ body{padding:1em} }

Squint to see the difference.

Understanding the solution

What’s going on, exactly? I’ll break it down piece by piece.

  1. @media: Starts our search looking for the exact string @media.
  2. (?: |\(|\)|[a-z]|:|-|\d)+: Checks for any values the media query might have. This includes things like only screen or screen and (min-width: 500px). I use (?:), the non-capturing group construct, because I only want one capture group—the part I need to modify.
  3. {(.+?(?=}})): Isolates the media query block. Starting with the opening bracket {, I want to capture everything until the end of the block (marked by }}). This is where the un-greedy quantifier comes in. If I had used .+ instead of .+?, it would have consumed all media queries and stopped only at the end of the style block.
  4. Then it was just a matter of using String.prototype.replace:
"cssstring".replace(REGEX, (match, group) => match.split(group).join(` ${group} `))

And that’s how I spent two hours at work one day.