Skip to content

Partial reload guard rails #154

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

Closed
wants to merge 7 commits into from
7 changes: 7 additions & 0 deletions lib/inertia_rails.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,15 @@

module InertiaRails
class Error < StandardError; end
class UnoptimizedPartialReloadError < StandardError; end

def self.deprecator # :nodoc:
@deprecator ||= ActiveSupport::Deprecation.new
end

def self.warn(message)
full_message = "[InertiaRails]: WARNING! #{message}"
Kernel.warn full_message if Rails.env.development? || Rails.env.test?
Copy link
Collaborator

Choose a reason for hiding this comment

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

At a glance, I don't think I like this pattern of ignorable warnings.. I think if we go this route I'd rather deprecate here and explicitly raise.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I see it as analogous to React spitting "hey you probably forgot a key when you iterated over that array!" into your JS console.

That said, I could be convinced that "no half measures!" is a good principle here, or that "Rails devs don't expect warnings the way React devs might".

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Closest analogue I can think of in Rails-land is strong parameters, which log filtered out params by default (IIRC).

Admittedly, that isn't always a great experience, (here's a thread of people wishing it raised by default in dev), but in this scenario we're going from "no warning at all" to "at least the logs will give you a hint".

Rails.logger.warn full_message
end
end
2 changes: 2 additions & 0 deletions lib/inertia_rails/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ class Configuration

# Used to detect version drift between server and client.
version: nil,

raise_on_unoptimized_partial_reloads: false,
Copy link
Contributor

Choose a reason for hiding this comment

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

I'd use this in production by providing a custom callback with an APM error notification call, for example. So, maybe we should accept :error, :warn, false, and a callable?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

It would address Brandon's reluctance to relying on warnings, and a callable would make it vary behavior based on environment (i.e. be less aggressive in prod). I like it.

}.freeze

OPTION_NAMES = DEFAULTS.keys.freeze
Expand Down
34 changes: 32 additions & 2 deletions lib/inertia_rails/renderer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,10 @@ def merge_props(shared_data, props)
end

def computed_props
_props = merge_props(shared_data, props).select do |key, prop|
_merged_props = merge_props(shared_data, props)
validate_partial_props(_merged_props)

_filtered_props = _merged_props.select do |key, prop|
if rendering_partial_component?
key.in? partial_keys
else
Expand All @@ -77,7 +80,7 @@ def computed_props
end

deep_transform_values(
_props,
_filtered_props,
lambda do |prop|
prop.respond_to?(:call) ? controller.instance_exec(&prop) : prop
end
Expand Down Expand Up @@ -112,5 +115,32 @@ def resolve_component(component)

configuration.component_path_resolver(path: controller.controller_path, action: controller.action_name)
end

def validate_partial_props(merged_props)
return unless rendering_partial_component?

unrequested_props = merged_props.select do |key, prop|
Copy link
Collaborator

Choose a reason for hiding this comment

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

Is this based on what the frontend requests?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yea, if the frontend requests only :search as part of a partial reload, unrequested_props will have every prop except for :search.

Then below we filter out any props that aren't callable, leaving us with props that were defined as values.

!key.in?(partial_keys)
Copy link
Contributor

Choose a reason for hiding this comment

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

This code might be called on every Inertia request, so I prefer optimized code over readability. For example, we can eliminate the second loop and avoid creating an additional array by using:

Suggested change
!key.in?(partial_keys)
!key.in?(partial_keys) && !prop.respond_to?(:call)

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I shudder to think of the application with an action with so many props that this would make a difference 😅, but this is easy enough to change.

You guessed correctly that it's for readability.

end

props_that_will_unecessarily_compute = unrequested_props.select do |key, prop|
Copy link
Collaborator

Choose a reason for hiding this comment

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

genuine question: Is there a valid use-case for a prop here that is callable, but evaluates every time?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I'm not sure I understand the question. Props that are callable get called (or skipped) after this method runs.

This method isn't doing anything more fancy than checking to see if there are any unrequested props that were defined as values (and it short circuits unless we're partial reloading). The filtering doesn't mutate anything or get used in the actual computing/rendering.

