inertia_jb 0.3.1 → 0.4.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: 37e2ba2d8b4c896a4a4f7f006aa32cbee162cc7b798a77e78daab8a305512c65
4
- data.tar.gz: 7d1f22f9ba1596965f7d8fc52c7ccc3f7f7aae78d3a1093464b77791ed858a27
3
+ metadata.gz: e28cad09da1cc8c9b7098013dae68a3f87a17c9c770448955459672f4df53d38
4
+ data.tar.gz: f3b9082f2f747bd5b263710652d816a7f750eae331eb2fde9c2a86190e71b003
5
5
  SHA512:
6
- metadata.gz: 69f29e30c55a3efe39d54695948fec6de98ef96cf1a788dcb7806fb05186bc6e04f3f92cdb4ae5d175457489b042a1a3d5c8e692df2fdb07d7559d22f13edea3
7
- data.tar.gz: c666ba929d8d67a7daaed36b39985ac9642b546d182dbccf30abc0281b5c115daef7f395157bbee6a0e35943c29cdb5b2ce84d07a528993d6ee528db0e269eea
6
+ metadata.gz: b89149beecc268c316a4e91126c33df0b840a44b66a5b536a2b89bdfc21c74a3489ed699f2800185f7834fadd0daeca8b4653e2efcd2309f4b0c9e1c3a15205b
7
+ data.tar.gz: f95ce5ed914aa0477fa63a33af696da8dc921eeb3609f56cd45cda181f9c259820bb8080a2d308f9617c8b729505c25c3574c4c4f546ef3bf6376736b1961805
data/README.md CHANGED
@@ -2,15 +2,16 @@
2
2
 
