i18n Gem Gotchas in Ruby 3.0: What You Need to Know

i18n Gem Gotchas in Ruby 3.0: What You Need to Know

If you are using the i18n gem opens a new window with Ruby 3.0 or are planning to upgrade Ruby to 3.0 while using the i18n gem, this blog post will cover a gotcha that can be tricky to understand.

The problem

Suppose you are using the i18n gem in an application and the code has some logic depending on Ruby’s frozen? method, then you would see totally different behavior in Ruby 2.7 and in Ruby 3.0.

Take a look at this code snippet when run in Ruby 2.7

# Ruby 2.7
a = I18n.t("user.title")
a.frozen?
=> false

b = a.clone
b.frozen?
=> false

Here the translated result stored in variable a is not frozen. And when we clone the object a, the cloned object is also not frozen.

Now let’s take a look at the same snippet of code when run in Ruby 3.0

# Ruby 3.0
a = I18n.t("user.title")
a.frozen?
=> true


b = a.clone
b.frozen?
=> true

Here the translated result stored in variable a is frozen. And when we clone the object a , the cloned object is also frozen.

As a matter of fact, clone is just an example used to explain the consequences of a translation object being frozen. The real problem is the different behavior of the translation object when asking frozen? in Ruby 2.7 vs 3.0.

Further Analysis

Let’s dig deeper into the problem to understand more about the behavioral change in Ruby 2.7 and 3.0. The main problem is to focus on why the translated object is frozen in Ruby 3.0 and not in Ruby 2.7.

This brings us to this opens a new window method in the i18n gem code.

def load_yml(filename)
  begin
    if YAML.respond_to?(:unsafe_load_file) # Psych 4.0 way
      [YAML.unsafe_load_file(filename, symbolize_names: true, freeze: true), true]
    else
      [YAML.load_file(filename), false]
    end
  rescue TypeError, ScriptError, StandardError => e
    raise InvalidLocaleData.new(filename, e.inspect)
  end
end

Let’s focus on the if / else condition. We can see that, when the if condition is true, freeze: true is set.

The if condition is YAML.respond_to?(:unsafe_load_file). Let’s run this check for Ruby 2.7 and 3.0 in the Rails console.

In Ruby 2.7

YAML.respond_to?(:unsafe_load_file)
=> false

In Ruby 3.0

YAML.respond_to?(:unsafe_load_file)
=> true

Since Ruby 3.0 returns true for the if condition check, then the freeze: true option is passed and that explains the frozen? method behavior for Ruby 3 and non-frozen behavior for Ruby 2.7.

Further gotcha with clone

Now consider this code for Ruby 2.7

# Ruby 2.7
a = I18n.t("user.age_group")
=> { senior_citizen: ">= 60", non_senior_citizen: "< 60"}

b = a.clone
b[:child] = "< 10"

The above code works fine for Ruby 2.7.

Now let’s run the same code with Ruby 3.0

# Ruby 3.0
a = I18n.t("user.age_group")
=> { senior_citizen: ">= 60", non_senior_citizen: "< 60"}

b = a.clone
b[:child] = "< 10"
=> this raises exception "can't modify frozen Hash"

This code does not run in Ruby 3.0.

As seen in a few initial snippets of code, the cloned object carries the same behavior as the original object. If the original object is frozen, then the cloned object would also be frozen.

How do we solve this problem?

If you were running the application in Ruby 2.7 and then upgrading to Ruby 3.0, suddenly you come across this behavior change that can break the logic implemented in the application. So, how do we solve this?

There are a couple of solutions that we can implement.

First solution

When cloning a translation object, the frozen status will be preserved in the cloned object by default. So we can explicitly set it as false when cloning the object.

We can make changes like shown here:

# Ruby 3.0
a = I18n.t("user.age_group")
a.frozen?
=> true

b = a.clone
b.frozen?
=> true

b = a.clone(freeze: false)
b.frozen?
=> false

Second solution

An alternate solution can be to use the dup method over the clone method. The dup method does not maintain the state of the original object. The following code snippet will make things clear.

a = I18n.t("user.age_group")
a.frozen?
=> true

b = a.clone
b.frozen?
=> true

b = a.dup
b.frozen?
=> false

The dup method doesn’t freeze the copied object even if the original one was frozen.

Conclusion

In our latest Ruby upgrade project we ended up using the first solution as it seems less risky and more explicit. I hope this post helps you in your journey of dealing with gotchas. If your team needs help with a ruby upgrade feel free to reach out opens a new window .

Get the book