i18n Gem Gotchas in Ruby 3.0: What You Need to Know
If you are using the i18n gem 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 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 .