inertia_jb 0.2.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: 73afc6da5300453502fb918b78a802c92401b8dbe91fa6307ed4523a664c9716
4
- data.tar.gz: 0fb62ff6422c754317d18f6ddcf49cb46982822a931f72db2773999117ea9652
3
+ metadata.gz: c821eb8ccc74579d17f768744858bb9a14f7838201dd2ff89be61f8f70298780
4
+ data.tar.gz: 4e385aefbe3adcdcd6069b422382cd91cc7cbe208f2275230433110a761a4d3d
5
5
  SHA512:
6
- metadata.gz: cf13cab4f4de7bf9973354d12b590e58473c873a0eb07a84ea14b28a87d32e1ef6c57310c7255f4b7c92e828b66463e12d5d67c25f55c9cb12d558ac8e1d6970
7
- data.tar.gz: '08e77238e1754dcdd0e2e74df5e984d38bab7c922ea9fa755495e09b50c056ef674eff9df8ea26dd0db3d91eca09ab1973b4744129da93064f691fccae40c4e9'
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
 
@@ -95,31 +92,6 @@ a layout; Inertia (XHR) visits always return a bare JSON body with no layout.
95
92
  The layout is chosen from inertia-rails' `config.layout`, matching
96
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
 
@@ -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
@@ -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.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: {})
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.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-23 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