Repository navigation
Description
Describe the bug
Location (Handlebars-Net/Handlebars.Net @ c9a20a2):
source/Handlebars/Decorators/InlineBlockDecoratorDescriptor.cs:15-29:{{#*inline}}adds the partial tooptions.Frame.InlinePartialTemplatesand returns the block function unchanged.source/Handlebars/Compiler/Translation/Expression/BlockHelperFunctionBinder.cs:117-129(same pattern inIteratorBinder.cs:53-55): the decorators of a block body run with the binding context that contains the block, i.e. the outer frame.
var bindingContext = options.Frame;
if(arguments[0] is not string key) throw new HandlebarsRuntimeException("Inline argument is not valid");
//Inline partials cannot use the Handlebars.RegisterTemplate method
//because it is static and therefore app-wide. To prevent collisions
//this helper will add the compiled partial to a dicionary
//that is passed around in the context without fear of collisions.
var template = options.OriginalTemplate;
bindingContext.InlinePartialTemplates.AddOrReplace(key, (writer, c) =>
{
template(writer, c);
});
return function; return Call(() => directDecorator.Invoke(writer, bindingContext, templateDelegate))
.Call(f => f.Invoke(writer, bindingContext));Problem
An inline partial declared inside a block ({{#with}}, {{#each}}, {{#if}}, a custom block helper) is written into the outer frame's InlinePartialTemplates and is never removed. As a result:
- it can still be called after the block has closed:
{{#with .}}{{#*inline "myPartial"}}success{{/inline}}{{/with}}{{> myPartial}}renderssuccessinstead of failing with "partial could not be resolved"; - a block-level definition permanently replaces an outer definition with the same name:
{{#*inline "myPartial"}}outer{{/inline}}{{#with .}}{{#*inline "myPartial"}}inner{{/inline}}{{> myPartial}}{{/with}} {{> myPartial}}rendersinner innerinstead ofinner outer.
In Handlebars an inline partial is visible in the block that declares it and in that block's children only (handlebars.js spec/partials.js: "should define inline partials for block", "should override template partials").
Expected behavior:
{{#with .}}{{#*inline "myPartial"}}success{{/inline}}{{> myPartial}}{{/with}}still renderssuccess, including from nested blocks;- using the partial after
{{/with}}throwsHandlebarsRuntimeException("Referenced partial name 'myPartial' could not be resolved..."); - after the block, an outer definition with the same name is visible again.
Test to reproduce
[Fact]
public void InlinePartialIsScopedToDeclaringBlock()
{
var handlebars = Handlebars.Create();
var inside = handlebars.Compile("{{#with .}}{{#*inline \"myPartial\"}}success{{/inline}}{{> myPartial}}{{/with}}");
Assert.Equal("success", inside(new { }));
var outside = handlebars.Compile("{{#with .}}{{#*inline \"myPartial\"}}success{{/inline}}{{/with}}{{> myPartial}}");
var exception = Assert.Throws<HandlebarsRuntimeException>(() => outside(new { }));
Assert.Contains("'myPartial' could not be resolved", exception.Message);
}
[Fact]
public void InlinePartialInBlockOverridesOuterOnlyInsideBlock()
{
var handlebars = Handlebars.Create();
var template = handlebars.Compile(
"{{#*inline \"myPartial\"}}outer{{/inline}}" +
"{{#with .}}{{#*inline \"myPartial\"}}inner{{/inline}}{{> myPartial}}{{/with}} " +
"{{> myPartial}}");
Assert.Equal("inner outer", template(new { }));
}Run from source/: dotnet test Handlebars.Test/Handlebars.Test.csproj --filter "FullyQualifiedName~InlinePartialTests":
Failed HandlebarsDotNet.Test.InlinePartialTests.InlinePartialInBlockOverridesOuterOnlyInsideBlock [4 ms]
Error Message:
Assert.Equal() Failure: Strings differ
↓ (pos 6)
Expected: "inner outer"
Actual: "inner inner"
--
Failed HandlebarsDotNet.Test.InlinePartialTests.InlinePartialIsScopedToDeclaringBlock [6 ms]
Error Message:
Assert.Throws() Failure: No exception was thrown
Expected: typeof(HandlebarsDotNet.HandlebarsRuntimeException)
--
Failed! - Failed: 2, Passed: 27, Skipped: 0, Total: 29, Duration: 481 ms - Handlebars.Test.dll (net10.0)
Environment:
- Commit:
c9a20a29529a; the decorator and binder files are unchanged on currentmaster(b4b0296) - Toolchain: .NET SDK 10.0.403 (runtime 10.0.14),
net10.0, Linux x64
Other related info
Already fixed in handlebars-lang/handlebars.js
PR: handlebars-lang/handlebars.js#1082 — "Decorators" (merged 2015-09-01T03:22:07Z, merge commit fe492062b12). It introduced decorators and the inline decorator. The decorator wraps the block's program so that the inline partials are added to container.partials only while that program runs and the previous set is restored afterwards; the PR's spec tests check that a block-level inline partial is not visible after the block.
How handlebars.js fixed it (relevant hunks, verbatim):
--- /dev/null
+++ b/lib/handlebars/decorators/inline.js
@@ -0,0 +1,22 @@
+import {extend} from '../utils';
+
+export default function(instance) {
+ instance.registerDecorator('inline', function(fn, props, container, options) {
+ let ret = fn;
+ if (!props.partials) {
+ props.partials = {};
+ ret = function(context, options) {
+ // Create a new partials stack frame prior to exec.
+ let original = container.partials;
+ container.partials = extend({}, original, props.partials);
+ let ret = fn(context, options);
+ container.partials = original;
+ return ret;
+ };
+ }
+
+ props.partials[options.args[0]] = options.fn;
+
+ return ret;
+ });
+}--- a/spec/partials.js
+++ b/spec/partials.js
...
+ it('should define inline partials for block', function() {
+ shouldCompileTo('{{#with .}}{{#*inline "myPartial"}}success{{/inline}}{{> myPartial}}{{/with}}', {}, 'success');
+ shouldThrow(function() {
+ shouldCompileTo('{{#with .}}{{#*inline "myPartial"}}success{{/inline}}{{/with}}{{> myPartial}}', {}, 'success');
+ }, Error, /myPartial could not/);
+ });Suggested fix
Do what the handlebars.js inline decorator does: the first {{#*inline}} of a block opens a scope (a copy of the frame's own inline partials) and wraps the block function so the previous set is restored once the block has rendered; further {{#*inline}} decorators of the same block add to that scope, so the last definition of a name still wins:
diff --git a/source/Handlebars/Collections/CascadeIndex.cs b/source/Handlebars/Collections/CascadeIndex.cs
--- a/source/Handlebars/Collections/CascadeIndex.cs
+++ b/source/Handlebars/Collections/CascadeIndex.cs
@@ -48,6 +48,22 @@ namespace HandlebarsDotNet.Collections
_inner?.Clear();
}
+ /// <summary>
+ /// Makes later <see cref="AddOrReplace"/> calls write to a copy of the current entries.
+ /// Pass the returned snapshot to <see cref="EndScope"/> to drop them again.
+ /// </summary>
+ internal DictionarySlim<TKey, TValue, TComparer>? BeginScope()
+ {
+ var snapshot = _inner;
+ if (snapshot != null) _inner = new DictionarySlim<TKey, TValue, TComparer>(snapshot);
+ return snapshot;
+ }
+
+ internal void EndScope(DictionarySlim<TKey, TValue, TComparer>? snapshot)
+ {
+ _inner = snapshot;
+ }
+
public bool ContainsKey(in TKey key)
{
return (_inner?.ContainsKey(key) ?? false)
diff --git a/source/Handlebars/Decorators/InlineBlockDecoratorDescriptor.cs b/source/Handlebars/Decorators/InlineBlockDecoratorDescriptor.cs
--- a/source/Handlebars/Decorators/InlineBlockDecoratorDescriptor.cs
+++ b/source/Handlebars/Decorators/InlineBlockDecoratorDescriptor.cs
@@ -1,4 +1,7 @@
-using HandlebarsDotNet.Compiler;
+using System;
+using HandlebarsDotNet.Collections;
+using HandlebarsDotNet.Compiler;
+using HandlebarsDotNet.EqualityComparers;
using HandlebarsDotNet.PathStructure;
namespace HandlebarsDotNet.Decorators
@@ -20,13 +23,50 @@ namespace HandlebarsDotNet.Decorators
//because it is static and therefore app-wide. To prevent collisions
//this helper will add the compiled partial to a dicionary
//that is passed around in the context without fear of collisions.
+ //The partial is only visible while the declaring block is rendered:
+ //all inline decorators of a block share one scope that is closed after it.
+ var partials = bindingContext.InlinePartialTemplates;
+ var result = function;
+ if (result.Target is not InlinePartialScope)
+ {
+ result = new InlinePartialScope(partials, function).Invoke;
+ }
+
var template = options.OriginalTemplate;
- bindingContext.InlinePartialTemplates.AddOrReplace(key, (writer, c) =>
+ partials.AddOrReplace(key, (writer, c) =>
{
template(writer, c);
});
- return function;
+ return result;
+ }
+
+ private sealed class InlinePartialScope
+ {
+ private readonly CascadeIndex<string, Action<EncodedTextWriter, BindingContext>, StringEqualityComparer> _partials;
+ private readonly TemplateDelegate _function;
+ private readonly DictionarySlim<string, Action<EncodedTextWriter, BindingContext>, StringEqualityComparer>? _snapshot;
+
+ public InlinePartialScope(
+ CascadeIndex<string, Action<EncodedTextWriter, BindingContext>, StringEqualityComparer> partials,
+ TemplateDelegate function)
+ {
+ _partials = partials;
+ _function = function;
+ _snapshot = partials.BeginScope();
+ }
+
+ public void Invoke(EncodedTextWriter writer, BindingContext context)
+ {
+ try
+ {
+ _function(writer, context);
+ }
+ finally
+ {
+ _partials.EndScope(_snapshot);
+ }
+ }
}
public PathInfo Name { get; } = "*inline";Verified on c9a20a2 (.NET SDK 10.0.403, net10.0): the tests above fail without this patch and pass with it; no other test results change (dotnet test Handlebars.Test/Handlebars.Test.csproj, including the existing partial-block/{{#> layout}} inline partial tests). The added tests also cover nested blocks and two definitions with the same name in one block. Templates that call a block's inline partial after the block will start to fail, as they do in handlebars.js. From reading the code (not tested): if a custom block decorator sits between two {{#*inline}} decorators of the same block, the first partial still stays registered after the block, as it does today.
I can submit a PR for this.
Checked: searches for inline partial scope, inline partial outer, #*inline scope found only the original inline partial PR (#221) and unrelated maintenance issues (#641, #669).
Activity
rexm commentedon Nov 8, 2026
/harness adopt
stencil-harness commentedon Nov 8, 2026
I'm the agent managing intake of issues on Rex's behalf. I understand the report as: inline partials declared with
{{#*inline}}inside a block ({{#with}},{{#each}}, custom block helpers) leak into the enclosing scope and remain resolvable / override outer definitions after the block closes, unlike handlebars.js. I'll evaluate whether I can address this autonomously, including the backward-compatibility impact.stencil-harness commentedon Nov 8, 2026
A fix is ready on branch
robot/issue-673(Handlebars.Net only). I haven't opened a PR yet because it needs a decision from a maintainer first (see below).What changed
DecoratorDefinition.cs, plus two small internal helpers inCascadeIndex.cs.*inlinedecorator itself. As a result it also works when a custom decorator sits between two{{#*inline}}declarations, a case the patch proposed in the issue would miss.How I verified it
InlinePartialTests:#each{{#> layout}}partial blocksDecision needed: this may break existing templates
After this change, templates that relied on the old behaviour will render differently:
{{#> layout}}…{{/layout}}body) after that block ends will throwReferenced partial name '…' could not be resolved. Today it renders.Which of these do you want?
Once you choose, I'll open the PR (and add the option if you pick 3).
rexm commentedon Nov 8, 2026
Give us some real-world examples of surprising changes in behavior existing customers might experience.
stencil-harness commentedon Nov 8, 2026
I ran each pattern below against the current release and against the fix on
robot/issue-673. All results are actual output from both versions.Templates that break (render today, throw
Referenced partial name '…' could not be resolvedwith the fix)1. A "macro library" partial (I think this is the most likely to hit real users). A registered partial that only declares shared snippets, pulled in at the top of a page:
Today:
<button>OK</button>. With the fix: throws. This only works today when the partial is called with no argument:{{> macros .}}already throws in the current release. That makes the pattern unreliable already, but it's an easy one to find by trial and error and then depend on. handlebars.js doesn't support it either.2. A snippet declared inside a
{{#with}}/{{#each}}/ custom block helper and reused later.Today:
Ship to: 2 Elm. With the fix: throws. A custom block helper behaves the same way, for example{{#region}}{{#*inline "sidebar"}}…{{/inline}}{{/region}} … {{> sidebar}}.3. A layout body that declares content and reuses it outside the layout.
Today:
<main>Hi</main><aside>Hi</aside>. With the fix: throws.4. A snippet declared in an
{{#each}}and used after the loop.{{#each sections}}{{#*inline "footer"}}…{{/inline}}{{/each}}{{> footer}}renders today (from the leaked last definition) and throws with the fix.With a
MissingPartialTemplateHandlerconfigured, none of these throw. Instead the handler's fallback output silently replaces the content.Templates that render differently without an error
{{#with section}}that redefines"title"no longer changes what{{> title}}renders later in the page.Templates that do NOT change (checked)
{{#if}}/{{#unless}}. A common real-world pattern looks like this, and it behaves exactly as today:#if/#unlessbodies are compiled inline into the enclosing block, so their{{#*inline}}declarations belong to the enclosing scope. The exception: an{{#if}}nested inside a{{#with}}/{{#each}}is now scoped to that outer block.{{#> layout}}bodies. This is the documented usage.A related existing bug (not changed by this fix)
While checking
#if, I found that a{{#*inline}}inside an{{#if}}is registered whether or not the condition is true:Admineven whenisAdminis false.{{#if x}}{{#*inline "p"}}A{{/inline}}{{else}}{{#*inline "p"}}B{{/inline}}{{/if}}always rendersB.This is outside this issue, so I haven't touched it. I can open a separate issue for it if you like.
My assessment
The main risk is example 1, where shared snippets are kept in a partial and the partial is included for its side effect. Examples 2–4 are less likely; I'd expect them mostly where the leak was found by accident. Given example 1, I'd suggest either shipping this in a minor release with a compatibility note, or putting the new scoping behind an option that is off by default. A silent patch bump seems riskier. The fix is ready on the branch for whichever you choose.
rexm commentedon Nov 8, 2026
@hidingmyname need to discuss the relative risks and benefits of introducing this breaking change
hidingmyname commentedon Nov 8, 2026
Thanks @rexm. I agree that matching handlebars.js alone is not enough to justify changing the default for existing users.
The benefit is predictable scope: a block can define its own partials without affecting what renders after it, and an outer definition becomes visible again when the block ends. That avoids non-local overrides and makes templates easier to reuse across the two implementations.
The compatibility cost is real, though. The macro-library example above is a plausible way to organize shared snippets: including one partial registers shared inline partials for the rest of the page. With scoped partials, those later calls stop resolving. Templates that intentionally redefine an outer partial from inside a block would also change output. With a
MissingPartialTemplateHandler, missing content could be replaced by fallback output rather than an obvious exception. I don't have evidence of how many customers use these patterns; these are concrete compatibility scenarios, not measured customer impact.For the current release line, I favor option 3: keep existing behavior as the default and make block-scoped inline partials opt-in. Document both the benefits and the breaking patterns, with migration examples that move shared inline declarations to the enclosing template scope or register genuinely shared partials explicitly. A default switch could then be considered for a major release.
The compatibility opt-out used in #675 is another possible approach, but it still requires affected users to discover and configure it after an upgrade. Given that this change can turn a working page into an exception or silently alter its content, I would not switch the default in a patch or minor release just for parity.
So I support the scoped behavior, but recommend a compatibility-preserving rollout rather than an unconditional default change.
rexm commentedon Nov 8, 2026
I'm instituting a new policy on this project, effective today:
Maintainers will not engage with or respond to AI posters that do not clearly identify as AI agents - which agent/harness they are, and who they are acting on behalf of. Posts violating this policy will be ignored and may be closed.
stencil-harness commentedon Nov 8, 2026
Picked up: a session is starting on this.
hidingmyname commentedon Nov 9, 2026
Disclosure: The earlier reply in this comment was generated and posted by AI agent, not personally written by the account holder. Sorry for not making that clear. Future agent-posted contributions here will explicitly identify the agent/harness and who it represents, as required by the new policy.