Repository: reactjs/react-rails (2026-04-13T01:11:26Z)

Issue:
Rails layout not applied when using `render :component`
When upgrading from Rails 7.2 to 8, we encountered an issue where controller layouts are no longer applied when using render :component with server-side rendering in react-rails. [This document](https://github.com/reactjs/react-rails/blob/master/docs/controller-actions.md) talks about this use case, specifically "By default, your current layout will be used and the component, rather than a view, will be rendered in place of yield."

In Rails 7.2.2.1 with react-rails 2.6.2, everything works as expected. The problem is introduced in Rails 8 due to a change in the render pipeline: the point at which layouts are applied has shifted. Specifically, the component renderer adds an `inline` key to the render options. In Rails 7.2, the layout was added before this step, so the presence of the inline key didn't affect the layout being applied. In Rails 8, the pipeline was altered so that the component renderer adds the inline key before the layout is applied, which prevents the layout from being included by default.

[This](https://github.com/rails/rails/commit/42d75ed3a8b96ee4610601ecde7c40e9d65e003f) is the Rails commit that introduced the change. 




Discussion:
Workaround more than a fix, but adding
```ruby
  def render(*args, **kwargs)
    kwargs[:layout] = 'application' if kwargs[:component] && kwargs[:layout].nil?
    super
  end
```
 to my `ApplicationController` got me running again without having to add `layout: ` everywhere.



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bb7b0c9b..a923e4cd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,9 @@ Changes since the last non-beta release.
 
 _Please add entries here for your pull requests that have not yet been released. Include LINKS for PRs and committers._
 
+#### Fixed
+- Preserve default controller layouts for `render component:` after the Rails 8 render pipeline change. [PR 1418](https://github.com/reactjs/react-rails/pull/1418) by [justin808](https://github.com/justin808). Fixes [#1356](https://github.com/reactjs/react-rails/issues/1356).
+
 ## [3.3.0] - 2026-03-31
 
 #### Added
diff --git a/lib/react/rails/railtie.rb b/lib/react/rails/railtie.rb
index 90e856a1..4d80b5d3 100644
--- a/lib/react/rails/railtie.rb
+++ b/lib/react/rails/railtie.rb
@@ -66,7 +66,7 @@ class Railtie < ::Rails::Railtie
         ActionController::Renderers.add :component do |component_name, options|
           renderer = ::React::Rails::ControllerRenderer.new(controller: self)
           html = renderer.call(component_name, options)
-          render_options = options.merge(inline: html)
+          render_options = Railtie.component_render_options(options, html)
           render(render_options)
         end
       end
@@ -121,6 +121,14 @@ def self.append_react_build_to_assets_version!(assets, react_build)
         versioned_assets.version = [versioned_assets.version, "react-#{react_build}"].compact.join("-")
       end
 
+      # :nodoc:
+      def self.component_render_options(options, html)
+        render_options = options.merge(inline: html)
+        return render_options if render_options.key?(:layout)
+
+        render_options.merge(layout: true)
+      end
+
       def self.versioned_assets_for(assets)
         return assets if versioned_assets?(assets)
 


Test patch:
diff --git a/test/react/rails/component_renderer_test.rb b/test/react/rails/component_renderer_test.rb
new file mode 100644
index 00000000..77693f7f
--- /dev/null
+++ b/test/react/rails/component_renderer_test.rb
@@ -0,0 +1,83 @@
+# frozen_string_literal: true
+
+require "test_helper"
+
+class ComponentRendererController < ActionController::Base
+  append_view_path File.expand_path("../../dummy/app/views", __dir__)
+  layout "application"
+
+  def default_layout
+    render component: "TodoList"
+  end
+
+  def explicit_layout_false
+    render component: "TodoList", layout: false
+  end
+
+  def explicit_named_layout
+    render component: "TodoList", layout: "app_no_turbolinks"
+  end
+end
+
+class ComponentRendererTest < ActionController::TestCase
+  tests ComponentRendererController
+
+  FakeRenderer = Struct.new(:html, :calls) do
+    def initialize(html)
+      super(html, [])
+    end
+
+    def call(component_name, options)
+      calls << [component_name, options]
+      html
+    end
+  end
+
+  setup do
+    @routes = ActionDispatch::Routing::RouteSet.new
+    @routes.draw do
+      get "default_layout", to: "component_renderer#default_layout"
+      get "explicit_layout_false", to: "component_renderer#explicit_layout_false"
+      get "explicit_named_layout", to: "component_renderer#explicit_named_layout"
+    end
+  end
+
+  test "render component uses the current layout by default" do # rubocop:disable Minitest/MultipleAssertions
+    fake_renderer = FakeRenderer.new("<main>SSR</main>")
+
+    React::Rails::ControllerRenderer.stub(:new, ->(*) { fake_renderer }) do
+      get :default_layout
+    end
+
+    assert_response :success
+    assert_match(%r{<title>Dummy</title>}, response.body)
+    assert_match(%r{<main>SSR</main>}, response.body)
+    assert_equal "TodoList", fake_renderer.calls.dig(0, 0)
+  end
+
+  test "render component preserves explicit layout false" do # rubocop:disable Minitest/MultipleAssertions
+    fake_renderer = FakeRenderer.new("<main>SSR</main>")
+
+    React::Rails::ControllerRenderer.stub(:new, ->(*) { fake_renderer }) do
+      get :explicit_layout_false
+    end
+
+    assert_response :success
+    assert_no_match(%r{<title>Dummy</title>}, response.body)
+    assert_match(%r{<main>SSR</main>}, response.body)
+    assert_equal "TodoList", fake_renderer.calls.dig(0, 0)
+  end
+
+  test "render component preserves a named layout override" do # rubocop:disable Minitest/MultipleAssertions
+    fake_renderer = FakeRenderer.new("<main>SSR</main>")
+
+    React::Rails::ControllerRenderer.stub(:new, ->(*) { fake_renderer }) do
+      get :explicit_named_layout
+    end
+
+    assert_response :success
+    assert_match(/app_no_turbolinks/, response.body)
+    assert_match(%r{<main>SSR</main>}, response.body)
+    assert_equal "TodoList", fake_renderer.calls.dig(0, 0)
+  end
+end
diff --git a/test/react/rails/railtie_test.rb b/test/react/rails/railtie_test.rb
index 1bc0540c..a42ef896 100644
--- a/test/react/rails/railtie_test.rb
+++ b/test/react/rails/railtie_test.rb
@@ -45,4 +45,24 @@ class RailtieTest < ActionDispatch::IntegrationTest
       React::Rails::Railtie.append_react_build_to_assets_version!(assets, "development")
     end
   end
+
+  test "component render options default to using the current layout" do
+    render_options = React::Rails::Railtie.component_render_options({ status: :accepted }, "<div>SSR</div>")
+
+    assert_equal "<div>SSR</div>", render_options[:inline]
+    assert_same true, render_options[:layout]
+    assert_equal :accepted, render_options[:status]
+  end
+
+  test "component render options preserve explicit layout overrides" do
+    render_options = React::Rails::Railtie.component_render_options({ layout: false }, "<div>SSR</div>")
+
+    assert_same false, render_options[:layout]
+  end
+
+  test "component render options preserve a named layout override" do
+    render_options = React::Rails::Railtie.component_render_options({ layout: "admin" }, "<div>SSR</div>")
+
+    assert_equal "admin", render_options[:layout]
+  end
 end