diff --git a/src/modules/Elsa.Expressions.JavaScript/Extensions/EngineExtensions.cs b/src/modules/Elsa.Expressions.JavaScript/Extensions/EngineExtensions.cs
index 7d00527aa..1ec883a53 100644
--- a/src/modules/Elsa.Expressions.JavaScript/Extensions/EngineExtensions.cs
+++ b/src/modules/Elsa.Expressions.JavaScript/Extensions/EngineExtensions.cs
@@ -15,12 +15,56 @@ public static class EngineExtensions
///
/// Register the specified type T with the engine.
///
- public static void RegisterType(this Engine engine) => engine.SetValue(typeof(T).Name, TypeReference.CreateTypeReference(engine, typeof(T)));
-
+ public static void RegisterType(this Engine engine) => engine.RegisterType(typeof(T));
+
///
- /// Register the specified type T with the engine.
+ /// Register the specified type with the engine, under its type name.
///
- public static void RegisterType(this Engine engine, Type type) => engine.SetValue(type.Name, TypeReference.CreateTypeReference(engine, type));
+ ///
+ ///
+ /// Types whose name cannot be written as a JavaScript identifier are skipped. A constructed generic type such
+ /// as IDictionary<string, object> is named IDictionary`2 and an array type such as
+ /// byte[] is named Byte[]. Registered, those would only be reachable through bracket notation —
+ /// globalThis['Byte[]'] — and every constructed generic type of the same arity would claim the same
+ /// global, so the last one registered would silently win.
+ ///
+ ///
+ /// A name that is already taken is left alone. Registering the same type twice is therefore a no-op, and a
+ /// global the host installed — through JintOptions.RegisterType, JintOptions.ConfigureEngine or
+ /// the per-evaluation configureEngine callback, all of which run before the built-in registrations —
+ /// is never replaced.
+ ///
+ ///
+ public static void RegisterType(this Engine engine, Type type)
+ {
+ var name = type.Name;
+
+ if (!IsUsableAsIdentifier(name))
+ return;
+
+ // The type registrations are contributed by several independent handlers, whose sets overlap, and they run
+ // after the host has configured the engine. Leaving an occupied name alone does two things: the overlapping
+ // types are no longer described through reflection again for every expression evaluation, and a global that
+ // the host - or JavaScript itself - already put there keeps its value.
+ if (engine.Global.HasOwnProperty(name))
+ return;
+
+ engine.SetValue(name, TypeReference.CreateTypeReference(engine, type));
+ }
+
+ private static bool IsUsableAsIdentifier(string name)
+ {
+ if (string.IsNullOrEmpty(name) || (!char.IsLetter(name[0]) && name[0] != '_' && name[0] != '$'))
+ return false;
+
+ foreach (var c in name)
+ {
+ if (!char.IsLetterOrDigit(c) && c != '_' && c != '$')
+ return false;
+ }
+
+ return true;
+ }
internal static void SyncVariablesContainer(this Engine engine, IOptions options, string name, object? value)
{
@@ -32,4 +76,4 @@ public static class EngineExtensions
variablesContainer[name] = ObjectConverterHelper.ProcessVariableValue(engine, value);
engine.SetValue("variables", variablesContainer);
}
-}
\ No newline at end of file
+}
diff --git a/test/integration/Elsa.JavaScript.IntegrationTests/TypeRegistrationTests.cs b/test/integration/Elsa.JavaScript.IntegrationTests/TypeRegistrationTests.cs
new file mode 100644
index 000000000..f592c6cdd
--- /dev/null
+++ b/test/integration/Elsa.JavaScript.IntegrationTests/TypeRegistrationTests.cs
@@ -0,0 +1,91 @@
+using Elsa.Expressions.JavaScript.Contracts;
+using Elsa.Expressions.Models;
+using Elsa.Extensions;
+using Elsa.Testing.Shared;
+using Jint;
+using Jint.Native;
+using Jint.Runtime.Interop;
+using Microsoft.Extensions.DependencyInjection;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Elsa.JavaScript.IntegrationTests;
+
+///
+/// Verifies which .NET types are exposed to JavaScript and under which names.
+///
+public class TypeRegistrationTests
+{
+ private readonly IServiceProvider _serviceProvider;
+ private readonly IJavaScriptEvaluator _evaluator;
+
+ public TypeRegistrationTests(ITestOutputHelper testOutputHelper)
+ {
+ _serviceProvider = new TestApplicationBuilder(testOutputHelper).Build();
+ _evaluator = _serviceProvider.GetRequiredService();
+ }
+
+ [Theory(DisplayName = "Common .NET types are available under their type name")]
+ [InlineData("DateTime")]
+ [InlineData("DateTimeOffset")]
+ [InlineData("TimeSpan")]
+ [InlineData("Guid")]
+ [InlineData("Random")]
+ [InlineData("LogPersistenceMode")]
+ [InlineData("ExpandoObject")]
+ [InlineData("JsonElement")]
+ [InlineData("JsonNode")]
+ [InlineData("JsonObject")]
+ [InlineData("Stream")]
+ public async Task CommonTypesAreRegistered(string typeName)
+ {
+ Assert.Equal("function", await EvaluateAsync($"return typeof {typeName};"));
+ }
+
+ [Theory(DisplayName = "Types whose name is not a JavaScript identifier are not registered")]
+ [InlineData("IDictionary`2")]
+ [InlineData("Byte[]")]
+ public async Task TypesWithUnusableNamesAreNotRegistered(string globalName)
+ {
+ Assert.Equal("undefined", await EvaluateAsync($"return typeof globalThis['{globalName}'];"));
+ }
+
+ [Fact(DisplayName = "A type registered by the host stays available after the built-in registrations run")]
+ public async Task HostRegisteredTypesSurviveTheBuiltInRegistrations()
+ {
+ Assert.Equal("function", await EvaluateAsync("return typeof Uri;", engine => engine.RegisterType()));
+ }
+
+ [Fact(DisplayName = "A global installed by the host wins over the built-in registration of the same name")]
+ public async Task HostGlobalsAreNotOverwrittenByTheBuiltInRegistrations()
+ {
+ // Guid is registered by both ConfigureEngineWithCommonTypes and the default variable descriptor set, and
+ // those handlers run after the configureEngine callback. The host's value has to survive them.
+ Assert.Equal("host-provided", await EvaluateAsync("return Guid;", engine => engine.SetValue("Guid", "host-provided")));
+ }
+
+ [Fact(DisplayName = "A registered type is exposed as a CLR type reference and registering it again is a no-op")]
+ public async Task RegisterTypeInstallsATypeReferenceAndIsIdempotent()
+ {
+ JsValue afterFirstRegistration = JsValue.Undefined;
+ JsValue afterSecondRegistration = JsValue.Undefined;
+
+ await EvaluateAsync("return 'done';", engine =>
+ {
+ engine.RegisterType();
+ afterFirstRegistration = engine.GetValue("Uri");
+ engine.RegisterType();
+ afterSecondRegistration = engine.GetValue("Uri");
+ });
+
+ var typeReference = Assert.IsType(afterFirstRegistration);
+ Assert.Equal(typeof(Uri), typeReference.ReferenceType);
+ Assert.Same(afterFirstRegistration, afterSecondRegistration);
+ }
+
+ private async Task EvaluateAsync(string script, Action? configureEngine = null)
+ {
+ var context = new ExpressionExecutionContext(_serviceProvider, new());
+ return (string?)await _evaluator.EvaluateAsync(script, typeof(string), context, configureEngine: configureEngine);
+ }
+}