Skip to content

Commit 936e9b4

Browse files
committed
Support InertiaRails.once prop
1 parent 3d7695c commit 936e9b4

File tree

13 files changed

+721
-16
lines changed

13 files changed

+721
-16
lines changed

lib/inertia_rails/base_prop.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
module InertiaRails
44
# Base class for all props.
55
class BaseProp
6-
def initialize(&block)
6+
def initialize(**, &block)
77
@block = block
88
end
99

lib/inertia_rails/defer_prop.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
module InertiaRails
44
class DeferProp < IgnoreOnFirstLoadProp
5+
prepend PropOnceable
56
prepend PropMergeable
67

78
DEFAULT_GROUP = 'default'

lib/inertia_rails/inertia_rails.rb

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# frozen_string_literal: true
22

3+
require 'inertia_rails/prop_onceable'
34
require 'inertia_rails/prop_mergeable'
45
require 'inertia_rails/base_prop'
56
require 'inertia_rails/ignore_on_first_load_prop'
@@ -8,6 +9,7 @@
89
require 'inertia_rails/optional_prop'
910
require 'inertia_rails/defer_prop'
1011
require 'inertia_rails/merge_prop'
12+
require 'inertia_rails/once_prop'
1113
require 'inertia_rails/scroll_prop'
1214
require 'inertia_rails/configuration'
1315
require 'inertia_rails/meta_tag'
@@ -36,6 +38,10 @@ def always(&block)
3638
AlwaysProp.new(&block)
3739
end
3840

41+
def once(...)
42+
OnceProp.new(...)
43+
end
44+
3945
def merge(...)
4046
MergeProp.new(...)
4147
end
@@ -44,8 +50,8 @@ def deep_merge(match_on: nil, &block)
4450
MergeProp.new(deep_merge: true, match_on: match_on, &block)
4551
end
4652

47-
def defer(group: nil, merge: nil, deep_merge: nil, match_on: nil, &block)
48-
DeferProp.new(group: group, merge: merge, deep_merge: deep_merge, match_on: match_on, &block)
53+
def defer(...)
54+
DeferProp.new(...)
4955
end
5056

5157
def scroll(metadata = nil, **options, &block)

lib/inertia_rails/merge_prop.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
module InertiaRails
44
class MergeProp < BaseProp
5+
prepend PropOnceable
56
prepend PropMergeable
67

78
def initialize(**_props, &block)

lib/inertia_rails/once_prop.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# frozen_string_literal: true
2+
3+
module InertiaRails
4+
class OnceProp < BaseProp
5+
prepend PropOnceable
6+
7+
def initialize(**, &block)
8+
@once = true
9+
super(&block)
10+
end
11+
end
12+
end

lib/inertia_rails/optional_prop.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@
22

33
module InertiaRails
44
class OptionalProp < IgnoreOnFirstLoadProp
5+
prepend PropOnceable
56
end
67
end

lib/inertia_rails/prop_onceable.rb

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# frozen_string_literal: true
2+
3+
module InertiaRails
4+
module PropOnceable
5+
attr_reader :once_key, :once_expires_in
6+
7+
def initialize(**props, &block)
8+
@once = props.fetch(:once, false)
9+
@once_key = props[:key]
10+
@once_expires_in = props[:expires_in]
11+
@fresh = props.fetch(:fresh, false)
12+
13+
super
14+
end
15+
16+
def once?
17+
@once
18+
end
19+
20+
def fresh?
21+
@fresh
22+
end
23+
24+
def expires_at
25+
return nil unless @once_expires_in
26+
27+
timestamp = case @once_expires_in
28+
when ActiveSupport::Duration
29+
(Time.current + @once_expires_in).to_f
30+
when Numeric
31+
Time.current.to_f + @once_expires_in
32+
else
33+
raise ArgumentError, "Invalid `expires_in` value: #{@once_expires_in.inspect}"
34+
end
35+
36+
(timestamp * 1000).to_i
37+
end
38+
end
39+
end

lib/inertia_rails/renderer.rb

