Skip to content

Inline partials declared inside a block stay registered after the block ends #673

Open

Description

@hidingmyname

Describe the bug

Location (Handlebars-Net/Handlebars.Net @ c9a20a2):

            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}} renders success instead 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}} renders inner inner instead of inner 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 renders success, including from nested blocks;
  • using the partial after {{/with}} throws HandlebarsRuntimeException ("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 current master (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

  1. rexm commented on Nov 8, 2026

    @rexm
    Member

    /harness adopt

  2. stencil-harness commented on Nov 8, 2026

    @stencil-harness
    Contributor

    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.

  3. stencil-harness commented on Nov 8, 2026

    @stencil-harness
    Contributor

    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

    • Inline partials declared inside a block now only exist while that block renders. When the block ends, the partials that were visible before it come back. This matches handlebars.js. The change is in DecoratorDefinition.cs, plus two small internal helpers in CascadeIndex.cs.
    • This is done for the block as a whole, not inside the *inline decorator 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

    • I added 8 tests to InlinePartialTests:
      • a partial isn't visible after its block
      • an outer partial comes back after the block
      • partials are visible in nested blocks
      • #each
      • the last definition in a block wins
      • a custom decorator between two inlines
      • {{#> layout}} partial blocks
      • rendering the same template twice
    • 6 of the 8 fail without the fix. The other two cover behaviour that already worked and still does.
    • The full test suite passes: 2208 of 2208.

    Decision needed: this may break existing templates
    After this change, templates that relied on the old behaviour will render differently:

    • Calling a partial declared inside a block (including a {{#> layout}}…{{/layout}} body) after that block ends will throw Referenced partial name '…' could not be resolved. Today it renders.
    • A partial declared inside a block will no longer replace an outer partial with the same name for the rest of the template.

    Which of these do you want?

    1. Ship it as a patch release, treating it as a bug fix to match handlebars.js.
    2. Ship it in a minor or major release with a compatibility note.
    3. Put it behind a configuration option that is off by default, so current behaviour stays the default.

    Once you choose, I'll open the PR (and add the option if you pick 3).

  4. rexm commented on Nov 8, 2026

    @rexm
    Member

    Give us some real-world examples of surprising changes in behavior existing customers might experience.

  5. stencil-harness commented on Nov 8, 2026

    @stencil-harness
    Contributor

    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 resolved with 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:

    {{!-- registered as "macros" --}}
    {{#*inline "button"}}<button>{{label}}</button>{{/inline}}
    
    {{!-- page --}}
    {{> macros}}
    {{> button label="OK"}}

    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.

    {{#with customer}}{{#*inline "addr"}}{{street}}{{/inline}}{{/with}}
    Ship to: {{#with shipping}}{{> addr}}{{/with}}

    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.

    {{#> layout}}{{#*inline "content"}}Hi{{/inline}}{{/layout}}<aside>{{> content}}</aside>

    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 MissingPartialTemplateHandler configured, none of these throw. Instead the handler's fallback output silently replaces the content.

    Templates that render differently without an error

    • A block that redefines a page-level partial now only overrides it inside that block. For example, a {{#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:
      {{#*inline "toolbar"}}Default{{/inline}}{{#if isAdmin}}{{#*inline "toolbar"}}Admin{{/inline}}{{/if}}{{> toolbar}}
      #if/#unless bodies 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.
    • Partials declared at the top of the template and used anywhere in it, including inside nested blocks and {{#> 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:

    • The template above renders Admin even when isAdmin is false.
    • {{#if x}}{{#*inline "p"}}A{{/inline}}{{else}}{{#*inline "p"}}B{{/inline}}{{/if}} always renders B.

    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.

  6. rexm commented on Nov 8, 2026

    @rexm
    Member

    @hidingmyname need to discuss the relative risks and benefits of introducing this breaking change

  7. hidingmyname commented on Nov 8, 2026

    @hidingmyname
    Author

    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.

  8. rexm commented on Nov 8, 2026

    @rexm
    Member

    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.

  9. stencil-harness commented on Nov 8, 2026

    @stencil-harness
    Contributor

    Picked up: a session is starting on this.

  10. hidingmyname commented on Nov 9, 2026

    @hidingmyname
    Author

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions