diff --git a/docs/guide/deferred-props.md b/docs/guide/deferred-props.md index 9db7eb63..7e4a3a29 100644 --- a/docs/guide/deferred-props.md +++ b/docs/guide/deferred-props.md @@ -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. diff --git a/docs/guide/merging-props.md b/docs/guide/merging-props.md index df843442..172aa54b 100644 --- a/docs/guide/merging-props.md +++ b/docs/guide/merging-props.md @@ -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 @@ -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. @@ -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 diff --git a/lib/inertia_rails/defer_prop.rb b/lib/inertia_rails/defer_prop.rb index 2d486b51..ec85ac04 100644 --- a/lib/inertia_rails/defer_prop.rb +++ b/lib/inertia_rails/defer_prop.rb @@ -4,9 +4,9 @@ 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) @@ -14,6 +14,7 @@ def initialize(group: nil, merge: nil, deep_merge: nil, &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? diff --git a/lib/inertia_rails/generators/controller_template_base.rb b/lib/inertia_rails/generators/controller_template_base.rb index 75d261b5..aa452646 100644 --- a/lib/inertia_rails/generators/controller_template_base.rb +++ b/lib/inertia_rails/generators/controller_template_base.rb @@ -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' diff --git a/lib/inertia_rails/generators/helper.rb b/lib/inertia_rails/generators/helper.rb index fb37cdb9..51434c31 100644 --- a/lib/inertia_rails/generators/helper.rb +++ b/lib/inertia_rails/generators/helper.rb @@ -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 diff --git a/lib/inertia_rails/inertia_rails.rb b/lib/inertia_rails/inertia_rails.rb index d2fb961f..f06bf8b2 100644 --- a/lib/inertia_rails/inertia_rails.rb +++ b/lib/inertia_rails/inertia_rails.rb @@ -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 diff --git a/lib/inertia_rails/merge_prop.rb b/lib/inertia_rails/merge_prop.rb index a90352b2..639a1e95 100644 --- a/lib/inertia_rails/merge_prop.rb +++ b/lib/inertia_rails/merge_prop.rb @@ -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? diff --git a/lib/inertia_rails/renderer.rb b/lib/inertia_rails/renderer.rb index 1e1b2043..641ddeeb 100644 --- a/lib/inertia_rails/renderer.rb +++ b/lib/inertia_rails/renderer.rb @@ -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 + + 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 @@ -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 @@ -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) diff --git a/spec/dummy/app/controllers/inertia_render_test_controller.rb b/spec/dummy/app/controllers/inertia_render_test_controller.rb index 4d5134be..85928972 100644 --- a/spec/dummy/app/controllers/inertia_render_test_controller.rb +++ b/spec/dummy/app/controllers/inertia_render_test_controller.rb @@ -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 diff --git a/spec/inertia/rendering_spec.rb b/spec/inertia/rendering_spec.rb index fa635b69..537cb5c3 100644 --- a/spec/inertia/rendering_spec.rb +++ b/spec/inertia/rendering_spec.rb @@ -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 @@ -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