inertia_jb 0.2.0 → 0.3.1

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: 73afc6da5300453502fb918b78a802c92401b8dbe91fa6307ed4523a664c9716
4
- data.tar.gz: 0fb62ff6422c754317d18f6ddcf49cb46982822a931f72db2773999117ea9652
3
+ metadata.gz: 37e2ba2d8b4c896a4a4f7f006aa32cbee162cc7b798a77e78daab8a305512c65
4
+ data.tar.gz: 7d1f22f9ba1596965f7d8fc52c7ccc3f7f7aae78d3a1093464b77791ed858a27
5
5
  SHA512:
6
- metadata.gz: cf13cab4f4de7bf9973354d12b590e58473c873a0eb07a84ea14b28a87d32e1ef6c57310c7255f4b7c92e828b66463e12d5d67c25f55c9cb12d558ac8e1d6970
7
- data.tar.gz: '08e77238e1754dcdd0e2e74df5e984d38bab7c922ea9fa755495e09b50c056ef674eff9df8ea26dd0db3d91eca09ab1973b4744129da93064f691fccae40c4e9'
6
+ metadata.gz: 69f29e30c55a3efe39d54695948fec6de98ef96cf1a788dcb7806fb05186bc6e04f3f92cdb4ae5d175457489b042a1a3d5c8e692df2fdb07d7559d22f13edea3
7
+ data.tar.gz: c666ba929d8d67a7daaed36b39985ac9642b546d182dbccf30abc0281b5c115daef7f395157bbee6a0e35943c29cdb5b2ce84d07a528993d6ee528db0e269eea
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
 
@@ -93,33 +90,8 @@ On an **initial (non-XHR) page load** the `data-page` root element is wrapped in
93
90
  a layout; Inertia (XHR) visits always return a bare JSON body with no layout.
94
91
 
95
92
  The layout is chosen from inertia-rails' `config.layout`, matching
96
- `InertiaRails::Renderer`'s own semantics:
93
+ `InertiaRails::Renderer`'s own semantics.
97
94
 
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
95
 
124
96
  ## Templates and partials
125
97
 
@@ -145,11 +117,75 @@ of this setting.
145
117
  which you embed directly. Don't name partials `.html.inertia` — that extension
146
118
  triggers the Inertia response wrapper and is only for top-level page templates.
147
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
+
148
184
  ## Inertia prop types
149
185
 
150
186
  Because props are just a Hash, Inertia's special prop types are plain values you
151
187
  drop in. Inside a `.html.inertia` template you can use the short helpers
152
- (`optional`, `always`, `defer`, `scroll`, `merge`, `deep_merge`) or the full
188
+ (`optional`, `always`, `defer`, `scroll`, `merge`, `deep_merge`, `once`, `cache`) or the full
153
189
  `InertiaRails.*` methods.
154
190
 
155
191
  ```ruby
@@ -171,23 +207,24 @@ drop in. Inside a `.html.inertia` template you can use the short helpers
171
207
  # Infinite scrolling (accepts a paginator or explicit metadata).
172
208
  feed: scroll(@pagy) {
173
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)
174
219
  }
175
220
  }
176
221
  ```
177
222
 
