Skip to content

Add smart merging with match_on option #234

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/guide/deferred-props.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ end

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.

### Combining with mergeable props

Deferred props can be combined with mergeable props. You can learn more about this feature in the [Merging props](/guide/merging-props) section.

## Client side

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.
Expand Down
27 changes: 26 additions & 1 deletion docs/guide/merging-props.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,15 @@ class UsersController < ApplicationController
records: records.as_json(...),
pagy: pagy_metadata(pagy)
}
},
# with match_on parameter for smart merging:
products: InertiaRails.merge(match_on: 'id') { Product.all.as_json(...) },
# nested objects with match_on:
categories: InertiaRails.deep_merge(match_on: %w[items.id tags.id]) {
{
items: Category.all.as_json(...),
tags: Tag.all.as_json(...)
}
}
}
end
Expand All @@ -34,7 +43,14 @@ end

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.

**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.
### Smart merging with `match_on`

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.

The `match_on` parameter enables smart merging by specifying a field to match on when merging arrays of objects:

- For `merge` with simple arrays, specify the object key to match on (e.g., `'id'`)
- For `deep_merge` with nested structures, use dot notation to specify the path (e.g., `'items.id'`)

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.

Expand All @@ -54,6 +70,15 @@ class UsersController < ApplicationController
records: records.as_json(...),
pagy: pagy_metadata(pagy)
}
},
# with match_on parameter:
products: InertiaRails.defer(merge: true, match_on: 'id') { products.as_json(...) },
# nested objects with match_on:
categories: InertiaRails.defer(deep_merge: true, match_on: %w[items.id tags.id]) {
{
items: Category.all.as_json(...),
tags: Tag.all.as_json(...)
}
}
}
end
Expand Down
5 changes: 3 additions & 2 deletions lib/inertia_rails/defer_prop.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,17 @@ module InertiaRails
class DeferProp < IgnoreOnFirstLoadProp
DEFAULT_GROUP = 'default'

attr_reader :group
attr_reader :group, :match_on

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

super(&block)

@group = group || DEFAULT_GROUP
@merge = merge || deep_merge
@deep_merge = deep_merge
@match_on = match_on.nil? ? nil : Array(match_on)
end

def merge?
Expand Down
2 changes: 1 addition & 1 deletion lib/inertia_rails/generators/controller_template_base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ class ControllerTemplateBase < Rails::Generators::NamedBase
default: Helper.guess_the_default_framework

class_option :typescript, type: :boolean, desc: 'Whether to use TypeScript',
default: Helper.guess_typescript
default: Helper.uses_typescript?

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

Expand Down
2 changes: 1 addition & 1 deletion lib/inertia_rails/generators/helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ def guess_the_default_framework(package_json_path = DEFAULT_PACKAGE_PATH)
end
end

def guess_typescript
def uses_typescript?
Rails.root.join('tsconfig.json').exist?
end

Expand Down
12 changes: 6 additions & 6 deletions lib/inertia_rails/inertia_rails.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,16 +33,16 @@ def always(&block)
AlwaysProp.new(&block)
end

def merge(&block)
MergeProp.new(&block)
def merge(match_on: nil, &block)
MergeProp.new(match_on: match_on, &block)
end

def deep_merge(&block)
MergeProp.new(deep_merge: true, &block)
def deep_merge(match_on: nil, &block)
MergeProp.new(deep_merge: true, match_on: match_on, &block)
end

def defer(group: nil, merge: nil, deep_merge: nil, &block)
DeferProp.new(group: group, merge: merge, deep_merge: deep_merge, &block)
def defer(group: nil, merge: nil, deep_merge: nil, match_on: nil, &block)
DeferProp.new(group: group, merge: merge, deep_merge: deep_merge, match_on: match_on, &block)
end
end
end
5 changes: 4 additions & 1 deletion lib/inertia_rails/merge_prop.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@

module InertiaRails
class MergeProp < BaseProp
def initialize(deep_merge: false, &block)
attr_reader :match_on

def initialize(deep_merge: false, match_on: nil, &block)
super(&block)
@deep_merge = deep_merge
@match_on = match_on.nil? ? nil : Array(match_on)
end