!prop.respond_to?(:call)
end

if props_that_will_unecessarily_compute.present?
props = props_that_will_unecessarily_compute.keys.map { |k| ":#{k}" }.join(', ')
is_plural = props_that_will_unecessarily_compute.length > 1
Copy link
Contributor

Choose a reason for hiding this comment

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

I'd say it's too much for an error message 😅

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

haha fair enough

verb = is_plural ? "props are" : "prop is"
pronoun = is_plural ? "them because they are defined as values" : "it because it is defined as a value"

warning_message = "The #{props} #{verb} being computed even though your partial reload did not request #{pronoun}. You might want to wrap these in a callable like a lambda ->{} or InertiaRails::Lazy()."

if configuration.raise_on_unoptimized_partial_reloads
raise InertiaRails::UnoptimizedPartialReloadError, warning_message
else
InertiaRails.warn warning_message
end
end
end
end
end
13 changes: 13 additions & 0 deletions spec/dummy/app/controllers/concerns/searchable.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
module Searchable
extend ActiveSupport::Concern

included do
inertia_config(raise_on_unoptimized_partial_reloads: true)

inertia_share do
{
search: { query: '', results: [] }
}
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
class InertiaHasSearchableController < ApplicationController
include Searchable

def index
render inertia: 'TestComponent', props: {
unrequested_prop: 'This will always compute even when not requested in a partial reload',
}
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
class InertiaUnoptimizedPartialReloadsController < ApplicationController
inertia_share search: { query: '', results: [] }

def index
render inertia: 'TestComponent', props: {
expensive_prop: expensive_prop,
}
end

def with_multiple
render inertia: 'TestComponent', props: {
expensive_prop: expensive_prop,
another_expensive_prop: "another one",
callable_prop: -> { 'This is a callable prop' },
}
end

def expensive_prop
'Imagine this is slow to compute'
end
end
5 changes: 5 additions & 0 deletions spec/dummy/config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,9 @@
get 'conditional_share_show' => 'inertia_conditional_sharing#show'
get 'conditional_share_edit' => 'inertia_conditional_sharing#edit'
get 'conditional_share_show_with_a_problem' => 'inertia_conditional_sharing#show_with_a_problem'

get 'unoptimized_partial_reloads' => 'inertia_unoptimized_partial_reloads#index'
get 'unoptimized_partial_reloads_with_exception' => 'inertia_unoptimized_partial_reloads#with_exception'
get 'unoptimized_partial_reloads_with_mutiple' => 'inertia_unoptimized_partial_reloads#with_multiple'
get 'has_searchable' => 'inertia_has_searchable#index'
end
88 changes: 88 additions & 0 deletions spec/inertia/partial_reload_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
RSpec.describe 'partial reloads', type: :request do
describe 'optimization guard rails' do
context 'when a non-requested prop is defined as a value' do
let (:fake_std_err) {FakeStdErr.new}
let (:warn_message) {'[InertiaRails]: WARNING! The :expensive_prop prop is being computed even though your partial reload did not request it because it is defined as a value. You might want to wrap these in a callable like a lambda ->{} or InertiaRails::Lazy().'}
let (:warn_message_with_multiple) {'[InertiaRails]: WARNING! The :expensive_prop, :another_expensive_prop props are being computed even though your partial reload did not request them because they are defined as values. You might want to wrap these in a callable like a lambda ->{} or InertiaRails::Lazy().'}

around(:each) do |example|
begin
original_stderr = $stderr
Copy link
Collaborator

Choose a reason for hiding this comment

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

The complexity in this test is what tipped me off that I should be uncomfortable with the warning method above

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I think I could make this cleaner by warning via Rails.logger instead of going all the way down to Kernel.warn.

