Skip to content
23 changes: 20 additions & 3 deletions source/Handlebars/BindingContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ internal void SetDataObject(object? data)
private void Initialize()
{
Root = ParentContext?.Root ?? this;
HasFrameHelpers = ParentContext?.HasFrameHelpers ?? false;

ContextDataObject.AddOrReplace(ChainSegment.Root, Root.Value, out WellKnownVariables[(int) WellKnownVariable.Root]);

Expand Down Expand Up @@ -117,9 +118,17 @@ out WellKnownVariables[(int) WellKnownVariable.Parent]
internal CascadeIndex<string, IHelperDescriptor<BlockHelperOptions>, StringEqualityComparer> BlockHelpers { get; }

internal TemplateDelegate? PartialBlockTemplate { get; set; }

internal short PartialDepth { get; set; }

/// <summary>
/// <c>true</c> once any code obtained this frame's (or an ancestor frame's) helper
/// registries for writing — see <see cref="IHelpersRegistry"/>. Until then the
/// per-frame helper chain is known to be empty, letting hot paths
/// (<see cref="Helpers.LateBindHelperDescriptor"/>) skip the cascade lookup entirely.
/// </summary>
internal bool HasFrameHelpers { get; set; }

public object? Value { get; set; }

[MethodImpl(MethodImplOptions.AggressiveInlining)]
Expand Down Expand Up @@ -203,8 +212,16 @@ private static void PopulateHash(HashParameterDictionary hash, object from)
}
}

IIndexed<string, IHelperDescriptor<HelperOptions>> IHelpersRegistry.GetHelpers() => Helpers;
IIndexed<string, IHelperDescriptor<HelperOptions>> IHelpersRegistry.GetHelpers()
{
HasFrameHelpers = true;
return Helpers;
}

IIndexed<string, IHelperDescriptor<BlockHelperOptions>> IHelpersRegistry.GetBlockHelpers() => BlockHelpers;
IIndexed<string, IHelperDescriptor<BlockHelperOptions>> IHelpersRegistry.GetBlockHelpers()
{
HasFrameHelpers = true;
return BlockHelpers;
}
}
}
4 changes: 2 additions & 2 deletions source/Handlebars/Decorators/BlockDecoratorOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,8 @@ public void Template(in EncodedTextWriter writer, object? context)
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Template(in EncodedTextWriter writer, BindingContext context) => OriginalTemplate(writer, context);

IIndexed<string, IHelperDescriptor<HelperOptions>> IHelpersRegistry.GetHelpers() => Frame.Helpers;
IIndexed<string, IHelperDescriptor<HelperOptions>> IHelpersRegistry.GetHelpers() => ((IHelpersRegistry) Frame).GetHelpers();

IIndexed<string, IHelperDescriptor<BlockHelperOptions>> IHelpersRegistry.GetBlockHelpers() => Frame.BlockHelpers;
IIndexed<string, IHelperDescriptor<BlockHelperOptions>> IHelpersRegistry.GetBlockHelpers() => ((IHelpersRegistry) Frame).GetBlockHelpers();
}
}
4 changes: 2 additions & 2 deletions source/Handlebars/Decorators/DecoratorOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ BindingContext frame

public PathInfo Name { get; }

IIndexed<string, IHelperDescriptor<HelperOptions>> IHelpersRegistry.GetHelpers() => Frame.Helpers;
IIndexed<string, IHelperDescriptor<HelperOptions>> IHelpersRegistry.GetHelpers() => ((IHelpersRegistry) Frame).GetHelpers();

IIndexed<string, IHelperDescriptor<BlockHelperOptions>> IHelpersRegistry.GetBlockHelpers() => Frame.BlockHelpers;
IIndexed<string, IHelperDescriptor<BlockHelperOptions>> IHelpersRegistry.GetBlockHelpers() => ((IHelpersRegistry) Frame).GetBlockHelpers();
}
}
9 changes: 8 additions & 1 deletion source/Handlebars/Extensions/EnumerableExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,14 @@ internal static class EnumerableExtensions
public static bool Any(this IEnumerable builder)
{
var enumerator = builder.GetEnumerator();
return enumerator.MoveNext();
try
{
return enumerator.MoveNext();
}
finally
{
(enumerator as IDisposable)?.Dispose();
}
}

