-
Notifications
You must be signed in to change notification settings - Fork 47
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
Partial reload guard rails #154
Conversation
…eir partial reloads are needlessly computing extra data
… during a partial reload
…re not fully optimized
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.
Not quite a review yet (I'm very tired after a runDisney weekend 😄 ) but I'm sitting at a computer and thought I'd comment my quick thoughts!
|
||
def self.warn(message) | ||
full_message = "[InertiaRails]: WARNING! #{message}" | ||
Kernel.warn full_message if Rails.env.development? || Rails.env.test? |
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".
|
||
around(:each) do |example| | ||
begin | ||
original_stderr = $stderr |
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.
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 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.
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 comment
The 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 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) | ||
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 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?
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'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.
Endorphins are the perfect fuel for code review! 😅 |
My thought is that the ideal development experience would be for inertia_rails to handle this complexity automatically. At first consideration through my brain, it feels like that's not possible or at a minimum could require some very hairy metaprogramming, but I'm also only superficially aware of what the internals here are, so perhaps I'm missing something. Mainly piping up to ask "are we treating the symptom or the disease" here -- but maybe a "cure" isn't realistic? |
Alternately, assuming there's no realistic way to prevent controllers using the existing structure from evaluating code that is defined right in their methods (which would definitely be the expected situation 😄 ), perhaps it's worth thinking about if there is an alternate syntax / structure that would more easily support automatically dealing with this sort of thing, ideally such a structure could even help organize things for the non-trivial use cases with lots of concerns/inertia_shares, etc. I'm definitely totally spitballing here, but one possibility would be to optionally pass symbols to the inertia render, where the symbols would just be methods to call in order to fetch the relevant data. This feels fairly Rails-y, in the way that a number of Rails' class declaration-type methods use symbols to specify methods ( Then, if this feels like a decent enough experience, it could become the default way, or what a generator generates, etc, which would be the way this gets "handled automatically" for new folks without them needing to know/worry about it. There's several suppositions in there, though! The main point I'm raising is -- is there perhaps a "bigger picture" way to smooth both this experience and other experiences? |
I think the "bigger picture" involves implementing an inertia-aware serialization layer, which can be achieved through a serialization plugin system. I plan to first finish upstreaming the contrib gem generators and preparing the v2 beta branch, and then start working on the serialization. Conceptually, it would work like this: class UsersController < ApplicationController
inertia_config serialization_plugin: :alba
def index = render inertia: User.all
end
class UsersIndexSerializer < ActionSerializer
attributes :basic, :attributes
has_many :friends
has_many :notifications, always: true
attribute :optional_attr, optional: true
end
# somewhere in the plugin internals:
serializer = lookup_serializer(...)
serializer.as_json(only: ...) # or other serializer-specific logic that allows partial rendering |
@@ -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 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?
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.
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.
|
||
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 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 😅
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.
haha fair enough
return unless rendering_partial_component? | ||
|
||
unrequested_props = merged_props.select do |key, prop| | ||
!key.in?(partial_keys) |
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.
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:
!key.in?(partial_keys) | |
!key.in?(partial_keys) && !prop.respond_to?(:call) |
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 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.
@mattdb I have had the same thought (wouldn't it be great if we could automatically skip computing non-requested props in a partial reload!), but I wouldn't want to lose the concise "happy path": def index
render inertia: 'SomeComponent', props: { this_is: 'so nice and clean' }
end So I think defining props-as-values is a constraint we choose. The new docs are much clearer about wrapping things in callables to get the performance benefit, and adding a warning (with options to escalate) seems like a healthy amount of guidance. |
I'm interested in this idea, and it would save on time spent in serializers (which is often non-trivial!), Oh wait, nvm -- ARel's deferred loading, I forgot about that -- this is definitely an intriguing line of thinking, then! |
Now that I think about it, the library already takes advantage of that, doesn't it? class UsersController < ApplicationController
def index
render inertia: 'SomeComponent', props: {
users: User.all,
partially_reloaded_prop: 'hi there',
}
end If you partial reload that second prop, InertiaRails won't fetch the The real killer for partial reload performance would be anything that either 1) explicitly loads your AR records before they are passed to Inertia instead of leaving them as a relation ( I think we're in good shape here. Explicit support for callables in the library, clear docs, helpful deferred loading from ActiveRecord, and configurable warnings in dev (once we merge this in). (This isn't a comment about the serialization plugin idea, which at a glance looks like it could add a nice developer experience option!) |
Closing in favor of #165 |
One of our team members (hi @mattdb) raised a good point last week about a rough edge with partial reloading. If you don't wrap your props in callables, you run the risk of unnecessarily computing expensive props even though your response will only include the partial reloaded props. This is called out in the docs, but Matt pointed out that this gets tricky if you are sharing inertia data in concerns. An unoptimized prop can pretty easily slip through the cracks.
Say you have a search bar that's present on every page. You can drive it via Inertia, and it's a great use case for partial reloading:
In this example,
Thing.all
will run every time the app partial reloads:search
. And it's pretty easy for that to slip by ifSearchable
gets added by a different person than whoever coded upThingsController#index
.I've really like the helpful exceptions that have been added in #121 , #137 and now in #152 . The PR adds something similar to help with the situation described above.
By default, InertiaRails will now emit a warning if it detects 1) a partial reload 2) with prop(s) that weren't requested with the partial reload and aren't callable.
There's also a new config option
raise_on_unoptimized_partial_reloads
which, as it says, will be more aggressive and raise an error if that occurs.A few things I'm still pondering in the design:
:ignore
,:warn
,:raise
Thing.all
just slows things down a bit{ some_prop: 'some string' }
isn't worth optimizing.