Ruby 3.1 support for Rails LTS

We are pleased to announce that all versions of Rails LTS now support Ruby 3.1, additionally to all Ruby versions we previously supported.

As always, "support" means:

  • You should not run into errors that cannot be solved without changes to Rails.
  • We did our best to not require you to make too many changes.
  • There will most likely be issues within your own code and with third party gems.

We have successfully upgraded a medium-sized app to Ruby 3.1 for each version of Rails LTS without major trouble, but be aware that this upgrade will be bumpier than previous Ruby 2.x upgrades. I expect we might get some feedback and fix some edge cases in the coming weeks, so if you can wait a bit, it will probably not hurt.

As always, our highest priority has been to not break any existing apps running on legacy Rubies. We are not aware of serious vulnerabilities within Ruby that are likely to come into play in the context of a Rails app. We ourselves will most likely keep running Ruby 2 apps for a while.

Upgrade instructions

To upgrade a Rails LTS app to Ruby 3:

  • Make sure you are on the latest patch of Rails LTS.
  • Bump your Ruby version to a 3.1.x version.
  • Add the ruby3-backward-compatibility gem Show archive.org snapshot to your Gemfile (this is a public gem maintained by us). More info below.
  • Make sure you use i18n version > 1. There seems to have been few to none breaking changes in i18n.
  • Try running your app and see what fails. Fix it.

Why stuff breaks

There are a few typical issues that cause problems on Ruby 3.1, detailed below. To address some (but not all) of them, we created the ruby3-backward-compatibility gem.

As a general observation, we encountered significantly fewer issue in older code than in more recent code, so upgrading a Rails 2 app might in this case be easier than a Rails 5 app. Old code does not use keyword arguments, which is the main pain point.

Changes to stdlib and gem APIs

Ruby 3 has removed some deprecated APIs. For example,

# no longer works:
ERB.new(template, nil, '-', '@output_buffer')
# instead you use:
ERB.new(template, trim_mode: '-', eoutvar: '@output_buffer')

# no longer works:
YAML.safe_load(yaml, [Time], [], true, 'foo.yml')
# instead you use:
YAML.safe_load(yaml, permitted_classes: [Time], permitted_symbols: [], aliases: true, filename: 'foo.yml')

# was a noop, but is now undefined:
object.taint
object.untaint

The ruby3-backward-compatibility gem patches all these, so the old API keeps working as in Ruby 2.7.

YAML safety

As you probably know, loading user-controlled YAML is dangerous, which is why YAML introduced YAML.safe_load for a while. Rails LTS uses safe_load where necessary.

However, when you upgrade to psych version >= 4 (the "yaml" gem; which is default for Ruby 3), the default changes. YAML.load used to be "unsafe", but now means "safe load". You have to use YAML.unsafe_load explictly to get the old behavior.

This is something we cannot address globally. One could patch YAML.load to use the old behavior, but this is dangerous in case some newer code expects the new, safe behavior.

Instead, you need to change YAML.load to YAML.unsafe_load where required.

Keyword arguments

This is the big one. Ruby 3 no longer allows you to call methods expecting keyword arguments with a hash. For example:

def method_with_kwarg(param, keyword_param: 'default')
end

def method_with_options_hash(params, options = {})
end

# no longer works:
method_with_kwargs('foo', { keyword_param: 'bar' })
# still works:
method_with_kwargs('foo', keyword_param: 'bar')
method_with_kwargs('foo', **{ keyword_param: 'bar' })
# also still works, surprisingly:
method_with_kwargs('foo', :keyword_param => 'bar')

# the reverse (converting keyword to an options hash) still works fine:
method_with_options_hash('foo', bar: 'baz')

Unfortunately, these invalid calls happen all over the place. To simplify the fix, the ruby3-backward-compatibility gem introduces this helper:

def method_with_kwarg(param, keyword_param: 'default')
end

extend Ruby3BackwardCompatibility::CallableWithHash
callable_with_hash :method_with_kwarg

# now this works again:
method_with_kwarg('foo', { keyword_param: 'default' })

callable_with_hash works, by wrapping the original method. The wrapper will check if the caller used a hash in a position that looks like it might have been a keyword arg, and then casts the hash to keywords by using **.

You can even use this on external APIs, like:

# i18n now requires keyword args, so this fails
I18n.t('user', { scope: ['models'] })

# This will make it work again
module I18n
  extend Ruby3BackwardCompatibility::CallableWithHash
  callable_with_hash :t
end

(In the case of i18n, this fix is already included in the ruby3-backward-compatibilty gem itself.)

We applied callbable_with_hash to a significant number of Rails methods (especially on Rails 5), so you should mostly be good calling Rails methods with either style.

Delegation

If your code uses some custom delegation (or method_missing) style code, you will have to take care of keyword arguments as well.

class Foo < Base
  # this does not work with keyword args
  def method1(*args)
    super
  end
    
  # this will
  def method2(*args, **kwargs)
    super
  end
  
  # as will this
  def method3(...)
    super
  end
end

If for some reason you have to write code that also works with some legacy ruby versions, find even more details here.

Tobias Kraze Over 1 year ago