-
Notifications
You must be signed in to change notification settings - Fork 64
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
Changes from all commits
351ba22
dbcd41e
23f9f00
62ec4b9
68ddff6
7555b1a
9931f88
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -22,6 +22,8 @@ class Configuration | |
|
||
# Used to detect version drift between server and client. | ||
version: nil, | ||
|
||
raise_on_unoptimized_partial_reloads: false, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
|
@@ -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 | ||||||
|
@@ -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 | ||||||
|
@@ -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| | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this based on what the frontend requests? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yea, if the frontend requests only Then below we filter out any props that aren't callable, leaving us with props that were defined as values. |
||||||
!key.in?(partial_keys) | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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| | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'd say it's too much for an error message 😅 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
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 |
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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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".
There was a problem hiding this comment.
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".