Skip to content

Commit 5b74c17

Browse files
authored
Merge pull request #234 from skryukov/merge-strategies
Add smart merging with `match_on` option
2 parents 79f6a35 + 5834995 commit 5b74c17

File tree

10 files changed

+111
-34
lines changed

10 files changed

+111
-34
lines changed

docs/guide/deferred-props.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ end
3939

4040
In the example above, the `teams`, `projects`, and `tasks` props will be fetched in one request, while the `permissions` prop will be fetched in a separate request in parallel. Group names are arbitrary strings and can be anything you choose.
4141

42+
### Combining with mergeable props
43+
44+
Deferred props can be combined with mergeable props. You can learn more about this feature in the [Merging props](/guide/merging-props) section.
45+
4246
## Client side
4347

4448
On the client side, Inertia provides the `Deferred` component to help you manage deferred props. This component will automatically wait for the specified deferred props to be available before rendering its children.

docs/guide/merging-props.md

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,15 @@ class UsersController < ApplicationController
2626
records: records.as_json(...),
2727
pagy: pagy_metadata(pagy)
2828
}
29+
},
30+
# with match_on parameter for smart merging:
31+
products: InertiaRails.merge(match_on: 'id') { Product.all.as_json(...) },
32+
# nested objects with match_on:
33+
categories: InertiaRails.deep_merge(match_on: %w[items.id tags.id]) {
34+
{
35+
items: Category.all.as_json(...),
36+
tags: Tag.all.as_json(...)
37+
}
2938
}
3039
}
3140
end
@@ -34,7 +43,14 @@ end
3443

3544
On the client side, Inertia detects that this prop should be merged. If the prop returns an array, it will append the response to the current prop value. If it's an object, it will merge the response with the current prop value. If you have opted to `deepMerge`, Inertia ensures a deep merge of the entire structure.
3645

37-
**Of note:** During the merging process, if the value is an array, the incoming items will be _appended_ to the existing array, not merged by index.
46+
### Smart merging with `match_on`
47+
48+
By default, arrays are simply appended during merging. If you need to update specific items in an array or replace them based on a unique identifier, you can use the `match_on` parameter.
49+
50+
The `match_on` parameter enables smart merging by specifying a field to match on when merging arrays of objects:
51+
52+
- For `merge` with simple arrays, specify the object key to match on (e.g., `'id'`)
53+
- For `deep_merge` with nested structures, use dot notation to specify the path (e.g., `'items.id'`)
3854

3955
You can also combine [deferred props](/guide/deferred-props) with mergeable props to defer the loading of the prop and ultimately mark it as mergeable once it's loaded.
4056

@@ -54,6 +70,15 @@ class UsersController < ApplicationController
5470
records: records.as_json(...),
5571
pagy: pagy_metadata(pagy)
5672
}
73+
},
74+
# with match_on parameter:
75+
products: InertiaRails.defer(merge: true, match_on: 'id') { products.as_json(...) },
76+
# nested objects with match_on:
77+
categories: InertiaRails.defer(deep_merge: true, match_on: %w[items.id tags.id]) {
78+
{
79+
items: Category.all.as_json(...),
80+
tags: Tag.all.as_json(...)
81+
}
5782
}
5883
}
5984
end

lib/inertia_rails/defer_prop.rb

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,17 @@ module InertiaRails
44
class DeferProp < IgnoreOnFirstLoadProp
55
DEFAULT_GROUP = 'default'
66

7-
attr_reader :group
7+
attr_reader :group, :match_on
88

9-
def initialize(group: nil, merge: nil, deep_merge: nil, &block)
9+
def initialize(group: nil, merge: nil, deep_merge: nil, match_on: nil, &block)
1010
raise ArgumentError, 'Cannot set both `deep_merge` and `merge` to true' if deep_merge && merge
1111

1212
super(&block)
1313

1414
@group = group || DEFAULT_GROUP
1515
@merge = merge || deep_merge
1616
@deep_merge = deep_merge
17+
@match_on = match_on.nil? ? nil : Array(match_on)
1718
end
1819

1920
def merge?

lib/inertia_rails/generators/controller_template_base.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ class ControllerTemplateBase < Rails::Generators::NamedBase
1111
default: Helper.guess_the_default_framework
1212

1313
class_option :typescript, type: :boolean, desc: 'Whether to use TypeScript',
14-
default: Helper.guess_typescript
14+
default: Helper.uses_typescript?
1515

1616
argument :actions, type: :array, default: [], banner: 'action action'
1717

lib/inertia_rails/generators/helper.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ def guess_the_default_framework(package_json_path = DEFAULT_PACKAGE_PATH)
2323
end
2424
end
2525

26-
def guess_typescript
26+
def uses_typescript?
2727
Rails.root.join('tsconfig.json').exist?
2828
end
2929

lib/inertia_rails/inertia_rails.rb

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,16 +33,16 @@ def always(&block)
3333
AlwaysProp.new(&block)
3434
end
3535

