Ruby On Rails 2026

From bibbleWiki
Jump to navigation Jump to search

Introduction

Got new job which wanted this skill to poking around to have another go.

Installation

No robot back in 2020 when last I gave this the initial look. It is telling me to use asdf with a specific version. Clearly I need to check this out before doing. For Ubuntu its self it say

sudo apt install -y build-essential libssl-dev libreadline-dev zlib1g-dev libsqlite3-dev

So went with the version of asdf it said because the second instructions fail because there is no completions directory with the latest release so for asdf

sudo apt install curl git
git clone https://github.com/asdf-vm/asdf.git ~/.asdf --branch v0.14.0
echo '. "$HOME/.asdf/asdf.sh"' >> ~/.bashrc
echo '. "$HOME/.asdf/completions/asdf.bash"' >> ~/.bashrc
source ~/.bashrc

Now for rails went with 3.3.11

asdf plugin add ruby
asdf install ruby 3.3.11
asdf global ruby 3.3.11

And for Bundler and Rails

gem install bundler -v 2.4.19
gem install rails
rails -v Rails 8.1.3

Gemfile Stuff

Gemfile Basics

The Gemfile is Ruby’s equivalent of npm’s package.json. It defines which gems your application depends on and in which environments they should be installed.

Bundler supports groups, which work similarly to npm’s dependencies and devDependencies.

Grouping Gems

You can group gems so they are only installed or loaded in specific environments.

A common example is the development group:

group :development do
gem "web-console"
gem "rubocop", require: false
gem "rubocop-rails", require: false
gem "htmlbeautifier"
end

You can also combine multiple groups:

group :development, :test do
gem "debug", platforms: %i[mri windows], require: "debug/prelude"
gem "bundler-audit", require: false
end

Gems inside these blocks are only installed when Bundler is run in those environments, and Rails only loads them when running in that environment.

Inline Group Syntax

Bundler also supports an inline form, which is functionally identical to the block syntax:

gem "sorbet", group: :development

This line is equivalent to:

group :development do
gem "sorbet"
end

Use whichever style reads better for your Gemfile.

Gems Loaded in All Environments

Any gem not placed inside a group is installed and loaded in every environment (development, test, production).

For example:

gem "sorbet-runtime"

This gem will be available everywhere because it is not wrapped in a group.

Making my Website Page

Getting Started

So set up and ready to go. A few things to note

  • routes in /config/routes
  • views in /app/views/<page-name>
  • layouts in /app/views/layouts
  • components in app and a PageHeader component component with

So I generated an app, page and component

rails new bibble_web_ror --css=tailwind
cd bibble_web_ror 
rails generate controller Bill index
rails generate component PageHeader

Told the robot what I did for view and it kindly gave me the rest. Here is the amended layout

  <body class="min-h-screen bg-(--color-bg-page) text-(--color-text-primary)">
    <a href="#main-content" class="sr-only focus:not-sr-only">Skip to content</a>
    <main id="main-content"
        class="max-w-300 bg-(--color-bg-sections) px-2 pt-2 shadow-(--shadow-page) not-only:mx-auto">
      <%= render PageHeaderComponent.new(title: @title, subtitle: @subtitle) %>
      <div class="u-container">
        <%= yield %>
      </div>
    </main>
    <footer class="p-4 text-center text-sm text-(--color-text-primary)">
      &copy; 2025 Iain Wiseman
    </footer>
  </body>

And for the component

<header class="mb-6">
  <h1 class="text-3xl font-bold text-(--color-text-primary)">
    <%= @title %>
  </h1>

  <% if @subtitle.present? %>
    <p class="mt-1 text-(--color-text-secondary)">
      <%= @subtitle %>
    </p>
  <% end %>
</header>

The generate component failed because I am on rails 8. Suspect there a billion of these generators but we chose view_component. You need to add it to the gemfile and do a bundle install.

rails generate view_component:erb PageHeader

Next it failed again because ruby expects there to be a .rb file which is not automatically generated. Not sure why but suspect my new employer will let me know. Anyway here it is.

class PageHeaderComponent < ViewComponent::Base
  def initialize(title:, subtitle: nil)
    @title = title
    @subtitle = subtitle
  end
end

The robot says you can generate this with

rails generate component PageHeader --ruby

So this did not work so I did something I hate doing, read the docs which show

 rails generate view_component:component PageHeader

And of course this did work.

Tailwind

This did not work either. Ended up following the excellent instructions on their site for tailwind which mainly involved.

bundle add tailwindcss-rails
rails tailwindcss:install

