inertia_jb 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5318dc498cde30ce2dfada1a46e68138c4a85dfef6a7cf275b411f861e059d67
4
- data.tar.gz: abb2907bdaba0907f38376557c750e99d918988dc28e2b36e02bd209bbcedd37
3
+ metadata.gz: 73afc6da5300453502fb918b78a802c92401b8dbe91fa6307ed4523a664c9716
4
+ data.tar.gz: 0fb62ff6422c754317d18f6ddcf49cb46982822a931f72db2773999117ea9652
5
5
  SHA512:
6
- metadata.gz: 8039dfc4f8d98b88c328c622a007f9baf110bed9024b828443bac26ef12cc05853c5c59087b472f454de9dace0693c34cd864914822541f4be1879db76e7f500
7
- data.tar.gz: 535c7d2247b8da621a0f55ab3973006b27a2dab97a0af71cfd2294bebd65b9a2406a5ab8ba6956c3097a5bbff85472e3c9b7db8a1f32b678ad1b8495d8a90168
6
+ metadata.gz: cf13cab4f4de7bf9973354d12b590e58473c873a0eb07a84ea14b28a87d32e1ef6c57310c7255f4b7c92e828b66463e12d5d67c25f55c9cb12d558ac8e1d6970
7
+ data.tar.gz: '08e77238e1754dcdd0e2e74df5e984d38bab7c922ea9fa755495e09b50c056ef674eff9df8ea26dd0db3d91eca09ab1973b4744129da93064f691fccae40c4e9'
data/README.md CHANGED
@@ -87,6 +87,40 @@ end
87
87
 
88
88
  You do **not** need to call `use_inertia_instance_props`.
89
89
 
90
+ ## Layout
91
+
92
+ On an **initial (non-XHR) page load** the `data-page` root element is wrapped in
93
+ a layout; Inertia (XHR) visits always return a bare JSON body with no layout.
94
+
95
+ The layout is chosen from inertia-rails' `config.layout`, matching
96
+ `InertiaRails::Renderer`'s own semantics:
97
+
98
+ ```ruby
99
+ # config/initializers/inertia_rails.rb
100
+ InertiaRails.configure do |config|
101
+ config.default_render = false
102
+
103
+ # config.layout = true # (default) use the controller's normal layout
104
+ # config.layout = "inertia" # use app/views/layouts/inertia.html.erb
105
+ # config.layout = false # no layout — render just the <div id="app"> root
106
+ end
107
+ ```
108
+
109
+ - `true` / `nil` — the controller's default layout, resolved the normal Rails
110
+ way (`app/views/layouts/application.html.erb`, or any `layout "..."`
111
+ declaration in the controller).
112
+ - A **String** — that named layout (`app/views/layouts/<name>.html.erb`).
113
+ - `false` — no layout at all; the response is only the `<div id="app"
114
+ data-page="…">` root, so you provide `<html>`/`<head>`/asset tags elsewhere.
115
+
116
+ Because `config.layout` is scoped per controller in inertia-rails, you can also
117
+ set it on a single controller with `inertia_config layout: "..."`. Plain
118
+ (non-Inertia) `.html.erb` actions in the same app keep their layout regardless
119
+ of this setting.
120
+
121
+ > **Note:** this gem does not perform server-side rendering (SSR), so
122
+ > `inertia_ssr_head` in your layout will always be empty.
123
+
90
124
  ## Templates and partials
91
125
 
92
126
  - **Page templates** live at `app/views/<controller>/<action>.html.inertia` and
@@ -1,42 +1,28 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module InertiaJb
4
- # Controller concern that makes `.html.inertia` templates behave like proper
5
- # Inertia responses.
4
+ # Controller concern that renders `.html.inertia` templates as Inertia
5
+ # responses.
6
6
  #
7
- # Our {Renderer} only computes the page hash (it does not go through
8
- # +InertiaRails::Renderer#render+), so we set the Inertia response headers and
9
- # skip the layout for Inertia (XHR) requests heremirroring inertia-builder.
7
+ # A `.html.inertia` page template is reached through Rails' normal implicit
8
+ # render. Its body (compiled by {Handler}) throws the props Hash back here,
9
+ # which aborts the implicit render layout and allbefore we re-render the
10
+ # page through inertia-rails' native renderer via `render inertia:`. Component
11
+ # resolution, shared data, `PropsResolver`/partial reloads, `config.layout`,
12
+ # SSR and the X-Inertia/Vary headers are therefore all handled by
13
+ # inertia-rails, identically to a plain inertia-rails app.
10
14
  #