36-
def merge(&block)
37-
MergeProp.new(&block)
36+
def merge(match_on: nil, &block)
37+
MergeProp.new(match_on: match_on, &block)
3838
end
3939

40-
def deep_merge(&block)
41-
MergeProp.new(deep_merge: true, &block)
40+
def deep_merge(match_on: nil, &block)
41+
MergeProp.new(deep_merge: true, match_on: match_on, &block)
4242
end
4343

44-
def defer(group: nil, merge: nil, deep_merge: nil, &block)
45-
DeferProp.new(group: group, merge: merge, deep_merge: deep_merge, &block)
44+
def defer(group: nil, merge: nil, deep_merge: nil, match_on: nil, &block)
45+
DeferProp.new(group: group, merge: merge, deep_merge: deep_merge, match_on: match_on, &block)
4646
end
4747
end
4848
end

lib/inertia_rails/merge_prop.rb

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@
22

33
module InertiaRails
44
class MergeProp < BaseProp
5-
def initialize(deep_merge: false, &block)
5+
attr_reader :match_on
6+
7+
def initialize(deep_merge: false, match_on: nil, &block)
68
super(&block)
79
@deep_merge = deep_merge
10+
@match_on = match_on.nil? ? nil : Array(match_on)
811
end
912

1013
def merge?

lib/inertia_rails/renderer.rb

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -106,14 +106,17 @@ def page
106106
deferred_props = deferred_props_keys
107107
default_page[:deferredProps] = deferred_props if deferred_props.present?
108108

109-
all_merge_props = merge_props_keys
110-
111-
deep_merge_props, merge_props = all_merge_props.partition do |key|
112-
@props[key].deep_merge?
109+
deep_merge_props, merge_props = all_merge_props.partition do |_key, prop|
110+
prop.deep_merge?
113111
end
114112

115-
default_page[:mergeProps] = merge_props if merge_props.present?
116-
default_page[:deepMergeProps] = deep_merge_props if deep_merge_props.present?
113+
match_props_on = all_merge_props.filter_map do |key, prop|
114+
prop.match_on.map { |ms| "#{key}.#{ms}" } if prop.match_on.present?
115+
end.flatten
116+
117+
default_page[:mergeProps] = merge_props.map(&:first) if merge_props.present?
118+
default_page[:deepMergeProps] = deep_merge_props.map(&:first) if deep_merge_props.present?
119+
default_page[:matchPropsOn] = match_props_on if match_props_on.present?
117120

118121
default_page
119122
end
@@ -147,9 +150,16 @@ def deferred_props_keys
147150
end
148151
end
149152

150-
def merge_props_keys
151-
@props.each_with_object([]) do |(key, prop), result|
152-
result << key if prop.try(:merge?) && reset_keys.exclude?(key)
153+
def all_merge_props
154+
@all_merge_props ||= @props.select do |key, prop|
155+
next unless prop.try(:merge?)
156+
next if reset_keys.include?(key)
157+
next if rendering_partial_component? && (
158+
(partial_keys.present? && partial_keys.exclude?(key.name)) ||
159+
(partial_except_keys.present? && partial_except_keys.include?(key.name))
160+
)
161+
162+
true
153163
end
154164
end
155165

@@ -180,7 +190,7 @@ def resolve_component(component)
180190
def keep_prop?(prop, path)
181191
return true if prop.is_a?(AlwaysProp)
182192

183-
if rendering_partial_component?
193+
if rendering_partial_component? && (partial_keys.present? || partial_except_keys.present?)
184194
path_with_prefixes = path_prefixes(path)
185195
return false if excluded_by_only_partial_keys?(path_with_prefixes)
186196
return false if excluded_by_except_partial_keys?(path_with_prefixes)

spec/dummy/app/controllers/inertia_render_test_controller.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,10 +106,14 @@ def always_props
106106
def merge_props
107107
render inertia: 'TestComponent', props: {
108108
merge: InertiaRails.merge { 'merge prop' },
109+
match_on: InertiaRails.merge(match_on: 'id') { [id: 1] },
109110
deep_merge: InertiaRails.deep_merge { { deep: 'merge prop' } },
111+
deep_match_on: InertiaRails.deep_merge(match_on: 'deep.id') { { deep: [id: 1] } },
110112
regular: 'regular prop',
111113
deferred_merge: InertiaRails.defer(merge: true) { 'deferred and merge prop' },
114+
deferred_match_on: InertiaRails.defer(merge: true, match_on: 'id') { [id: 1] },
112115
deferred_deep_merge: InertiaRails.defer(deep_merge: true) { { deep: 'deferred and merge prop' } },
116+
deferred_deep_match_on: InertiaRails.defer(deep_merge: true, match_on: 'deep.id') { { deep: [id: 1] } },
113117
deferred: InertiaRails.defer { 'deferred' },
114118
}
115119
end

spec/inertia/rendering_spec.rb

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -530,30 +530,37 @@
530530
before { get merge_props_path, headers: headers }
531531

