Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ Mark computed properties and methods with `[Expressive]` to generate companion e
| Tuples, index/range, `with`, collection expressions | And more modern C# syntax |
| Expression transformers | Built-in + custom `IExpressionTreeTransformer` pipeline |
| SQL window functions | ROW_NUMBER, RANK, DENSE_RANK, NTILE, PERCENT_RANK, CUME_DIST, SUM/AVG/COUNT/MIN/MAX OVER, LAG/LEAD, FIRST_VALUE/LAST_VALUE/NTH_VALUE with ROWS/RANGE frames (experimental) |
| Hot reload | Compatible with `dotnet watch` — edits to `[Expressive]` bodies propagate to generated expression trees |

See the [full documentation](https://efnext.github.io/ExpressiveSharp/guide/introduction) for detailed usage, [reference](https://efnext.github.io/ExpressiveSharp/reference/expressive-attribute), and [recipes](https://efnext.github.io/ExpressiveSharp/recipes/computed-properties).

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,12 +145,15 @@ private static void WriteRegistryEntryStatement(IndentedTextWriter writer, Expre
}

/// <summary>
/// Emits the <c>_map</c> field that lazily builds the registry once at class-load time:
/// <c>private static readonly Dictionary&lt;nint, LambdaExpression&gt; _map = Build();</c>
/// Emits the <c>_map</c> field plus a <c>ResetMap</c> entry point used by the hot-reload
/// handler to rebuild the map after a metadata update has patched the factory-method IL.
/// Not <c>readonly</c> — the handler reassigns it.
/// </summary>
private static void EmitMapField(IndentedTextWriter writer)
{
writer.WriteLine("private static readonly Dictionary<nint, LambdaExpression> _map = Build();");
writer.WriteLine("private static Dictionary<nint, LambdaExpression> _map = Build();");
writer.WriteLine();
writer.WriteLine("internal static void ResetMap() => _map = Build();");
Comment on lines +154 to +156
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ExpressionRegistry’s _map is now reassigned at runtime via ResetMap(), but the generated code publishes the new Dictionary with a plain assignment. If lookups can run concurrently with hot reload, this risks unsafe publication under the .NET memory model (another thread may observe a partially-initialized dictionary instance). Generate ResetMap using Volatile.Write/Interlocked.Exchange and have TryGet read the field via Volatile.Read (or copy to a local via Volatile.Read) before calling TryGetValue.

Copilot uses AI. Check for mistakes.
}

/// <summary>
Expand Down
48 changes: 48 additions & 0 deletions src/ExpressiveSharp/Services/ExpressiveHotReloadHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
using System;
using System.Reflection;
using System.Reflection.Metadata;

[assembly: MetadataUpdateHandler(typeof(ExpressiveSharp.Services.ExpressiveHotReloadHandler))]

namespace ExpressiveSharp.Services;

internal static class ExpressiveHotReloadHandler
{
public static void ClearCache(Type[]? updatedTypes)
{
ResetGeneratedRegistries();
ExpressiveResolver.ClearCachesForMetadataUpdate();
ExpressiveReplacer.ClearCachesForMetadataUpdate();
}
Comment on lines +11 to +16
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ClearCache ignores the updatedTypes payload and instead scans every loaded assembly on each hot reload. In larger apps this can be noticeably expensive (and can repeatedly hit reflection exceptions), even when only one project/type changed. Consider using updatedTypes (when non-null/non-empty) to derive the small set of affected assemblies and only attempt registry resets for those, falling back to a full scan only when the runtime doesn’t provide updated types.

Copilot uses AI. Check for mistakes.

public static void UpdateApplication(Type[]? updatedTypes) => ClearCache(updatedTypes);

/// <summary>
/// Finds every loaded assembly's generated <c>ExpressiveSharp.Generated.ExpressionRegistry</c>
/// class and invokes its <c>ResetMap()</c> method so the next <c>TryGet</c> rebuilds
/// <c>LambdaExpression</c> instances from the hot-reloaded factory IL.
/// </summary>
private static void ResetGeneratedRegistries()
{
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
{
if (assembly.IsDynamic) continue;

Type? registryType;
try
{
registryType = assembly.GetType("ExpressiveSharp.Generated.ExpressionRegistry", throwOnError: false);
}
catch
{
continue;
}
Comment on lines +36 to +39

var reset = registryType?.GetMethod("ResetMap", BindingFlags.Static | BindingFlags.NonPublic);
if (reset is null) continue;

try { reset.Invoke(null, null); }
catch { /* best-effort; stale registry stays stale */ }
}
Comment on lines +27 to +46
}
}
2 changes: 2 additions & 0 deletions src/ExpressiveSharp/Services/ExpressiveReplacer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ public class ExpressiveReplacer : ExpressionVisitor

private static readonly ConditionalWeakTable<Type, StrongBox<bool>> _compilerGeneratedClosureCache = new();

internal static void ClearCachesForMetadataUpdate() => _compilerGeneratedClosureCache.Clear();

public ExpressiveReplacer(IExpressiveResolver resolver)
{
_resolver = resolver;
Expand Down
18 changes: 18 additions & 0 deletions src/ExpressiveSharp/Services/ExpressiveResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,24 @@ internal static void ResetAllCaches()
_assemblyScanFilter = null;
}

/// <summary>
/// Invalidates cached expression trees so the next lookup rebuilds from the (possibly
/// hot-reloaded) generated factory method. Called from <see cref="ExpressiveHotReloadHandler"/>.
/// Preserves <c>_assemblyScanFilter</c> and <c>_typeNameCache</c> — neither goes stale on
/// non-rude edits, and wiping the filter would silently disable a user-configured restriction.
/// </summary>
internal static void ClearCachesForMetadataUpdate()
{
_expressionCache.Clear();
_reflectionCache.Clear();
_assemblyRegistries.Clear();
Volatile.Write(ref _lastScannedAssemblyCount, 0);
}

internal static bool IsExpressionCached(MemberInfo mi) => _expressionCache.ContainsKey(mi);

internal static Func<Assembly, bool>? GetAssemblyScanFilter() => _assemblyScanFilter;

private static Func<Assembly, bool>? _assemblyScanFilter;

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ namespace ExpressiveSharp.Generated
return map;
}

