Skip to content
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

Unoptimized partial reload warnings #165

Open
wants to merge 7 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
8 changes: 8 additions & 0 deletions lib/inertia_rails.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@

module InertiaRails
class Error < StandardError; end
class UnoptimizedPartialReload < StandardError
attr_reader :paths

def initialize(paths)
@paths = paths
super("The #{paths.join(', ')} prop(s) were excluded in a partial reload but still evaluated because they are defined as values.")
end
end

def self.deprecator # :nodoc:
@deprecator ||= ActiveSupport::Deprecation.new
Expand Down
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,

action_on_unoptimized_partial_reload: :log,
}.freeze

OPTION_NAMES = DEFAULTS.keys.freeze
Expand Down
15 changes: 15 additions & 0 deletions lib/inertia_rails/engine.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,20 @@ class Engine < ::Rails::Engine
include ::InertiaRails::Controller
end
end

initializer "inertia_rails.subscribe_to_notifications" do
if Rails.env.development? || Rails.env.test?
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 want the default experience to add warnings to the logs in development / test only.

ActiveSupport::Notifications.subscribe('inertia_rails.unoptimized_partial_render') do |*args|
event = ActiveSupport::Notifications::Event.new(*args)

message =
"InertiaRails: The \"#{event.payload[:paths].join(', ')}\" " \
"prop(s) were excluded in a partial reload but still evaluated because they are defined as values. " \
"Consider wrapping them in something callable like a lambda."

Rails.logger.debug(message)
end
end
end
end
end
145 changes: 96 additions & 49 deletions lib/inertia_rails/renderer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,6 @@

module InertiaRails
class Renderer
KEEP_PROP = :keep
DONT_KEEP_PROP = :dont_keep

attr_reader(
:component,
:configuration,
Expand All @@ -30,21 +27,30 @@ def initialize(component, controller, request, response, render_method, props: n
end

def render
set_vary_header

validate_partial_reload_optimization if rendering_partial_component?

return render_inertia_response if @request.headers['X-Inertia']
return render_ssr if configuration.ssr_enabled rescue nil

render_full_page
Comment on lines +30 to +37
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Moved these into methods purely for readability. IMO this method should be super readable since it spells out the core functionality

end

private

def set_vary_header
if @response.headers["Vary"].blank?
@response.headers["Vary"] = 'X-Inertia'
else
@response.headers["Vary"] = "#{@response.headers["Vary"]}, X-Inertia"
end
if @request.headers['X-Inertia']
@response.set_header('X-Inertia', 'true')
@render_method.call json: page, status: @response.status, content_type: Mime[:json]
else
return render_ssr if configuration.ssr_enabled rescue nil
@render_method.call template: 'inertia', layout: layout, locals: view_data.merge(page: page)
end
end

private
def render_inertia_response
@response.set_header('X-Inertia', 'true')
@render_method.call json: page, status: @response.status, content_type: Mime[:json]
end

def render_ssr
uri = URI("#{configuration.ssr_url}/render")
Expand All @@ -54,6 +60,10 @@ def render_ssr
@render_method.call html: res['body'].html_safe, layout: layout, locals: view_data.merge(page: page)
end

def render_full_page
@render_method.call template: 'inertia', layout: layout, locals: view_data.merge(page: page)
end

def layout
layout = configuration.layout
layout.nil? ? true : layout
Expand All @@ -68,58 +78,28 @@ def shared_data
#
# Functionally, this permits using either string or symbol keys in the controller. Since the results
# is cast to json, we should treat string/symbol keys as identical.
def merge_props(shared_data, props)
if @deep_merge
shared_data.deep_symbolize_keys.deep_merge!(props.deep_symbolize_keys)
def merged_props
@merged_props ||= if @deep_merge
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

memoizing just to future proof. it's only called once, but can't hurt

shared_data.deep_symbolize_keys.deep_merge!(@props.deep_symbolize_keys)
else
shared_data.symbolize_keys.merge(props.symbolize_keys)
shared_data.symbolize_keys.merge(@props.symbolize_keys)
end
end

def computed_props
_props = merge_props(shared_data, props)

deep_transform_props _props do |prop, path|
next [DONT_KEEP_PROP] unless keep_prop?(prop, path)

transformed_prop = case prop
when BaseProp
prop.call(controller)
when Proc
controller.instance_exec(&prop)
else
prop
end

[KEEP_PROP, transformed_prop]
end
def prop_transformer
@prop_transformer ||= PropTransformer.new(controller)
.select_transformed(merged_props){ |prop, path| keep_prop?(prop, path) }
end

def page
{
component: component,
props: computed_props,
props: prop_transformer.props,
url: @request.original_fullpath,
version: configuration.version,
}
end

def deep_transform_props(props, parent_path = [], &block)
props.reduce({}) do |transformed_props, (key, prop)|
current_path = parent_path + [key]

if prop.is_a?(Hash) && prop.any?
nested = deep_transform_props(prop, current_path, &block)
transformed_props.merge!(key => nested) unless nested.empty?
else
action, transformed_prop = block.call(prop, current_path)
transformed_props.merge!(key => transformed_prop) if action == KEEP_PROP
end

transformed_props
end
end

def partial_keys
(@request.headers['X-Inertia-Partial-Data'] || '').split(',').compact
end
Expand Down Expand Up @@ -166,5 +146,72 @@ def excluded_by_only_partial_keys?(path_with_prefixes)
def excluded_by_except_partial_keys?(path_with_prefixes)
partial_except_keys.present? && (path_with_prefixes & partial_except_keys).any?
end

def validate_partial_reload_optimization
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

don't love the long method name... i'm open to suggestions!

if prop_transformer.unoptimized_prop_paths.any?
case configuration.action_on_unoptimized_partial_reload
when :log
ActiveSupport::Notifications.instrument(
'inertia_rails.unoptimized_partial_render',
paths: prop_transformer.unoptimized_prop_paths,
controller: controller,
action: controller.action_name,
Comment on lines +156 to +158
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

@skryukov you mentioned wanting this feature to accept a callable... with this anyone can register a subscriber with that sort of functionality. any other options you'd like to see passed to the subscriber context?

)
when :raise
raise InertiaRails::UnoptimizedPartialReload.new(prop_transformer.unoptimized_prop_paths)
end
end
end

class PropTransformer
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 moved the transformation logic into a nested subclass so 1) all the behavior is encapsulated in a single place and 2) I had a place to store unoptimized_prop_paths while traversing the props hash. The transformation code is the same, and it was super easy to drop in the track_unoptimized_prop call.

