diff --git a/README.md b/README.md
index 30cb05a6..1541039c 100644
--- a/README.md
+++ b/README.md
@@ -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).
diff --git a/src/ExpressiveSharp.Generator/Registry/ExpressionRegistryEmitter.cs b/src/ExpressiveSharp.Generator/Registry/ExpressionRegistryEmitter.cs
index 2cce32b9..30c0a5bc 100644
--- a/src/ExpressiveSharp.Generator/Registry/ExpressionRegistryEmitter.cs
+++ b/src/ExpressiveSharp.Generator/Registry/ExpressionRegistryEmitter.cs
@@ -145,12 +145,16 @@ private static void WriteRegistryEntryStatement(IndentedTextWriter writer, Expre
}
///
- /// Emits the _map field that lazily builds the registry once at class-load time:
- /// private static readonly Dictionary<nint, LambdaExpression> _map = Build();
+ /// Emits the _map field plus a ResetMap entry point used by the hot-reload
+ /// handler to rebuild the map after a metadata update has patched the factory-method IL.
+ /// volatile ensures the new
+ /// is safely published to concurrent readers on weak-memory architectures.
///
private static void EmitMapField(IndentedTextWriter writer)
{
- writer.WriteLine("private static readonly Dictionary _map = Build();");
+ writer.WriteLine("private static volatile Dictionary _map = Build();");
+ writer.WriteLine();
+ writer.WriteLine("internal static void ResetMap() => _map = Build();");
}
///
diff --git a/src/ExpressiveSharp/Services/ExpressiveHotReloadHandler.cs b/src/ExpressiveSharp/Services/ExpressiveHotReloadHandler.cs
new file mode 100644
index 00000000..cb491659
--- /dev/null
+++ b/src/ExpressiveSharp/Services/ExpressiveHotReloadHandler.cs
@@ -0,0 +1,69 @@
+using System;
+using System.Collections.Generic;
+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(SelectAffectedAssemblies(updatedTypes));
+ ExpressiveResolver.ClearCachesForMetadataUpdate();
+ ExpressiveReplacer.ClearCachesForMetadataUpdate();
+ }
+
+ public static void UpdateApplication(Type[]? updatedTypes) => ClearCache(updatedTypes);
+
+ ///
+ /// When the runtime tells us which types changed, use their assemblies directly.
+ /// Fall back to a full scan only when is null or empty,
+ /// which the runtime may do for large/unknown change sets.
+ ///
+ private static IEnumerable SelectAffectedAssemblies(Type[]? updatedTypes)
+ {
+ if (updatedTypes is { Length: > 0 })
+ {
+ var set = new HashSet();
+ foreach (var t in updatedTypes)
+ {
+ if (t is not null) set.Add(t.Assembly);
+ }
+ return set;
+ }
+
+ return AppDomain.CurrentDomain.GetAssemblies();
+ }
+
+ ///
+ /// Invokes ResetMap() on each assembly's generated ExpressionRegistry class
+ /// (when present) so the next TryGet rebuilds LambdaExpression instances
+ /// from the hot-reloaded factory IL.
+ ///
+ private static void ResetGeneratedRegistries(IEnumerable assemblies)
+ {
+ foreach (var assembly in assemblies)
+ {
+ if (assembly.IsDynamic) continue;
+
+ Type? registryType;
+ try
+ {
+ registryType = assembly.GetType("ExpressiveSharp.Generated.ExpressionRegistry", throwOnError: false);
+ }
+ catch
+ {
+ continue;
+ }
+
+ 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 */ }
+ }
+ }
+}
diff --git a/src/ExpressiveSharp/Services/ExpressiveReplacer.cs b/src/ExpressiveSharp/Services/ExpressiveReplacer.cs
index 2674981e..58b6a278 100644
--- a/src/ExpressiveSharp/Services/ExpressiveReplacer.cs
+++ b/src/ExpressiveSharp/Services/ExpressiveReplacer.cs
@@ -21,6 +21,8 @@ public class ExpressiveReplacer : ExpressionVisitor
private static readonly ConditionalWeakTable> _compilerGeneratedClosureCache = new();
+ internal static void ClearCachesForMetadataUpdate() => _compilerGeneratedClosureCache.Clear();
+
public ExpressiveReplacer(IExpressiveResolver resolver)
{
_resolver = resolver;
diff --git a/src/ExpressiveSharp/Services/ExpressiveResolver.cs b/src/ExpressiveSharp/Services/ExpressiveResolver.cs
index ce5f4d7f..27e2c345 100644
--- a/src/ExpressiveSharp/Services/ExpressiveResolver.cs
+++ b/src/ExpressiveSharp/Services/ExpressiveResolver.cs
@@ -35,6 +35,24 @@ internal static void ResetAllCaches()
_assemblyScanFilter = null;
}
+ ///
+ /// Invalidates cached expression trees so the next lookup rebuilds from the (possibly
+ /// hot-reloaded) generated factory method. Called from .
+ /// Preserves _assemblyScanFilter and _typeNameCache — neither goes stale on
+ /// non-rude edits, and wiping the filter would silently disable a user-configured restriction.
+ ///
+ 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? GetAssemblyScanFilter() => _assemblyScanFilter;
+
private static Func? _assemblyScanFilter;
///
diff --git a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/RegistryTests.MethodOverloads_BothRegistered.verified.txt b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/RegistryTests.MethodOverloads_BothRegistered.verified.txt
index 93453bac..8fe54561 100644
--- a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/RegistryTests.MethodOverloads_BothRegistered.verified.txt
+++ b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/RegistryTests.MethodOverloads_BothRegistered.verified.txt
@@ -22,7 +22,9 @@ namespace ExpressiveSharp.Generated
return map;
}
- private static readonly Dictionary _map = Build();
+ private static volatile Dictionary _map = Build();
+
+ internal static void ResetMap() => _map = Build();
public static LambdaExpression TryGet(MemberInfo member)
{
diff --git a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/RegistryTests.MultipleExpressives_AllRegistered.verified.txt b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/RegistryTests.MultipleExpressives_AllRegistered.verified.txt
index 2a77d60a..64aab713 100644
--- a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/RegistryTests.MultipleExpressives_AllRegistered.verified.txt
+++ b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/RegistryTests.MultipleExpressives_AllRegistered.verified.txt
@@ -22,7 +22,9 @@ namespace ExpressiveSharp.Generated
return map;
}
- private static readonly Dictionary _map = Build();
+ private static volatile Dictionary _map = Build();
+
+ internal static void ResetMap() => _map = Build();
public static LambdaExpression TryGet(MemberInfo member)
{
diff --git a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/RegistryTests.SingleMethod_RegistryContainsEntry.verified.txt b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/RegistryTests.SingleMethod_RegistryContainsEntry.verified.txt
index 804c39ad..3ff6151d 100644
--- a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/RegistryTests.SingleMethod_RegistryContainsEntry.verified.txt
+++ b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/RegistryTests.SingleMethod_RegistryContainsEntry.verified.txt
@@ -21,7 +21,9 @@ namespace ExpressiveSharp.Generated
return map;
}
- private static readonly Dictionary _map = Build();
+ private static volatile Dictionary _map = Build();
+
+ internal static void ResetMap() => _map = Build();
public static LambdaExpression TryGet(MemberInfo member)
{
diff --git a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/RegistryTests.SingleProperty_RegistryContainsEntry.verified.txt b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/RegistryTests.SingleProperty_RegistryContainsEntry.verified.txt
index 5126e389..56bdc827 100644
--- a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/RegistryTests.SingleProperty_RegistryContainsEntry.verified.txt
+++ b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/RegistryTests.SingleProperty_RegistryContainsEntry.verified.txt
@@ -21,7 +21,9 @@ namespace ExpressiveSharp.Generated
return map;
}
- private static readonly Dictionary _map = Build();
+ private static volatile Dictionary _map = Build();
+
+ internal static void ResetMap() => _map = Build();
public static LambdaExpression TryGet(MemberInfo member)
{
diff --git a/tests/ExpressiveSharp.Tests/Services/ExpressiveHotReloadHandlerTests.cs b/tests/ExpressiveSharp.Tests/Services/ExpressiveHotReloadHandlerTests.cs
new file mode 100644
index 00000000..d1149146
--- /dev/null
+++ b/tests/ExpressiveSharp.Tests/Services/ExpressiveHotReloadHandlerTests.cs
@@ -0,0 +1,95 @@
+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(_ => 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 ClearCache_WithUpdatedTypes_ClearsResolverCache()
+ {
+ var mi = typeof(Product).GetProperty(nameof(Product.Total))!;
+ var resolver = new ExpressiveResolver();
+
+ _ = resolver.FindGeneratedExpression(mi);
+ Assert.IsTrue(ExpressiveResolver.IsExpressionCached(mi));
+
+ ExpressiveHotReloadHandler.ClearCache([typeof(Product)]);
+
+ Assert.IsFalse(ExpressiveResolver.IsExpressionCached(mi));
+ }
+
+ [TestMethod]
+ public void UpdateApplication_WithNull_DoesNotThrow()
+ {
+ ExpressiveHotReloadHandler.UpdateApplication(null);
+ }
+
+ [TestMethod]
+ public void Assembly_RegistersExpressiveHotReloadHandler()
+ {
+ var attributes = typeof(ExpressiveResolver).Assembly
+ .GetCustomAttributes()
+ .ToList();
+
+ Assert.IsTrue(attributes.Any(a => a.HandlerType == typeof(ExpressiveHotReloadHandler)),
+ "MetadataUpdateHandlerAttribute for ExpressiveHotReloadHandler not found on ExpressiveSharp assembly.");
+ }
+}