How Upgrading Ruby Broke JavaScript

Matheus Richard

I was working on a Rails app and finished all my tasks. I wanted to be proactive, and a new Ruby minor version was available (3.2.1). It was almost 5 PM, what better time to upgrade my app?

These minor version upgrades are simple, right? I bumped the Ruby version and the CI Docker image, hit bundle install, and the tests passed locally. All good news. I pushed the branch to open a PR, and then… CI broke. What?

The beginning of the pains

I checked the error in CI, and it’s a system spec failing. Weird! Everything passed locally. The error said Capybara couldn’t find a select option. I looked at Capybara’s error screenshot, and the expected text was there!

Okay, desperate moments require desperate actions, so I used an advanced debugging technique: printing to the CI console. I printed the text of the option tag, and it… looked exactly what the test asserted. But strangely, it returned false when I compared the option.text to the expected text.

In disbelief, I copied the option text from the CI logs to my editor when suddenly I saw a highlighted char. One of the spaces in the text wasn’t a normal blank space but a U+202f char.

It’s not a bug. It’s a feature!

Why on Earth did that character end up there? I certainly didn’t add it! I checked how that select options were being created. Each option content was just a Time object converted to a string with Rails translation helpers, nothing fancy. Except, there was a Stimulus controller attached to them. It was a controller called local-time, which allowed users to see the time values in their local time. This controller looked like this:

import { Controller } from "@hotwired/stimulus"

const locale = document.documentElement.lang
const dateFormatter = new Intl.DateTimeFormat(locale, { timeStyle: "full" })

export default class extends Controller {
  connect() {
    const datetime = new Date(this.element.value)
    const formattedDatetime = dateFormatter.format(datetime)

    this.element.textContent = formattedDatetime
  }
}

So the error had something to do with JS. After some unlucky Google searches, I tried searching “Google Chrome U+202f” and found this Stack Overflow answer.

It looks like Chromium changed how Intl date parsing worked. But it eventually undid that because it was causing too much trouble (Firefox had done something similar too).

Well, that explained why the test passed on main (using older Chrome) and locally (newer Chrome) but not on that specific branch (a version of Chrome with that behavior).

The fix

This is not super important for this article, but it would feel incomplete without it. The best solution was simply upgrading Chrome on CI, which eliminates that undesired behavior altogether.

If that was not possible, for some reason, we could modify the local-time Stimulus controller to replace all instances of the U+202f char with a normal space:

const formattedDatetime = dateFormatter.format(datetime)
                                       .replaceAll("\u202F", " ")
// ...

The lesson

Although there’s a chance of someone getting to this article and finding a way to fix their bug, I think there’s something more important for us here.

When a bug happens, oftentimes it comes from false assumptions. In this particular case, I had several assumptions while fixing the bug:

  1. I initially assumed that I was just upgrading Ruby, when in fact, I happened to upgrade CI’s Docker file, which upgraded Chrome as well.
  2. I assumed that the text in the option tag was equal to the test expectation just because they looked the same.
  3. I implicitly assumed that the browser had the same behavior in different versions.
  4. I also assumed my local Chrome instance behaved exactly like the one in CI.

Sometimes, a bug is the culmination of a lengthy chain reaction of falling dominoes. We have to go back and find what caused the initial topple. That is why it’s important to be aware of the assumptions you’re making while debugging. If that topic interests you, we made a whole series about it!