The caller (above) uses a .select_transformed method with a block, so the caller only has to worry about filtering now (instead of both filtering and transforming). We also get to remove the admittedly-clunky keep/dont keep enum!

I considered pulling this out of the renderer class, but I don't really see a need for it. It's tested by our request specs and never going to be used outside that context.

attr_reader :props, :unoptimized_prop_paths

def initialize(controller)
@controller = controller
@unoptimized_prop_paths = []
@props = {}
end

def select_transformed(initial_props, &block)
@unoptimized_prop_paths = []
@props = deep_transform_and_select(initial_props, &block)

self
end

private

def deep_transform_and_select(original_props, parent_path = [], &block)
original_props.reduce({}) do |transformed_props, (key, prop)|
current_path = parent_path + [key]

if prop.is_a?(Hash) && prop.any?
nested = deep_transform_and_select(prop, current_path, &block)
transformed_props.merge!(key => nested) unless nested.empty?
elsif block.call(prop, current_path)
transformed_props.merge!(key => transform_prop(prop))
elsif !prop.respond_to?(:call)
track_unoptimized_prop(current_path)
end

transformed_props
end
end

def transform_prop(prop)
case prop
when BaseProp
prop.call(@controller)
when Proc
@controller.instance_exec(&prop)
else
prop
end
end

def track_unoptimized_prop(path)
@unoptimized_prop_paths << path.join(".")
end
end
end
end
2 changes: 1 addition & 1 deletion spec/dummy/config/environments/test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

Rails.application.configure do
# Settings specified here will take precedence over those in config/application.rb.

config.cache_classes = false

# Do not eager load code on boot. This avoids loading your whole application
Expand Down
49 changes: 49 additions & 0 deletions spec/inertia/configuration_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -153,11 +153,60 @@
end
end
end

describe '.action_on_unoptimized_partial_reload' do
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Much cleaner specs than in #154 :)

let(:headers) {{
'X-Inertia' => true,
'X-Inertia-Partial-Data' => 'nested.first',
'X-Inertia-Partial-Component' => 'TestComponent',
}}

context 'default case' do
let(:headers) {{
'X-Inertia' => true,
'X-Inertia-Partial-Data' => 'nested.first',
'X-Inertia-Partial-Component' => 'TestComponent',
}}

it 'logs a warning' do
expect(Rails.logger).to receive(:debug).with(/flat, nested\.second/)
get except_props_path, headers: headers
end
end

context 'when set to :raise' do
before do
InertiaRails.configure {|c| c.action_on_unoptimized_partial_reload = :raise}
end

let(:headers) {{
'X-Inertia' => true,
'X-Inertia-Partial-Data' => 'nested.first',
'X-Inertia-Partial-Component' => 'TestComponent',
}}

it 'raises an exception' do
expect { get except_props_path, headers: headers }.to raise_error(InertiaRails::UnoptimizedPartialReload).with_message(/flat, nested\.second/)
end
end

context 'with an unknown value' do
before do
InertiaRails.configure {|c| c.action_on_unoptimized_partial_reload = :unknown}
end

it 'does nothing' do
expect(Rails.logger).not_to receive(:debug)
get except_props_path, headers: headers
end
end
end
end

def reset_config!
InertiaRails.configure do |config|
config.version = nil
config.layout = 'application'
config.action_on_unoptimized_partial_reload = :log
end
end