Skip to content

Releases: Handlebars-Net/Handlebars.Net

2.4.3

Choose a tag to compare

@rexmrexm released this 09 Aug 16:45
c9a20a2

A performance-focused release. Combined with the rendering work already shipped in 2.4.0, common rendering paths are now up to 56% faster than pre-2.4.0, and template compilation is up to 88% faster (#668). No API or behavior changes; all improvements are behavior-preserving and were validated against the full test suite (1912 tests) and A/B benchmark runs at every step.

Performance

Rendering — up to 56% faster since pre-2.4.0

SuiteCasePre-2.4.02.4.3Δ (cumulative)
RenderListN=100, object27.48 µs12.16 µs−56%
RenderSimpleobject856.85 ns431 ns−50%
RenderNestedrows=20, object24.52 µs12.44 µs−49%
RenderToStringclean13.95 µs7.48 µs−46%

Methodology: the pre-2.4.0 baseline is the "Before" figure from each 2.4.0 change's own A/B benchmark (#652, #651); the 2.4.3 figure is this release's measured result. 2.4.1 and 2.4.2 shipped no rendering changes.

A few cases were only benchmarked starting at 2.4.2 -> 2.4.3:

SuiteCaseBaselineResultΔBaseline release
RenderToStringhtml13.15 µs10.37 µs−21%2.4.2 (no pre-2.4.0 figure)
RenderListN=1000, dictionary191.3 µs126.7 µs−34%2.4.2 (no pre-2.4.0 figure)
EndToEnd26.1 µs23.5 µs−10%2.4.2 (no pre-2.4.0 figure)
RenderSimpledictionary509.96 ns399.20 ns−22%pre-2.4.0 (from 2.4.0's #651; not re-benchmarked in 2.4.3)

Allocation reductions:

SuiteCase2.4.22.4.3Δ
RenderToStringclean30.2 KB13.4 KB−56%
RenderToStringhtml33.4 KB16.7 KB−50%

Compilation — up to 88% faster (#668)

Benchmark2.4.22.4.3Change
Compilation (nested 3-level template)10.80 ms1.85 ms−83%
CompileMany N=1057.7 ms6.97 ms−88%
CompileMany N=100537.9 ms78.1 ms−85%

Template compile time turned out to be dominated by the JIT compiling each template's dynamic method at CreateDelegate, inline-expanding the [AggressiveInlining] write/encoder machinery into every mustache call site of every template. The compiler now emits thin NoInlining static entry points that are JIT-compiled once per process. Render performance was verified unchanged by A/B guardrail benchmarks (the one variant that cost ~4% at render time was rejected and kept inline).

Compatibility notes

  • No public API changes; all new members are internal. Template semantics are unchanged, including late registration of helpers, helper resolvers, and descriptor providers after compile.
  • The ChainSegment descriptor cache allocates a small entry (~48 B) when a segment observes a new instance type; heterogeneous collections rendered through dotted access can re-allocate per type flip, while the common homogeneous case allocates once per segment ever.

What changed

What changed:

Since pre-2.4.0 (#652, #651, #653):

  • ObjectDescriptor's member accessor is pre-bound to its described type instead of re-resolving through a shared type-keyed lookup on every access, and bool property reads return cached boxed instances instead of allocating a fresh box per read. (#652)
  • HTML encoders bulk-write clean runs of text via SearchValues<char> (net8.0+) or a plain scan (netstandard) instead of one TextWriter.Write(char) call per character, falling back to the original per-character path only where escaping is actually needed. (#651)
  • The boxed-integer cache used for {{#each}} iterator indexes grew from 20 to 1024 entries, eliminating a 24-byte-per-item allocation that was the dominant remaining allocation source in list rendering (e.g. 23.5 KB → 0 B for a 1000-item {{#each}}). (#653)

In this release (#667):

  • Cache helper-resolver presence in the late-bind descriptors.ObservableList<T>.Count acquires a ReaderWriterLockSlim per call, and every simple {{name}} paid it once per render — loops paid hundreds of lock acquisitions per render. The descriptors now subscribe once to the append-only resolver list and keep a flag; resolvers registered after compile still take effect.
  • Retain up to 32K chars in the pooled ReusableStringWriter. Outputs over 4096 chars discarded the pooled writer every render, re-growing a fresh StringBuilder(16) chunk by chunk — most of RenderToString's allocations.
  • Monomorphic descriptor cache on ChainSegment. Dotted member access re-resolved the instance's ObjectDescriptor through the ambient context + type-keyed lookup on every segment per render; each segment now holds an immutable (factory, version, type) → descriptor entry, self-invalidated via a version stamp when descriptor providers are registered.
  • Skip the frame-helper cascade walk when no frame-local helpers exist (decorator / in-render registration is tracked per frame).
  • Skip the ConditionalWeakTable probe in SafeStrings until a safe-marked string is ever produced.
  • Read ThrowOnUnresolvedBindingExpression only on the unresolved branch of path resolution.
  • Cheaper falsy checks: typed zero comparisons instead of Convert.ToBoolean dispatch, an O(1) ICollection.Count emptiness fast path, and enumerator disposal in Any().

Contributors

@rexm

Full Changelog: 2.4.2...2.4.3

2.4.2

Choose a tag to compare

@rexmrexm released this 09 Aug 03:31
e5f4548

Compatibility notes

  • The published package now includes a net10.0 target. CI was already building and testing against the .NET 10 SDK, and the test/benchmark projects already targeted net10.0, but the Handlebars library itself only shipped netstandard2.0, netstandard2.1, and net8.0 — so .NET 10 apps silently fell back to the net8.0 binary. The package now ships a first-class net10.0 build alongside the existing targets. (#666, fixes #659)
  • No API or behavior changes. Existing targets are unchanged; consumers on .NET 10 simply pick up the new target automatically on upgrade.

Contributors

@rexm

Full Changelog: 2.4.1...2.4.2

2.4.1

Choose a tag to compare

@github-actionsgithub-actions released this 07 Aug 01:13
bb496ab

Fixes

Both regressions below were introduced on 2026-06-20 and shipped in 2.4.0 (released 2026-08-06). Neither was an intentional public API change — this release restores prior correct behavior.

  • Static template text no longer has its line endings silently rewritten. A fix for internal indentation handling started normalizing every \r\n/\r in static template text to \n, so a literal \r\n a caller wrote into a template string (e.g. between {{#each}} iterations) was silently turned into \n. Static text now round-trips verbatim, matching this library's long-standing behavior and handlebars.js. (#663, fixes #661)
  • Subexpression results are plain strings again, not an internal wrapper type. A fix for double-encoding across subexpression boundaries (#543) wrapped every writer-based helper's captured output in an internal sealed class SafeString when used as a subexpression argument. Only a few internal call sites knew how to unwrap it — any other consumer, including reflection-based/typed helper binders in third-party packages (e.g. Handlebars.Net.Helpers), received an opaque type it could neither cast to string nor unwrap, throwing InvalidCastException. The double-encoding fix is preserved, but the signal is now carried by an invisible reference-keyed marker instead of a boxing type, so the value is a genuine System.String everywhere except the one place that needs to know. (#664, fixes #660)

Compatibility notes

  • No API surface changes. Both fixes are behavior reverts to what 2.3.0 and earlier already did; anything that worked before 2.4.0 works the same way again.
  • If your code adapted to either regression (e.g. expected \n-only output regardless of source line endings, or handled a SafeString-typed subexpression argument), that adaptation is no longer necessary but should remain harmless.

Contributors

@rexm

Full Changelog: 2.4.0...2.4.1

2.4.0

Choose a tag to compare

@rexmrexm released this 06 Aug 03:23
39ad350

Performance

Rendering plain .NET objects and string-heavy templates got substantially faster this release, across three targeted changes to the hot rendering path:

  • ~10–33% faster object rendering, less allocationObjectDescriptor's member accessor is now pre-bound to its described type instead of re-resolving through a shared type-keyed lookup on every access, and bool property reads return cached boxed instances instead of allocating a fresh box per read. (#652)
    BenchmarkCaseBeforeAfterΔ
    RenderSimpleobject856.85 ns575.79 ns−32.8%
    RenderNestedobject, rows=2024.52 us18.94 us−22.7%
    RenderListN=100, object27.48 us22.77 us−17.1%
  • ~10–20% faster string-heavy rendering — HTML encoders now bulk-write clean runs of text via SearchValues<char> (net8.0+) or a plain scan (netstandard) instead of one TextWriter.Write(char) call per character, falling back to the original per-character path only where escaping is actually needed. Output is byte-for-byte identical. (#651)
    BenchmarkCaseBeforeAfterΔ
    RenderSimpledictionary509.96 ns399.20 ns−21.7%
    RenderListN=100, object27.48 us22.05 us−19.8%
    RenderToStringclean13.95 us10.97 us−21.4%
  • Zero-allocation {{#each}} iteration — the boxed-integer cache used for iterator indexes grew from 20 to 1024 entries, eliminating a 24-byte-per-item allocation that was the dominant remaining allocation source in list rendering (e.g. 23.5 KB → 0 B for a 1000-item {{#each}}). (#653)

Combined, typical object-rendering and list-rendering templates should see meaningfully lower latency and near-zero allocation on the common paths; dictionary/expando-backed templates benefit from the encoder work but were otherwise already efficient.

New features

  • Nullable Reference Types — the entire public API surface is now annotated for Nullable Reference Types. Binary- and runtime-compatible (annotations are compile-time metadata only); projects without <Nullable>enable</Nullable> are unaffected. Nullable-enabled consumers get compiler-checked null contracts on the public API, and extensibility interfaces (IPartialTemplateResolver, ITextEncoder, IMemberAccessor, IHelperResolver, IFormatterProvider, IObjectDescriptorProvider, IHelperDescriptor<T>, ViewEngineFileSystem) gained nullability annotations that may surface mismatch warnings (e.g. CS8767) in existing implementations until updated. (#642, @TheConstructor)
  • System.Text.Json.JsonElement support — first-class support for JsonElement (e.g. the result of JsonSerializer.Deserialize<object>(json)) in templates: nested member access, {{#each}} iteration over both JSON objects and arrays, and correct {{#if}}/{{#unless}} truthiness — bringing it to parity with the existing Newtonsoft JObject/JToken support. (#657)
  • Multi-dimensional array support — true C# multi-dimensional arrays (e.g. int[,]) can now be indexed via path expressions ({{grid.[0].[1]}}) and iterated with {{#each}}, which walks the outer-most dimension and yields row/slab slices for the rest. Jagged arrays and existing IList/IEnumerable behavior are unaffected. (#649)
  • else if chaining for block helpers{{else name args}}...{{/outer}} now works for any block helper, not just {{#if}}, and chains recursively to any depth, e.g. {{#StringEqualityBlockHelper @value 'dog'}}...{{else StringEqualityBlockHelper @value 'cat'}}...{{else}}...{{/StringEqualityBlockHelper}}. (#648)

Fixes

  • Properties whose only implementation is a C# 8+ default interface member (declared and bodied on an interface, not overridden by the concrete class) are now resolved correctly by both {{PropertyName}} lookup and {{#each this}} enumeration, instead of being silently skipped. (#658, fixes #601)
  • {{#*inline "name" ...}} no longer throws when passed hash arguments or extra positional arguments, matching Handlebars.js's inline decorator behavior. (#647, fixes #560)
  • Corrected the nullable annotation on Try* out-parameters for concrete reference types (introduced in #642) from [MaybeNullWhen(false)] out T to [NotNullWhen(true)] out T?, matching BCL convention (e.g. Uri.TryCreate) and giving a stronger compiler guarantee against unchecked dereferences. Affects ~20 Try* methods across IObjectDescriptorProvider/ObjectDescriptor, IFormatterProvider, DynamicMemberAccessor, TypeExtensions, BindingContext, PathResolver, and BlockAccumulatorContext. Compile-time-only change, not binary breaking. (#655, fixes #654)
  • Resolved a SonarCloud reliability regression (A→B) surfaced by #642's diff against two pre-existing mutable-array-exposure smells; Closure.A is now internal and PathInfo.Segments carries an explicit suppression. (#656)

Compatibility notes

  • All changes in this release are binary-compatible. The Nullable Reference Types annotations and the NotNullWhen/MaybeNullWhen correction are compile-time metadata only.
  • If you implement IPartialTemplateResolver, ITextEncoder, IMemberAccessor, IHelperResolver, IFormatterProvider, IObjectDescriptorProvider, IHelperDescriptor<T>, or derive from ViewEngineFileSystem, and build with <Nullable>enable</Nullable>, you may see new nullability-mismatch warnings until your implementation's annotations are updated to match.
  • HandlebarsConfiguration.FileSystem is now declared nullable (ViewEngineFileSystem?), matching its actual default.
  • Built-in collection formatters now throw ArgumentNullException (with parameter name) instead of a raw NullReferenceException for null/mismatched values.

Contributors

@TheConstructor, @rexm

Full Changelog: 2.2.0...2.4.0

2.3.0

Choose a tag to compare

@rexmrexm released this 05 Aug 02:26
5015116

Changes

  • Nullable Reference Types @TheConstructor (#642)

    The entire public API surface is now annotated for Nullable Reference Types.

    Compatibility notes:

    • Binary- and runtime-compatible. Nullability annotations are compile-time metadata only; generated IL is unchanged. Projects without <Nullable>enable</Nullable> are unaffected.
    • Nullable-enabled consumers get compiler-checked null contracts on the public API: Try* methods are annotated with [MaybeNullWhen(false)], optional parameters and nullable returns are declared with ?, etc.
    • If you implement extensibility interfaces, their signatures gained nullability annotations and your existing implementations may produce nullability-mismatch warnings (e.g. CS8767) until you add the matching ? annotations. Affected: IPartialTemplateResolver, ITextEncoder, IMemberAccessor, IHelperResolver, IFormatterProvider, IObjectDescriptorProvider, IHelperDescriptor<T>, and the ViewEngineFileSystem base class.
    • HandlebarsConfiguration.FileSystem is now declared nullable (ViewEngineFileSystem?), matching its actual default.
    • The built-in collection formatters now throw ArgumentNullException with a parameter name instead of a raw NullReferenceException when given a null or mismatched value.

Contributors

@TheConstructor

Full Changelog: 2.2.0...2.3.0

2.2.0

Choose a tag to compare

@rexmrexm released this 05 Aug 01:20
b70bc9a

Handlebars.Net 2.2.0

⚠️ Behavior & compatibility changes — please read before upgrading

  • Dropped end-of-life target frameworks. Removed netstandard1.3, net451, and net6. The package now targets netstandard2.0, netstandard2.1, and net8.0. Modern consumers (.NET Framework 4.6.1+, .NET Core 2.0+, .NET 5/8+) are unaffected via netstandard2.0; only consumers on the removed legacy frameworks need to stay on 2.1.x.
  • Single quote (') is now HTML-encoded by default (#546), matching Handlebars.js behavior. This is invisible when rendering HTML, but changes raw output bytes — review any exact-string or snapshot assertions on rendered output.

Bug fixes

  • Case-sensitive resolution of same-spelling path variables, resolved independently in PathBinder (#434)
  • Case-sensitive key lookup for IDictionary/Hashtable (#521) and DictionaryMemberAccessor (#466)
  • @partial-block now usable inside #if and as a block partial (#519)
  • #if with includeZero=true now supported; includeZero honored in built-in conditional blocks (#285)
  • Parent context traversal inside custom block helpers within #each (#539)
  • Safe-string flag preserved when helper output crosses a subexpression boundary (#543)
  • WriteSafeString encoding consistent regardless of helper registration order (#559)
  • Preserve backslashes in template literal text (#462) and double backslashes in static text (#349)
  • Preserve partial indentation (#614)
  • Handle escaped double-quotes in delimited string literal arguments (#584)
  • Allow Unicode letters as first character in identifiers (#416); strip invisible Unicode chars (BOM, etc.) from identifiers (#605)
  • Clearer error when a partial is registered on the wrong Handlebars instance (#545)
  • Removed byref/in delegate parameters incompatible with Mono/Xamarin (#458)
  • Prevent unbounded DictionarySlim growth when replacing existing keys (#541)
  • Normalize CRLF to LF in StaticConverter for platform-independent output

Maintenance

  • CI moved to windows-latest; bumped deprecated GitHub Actions and SDK versions; pinned third-party actions to commit SHAs
  • Resolved SonarCloud security/quality findings; dev environment updated to .NET 10
  • Added render-time/compile-scaling benchmark suite and Handlebars.js regression coverage

2.1.6

Choose a tag to compare

@github-actionsgithub-actions released this 03 Apr 23:04
9cf1672

Changes

Maintenance 🧰

  • Added in missing SDK's for release @thompson-tomo (#579)

    Added in required sdk's for the release build which was missed in #578

  • Update of TFM & dependency optimisation @thompson-tomo (#578)

    By adjusting the TFM'S we have been able to produce a package with no dependencies on the latest frameworks hence an optimised dependency graph.

    The following frameworks have been added:

    • Net 6

    The following frameworks even though requested was not added:

    • Net 5

    The following frameworks were removed:

    • Net 4.5.2
    • Net 4.6

    Closes: #573
    Closes: #415

Contributors

@oformaniuk and @thompson-tomo

2.1.5

Choose a tag to compare

@github-actionsgithub-actions released this 01 Apr 02:32
bed0c0e

Changes

Features 🚀

  • Add EmbedUntrackedSources @lahma (#570)

    I would also suggest changing to use newer GH Actions images for building so that other warnings would go away (old SDK in use). Maybe another modernization step could be removing old unsupported full framework targets and only support oldest supported net462. Adding net6.0 target would allow one target without dependency on Microsoft.Csharp.

  • Use PackageLicenseExpression in NuGet package @StefH (#564)

Bug Fixes 🐛

  • Introduce PartialRecursionDepthLimit @RoosterDragon (#552)

    When evaluating templates with partials, it is possible to recurse in the evaluation of those partials. This can be useful for dealing with tree like data, such as rendering a list of friends-of-friends-of-friends-of-etc....

    The ability to recurse can lead to stack overflows. For example if a sufficiently deep tree is provided as input data, or more simply if the partial calls itself in an infinite loop. As a stack overflow terminates the process, this is not desirable behaviour as it is an unavoidable crash.

    To resolve this a configurable PartialRecursionDepthLimit is introduced, defaulting to 100. Now when a template is evaluated a HandlebarsRuntimeException will be thrown if this limit is reached. This allows the caller to catch the exception and recover gracefully, rather than terminating the process.

  • Allow slashes properly within escape blocks @Hoeksema (#567)

    closes #566

    The path parsing currently doesn't work properly when there are embedded slashes within an ignore block.

    This PR fixes this issue:

    • No more exceptions thrown when using // within an escaped block
    • Allowing multiple / to occur within an escape block without breakage

    Before, the individual segments between slashes in addition to the entire escaped block were returned by PathInfo. Now, it returns just the latter, which is correct. All existing unit tests still pass and new tests were added to exercise the failing cases in #566.

  • Throw properly on open ignore block instead of crashing @Hoeksema (#569)

    Closes #568

    Resolve the hang on compile when there is an open ignore block

    Reshuffle the logic so that the throw check for end of template is done before trying to process the char

  • Fix LiteralConverter to support long @StefH (#562)

Maintenance 🧰

Contributors

@Hoeksema, @RoosterDragon, @StefH, @lahma, @oformaniuk and @thompson-tomo

2.1.4

Choose a tag to compare

@github-actionsgithub-actions released this 04 Mar 07:24
50614fd

Features 🚀

  • Add optional 3rd argument to lookup helper @StefH (#542)

Contributors

@StefH

2.1.3

Choose a tag to compare

@github-actionsgithub-actions released this 15 Feb 00:03
3585d29

Changes

Contributors

@Nisden, @anth12, @rexm, @zjklee and Anthony Halliday