11
- # +InertiaRails::Controller+ (which provides +inertia_share+, shared data and
12
- # +default_render+) is already mixed into +ActionController::Base+ by
15
+ # Plain (non-Inertia) `.html.erb` actions never throw, so `catch` falls
16
+ # through to `super` and they render exactly as in vanilla Rails.
17
+ #
18
+ # +InertiaRails::Controller+ is already mixed into +ActionController::Base+ by
13
19
  # inertia-rails' engine, so we don't include it again.
14
20
  module Controller
15
21
  extend ActiveSupport::Concern
16
22
 
17
- included do
18
- before_action :format_inertia_jb_response, if: -> { inertia_json_request? }
19
- end
20
-
21
- private
22
-
23
- def format_inertia_jb_response
24
- response.headers["Vary"] = if response.headers["Vary"].blank?
25
- "X-Inertia"
26
- else
27
- "#{response.headers['Vary']}, X-Inertia"
28
- end
29
- response.set_header("X-Inertia", "true")
30
- end
31
-
32
- # Inertia (XHR) requests return a bare JSON body — no application layout.
33
- # Initial page loads keep the layout so the `data-page` root is wrapped.
34
- def action_has_layout?
35
- !inertia_json_request? && super
36
- end
37
-
38
- def inertia_json_request?
39
- request.headers["X-Inertia"] == "true"
23
+ def default_render(*)
24
+ props = catch(:__inertia_jb) { return super }
25
+ render(inertia: props)
40
26
  end
41
27
  end
42
28
  end
@@ -4,21 +4,18 @@ module InertiaJb
4
4
  # ActionView template handler for `*.html.inertia` templates.
5
5
  #
6
6
  # An `.html.inertia` template is jb-style Ruby: its last expression is a Hash
7
- # of Inertia props. We evaluate it, then hand the resulting Hash to
8
- # {Renderer}, whose return value becomes the rendered body.
9
- #
10
- # Unlike props_template, there is no shared builder state to guard: jb
11
- # partials each return their own Hash independently, so no `@__json`-style
12
- # finalize dance is needed here.
7
+ # of Inertia props. The handler evaluates that Hash and immediately `throw`s
8
+ # it back to {Controller#default_render}, which hands it to inertia-rails'
9
+ # native renderer. The `throw` also aborts this implicit render (and the
10
+ # layout it was about to apply), so the handler emits no body of its own.
13
11
  class Handler
14
12
  def self.call(template, source = nil)
15
13
  source ||= template.source
16
14
 
17
15
  # `begin;#{source}` keeps the template's own line numbers aligned in
18
- # backtraces. The begin/end block evaluates to the template's last
19
- # expression (the props Hash).
20
- "__inertia_props = begin;#{source}\nend\n" \
21
- "::InertiaJb::Renderer.new(self, __inertia_props, true).render"
16
+ # backtraces. The block evaluates to the template's last expression (the
17
+ # props Hash), which we throw up to the controller to render.
18
+ "throw(:__inertia_jb, begin;#{source}\nend)"
22
19
  end
23
20
  end
24
21
  end
@@ -12,6 +12,7 @@ module InertiaJb
12
12
  ActiveSupport.on_load(:action_view) do
13
13
  ActionView::Template.register_template_handler :inertia, InertiaJb::Handler
14
14
  ActionView::Base.include InertiaJb::Helper
15
+ ActionView::TemplateRenderer.prepend InertiaJb::Renderer
15
16
  end
16
17
  end
17
18
  end
@@ -1,41 +1,34 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module InertiaJb
4
- # Bridges a props Hash (built by a `.html.inertia` template) to the Inertia
5
- # protocol via +InertiaRails::Renderer+.
4
+ # Prepended onto +ActionView::TemplateRenderer+ to guarantee that a
5
+ # `.html.inertia` template is never wrapped in a layout.
6
6
  #