I found I had to run with bin/dev as the generation of color did not happen

bin/dev

The config tailwind.config.js needs to be empty like in vue. And the theme stuff is in app/assets/stylesheets/tailwind/application.css

Debugger

This seemed harder than it needed to be. I think there have been several approaches at it which has left some stuff behind. Anyway I used https://mickzijdel.com/blog/2025-05-19-setting-up-a-debugger-for-ruby-on-rails-8/ for my setup. First I installed the vs code extension VSCode rdbg ruby debugger by Koichi Sasada. Next I added these to my gemfile

gem "rdbg" # Ruby debug integration
gem "foreman" # Necessary for the procfile to work

So next was the Procfile.dev in the root of the project. Replace the original web: line with this

web: RUBY_DEBUG_OPEN=TRUE RUBY_DEBUG_NONSTOP=TRUE rdbg --command --open --stop-at-load -- bundle exec bin/rails server -p 3000
css: bin/rails tailwindcss:watch

So here are the files for reference.
First Tasks

{
  "version": "2.0.0",
  "tasks": [
    {
      "label": "runBinDev",
      "type": "shell",
      "command": "bin/dev",
      "isBackground": true,
      "problemMatcher": [
        {
          "owner": "custom-rdbg-task",
          "pattern": [
            {
              "regexp": "^.*DEBUGGER: Debugger can attach.*$",
              "kind": "info",
              "file": 1,
              "location": 1,
              "message": 0
            }
          ],
          "background": {
            "activeOnStart": true,
            "beginsPattern": "^.*DEBUGGER: Debugger can attach.*$",
            "endsPattern": "^.*DEBUGGER: Debugger can attach.*$"
          }
        }
      ],
      "presentation": {
        "reveal": "always",
        "panel": "dedicated",
        "clear": true
      },
      "group": {
        "kind": "build",
        "isDefault": false
      }
    }
  ]
}

Now launch.json

{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "rdbg",
      "name": "Run bin/dev & Attach with rdbg",
      "request": "attach",
      "preLaunchTask": "runBinDev",
      "localfs": true,
      "localfsMap": "${workspaceFolder}:${workspaceFolder}"
    },
    {
      "type": "rdbg",
      "name": "Attach with rdbg",
      "request": "attach",
      "localfs": true,
      "localfsMap": "${workspaceFolder}:${workspaceFolder}"
    }
  ]
}

First Go

Writing a Test Case

So wanted to get a test case going because of the lack of type checking. When you generate a new component you get an empty test case. So for

rails generate component PageHeader

IWe get a app/component/page_header_component.html.erb and app/component/page_header_component.rb which I edited to be

<header class="relative mb-1 bg-(--color-nav-background) pt-4 pb-4 pl-12 text-center sm:pl-4">
  <%= render PageHeaderButtonComponent.new %>
  <h1 class="mb-2 text-center text-3xl font-bold text-(--color-title)">
    <%= @title %>
  </h1>
  <% if @subtitle.present? %>
    <p class="mb-4 text-center text-[1.2rem] font-normal text-(--color-subtitle)">
      <%= @subtitle %>
    </p>
  <% end %>
  <%= render PageHeaderNavComponent.new %>
</header>

And

class PageHeaderComponent < ViewComponent::Base
  def initialize(title:, subtitle: nil)
    @title = title
    @subtitle = subtitle
  end
end

But we also get a

# frozen_string_literal: true

require "test_helper"

class PageHeaderHeaderComponentTest < ViewComponent::TestCase
  def test_component_renders_something_useful
    # assert_equal(
    #   %(<span>Hello, components!</span>),
    #   render_inline(PageHeaderHeaderComponent.new(message: "Hello, components!")).css("span").to_html
    # )
  end
end

This I adapted to be

# frozen_string_literal: true

require "test_helper"

class PageHeaderComponentTest < ViewComponent::TestCase
  def test_component_renders_something_useful
    rendered = render_inline(PageHeaderComponent.new(title: "my title", subtitle: "my subtitle"))

    assert_equal "my title", rendered.css("h1").text.strip
    assert_equal "my subtitle", rendered.css("p").text.strip
    assert rendered.at_css("button[data-controller='theme-toggle']").present?
  end
end

I found I had not done the following

  • Defined view_components correctly in the gemfile as it needs test
  • Not modified test_helper.rb

So for the gemfile we needed

group :development, :test do
  # view_component for building reusable view components [https://viewcomponent.org/]
  gem "view_component"
end

And for the test_helper.rb, I am assuming this is autoloading, I need to have