Lines changed: 52 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ def render
4848
else
4949
"#{@response.headers['Vary']}, X-Inertia"
5050
end
51-
if @request.headers['X-Inertia']
51+
if @request.inertia?
5252
@response.set_header('X-Inertia', 'true')
5353
@render_method.call json: page.to_json, status: @response.status, content_type: Mime[:json]
5454
else
@@ -97,15 +97,17 @@ def merge_props(shared_props, props, deep_merge)
9797
def computed_props
9898
# rubocop:disable Style/MultilineBlockChain
9999
@props
100-
.tap do |merged_props| # Always keep errors in the props
101-
if merged_props.key?(:errors) && !merged_props[:errors].is_a?(BaseProp)
102-
errors = merged_props[:errors]
103-
merged_props[:errors] = InertiaRails.always { errors }
104-
end
100+
.tap do |merged_props|
101+
# Always keep errors in the props
102+
if merged_props.key?(:errors) && !merged_props[:errors].is_a?(BaseProp)
103+
errors = merged_props[:errors]
104+
merged_props[:errors] = InertiaRails.always { errors }
105105
end
106+
end
106107
.then { |props| deep_transform_props(props) } # Internal hydration/filtering
107108
.then { |props| @configuration.prop_transformer(props: props) } # Apply user-defined prop transformer
108-
.tap do |props| # Add meta tags last (never transformed)
109+
.tap do |props|
110+
# Add meta tags last (never transformed)
109111
props[:_inertia_meta] = meta_tags if meta_tags.present?
110112
end
111113
# rubocop:enable Style/MultilineBlockChain
@@ -128,6 +130,9 @@ def page
128130
@page[:scrollProps] = scroll_props if scroll_props.present?
129131
@page.merge!(resolve_merge_props)
130132

133+
once_props = resolve_once_props
134+
@page[:onceProps] = once_props if once_props.present?
135+
131136
@page
132137
end
133138

@@ -173,6 +178,17 @@ def resolve_merge_props
173178
}.delete_if { |_, v| v.blank? }
174179
end
175180

181+
def resolve_once_props
182+
@props.each_with_object({}) do |(key, prop), result|
183+
next unless prop.try(:once?)
184+
next if excluded_by_partial_request?([key.to_s])
185+
186+
once_key = (prop.once_key || key).to_s
187+
188+
result[once_key] = { prop: key.to_s, expiresAt: prop.expires_at }.compact
189+
end
190+
end
191+
176192
def resolve_match_on_props
177193
all_merge_props.filter_map do |key, prop|
178194
prop.match_on.map! { |ms| "#{key}.#{ms}" } if prop.match_on.present?
@@ -251,6 +267,10 @@ def partial_except_keys
251267
@partial_except_keys ||= (@request.headers['X-Inertia-Partial-Except'] || '').split(',').compact_blank!
252268
end
253269

270+
def except_once_keys
271+
@except_once_keys ||= (@request.headers['X-Inertia-Except-Once-Props'] || '').split(',').compact_blank!
272+
end
273+
254274
def rendering_partial_component?
255275
@request.headers['X-Inertia-Partial-Component'] == @component
256276
end
@@ -265,19 +285,38 @@ def resolve_component(component)
265285

266286
def keep_prop?(prop, path)
267287
return true if prop.is_a?(AlwaysProp)
268-
269-
if rendering_partial_component? && (partial_keys.present? || partial_except_keys.present?)
270-
path_with_prefixes = path_prefixes(path)
271-
return false if excluded_by_only_partial_keys?(path_with_prefixes)
272-
return false if excluded_by_except_partial_keys?(path_with_prefixes)
273-
end
288+
return false if excluded_by_once_cache?(prop, path)
289+
return false if excluded_by_partial_request?(path)
274290

275291
# Precedence: Evaluate IgnoreOnFirstLoadProp only after partial keys have been checked
276292
return false if prop.is_a?(IgnoreOnFirstLoadProp) && !rendering_partial_component?
277293

278294
true
279295
end
280296

297+
def excluded_by_once_cache?(prop, path)
298+
return false unless prop.try(:once?)
299+
return false if prop.try(:fresh?)
300+
return false if explicitly_requested?(path)
301+
302+
once_key = (prop.once_key || path.join('.')).to_s
303+
except_once_keys.include?(once_key)
304+
end
305+
306+
def explicitly_requested?(path)
307+
return false unless rendering_partial_component? && partial_keys.present?
308+
309+
path_with_prefixes = path_prefixes(path)
310+
(path_with_prefixes & partial_keys).any?
311+
end
312+
313+
def excluded_by_partial_request?(path)
314+
return false unless rendering_partial_component? && (partial_keys.present? || partial_except_keys.present?)
315+
316+
path_with_prefixes = path_prefixes(path)
317+
excluded_by_only_partial_keys?(path_with_prefixes) || excluded_by_except_partial_keys?(path_with_prefixes)
318+
end
319+
281320
def path_prefixes(parts)
282321
(0...parts.length).map do |i|
283322
parts[0..i].join('.')

