Uh oh!
There was an error while loading. Please reload this page.
Releases: Handlebars-Net/Handlebars.Net
Release list
2.4.3
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
| Suite | Case | Pre-2.4.0 | 2.4.3 | Δ (cumulative) |
|---|---|---|---|---|
| RenderList | N=100, object | 27.48 µs | 12.16 µs | −56% |
| RenderSimple | object | 856.85 ns | 431 ns | −50% |
| RenderNested | rows=20, object | 24.52 µs | 12.44 µs | −49% |
| RenderToString | clean | 13.95 µs | 7.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:
| Suite | Case | Baseline | Result | Δ | Baseline release |
|---|---|---|---|---|---|
| RenderToString | html | 13.15 µs | 10.37 µs | −21% | 2.4.2 (no pre-2.4.0 figure) |
| RenderList | N=1000, dictionary | 191.3 µs | 126.7 µs | −34% | 2.4.2 (no pre-2.4.0 figure) |
| EndToEnd | — | 26.1 µs | 23.5 µs | −10% | 2.4.2 (no pre-2.4.0 figure) |
| RenderSimple | dictionary | 509.96 ns | 399.20 ns | −22% | pre-2.4.0 (from 2.4.0's #651; not re-benchmarked in 2.4.3) |
Allocation reductions:
| Suite | Case | 2.4.2 | 2.4.3 | Δ |
|---|---|---|---|---|
| RenderToString | clean | 30.2 KB | 13.4 KB | −56% |
| RenderToString | html | 33.4 KB | 16.7 KB | −50% |
Compilation — up to 88% faster (#668)
| Benchmark | 2.4.2 | 2.4.3 | Change |
|---|---|---|---|
| Compilation (nested 3-level template) | 10.80 ms | 1.85 ms | −83% |
| CompileMany N=10 | 57.7 ms | 6.97 ms | −88% |
| CompileMany N=100 | 537.9 ms | 78.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
ChainSegmentdescriptor 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, andboolproperty 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 oneTextWriter.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>.Countacquires aReaderWriterLockSlimper 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 freshStringBuilder(16)chunk by chunk — most of RenderToString's allocations. - Monomorphic descriptor cache on
ChainSegment. Dotted member access re-resolved the instance'sObjectDescriptorthrough the ambient context + type-keyed lookup on every segment per render; each segment now holds an immutable(factory, version, type) → descriptorentry, 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
ConditionalWeakTableprobe inSafeStringsuntil a safe-marked string is ever produced. - Read
ThrowOnUnresolvedBindingExpressiononly on the unresolved branch of path resolution. - Cheaper falsy checks: typed zero comparisons instead of
Convert.ToBooleandispatch, an O(1)ICollection.Countemptiness fast path, and enumerator disposal inAny().
Contributors
Full Changelog: 2.4.2...2.4.3
2.4.2
Compatibility notes
- The published package now includes a
net10.0target. CI was already building and testing against the .NET 10 SDK, and the test/benchmark projects already targetednet10.0, but theHandlebarslibrary itself only shippednetstandard2.0,netstandard2.1, andnet8.0— so .NET 10 apps silently fell back to thenet8.0binary. The package now ships a first-classnet10.0build 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
Full Changelog: 2.4.1...2.4.2
2.4.1
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/\rin static template text to\n, so a literal\r\na 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 SafeStringwhen 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 tostringnor unwrap, throwingInvalidCastException. 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 genuineSystem.Stringeverywhere 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 aSafeString-typed subexpression argument), that adaptation is no longer necessary but should remain harmless.
Contributors
Full Changelog: 2.4.0...2.4.1
2.4.0
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 allocation —
ObjectDescriptor's member accessor is now pre-bound to its described type instead of re-resolving through a shared type-keyed lookup on every access, andboolproperty reads return cached boxed instances instead of allocating a fresh box per read. (#652)Benchmark Case Before After Δ RenderSimple object 856.85 ns 575.79 ns −32.8% RenderNested object, rows=20 24.52 us 18.94 us −22.7% RenderList N=100, object 27.48 us 22.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 oneTextWriter.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)Benchmark Case Before After Δ RenderSimple dictionary 509.96 ns 399.20 ns −21.7% RenderList N=100, object 27.48 us 22.05 us −19.8% RenderToString clean 13.95 us 10.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.JsonElementsupport — first-class support forJsonElement(e.g. the result ofJsonSerializer.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 NewtonsoftJObject/JTokensupport. (#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 existingIList/IEnumerablebehavior are unaffected. (#649) else ifchaining 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'sinlinedecorator behavior. (#647, fixes #560)- Corrected the nullable annotation on
Try*out-parameters for concrete reference types (introduced in #642) from[MaybeNullWhen(false)] out Tto[NotNullWhen(true)] out T?, matching BCL convention (e.g.Uri.TryCreate) and giving a stronger compiler guarantee against unchecked dereferences. Affects ~20Try*methods acrossIObjectDescriptorProvider/ObjectDescriptor,IFormatterProvider,DynamicMemberAccessor,TypeExtensions,BindingContext,PathResolver, andBlockAccumulatorContext. 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.Ais nowinternalandPathInfo.Segmentscarries an explicit suppression. (#656)
Compatibility notes
- All changes in this release are binary-compatible. The Nullable Reference Types annotations and the
NotNullWhen/MaybeNullWhencorrection are compile-time metadata only. - If you implement
IPartialTemplateResolver,ITextEncoder,IMemberAccessor,IHelperResolver,IFormatterProvider,IObjectDescriptorProvider,IHelperDescriptor<T>, or derive fromViewEngineFileSystem, and build with<Nullable>enable</Nullable>, you may see new nullability-mismatch warnings until your implementation's annotations are updated to match. HandlebarsConfiguration.FileSystemis now declared nullable (ViewEngineFileSystem?), matching its actual default.- Built-in collection formatters now throw
ArgumentNullException(with parameter name) instead of a rawNullReferenceExceptionfor null/mismatched values.
Contributors
Full Changelog: 2.2.0...2.4.0
2.3.0
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 theViewEngineFileSystembase class. HandlebarsConfiguration.FileSystemis now declared nullable (ViewEngineFileSystem?), matching its actual default.- The built-in collection formatters now throw
ArgumentNullExceptionwith a parameter name instead of a rawNullReferenceExceptionwhen given a null or mismatched value.
- Binary- and runtime-compatible. Nullability annotations are compile-time metadata only; generated IL is unchanged. Projects without
Contributors
Full Changelog: 2.2.0...2.3.0
2.2.0
Handlebars.Net 2.2.0
⚠️ Behavior & compatibility changes — please read before upgrading
- Dropped end-of-life target frameworks. Removed
netstandard1.3,net451, andnet6. The package now targetsnetstandard2.0,netstandard2.1, andnet8.0. Modern consumers (.NET Framework 4.6.1+, .NET Core 2.0+, .NET 5/8+) are unaffected vianetstandard2.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) andDictionaryMemberAccessor(#466) @partial-blocknow usable inside#ifand as a block partial (#519)#ifwithincludeZero=truenow supported;includeZerohonored 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)
WriteSafeStringencoding 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/indelegate parameters incompatible with Mono/Xamarin (#458) - Prevent unbounded
DictionarySlimgrowth when replacing existing keys (#541) - Normalize CRLF to LF in
StaticConverterfor 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
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
Contributors
2.1.5
Changes
#574 Repo readme is mirrored into nuget package @thompson-tomo (#575)
Closes #574
Features 🚀
Add EmbedUntrackedSources @lahma (#570)
- added
EmbedUntrackedSourcesto remove one warning, see https://nuget.info/packages/Handlebars.Net/2.1.4
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. Addingnet6.0target would allow one target without dependency onMicrosoft.Csharp.- added
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.
- No more exceptions thrown when using
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 🧰
Fix/ci @oformaniuk (#576)
Contributors
@Hoeksema, @RoosterDragon, @StefH, @lahma, @oformaniuk and @thompson-tomo
2.1.4
2.1.3
Changes
Fix 535 OutOfMemoryException @anth12 (#536)
The following line currently throws an OutOfMemoryException for unrecognised expressions:
https://github.com/Handlebars-Net/Handlebars.Net/blob/01ad0e69e56331371f85a1c59c008d7ecfd06f78/source/Handlebars/Compiler/Lexer/Parsers/BlockParamsParser.cs#L28This PR exits the while loop when reaching the end of the end of the string reader.
Rendering inline blocks are incorrectly escaped @Nisden (#538)
This pull request demonstrates the the issue experienced in #524