inertia_jb 0.1.0 → 0.3.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: c821eb8ccc74579d17f768744858bb9a14f7838201dd2ff89be61f8f70298780
4
+ data.tar.gz: 4e385aefbe3adcdcd6069b422382cd91cc7cbe208f2275230433110a761a4d3d
5
5
  SHA512:
6
- metadata.gz: 8039dfc4f8d98b88c328c622a007f9baf110bed9024b828443bac26ef12cc05853c5c59087b472f454de9dace0693c34cd864914822541f4be1879db76e7f500
7
- data.tar.gz: 535c7d2247b8da621a0f55ab3973006b27a2dab97a0af71cfd2294bebd65b9a2406a5ab8ba6956c3097a5bbff85472e3c9b7db8a1f32b678ad1b8495d8a90168
6
+ metadata.gz: fb985df39ed4ec250604244d93c968884b8d4a47588e031126d48f5b9489e5015192fc021de81c4b4e4d06018714065ed757046ec9728ed7804f930f073dd95e
7
+ data.tar.gz: d3e94480c54b50667a66c9416a59d6124e410b6d5da165e3344137128c9b2b88b111e07897ffccc0b56a915c955e060a0c103129813d476a99e085af337f2ccf
data/README.md CHANGED
@@ -63,9 +63,6 @@ Hashes, so there is zero impedance mismatch: no DSL to learn, no intermediate
63
63
  representation, and the full power of Ruby for building collections and
64
64
  conditionals.
65
65
 
66
- > Coming from `props_template`? That gem streams JSON strings, which don't map
67
- > cleanly onto Inertia's Hash-based resolver. jb's plain-Hash output is the
68
- > natural fit, which is why this gem is built on it.
69
66
 
70
67
  ## Installation
71
68
 
@@ -87,6 +84,15 @@ end
87
84
 
88
85
  You do **not** need to call `use_inertia_instance_props`.
89
86
 
87
+ ## Layout
88
+
89
+ On an **initial (non-XHR) page load** the `data-page` root element is wrapped in
90
+ a layout; Inertia (XHR) visits always return a bare JSON body with no layout.
91
+
92
+ The layout is chosen from inertia-rails' `config.layout`, matching
93
+ `InertiaRails::Renderer`'s own semantics:
94
+
95
+
90
96
  ## Templates and partials
91
97
 
92
98
  - **Page templates** live at `app/views/<controller>/<action>.html.inertia` and
@@ -111,11 +117,75 @@ You do **not** need to call `use_inertia_instance_props`.
111
117
  which you embed directly. Don't name partials `.html.inertia` — that extension
112
118
  triggers the Inertia response wrapper and is only for top-level page templates.
113
119
 
120
+ ## Sharing a partial with a plain JSON API
121
+
122
+ An Inertia page and a plain JSON endpoint are both, in the end, just **a Hash**,
123
+ so a single jb partial can back both. Name the partial **without a format**
124
+ (`_message.jb`, not `_message.html.jb`) so it resolves for the `html` format
125
+ Inertia uses *and* the `json` format a normal API request uses:
126
+
127
+ ```ruby
128
+ # app/views/messages/_message.jb
129
+ {
130
+ id: message.id,
131
+ content: message.content,
132
+ author: render(partial: "authors/author", object: message.author)
133
+ }
134
+ ```
135
+
136
+ ```ruby
137
+ # app/views/messages/show.html.inertia — the Inertia page
138
+ { **render(partial: "messages/message", object: @message) }
139
+ ```
140
+
141
+ ```ruby
142
+ # app/views/messages/index.html.inertia — nested under a key
143
+ { messages: render(partial: "messages/message", collection: @messages, as: :message) }
144
+ ```
145
+
146
+ ```ruby
147
+ # app/views/messages/show.json.jb — a plain JSON endpoint
148
+ render(partial: "messages/message", object: @message)
149
+ ```
150
+
151
+
152
+ Note the asymmetry: the JSON endpoint can return that Array at the top level,
153
+ but the Inertia page **must** nest it under a key (`{ posts: … }`) — Inertia
154
+ props must be an object, never a top-level Array.
155
+
156
+ > **Gotcha — wrap the page template in a Hash literal.** A `.html.inertia` page
157
+ > must **not** be a bare top-level `render(partial: …)`:
158
+ >
159
+ > ```ruby
160
+ > # ❌ props get misread as the component name
161
+ > render(partial: "messages/message", object: @message)
162
+ >
163
+ > # ✅ spread into a real Hash literal
164
+ > { **render(partial: "messages/message", object: @message) }
165
+ > ```
166
+ >
167
+ > jb's `render(partial:)` returns a `Jb::TemplateResult` (a delegator), not a
168
+ > true `Hash`. inertia-rails decides *"is this props or a component name?"* with
169
+ > `component.is_a?(Hash)`, so a bare partial result is taken for a component name
170
+ > and your props end up in the `component` field. Wrapping it in a literal
171
+ > `{ **… }` — or nesting it under a key, e.g. `{ message: render(…) }` — makes
172
+ > the top-level value a genuine `Hash`, which inertia-rails reads as props. A
173
+ > `.json.jb` endpoint never hits this, because jb serializes its top-level result
174
+ > with `to_json` directly.
175
+
176
+ If you'd rather keep a format-specific partial (`_message.json.jb`), borrow the
177
+ `:json` variant from the Inertia side with `formats:`:
178
+
179
+ ```ruby
180
+ # app/views/messages/show.html.inertia
181
+ { **render(partial: "messages/message", object: @message, formats: [:json]) }
182
+ ```
183
+
114
184
  ## Inertia prop types