def merge?
Expand Down
30 changes: 20 additions & 10 deletions lib/inertia_rails/renderer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -106,14 +106,17 @@ def page
deferred_props = deferred_props_keys
default_page[:deferredProps] = deferred_props if deferred_props.present?

all_merge_props = merge_props_keys

deep_merge_props, merge_props = all_merge_props.partition do |key|
@props[key].deep_merge?
deep_merge_props, merge_props = all_merge_props.partition do |_key, prop|
prop.deep_merge?
end

default_page[:mergeProps] = merge_props if merge_props.present?
default_page[:deepMergeProps] = deep_merge_props if deep_merge_props.present?
match_props_on = all_merge_props.filter_map do |key, prop|
prop.match_on.map { |ms| "#{key}.#{ms}" } if prop.match_on.present?
end.flatten
Comment on lines +113 to +115
Copy link
Preview

Copilot AI Jun 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] You can simplify this logic by using flat_map instead of filter_map plus flatten, for example:

match_props_on = all_merge_props.flat_map do |key, prop|
  prop.match_on.present? ? prop.match_on.map { |ms| "#{key}.#{ms}" } : []
end
Suggested change
match_props_on = all_merge_props.filter_map do |key, prop|
prop.match_on.map { |ms| "#{key}.#{ms}" } if prop.match_on.present?
end.flatten
match_props_on = all_merge_props.flat_map do |key, prop|
prop.match_on.present? ? prop.match_on.map { |ms| "#{key}.#{ms}" } : []
end

Copilot uses AI. Check for mistakes.


default_page[:mergeProps] = merge_props.map(&:first) if merge_props.present?
default_page[:deepMergeProps] = deep_merge_props.map(&:first) if deep_merge_props.present?
default_page[:matchPropsOn] = match_props_on if match_props_on.present?

default_page
end
Expand Down Expand Up @@ -147,9 +150,16 @@ def deferred_props_keys
end
end

def merge_props_keys
@props.each_with_object([]) do |(key, prop), result|
result << key if prop.try(:merge?) && reset_keys.exclude?(key)
def all_merge_props
@all_merge_props ||= @props.select do |key, prop|
next unless prop.try(:merge?)
next if reset_keys.include?(key)
next if rendering_partial_component? && (
(partial_keys.present? && partial_keys.exclude?(key.name)) ||
(partial_except_keys.present? && partial_except_keys.include?(key.name))
)

true
end
end

Expand Down Expand Up @@ -180,7 +190,7 @@ def resolve_component(component)
def keep_prop?(prop, path)
return true if prop.is_a?(AlwaysProp)

if rendering_partial_component?
if rendering_partial_component? && (partial_keys.present? || partial_except_keys.present?)
path_with_prefixes = path_prefixes(path)
return false if excluded_by_only_partial_keys?(path_with_prefixes)
return false if excluded_by_except_partial_keys?(path_with_prefixes)
Expand Down
4 changes: 4 additions & 0 deletions spec/dummy/app/controllers/inertia_render_test_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -106,10 +106,14 @@ def always_props
def merge_props
render inertia: 'TestComponent', props: {
merge: InertiaRails.merge { 'merge prop' },
match_on: InertiaRails.merge(match_on: 'id') { [id: 1] },
deep_merge: InertiaRails.deep_merge { { deep: 'merge prop' } },
deep_match_on: InertiaRails.deep_merge(match_on: 'deep.id') { { deep: [id: 1] } },
regular: 'regular prop',
deferred_merge: InertiaRails.defer(merge: true) { 'deferred and merge prop' },
deferred_match_on: InertiaRails.defer(merge: true, match_on: 'id') { [id: 1] },
deferred_deep_merge: InertiaRails.defer(deep_merge: true) { { deep: 'deferred and merge prop' } },
deferred_deep_match_on: InertiaRails.defer(deep_merge: true, match_on: 'deep.id') { { deep: [id: 1] } },
deferred: InertiaRails.defer { 'deferred' },
}
end
Expand Down
54 changes: 42 additions & 12 deletions spec/inertia/rendering_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -530,30 +530,37 @@
before { get merge_props_path, headers: headers }

