From 9bed375855ab019bfc70ae94f531b8b3c9956831 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20Barri=C3=A9?= Date: Thu, 18 Jun 2026 20:04:58 +0200 Subject: [PATCH 1/8] Pass a cache flag instead of a details key through template lookup Template lookup threaded a details_key alongside the details hash: an interned TemplateDetails::Requested computed by LookupContext::DetailsKey when caching was on, nil when off. In the resolver that key did two things -- stand in as the requested-details object for matching, and, by being non-nil, select the persistent @unbound_templates cache over a throwaway one. It was never part of the cache key itself, which is just the virtual path; the interning bought the resolver nothing. Pass the @cache boolean through find/find!/find_all/exists? instead. The resolver builds Requested from the details hash directly and picks its cache by the flag. LookupContext's own details are always valid formats (formats= validates), so dropping the DetailsKey formats normalization on this path is a no-op; it still guards the digest cache key, which keeps using DetailsKey for now. Removes the now-unused LookupContext#details_key. --- actionview/lib/action_view/lookup_context.rb | 44 ++++--------------- actionview/lib/action_view/path_set.rb | 18 ++++---- .../lib/action_view/template/resolver.rb | 14 +++--- .../test/template/lookup_context_test.rb | 22 ---------- 4 files changed, 25 insertions(+), 73 deletions(-) diff --git a/actionview/lib/action_view/lookup_context.rb b/actionview/lib/action_view/lookup_context.rb index 08ae118f67b76..7eb3aaa50006c 100644 --- a/actionview/lib/action_view/lookup_context.rb +++ b/actionview/lib/action_view/lookup_context.rb @@ -107,13 +107,6 @@ def self.view_context_class module DetailsCache attr_accessor :cache - # Calculate the details key. Remove the handlers from calculation to improve performance - # since the user cannot modify it explicitly. - def details_key # :nodoc: - @details_key ||= DetailsKey.details_cache_key(@details) if @cache - end - - # Temporary skip passing the details_key forward. def disable_cache old_value, @cache = @cache, false yield @@ -123,9 +116,8 @@ def disable_cache private def _set_detail(key, value) # :doc: - @details = @details.dup if @digest_cache || @details_key + @details = @details.dup if @digest_cache @digest_cache = nil - @details_key = nil @details[key] = value end end @@ -136,33 +128,28 @@ module ViewPaths def find(name, prefixes = [], partial = false, keys = [], options = {}) name, prefixes = normalize_name(name, prefixes) - details, details_key = detail_args_for(options) - @view_paths.find(name, prefixes, partial, details, details_key, keys) + @view_paths.find(name, prefixes, partial, detail_args_for(options), @cache, keys) end def find!(name, prefixes = [], partial = false, keys = [], options = {}) name, prefixes = normalize_name(name, prefixes) - details, details_key = detail_args_for(options) - @view_paths.find!(name, prefixes, partial, details, details_key, keys) + @view_paths.find!(name, prefixes, partial, detail_args_for(options), @cache, keys) end def find_all(name, prefixes = [], partial = false, keys = [], options = {}) name, prefixes = normalize_name(name, prefixes) - details, details_key = detail_args_for(options) - @view_paths.find_all(name, prefixes, partial, details, details_key, keys) + @view_paths.find_all(name, prefixes, partial, detail_args_for(options), @cache, keys) end def exists?(name, prefixes = [], partial = false, keys = [], **options) name, prefixes = normalize_name(name, prefixes) - details, details_key = detail_args_for(options) - @view_paths.exists?(name, prefixes, partial, details, details_key, keys) + @view_paths.exists?(name, prefixes, partial, detail_args_for(options), @cache, keys) end alias :template_exists? :exists? def any?(name, prefixes = [], partial = false) name, prefixes = normalize_name(name, prefixes) - details, details_key = detail_args_for_any - @view_paths.exists?(name, prefixes, partial, details, details_key, []) + @view_paths.exists?(name, prefixes, partial, detail_args_for_any, @cache, []) end alias :any_templates? :any? @@ -191,16 +178,8 @@ def build_view_paths(paths) # Compute details hash and key according to user options (e.g. passed from #render). def detail_args_for(options) # :doc: - return @details, details_key if options.empty? # most common path. - user_details = @details.merge(options) - - if @cache - details_key = DetailsKey.details_cache_key(user_details) - else - details_key = nil - end - - [user_details, details_key] + return @details if options.empty? # most common path. + @details.merge(options) end def detail_args_for_any @@ -215,11 +194,7 @@ def detail_args_for_any end end - if @cache - [details, DetailsKey.details_cache_key(details)] - else - [details, nil] - end + details end end @@ -248,7 +223,6 @@ def normalize_name(name, prefixes) include ViewPaths def initialize(view_paths, details = {}, prefixes = []) - @details_key = nil @digest_cache = nil @cache = true @prefixes = prefixes diff --git a/actionview/lib/action_view/path_set.rb b/actionview/lib/action_view/path_set.rb index 01d1ffcc46c79..60c8239374ac1 100644 --- a/actionview/lib/action_view/path_set.rb +++ b/actionview/lib/action_view/path_set.rb @@ -37,25 +37,25 @@ def +(other) PathSet.new(paths + array) end - def find(path, prefixes, partial, details, details_key, locals) - find_all(path, prefixes, partial, details, details_key, locals).first + def find(path, prefixes, partial, details, cache, locals) + find_all(path, prefixes, partial, details, cache, locals).first end - def find!(path, prefixes, partial, details, details_key, locals) - find(path, prefixes, partial, details, details_key, locals) || - raise(MissingTemplate.new(self, path, prefixes, partial, details, details_key, locals)) + def find!(path, prefixes, partial, details, cache, locals) + find(path, prefixes, partial, details, cache, locals) || + raise(MissingTemplate.new(self, path, prefixes, partial, details, cache, locals)) end - def find_all(path, prefixes, partial, details, details_key, locals) + def find_all(path, prefixes, partial, details, cache, locals) search_combinations(prefixes) do |resolver, prefix| - templates = resolver.find_all(path, prefix, partial, details, details_key, locals) + templates = resolver.find_all(path, prefix, partial, details, cache, locals) return templates unless templates.empty? end [] end - def exists?(path, prefixes, partial, details, details_key, locals) - find_all(path, prefixes, partial, details, details_key, locals).any? + def exists?(path, prefixes, partial, details, cache, locals) + find_all(path, prefixes, partial, details, cache, locals).any? end private diff --git a/actionview/lib/action_view/template/resolver.rb b/actionview/lib/action_view/template/resolver.rb index f07f97d568fdd..4eacd3d0921f5 100644 --- a/actionview/lib/action_view/template/resolver.rb +++ b/actionview/lib/action_view/template/resolver.rb @@ -57,8 +57,8 @@ def clear_cache end # Normalizes the arguments and passes it on to find_templates. - def find_all(name, prefix = nil, partial = false, details = {}, key = nil, locals = []) - _find_all(name, prefix, partial, details, key, locals) + def find_all(name, prefix = nil, partial = false, details = {}, cache = false, locals = []) + _find_all(name, prefix, partial, details, cache, locals) end def built_templates # :nodoc: @@ -72,7 +72,7 @@ def all_template_paths # :nodoc: end private - def _find_all(name, prefix, partial, details, key, locals) + def _find_all(name, prefix, partial, details, cache, locals) find_templates(name, prefix, partial, details, locals) end @@ -128,12 +128,12 @@ def built_templates # :nodoc: end private - def _find_all(name, prefix, partial, details, key, locals) - requested_details = key || TemplateDetails::Requested.new(**details) - cache = key ? @unbound_templates : Concurrent::Map.new + def _find_all(name, prefix, partial, details, cache, locals) + requested_details = TemplateDetails::Requested.new(**details) + store = cache ? @unbound_templates : Concurrent::Map.new unbound_templates = - cache.compute_if_absent(TemplatePath.virtual(name, prefix, partial)) do + store.compute_if_absent(TemplatePath.virtual(name, prefix, partial)) do path = TemplatePath.build(name, prefix, partial) unbound_templates_from_path(path) end diff --git a/actionview/test/template/lookup_context_test.rb b/actionview/test/template/lookup_context_test.rb index e849999c892ee..482bcb9967c54 100644 --- a/actionview/test/template/lookup_context_test.rb +++ b/actionview/test/template/lookup_context_test.rb @@ -127,28 +127,6 @@ def teardown end end - test "generates a new details key for each details hash" do - keys = [] - keys << @lookup_context.details_key - assert_equal 1, keys.uniq.size - - @lookup_context.locale = :da - keys << @lookup_context.details_key - assert_equal 2, keys.uniq.size - - @lookup_context.locale = :en - keys << @lookup_context.details_key - assert_equal 2, keys.uniq.size - - @lookup_context.formats = [:html] - keys << @lookup_context.details_key - assert_equal 3, keys.uniq.size - - @lookup_context.formats = nil - keys << @lookup_context.details_key - assert_equal 3, keys.uniq.size - end - test "uses details as part of cache key" do fixtures = { "test/_foo.erb" => "Foo", From 64dd7c575bafff8173abbc2a9d189e68e2c9471e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20Barri=C3=A9?= Date: Thu, 18 Jun 2026 20:08:19 +0200 Subject: [PATCH 2/8] Introduce a LookupContext::Details value object The requested template details -- the four registered axes locale, formats, variants and handlers -- were held in a plain Hash, with the accessors generated by register_detail writing into it and a default_procs registry supplying defaults. LookupContext mixed in the generated Accessors plus a DetailsCache module, and reached into @details[:key] from its own overrides. Replace the Hash with a small mutable value object, LookupContext::Details, that owns the four axes, their lazy defaults, and the formats normalization (expanding "*/*", validating symbols, the :js -> :html fallback) that used to live in LookupContext#formats=. Each accessor sets its own instance variable directly. LookupContext delegates the registered accessors to it and keeps only the locale (symbol vs array) and I18n-config special cases. This is behaviour-preserving: details are still threaded downstream as a Hash via Details#to_h, so the resolver, digest cache and lookup path are unchanged. register_detail / default_procs / the generated Accessors module are dropped in favour of the explicit REGISTERED_DETAILS list. --- actionview/lib/action_view/lookup_context.rb | 232 +++++++++++-------- 1 file changed, 134 insertions(+), 98 deletions(-) diff --git a/actionview/lib/action_view/lookup_context.rb b/actionview/lib/action_view/lookup_context.rb index 7eb3aaa50006c..a72acf34e9ddb 100644 --- a/actionview/lib/action_view/lookup_context.rb +++ b/actionview/lib/action_view/lookup_context.rb @@ -15,43 +15,121 @@ module ActionView class LookupContext # :nodoc: attr_accessor :prefixes - singleton_class.attr_accessor :default_procs - self.default_procs = {}.freeze - def self.registered_details - self.default_procs.keys + [:locale, :formats, :variants, :handlers] end - def self.register_detail(name, &block) - self.default_procs = self.default_procs.merge(name => block).freeze + class Details + def initialize(locale: nil, formats: nil, variants: nil, handlers: nil) + @locale = locale && Array(locale) + @formats = formats && Array(formats) + @variants = variants == :any ? :any : variants && Array(variants) + @handlers = handlers && Array(handlers) + end - Accessors.define_method(:"default_#{name}", &block) - Accessors.module_eval <<-METHOD, __FILE__, __LINE__ + 1 - def #{name} - @details[:#{name}] || [] - end + attr_reader :html_fallback_for_js + + def locale + @locale ||= default_locale + end - def #{name}=(value) - value = value.present? ? Array(value) : default_#{name} - _set_detail(:#{name}, value) if value != @details[:#{name}] + def locale=(value) + value = value.present? ? Array(value) : default_locale + return if value == @locale + @locale = value + @digest_cache = nil + end + + def formats + @formats ||= default_formats + end + + def formats=(values) + if values + values = values.dup + values.concat(default_formats) if values.delete "*/*" + values.uniq! + + Template.validate_formats(values) + + if (values.length == 1) && (values[0] == :js) + values << :html + @html_fallback_for_js = true + end end - METHOD - end - # Holds accessors for the registered details. - module Accessors # :nodoc: - end + values = values.presence || default_formats + return if values == @formats + @formats = values + @digest_cache = nil + end - register_detail(:locale) do - locales = [I18n.locale] - locales.concat(I18n.fallbacks[I18n.locale]) if I18n.respond_to? :fallbacks - locales << I18n.default_locale - locales.uniq! - locales + def variants + @variants ||= default_variants + end + + def variants=(value) + value = value.present? ? Array(value) : default_variants + return if value == @variants + @variants = value + @digest_cache = nil + end + + def handlers + @handlers ||= default_handlers + end + + def handlers=(value) + value = value.present? ? Array(value) : default_handlers + return if value == @handlers + @handlers = value + @digest_cache = nil + end + + def default_locale + locales = [I18n.locale] + locales.concat(I18n.fallbacks[I18n.locale]) if I18n.respond_to? :fallbacks + locales << I18n.default_locale + locales.uniq! + locales + end + + def default_formats + ActionView::Base.default_formats || [:html, :text, :js, :css, :xml, :json] + end + + def default_variants + [] + end + + def default_handlers + Template::Handlers.extensions + end + + def digest_cache + @digest_cache ||= DetailsKey.digest_cache(to_h) + end + + def merge(options) + return self if [:locale, :formats, :variants, :handlers].freeze.none? { |key| options[key] } + self.class.new( + locale: options[:locale] || locale, + formats: options[:formats] || formats, + variants: options[:variants] || variants, + handlers: options[:handlers] || handlers, + ) + end + + def to_h + { locale: locale, formats: formats, variants: variants, handlers: handlers } + end + + private + def initialize_copy(other) + @digest_cache = nil + super + end end - register_detail(:formats) { ActionView::Base.default_formats || [:html, :text, :js, :css, :xml, :json] } - register_detail(:variants) { [] } - register_detail(:handlers) { Template::Handlers.extensions } class DetailsKey # :nodoc: alias :eql? :equal? @@ -103,28 +181,9 @@ def self.view_context_class @view_context_mutex = Mutex.new ActiveSupport.on_load(:action_view) { ActionView::LookupContext.view_context_class } - # Add caching behavior on top of Details. - module DetailsCache - attr_accessor :cache - - def disable_cache - old_value, @cache = @cache, false - yield - ensure - @cache = old_value - end - - private - def _set_detail(key, value) # :doc: - @details = @details.dup if @digest_cache - @digest_cache = nil - @details[key] = value - end - end - # Helpers related to template lookup using the lookup context information. module ViewPaths - attr_reader :view_paths, :html_fallback_for_js + attr_reader :view_paths def find(name, prefixes = [], partial = false, keys = [], options = {}) name, prefixes = normalize_name(name, prefixes) @@ -178,24 +237,12 @@ def build_view_paths(paths) # Compute details hash and key according to user options (e.g. passed from #render). def detail_args_for(options) # :doc: - return @details if options.empty? # most common path. - @details.merge(options) + return @details.to_h if options.empty? # most common path. + @details.merge(options).to_h end def detail_args_for_any - @detail_args_for_any ||= begin - details = {} - - LookupContext.registered_details.each do |k| - if k == :variants - details[k] = :any - else - details[k] = LookupContext.default_procs[k].call - end - end - - details - end + @detail_args_for_any ||= Details.new(variants: :any).to_h end # Fix when prefix is specified as part of the template name @@ -218,59 +265,48 @@ def normalize_name(name, prefixes) end end - include Accessors - include DetailsCache include ViewPaths + attr_accessor :cache + def initialize(view_paths, details = {}, prefixes = []) - @digest_cache = nil @cache = true @prefixes = prefixes - @details = initialize_details({}, details) + @details = if details.is_a?(Details) + details + else + Details.new( + locale: details[:locale], + formats: details[:formats], + variants: details[:variants], + handlers: details[:handlers], + ) + end + @view_paths = build_view_paths(view_paths) end - def digest_cache - @digest_cache ||= DetailsKey.digest_cache(@details) + delegate :formats, :formats=, :variants, :variants=, :handlers, :handlers=, + :html_fallback_for_js, :default_formats, :digest_cache, to: :@details + + def disable_cache + old_value, @cache = @cache, false + yield + ensure + @cache = old_value end def with_prepended_formats(formats) details = @details.dup - details[:formats] = formats + details.formats = formats self.class.new(@view_paths, details, @prefixes) end - def initialize_details(target, details) - LookupContext.registered_details.each do |k| - target[k] = details[k] || LookupContext.default_procs[k].call - end - target - end - private :initialize_details - - # Override formats= to expand ["*/*"] values and automatically - # add :html as fallback to :js. - def formats=(values) - if values - values = values.dup - values.concat(default_formats) if values.delete "*/*" - values.uniq! - - Template.validate_formats(values) - - if (values.length == 1) && (values[0] == :js) - values << :html - @html_fallback_for_js = true - end - end - super(values) - end - # Override locale to return a symbol instead of array. def locale - @details[:locale].first + @details.locale.first end # Overload locale= to also set the I18n.locale. If the current I18n.config object responds @@ -282,7 +318,7 @@ def locale=(value) config.locale = value end - super(default_locale) + @details.locale = @details.default_locale end end end From f1a0d41cf04de65acbeeb59fa0d13d92697a42bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20Barri=C3=A9?= Date: Thu, 18 Jun 2026 20:57:30 +0200 Subject: [PATCH 3/8] Rank templates from the request Details Template matching went through TemplateDetails::Requested: the resolver built one from the details hash, then asked each candidate template whether it matched? it and, if several did, computed a sort_key_for each. Ranking lived on the template's own details, keyed off precomputed index hashes on Requested. Move ranking onto the requested Details with #template_rank, which returns the per-axis index tuple ([format, locale, variant, handler]) used to order matches, or nil when a template doesn't match. The Details object now flows through find/find_all unchanged -- LookupContext#detail_args_for returns it directly instead of a hash -- and the resolver ranks against it. This also lets lookup ask for the single best match without materializing the whole candidate list: add Resolver#find / PathSet#find that rank-and-keep-best (find_best_by_details) and short-circuit per resolver, which is what the non-raising #find introduced earlier now uses. TemplateDetails#matches? and #sort_key_for are gone. --- actionview/lib/action_view/lookup_context.rb | 31 +++++++++- actionview/lib/action_view/path_set.rb | 8 ++- .../lib/action_view/template/resolver.rb | 57 +++++++++++++------ .../lib/action_view/template_details.rb | 16 ------ .../test/template/lookup_context_test.rb | 2 +- .../test/template/resolver_shared_tests.rb | 30 +++++----- .../template/testing/fixture_resolver_test.rb | 14 ++--- 7 files changed, 99 insertions(+), 59 deletions(-) diff --git a/actionview/lib/action_view/lookup_context.rb b/actionview/lib/action_view/lookup_context.rb index a72acf34e9ddb..d41e8b0958522 100644 --- a/actionview/lib/action_view/lookup_context.rb +++ b/actionview/lib/action_view/lookup_context.rb @@ -120,6 +120,15 @@ def merge(options) ) end + def template_rank(template) + d = template.details + format = rank(formats, d.format) or return + locale = rank(self.locale, d.locale) or return + variant = variant_rank(d.variant) or return + handler = rank(handlers, d.handler) or return + [format, locale, variant, handler] + end + def to_h { locale: locale, formats: formats, variants: variants, handlers: handlers } end @@ -129,6 +138,22 @@ def initialize_copy(other) @digest_cache = nil super end + + def rank(requested, value) + if requested + requested.index(value) || (requested.size if value.nil?) + elsif value.nil? + 0 + end + end + + def variant_rank(value) + if variants == :any + value.nil? ? 0 : 1 + else + rank(variants, value) + end + end end class DetailsKey # :nodoc: @@ -237,12 +262,12 @@ def build_view_paths(paths) # Compute details hash and key according to user options (e.g. passed from #render). def detail_args_for(options) # :doc: - return @details.to_h if options.empty? # most common path. - @details.merge(options).to_h + return @details if options.empty? # most common path. + @details.merge(options) end def detail_args_for_any - @detail_args_for_any ||= Details.new(variants: :any).to_h + @detail_args_for_any ||= Details.new(variants: :any) end # Fix when prefix is specified as part of the template name diff --git a/actionview/lib/action_view/path_set.rb b/actionview/lib/action_view/path_set.rb index 60c8239374ac1..641ed6f989eb9 100644 --- a/actionview/lib/action_view/path_set.rb +++ b/actionview/lib/action_view/path_set.rb @@ -38,12 +38,16 @@ def +(other) end def find(path, prefixes, partial, details, cache, locals) - find_all(path, prefixes, partial, details, cache, locals).first + search_combinations(prefixes) do |resolver, prefix| + template = resolver.find(path, prefix, partial, details, cache, locals) + return template if template + end + nil end def find!(path, prefixes, partial, details, cache, locals) find(path, prefixes, partial, details, cache, locals) || - raise(MissingTemplate.new(self, path, prefixes, partial, details, cache, locals)) + raise(MissingTemplate.new(self, path, prefixes, partial, details.to_h, cache, locals)) end def find_all(path, prefixes, partial, details, cache, locals) diff --git a/actionview/lib/action_view/template/resolver.rb b/actionview/lib/action_view/template/resolver.rb index 4eacd3d0921f5..20bea000da0db 100644 --- a/actionview/lib/action_view/template/resolver.rb +++ b/actionview/lib/action_view/template/resolver.rb @@ -3,6 +3,7 @@ require "pathname" require "active_support/core_ext/class" require "active_support/core_ext/module/attribute_accessors" +require "active_support/core_ext/string/access" require "action_view/template" require "concurrent/map" @@ -61,6 +62,10 @@ def find_all(name, prefix = nil, partial = false, details = {}, cache = false, l _find_all(name, prefix, partial, details, cache, locals) end + def find(name, prefix = nil, partial = false, details = {}, cache = false, locals = []) + _find(name, prefix, partial, details, cache, locals) + end + def built_templates # :nodoc: # Used for error pages [] @@ -76,6 +81,10 @@ def _find_all(name, prefix, partial, details, cache, locals) find_templates(name, prefix, partial, details, locals) end + def _find(name, prefix, partial, details, cache, locals) + find_all(name, prefix, partial, details, cache, locals).first + end + delegate :caching?, to: :class # This is what child classes implement. No defaults are needed @@ -129,20 +138,26 @@ def built_templates # :nodoc: private def _find_all(name, prefix, partial, details, cache, locals) - requested_details = TemplateDetails::Requested.new(**details) - store = cache ? @unbound_templates : Concurrent::Map.new + unbound_templates = unbound_templates_for(name, prefix, partial, cache) - unbound_templates = - store.compute_if_absent(TemplatePath.virtual(name, prefix, partial)) do - path = TemplatePath.build(name, prefix, partial) - unbound_templates_from_path(path) - end - - filter_and_sort_by_details(unbound_templates, requested_details).map do |unbound_template| + filter_and_sort_by_details(unbound_templates, details).map do |unbound_template| unbound_template.bind_locals(locals) end end + def _find(name, prefix, partial, details, cache, locals) + unbound_templates = unbound_templates_for(name, prefix, partial, cache) + + find_best_by_details(unbound_templates, details)&.bind_locals(locals) + end + + def unbound_templates_for(name, prefix, partial, cache) + store = cache ? @unbound_templates : Concurrent::Map.new + store.compute_if_absent(TemplatePath.virtual(name, prefix, partial)) do + unbound_templates_from_path(TemplatePath.build(name, prefix, partial)) + end + end + def source_for_template(template) Template::Sources::File.new(template) end @@ -177,18 +192,26 @@ def unbound_templates_from_path(path) end end - def filter_and_sort_by_details(templates, requested_details) - filtered_templates = templates.select do |template| - template.details.matches?(requested_details) + def filter_and_sort_by_details(templates, details) + ranked = templates.filter_map do |template| + rank = details.template_rank(template) + [rank, template] if rank end - if filtered_templates.count > 1 - filtered_templates.sort_by! do |template| - template.details.sort_key_for(requested_details) + ranked.sort_by!(&:first) if ranked.size > 1 + ranked.map!(&:last) + end + + def find_best_by_details(templates, details) + best = best_rank = nil + templates.each do |template| + rank = details.template_rank(template) or next + if best_rank.nil? || (rank <=> best_rank) < 0 + best = template + best_rank = rank end end - - filtered_templates + best end # Safe glob within @path diff --git a/actionview/lib/action_view/template_details.rb b/actionview/lib/action_view/template_details.rb index 0f74fb65b2a48..f48fb4bcc40b2 100644 --- a/actionview/lib/action_view/template_details.rb +++ b/actionview/lib/action_view/template_details.rb @@ -39,22 +39,6 @@ def initialize(locale, handler, format, variant) @variant = variant end - def matches?(requested) - requested.formats_idx[@format] && - requested.locale_idx[@locale] && - requested.variants_idx[@variant] && - requested.handlers_idx[@handler] - end - - def sort_key_for(requested) - [ - requested.formats_idx[@format], - requested.locale_idx[@locale], - requested.variants_idx[@variant], - requested.handlers_idx[@handler] - ] - end - def handler_class Template.handler_for_extension(handler) end diff --git a/actionview/test/template/lookup_context_test.rb b/actionview/test/template/lookup_context_test.rb index 482bcb9967c54..e73a8460e2b68 100644 --- a/actionview/test/template/lookup_context_test.rb +++ b/actionview/test/template/lookup_context_test.rb @@ -214,7 +214,7 @@ def setup test "if a single prefix is passed as a string and the lookup fails, MissingTemplate accepts it" do e = assert_raise ActionView::MissingTemplate do - details = { handlers: [], formats: [], variants: [], locale: [] } + details = ActionView::LookupContext::Details.new(handlers: [], formats: [], variants: [], locale: []) @lookup_context.view_paths.find!("foo", "parent", true, details, nil, []) end assert_match %r{Missing partial parent/_foo with .*\n\nSearched in:\n \* "/Path/to/views"\n}, e.message diff --git a/actionview/test/template/resolver_shared_tests.rb b/actionview/test/template/resolver_shared_tests.rb index 67bff214a1945..cc41306b1343d 100644 --- a/actionview/test/template/resolver_shared_tests.rb +++ b/actionview/test/template/resolver_shared_tests.rb @@ -19,10 +19,14 @@ def context @context ||= ActionView::LookupContext.new(resolver) end + def details(**args) + ActionView::LookupContext::Details.new(**args) + end + def test_can_find_with_no_extensions with_file "test/hello_world", "Hello default!" - templates = resolver.find_all("hello_world", "test", false, locale: [:en], formats: [:html], variants: [:phone], handlers: [:erb]) + templates = resolver.find_all("hello_world", "test", false, details(locale: [:en], formats: [:html], variants: [:phone], handlers: [:erb])) assert_equal 1, templates.size assert_equal "Hello default!", templates[0].source assert_equal "test/hello_world", templates[0].virtual_path @@ -34,7 +38,7 @@ def test_can_find_with_no_extensions def test_can_find_with_just_handler with_file "test/hello_world.erb", "Hello erb!" - templates = resolver.find_all("hello_world", "test", false, locale: [:en], formats: [:html], variants: [:phone], handlers: [:erb]) + templates = resolver.find_all("hello_world", "test", false, details(locale: [:en], formats: [:html], variants: [:phone], handlers: [:erb])) assert_equal 1, templates.size assert_equal "Hello erb!", templates[0].source assert_equal "test/hello_world", templates[0].virtual_path @@ -46,7 +50,7 @@ def test_can_find_with_just_handler def test_can_find_with_format_and_handler with_file "test/hello_world.text.builder", "Hello plain text!" - templates = resolver.find_all("hello_world", "test", false, locale: [:en], formats: [:html, :text], variants: [:phone], handlers: [:erb, :builder]) + templates = resolver.find_all("hello_world", "test", false, details(locale: [:en], formats: [:html, :text], variants: [:phone], handlers: [:erb, :builder])) assert_equal 1, templates.size assert_equal "Hello plain text!", templates[0].source assert_equal "test/hello_world", templates[0].virtual_path @@ -58,7 +62,7 @@ def test_can_find_with_format_and_handler def test_can_find_with_variant_format_and_handler with_file "test/hello_world.html+phone.erb", "Hello plain text!" - templates = resolver.find_all("hello_world", "test", false, locale: [:en], formats: [:html], variants: [:phone], handlers: [:erb]) + templates = resolver.find_all("hello_world", "test", false, details(locale: [:en], formats: [:html], variants: [:phone], handlers: [:erb])) assert_equal 1, templates.size assert_equal "Hello plain text!", templates[0].source assert_equal "test/hello_world", templates[0].virtual_path @@ -70,7 +74,7 @@ def test_can_find_with_variant_format_and_handler def test_can_find_with_any_variant_format_and_handler with_file "test/hello_world.html+phone.erb", "Hello plain text!" - templates = resolver.find_all("hello_world", "test", false, locale: [:en], formats: [:html], variants: :any, handlers: [:erb]) + templates = resolver.find_all("hello_world", "test", false, details(locale: [:en], formats: [:html], variants: :any, handlers: [:erb])) assert_equal 1, templates.size assert_equal "Hello plain text!", templates[0].source assert_equal "test/hello_world", templates[0].virtual_path @@ -83,7 +87,7 @@ def test_can_find_when_special_chars_in_path dir = "test +()[]{}" with_file "#{dir}/hello_world", "Hello funky path!" - templates = resolver.find_all("hello_world", dir, false, locale: [:en], formats: [:html], variants: [:phone], handlers: [:erb]) + templates = resolver.find_all("hello_world", dir, false, details(locale: [:en], formats: [:html], variants: [:phone], handlers: [:erb])) assert_equal 1, templates.size assert_equal "Hello funky path!", templates[0].source assert_equal "#{dir}/hello_world", templates[0].virtual_path @@ -92,10 +96,10 @@ def test_can_find_when_special_chars_in_path def test_doesnt_find_template_with_wrong_details with_file "test/hello_world.html.erb", "Hello plain text!" - templates = resolver.find_all("hello_world", "test", false, locale: [], formats: [:xml], variants: :any, handlers: [:builder]) + templates = resolver.find_all("hello_world", "test", false, details(locale: [], formats: [:xml], variants: :any, handlers: [:builder])) assert_equal 0, templates.size - templates = resolver.find_all("hello_world", "test", false, locale: [], formats: [:xml], variants: :any, handlers: [:erb]) + templates = resolver.find_all("hello_world", "test", false, details(locale: [], formats: [:xml], variants: :any, handlers: [:erb])) assert_equal 0, templates.size end @@ -150,7 +154,7 @@ def test_templates_sort_by_formats_json_first with_file "test/hello_world.html.erb", "Hello HTML!" with_file "test/hello_world.json.builder", "Hello JSON!" - templates = resolver.find_all("hello_world", "test", false, locale: [], formats: [:json, :html], variants: :any, handlers: [:erb, :builder]) + templates = resolver.find_all("hello_world", "test", false, details(locale: [], formats: [:json, :html], variants: :any, handlers: [:erb, :builder])) assert_equal 2, templates.size assert_equal "Hello JSON!", templates[0].source @@ -163,7 +167,7 @@ def test_templates_sort_by_formats_html_first with_file "test/hello_world.html.erb", "Hello HTML!" with_file "test/hello_world.json.builder", "Hello JSON!" - templates = resolver.find_all("hello_world", "test", false, locale: [], formats: [:html, :json], variants: :any, handlers: [:erb, :builder]) + templates = resolver.find_all("hello_world", "test", false, details(locale: [], formats: [:html, :json], variants: :any, handlers: [:erb, :builder])) assert_equal 2, templates.size assert_equal "Hello HTML!", templates[0].source @@ -175,7 +179,7 @@ def test_templates_sort_by_formats_html_first def test_templates_with_variant with_file "test/hello_world.html+mobile.erb", "Hello HTML!" - templates = resolver.find_all("hello_world", "test", false, locale: [], formats: [:html, :json], variants: :any, handlers: [:erb, :builder]) + templates = resolver.find_all("hello_world", "test", false, details(locale: [], formats: [:html, :json], variants: :any, handlers: [:erb, :builder])) assert_equal 1, templates.size assert_equal "Hello HTML!", templates[0].source @@ -206,7 +210,7 @@ def test_finds_variants_in_order def test_templates_no_format_with_variant with_file "test/hello_world+mobile.erb", "Hello HTML!" - templates = resolver.find_all("hello_world", "test", false, locale: [], formats: [:html, :json], variants: :any, handlers: [:erb, :builder]) + templates = resolver.find_all("hello_world", "test", false, details(locale: [], formats: [:html, :json], variants: :any, handlers: [:erb, :builder])) assert_equal 1, templates.size assert_equal "Hello HTML!", templates[0].source @@ -218,7 +222,7 @@ def test_templates_no_format_with_variant def test_templates_no_format_or_handler_with_variant with_file "test/hello_world+mobile", "Hello HTML!" - templates = resolver.find_all("hello_world", "test", false, locale: [], formats: [:html, :json], variants: :any, handlers: [:erb, :builder]) + templates = resolver.find_all("hello_world", "test", false, details(locale: [], formats: [:html, :json], variants: :any, handlers: [:erb, :builder])) assert_equal 1, templates.size assert_equal "Hello HTML!", templates[0].source diff --git a/actionview/test/template/testing/fixture_resolver_test.rb b/actionview/test/template/testing/fixture_resolver_test.rb index 2b0136ff2224a..cc67c5559094c 100644 --- a/actionview/test/template/testing/fixture_resolver_test.rb +++ b/actionview/test/template/testing/fixture_resolver_test.rb @@ -5,13 +5,13 @@ class FixtureResolverTest < ActiveSupport::TestCase def test_should_return_empty_list_for_unknown_path resolver = ActionView::FixtureResolver.new() - templates = resolver.find_all("path", "arbitrary", false, locale: [], formats: [:html], variants: [], handlers: []) + templates = resolver.find_all("path", "arbitrary", false, ActionView::LookupContext::Details.new(locale: [], formats: [:html], variants: [], handlers: [])) assert_equal [], templates, "expected an empty list of templates" end def test_should_return_template_for_declared_path resolver = ActionView::FixtureResolver.new("arbitrary/path.erb" => "this text") - templates = resolver.find_all("path", "arbitrary", false, locale: [], formats: [:html], variants: [], handlers: [:erb]) + templates = resolver.find_all("path", "arbitrary", false, ActionView::LookupContext::Details.new(locale: [], formats: [:html], variants: [], handlers: [:erb])) assert_equal 1, templates.size, "expected one template" assert_equal "this text", templates.first.source assert_equal "arbitrary/path", templates.first.virtual_path @@ -20,7 +20,7 @@ def test_should_return_template_for_declared_path def test_should_match_templates_with_variants resolver = ActionView::FixtureResolver.new("arbitrary/path.html+variant.erb" => "this text") - templates = resolver.find_all("path", "arbitrary", false, locale: [], formats: [:html], variants: [:variant], handlers: [:erb]) + templates = resolver.find_all("path", "arbitrary", false, ActionView::LookupContext::Details.new(locale: [], formats: [:html], variants: [:variant], handlers: [:erb])) assert_equal 1, templates.size, "expected one template" assert_equal "this text", templates.first.source assert_equal "arbitrary/path", templates.first.virtual_path @@ -30,8 +30,8 @@ def test_should_match_templates_with_variants def test_should_match_locales resolver = ActionView::FixtureResolver.new("arbitrary/path.erb" => "this text", "arbitrary/path.fr.erb" => "ce texte") - en = resolver.find_all("path", "arbitrary", false, locale: [:en], formats: [:html], variants: [], handlers: [:erb]) - fr = resolver.find_all("path", "arbitrary", false, locale: [:fr], formats: [:html], variants: [], handlers: [:erb]) + en = resolver.find_all("path", "arbitrary", false, ActionView::LookupContext::Details.new(locale: [:en], formats: [:html], variants: [], handlers: [:erb])) + fr = resolver.find_all("path", "arbitrary", false, ActionView::LookupContext::Details.new(locale: [:fr], formats: [:html], variants: [], handlers: [:erb])) assert_equal 1, en.size assert_equal 2, fr.size @@ -43,10 +43,10 @@ def test_should_match_locales def test_should_return_all_variants_for_any resolver = ActionView::FixtureResolver.new("arbitrary/path.html.erb" => "this html", "arbitrary/path.html+variant.erb" => "this text") - templates = resolver.find_all("path", "arbitrary", false, locale: [], formats: [:html], variants: [], handlers: [:erb]) + templates = resolver.find_all("path", "arbitrary", false, ActionView::LookupContext::Details.new(locale: [], formats: [:html], variants: [], handlers: [:erb])) assert_equal 1, templates.size, "expected one template" assert_equal "this html", templates.first.source - templates = resolver.find_all("path", "arbitrary", false, locale: [], formats: [:html], variants: :any, handlers: [:erb]) + templates = resolver.find_all("path", "arbitrary", false, ActionView::LookupContext::Details.new(locale: [], formats: [:html], variants: :any, handlers: [:erb])) assert_equal 2, templates.size, "expected all templates" end end From 8181543b24bd0a0bbd75a544a1095a0e21e66331 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20Barri=C3=A9?= Date: Thu, 18 Jun 2026 20:58:49 +0200 Subject: [PATCH 4/8] Key the template digest cache by a frozen Array, not an interned object The per-details digest cache was keyed by the interned TemplateDetails::Requested that DetailsKey.details_cache_key returns: equal detail hashes were folded to a shared Requested object so they'd hash-equal as a cache key. Have Details produce the key itself with #to_cache_key -- a frozen, value-equal Array snapshot of the resolved axes (with formats normalized, which is the only thing the interner's normalization was still doing) -- and key the digest cache by that. Value-equal Arrays already hash-equal, so the interning is no longer needed to share a bucket, and the mutable Details object never has to double as a Hash key. Details#to_h, now only used by #inspect, is inlined there. --- actionview/lib/action_view/lookup_context.rb | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/actionview/lib/action_view/lookup_context.rb b/actionview/lib/action_view/lookup_context.rb index d41e8b0958522..58bddca22eebd 100644 --- a/actionview/lib/action_view/lookup_context.rb +++ b/actionview/lib/action_view/lookup_context.rb @@ -107,7 +107,7 @@ def default_handlers end def digest_cache - @digest_cache ||= DetailsKey.digest_cache(to_h) + @digest_cache ||= DetailsKey.digest_cache(to_cache_key) end def merge(options) @@ -154,6 +154,10 @@ def variant_rank(value) rank(variants, value) end end + + def to_cache_key + [locale, Template.normalized_formats(formats) || formats, variants, handlers].freeze + end end class DetailsKey # :nodoc: @@ -162,8 +166,8 @@ class DetailsKey # :nodoc: @details_keys = Concurrent::Map.new @digest_cache = Concurrent::Map.new - def self.digest_cache(details) - @digest_cache[details_cache_key(details)] ||= Concurrent::Map.new + def self.digest_cache(key) + @digest_cache[key] ||= Concurrent::Map.new end def self.details_cache_key(details) From b2d74804f2c0f837d8c4861eff315dd3563390a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20Barri=C3=A9?= Date: Thu, 18 Jun 2026 21:00:44 +0200 Subject: [PATCH 5/8] Move the template digest cache to ActionView::Digestor The process-wide store of per-details digest buckets lived on LookupContext::DetailsKey (@digest_cache, plus digest_cache and digest_caches), even though every reader goes through Digestor.digest and the node digests. Move it next to that code as Digestor.cache / Digestor.digest_caches / Digestor.clear_cache. Details#digest_cache now interns its bucket through Digestor.cache(to_cache_key), and DetailsKey.clear resets it via Digestor.clear_cache. DetailsKey is left holding only the Requested interner and clear. --- actionview/lib/action_view/digestor.rb | 14 ++++++++++++++ actionview/lib/action_view/lookup_context.rb | 13 ++----------- .../application/per_request_digest_cache_test.rb | 2 +- 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/actionview/lib/action_view/digestor.rb b/actionview/lib/action_view/digestor.rb index 0d906cb9c4431..21349637416fe 100644 --- a/actionview/lib/action_view/digestor.rb +++ b/actionview/lib/action_view/digestor.rb @@ -1,12 +1,26 @@ # frozen_string_literal: true +require "concurrent/map" require "action_view/dependency_tracker" module ActionView class Digestor @@digest_mutex = Mutex.new + @cache = Concurrent::Map.new class << self + def cache(details_key) + @cache[details_key] ||= Concurrent::Map.new + end + + def digest_caches + @cache.values + end + + def clear_cache + @cache.clear + end + # Supported options: # # * name - Template name diff --git a/actionview/lib/action_view/lookup_context.rb b/actionview/lib/action_view/lookup_context.rb index 58bddca22eebd..7ab81e46e70a2 100644 --- a/actionview/lib/action_view/lookup_context.rb +++ b/actionview/lib/action_view/lookup_context.rb @@ -107,7 +107,7 @@ def default_handlers end def digest_cache - @digest_cache ||= DetailsKey.digest_cache(to_cache_key) + @digest_cache ||= Digestor.cache(to_cache_key) end def merge(options) @@ -164,11 +164,6 @@ class DetailsKey # :nodoc: alias :eql? :equal? @details_keys = Concurrent::Map.new - @digest_cache = Concurrent::Map.new - - def self.digest_cache(key) - @digest_cache[key] ||= Concurrent::Map.new - end def self.details_cache_key(details) @details_keys.fetch(details) do @@ -188,11 +183,7 @@ def self.clear end ActionView::LookupContext.reset_view_context_class @details_keys.clear - @digest_cache.clear - end - - def self.digest_caches - @digest_cache.values + Digestor.clear_cache end end diff --git a/railties/test/application/per_request_digest_cache_test.rb b/railties/test/application/per_request_digest_cache_test.rb index 809f94be8dcd1..e77c582c94f54 100644 --- a/railties/test/application/per_request_digest_cache_test.rb +++ b/railties/test/application/per_request_digest_cache_test.rb @@ -56,7 +56,7 @@ def index get "/customers" assert_equal 200, last_response.status - values = ActionView::LookupContext::DetailsKey.digest_caches.first.values + values = ActionView::Digestor.digest_caches.first.values assert_equal [ "ddb451d2c1b2374caa676005893bb776" ], values assert_equal %w(david dingus), last_response.body.split.map(&:strip) end From f750cd83d23fe2beb32578792a818481e5e41072 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20Barri=C3=A9?= Date: Thu, 18 Jun 2026 21:01:45 +0200 Subject: [PATCH 6/8] Remove TemplateDetails::Requested and the details interner With ranking moved onto Details#template_rank and the digest cache keyed by a frozen Array, nothing builds a TemplateDetails::Requested or its precomputed index hashes anymore, and nothing looks up DetailsKey.details_cache_key. The @details_keys interner that handed out shared Requested instances is dead. Delete the Requested class and the interner. DetailsKey is left holding only clear. --- actionview/lib/action_view/lookup_context.rb | 17 ----------- .../lib/action_view/template_details.rb | 28 ------------------- 2 files changed, 45 deletions(-) diff --git a/actionview/lib/action_view/lookup_context.rb b/actionview/lib/action_view/lookup_context.rb index 7ab81e46e70a2..1e2b008b26c08 100644 --- a/actionview/lib/action_view/lookup_context.rb +++ b/actionview/lib/action_view/lookup_context.rb @@ -161,28 +161,11 @@ def to_cache_key end class DetailsKey # :nodoc: - alias :eql? :equal? - - @details_keys = Concurrent::Map.new - - def self.details_cache_key(details) - @details_keys.fetch(details) do - if formats = details[:formats] - if normalized = Template.normalized_formats(formats) - details = details.dup - details[:formats] = normalized - end - end - @details_keys[details] ||= TemplateDetails::Requested.new(**details) - end - end - def self.clear ActionView::PathRegistry.all_resolvers.each do |resolver| resolver.clear_cache end ActionView::LookupContext.reset_view_context_class - @details_keys.clear Digestor.clear_cache end end diff --git a/actionview/lib/action_view/template_details.rb b/actionview/lib/action_view/template_details.rb index f48fb4bcc40b2..97872922e2a39 100644 --- a/actionview/lib/action_view/template_details.rb +++ b/actionview/lib/action_view/template_details.rb @@ -2,34 +2,6 @@ module ActionView class TemplateDetails # :nodoc: - class Requested - attr_reader :locale, :handlers, :formats, :variants - attr_reader :locale_idx, :handlers_idx, :formats_idx, :variants_idx - - ANY_HASH = Hash.new(1).merge(nil => 0).freeze - - def initialize(locale:, handlers:, formats:, variants:) - @locale = locale - @handlers = handlers - @formats = formats - @variants = variants - - @locale_idx = build_idx_hash(locale) - @handlers_idx = build_idx_hash(handlers) - @formats_idx = build_idx_hash(formats) - if variants == :any - @variants_idx = ANY_HASH - else - @variants_idx = build_idx_hash(variants) - end - end - - private - def build_idx_hash(arr) - [*arr, nil].each_with_index.to_h.freeze - end - end - attr_reader :locale, :handler, :format, :variant def initialize(locale, handler, format, variant) From edb046b3118ebfd4f3f07e11d5e91cf8d610993f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20Barri=C3=A9?= Date: Thu, 18 Jun 2026 21:02:37 +0200 Subject: [PATCH 7/8] Remove LookupContext::DetailsKey DetailsKey existed to intern detail hashes and hold the digest cache; both are gone. All it had left was clear, which resets the resolver caches, the shared view-context class and the digest cache. Fold that into LookupContext.clear and delete DetailsKey. Callers that reached for ActionView::LookupContext::DetailsKey.clear now call ActionView::LookupContext.clear. --- actionmailer/lib/action_mailer.rb | 2 +- actionpack/lib/action_dispatch.rb | 2 +- .../test/controller/mime/respond_to_test.rb | 4 ++-- actionpack/test/controller/render_test.rb | 4 ++-- actionview/lib/action_view/cache_expiry.rb | 2 +- actionview/lib/action_view/lookup_context.rb | 16 ++++++---------- .../test/actionpack/controller/layout_test.rb | 4 ++-- .../test/actionpack/controller/render_test.rb | 4 ++-- .../actionpack/controller/view_paths_test.rb | 2 +- ...er_partial_with_record_identification_test.rb | 2 +- .../test/template/compiled_templates_test.rb | 2 +- actionview/test/template/digestor_test.rb | 2 +- actionview/test/template/log_subscriber_test.rb | 2 +- actionview/test/template/lookup_context_test.rb | 2 +- actionview/test/template/render_test.rb | 8 ++++---- .../test/template/streaming_render_test.rb | 2 +- .../template/structured_event_subscriber_test.rb | 2 +- actionview/test/template/test_case_test.rb | 2 +- .../application/per_request_digest_cache_test.rb | 2 +- 19 files changed, 31 insertions(+), 35 deletions(-) diff --git a/actionmailer/lib/action_mailer.rb b/actionmailer/lib/action_mailer.rb index 1ab3de192c599..66315710785bc 100644 --- a/actionmailer/lib/action_mailer.rb +++ b/actionmailer/lib/action_mailer.rb @@ -107,5 +107,5 @@ def _deliver_all_later(delivery_method, *deliveries, **options) ActiveSupport.on_load(:action_view) do ActionView::Base.default_formats ||= Mime.symbols ActionView::Template.mime_types_implementation = Mime - ActionView::LookupContext::DetailsKey.clear + ActionView::LookupContext.clear end diff --git a/actionpack/lib/action_dispatch.rb b/actionpack/lib/action_dispatch.rb index e6f5d0819e924..7d332768ee14b 100644 --- a/actionpack/lib/action_dispatch.rb +++ b/actionpack/lib/action_dispatch.rb @@ -163,5 +163,5 @@ def self.eager_load! ActiveSupport.on_load(:action_view) do ActionView::Base.default_formats ||= Mime.symbols ActionView::Template.mime_types_implementation = Mime - ActionView::LookupContext::DetailsKey.clear + ActionView::LookupContext.clear end diff --git a/actionpack/test/controller/mime/respond_to_test.rb b/actionpack/test/controller/mime/respond_to_test.rb index 5a9e8f1906e18..4ff1ababeddbe 100644 --- a/actionpack/test/controller/mime/respond_to_test.rb +++ b/actionpack/test/controller/mime/respond_to_test.rb @@ -336,7 +336,7 @@ def setup Mime::Type.register("text/x-mobile", :mobile) Mime::Type.register("application/fancy-xml", :fancy_xml) Mime::Type.register("text/html; fragment", :html_fragment) - ActionView::LookupContext::DetailsKey.clear + ActionView::LookupContext.clear end def teardown @@ -345,7 +345,7 @@ def teardown Mime::Type.unregister(:mobile) Mime::Type.unregister(:fancy_xml) Mime::Type.unregister(:html_fragment) - ActionView::LookupContext::DetailsKey.clear + ActionView::LookupContext.clear end def test_html_fragment diff --git a/actionpack/test/controller/render_test.rb b/actionpack/test/controller/render_test.rb index 7b37dd5d4ccbb..adffe8ef65fef 100644 --- a/actionpack/test/controller/render_test.rb +++ b/actionpack/test/controller/render_test.rb @@ -354,11 +354,11 @@ def modify_template(name) key = name + ".erb" original = hash[key] hash[key] = "#{original} Modified!" - ActionView::LookupContext::DetailsKey.clear + ActionView::LookupContext.clear yield ensure hash[key] = original - ActionView::LookupContext::DetailsKey.clear + ActionView::LookupContext.clear end end diff --git a/actionview/lib/action_view/cache_expiry.rb b/actionview/lib/action_view/cache_expiry.rb index 488c6768ccf0e..4bdf9a0bd42ac 100644 --- a/actionview/lib/action_view/cache_expiry.rb +++ b/actionview/lib/action_view/cache_expiry.rb @@ -52,7 +52,7 @@ def deactivate private def reload! - ActionView::LookupContext::DetailsKey.clear + ActionView::LookupContext.clear end def build_watcher diff --git a/actionview/lib/action_view/lookup_context.rb b/actionview/lib/action_view/lookup_context.rb index 1e2b008b26c08..6958f868955c4 100644 --- a/actionview/lib/action_view/lookup_context.rb +++ b/actionview/lib/action_view/lookup_context.rb @@ -19,6 +19,12 @@ def self.registered_details [:locale, :formats, :variants, :handlers] end + def self.clear + ActionView::PathRegistry.all_resolvers.each(&:clear_cache) + reset_view_context_class + Digestor.clear_cache + end + class Details def initialize(locale: nil, formats: nil, variants: nil, handlers: nil) @locale = locale && Array(locale) @@ -160,16 +166,6 @@ def to_cache_key end end - class DetailsKey # :nodoc: - def self.clear - ActionView::PathRegistry.all_resolvers.each do |resolver| - resolver.clear_cache - end - ActionView::LookupContext.reset_view_context_class - Digestor.clear_cache - end - end - def self.reset_view_context_class @view_context_mutex.synchronize { @view_context_class = nil } end diff --git a/actionview/test/actionpack/controller/layout_test.rb b/actionview/test/actionpack/controller/layout_test.rb index 886015ebdbfee..fd4bd639befa9 100644 --- a/actionview/test/actionpack/controller/layout_test.rb +++ b/actionview/test/actionpack/controller/layout_test.rb @@ -19,12 +19,12 @@ module TemplateHandlerHelper def with_template_handler(*extensions, handler) ActionView::Template.register_template_handler(*extensions, handler) ActionController::Base.view_paths.paths.each(&:clear_cache) - ActionView::LookupContext::DetailsKey.clear + ActionView::LookupContext.clear yield ensure ActionView::Template.unregister_template_handler(*extensions) ActionController::Base.view_paths.paths.each(&:clear_cache) - ActionView::LookupContext::DetailsKey.clear + ActionView::LookupContext.clear end end diff --git a/actionview/test/actionpack/controller/render_test.rb b/actionview/test/actionpack/controller/render_test.rb index 631f0a717795b..53e88d3a86254 100644 --- a/actionview/test/actionpack/controller/render_test.rb +++ b/actionview/test/actionpack/controller/render_test.rb @@ -1528,11 +1528,11 @@ def test_render_renderable def with_annotations_enabled ActionView::Base.annotate_rendered_view_with_filenames = true - ActionView::LookupContext::DetailsKey.clear + ActionView::LookupContext.clear yield ensure ActionView::Base.annotate_rendered_view_with_filenames = false - ActionView::LookupContext::DetailsKey.clear + ActionView::LookupContext.clear end def test_template_annotations diff --git a/actionview/test/actionpack/controller/view_paths_test.rb b/actionview/test/actionpack/controller/view_paths_test.rb index 4e2446f724563..9bbe2dcf5f121 100644 --- a/actionview/test/actionpack/controller/view_paths_test.rb +++ b/actionview/test/actionpack/controller/view_paths_test.rb @@ -30,7 +30,7 @@ def hello_world; render(template: "test/hello_world"); end end def setup - ActionView::LookupContext::DetailsKey.clear + ActionView::LookupContext.clear @controller = TestController.new @request = ActionController::TestRequest.create(@controller.class) diff --git a/actionview/test/activerecord/render_partial_with_record_identification_test.rb b/actionview/test/activerecord/render_partial_with_record_identification_test.rb index f8a374517d5da..d8030ebbc072e 100644 --- a/actionview/test/activerecord/render_partial_with_record_identification_test.rb +++ b/actionview/test/activerecord/render_partial_with_record_identification_test.rb @@ -54,7 +54,7 @@ class RenderPartialWithRecordIdentificationTest < ActiveRecordTestCase def setup super - ActionView::LookupContext::DetailsKey.clear + ActionView::LookupContext.clear end def test_rendering_partial_with_has_many_and_belongs_to_association diff --git a/actionview/test/template/compiled_templates_test.rb b/actionview/test/template/compiled_templates_test.rb index ea9646b484a5f..28b25013bfa8a 100644 --- a/actionview/test/template/compiled_templates_test.rb +++ b/actionview/test/template/compiled_templates_test.rb @@ -14,7 +14,7 @@ def setup def teardown super - ActionView::LookupContext::DetailsKey.clear + ActionView::LookupContext.clear end def test_template_with_nil_erb_return diff --git a/actionview/test/template/digestor_test.rb b/actionview/test/template/digestor_test.rb index 38989ef6a5760..a19881bb361b3 100644 --- a/actionview/test/template/digestor_test.rb +++ b/actionview/test/template/digestor_test.rb @@ -23,7 +23,7 @@ def setup @cwd = Dir.pwd @tmp_dir = Dir.mktmpdir - ActionView::LookupContext::DetailsKey.clear + ActionView::LookupContext.clear FileUtils.cp_r FixtureFinder::FIXTURES_DIR, @tmp_dir Dir.chdir @tmp_dir end diff --git a/actionview/test/template/log_subscriber_test.rb b/actionview/test/template/log_subscriber_test.rb index afa2479f649e9..80872f7a4f860 100644 --- a/actionview/test/template/log_subscriber_test.rb +++ b/actionview/test/template/log_subscriber_test.rb @@ -21,7 +21,7 @@ def setup @old_logger = ActionView::LogSubscriber.logger ActionView::LogSubscriber.logger = @logger - ActionView::LookupContext::DetailsKey.clear + ActionView::LookupContext.clear view_paths = ActionController::Base.view_paths diff --git a/actionview/test/template/lookup_context_test.rb b/actionview/test/template/lookup_context_test.rb index e73a8460e2b68..70b8790395d95 100644 --- a/actionview/test/template/lookup_context_test.rb +++ b/actionview/test/template/lookup_context_test.rb @@ -6,7 +6,7 @@ class LookupContextTest < ActiveSupport::TestCase def setup @lookup_context = build_lookup_context(FIXTURE_LOAD_PATH, {}) - ActionView::LookupContext::DetailsKey.clear + ActionView::LookupContext.clear end def build_lookup_context(paths, details) diff --git a/actionview/test/template/render_test.rb b/actionview/test/template/render_test.rb index 1aa7be3a9e13c..b4f1e939ef8a9 100644 --- a/actionview/test/template/render_test.rb +++ b/actionview/test/template/render_test.rb @@ -845,7 +845,7 @@ class FrozenStringLiteralEnabledViewRenderTest < ActiveSupport::TestCase include RenderTestCases def setup - ActionView::LookupContext::DetailsKey.clear + ActionView::LookupContext.clear @previous_frozen_literal = ActionView::Template.frozen_string_literal ActionView::Template.frozen_string_literal = true @@ -871,7 +871,7 @@ class CachedViewRenderTest < ActiveSupport::TestCase # Ensure view path cache is primed def setup - ActionView::LookupContext::DetailsKey.clear + ActionView::LookupContext.clear view_paths = ActionController::Base.view_paths assert_equal ActionView::FileSystemResolver, view_paths.first.class setup_view(view_paths) @@ -909,7 +909,7 @@ class LazyViewRenderTest < ActiveSupport::TestCase # Test the same thing as above, but make sure the view path # is not eager loaded def setup - ActionView::LookupContext::DetailsKey.clear + ActionView::LookupContext.clear path = ActionView::FileSystemResolver.new(FIXTURE_LOAD_PATH) view_paths = ActionView::PathSet.new([path]) assert_equal ActionView::FileSystemResolver.new(FIXTURE_LOAD_PATH), view_paths.first @@ -962,7 +962,7 @@ class CachedCustomer < Customer; end # Ensure view path cache is primed setup do - ActionView::LookupContext::DetailsKey.clear + ActionView::LookupContext.clear view_paths = ActionController::Base.view_paths assert_equal ActionView::FileSystemResolver, view_paths.first.class diff --git a/actionview/test/template/streaming_render_test.rb b/actionview/test/template/streaming_render_test.rb index f0e7ec1241e2a..2b5d8cdead7e8 100644 --- a/actionview/test/template/streaming_render_test.rb +++ b/actionview/test/template/streaming_render_test.rb @@ -7,7 +7,7 @@ class TestController < ActionController::Base class SetupFiberedBase < ActiveSupport::TestCase def setup - ActionView::LookupContext::DetailsKey.clear + ActionView::LookupContext.clear view_paths = ActionController::Base.view_paths diff --git a/actionview/test/template/structured_event_subscriber_test.rb b/actionview/test/template/structured_event_subscriber_test.rb index 9b0cd6457ca6d..3423699e6bd2b 100644 --- a/actionview/test/template/structured_event_subscriber_test.rb +++ b/actionview/test/template/structured_event_subscriber_test.rb @@ -12,7 +12,7 @@ class StructuredEventSubscriberTest < ActiveSupport::TestCase def setup super - ActionView::LookupContext::DetailsKey.clear + ActionView::LookupContext.clear view_paths = ActionController::Base.view_paths diff --git a/actionview/test/template/test_case_test.rb b/actionview/test/template/test_case_test.rb index 21bc4a04f66c1..ff71f23cc8a15 100644 --- a/actionview/test/template/test_case_test.rb +++ b/actionview/test/template/test_case_test.rb @@ -26,7 +26,7 @@ class TestCase module SharedTests def setup - ActionView::LookupContext::DetailsKey.clear + ActionView::LookupContext.clear super end diff --git a/railties/test/application/per_request_digest_cache_test.rb b/railties/test/application/per_request_digest_cache_test.rb index e77c582c94f54..2825ce8f63941 100644 --- a/railties/test/application/per_request_digest_cache_test.rb +++ b/railties/test/application/per_request_digest_cache_test.rb @@ -62,7 +62,7 @@ def index end test "template digests are cleared before a request" do - assert_called(ActionView::LookupContext::DetailsKey, :clear, times: 2) do + assert_called(ActionView::LookupContext, :clear, times: 2) do get "/customers" assert_equal 200, last_response.status end From 3347a94798086a7c9f98666dd2eac1392c64ac35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20Barri=C3=A9?= Date: Thu, 18 Jun 2026 21:03:40 +0200 Subject: [PATCH 8/8] Flow Details through the renderers instead of an extracted hash The renderers turned the render options into a details hash with AbstractRenderer#extract_details (filtering to the registered keys and Array-wrapping each), then handed that hash to LookupContext, which merged it onto its own details on every lookup. Have the renderers ask the LookupContext for a resolved Details up front: LookupContext#details_for is now public and, besides the no-options and overrides cases, returns an already-resolved Details untouched so a renderer's @details flows straight back through find/find_all without being re-merged. TemplateRenderer#resolve_layout builds its format-scoped lookups with Details#merge. extract_details / NO_DETAILS are gone. find/find!/find_all default options to nil, and PathSet#exists? short-circuits on the first matching resolver rather than gathering all matches. --- actionview/lib/action_view/lookup_context.rb | 45 ++++++++++--------- actionview/lib/action_view/path_set.rb | 5 ++- .../action_view/renderer/abstract_renderer.rb | 14 ------ .../action_view/renderer/partial_renderer.rb | 2 +- .../action_view/renderer/template_renderer.rb | 2 +- 5 files changed, 29 insertions(+), 39 deletions(-) diff --git a/actionview/lib/action_view/lookup_context.rb b/actionview/lib/action_view/lookup_context.rb index 6958f868955c4..e52d3baea2186 100644 --- a/actionview/lib/action_view/lookup_context.rb +++ b/actionview/lib/action_view/lookup_context.rb @@ -15,10 +15,6 @@ module ActionView class LookupContext # :nodoc: attr_accessor :prefixes - def self.registered_details - [:locale, :formats, :variants, :handlers] - end - def self.clear ActionView::PathRegistry.all_resolvers.each(&:clear_cache) reset_view_context_class @@ -184,35 +180,36 @@ def self.view_context_class module ViewPaths attr_reader :view_paths - def find(name, prefixes = [], partial = false, keys = [], options = {}) + def find(name, prefixes = [], partial = false, keys = [], options = nil) name, prefixes = normalize_name(name, prefixes) - @view_paths.find(name, prefixes, partial, detail_args_for(options), @cache, keys) + @view_paths.find(name, prefixes, partial, details_for(options), @cache, keys) end - def find!(name, prefixes = [], partial = false, keys = [], options = {}) + def find!(name, prefixes = [], partial = false, keys = [], options = nil) name, prefixes = normalize_name(name, prefixes) - @view_paths.find!(name, prefixes, partial, detail_args_for(options), @cache, keys) + @view_paths.find!(name, prefixes, partial, details_for(options), @cache, keys) end - def find_all(name, prefixes = [], partial = false, keys = [], options = {}) + def find_all(name, prefixes = [], partial = false, keys = [], options = nil) name, prefixes = normalize_name(name, prefixes) - @view_paths.find_all(name, prefixes, partial, detail_args_for(options), @cache, keys) + @view_paths.find_all(name, prefixes, partial, details_for(options), @cache, keys) end def exists?(name, prefixes = [], partial = false, keys = [], **options) name, prefixes = normalize_name(name, prefixes) - @view_paths.exists?(name, prefixes, partial, detail_args_for(options), @cache, keys) + @view_paths.exists?(name, prefixes, partial, details_for(options), @cache, keys) end alias :template_exists? :exists? def any?(name, prefixes = [], partial = false) name, prefixes = normalize_name(name, prefixes) - @view_paths.exists?(name, prefixes, partial, detail_args_for_any, @cache, []) + @view_paths.exists?(name, prefixes, partial, details_for_any, @cache, []) end alias :any_templates? :any? - def any_formats?(name, prefixes = [], partial = false, keys = [], options = {}) - exists?(name, prefixes, partial, keys, **options, formats: default_formats) + def any_formats?(name, prefixes = [], partial = false, keys = [], options = nil) + name, prefixes = normalize_name(name, prefixes) + @view_paths.exists?(name, prefixes, partial, details_for(options).merge(formats: default_formats), @cache, keys) end def append_view_paths(paths) @@ -223,6 +220,16 @@ def prepend_view_paths(paths) @view_paths = build_view_paths(paths + @view_paths.to_a) end + # Resolves the Details to look up with from the user options passed to + # #render (e.g. formats: :json): the context's own Details when + # there are none, an already-resolved Details (e.g. from a renderer) left + # untouched, or the context's Details with the overrides layered on. + def details_for(options) + return @details unless options + return options if Details === options + @details.merge(options) + end + private # Whenever setting view paths, makes a copy so that we can manipulate them in # instance objects as we wish. @@ -234,14 +241,8 @@ def build_view_paths(paths) end end - # Compute details hash and key according to user options (e.g. passed from #render). - def detail_args_for(options) # :doc: - return @details if options.empty? # most common path. - @details.merge(options) - end - - def detail_args_for_any - @detail_args_for_any ||= Details.new(variants: :any) + def details_for_any + @details_for_any ||= Details.new(variants: :any) end # Fix when prefix is specified as part of the template name diff --git a/actionview/lib/action_view/path_set.rb b/actionview/lib/action_view/path_set.rb index 641ed6f989eb9..ba28c4b816fce 100644 --- a/actionview/lib/action_view/path_set.rb +++ b/actionview/lib/action_view/path_set.rb @@ -59,7 +59,10 @@ def find_all(path, prefixes, partial, details, cache, locals) end def exists?(path, prefixes, partial, details, cache, locals) - find_all(path, prefixes, partial, details, cache, locals).any? + search_combinations(prefixes) do |resolver, prefix| + return true if resolver.find(path, prefix, partial, details, cache, locals) + end + false end private diff --git a/actionview/lib/action_view/renderer/abstract_renderer.rb b/actionview/lib/action_view/renderer/abstract_renderer.rb index e5702654e1b7a..fa328583babf7 100644 --- a/actionview/lib/action_view/renderer/abstract_renderer.rb +++ b/actionview/lib/action_view/renderer/abstract_renderer.rb @@ -154,20 +154,6 @@ def format end private - NO_DETAILS = {}.freeze - - def extract_details(options) # :doc: - details = nil - LookupContext.registered_details.each do |key| - value = options[key] - - if value - (details ||= {})[key] = Array(value) - end - end - details || NO_DETAILS - end - def prepend_formats(formats) # :doc: formats = Array(formats) return if formats.empty? || @lookup_context.html_fallback_for_js diff --git a/actionview/lib/action_view/renderer/partial_renderer.rb b/actionview/lib/action_view/renderer/partial_renderer.rb index 2a11dda74cdbc..189edebad3ef5 100644 --- a/actionview/lib/action_view/renderer/partial_renderer.rb +++ b/actionview/lib/action_view/renderer/partial_renderer.rb @@ -240,7 +240,7 @@ def initialize(lookup_context, options) super(lookup_context) @options = options @locals = @options[:locals] || {} - @details = extract_details(@options) + @details = @lookup_context.details_for(@options) end def render(partial, context, block) diff --git a/actionview/lib/action_view/renderer/template_renderer.rb b/actionview/lib/action_view/renderer/template_renderer.rb index 1e4f77cfbb482..9ff136c1c5734 100644 --- a/actionview/lib/action_view/renderer/template_renderer.rb +++ b/actionview/lib/action_view/renderer/template_renderer.rb @@ -3,7 +3,7 @@ module ActionView class TemplateRenderer < AbstractRenderer # :nodoc: def render(context, options, &block) - @details = extract_details(options) + @details = @lookup_context.details_for(options) template = determine_template(options, &block) prepend_formats(template.format)