diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/JintExtensions.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/JintExtensions.cs
index 7787448fe..2dc19097d 100644
--- a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/JintExtensions.cs
+++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/JintExtensions.cs
@@ -7,6 +7,7 @@
using Jint;
using Jint.Native;
+using Jint.Runtime.Interop;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Core.Scripting.Internal;
@@ -73,7 +74,10 @@ public static class JintExtensions
{
foreach (var (key, item) in vars)
{
- engine.SetValue(key, item);
+ // Deferred instead of Engine.SetValue, which maps every variable now. The global itself is
+ // installed eagerly, so existence checks and enumeration see the name without materializing
+ // anything; only the mapping waits for the first read of the value.
+ engine.Advanced.AddLazyGlobal(key, e => MapVariable(e, item));
}
}
@@ -81,4 +85,18 @@ public static class JintExtensions
return context;
}
+
+ ///
+ /// The conversion performs, including its special case for
+ /// a CLR type, so deferring a variable cannot change what the script sees.
+ ///
+ private static JsValue MapVariable(Engine engine, object? item)
+ {
+ if (item is Type type)
+ {
+ return TypeReference.CreateTypeReference(engine, type);
+ }
+
+ return JsValue.FromObject(engine, item);
+ }
}
diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/WritableContext.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/WritableContext.cs
index a04b3a255..3ccfcab70 100644
--- a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/WritableContext.cs
+++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/WritableContext.cs
@@ -1,4 +1,4 @@
-// ==========================================================================
+// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
@@ -8,6 +8,7 @@
using Jint;
using Jint.Native;
using Jint.Native.Object;
+using Jint.Runtime.Descriptors;
namespace Squidex.Domain.Apps.Core.Scripting;
@@ -20,9 +21,17 @@ internal sealed class WritableContext : ObjectInstance
{
this.vars = vars;
+ // Scripts touch a fraction of the variables, but mapping one is not always cheap: a content data
+ // variable builds a wrapper, a user variable walks and groups every claim. The descriptors are
+ // installed eagerly - so key order, enumeration and existence checks are exactly what they were -
+ // and only the mapping waits for the first read of a value. Once it has run the descriptor drops
+ // back to an ordinary data property and rejoins the write inline cache, which is what a
+ // hand-written CustomJsValue descriptor cannot do.
foreach (var (key, item) in vars)
{
- base.Set(key, FromObject(engine, item), this);
+ SetOwnProperty(key, PropertyDescriptor.CreateLazy(
+ (Engine: engine, Item: item),
+ static state => FromObject(state.Engine, state.Item)));
}
}
diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineTests.cs
index 365f44cb3..fec93fd7b 100644
--- a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineTests.cs
+++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineTests.cs
@@ -1047,6 +1047,153 @@ public class JintScriptEngineTests : IClassFixture
Assert.Equal(JsonValue.Create("number,json,user"), actual);
}
+ [Fact]
+ public void Should_not_map_unread_variable()
+ {
+ var principal = new CountingPrincipal();
+
+ var vars = new ScriptVars
+ {
+ ["number"] = 13,
+ ["user"] = principal,
+ };
+
+ const string script = @"
+ number + 1;
+ ";
+
+ var actual = sut.Execute(vars, script);
+
+ Assert.Equal(JsonValue.Create(14), actual);
+ Assert.Equal(0, principal.Reads);
+ }
+
+ [Fact]
+ public void Should_see_unread_variable_in_enumeration()
+ {
+ var principal = new CountingPrincipal();
+
+ var vars = new ScriptVars
+ {
+ ["user"] = principal,
+ };
+
+ const string script = @"
+ ('user' in globalThis) + ',' + (Object.getOwnPropertyNames(globalThis).indexOf('user') >= 0);
+ ";
+
+ var actual = sut.Execute(vars, script);
+
+ Assert.Equal(JsonValue.Create("true,true"), actual);
+ Assert.Equal(0, principal.Reads);
+ }
+
+ [Fact]
+ public void Should_map_variable_on_first_read()
+ {
+ var principal = new CountingPrincipal();
+
+ var vars = new ScriptVars
+ {
+ ["user"] = principal,
+ };
+
+ const string script = @"
+ user.id;
+ ";
+
+ var actual = sut.Execute(vars, script);
+
+ Assert.Equal(JsonValue.Create("user1"), actual);
+ Assert.True(principal.Reads > 0);
+ }
+
+ [Fact]
+ public void Should_not_map_unread_context_variable()
+ {
+ var principal = new CountingPrincipal();
+
+ var vars = new ScriptVars
+ {
+ ["number"] = 13,
+ ["user"] = principal,
+ };
+
+ const string script = @"
+ ctx.number + 1;
+ ";
+
+ var actual = sut.Execute(vars, script, new ScriptOptions { AsContext = true });
+
+ Assert.Equal(JsonValue.Create(14), actual);
+ Assert.Equal(0, principal.Reads);
+ }
+
+ [Fact]
+ public void Should_see_unread_context_variable_in_enumeration()
+ {
+ var principal = new CountingPrincipal();
+
+ var vars = new ScriptVars
+ {
+ ["number"] = 13,
+ ["user"] = principal,
+ };
+
+ const string script = @"
+ Object.keys(ctx).join(',') + '|' + ('user' in ctx);
+ ";
+
+ var actual = sut.Execute(vars, script, new ScriptOptions { AsContext = true });
+
+ Assert.Equal(JsonValue.Create("number,user|true"), actual);
+ Assert.Equal(0, principal.Reads);
+ }
+
+ [Fact]
+ public void Should_map_context_variable_on_first_read()
+ {
+ var principal = new CountingPrincipal();
+
+ var vars = new ScriptVars
+ {
+ ["user"] = principal,
+ };
+
+ const string script = @"
+ ctx.user.id;
+ ";
+
+ var actual = sut.Execute(vars, script, new ScriptOptions { AsContext = true });
+
+ Assert.Equal(JsonValue.Create("user1"), actual);
+ Assert.True(principal.Reads > 0);
+ }
+
+ private sealed class CountingPrincipal : ClaimsPrincipal
+ {
+ public int Reads { get; private set; }
+
+ public CountingPrincipal()
+ : base(new ClaimsIdentity(
+ [
+ new Claim(OpenIdClaims.Subject, "user1"),
+ new Claim(OpenIdClaims.Name, "user"),
+ ], "Squidex"))
+ {
+ }
+
+ public override IEnumerable Claims
+ {
+ get
+ {
+ Reads++;
+
+ return base.Claims;
+ }
+ }
+ }
+
private static ScriptVars CreateVars()
{
return new ScriptVars