public static bool IsOneOf<TSource, TExpected>(this IEnumerable<TSource> source)
Expand Down
40 changes: 22 additions & 18 deletions source/Handlebars/HandlebarsUtils.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,25 @@ public static bool IsFalsy([NotNullWhen(false)] object? value, bool includeZero)
return IsFalsyJsonElement(element, includeZero);
}

if (IsNumber(value) && !includeZero)
if (includeZero) return false;

// Typed zero checks in likelihood order; avoids IsNumber's isinst cascade
// followed by Convert.ToBoolean's IConvertible dispatch on the hot path.
return value switch
{
return !Convert.ToBoolean(value);
}
return false;
int i => i == 0,
long l => l == 0L,
double d => d == 0d,
float f => f == 0f,
decimal m => m == 0m,
byte b8 => b8 == 0,
sbyte s8 => s8 == 0,
short s16 => s16 == 0,
ushort u16 => u16 == 0,
uint u32 => u32 == 0u,
ulong u64 => u64 == 0ul,
_ => false
};
}

private static bool IsFalsyJsonElement(JsonElement element, bool includeZero)
Expand Down Expand Up @@ -84,23 +98,13 @@ public static bool IsFalsyOrEmpty([NotNullWhen(false)] object? value, bool inclu
|| (element.ValueKind == JsonValueKind.Array && element.GetArrayLength() == 0);
}

// O(1) emptiness checks that avoid allocating an enumerator for the common
// collection shapes; the IEnumerable fallback boxes List<T>-style struct enumerators.
if (value is ICollection collection) return collection.Count == 0;

return value is IEnumerable enumerable && !enumerable.Any();
}

private static bool IsNumber([NotNullWhen(true)] object? value)
{
return value is sbyte
|| value is byte
|| value is short
|| value is ushort
|| value is int
|| value is uint
|| value is long
|| value is ulong
|| value is float
|| value is double
|| value is decimal;
}
}
}

Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
using System;
using System.Runtime.CompilerServices;
using HandlebarsDotNet.Collections;
using HandlebarsDotNet.PathStructure;

