fix(render): preserve scope chain when self: bound on render tag

The render tag wrote `self:` attributes directly into @scopes[0] under
key 'self', which shadowed the SelfDrop. `self[var]` lookups inside the
snippet then did strict key access on the bound object instead of
walking the scope chain, resolving to nil for any key not in the object.

SelfDrop now holds an optional `bound_self`; `[]` and `key?` consult it
first via duck typing (matches lib/liquid/variable_lookup.rb), then fall
through to the scope-chain walk on miss. Render#render_tag routes
attribute key Expression::SELF to inner_context.self_drop.bound_self=
instead of the generic scope write. Lookup order is bound-first;
existing self: users' hits stay intact, previously-nil misses now
resolve via fallthrough.

PR #2060 introduced the SelfDrop and bare-bracket prohibition but had
no coverage for the {% render 'snippet', self: obj %} interaction.
Adds 8 tests including two-deep nested renders with leak detection
and a composite chain combining renders, top-level self[var], and
regular variables.

Discovered while investigating SFR strict-parser migration parity
diffs.
This commit is contained in:
Josh Faigan
2026-04-30 16:46:46 -04:00
parent 1954a2655c
commit bb40962e4e
4 changed files with 266 additions and 4 deletions
+5 -1
View File
@@ -200,6 +200,10 @@ module Liquid
object.respond_to?(:evaluate) ? object.evaluate(self) : object
end
def self_drop
@self_drop ||= SelfDrop.new(self)
end
# Fetches an object starting at the local scope and then moving up the hierachy
def find_variable(key, raise_on_not_found: true)
# This was changed from find() to find_index() because this is a very hot
@@ -208,7 +212,7 @@ module Liquid
# `self` resolves to a SelfDrop (enabling `self['var']` lookups),
# but only when it hasn't been explicitly assigned as a local variable.
return SelfDrop.new(self) if key == Expression::SELF && !index
return self_drop if key == Expression::SELF && !index
variable = if index
lookup_and_evaluate(@scopes[index], key, raise_on_not_found: raise_on_not_found)
+21 -2
View File
@@ -16,23 +16,42 @@ module Liquid
# then the local value takes precedence over the `self` object.
# @liquid_access global
class SelfDrop < Drop
attr_accessor :bound_self
def initialize(context)
super()
@context = context
@bound_self = nil
end
def [](key)
@context.find_variable(key)
if @bound_self && bound_has?(key)
bound_lookup(key)
else
@context.find_variable(key)
end
rescue UndefinedVariable
nil
end
def key?(key)
@context.variable_defined?(key)
(@bound_self && bound_has?(key)) || @context.variable_defined?(key)
end
def to_liquid
self
end
private
def bound_has?(key)
@bound_self.respond_to?(:key?) && @bound_self.key?(key)
end
def bound_lookup(key)
return unless @bound_self.respond_to?(:[])
@bound_self[key]
end
end
end
+6 -1
View File
@@ -66,7 +66,12 @@ module Liquid
inner_context['forloop'] = forloop if forloop
@attributes.each do |key, value|
inner_context[key] = context.evaluate(value)
evaluated = context.evaluate(value)
if key == Expression::SELF
inner_context.self_drop.bound_self = evaluated
else
inner_context[key] = evaluated
end
end
inner_context[context_variable_name] = var unless var.nil?
partial.render_to_output_buffer(inner_context, output)