it 'returns non-optional props and meta on first load' do
expect(response.parsed_body['props']).to eq('merge' => 'merge prop', 'deep_merge' => { 'deep' => 'merge prop' },
'regular' => 'regular prop')
expect(response.parsed_body['mergeProps']).to match_array(%w[merge deferred_merge])
expect(response.parsed_body['deepMergeProps']).to match_array(%w[deep_merge deferred_deep_merge])
expect(response.parsed_body['deferredProps']).to eq('default' => %w[deferred_merge deferred_deep_merge deferred])
expect(response.parsed_body['props']).to eq(
'merge' => 'merge prop', 'match_on' => [{ 'id' => 1 }],
'deep_merge' => { 'deep' => 'merge prop' }, 'deep_match_on' => { 'deep' => [{ 'id' => 1 }] },
'regular' => 'regular prop'
)
expect(response.parsed_body['mergeProps']).to match_array(%w[merge match_on deferred_merge deferred_match_on])
expect(response.parsed_body['deepMergeProps']).to match_array(%w[deep_merge deep_match_on deferred_deep_merge deferred_deep_match_on])
expect(response.parsed_body['deferredProps']).to eq('default' => %w[deferred_merge deferred_match_on deferred_deep_merge deferred_deep_match_on deferred])
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])
end

context 'with a partial reload' do
let(:headers) do
{
'X-Inertia' => true,
'X-Inertia-Partial-Data' => 'deferred_merge,deferred_deep_merge',
'X-Inertia-Partial-Data' => 'deferred_merge,deferred_deep_merge,deferred_deep_match_on,deferred_match_on',
'X-Inertia-Partial-Component' => 'TestComponent',
}
end

it 'returns listed and merge props' do
it 'returns listed merge props' do
expect(response.parsed_body['props']).to eq(
'deferred_merge' => 'deferred and merge prop',
'deferred_deep_merge' => { 'deep' => 'deferred and merge prop' }
'deferred_deep_merge' => { 'deep' => 'deferred and merge prop' },
'deferred_deep_match_on' => { 'deep' => [{ 'id' => 1 }] },
'deferred_match_on' => [{ 'id' => 1 }]
)
expect(response.parsed_body['mergeProps']).to match_array(%w[merge deferred_merge])
expect(response.parsed_body['deepMergeProps']).to match_array(%w[deep_merge deferred_deep_merge])
expect(response.parsed_body['mergeProps']).to match_array(%w[deferred_merge deferred_match_on])
expect(response.parsed_body['deepMergeProps']).to match_array(%w[deferred_deep_merge deferred_deep_match_on])
expect(response.parsed_body['deferredProps']).to be_nil
expect(response.parsed_body['matchPropsOn']).to match_array(%w[deferred_deep_match_on.deep.id deferred_match_on.id])
end
end

Expand All @@ -567,13 +574,36 @@
}
end

it 'returns listed and merge props' do
it 'returns listed props' do
expect(response.parsed_body['props']).to eq(
'deferred_merge' => 'deferred and merge prop',
'deferred_deep_merge' => { 'deep' => 'deferred and merge prop' }
)
expect(response.parsed_body['mergeProps']).to match_array(%w[merge])
expect(response.parsed_body['mergeProps']).to be_nil
expect(response.parsed_body['deferredProps']).to be_nil
expect(response.parsed_body['matchPropsOn']).to be_nil
end
end

context 'with an except header' do
let(:headers) do
{
'X-Inertia' => true,
'X-Inertia-Partial-Data' => 'deferred_merge,deferred_deep_merge,deep_match_on',
'X-Inertia-Partial-Except' => 'deferred_merge',
'X-Inertia-Partial-Component' => 'TestComponent',
}
end

it 'returns only the excepted props' do
expect(response.parsed_body['props']).to eq(
'deferred_deep_merge' => { 'deep' => 'deferred and merge prop' },
'deep_match_on' => { 'deep' => [{ 'id' => 1 }] }
)
expect(response.parsed_body['mergeProps']).to be_nil
expect(response.parsed_body['deepMergeProps']).to match_array(%w[deferred_deep_merge deep_match_on])
expect(response.parsed_body['deferredProps']).to be_nil
expect(response.parsed_body['matchPropsOn']).to match_array(%w[deep_match_on.deep.id])
end
end
end
Expand Down