private static readonly Dictionary<nint, LambdaExpression> _map = Build();
private static Dictionary<nint, LambdaExpression> _map = Build();

internal static void ResetMap() => _map = Build();

public static LambdaExpression TryGet(MemberInfo member)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ namespace ExpressiveSharp.Generated
return map;
}

private static readonly Dictionary<nint, LambdaExpression> _map = Build();
private static Dictionary<nint, LambdaExpression> _map = Build();

internal static void ResetMap() => _map = Build();

public static LambdaExpression TryGet(MemberInfo member)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ namespace ExpressiveSharp.Generated
return map;
}

private static readonly Dictionary<nint, LambdaExpression> _map = Build();
private static Dictionary<nint, LambdaExpression> _map = Build();

internal static void ResetMap() => _map = Build();

public static LambdaExpression TryGet(MemberInfo member)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ namespace ExpressiveSharp.Generated
return map;
}

private static readonly Dictionary<nint, LambdaExpression> _map = Build();
private static Dictionary<nint, LambdaExpression> _map = Build();

internal static void ResetMap() => _map = Build();

public static LambdaExpression TryGet(MemberInfo member)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
using System.Reflection;
using System.Reflection.Metadata;
using ExpressiveSharp.Services;
using ExpressiveSharp.Tests.TestFixtures;

namespace ExpressiveSharp.Tests.Services;

[TestClass]
public class ExpressiveHotReloadHandlerTests
{
[TestMethod]
public void ClearCache_AfterResolve_RemovesMemberFromCache()
{
var mi = typeof(Product).GetProperty(nameof(Product.Total))!;
var resolver = new ExpressiveResolver();

_ = resolver.FindGeneratedExpression(mi);
Assert.IsTrue(ExpressiveResolver.IsExpressionCached(mi));

ExpressiveHotReloadHandler.ClearCache(null);

Assert.IsFalse(ExpressiveResolver.IsExpressionCached(mi));
}

[TestMethod]
public void ClearCache_PreservesAssemblyScanFilter()
{
var sentinel = new Func<Assembly, bool>(_ => true);
ExpressiveResolver.SetAssemblyScanFilter(sentinel);
try
{
ExpressiveHotReloadHandler.ClearCache(null);

Assert.AreSame(sentinel, ExpressiveResolver.GetAssemblyScanFilter());
}
finally
{
ExpressiveResolver.SetAssemblyScanFilter(null);
}
}

[TestMethod]
public void ClearCache_RebuildReturnsEquivalentExpression()
{
var mi = typeof(Product).GetProperty(nameof(Product.Total))!;
var resolver = new ExpressiveResolver();

var before = resolver.FindGeneratedExpression(mi).ToString();

ExpressiveHotReloadHandler.ClearCache(null);

var after = resolver.FindGeneratedExpression(mi).ToString();

Assert.AreEqual(before, after);
}

[TestMethod]
public void ClearCache_WithNullAndEmptyAndPopulatedArrays_DoesNotThrow()
{
ExpressiveHotReloadHandler.ClearCache(null);
ExpressiveHotReloadHandler.ClearCache([]);
ExpressiveHotReloadHandler.ClearCache([typeof(Product)]);
}

[TestMethod]
public void UpdateApplication_WithNull_DoesNotThrow()
{
ExpressiveHotReloadHandler.UpdateApplication(null);
}

[TestMethod]
public void Assembly_RegistersExpressiveHotReloadHandler()
{
var attributes = typeof(ExpressiveResolver).Assembly
.GetCustomAttributes<MetadataUpdateHandlerAttribute>()
.ToList();

Assert.IsTrue(attributes.Any(a => a.HandlerType == typeof(ExpressiveHotReloadHandler)),
"MetadataUpdateHandlerAttribute for ExpressiveHotReloadHandler not found on ExpressiveSharp assembly.");
}
}
Loading