From 11f7c9ab9880626edd6b742e7e53cb0c584c39ea Mon Sep 17 00:00:00 2001 From: Marko Lahma Date: Wed, 29 Jul 2026 14:16:00 +0300 Subject: [PATCH] Project JSON objects into script as shape-mode objects JsonMapper built every JSON object it projects into script as an instance of a private ObjectInstance subclass that existed only to be instantiable. A host subclass can never carry the engine's shape-mode storage flag, so each of those objects - all of ctx.data's leaf objects, and every JsonValue var - sat permanently outside the own-property inline caches, and a script reading the same property across a batch of content items re-resolved it every time. JsObject.CreateFromEntries builds the same object through the hidden class machinery instead: repeated calls presenting the same key sequence, which every content item of one schema does, share an interned hidden class, so those reads stay monomorphic. The result is documented as indistinguishable from the equivalent object literal - same own key order, same configurable/enumerable/writable data properties - and anything the representation cannot express (a digit-leading key, a very wide object) falls back to the ordinary dictionary representation rather than to different behaviour. That fallback is silent, which is why the shaping is asserted rather than assumed: Engine.Advanced.HasSharedShape is the supported predicate for it, and JsonMapperTests pins that the projected object and its nested objects answer true. Building them as a host subclass again would fail that test. Three smaller fixes in the same file: - the reverse direction allocated a string key per array element (a.Get(i.ToString(...))); the indexed accessor reads the dense backing directly and keeps the prototype walk for a modified array; - JsNumber.Create reuses cached instances for small integers where new JsNumber always allocated; - JsString.Create, public since 4.15.3, interns the empty and single character strings where new JsString always allocated. Pinned by the projection tests added first - own key order, JSON.stringify, for..in, mutation including delete and add, and the round trip back to JsonValue - which pass before and after. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016uV6H9cTntzsoKiaJRBn4f --- .../Scripting/Internal/JsonMapper.cs | 31 ++++++----- .../Operations/Scripting/JsonMapperTests.cs | 53 +++++++++++++++++++ 2 files changed, 70 insertions(+), 14 deletions(-) create mode 100644 backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JsonMapperTests.cs diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/JsonMapper.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/JsonMapper.cs index 4fd3efdf8..3f0ed8ac1 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/JsonMapper.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/JsonMapper.cs @@ -6,7 +6,6 @@ // ========================================================================== using System.Collections; -using System.Globalization; using Jint; using Jint.Native; using Jint.Native.Object; @@ -18,10 +17,6 @@ namespace Squidex.Domain.Apps.Core.Scripting.Internal; public static class JsonMapper { - private sealed class JsonObjectInstance(Engine engine) : ObjectInstance(engine) - { - } - public static JsValue Map(JsonValue value, Engine engine) { switch (value.Value) @@ -33,9 +28,9 @@ public static class JsonMapper case false: return JsBoolean.False; case double n: - return new JsNumber(n); + return JsNumber.Create(n); case string s: - return new JsString(s); + return JsString.Create(s); case JsonObject o: return FromObject(o, engine); case JsonArray a: @@ -58,16 +53,20 @@ public static class JsonMapper return engine.Intrinsics.Array.Construct(target); } - private static JsonObjectInstance FromObject(JsonObject obj, Engine engine) + private static JsObject FromObject(JsonObject obj, Engine engine) { - var target = new JsonObjectInstance(engine); + // Built through the hidden class machinery, so JSON objects sharing a key sequence - every content + // item of the same schema does - share one hidden class and keep a script reading them monomorphic. + // A bare ObjectInstance subclass can never be in shape mode and is outside the read caches entirely. + var entries = new KeyValuePair[obj.Count]; + var index = 0; foreach (var (key, value) in obj) { - target.Set(key, Map(value, engine)); + entries[index++] = new KeyValuePair(key, Map(value, engine)); } - return target; + return JsObject.CreateFromEntries(engine, entries); } public static JsonValue Map(JsValue? value) @@ -116,11 +115,15 @@ public static class JsonMapper if (value is JsArray a) { - var result = new JsonArray((int)a.Length); + var length = a.Length; + + var result = new JsonArray((int)length); - for (var i = 0; i < a.Length; i++) + // The indexed accessor reads the dense backing directly, where a string key would allocate one + // key per element and route through the full property lookup. + for (var i = 0u; i < length; i++) { - result.Add(Map(a.Get(i.ToString(CultureInfo.InvariantCulture)))); + result.Add(Map(a[i])); } return result; diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JsonMapperTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JsonMapperTests.cs new file mode 100644 index 000000000..2872ebcd8 --- /dev/null +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JsonMapperTests.cs @@ -0,0 +1,53 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Jint; +using Jint.Native.Object; +using Squidex.Domain.Apps.Core.Scripting.Internal; +using Squidex.Infrastructure.Json.Objects; + +namespace Squidex.Domain.Apps.Core.Operations.Scripting; + +public class JsonMapperTests +{ + [Fact] + public void Should_map_json_objects_into_a_shared_shape() + { + var engine = new Engine(o => o.Strict()); + + var mapped = (ObjectInstance)JsonMapper.Map(CreateJson(), engine); + var nested = (ObjectInstance)mapped.Get("nested"); + + // A shared shape is what keeps a script reading a batch of content items monomorphic. It is a + // performance property and never a correctness one, but it is silent when it regresses: building + // these objects as a host ObjectInstance subclass again would put them back in the per-object + // dictionary with no test noticing. + Assert.True(engine.Advanced.HasSharedShape(mapped)); + Assert.True(engine.Advanced.HasSharedShape(nested)); + } + + [Fact] + public void Should_share_the_shape_between_objects_of_the_same_shape() + { + var engine = new Engine(o => o.Strict()); + + var first = (ObjectInstance)JsonMapper.Map(CreateJson(), engine); + var second = (ObjectInstance)JsonMapper.Map(CreateJson(), engine); + + Assert.True(engine.Advanced.HasSharedShape(first)); + Assert.True(engine.Advanced.HasSharedShape(second)); + } + + private static JsonValue CreateJson() + { + return JsonValue.Create( + new JsonObject() + .Add("name", JsonValue.Create("squidex")) + .Add("count", JsonValue.Create(3)) + .Add("nested", JsonValue.Create(new JsonObject().Add("flag", JsonValue.True)))); + } +}