178
223
  See the inertia-rails docs for [partial reloads](https://inertiajs.com/partial-reloads),
179
- [deferred props](https://inertiajs.com/deferred-props), and
180
- [infinite scroll](https://inertia-rails.dev/guide/infinite-scroll).
181
-
182
- ## Caching
183
-
184
- Use plain Rails caching — you're caching Ruby Hashes:
185
-
186
- ```ruby
187
- @posts.map do |post|
188
- Rails.cache.fetch(post) { render(partial: "posts/post", object: post) }
189
- end
190
- ```
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).
191
228
 
192
229
  ## camelCase keys
193
230
 
@@ -4,38 +4,78 @@ module InertiaJb
4
4
  # Optional syntax sugar available inside `.html.inertia` templates, so you can
5
5
  # write `optional { ... }` instead of `InertiaRails.optional { ... }`.
6
6
  #
7
- # These simply delegate to the +InertiaRails+ factory methods; the returned
8
- # prop objects are resolved by +InertiaRails::PropsResolver+ when the page is
9
- # built (respecting partial reloads, grouping, etc.).
7
+ # These delegate to the +InertiaRails+ factory methods; the returned prop
8
+ # objects are resolved by +InertiaRails::PropsResolver+ when the page is built
9
+ # (respecting partial reloads, grouping, etc.).
10
+ #
11
+ # Every block is bound to the current view with {InertiaJb.bind_to_view} so
12
+ # that a lazy prop's block runs in the *same* context an eager prop would —
13
+ # see that method for why this matters.
10
14
  module Helper
11
15
  # Only fetched when explicitly requested in a partial reload.
12
16
  def optional(&block)
13
- ::InertiaRails.optional(&block)
17
+ ::InertiaRails.optional(&InertiaJb.bind_to_view(self, block))
14
18
  end
15
19
 
16
20
  # Always fetched, even when not requested in a partial reload.
17
21
  def always(&block)
18
- ::InertiaRails.always(&block)
22
+ ::InertiaRails.always(&InertiaJb.bind_to_view(self, block))
19
23
  end
20
24
 
21
25
  # Excluded from the initial load; fetched in a follow-up request.
22
- def defer(...)
23
- ::InertiaRails.defer(...)
26
+ def defer(*args, **kwargs, &block)
27
+ ::InertiaRails.defer(*args, **kwargs, &InertiaJb.bind_to_view(self, block))
24
28
  end
25
29
 
26
30
  # Infinite-scroll prop (accepts a paginator or explicit metadata).
27
- def scroll(...)
28
- ::InertiaRails.scroll(...)
31
+ def scroll(*args, **kwargs, &block)
32
+ ::InertiaRails.scroll(*args, **kwargs, &InertiaJb.bind_to_view(self, block))
29
33
  end
30
34
 
31
35
  # Merged with existing client-side data instead of replacing it.
32
- def merge(...)
33
- ::InertiaRails.merge(...)
36
+ def merge(*args, **kwargs, &block)
37
+ ::InertiaRails.merge(*args, **kwargs, &InertiaJb.bind_to_view(self, block))
34
38
  end
35
39
 
36
40
  # Deep-merged with existing client-side data.
37
- def deep_merge(...)
38
- ::InertiaRails.deep_merge(...)
41
+ def deep_merge(*args, **kwargs, &block)
42
+ ::InertiaRails.deep_merge(*args, **kwargs, &InertiaJb.bind_to_view(self, block))
43
+ end
44
+
45
+ # Sent once and cached client-side; skipped on later visits unless reset or expired.
46
+ def once(*args, **kwargs, &block)
47
+ ::InertiaRails.once(*args, **kwargs, &InertiaJb.bind_to_view(self, block))
48
+ end
49
+
50
+ # Server-side cached via Rails.cache; the block's result is stored as JSON
51
+ # and reused on subsequent requests until the cache expires.
52
+ def cache(*args, **kwargs, &block)
53
+ ::InertiaRails.cache(*args, **kwargs, &InertiaJb.bind_to_view(self, block))
39
54
  end
40
55
  end
56
+
57
+ # Binds a lazy-prop block to the view that declared it, so the block evaluates
58
+ # in the same view context as an eager prop.
59
+ #
60
+ # Eager props are evaluated while the +.inertia+ template renders, so their
61
+ # +render(partial: ...)+ is the *view* helper — it returns jb data and never
62
+ # touches +response_body+. Lazy props (+defer+/+optional+/+always+/...) are
63
+ # different: inertia-rails resolves them later via
64
+ # +controller.instance_exec(&block)+, i.e. in *controller* context, where the
65
+ # same +render(partial: ...)+ binds to +ActionController+'s +render+ and
66
+ # assigns +response_body+. inertia-rails then performs its own
67
+ # +render(json:)+, and the already-set +response_body+ raises
68
+ # +AbstractController::DoubleRenderError+.
69
+ #
70
+ # Wrapping the block in +view.instance_exec+ keeps every block — eager or
71
+ # lazy — running against the view, so +render+ behaves identically everywhere.
72
+ # Controller-exposed helpers still work in the view (e.g. +policy_scope+,
73
+ # +current_user+ via Pundit/Devise +helper_method+s); the only controller-only
74
+ # call that does not carry over is +render_to_string+ — use +render(partial:,
75
+ # formats: [:html])+ instead, which returns the same String in a view.
76
+ def self.bind_to_view(view, block)
77
+ return block if block.nil?
78
+
79
+ proc { view.instance_exec(&block) }
80
+ end
41
81
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module InertiaJb
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.1"
5
5
  end
@@ -29,11 +29,24 @@ 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
37
+
38
+ # lazy props (defer/optional) whose block renders a jb partial.
39
+ def lazy_partial
40
+ @author = { id: 42, name: "John" }
41
+ @comments = [{ body: "first" }, { body: "second" }]
42
+ end
32
43
  end
33
44
 
34
45
  class ControllerTest < ActionController::TestCase
35
46
  tests TestController
36
47
 
48
+ include ActiveSupport::Testing::TimeHelpers
49
+
37
50
  TEMPLATES = {
38
51
  "layouts/application.html.erb" => "<html><body><%= yield %></body></html>",
39
52
  "test/index.html.inertia" => "{ content: 'content' }",
@@ -45,7 +58,16 @@ class ControllerTest < ActionController::TestCase
45
58
  "authors/_author.html.jb" => "{ id: author[:id], name: author[:name] }",
46
59
  "comments/_comment.html.jb" => "{ body: comment[:body] }",
47
60
  "test/special.html.inertia" =>
48
- "{ id: @id, stats: optional { { visits: 42 } }, feed: defer(group: :feed) { [1, 2, 3] } }"
61
+ "{ id: @id, stats: optional { { visits: 42 } }, feed: defer(group: :feed) { [1, 2, 3] } }",
62
+ "test/cached.html.inertia" =>
63
+ "{ flash: once { 'hello' }, " \
64
+ "report: cache('report', expires_in: 1.minute) { Time.current.to_f } }",
65
+ # A deferred/optional prop whose block renders a jb partial. The block is
66
+ # resolved by inertia-rails in controller context, so a bare `render` there
67
+ # would set response_body and collide with inertia's own render(json:).
68
+ "test/lazy_partial.html.inertia" =>
69
+ "{ deferredAuthor: defer { render(partial: 'authors/author', object: @author) }, " \
70
+ "optionalComments: optional { render(partial: 'comments/comment', collection: @comments) } }"
49
71
  }.freeze
50
72
 
51
73
  def setup
@@ -53,7 +75,7 @@ class ControllerTest < ActionController::TestCase
53
75
 
54
76
  @routes = ActionDispatch::Routing::RouteSet.new
55
77
  @routes.draw do
56
- %i[index nested collection with_partial special].each do |action|
78
+ %i[index nested collection with_partial special cached lazy_partial].each do |action|
57
79
  get action.to_s => "test##{action}"
58
80
  end
59
81
  end
@@ -65,6 +87,7 @@ class ControllerTest < ActionController::TestCase
65
87
  def teardown
66
88
  super
67
89
  @routes.clear!
90
+ Rails.cache.clear
68
91
  end
69
92
 
70
93
  # ---- basic rendering -----------------------------------------------------
@@ -158,6 +181,79 @@ class ControllerTest < ActionController::TestCase
158
181
  assert_equal({ "feed" => ["feed"] }, page["deferredProps"])
159
182
  end
160
183
 
184
+ # ---- once props (client-cached) -----------------------------------------
185
+
186
+ def test_once_prop_included_on_first_visit
187
+ inertia_get :cached
188
+ page = JSON.parse(response.body)
189
+
190
+ assert_equal "hello", page.dig("props", "flash")
191
+ assert_equal({ "flash" => { "prop" => "flash" } }, page["onceProps"])
192
+ end
193
+
194
+ def test_once_prop_excluded_when_client_reports_it_cached
195
+ inertia_get :cached,
196
+ headers: {
197
+ "X-Inertia-Partial-Component" => "test/cached",
198
+ "X-Inertia-Except-Once-Props" => "flash"
199
+ }
200
+ page = JSON.parse(response.body)
201
+
202
+ refute page["props"].key?("flash"), "once prop should be absent when client reports it cached"
203
+ end
204
+
205
+ # ---- lazy props that render jb partials ---------------------------------
206
+
207
+ def test_deferred_prop_rendering_jb_partial_does_not_double_render
208
+ inertia_get :lazy_partial,
209
+ headers: {
210
+ "X-Inertia-Partial-Component" => "test/lazy_partial",
211
+ "X-Inertia-Partial-Data" => "deferredAuthor"
212
+ }
213
+
214
+ assert_response :success
215
+ page = JSON.parse(response.body)
216
+ assert_equal({ "id" => 42, "name" => "John" }, page.dig("props", "deferredAuthor"))
217
+ end
218
+
219
+ def test_optional_prop_rendering_jb_partial_does_not_double_render
220
+ inertia_get :lazy_partial,
221
+ headers: {
222
+ "X-Inertia-Partial-Component" => "test/lazy_partial",
223
+ "X-Inertia-Partial-Data" => "optionalComments"
224
+ }
225
+
226
+ assert_response :success
227
+ page = JSON.parse(response.body)
228
+ assert_equal(
229
+ [{ "body" => "first" }, { "body" => "second" }],
230
+ page.dig("props", "optionalComments")
231
+ )
232
+ end
233
+
234
+ # ---- cached props (server-cached) ---------------------------------------
235
+
236
+ def test_cached_prop_serves_same_value_across_requests
237
+ inertia_get :cached
238
+ first = JSON.parse(response.body).dig("props", "report")
239
+
240
+ inertia_get :cached
241
+ second = JSON.parse(response.body).dig("props", "report")
242
+
243
+ assert_equal first, second, "cached prop should return the same value across requests"
244
+ end
245
+
246
+ def test_cached_prop_recomputes_after_expiry
247
+ inertia_get :cached
248
+ first = JSON.parse(response.body).dig("props", "report")
249
+
250
+ travel_to 2.minutes.from_now do
251
+ inertia_get :cached
252
+ second = JSON.parse(response.body).dig("props", "report")
253
+ refute_equal first, second, "cached prop should recompute after expiry"
254
+ end
255
+ end
256
+
161
257
  private
162
258
 
163
259
  def inertia_get(action, headers: {})
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.2.0
4
+ version: 0.3.1
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-23 00:00:00.000000000 Z
11
+ date: 2026-07-25 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: inertia_rails