3
3
  InertiaJb lets you declare [Inertia.js](https://inertiajs.com/) props
4
4
  for your Rails frontend components inside **view templates**, using plain Ruby
5
- Hashes powered by [jb](https://github.com/amatsuda/jb).
5
+ Hashes.
6
6
 
7
7
  It is built on top of the [inertia-rails](https://github.com/inertiajs/inertia-rails) gem.
8
8
 
9
- A `*.html.inertia` template is just Ruby code whose last expression is a Hash.
10
- That Hash is handed straight to `InertiaRails::Renderer`, so **every Inertia
11
- protocol feature works out of the box** partial reloads (at any nesting
12
- depth), optional / always / deferred / scroll props, prop merging, shared data,
13
- and global key transforms.
9
+ A `*.inertia` template is just Ruby code whose last expression is a Hash.
10
+ `inertia` is registered as a real Rails template format, so the template renders
11
+ through the ordinary Rails pipeline and its resulting Hash is handed straight to
12
+ `InertiaRails::Renderer` meaning **every Inertia protocol feature works out of
13
+ the box**: partial reloads (at any nesting depth), optional / always / deferred /
14
+ scroll props, prop merging, shared data, and global key transforms.
14
15
 
15
16
  ```ruby
16
17
  # app/controllers/messages_controller.rb
@@ -25,7 +26,7 @@ end
25
26
  ```
26
27
 
27
28
  ```ruby
28
- # app/views/messages/show.html.inertia
29
+ # app/views/messages/show.inertia
29
30
  {
30
31
  message: {
31
32
  content: @message.content,
@@ -55,15 +56,6 @@ export default function Message({ user, message }) {
55
56
  }
56
57
  ```
57
58
 
58
- ## Why jb?
59
-
60
- Inertia's server side is fundamentally about producing **a Hash of props** that
61
- `inertia-rails` then resolves and serializes. jb templates *are* plain Ruby
62
- Hashes, so there is zero impedance mismatch: no DSL to learn, no intermediate
63
- representation, and the full power of Ruby for building collections and
64
- conditionals.
65
-
66
-
67
59
  ## Installation
68
60
 
69
61
  Add the gem to your Gemfile:
@@ -72,8 +64,9 @@ Add the gem to your Gemfile:
72
64
  gem "inertia_jb"
73
65
  ```
74
66
 
75
- Then, in your inertia-rails initializer, **disable `default_render`** so that a
76
- normal implicit render falls through to your `.html.inertia` template:
67
+ Then, in your inertia-rails initializer, keep `default_render` **off** (that is
68
+ its own default) so a normal implicit render falls through to your `.inertia`
69
+ template:
77
70
 
78
71
  ```ruby
79
72
  # config/initializers/inertia_rails.rb
@@ -82,8 +75,6 @@ InertiaRails.configure do |config|
82
75
  end
83
76
  ```
84
77
 
85
- You do **not** need to call `use_inertia_instance_props`.
86
-
87
78
  ## Layout
88
79
 
89
80
  On an **initial (non-XHR) page load** the `data-page` root element is wrapped in
@@ -95,13 +86,13 @@ The layout is chosen from inertia-rails' `config.layout`, matching
95
86
 
96
87
  ## Templates and partials
97
88
 
98
- - **Page templates** live at `app/views/<controller>/<action>.html.inertia` and
89
+ - **Page templates** live at `app/views/<controller>/<action>.inertia` and
99
90
  must return a Hash (your Inertia props).
100
91
  - **Partials** are ordinary [jb](https://github.com/amatsuda/jb) partials named
101
- `_name.html.jb`. They return a Hash, and compose naturally:
92
+ `_name.jb`. They return a Hash, and compose naturally:
102
93
 
103
94
  ```ruby
104
- # app/views/messages/show.html.inertia
95
+ # app/views/messages/show.inertia
105
96
  {
106
97
  author: render(partial: "authors/author", object: @message.author),
107
98
  comments: render(partial: "comments/comment", collection: @message.comments)
@@ -109,20 +100,20 @@ The layout is chosen from inertia-rails' `config.layout`, matching
109
100
  ```
110
101
 
111
102
  ```ruby
112
- # app/views/authors/_author.html.jb
103
+ # app/views/authors/_author.jb
113
104
  { id: author.id, name: author.name }
114
105
  ```
115
106
 
116
107
  `render(partial:, collection:)` returns an **array of Hashes** (thanks to jb),
117
- which you embed directly. Don't name partials `.html.inertia` — that extension
118
- triggers the Inertia response wrapper and is only for top-level page templates.
108
+ which you embed directly. Don't name partials `.inertia` — that format is for
109
+ top-level page templates only, and turns the result into an Inertia response.
119
110
 
120
111
  ## Sharing a partial with a plain JSON API
121
112
 
122
113
  An Inertia page and a plain JSON endpoint are both, in the end, just **a Hash**,
123
114
  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:
115
+ (`_message.jb`, not `_message.html.jb`) so it resolves for the formats an Inertia
116
+ page renders under *and* the `json` format a normal API request uses:
126
117
 
127
118
  ```ruby
128
119
  # app/views/messages/_message.jb
@@ -134,12 +125,12 @@ Inertia uses *and* the `json` format a normal API request uses:
134
125
  ```
135
126
 
136
127
  ```ruby
137
- # app/views/messages/show.html.inertia — the Inertia page
138
- { **render(partial: "messages/message", object: @message) }
128
+ # app/views/messages/show.inertia — the Inertia page
129
+ render(partial: "messages/message", object: @message)
139
130
  ```
140
131
 
141
132
  ```ruby
142
- # app/views/messages/index.html.inertia — nested under a key
133
+ # app/views/messages/index.inertia — nested under a key
143
134
  { messages: render(partial: "messages/message", collection: @messages, as: :message) }
144
135
  ```
145
136
 
@@ -150,41 +141,22 @@ render(partial: "messages/message", object: @message)
150
141
 
151
142
 
152
143
  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.
144
+ but the Inertia page **must** nest it under a key (`{ messages: … }`) — Inertia
145
+ props must be an object, never a top-level Array. An **object** partial is fine
146
+ bare, as in `show.inertia` above.
175
147
 
176
148
  If you'd rather keep a format-specific partial (`_message.json.jb`), borrow the
177
149
  `:json` variant from the Inertia side with `formats:`:
178
150
 
179
151
  ```ruby
180
- # app/views/messages/show.html.inertia
181
- { **render(partial: "messages/message", object: @message, formats: [:json]) }
152
+ # app/views/messages/show.inertia
153
+ render(partial: "messages/message", object: @message, formats: [:json])
182
154
  ```
183
155
 
184
156
  ## Inertia prop types
185
157
 
186
158
  Because props are just a Hash, Inertia's special prop types are plain values you
187
- drop in. Inside a `.html.inertia` template you can use the short helpers
159
+ drop in. Inside a `.inertia` template you can use the short helpers
188
160
  (`optional`, `always`, `defer`, `scroll`, `merge`, `deep_merge`, `once`, `cache`) or the full
189
161
  `InertiaRails.*` methods.
190
162
 
@@ -246,8 +218,29 @@ Alternatively, just write camelCase keys directly in your templates.
246
218
 
247
219
  - Inertia props must be an **object**, so page templates should return a Hash
248
220
  (not a top-level Array).
249
- - Don't install this alongside `inertia-builder`; both register an `:inertia`
250
- template handler.
221
+ - The gem registers `inertia` as a `Mime::Type` **alias** of `text/html`. Because
222
+ it is an alias, it is skipped by content negotiation: `inertia` is a template
223
+ format, never a wire format, and `Accept` headers are unaffected.
224
+ - If both `show.html.erb` and `show.inertia` exist for one action, Rails prefers
225
+ the exact format match — the ERB one.
226
+ - If your app pins `config.action_view.default_formats`, you don't need to add
227
+ `inertia` to it: page templates carry no format extension, so they resolve under
228
+ whatever format the request already asked for.
229
+
230
+ ## Upgrading from 0.3.x
231
+
232
+ 1. **Rename page templates**: `show.html.inertia` → `show.inertia`. A leftover
233
+ `.html.inertia` template raises with the rename instruction, so nothing fails
234
+ silently. Jb partials (`_author.html.jb`, `_author.jb`) don't change.
235
+ 2. **Component names now follow the rendered template** rather than
236
+ `controller_path`/`action_name`. Identical for a normal implicit render; it
237
+ differs when you `render :other_action` (you now get `other_action`'s
238
+ component, which is the point) or when a template is inherited from a parent
239
+ controller's view directory (the component follows the template's directory).
240
+ 3. `{ **render(partial: …) }` in a page template can lose the wrapper — a bare
241
+ `render(partial: …)` is now read as props.
242
+
243
+ `config.default_render = false` is still required, unchanged from 0.3.x.
251
244
 
252
245
  ## Development
253
246
 
@@ -1,28 +1,38 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module InertiaJb
4
- # Controller concern that renders `.html.inertia` templates as Inertia
5
- # responses.
4
+ # Controller concern that renders `*.inertia` templates as Inertia responses.
6
5
  #
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 all before 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.
6
+ # There is no control-flow trickery here: an `.inertia` page goes through
7
+ # Rails' ordinary render pipeline and its *return value* a {Props} object
8
+ # comes back out of `render_to_body`. We recognise it there and re-enter with
9
+ # `inertia:`, which inertia-rails registered as a proper
10
+ # +ActionController::Renderers+ renderer. Component resolution, shared data,
11
+ # `PropsResolver`/partial reloads, `config.layout`, SSR and the
12
+ # X-Inertia/Vary headers therefore all stay with inertia-rails.
14
13
  #
15
- # Plain (non-Inertia) `.html.erb` actions never throw, so `catch` falls
16
- # through to `super` and they render exactly as in vanilla Rails.
14
+ # Because the hand-off keys on the rendered result, `default_render` needs no
15
+ # hook of its own: Rails' ordinary implicit render finds the `.inertia`
16
+ # template, and explicit renders work the same way — `render :new`,
17
+ # `render template: "other/page"`, `render :edit, status: :unprocessable_entity`.
18
+ #
19
+ # The one thing that *does* have to stay out of the way is inertia-rails'
20
+ # `config.default_render`, which short-circuits `default_render` into
21
+ # `render inertia: true` before any template is consulted; apps therefore set it
22
+ # to `false` (its own default).
17
23
  #
18
24
  # +InertiaRails::Controller+ is already mixed into +ActionController::Base+ by
19
25
  # inertia-rails' engine, so we don't include it again.
20
26
  module Controller
21
27
  extend ActiveSupport::Concern
22
28
 
23
- def default_render(*)
24
- props = catch(:__inertia_jb) { return super }
25
- render(inertia: props)
29
+ def render_to_body(options = {})
30
+ body = super
31
+ return body unless body.is_a?(InertiaJb::Props)
32
+
33
+ # Second pass: `:inertia` is handled by inertia-rails' renderer, which
34
+ # returns a real String body, so this never recurses further.
35
+ render_to_body(inertia: body.component(inertia_configuration), props: body.props)
26
36
  end
27
37
  end
28
38
  end
@@ -1,21 +1,47 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module InertiaJb
4
- # ActionView template handler for `*.html.inertia` templates.
4
+ # ActionView template handler for `*.inertia` page templates.
5
5
  #
6
- # An `.html.inertia` template is jb-style Ruby: its last expression is a Hash
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.
6
+ # An `.inertia` template is jb-style Ruby: its last expression is a Hash of
7
+ # Inertia props. The handler compiles it into a {Props} object — the
8
+ # template's *return value*, produced by an otherwise completely ordinary
9
+ # Rails render. {Controller#render_to_body} recognises that value and hands it
10
+ # to inertia-rails' `:inertia` renderer.
11
+ #
12
+ # The `default_format` declaration is what makes `:inertia` a real Rails
13
+ # template format: a file named `show.inertia` carries no format extension, so
14
+ # +TemplateDetails#format_or_default+ falls back to this value and the built
15
+ # +ActionView::Template+ reports `format == :inertia`. Two things follow for
16
+ # free — the template still resolves for an ordinary `html` request (a
17
+ # format-less path matches any requested format), and no layout is ever found
18
+ # for it, because layout lookup runs against `formats.first` (`:inertia`) and
19
+ # `layouts/*.html.erb` simply does not match.
11
20
  class Handler
21
+ class_attribute :default_format
22
+ self.default_format = :inertia
23
+
12
24
  def self.call(template, source = nil)
13
25
  source ||= template.source
14
26
 
27
+ unless template.format == :inertia
28
+ raise ArgumentError, <<~MESSAGE
29
+ InertiaJb: #{template.identifier} has format #{template.format.inspect}, not :inertia.
30
+ Page templates must be named `<action>.inertia` (drop the `.html.`) so that
31
+ Rails resolves them as the :inertia format.
32
+ MESSAGE
33
+ end
34
+
15
35
  # `begin;#{source}` keeps the template's own line numbers aligned in
16
36
  # 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)"
37
+ # props Hash). `@virtual_path` is assigned by Rails in the compiled
38
+ # method's preamble, so it is already set here.
39
+ "::InertiaJb::Props.new(@virtual_path, begin;#{source}\nend)"
40
+ end
41
+
42
+ # Hand the source to Ruby as-is, like Jb::Handler does.
43
+ def self.handles_encoding?
44
+ true
19
45
  end
20
46
  end
21
47
  end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module InertiaJb
4
+ # The result of rendering an `*.inertia` template: the props Hash plus the
5
+ # template's virtual path.
6
+ #
7
+ # It exists so that {Controller#render_to_body} can tell "this is a set of
8
+ # Inertia props" from "this is a response body" without inspecting formats or
9
+ # guessing, and so the Inertia component name can be derived from the template
10
+ # that actually rendered.
11
+ class Props
12
+ attr_reader :virtual_path, :props
13
+
14
+ def initialize(virtual_path, props)
15
+ @virtual_path = virtual_path
16
+ @props = props
17
+ end
18
+
19
+ # The Inertia component name for this page.
20
+ #
21
+ # Derived from the rendered template rather than from
22
+ # `controller_path`/`action_name`, so an explicit `render :edit` points at
23
+ # the `edit` component instead of the acting `create`.
24
+ #
25
+ # The virtual path is split into the same `path:`/`action:` pair inertia-rails
26
+ # feeds its own resolver, and handed to `config.component_path_resolver`
27
+ # unconditionally — a custom resolver (camelizing, prefixing, ...) owns the
28
+ # result in every case, including a page template that sits at the root of a
29
+ # view path (`path:` is then `""`, which the default resolver renders as
30
+ # `"/name"`).
31
+ def component(configuration)
32
+ prefix, _, name = virtual_path.rpartition("/")
33
+
34
+ configuration.component_path_resolver(path: prefix, action: name)
35
+ end
36
+ end
37
+ end
@@ -5,14 +5,27 @@ require "rails/railtie"
5
5
  module InertiaJb
6
6
  class Railtie < ::Rails::Railtie
7
7
  initializer :inertia_jb do
8
- ActiveSupport.on_load(:action_controller) do
9
- include InertiaJb::Controller
8
+ # Makes `:inertia` a valid Rails template format (`Mime::SET.symbols` is
9
+ # what `ActionView::Template::Types.symbols` reads). Registered here, in
10
+ # the initializer body, so it lands before any template path is parsed —
11
+ # the resolvers memoize a regex built from the format list.
12
+ #
13
+ # `register_alias` rather than `register`: the latter would overwrite
14
+ # `LOOKUP["text/html"]` and break HTML content negotiation app-wide.
15
+ # An alias skips the mime-string lookup, so `:inertia` never takes part in
16
+ # Accept negotiation — it is a template format, not a wire format.
17
+ Mime::Type.register_alias "text/html", :inertia unless Mime[:inertia]
18
+
19
+ # `prepend`, not `include`: `render_to_body` has to sit ahead of
20
+ # +ActionController::Renderers+ in the ancestry so `super` reaches it, and
21
+ # prepending makes that position independent of gem load order.
22
+ ActiveSupport.on_load(:action_controller_base) do
23
+ prepend InertiaJb::Controller
10
24
  end
11
25
 
12
26
  ActiveSupport.on_load(:action_view) do
13
27
  ActionView::Template.register_template_handler :inertia, InertiaJb::Handler
14
28
  ActionView::Base.include InertiaJb::Helper
15
- ActionView::TemplateRenderer.prepend InertiaJb::Renderer
16
29
  end
17
30
  end
18
31
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module InertiaJb
4
- VERSION = "0.3.1"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/inertia_jb.rb CHANGED
@@ -4,8 +4,8 @@ require "jb"
4
4
  require "inertia_rails"
5
5
 
6
6
  require "inertia_jb/version"
7
+ require "inertia_jb/props"
7
8
  require "inertia_jb/handler"
8
- require "inertia_jb/renderer"
9
9
  require "inertia_jb/controller"
10
10
  require "inertia_jb/helper"
11
11
  require "inertia_jb/railtie" if defined?(Rails::Railtie)
@@ -14,9 +14,12 @@ require "inertia_jb/railtie" if defined?(Rails::Railtie)
14
14
  # frontend components inside view templates, using plain Ruby Hashes powered by
15
15
  # the {https://github.com/amatsuda/jb jb} renderer.
16
16
  #
17
- # A `*.html.inertia` template is Ruby code whose last expression is a Hash of
18
- # props. That Hash is handed straight to +InertiaRails::Renderer+, so every
19
- # Inertia protocol feature (partial reloads, optional/always/deferred props,
20
- # merging, shared data, key transforms) works out of the box.
17
+ # `:inertia` is registered as a real Rails template format, so a `*.inertia`
18
+ # template is rendered by the ordinary Rails pipeline (and never wrapped in a
19
+ # layout). Its return value is a {Props} object carrying the template's last
20
+ # expression a Hash of props which {Controller#render_to_body} hands to
21
+ # inertia-rails' `:inertia` renderer. Every Inertia protocol feature (partial
22
+ # reloads, optional/always/deferred props, merging, shared data, key transforms)
23
+ # therefore works out of the box.
21
24
  module InertiaJb
22
25
  end
@@ -5,8 +5,6 @@ require "action_view/testing/resolvers"
5
5
  require "json"
6
6
 
7
7
  class TestController < ActionController::Base
8
- include InertiaJb::Controller
9
-
10
8
  layout "application"
11
9
 
12
10
  # Shared props are merged in automatically by inertia-rails.
@@ -49,23 +47,23 @@ class ControllerTest < ActionController::TestCase
49
47
 
50
48
  TEMPLATES = {
51
49
  "layouts/application.html.erb" => "<html><body><%= yield %></body></html>",
52
- "test/index.html.inertia" => "{ content: 'content' }",
53
- "test/nested.html.inertia" => "{ product: { id: 10, name: 'Keyboard', price: 29.99 } }",
54
- "test/collection.html.inertia" => "{ products: @products.map { |p| { id: p[:id], name: p[:name] } } }",
55
- "test/with_partial.html.inertia" =>
50
+ "test/index.inertia" => "{ content: 'content' }",
51
+ "test/nested.inertia" => "{ product: { id: 10, name: 'Keyboard', price: 29.99 } }",
52
+ "test/collection.inertia" => "{ products: @products.map { |p| { id: p[:id], name: p[:name] } } }",
53
+ "test/with_partial.inertia" =>
56
54
  "{ author: render(partial: 'authors/author', object: @author), " \
57
55
  "comments: render(partial: 'comments/comment', collection: @comments) }",
58
56
  "authors/_author.html.jb" => "{ id: author[:id], name: author[:name] }",
59
57
  "comments/_comment.html.jb" => "{ body: comment[:body] }",
60
- "test/special.html.inertia" =>
58
+ "test/special.inertia" =>
61
59
  "{ id: @id, stats: optional { { visits: 42 } }, feed: defer(group: :feed) { [1, 2, 3] } }",
62
- "test/cached.html.inertia" =>
60
+ "test/cached.inertia" =>
63
61
  "{ flash: once { 'hello' }, " \
64
62
  "report: cache('report', expires_in: 1.minute) { Time.current.to_f } }",
65
63
  # A deferred/optional prop whose block renders a jb partial. The block is
66
64
  # resolved by inertia-rails in controller context, so a bare `render` there
67
65
  # would set response_body and collide with inertia's own render(json:).
68
- "test/lazy_partial.html.inertia" =>
66
+ "test/lazy_partial.inertia" =>
69
67
  "{ deferredAuthor: defer { render(partial: 'authors/author', object: @author) }, " \
70
68
  "optionalComments: optional { render(partial: 'comments/comment', collection: @comments) } }"
71
69
  }.freeze
@@ -3,13 +3,15 @@
3
3
  require "test_helper"
4
4
  require "action_view/testing/resolvers"
5
5
 
6
- # Regression test for the "phantom layout render" bug.
6
+ # Regression test: an `.inertia` page must never be wrapped in — or even resolve
7
+ # — the controller's layout.
7
8
  #
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.
9
+ # This falls out of `:inertia` being a real template format: layout lookup runs
10
+ # against `formats.first` (`:inertia`), which `layouts/*.html.erb` cannot match.
11
+ # Nothing patches Rails to make it happen, so the guarantee is worth pinning:
12
+ # were the layout resolved, request logs and APM traces would show a layout
13
+ # render that contributes nothing to the response, and its timing would absorb
14
+ # the prop-building work.
13
15
  #
14
16
  # The controller here uses `layout "application"` but points Inertia at a
15
17
  # *different* shell (`config.layout = "inertia"`), so the two are easy to tell
@@ -17,7 +19,6 @@ require "action_view/testing/resolvers"
17
19
  # * the `.inertia` props template must be rendered with NO layout, and
18
20
  # * `layouts/application` must never be rendered at all.
19
21
  class SuppressLayoutController < ActionController::Base
20
- include InertiaJb::Controller
21
22
  layout "application"
22
23
  inertia_config layout: "inertia"
23
24
  def index; end
@@ -29,7 +30,7 @@ class LayoutSuppressionTest < ActionController::TestCase
29
30
  TEMPLATES = {
30
31
  "layouts/application.html.erb" => "<html><body><%= yield %></body></html>",
31
32
  "layouts/inertia.html.erb" => "<main data-inertia-shell=\"1\"><%= yield %></main>",
32
- "suppress_layout/index.html.inertia" => "{ ok: true }"
33
+ "suppress_layout/index.inertia" => "{ ok: true }"
33
34
  }.freeze
34
35
 
35
36
  def setup
@@ -47,7 +48,7 @@ class LayoutSuppressionTest < ActionController::TestCase
47
48
  def test_inertia_template_is_rendered_without_a_layout
48
49
  templates = capture("render_template.action_view") { get :index }
49
50
 
50
- page = templates.find { |e| e.payload[:identifier].end_with?("index.html.inertia") }
51
+ page = templates.find { |e| e.payload[:identifier].end_with?("index.inertia") }
51
52
  assert page, "the .inertia props template should have been rendered"
52
53
  assert_nil page.payload[:layout],
53
54
  "the .inertia props template must be rendered without a layout, " \
data/test/layout_test.rb CHANGED
@@ -9,21 +9,18 @@ require "json"
9
9
  # case gets its own controller and no global state is mutated.
10
10
 
11
11
  class LayoutDefaultController < ActionController::Base
12
- include InertiaJb::Controller
13
12
  layout "application"
14
13
  # No `inertia_config` — `config.layout` defaults to `true`.
15
14
  def index; end
16
15
  end
17
16
 
18
17
  class LayoutFalseController < ActionController::Base
19
- include InertiaJb::Controller
20
18
  layout "application"
21
19
  inertia_config layout: false
22
20
  def index; end
23
21
  end
24
22
 
25
23
  class LayoutNamedController < ActionController::Base
26
- include InertiaJb::Controller
27
24
  layout "application"
28
25
  inertia_config layout: "inertia"
29
26
  def index; end
@@ -32,7 +29,6 @@ end
32
29
  # A plain (non-Inertia) HTML action living in an app that configured
33
30
  # `config.layout = false`. Its layout must behave like vanilla Rails.
34
31
  class PlainController < ActionController::Base
35
- include InertiaJb::Controller
36
32
  layout "application"
37
33
  inertia_config layout: false
38
34
  def index; end
@@ -42,9 +38,9 @@ module LayoutTestHelpers
42
38
  TEMPLATES = {
43
39
  "layouts/application.html.erb" => "<html><body><%= yield %></body></html>",
44
40
  "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 }",
41
+ "layout_default/index.inertia" => "{ ok: true }",
42
+ "layout_false/index.inertia" => "{ ok: true }",
43
+ "layout_named/index.inertia" => "{ ok: true }",
48
44
  "plain/index.html.erb" => "plain-body-<%= 1 + 1 %>"
49
45
  }.freeze
50
46
 
@@ -0,0 +1,238 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+ require "action_view/testing/resolvers"
5
+ require "json"
6
+
7
+ # The hand-off from template to renderer keys on the *rendered result*, not on
8
+ # `default_render`, so explicit renders are first-class. These tests pin that
9
+ # down, plus the two boundaries of the `:inertia` format: it must take priority
10
+ # over inertia-rails' `config.default_render`, and it must not leak into other
11
+ # formats' template lookup.
12
+
13
+ class RenderController < ActionController::Base
14
+ layout "application"
15
+
16
+ def implicit; end
17
+
18
+ # The Rails idiom after a failed create: re-render another action's template.
19
+ # (422 spelled numerically — `:unprocessable_entity` is deprecated in Rack 3.2
20
+ # while `:unprocessable_content` doesn't exist on older Rack.)
21
+ def create
22
+ render :implicit, status: 422
23
+ end
24
+
25
+ def cross_template
26
+ render template: "other/thing"
27
+ end
28
+
29
+ # A page template whose whole body is a bare `render(partial:)` — jb hands
30
+ # back a Jb::TemplateResult, which reaches inertia-rails as `props:` and so
31
+ # needs no `{ **... }` wrapping.
32
+ def bare_partial
33
+ @author = { id: 42, name: "John" }
34
+ end
35
+
36
+ # A plain JSON endpoint next door: `.json.jb` must keep serializing to JSON.
37
+ def api; end
38
+ end
39
+
40
+ # inertia-rails' `config.default_render` short-circuits `default_render` into
41
+ # `render inertia: true`, which is why installing this gem asks for it to be off.
42
+ class DefaultRenderController < ActionController::Base
43
+ layout "application"
44
+ inertia_config default_render: true
45
+
46
+ def with_template; end
47
+ end
48
+
49
+ # A template still carrying the pre-0.4 name.
50
+ class LegacyNameController < ActionController::Base
51
+ def index; end
52
+ end
53
+
54
+ # The component name is always produced by inertia-rails' resolver, never
55
+ # assembled by this gem.
56
+ class CustomResolverController < ActionController::Base
57
+ inertia_config component_path_resolver: ->(path:, action:) { "#{path.camelize}/#{action.camelize}" }
58
+
59
+ def index; end
60
+ end
61
+
62
+ module RenderingTestHelpers
63
+ TEMPLATES = {
64
+ "layouts/application.html.erb" => "<html><body><%= yield %></body></html>",
65
+ "render/implicit.inertia" => "{ page: 'implicit' }",
66
+ "render/cross_template.inertia" => "{ page: 'never rendered' }",
67
+ "other/thing.inertia" => "{ page: 'other' }",
68
+ "render/bare_partial.inertia" => "render(partial: 'authors/author', object: @author)",
69
+ "authors/_author.html.jb" => "{ id: author[:id], name: author[:name] }",
70
+ "render/api.json.jb" => "{ ok: true }",
71
+ "default_render/with_template.inertia" => "{ from: 'template' }",
72
+ "legacy_name/index.html.inertia" => "{ page: 'legacy' }",
73
+ "custom_resolver/index.inertia" => "{ ok: true }"
74
+ }.freeze
75
+
76
+ def setup
77
+ super
78
+ @controller.prepend_view_path(ActionView::FixtureResolver.new(TEMPLATES))
79
+ end
80
+
81
+ def teardown
82
+ super
83
+ @routes.clear!
84
+ end
85
+
86
+ private
87
+
88
+ def draw(*actions)
89
+ controller_path = @controller.class.controller_path
90
+ @routes = ActionDispatch::Routing::RouteSet.new
91
+ @routes.draw do
92
+ actions.each { |action| get action.to_s => "#{controller_path}##{action}" }
93
+ end
94
+ end
95
+
96
+ def inertia_get(action, **kwargs)
97
+ @request.headers["X-Inertia"] = "true"
98
+ get action, **kwargs
99
+ end
100
+
101
+ def page
102
+ JSON.parse(response.body)
103
+ end
104
+ end
105
+
106
+ class ExplicitRenderTest < ActionController::TestCase
107
+ include RenderingTestHelpers
108
+ tests RenderController
109
+
110
+ def setup
111
+ super
112
+ draw :implicit, :create, :cross_template, :bare_partial, :api
113
+ end
114
+
115
+ def test_implicit_render_uses_the_template_component
116
+ inertia_get :implicit
117
+
118
+ assert_response :success
119
+ assert_equal "application/json; charset=utf-8", response.content_type
120
+ assert_equal "render/implicit", page["component"]
121
+ assert_equal "implicit", page.dig("props", "page")
122
+ end
123
+
124
+ def test_full_page_load_is_html
125
+ get :implicit
126
+
127
+ assert_response :success
128
+ assert_equal "text/html; charset=utf-8", response.content_type
129
+ assert_includes response.body, "<html><body>"
130
+ assert_includes response.body, "data-page"
131
+ end
132
+
133
+ # The old throw/catch design raised UncaughtThrowError here.
134
+ def test_explicit_render_of_another_action_keeps_status_and_component
135
+ inertia_get :create
136
+
137
+ assert_response 422
138
+ assert_equal "render/implicit", page["component"],
139
+ "the component must follow the rendered template, not the acting action"
140
+ assert_equal "implicit", page.dig("props", "page")
141
+ end
142
+
143
+ def test_explicit_render_template_across_directories
144
+ inertia_get :cross_template
145
+
146
+ assert_response :success
147
+ assert_equal "other/thing", page["component"]
148
+ assert_equal "other", page.dig("props", "page")
149
+ end
150
+
151
+ def test_page_template_may_be_a_bare_partial_render
152
+ inertia_get :bare_partial
153
+
154
+ assert_response :success
155
+ assert_equal({ "id" => 42, "name" => "John" }, page["props"].except("errors"))
156
+ end
157
+
158
+ # `:inertia` is a template format, not a wire format: it must not shadow the
159
+ # `.json.jb` lookup for a plain API request.
160
+ def test_json_endpoint_on_the_same_controller_is_untouched
161
+ get :api, format: :json
162
+
163
+ assert_response :success
164
+ assert_equal "application/json; charset=utf-8", response.content_type
165
+ assert_nil response.headers["X-Inertia"]
166
+ assert_equal({ "ok" => true }, page)
167
+ end
168
+ end
169
+
170
+ # Pins the reason the installation instructions ask for
171
+ # `config.default_render = false`: with it on, inertia-rails answers
172
+ # `default_render` itself and the `.inertia` template is never reached.
173
+ class DefaultRenderTest < ActionController::TestCase
174
+ include RenderingTestHelpers
175
+ tests DefaultRenderController
176
+
177
+ def setup
178
+ super
179
+ draw :with_template
180
+ end
181
+
182
+ def test_config_default_render_true_short_circuits_before_the_template
183
+ inertia_get :with_template
184
+
185
+ assert_response :success
186
+ assert_equal "default_render/with_template", page["component"]
187
+ refute page["props"].key?("from"),
188
+ "config.default_render = true renders `inertia: true` without consulting " \
189
+ "the template — hence the initializer step"
190
+ end
191
+ end
192
+
193
+ class InertiaMimeTypeTest < ActiveSupport::TestCase
194
+ def test_inertia_is_a_valid_template_format
195
+ assert Mime[:inertia], "Mime[:inertia] must exist for `lookup_context.formats` to accept it"
196
+ assert_includes ActionView::Template::Types.symbols, :inertia
197
+ end
198
+
199
+ # Registered as an *alias* of text/html: `Mime::Type.register` would repoint
200
+ # `LOOKUP["text/html"]` at :inertia and break HTML negotiation app-wide.
201
+ def test_inertia_does_not_take_part_in_content_negotiation
202
+ assert_equal :html, Mime::Type.lookup("text/html").symbol
203
+ assert_equal [Mime[:html]], Mime::Type.parse("text/html")
204
+ end
205
+ end
206
+
207
+ class CustomComponentResolverTest < ActionController::TestCase
208
+ include RenderingTestHelpers
209
+ tests CustomResolverController
210
+
211
+ def setup
212
+ super
213
+ draw :index
214
+ end
215
+
216
+ def test_component_name_goes_through_config_component_path_resolver
217
+ inertia_get :index
218
+
219
+ assert_response :success
220
+ assert_equal "CustomResolver/Index", page["component"]
221
+ end
222
+ end
223
+
224
+ class LegacyTemplateNameTest < ActionController::TestCase
225
+ include RenderingTestHelpers
226
+ tests LegacyNameController
227
+
228
+ def setup
229
+ super
230
+ draw :index
231
+ end
232
+
233
+ def test_html_inertia_template_reports_the_rename
234
+ error = assert_raises(ActionView::Template::Error) { get :index }
235
+
236
+ assert_includes error.message, "`<action>.inertia`"
237
+ end
238
+ 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.3.1
4
+ version: 0.4.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-25 00:00:00.000000000 Z
11
+ date: 2026-07-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: inertia_rails
@@ -81,12 +81,13 @@ files:
81
81
  - lib/inertia_jb/controller.rb
82
82
  - lib/inertia_jb/handler.rb
83
83
  - lib/inertia_jb/helper.rb
84
+ - lib/inertia_jb/props.rb
84
85
  - lib/inertia_jb/railtie.rb
85
- - lib/inertia_jb/renderer.rb
86
86
  - lib/inertia_jb/version.rb
87
87
  - test/controller_test.rb
88
88
  - test/layout_suppression_test.rb
89
89
  - test/layout_test.rb
90
+ - test/rendering_test.rb
90
91
  - test/test_helper.rb
91
92
  homepage: https://github.com/kikyous/inertia_jb
92
93
  licenses:
@@ -119,4 +120,5 @@ test_files:
119
120
  - test/controller_test.rb
120
121
  - test/layout_suppression_test.rb
121
122
  - test/layout_test.rb
123
+ - test/rendering_test.rb
122
124
  - test/test_helper.rb
@@ -1,34 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module InertiaJb
4
- # Prepended onto +ActionView::TemplateRenderer+ to guarantee that a
5
- # `.html.inertia` template is never wrapped in a layout.
6
- #
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
28
-
29
- def render_template(view, template, layout_name, locals)
30
- layout_name = nil if template.handler == InertiaJb::Handler
31
- super
32
- end
33
- end
34
- end