7
- # Because the props are already a plain Ruby Hash, +InertiaRails::Renderer+
8
- # can walk them natively: shared data is merged in, Procs and prop objects
9
- # (optional/always/defer/scroll) are resolved, and partial reloads are
10
- # filtered at any nesting depth.
11
- class Renderer
12
- def initialize(view_context, props, component)
13
- @view_context = view_context
14
- @inertia_renderer = ::InertiaRails::Renderer.new(
15
- component,
16
- view_context.controller,
17
- view_context.request,
18
- view_context.response,
19
- view_context.controller.method(:render),
20
- props: props
21
- )
22
- end
23
-
24
- def render
25
- # `page` computes the full Inertia page hash (running PropsResolver)
26
- # without triggering a controller render, so we can return the body
27
- # ourselves from inside the template rendering pipeline.
28
- page = @inertia_renderer.send(:page)
7
+ # An `.inertia` template's body (see {Handler}) throws its props Hash back to
8
+ # the controller before it produces any output, so a surrounding layout is
9
+ # always discarded. Left alone, Rails would still resolve and compile the
10
+ # controller's layout and emit phantom `render_layout.action_view` /
11
+ # `render_template.action_view` events for it — their `ensure` blocks fire as
12
+ # the `throw` unwinds the stack — whose timing absorbs the prop-building work,
13
+ # polluting request logs and APM traces with a layout render that never
14
+ # actually happened.
15
+ #
16
+ # +render_template+ is the one point where Rails has already resolved the
17
+ # template (so we can see its handler) but has not yet looked up the layout
18
+ # (`find_layout` runs one call deeper, in +render_with_layout+). Nulling the
19
+ # layout here for our handler skips the lookup entirely — cleanly, without the
20
+ # controller having to guess in advance which template an action will render.
21
+ #
22
+ # This only affects our own templates. The real Inertia shell is applied later
23
+ # by inertia-rails when it renders the `inertia` root template (an ERB
24
+ # template) with `config.layout`, and plain `.erb`/`.jb` templates keep their
25
+ # layouts exactly as in vanilla Rails.
26
+ module Renderer
27
+ private
29
28
 
30
- if @view_context.request.headers["X-Inertia"]
31
- page.to_json
32
- else
33
- @view_context.controller.render_to_string(
34
- template: "inertia",
35
- layout: false,
36
- locals: { page: page }
37
- )
38
- end
29
+ def render_template(view, template, layout_name, locals)
30
+ layout_name = nil if template.handler == InertiaJb::Handler
31
+ super
39
32
  end
40
33
  end
