Skip to content

Commit e11e8c5

Browse files
authored
Merge pull request #163 from inertiajs/dot-notation-for-only-props
Support dot notation for :only keys in partial reloads
2 parents 602394f + 2027984 commit e11e8c5

File tree

5 files changed

+272
-24
lines changed

5 files changed

+272
-24
lines changed

docs/guide/partial-reloads.md

+49-1
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ router.visit(url, {
4646

4747
## Except certain props
4848

49+
In addition to the only visit option you can also use the except option to specify which data the server should exclude. This option should also be an array of keys which correspond to the keys of the props.
50+
4951
:::tabs key:frameworks
5052
== Vue
5153

@@ -79,7 +81,53 @@ router.visit(url, {
7981

8082
:::
8183

82-
In addition to the only visit option you can also use the except option to specify which data the server should exclude. This option should also be an array of keys which correspond to the keys of the props.
84+
## Dot notation
85+
86+
Both the `only` and `except` visit options support dot notation to specify nested data, and they can be used together. In the following example, only `settings.theme` will be rendered, but without its `colors` property.
87+
88+
:::tabs key:frameworks
89+
== Vue
90+
91+
```js
92+
import { router } from '@inertiajs/vue3'
93+
94+
router.visit(url, {
95+
only: ['settings.theme'],
96+
except: ['setting.theme.colors'],
97+
})
98+
```
99+
100+
== React
101+
102+
```jsx
103+
import { router } from '@inertiajs/react'
104+
105+
router.visit(url, {
106+
only: ['settings.theme'],
107+
except: ['setting.theme.colors'],
108+
})
109+
```
110+
111+
== Svelte 4|Svelte 5
112+
113+
```js
114+
import { router } from '@inertiajs/svelte'
115+
116+
router.visit(url, {
117+
only: ['settings.theme'],
118+
except: ['setting.theme.colors'],
119+
})
120+
```
121+
122+
:::
123+
124+
Please remember that, by design, partial reloading filters props _before_ they are evaluated, so it can only target explicitly defined prop keys. Let's say you have this prop:
125+
126+
`users: -> { User.all }`
127+
128+
Requesting `only: ['users.name']` will exclude the entire `users` prop, since `users.name` is not available before evaluating the prop.
129+
130+
Requesting `except: ['users.name']` will not exclude anything.
83131

84132
## Router shorthand
85133

lib/inertia_rails/renderer.rb

+51-23
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66

77
module InertiaRails
88
class Renderer
9+
KEEP_PROP = :keep
10+
DONT_KEEP_PROP = :dont_keep
11+
912
attr_reader(
1013
:component,
1114
:configuration,
@@ -74,25 +77,21 @@ def merge_props(shared_data, props)
7477
end
7578

7679
def computed_props
77-
_props = merge_props(shared_data, props).select do |key, prop|
78-
if rendering_partial_component?
79-
partial_keys.none? || key.in?(partial_keys) || prop.is_a?(AlwaysProp)
80-
else
81-
!prop.is_a?(LazyProp)
82-
end
83-
end
80+
_props = merge_props(shared_data, props)
8481

85-
drop_partial_except_keys(_props) if rendering_partial_component?
82+
deep_transform_props _props do |prop, path|
83+
next [DONT_KEEP_PROP] unless keep_prop?(prop, path)
8684

87-
deep_transform_values _props do |prop|
88-
case prop
85+
transformed_prop = case prop
8986
when BaseProp
9087
prop.call(controller)
9188
when Proc
9289
controller.instance_exec(&prop)
9390
else
9491
prop
9592
end
93+
94+
[KEEP_PROP, transformed_prop]
9695
end
9796
end
9897

@@ -105,28 +104,28 @@ def page
105104
}
106105
end
107106

108-
def deep_transform_values(hash, &block)
109-
return block.call(hash) unless hash.is_a? Hash
107+
def deep_transform_props(props, parent_path = [], &block)
108+
props.reduce({}) do |transformed_props, (key, prop)|
109+
current_path = parent_path + [key]
110110

111-
hash.transform_values {|value| deep_transform_values(value, &block)}
112-
end
113-
114-
def drop_partial_except_keys(hash)
115-
partial_except_keys.each do |key|
116-
parts = key.to_s.split('.').map(&:to_sym)
117-
*initial_keys, last_key = parts
118-
current = initial_keys.any? ? hash.dig(*initial_keys) : hash
111+
if prop.is_a?(Hash) && prop.any?
112+
nested = deep_transform_props(prop, current_path, &block)
113+
transformed_props.merge!(key => nested) unless nested.empty?
114+
else
115+
action, transformed_prop = block.call(prop, current_path)
116+
transformed_props.merge!(key => transformed_prop) if action == KEEP_PROP
117+
end
119118

120-
current.delete(last_key) if current.is_a?(Hash) && !current[last_key].is_a?(AlwaysProp)
119+
transformed_props
121120
end
122121
end
123122

124123
def partial_keys
125-
(@request.headers['X-Inertia-Partial-Data'] || '').split(',').compact.map(&:to_sym)
124+
(@request.headers['X-Inertia-Partial-Data'] || '').split(',').compact
126125
end
127126

128127
def partial_except_keys
129-
(@request.headers['X-Inertia-Partial-Except'] || '').split(',').filter_map(&:to_sym)
128+
(@request.headers['X-Inertia-Partial-Except'] || '').split(',').compact
130129
end
131130

132131
def rendering_partial_component?
@@ -138,5 +137,34 @@ def resolve_component(component)
138137

139138
configuration.component_path_resolver(path: controller.controller_path, action: controller.action_name)
140139
end
140+
141+
def keep_prop?(prop, path)
142+
return true if prop.is_a?(AlwaysProp)
143+
144+
if rendering_partial_component?
145+
path_with_prefixes = path_prefixes(path)
146+
return false if excluded_by_only_partial_keys?(path_with_prefixes)
147+
return false if excluded_by_except_partial_keys?(path_with_prefixes)
148+
end
149+
150+
# Precedence: Evaluate LazyProp only after partial keys have been checked
151+
return false if prop.is_a?(LazyProp) && !rendering_partial_component?
152+
153+
true
154+
end
155+
156+
def path_prefixes(parts)
157+
(0...parts.length).map do |i|
158+
parts[0..i].join('.')
159+
end
160+
end
161+
162+
def excluded_by_only_partial_keys?(path_with_prefixes)
163+
partial_keys.present? && (path_with_prefixes & partial_keys).empty?
164+
end
165+
166+
def excluded_by_except_partial_keys?(path_with_prefixes)
167+
partial_except_keys.present? && (path_with_prefixes & partial_except_keys).any?
168+
end
141169
end
142170
end

spec/dummy/app/controllers/inertia_render_test_controller.rb

+31
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,37 @@ def except_props
2424
}
2525
end
2626

27+
def deeply_nested_props
28+
render inertia: 'TestComponent', props: {
29+
flat: 'flat param',
30+
lazy: InertiaRails.lazy('lazy param'),
31+
nested_lazy: InertiaRails.lazy do
32+
{
33+
first: 'first nested lazy param',
34+
}
35+
end,
36+
nested: {
37+
first: 'first nested param',
38+
second: 'second nested param',
39+
evaluated: -> do
40+
{
41+
first: 'first evaluated nested param',
42+
second: 'second evaluated nested param'
43+
}
44+
end,
45+
deeply_nested: {
46+
first: 'first deeply nested param',
47+
second: false,
48+
what_about_nil: nil,
49+
what_about_empty_hash: {},
50+
deeply_nested_always: InertiaRails.always { 'deeply nested always prop' },
51+
deeply_nested_lazy: InertiaRails.lazy { 'deeply nested lazy prop' }
52+
}
53+
},
54+
always: InertiaRails.always { 'always prop' }
55+
}
56+
end
57+
2758
def view_data
2859
render inertia: 'TestComponent', view_data: {
2960
name: 'Brian',

spec/dummy/config/routes.rb

+1
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
get 'always_props' => 'inertia_render_test#always_props'
3434
get 'except_props' => 'inertia_render_test#except_props'
3535
get 'non_inertiafied' => 'inertia_test#non_inertiafied'
36+
get 'deeply_nested_props' => 'inertia_render_test#deeply_nested_props'
3637

3738
get 'instance_props_test' => 'inertia_rails_mimic#instance_props_test'
3839
get 'default_render_test' => 'inertia_rails_mimic#default_render_test'

spec/inertia/rendering_spec.rb

+140
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,146 @@
111111
is_expected.to include('Brandon')
112112
end
113113
end
114+
115+
context 'with dot notation' do
116+
let(:headers) do
117+
{
118+
'X-Inertia' => true,
119+
'X-Inertia-Partial-Data' => 'nested.first,nested.deeply_nested.second,nested.deeply_nested.what_about_nil,nested.deeply_nested.what_about_empty_hash',
120+
'X-Inertia-Partial-Component' => 'TestComponent',
121+
}
122+
end
123+
124+
before { get deeply_nested_props_path, headers: headers }
125+
126+
it 'only renders the dot notated props' do
127+
expect(response.parsed_body['props']).to eq(
128+
'always' => 'always prop',
129+
'nested' => {
130+
'first' => 'first nested param',
131+
'deeply_nested' => {
132+
'second' => false,
133+
'what_about_nil' => nil,
134+
'what_about_empty_hash' => {},
135+
'deeply_nested_always' => 'deeply nested always prop',
136+
},
137+
},
138+
)
139+
end
140+
end
141+
142+
context 'with both partial and except dot notation' do
143+
let(:headers) do
144+
{
145+
'X-Inertia' => true,
146+
'X-Inertia-Partial-Component' => 'TestComponent',
147+
'X-Inertia-Partial-Data' => 'lazy,nested.deeply_nested',
148+
'X-Inertia-Partial-Except' => 'nested.deeply_nested.first',
149+
}
150+
end
151+
152+
before { get deeply_nested_props_path, headers: headers }
153+
154+
it 'renders the partial data and excludes the excepted data' do
155+
expect(response.parsed_body['props']).to eq(
156+
'always' => 'always prop',
157+
'lazy' => 'lazy param',
158+
'nested' => {
159+
'deeply_nested' => {
160+
'second' => false,
161+
'what_about_nil' => nil,
162+
'what_about_empty_hash' => {},
163+
'deeply_nested_always' => 'deeply nested always prop',
164+
'deeply_nested_lazy' => 'deeply nested lazy prop',
165+
},
166+
},
167+
)
168+
end
169+
end
170+
171+
context 'with nonsensical partial data that includes and excludes the same prop and tries to exclude an always prop' do
172+
let(:headers) do
173+
{
174+
'X-Inertia' => true,
175+
'X-Inertia-Partial-Component' => 'TestComponent',
176+
'X-Inertia-Partial-Data' => 'lazy',
177+
'X-Inertia-Partial-Except' => 'lazy,always',
178+
}
179+
end
180+
181+
before { get deeply_nested_props_path, headers: headers }
182+
183+
it 'excludes everything but Always props' do
184+
expect(response.parsed_body['props']).to eq(
185+
'always' => 'always prop',
186+
'nested' => {
187+
'deeply_nested' => {
188+
'deeply_nested_always' => 'deeply nested always prop',
189+
},
190+
},
191+
)
192+
end
193+
end
194+
195+
context 'with only props that target transformed data' do
196+
let(:headers) do
197+
{
198+
'X-Inertia' => true,
199+
'X-Inertia-Partial-Component' => 'TestComponent',
200+
'X-Inertia-Partial-Data' => 'nested.evaluated.first',
201+
}
202+
end
203+
204+
before { get deeply_nested_props_path, headers: headers }
205+
206+
it 'filters out the entire evaluated prop' do
207+
expect(response.parsed_body['props']).to eq(
208+
'always' => 'always prop',
209+
'nested' => {
210+
'deeply_nested' => {
211+
'deeply_nested_always' => 'deeply nested always prop',
212+
},
213+
},
214+
)
215+
end
216+
end
217+
218+
context 'with except props that target transformed data' do
219+
let(:headers) do
220+
{
221+
'X-Inertia' => true,
222+
'X-Inertia-Partial-Component' => 'TestComponent',
223+
'X-Inertia-Partial-Except' => 'nested.evaluated.first',
224+
}
225+
end
226+
227+
before { get deeply_nested_props_path, headers: headers }
228+
229+
it 'renders the entire evaluated prop' do
230+
expect(response.parsed_body['props']).to eq(
231+
'always' => 'always prop',
232+
'flat' => 'flat param',
233+
'lazy' => 'lazy param',
234+
'nested_lazy' => { 'first' => 'first nested lazy param' },
235+
'nested' => {
236+
'first' => 'first nested param',
237+
'second' => 'second nested param',
238+
'evaluated' => {
239+
'first' => 'first evaluated nested param',
240+
'second' => 'second evaluated nested param',
241+
},
242+
'deeply_nested' => {
243+
'first' => 'first deeply nested param',
244+
'second' => false,
245+
'what_about_nil' => nil,
246+
'what_about_empty_hash' => {},
247+
'deeply_nested_always' => 'deeply nested always prop',
248+
'deeply_nested_lazy' => 'deeply nested lazy prop',
249+
},
250+
},
251+
)
252+
end
253+
end
114254
end
115255

116256
context 'partial except rendering' do

0 commit comments

Comments
 (0)