ENV["RAILS_ENV"] ||= "test"
require_relative "../config/environment"
require "rails/test_help"
require "view_component/test_case"

module ActiveSupport
  class TestCase
    # Run tests in parallel with specified workers
    parallelize(workers: :number_of_processors)

    # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order.
    fixtures :all

    # Add more helper methods to be used by all tests here...
  end
end

Sorbet

There is no type safety in ruby but if you use sorbet it maybe prove to be useful. I set this up using mostly using the youtube Type Checking with the Sorbet Gem in Rails 7 | Ruby on Rails 7 Gem Tutorial by deanin. First of all make sure you have the vs code sorbet extension. Next the gemfile

# Runtime for sorbet type checking [https://sorbet.org/]
gem "sorbet", group: :development
gem "sorbet-runtime"
gem "tapioca", require: false, group: [ :development, :test ]

Then run this - said it took a while but not for me.

bundle exec tapioca init

Now run

bin/tapioca dsl

Now we are free to put our types in. So I started with something small. Basically you need the magic line # typed: true and to add extend T::Sig. From there you put in your sig line appropriately

# typed: true

class PageHeaderComponent < ViewComponent::Base
  extend T::Sig

  sig { params(title: String, subtitle: T.nilable(String)).void }
  def initialize(title:, subtitle: nil)
    @title = title
    @subtitle = subtitle
  end
end

Nothing has been easy with this but probably because I am new to this again. A lot of setup I feel. I needed to restart the workspace to get the test cases to appear in test explorer and found the error "The super class `ViewComponent::TestCase` of `PageHeaderComponentTest` does not derive from `Class`" in all of the the tests. The robot fixed it by changing todo.rbi which appears to be an export file for sorbet. It changed

module ViewComponent::TestCase

to

class ViewComponent::TestCase

And the error went away. However I noticed at the top of the file it said

# DO NOT EDIT MANUALLY
# This is an autogenerated file for unresolved constants.
# Please instead update this file by running `bin/tapioca todo`.

# typed: false

So wonder whether this was a plan stand and of course the answer was no. It then fixed it, hopefully properly, by adding the file sorbet/rbi/manual

# typed: true
# Manual stub for ViewComponent test support that isn't auto-discovered by Tapioca

class ViewComponent::TestCase
end

Second Go

Sorbet

Generating rbi

Well the Sorbet stuff was difficult to get going mainly because I used the robots. The finally solution was not to use bin/tapioca dsl but instead

bundle exec tapioca gem

This fixed all of the errors with sorbet but ci.rbi and my test cases

To fix ci.rbi I used a manual rbi in sorbet/rbi/manual/ci.rbi

# typed: true

module ::CI
  sig { params(block: T.proc.void).void }
  def self.run(&block); end
end

Aliases

In the example for PageDataLoad.rb we have a function which returns json like structure.

  sig { params(page_name: String).returns(T::Hash[String, T.any(T::Boolean, String, Integer, Float, NilClass, T::Array[T.untyped], T::Hash[String, T.untyped])]) }
  def self.load(page_name)
    new(page_name).load
  end

You can make aliases for a type which you can define in sorbet/types/json.rb

# typed: strict

# This replaces T::Hash[String, T.untyped] with Types::JSONHash
module Types
  JSONValue = T.type_alias { T.any(
    String,
    Integer,
    Float,
    TrueClass,
    FalseClass,
    NilClass,
    T::Array[T.untyped],
    T::Hash[String, T.untyped]
  ) }

  JSONHash = T.type_alias { T::Hash[String, JSONValue] }
end

Then you need to make sure it gets loaded by adding a types initializer in config/initializers/types.rb

require_relative "../../sorbet/types/json"

Test Case

For the test cases I change them from the above to use Template

# frozen_string_literal: true
# typed: false

require "test_helper"

class PageHeaderComponentTest < ViewComponent::Template
  def test_component_renders_something_useful
    rendered = render_inline(PageHeaderComponent.new(title: "my title", subtitle: "my subtitle"))

    assert_equal "my title", rendered.css("h1").text.strip
    assert_equal "my subtitle", rendered.css("p").text.strip
    assert rendered.at_css("button[data-controller='theme-toggle']").present?
  end
end

Now I can run srb tc with no errors. It even said great job which, for once I agree with.

New Stuff

Some of this was probably not around the last time I look at this. So here is some stuff I have found

Accessors

You can use attr_reader (readonly), attr_writer(writeonly) and attr_accessor(both) to create get/settters. This is convenient to reduce the amount of typing. For instance.