41
34
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module InertiaJb
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+ require "action_view/testing/resolvers"
5
+
6
+ # Regression test for the "phantom layout render" bug.
7
+ #
8
+ # An `.inertia` page throws its props before producing output, so the
9
+ # controller's layout is always discarded. Rails must therefore never resolve,
10
+ # compile, or *instrument* that layout while rendering the props template —
11
+ # otherwise request logs and APM traces show a `crm/application` layout render
12
+ # that never happened, with its timing absorbing the prop-building work.
13
+ #
14
+ # The controller here uses `layout "application"` but points Inertia at a
15
+ # *different* shell (`config.layout = "inertia"`), so the two are easy to tell
16
+ # apart in the instrumentation stream:
17
+ # * the `.inertia` props template must be rendered with NO layout, and
18
+ # * `layouts/application` must never be rendered at all.
19
+ class SuppressLayoutController < ActionController::Base
20
+ include InertiaJb::Controller
21
+ layout "application"
22
+ inertia_config layout: "inertia"
23
+ def index; end
24
+ end
25
+
26
+ class LayoutSuppressionTest < ActionController::TestCase
27
+ tests SuppressLayoutController
28
+
29
+ TEMPLATES = {
30
+ "layouts/application.html.erb" => "<html><body><%= yield %></body></html>",
31
+ "layouts/inertia.html.erb" => "<main data-inertia-shell=\"1\"><%= yield %></main>",
32
+ "suppress_layout/index.html.inertia" => "{ ok: true }"
33
+ }.freeze
34
+
35
+ def setup
36
+ super
37
+ @routes = ActionDispatch::Routing::RouteSet.new
38
+ @routes.draw { get "index" => "suppress_layout#index" }
39
+ @controller.prepend_view_path(ActionView::FixtureResolver.new(TEMPLATES))
40
+ end
41
+
42
+ def teardown
43
+ super
44
+ @routes.clear!
45
+ end
46
+
47
+ def test_inertia_template_is_rendered_without_a_layout
48
+ templates = capture("render_template.action_view") { get :index }
49
+
50
+ page = templates.find { |e| e.payload[:identifier].end_with?("index.html.inertia") }
51
+ assert page, "the .inertia props template should have been rendered"
52
+ assert_nil page.payload[:layout],
53
+ "the .inertia props template must be rendered without a layout, " \
54
+ "got #{page.payload[:layout].inspect}"
55
+ end
56
+
57
+ def test_controller_layout_is_never_rendered_for_an_inertia_page
58
+ layouts = capture("render_layout.action_view") { get :index }
59
+ identifiers = layouts.map { |e| e.payload[:identifier] }
60
+
61
+ assert identifiers.none? { |id| id.include?("layouts/application") },
62
+ "the controller layout must not be rendered for an Inertia page, saw #{identifiers.inspect}"
63
+ # Sanity check: the real Inertia shell (config.layout) still wraps the root.
64
+ assert identifiers.any? { |id| id.include?("layouts/inertia") },
65
+ "the Inertia shell layout should still wrap the data-page root, saw #{identifiers.inspect}"
66
+ end
67
+
68
+ def test_rendered_output_is_wrapped_only_by_the_inertia_shell
69
+ get :index
70
+
71
+ assert_response :success
72
+ assert_includes response.body, "data-inertia-shell"
73
+ assert_includes response.body, "data-page"
74
+ refute_includes response.body, "<html><body>"
75
+ end
76
+
77
+ private
78
+
79
+ # Collects the instrumentation events for `name` emitted while the block runs.
80
+ def capture(name)
81
+ events = []
82
+ subscriber = ActiveSupport::Notifications.subscribe(name) do |*args|
83
+ events << ActiveSupport::Notifications::Event.new(*args)
84
+ end
85
+ yield
86
+ events
87
+ ensure
88
+ ActiveSupport::Notifications.unsubscribe(subscriber)
89
+ end
90
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+ require "action_view/testing/resolvers"
5
+ require "json"
6
+
7
+ # Controllers exercising inertia-rails' `config.layout` for initial page loads.
8
+ # `config.layout` is scoped per controller class via `inertia_config`, so each
9
+ # case gets its own controller and no global state is mutated.
10
+
11
+ class LayoutDefaultController < ActionController::Base
12
+ include InertiaJb::Controller
13
+ layout "application"
14
+ # No `inertia_config` — `config.layout` defaults to `true`.
15
+ def index; end
16
+ end
17
+
18
+ class LayoutFalseController < ActionController::Base
19
+ include InertiaJb::Controller
20
+ layout "application"
21
+ inertia_config layout: false
22
+ def index; end
23
+ end
24
+
25
+ class LayoutNamedController < ActionController::Base
26
+ include InertiaJb::Controller
27
+ layout "application"
28
+ inertia_config layout: "inertia"
29
+ def index; end
30
+ end
31
+
32
+ # A plain (non-Inertia) HTML action living in an app that configured
33
+ # `config.layout = false`. Its layout must behave like vanilla Rails.
34
+ class PlainController < ActionController::Base
35
+ include InertiaJb::Controller
36
+ layout "application"
37
+ inertia_config layout: false
38
+ def index; end
39
+ end
40
+
41
+ module LayoutTestHelpers
42
+ TEMPLATES = {
43
+ "layouts/application.html.erb" => "<html><body><%= yield %></body></html>",
44
+ "layouts/inertia.html.erb" => "<main data-inertia-layout=\"1\"><%= yield %></main>",
45
+ "layout_default/index.html.inertia" => "{ ok: true }",
46
+ "layout_false/index.html.inertia" => "{ ok: true }",
47
+ "layout_named/index.html.inertia" => "{ ok: true }",
48
+ "plain/index.html.erb" => "plain-body-<%= 1 + 1 %>"
49
+ }.freeze
50
+
51
+ def setup
52
+ super
53
+ controller_path = @controller.class.controller_path
54
+ @routes = ActionDispatch::Routing::RouteSet.new
55
+ @routes.draw { get "index" => "#{controller_path}#index" }
56
+ @controller.prepend_view_path(ActionView::FixtureResolver.new(TEMPLATES))
57
+ end
58
+
59
+ def teardown
60
+ super
61
+ @routes.clear!
62
+ end
63
+ end
64
+
65
+ # config.layout = true (default): the controller's default layout wraps the root.
66
+ class LayoutDefaultTest < ActionController::TestCase
67
+ include LayoutTestHelpers
68
+ tests LayoutDefaultController
69
+
70
+ def test_default_layout_wraps_the_data_page_root
71
+ get :index
72
+
73
+ assert_response :success
74
+ assert_includes response.body, "<html><body>"
75
+ assert_includes response.body, 'id="app"'
76
+ assert_includes response.body, "data-page"
77
+ end
78
+ end
79
+
80
+ # config.layout = false: no layout at all — just the bare `data-page` root.
81
+ class LayoutFalseTest < ActionController::TestCase
82
+ include LayoutTestHelpers
83
+ tests LayoutFalseController
84
+
85
+ def test_disabled_layout_renders_bare_root
86
+ get :index
87
+
88
+ assert_response :success
89
+ assert_includes response.body, 'id="app"'
90
+ assert_includes response.body, "data-page"
91
+ refute_includes response.body, "<html><body>"
92
+ refute_includes response.body, "data-inertia-layout"
93
+ end
94
+
95
+ def test_xhr_still_returns_json_when_layout_disabled
96
+ @request.headers["X-Inertia"] = "true"
97
+ get :index
98
+
99
+ assert_equal "true", response.headers["X-Inertia"]
100
+ page = JSON.parse(response.body)
101
+ assert_equal true, page.dig("props", "ok")
102
+ refute_includes response.body, "data-page"
103
+ end
104
+ end
105
+
106
+ # config.layout = "inertia": the named layout wraps the root, not the
107
+ # controller's own `layout "application"`.
108
+ class LayoutNamedTest < ActionController::TestCase
109
+ include LayoutTestHelpers
110
+ tests LayoutNamedController
111
+
112
+ def test_named_layout_wraps_the_root
113
+ get :index
114
+
115
+ assert_response :success
116
+ assert_includes response.body, "data-inertia-layout"
117
+ assert_includes response.body, 'id="app"'
118
+ refute_includes response.body, "<html><body>"
119
+ end
120
+ end
121
+
122
+ # A non-Inertia `.html.erb` action must keep its layout even when the app set
123
+ # `config.layout = false` for Inertia responses.
124
+ class PlainHtmlLayoutTest < ActionController::TestCase
125
+ include LayoutTestHelpers
126
+ tests PlainController
127
+
128
+ def test_plain_html_action_is_unaffected_by_inertia_config_layout
129
+ get :index
130
+
131
+ assert_response :success
132
+ assert_includes response.body, "<html><body>"
133
+ assert_includes response.body, "plain-body-2"
134
+ refute_includes response.body, "data-page"
135
+ end
136
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: inertia_jb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - kikyous
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-22 00:00:00.000000000 Z
11
+ date: 2026-07-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: inertia_rails
@@ -85,6 +85,8 @@ files:
85
85
  - lib/inertia_jb/renderer.rb
86
86
  - lib/inertia_jb/version.rb
87
87
  - test/controller_test.rb
88
+ - test/layout_suppression_test.rb
89
+ - test/layout_test.rb
88
90
  - test/test_helper.rb
89
91
  homepage: https://github.com/kikyous/inertia_jb
90
92
  licenses:
@@ -115,4 +117,6 @@ summary: Declare Inertia.js props in Rails view templates with plain Ruby Hashes
115
117
  by jb)
116
118
  test_files:
117
119
  - test/controller_test.rb
120
+ - test/layout_suppression_test.rb
121
+ - test/layout_test.rb
118
122
  - test/test_helper.rb