namespace HandlebarsDotNet.Helpers.BlockHelpers
{
public sealed class LateBindBlockHelperDescriptor : IHelperDescriptor<BlockHelperOptions>
{
// See LateBindHelperDescriptor: ObservableList.Count locks per call, so the
// "are there helper resolvers" check is cached behind an observer-maintained flag.
private ObservableList<IHelperResolver>? _observedResolvers;
private IObserver<IObservableEvent<IHelperResolver>>? _observerRoot; // strong root: ObservableList holds observers weakly

Check failure on line 13 in source/Handlebars/Helpers/BlockHelpers/LateBindBlockHelperDescriptor.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this unread private field '_observerRoot' or refactor the code to use its value.

See more on https://sonarcloud.io/project/issues?id=Handlebars-Net_Handlebars.Net&issues=AZ_muU3fxezt7eMSJeXB&open=AZ_muU3fxezt7eMSJeXB&pullRequest=667
private volatile bool _hasResolvers;

public LateBindBlockHelperDescriptor(string name) => Name = name;

public PathInfo Name { get; }
Expand All @@ -16,16 +24,18 @@

public void Invoke(in EncodedTextWriter output, in BlockHelperOptions options, in Context context, in Arguments arguments)
{
if(options.Frame.BlockHelpers.TryGetValue(Name, out var contextHelper))
// Frame-local helpers only exist once something wrote to a frame's helper registry
// (decorators / in-render registration); skip the cascade walk in the common case.
if(options.Frame.HasFrameHelpers && options.Frame.BlockHelpers.TryGetValue(Name, out var contextHelper))
{
contextHelper.Invoke(options, context, arguments);
return;
}

// TODO: add cache

var configuration = options.Frame.Configuration;
var helperResolvers = (ObservableList<IHelperResolver>) configuration.HelperResolvers;
if(helperResolvers.Count != 0)
if (!ReferenceEquals(_observedResolvers, helperResolvers)) ObserveResolvers(helperResolvers);
if (_hasResolvers)
{
for (var index = 0; index < helperResolvers.Count; index++)
{
Expand All @@ -39,5 +49,19 @@
configuration.BlockHelpers["blockHelperMissing"]!.Value
.Invoke(output, options, context, arguments);
}

[MethodImpl(MethodImplOptions.NoInlining)]
private void ObserveResolvers(ObservableList<IHelperResolver> resolvers)
{
// Subscribe before snapshotting Count so a concurrent Add can never be missed;
// duplicate subscriptions from a racing first call are benign (same flag).
var observer = ObserverBuilder<IObservableEvent<IHelperResolver>>.Create(this)
.OnEvent<AddedObservableEvent<IHelperResolver>>((_, state) => state._hasResolvers = true)
.Build();
resolvers.Subscribe(observer);
_observerRoot = observer;
if (resolvers.Count != 0) _hasResolvers = true;
_observedResolvers = resolvers;
}
}
}
}
37 changes: 31 additions & 6 deletions source/Handlebars/Helpers/LateBindHelperDescriptor.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
using System;
using System.Runtime.CompilerServices;
using HandlebarsDotNet.Collections;
using HandlebarsDotNet.PathStructure;

namespace HandlebarsDotNet.Helpers
{
public sealed class LateBindHelperDescriptor : IHelperDescriptor<HelperOptions>
{
// ObservableList.Count takes a ReaderWriterLockSlim per call, and this descriptor runs
// for every simple {{name}} on every render — so the "are there helper resolvers" check
// is cached in a flag kept up to date by subscribing to the (append-only) resolver list.
private ObservableList<IHelperResolver>? _observedResolvers;
private IObserver<IObservableEvent<IHelperResolver>>? _observerRoot; // strong root: ObservableList holds observers weakly

Check failure on line 14 in source/Handlebars/Helpers/LateBindHelperDescriptor.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this unread private field '_observerRoot' or refactor the code to use its value.

See more on https://sonarcloud.io/project/issues?id=Handlebars-Net_Handlebars.Net&issues=AZ_muU7Mxezt7eMSJeXC&open=AZ_muU7Mxezt7eMSJeXC&pullRequest=667
private volatile bool _hasResolvers;

public LateBindHelperDescriptor(string name) => Name = name;

public PathInfo Name { get; }
Expand All @@ -13,15 +22,17 @@
{
var bindingContext = options.Frame;

if(options.Frame.Helpers.TryGetValue(Name, out var contextHelper))
// Frame-local helpers only exist once something wrote to a frame's helper registry
// (decorators / in-render registration); skip the cascade walk in the common case.
if(bindingContext.HasFrameHelpers && bindingContext.Helpers.TryGetValue(Name, out var contextHelper))
{
return contextHelper.Invoke(options, context, arguments);
}

// TODO: add cache

var configuration = options.Frame.Configuration;
var helperResolvers = (ObservableList<IHelperResolver>) configuration.HelperResolvers;
if (helperResolvers.Count != 0)
if (!ReferenceEquals(_observedResolvers, helperResolvers)) ObserveResolvers(helperResolvers);
if (_hasResolvers)
{
var targetType = arguments.Length > 0 ? arguments[0]!.GetType() : null;
for (var index = 0; index < helperResolvers.Count; index++)
Expand All @@ -35,13 +46,27 @@

var value = PathResolver.ResolvePath(bindingContext, Name);
if (!(value is UndefinedBindingResult)) return value;

return configuration.Helpers["helperMissing"]!.Value.Invoke(options, context, arguments);
}

public void Invoke(in EncodedTextWriter output, in HelperOptions options, in Context context, in Arguments arguments)
{
output.Write(Invoke(options, context, arguments));
}

[MethodImpl(MethodImplOptions.NoInlining)]
private void ObserveResolvers(ObservableList<IHelperResolver> resolvers)
{
// Subscribe before snapshotting Count so a concurrent Add can never be missed;
// duplicate subscriptions from a racing first call are benign (same flag).
var observer = ObserverBuilder<IObservableEvent<IHelperResolver>>.Create(this)
.OnEvent<AddedObservableEvent<IHelperResolver>>((_, state) => state._hasResolvers = true)
.Build();
resolvers.Subscribe(observer);
_observerRoot = observer;
if (resolvers.Count != 0) _hasResolvers = true;
_observedResolvers = resolvers;
}
}
}
}
6 changes: 5 additions & 1 deletion source/Handlebars/IO/PolledStringWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ namespace HandlebarsDotNet
{
public class ReusableStringWriter : StringWriter
{
private static readonly InternalObjectPool<ReusableStringWriter, Policy> Pool = new InternalObjectPool<ReusableStringWriter, Policy>(new Policy(16));
// Retain up to 32K chars (64KB, below the LOH threshold) so that templates producing
// typical page-sized output keep their grown StringBuilder across renders. The previous
// 4096-char limit made any render larger than 4KB discard the writer, forcing every
// subsequent render to re-grow a fresh StringBuilder(16) chunk by chunk.
private static readonly InternalObjectPool<ReusableStringWriter, Policy> Pool = new InternalObjectPool<ReusableStringWriter, Policy>(new Policy(16, 32 * 1024));

private IFormatProvider _formatProvider = null!;

Expand Down
14 changes: 13 additions & 1 deletion source/Handlebars/IO/SafeStrings.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Runtime.CompilerServices;
using System.Threading;

namespace HandlebarsDotNet.IO
{
Expand All @@ -16,15 +17,26 @@ internal static class SafeStrings
private static readonly ConditionalWeakTable<string, object> Marked = new();
private static readonly object Sentinel = new();

// Marking only ever happens when a helper's captured output is written (ReturnInvoke).
// Most applications never mark a single string, yet IsSafe sits on the hot path of every
// string written to output — so keep a global "has anything ever been marked" latch to
// skip the ConditionalWeakTable probe entirely until the first Mark. The latch is written
// with release semantics after the table entry exists, so a true reader always observes
// the corresponding table entry; a stale false reader merely re-encodes on the same
// thread-interleaving that was already possible before the mark completed.
private static bool _anyMarked;

[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static string Mark(string value)
{
if (value.Length == 0) return value;
Marked.GetValue(value, _ => Sentinel);
Volatile.Write(ref _anyMarked, true);
return value;
}

[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool IsSafe(string value) => value.Length == 0 || Marked.TryGetValue(value, out _);
public static bool IsSafe(string value)
=> value.Length == 0 || (Volatile.Read(ref _anyMarked) && Marked.TryGetValue(value, out _));
}
}
26 changes: 20 additions & 6 deletions source/Handlebars/ObjectDescriptors/ObjectDescriptorFactory.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using HandlebarsDotNet.Collections;
using HandlebarsDotNet.EqualityComparers;
using HandlebarsDotNet.Runtime;
Expand All @@ -25,18 +26,31 @@ public class ObjectDescriptorFactory : IObjectDescriptorProvider, IObserver<IObs

private readonly IObserver<IObservableEvent<IObjectDescriptorProvider>> _observer;

private int _version;

public static ObjectDescriptorFactory? Current => AmbientContext.Current?.ObjectDescriptorFactory;


/// <summary>
/// Monotonic stamp bumped whenever the provider set changes; lets external
/// per-call-site descriptor caches (see <see cref="PathStructure.ChainSegment"/>)
/// detect that previously resolved descriptors may be stale.
/// </summary>
internal int Version => Volatile.Read(ref _version);

public ObjectDescriptorFactory(ObservableList<IObjectDescriptorProvider>? providers = null)
{
_providers = new ObservableList<IObjectDescriptorProvider>();

if (providers != null) Append(providers);

_observer = ObserverBuilder<IObservableEvent<IObjectDescriptorProvider>>.Create(_descriptorsCache)
.OnEvent<AddedObservableEvent<IObjectDescriptorProvider>>((@event, state) => state.Clear())

_observer = ObserverBuilder<IObservableEvent<IObjectDescriptorProvider>>.Create(this)
.OnEvent<AddedObservableEvent<IObjectDescriptorProvider>>((@event, state) =>
{
state._descriptorsCache.Clear();
Interlocked.Increment(ref state._version);
})
.Build();

_providers.Subscribe(this);
}

Expand Down
Loading
Loading