532532
it 'returns non-optional props and meta on first load' do
533-
expect(response.parsed_body['props']).to eq('merge' => 'merge prop', 'deep_merge' => { 'deep' => 'merge prop' },
534-
'regular' => 'regular prop')
535-
expect(response.parsed_body['mergeProps']).to match_array(%w[merge deferred_merge])
536-
expect(response.parsed_body['deepMergeProps']).to match_array(%w[deep_merge deferred_deep_merge])
537-
expect(response.parsed_body['deferredProps']).to eq('default' => %w[deferred_merge deferred_deep_merge deferred])
533+
expect(response.parsed_body['props']).to eq(
534+
'merge' => 'merge prop', 'match_on' => [{ 'id' => 1 }],
535+
'deep_merge' => { 'deep' => 'merge prop' }, 'deep_match_on' => { 'deep' => [{ 'id' => 1 }] },
536+
'regular' => 'regular prop'
537+
)
538+
expect(response.parsed_body['mergeProps']).to match_array(%w[merge match_on deferred_merge deferred_match_on])
539+
expect(response.parsed_body['deepMergeProps']).to match_array(%w[deep_merge deep_match_on deferred_deep_merge deferred_deep_match_on])
540+
expect(response.parsed_body['deferredProps']).to eq('default' => %w[deferred_merge deferred_match_on deferred_deep_merge deferred_deep_match_on deferred])
541+
expect(response.parsed_body['matchPropsOn']).to match_array(%w[deep_match_on.deep.id deferred_deep_match_on.deep.id deferred_match_on.id match_on.id])
538542
end
539543

540544
context 'with a partial reload' do
541545
let(:headers) do
542546
{
543547
'X-Inertia' => true,
544-
'X-Inertia-Partial-Data' => 'deferred_merge,deferred_deep_merge',
548+
'X-Inertia-Partial-Data' => 'deferred_merge,deferred_deep_merge,deferred_deep_match_on,deferred_match_on',
545549
'X-Inertia-Partial-Component' => 'TestComponent',
546550
}
547551
end
548552

549-
it 'returns listed and merge props' do
553+
it 'returns listed merge props' do
550554
expect(response.parsed_body['props']).to eq(
551555
'deferred_merge' => 'deferred and merge prop',
552-
'deferred_deep_merge' => { 'deep' => 'deferred and merge prop' }
556+
'deferred_deep_merge' => { 'deep' => 'deferred and merge prop' },
557+
'deferred_deep_match_on' => { 'deep' => [{ 'id' => 1 }] },
558+
'deferred_match_on' => [{ 'id' => 1 }]
553559
)
554-
expect(response.parsed_body['mergeProps']).to match_array(%w[merge deferred_merge])
555-
expect(response.parsed_body['deepMergeProps']).to match_array(%w[deep_merge deferred_deep_merge])
560+
expect(response.parsed_body['mergeProps']).to match_array(%w[deferred_merge deferred_match_on])
561+
expect(response.parsed_body['deepMergeProps']).to match_array(%w[deferred_deep_merge deferred_deep_match_on])
556562
expect(response.parsed_body['deferredProps']).to be_nil
563+
expect(response.parsed_body['matchPropsOn']).to match_array(%w[deferred_deep_match_on.deep.id deferred_match_on.id])
557564
end
558565
end
559566

@@ -567,13 +574,36 @@
567574
}
568575
end
569576

570-
it 'returns listed and merge props' do
577+
it 'returns listed props' do
571578
expect(response.parsed_body['props']).to eq(
572579
'deferred_merge' => 'deferred and merge prop',
573580
'deferred_deep_merge' => { 'deep' => 'deferred and merge prop' }
574581
)
575-
expect(response.parsed_body['mergeProps']).to match_array(%w[merge])
582+
expect(response.parsed_body['mergeProps']).to be_nil
583+
expect(response.parsed_body['deferredProps']).to be_nil
584+
expect(response.parsed_body['matchPropsOn']).to be_nil
585+
end
586+
end
587+
588+
context 'with an except header' do
589+
let(:headers) do
590+
{
591+
'X-Inertia' => true,
592+
'X-Inertia-Partial-Data' => 'deferred_merge,deferred_deep_merge,deep_match_on',
593+
'X-Inertia-Partial-Except' => 'deferred_merge',
594+
'X-Inertia-Partial-Component' => 'TestComponent',
595+
}
596+
end
597+
598+
it 'returns only the excepted props' do
599+
expect(response.parsed_body['props']).to eq(
600+
'deferred_deep_merge' => { 'deep' => 'deferred and merge prop' },
601+
'deep_match_on' => { 'deep' => [{ 'id' => 1 }] }
602+
)
603+
expect(response.parsed_body['mergeProps']).to be_nil
604+
expect(response.parsed_body['deepMergeProps']).to match_array(%w[deferred_deep_merge deep_match_on])
576605
expect(response.parsed_body['deferredProps']).to be_nil
606+
expect(response.parsed_body['matchPropsOn']).to match_array(%w[deep_match_on.deep.id])
577607
end
578608
end
579609
end

0 commit comments

Comments
 (0)