spec/dummy/app/controllers/inertia_render_test_controller.rb

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,4 +189,80 @@ def reset_test
189189
regular_prop: 'regular value',
190190
}
191191
end
192+
193+
def once_props
194+
render inertia: 'TestComponent', props: {
195+
cached_data: InertiaRails.once { 'expensive data' },
196+
regular: 'regular prop',
197+
}
198+
end
199+
200+
def once_props_with_expires_in
201+
render inertia: 'TestComponent', props: {
202+
cached_data: InertiaRails.once(expires_in: 1.hour) { 'expensive data with expiration' },
203+
}
204+
end
205+
206+
def once_props_with_custom_key
207+
render inertia: 'TestComponent', props: {
208+
cached_data: InertiaRails.once(key: 'my_custom_key') { 'expensive data with custom key' },
209+
}
210+
end
211+
212+
def deferred_once_props
213+
render inertia: 'TestComponent', props: {
214+
name: 'Brian',
215+
deferred_cached: InertiaRails.defer(once: true) { 'deferred and cached' },
216+
}
217+
end
218+
219+
inertia_share only: [:shared_once_props] do
220+
{
221+
shared_cached: InertiaRails.once { 'shared once data' },
222+
}
223+
end
224+
225+
def shared_once_props
226+
render inertia: 'TestComponent', props: {
227+
name: 'Brian',
228+
}
229+
end
230+
231+
def nested_once_props
232+
render inertia: 'TestComponent', props: {
233+
nested: {
234+
cached: InertiaRails.once { 'nested cached data' },
235+
},
236+
regular: 'regular prop',
237+
}
238+
end
239+
240+
def multiple_once_props
241+
render inertia: 'TestComponent', props: {
242+
cached_one: InertiaRails.once { 'first cached' },
243+
cached_two: InertiaRails.once { 'second cached' },
244+
regular: 'regular prop',
245+
}
246+
end
247+
248+
def once_props_not_fresh
249+
render inertia: 'TestComponent', props: {
250+
cached_data: InertiaRails::OnceProp.new { 'cached data' },
251+
regular: 'regular prop',
252+
}
253+
end
254+
255+
def once_props_fresh
256+
render inertia: 'TestComponent', props: {
257+
cached_data: InertiaRails::OnceProp.new(fresh: true) { 'fresh data' },
258+
regular: 'regular prop',
259+
}
260+
end
261+
262+
def once_props_fresh_and_non_fresh
263+
render inertia: 'TestComponent', props: {
264+
foo: InertiaRails::OnceProp.new(fresh: true) { 'bar' },
265+
baz: InertiaRails::OnceProp.new { 'qux' },
266+
}
267+
end
192268
end

spec/dummy/config/routes.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,16 @@
4444
get 'prepend_merge_test' => 'inertia_render_test#prepend_merge_test'
4545
get 'nested_paths_test' => 'inertia_render_test#nested_paths_test'
4646
get 'reset_test' => 'inertia_render_test#reset_test'
47+
get 'once_props' => 'inertia_render_test#once_props'
48+
get 'once_props_with_expires_in' => 'inertia_render_test#once_props_with_expires_in'
49+
get 'once_props_with_custom_key' => 'inertia_render_test#once_props_with_custom_key'
50+
get 'deferred_once_props' => 'inertia_render_test#deferred_once_props'
51+
get 'shared_once_props' => 'inertia_render_test#shared_once_props'
52+
get 'nested_once_props' => 'inertia_render_test#nested_once_props'
53+
get 'multiple_once_props' => 'inertia_render_test#multiple_once_props'
54+
get 'once_props_not_fresh' => 'inertia_render_test#once_props_not_fresh'
55+
get 'once_props_fresh' => 'inertia_render_test#once_props_fresh'
56+
get 'once_props_fresh_and_non_fresh' => 'inertia_render_test#once_props_fresh_and_non_fresh'
4757
get 'non_inertiafied' => 'inertia_test#non_inertiafied'
4858
get 'deeply_nested_props' => 'inertia_render_test#deeply_nested_props'
4959

0 commit comments

Comments
 (0)