# typed: true

class PageDataError < StandardError
  extend T::Sig
  attr_reader :status, :detail

  sig { params(status: Integer, detail: String).void }
  def initialize(status:, detail:)
    @status = status
    @detail = detail
    super(detail)
  end
end

Introspection Tools (How to inspect objects, classes, and capabilities)

Ruby gives you a small set of powerful introspection methods. These let you ask objects questions about:

what they are

what they can do

what they contain

how they relate to other classes

These are the core tools you’ll use everywhere.

is_a?

Checks whether an object is an instance of a class or any subclass of that class. Use this when class identity matters (e.g., HTTP response types).

res = Net::HTTP.get_response(@url)

Is this a 2xx success response?
return nil unless res.is_a?(Net::HTTPSuccess)

instance_of?

Checks whether an object is exactly an instance of a class (no subclasses allowed).

1.instance_of?(Integer)      # true
1.is_a?(Numeric)             # true
1.instance_of?(Numeric)      # false (Integer < Numeric)

Use this when you need strict type matching.

respond_to?

Checks whether an object supports a given method. This is Ruby’s duck typing check: “Can it quack?”

user = { name: "Iain" }

if user.respond_to?(:to_json)
puts user.to_json
end

Use this when behavior matters, not class.

nil?

Checks whether a value is literally nil. Ruby has exactly one null value: nil.

value = maybe_get_value

if value.nil?
puts "No value returned"
end

Use this for null checks.

empty?

Checks whether a collection or string is empty.

[].empty?        # true
"".empty?        # true
[1, 2].empty?    # false

Use this when you want to distinguish between “no value” (nil) and “empty value” ("", [], {}).

class

Returns the object’s class.

res = Net::HTTP.get_response(@url)
puts res.class

=> Net::HTTPSuccess

Useful for debugging and exploration.

ancestors

Shows the inheritance chain and included modules. This is how you discover mixins.

Net::HTTPSuccess.ancestors

=> [Net::HTTPSuccess, Net::HTTPResponse, ... , Kernel, BasicObject]

Use this to understand where methods come from.

methods

Lists all methods an object responds to.

"hello".methods.sort

Useful when exploring unfamiliar objects.

instance_variables

Lists all instance variables on an object.

class User
def initialize
@name = "Iain"
@role = "Engineer"
end
end

u = User.new
u.instance_variables

=> [:@name, :@role]

Great for debugging internal state.

defined?

Checks whether something exists without raising an error.

defined?(some_undefined_variable)   # => nil
defined?(puts)                      # => "method"

Use this when probing safely.

kind_of?

Alias for is_a?. Same behavior, different name.

1.kind_of?(Numeric)   # true

Debugging

So clearing outputting values is quite important so here is an example to crib from. Pretty simple

class ContentHeaderVideoBannerBlockComponent < ViewComponent::Base
  def initialize(header_video_banner_block:)
    @block = header_video_banner_block

    # Print the Intrinsic Dimensions to the logs for debugging purposes
    Rails.logger.debug "Intrinsic Width is #{@block[:intrinsicWidth].inspect}"
    Rails.logger.debug "Intrinsic Height is #{@block[:intrinsicHeight].inspect}"

    # Approach 2
    width = @block[:intrinsicWidth]
    Rails.logger.debug "Intrinsic Width is #{width.nil? ? 'nil' : width}"

And we can print a whole class with

    Rails.logger.debug do
      {
        component: self.class.name,
        video_name: video_set[:videoName],
        image_name: image_set[:imageName],
        enabled_video_formats: enabled_video_formats,
        enabled_image_formats: enabled_image_formats,
        video_sources: @video_sources,
        image_sources: @image_sources,
        fallback_image_url: @fallback_image_url,
        aspect_ratio: @aspect_ratio
      }.inspect
    end

Little bit on Dry::Schema

Dry is the Zod of Ruby I guess. So not sure if I did this last time but battling my way through it this time around. A simple schema may be:

UserSchema = Dry::Schema.Params do
  required(:name).filled(:string)
  required(:age).filled(:integer)
  optional(:admin).filled(:bool)
end

You can make an instance with:

input = {
  "name" => "Iain",
  "age" => "42",
  "admin" => "true"
}

result = UserSchema.call(input)

If you inspect it, it will look like this:

#<Dry::Schema::Result{:name=>"Iain", :age=>42, :admin=>true} errors={} >

Using `to_h` converts the result into a plain Ruby Hash:

validated = result.to_h

And you will see:

{
  name: "Iain",
  age: 42,
  admin: true
}