115
185
 
116
186
  Because props are just a Hash, Inertia's special prop types are plain values you
117
187
  drop in. Inside a `.html.inertia` template you can use the short helpers
118
- (`optional`, `always`, `defer`, `scroll`, `merge`, `deep_merge`) or the full
188
+ (`optional`, `always`, `defer`, `scroll`, `merge`, `deep_merge`, `once`, `cache`) or the full
119
189
  `InertiaRails.*` methods.
120
190
 
121
191
  ```ruby
@@ -137,23 +207,24 @@ drop in. Inside a `.html.inertia` template you can use the short helpers
137
207
  # Infinite scrolling (accepts a paginator or explicit metadata).
138
208
  feed: scroll(@pagy) {
139
209
  render(partial: "feed/item", collection: @items)
210
+ },
211
+
212
+ # Sent once and cached client-side; skipped on later visits until reset.
213
+ flash: once { session.delete(:flash) },
214
+
215
+ # Server-side cached via Rails.cache; the block's JSON output is reused
216
+ # across requests until the cache entry expires.
217
+ report: cache("posts/#{@post.id}/report", expires_in: 5.minutes) {
218
+ render(partial: "reports/report", object: @post.report)
140
219
  }
141
220
  }
142
221
  ```
143
222
 
144
223
  See the inertia-rails docs for [partial reloads](https://inertiajs.com/partial-reloads),
145
- [deferred props](https://inertiajs.com/deferred-props), and
146
- [infinite scroll](https://inertia-rails.dev/guide/infinite-scroll).
147
-
148
- ## Caching
149
-
150
- Use plain Rails caching — you're caching Ruby Hashes:
151
-
152
- ```ruby
153
- @posts.map do |post|
154
- Rails.cache.fetch(post) { render(partial: "posts/post", object: post) }
155
- end
156
- ```
224
+ [deferred props](https://inertiajs.com/deferred-props),
225
+ [infinite scroll](https://inertia-rails.dev/guide/infinite-scroll),
226
+ [once props](https://inertia-rails.dev/guide/once-props), and
227
+ [prop caching](https://inertia-rails.dev/guide/prop-caching).
157
228
 
158
229
  ## camelCase keys
159
230
 
@@ -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
@@ -37,5 +37,16 @@ module InertiaJb
37
37
  def deep_merge(...)
38
38
  ::InertiaRails.deep_merge(...)
39
39
  end
40
+
41
+ # Sent once and cached client-side; skipped on later visits unless reset or expired.
42
+ def once(...)
43
+ ::InertiaRails.once(...)
44
+ end
45
+
46
+ # Server-side cached via Rails.cache; the block's result is stored as JSON
47
+ # and reused on subsequent requests until the cache expires.
48
+ def cache(...)
49
+ ::InertiaRails.cache(...)
50
+ end
40
51
  end
41
52
  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.3.0"
5
5
  end
@@ -29,11 +29,18 @@ class TestController < ActionController::Base
29
29
  def special
30
30
  @id = 1
31
31
  end
32
+
33
+ # once (client-cached) + cache (server-cached).
34
+ def cached
35
+ @call_count = 0
36
+ end
32
37
  end
33
38
 
34
39
  class ControllerTest < ActionController::TestCase
35
40
  tests TestController
36
41
 
42
+ include ActiveSupport::Testing::TimeHelpers
43
+
37
44
  TEMPLATES = {
38
45
  "layouts/application.html.erb" => "<html><body><%= yield %></body></html>",
39
46
  "test/index.html.inertia" => "{ content: 'content' }",
@@ -45,7 +52,10 @@ class ControllerTest < ActionController::TestCase
45
52
  "authors/_author.html.jb" => "{ id: author[:id], name: author[:name] }",
46
53
  "comments/_comment.html.jb" => "{ body: comment[:body] }",
47
54
  "test/special.html.inertia" =>
48
- "{ id: @id, stats: optional { { visits: 42 } }, feed: defer(group: :feed) { [1, 2, 3] } }"
55
+ "{ id: @id, stats: optional { { visits: 42 } }, feed: defer(group: :feed) { [1, 2, 3] } }",
56
+ "test/cached.html.inertia" =>
57
+ "{ flash: once { 'hello' }, " \
58
+ "report: cache('report', expires_in: 1.minute) { Time.current.to_f } }"
49
59
  }.freeze
50
60
 
51
61
  def setup
@@ -53,7 +63,7 @@ class ControllerTest < ActionController::TestCase
53
63
 
54
64
  @routes = ActionDispatch::Routing::RouteSet.new
55
65
  @routes.draw do
56
- %i[index nested collection with_partial special].each do |action|
66
+ %i[index nested collection with_partial special cached].each do |action|
57
67
  get action.to_s => "test##{action}"
58
68
  end
59
69
  end
@@ -65,6 +75,7 @@ class ControllerTest < ActionController::TestCase
65
75
  def teardown
66
76
  super
67
77
  @routes.clear!
78
+ Rails.cache.clear
68
79
  end
69
80
 
70
81
  # ---- basic rendering -----------------------------------------------------
@@ -158,6 +169,50 @@ class ControllerTest < ActionController::TestCase
158
169
  assert_equal({ "feed" => ["feed"] }, page["deferredProps"])
159
170
  end
160
171
 
172
+ # ---- once props (client-cached) -----------------------------------------
173
+
174
+ def test_once_prop_included_on_first_visit
175
+ inertia_get :cached
176
+ page = JSON.parse(response.body)
177
+
178
+ assert_equal "hello", page.dig("props", "flash")
179
+ assert_equal({ "flash" => { "prop" => "flash" } }, page["onceProps"])
180
+ end
181
+
182
+ def test_once_prop_excluded_when_client_reports_it_cached
183
+ inertia_get :cached,
184
+ headers: {
185
+ "X-Inertia-Partial-Component" => "test/cached",
186
+ "X-Inertia-Except-Once-Props" => "flash"
187
+ }
188
+ page = JSON.parse(response.body)
189
+
190
+ refute page["props"].key?("flash"), "once prop should be absent when client reports it cached"
191
+ end
192
+
193
+ # ---- cached props (server-cached) ---------------------------------------
194
+
195
+ def test_cached_prop_serves_same_value_across_requests
196
+ inertia_get :cached
197
+ first = JSON.parse(response.body).dig("props", "report")
198
+
199
+ inertia_get :cached
200
+ second = JSON.parse(response.body).dig("props", "report")
201
+
202
+ assert_equal first, second, "cached prop should return the same value across requests"
203
+ end
204
+
205
+ def test_cached_prop_recomputes_after_expiry
206
+ inertia_get :cached
207
+ first = JSON.parse(response.body).dig("props", "report")
208
+
209
+ travel_to 2.minutes.from_now do
210
+ inertia_get :cached
211
+ second = JSON.parse(response.body).dig("props", "report")
212
+ refute_equal first, second, "cached prop should recompute after expiry"
213
+ end
214
+ end
215
+
161
216
  private
162
217
 
163
218
  def inertia_get(action, headers: {})
@@ -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
data/test/test_helper.rb CHANGED
@@ -6,6 +6,7 @@ require "action_controller/railtie"
6
6
  require "inertia_rails"
7
7
  require "inertia_jb"
8
8
  require "active_support/testing/autorun"
9
+ require "active_support/testing/time_helpers"
9
10
 
10
11
  ActiveSupport.test_order = :random
11
12
 
@@ -14,6 +15,7 @@ ActiveSupport.test_order = :random
14
15
  Class.new(Rails::Application) do
15
16
  config.secret_key_base = "secret"
16
17
  config.eager_load = false
18
+ config.cache_store = :memory_store
17
19
  end.initialize!
18
20
 
19
21
  InertiaRails.configure do |c|
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.3.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-24 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