The important bit is above though, whether logging in dev/test is a useful interface for warning devs or if it's too easily ignored to be helpful.

$stderr = fake_std_err

example.run
ensure
$std_err = original_stderr
end
end

it 'only returns the requested prop' do
get unoptimized_partial_reloads_path, headers: {
'X-Inertia' => true,
'X-Inertia-Partial-Data' => 'search',
'X-Inertia-Partial-Component' => 'TestComponent',
}

expect(JSON.parse(response.body)['props'].deep_symbolize_keys).to eq({
search: {
query: '',
results: [],
},
})
end

it 'computes the non-requested prop anyway' do
expect_any_instance_of(InertiaUnoptimizedPartialReloadsController).to receive(:expensive_prop).with(any_args)

get unoptimized_partial_reloads_path, headers: {
'X-Inertia' => true,
'X-Inertia-Partial-Data' => 'search',
'X-Inertia-Partial-Component' => 'TestComponent',
}
end

it 'emits a warning' do
get unoptimized_partial_reloads_path, headers: {
'X-Inertia' => true,
'X-Inertia-Partial-Data' => 'search',
'X-Inertia-Partial-Component' => 'TestComponent',
}
expect(fake_std_err.messages[0].chomp).to(eq(warn_message))
end

it 'does not warn about callable props' do
get unoptimized_partial_reloads_path, headers: {
'X-Inertia' => true,
'X-Inertia-Partial-Data' => 'search',
'X-Inertia-Partial-Component' => 'TestComponent',
}

expect(fake_std_err.messages[0].chomp).not_to include('callable_prop')
end

context 'when there are multiple non-requested props defined as values' do
it 'emits a different warning' do
get unoptimized_partial_reloads_with_mutiple_path, headers: {
'X-Inertia' => true,
'X-Inertia-Partial-Data' => 'search',
'X-Inertia-Partial-Component' => 'TestComponent',
}

expect(fake_std_err.messages[0].chomp).to(eq(warn_message_with_multiple))
end
end

context 'when the controller is configured to raise_on_unoptimized_partial_reloads' do
it 'raises an error' do
expect {
get has_searchable_path, headers: {
'X-Inertia' => true,
'X-Inertia-Partial-Data' => 'search',
'X-Inertia-Partial-Component' => 'TestComponent',
}
}.to raise_error(InertiaRails::UnoptimizedPartialReloadError, /unrequested_prop/)
end
end
end
end
end
16 changes: 0 additions & 16 deletions spec/inertia/rspec_helper_spec.rb
Original file line number Diff line number Diff line change
@@ -1,21 +1,5 @@
require_relative '../../lib/inertia_rails/rspec'

class FakeStdErr
attr_accessor :messages

def initialize
@messages = []
end

def write(msg)
@messages << msg
end

# Rails 5.0 + Ruby 2.6 require puts to be a public method
def puts(thing)
end
end

RSpec.describe InertiaRails::RSpec, type: :request do
describe 'correctly set up inertia tests with inertia: true', inertia: true do
context 'with props' do
Expand Down
2 changes: 2 additions & 0 deletions spec/rails_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
Dir[Rails.root.join('spec', 'support', '**', '*.rb')].each { |f| require f }

require_relative './support/helper_module'
require_relative './support/fake_std_error'

# Add additional requires below this line. Rails is not loaded until this point!
# Requires supporting ruby files with custom matchers and macros, etc, in
# spec/support/ and its subdirectories. Files matching `spec/**/*_spec.rb` are
Expand Down
15 changes: 15 additions & 0 deletions spec/support/fake_std_error.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
class FakeStdErr
attr_accessor :messages

def initialize
@messages = []
end

def write(msg)
@messages << msg
end

# Rails 5.0 + Ruby 2.6 require puts to be a public method
def puts(thing)
end
end