diff --git a/Gemfile b/Gemfile index 9f9ca6fe..933393b8 100644 --- a/Gemfile +++ b/Gemfile @@ -6,6 +6,8 @@ eval_gemfile "Gemfile.devtools" gemspec +gem "dry-configurable", github: "dry-rb/dry-configurable", branch: "main" + group :tools do # Remove hotch until https://github.com/ko1/allocation_tracer/issues/19 is fixed and it can be # installed on macOS again. @@ -34,6 +36,8 @@ group :benchmarks do gem "actionpack" gem "actionview" gem "benchmark-ips" + gem "stackprof", platform: :mri + gem "memory_profiler" end group :docs do diff --git a/lib/hanami/view.rb b/lib/hanami/view.rb index 664b5539..74cb9e20 100644 --- a/lib/hanami/view.rb +++ b/lib/hanami/view.rb @@ -540,12 +540,6 @@ def self.scope(scope_class = nil, &block) # @!endgroup - # @api private - # @since 2.1.0 - def self.layout_path(layout) - File.join(*[config.layouts_dir, layout].compact) - end - # @api private # @since 2.1.0 def self.cache @@ -563,6 +557,7 @@ def initialize self.class.config.finalize! ensure_config + @config_data = config.to_data @exposures = self.class.exposures.bind(self) end @@ -595,16 +590,20 @@ def exposures # rubocop:disable Style/TrivialAccessors # # @api public # @since 2.1.0 - def call(format: config.default_format, context: config.default_context, layout: config.layout, **input) + def call(format: config_data.default_format, + context: config_data.default_context, + layout: config_data.layout, + **input) rendering = self.rendering(format: format, context: context) + scope_class = config_data.scope locals = locals(rendering, input) - output = rendering.template(config.template, rendering.scope(config.scope, locals)) + output = rendering.template(config_data.template, rendering.scope(scope_class, locals)) if layout output = rendering.template( - self.class.layout_path(layout), - rendering.scope(config.scope, layout_locals(locals)) + File.join(*[config_data.layouts_dir, layout].compact), + rendering.scope(scope_class, layout_locals(locals)) ) { output } end @@ -613,12 +612,18 @@ def call(format: config.default_format, context: config.default_context, layout: # @api private # @since 2.1.0 - def rendering(format: config.default_format, context: config.default_context) - Rendering.new(config: config, format: format, context: context) + def rendering(format: config_data.default_format, context: config_data.default_context) + Rendering.new(config_data:, format:, context:) end private + # Frozen Data snapshot of the view's resolved configuration values. + # Used for fast hot-path reads during rendering. + # + # @since 2.3.0 + attr_reader :config_data + def ensure_config raise UndefinedConfigError, :paths unless Array(config.paths).any? raise UndefinedConfigError, :template unless config.template @@ -626,7 +631,7 @@ def ensure_config def locals(rendering, input) exposures.(context: rendering.context, **input) do |value, exposure| - if exposure.decorate?(default: config.decorate_exposures) && value + if exposure.decorate?(default: config_data.decorate_exposures) && value rendering.part(exposure.name, value, as: exposure.options[:as]) else value diff --git a/lib/hanami/view/part_builder.rb b/lib/hanami/view/part_builder.rb index 77f4c829..3f29f234 100644 --- a/lib/hanami/view/part_builder.rb +++ b/lib/hanami/view/part_builder.rb @@ -66,15 +66,15 @@ def part_class(name:, as:, rendering:) if name.is_a?(Class) name else - View.cache.fetch_or_store(:part_class, name, rendering.config) do + View.cache.fetch_or_store(:part_class, name, rendering.cache_key) do resolve_part_class(name: name, rendering: rendering) end end end def resolve_part_class(name:, rendering:) - namespace = rendering.config.part_namespace - return rendering.config.part_class unless namespace + namespace = rendering.part_namespace + return rendering.part_class unless namespace name = rendering.inflector.camelize(name.to_s) @@ -91,7 +91,7 @@ def resolve_part_class(name:, rendering:) if klass && klass < Part klass else - rendering.config.part_class + rendering.part_class end end end diff --git a/lib/hanami/view/renderer.rb b/lib/hanami/view/renderer.rb index f7810f42..1306f3f6 100644 --- a/lib/hanami/view/renderer.rb +++ b/lib/hanami/view/renderer.rb @@ -21,12 +21,8 @@ class Renderer # @api private # @since 2.1.0 - attr_reader :config, :prefixes - - # @api private - # @since 2.1.0 - def initialize(config) - @config = config + def initialize(config_data) + @config_data = config_data @prefixes = [CURRENT_PATH_PREFIX] end @@ -37,7 +33,7 @@ def template(name, format, scope, &block) template_path = lookup(name, format) - raise TemplateNotFoundError.new(name, format, config.paths) unless template_path + raise TemplateNotFoundError.new(name, format, config_data.paths) unless template_path new_prefix = File.dirname(name) @prefixes << new_prefix unless @prefixes.include?(new_prefix) @@ -55,10 +51,12 @@ def partial(name, format, scope, &block) private + attr_reader :config_data, :prefixes + def lookup(name, format) - View.cache.fetch_or_store(:lookup, name, format, config, prefixes) { + View.cache.fetch_or_store(:lookup, name, format, config_data.object_id, prefixes) { catch :found do - config.paths.reduce(nil) do |_, path| + config_data.paths.reduce(nil) do |_, path| prefixes.reduce(nil) do |_, prefix| result = path.lookup(prefix, name, format) throw :found, result if result @@ -79,8 +77,8 @@ def render(path, scope, &block) end def tilt(path) - View.cache.fetch_or_store(:tilt, path, config) { - Hanami::View::Tilt[path, config.renderer_engine_mapping, config.renderer_options] + View.cache.fetch_or_store(:tilt, path, config_data.object_id) { + Hanami::View::Tilt[path, config_data.renderer_engine_mapping, config_data.renderer_options] } end end diff --git a/lib/hanami/view/rendering.rb b/lib/hanami/view/rendering.rb index 6e228e26..e9143cb1 100644 --- a/lib/hanami/view/rendering.rb +++ b/lib/hanami/view/rendering.rb @@ -7,28 +7,43 @@ class View class Rendering # @api private # @since 2.1.0 - attr_reader :config, :format + attr_reader :format # @api private # @since 2.1.0 attr_reader :inflector, :part_builder, :scope_builder + # @api private + # @since 2.3.0 + attr_reader :part_class, :part_namespace, :scope_class, :scope_namespace + + # Stable identity for the underlying config snapshot, suitable as a cache-key component. + # + # @api private + # @since 2.3.0 + attr_reader :cache_key + # @api private # @since 2.1.0 - attr_reader :context, :renderer + attr_reader :context # @api private # @since 2.1.0 - def initialize(config:, format:, context:) - @config = config + def initialize(config_data:, format:, context:) @format = format - @inflector = config.inflector - @part_builder = config.part_builder - @scope_builder = config.scope_builder + @inflector = config_data.inflector + @part_builder = config_data.part_builder + @scope_builder = config_data.scope_builder + + @part_class = config_data.part_class + @part_namespace = config_data.part_namespace + @scope_class = config_data.scope_class + @scope_namespace = config_data.scope_namespace + @cache_key = config_data.object_id @context = context.dup_for_rendering(self) - @renderer = Renderer.new(config) + @renderer = Renderer.new(config_data) end # @api private @@ -54,6 +69,10 @@ def part(name, value, as: nil) def scope(name = nil, locals) # rubocop:disable Style/OptionalArguments scope_builder.(name, locals: locals, rendering: self) end + + private + + attr_reader :renderer end end end diff --git a/lib/hanami/view/scope_builder.rb b/lib/hanami/view/scope_builder.rb index 5f497c9b..79ec9d9c 100644 --- a/lib/hanami/view/scope_builder.rb +++ b/lib/hanami/view/scope_builder.rb @@ -27,11 +27,11 @@ def call(name = nil, locals:, rendering:) def scope_class(name = nil, rendering:) if name.nil? - rendering.config.scope_class + rendering.scope_class elsif name.is_a?(Class) name else - View.cache.fetch_or_store(name, rendering.config) do + View.cache.fetch_or_store(name, rendering.cache_key) do resolve_scope_class(name: name, rendering: rendering) end end @@ -40,7 +40,7 @@ def scope_class(name = nil, rendering:) def resolve_scope_class(name:, rendering:) name = rendering.inflector.camelize(name.to_s) - namespace = rendering.config.scope_namespace + namespace = rendering.scope_namespace # Give autoloaders a chance to act begin @@ -55,7 +55,7 @@ def resolve_scope_class(name:, rendering:) if klass && klass < Scope klass else - rendering.config.scope_class + rendering.scope_class end end end diff --git a/spec/integration/part/decorated_attributes_spec.rb b/spec/integration/part/decorated_attributes_spec.rb index 56f4d499..e8a64fae 100644 --- a/spec/integration/part/decorated_attributes_spec.rb +++ b/spec/integration/part/decorated_attributes_spec.rb @@ -60,7 +60,7 @@ def initialize(author:, body:) let(:rendering) { Hanami::View::Rendering.new( - config: view.config, + config_data: view.config.to_data, format: :html, context: Hanami::View::Context.new ) @@ -71,6 +71,7 @@ def initialize(author:, body:) Class.new(Hanami::View) { config.part_builder = part_builder if part_builder + finalize! } } diff --git a/spec/unit/renderer_spec.rb b/spec/unit/renderer_spec.rb index 8063d3b2..59586642 100644 --- a/spec/unit/renderer_spec.rb +++ b/spec/unit/renderer_spec.rb @@ -1,7 +1,9 @@ # frozen_string_literal: true RSpec.describe Hanami::View::Renderer do - subject(:renderer) { Hanami::View::Renderer.new(view_class.config) } + subject(:renderer) { Hanami::View::Renderer.new(config_data) } + + let(:config_data) { view_class.config.to_data } let(:view_class) { Class.new(Hanami::View) {