From a03c37457d8b17063dc016701f83b019c2aedef1 Mon Sep 17 00:00:00 2001 From: Marko Lahma Date: Fri, 10 Jul 2026 08:04:21 +0300 Subject: [PATCH] Reuse class member definitions across class re-evaluations Every evaluation of a class - a factory function returning one, or a re-run prepared script - rebuilt a JintFunctionDefinition per method, accessor, field initializer and static block, and with it each member's body handler tree and inline caches. The per-engine definition cache introduced for hoisted declarations now also serves DefineMethod, MethodDefinitionEvaluation, class field initializers (including auto-accessor backing fields) and static blocks, keyed on their stable AST nodes; the dictionary key widened from FunctionDeclaration to Node. Closures, home objects, environments and private state stay strictly per evaluation: only the immutable definition (analysis + body handler tree) is shared, the ScriptFunction instances are created fresh with their own [[HomeObject]]/[[Environment]] as before. Tests pin factory-produced classes staying independent (identities, prototypes, method identities, per-class captured tags in field initializers and static blocks, private accessors), super binding per evaluation, and repeated prepared-script evaluation. Co-Authored-By: Claude Fable 5 --- Jint.Benchmark/ClassBenchmark.cs | 34 +++++++++ Jint.Tests/Runtime/ClassReEvaluationTests.cs | 80 ++++++++++++++++++++ Jint/AstExtensions.cs | 9 ++- Jint/Engine.cs | 39 ++++++---- Jint/Native/Function/ClassDefinition.cs | 29 +++++-- 5 files changed, 170 insertions(+), 21 deletions(-) create mode 100644 Jint.Tests/Runtime/ClassReEvaluationTests.cs diff --git a/Jint.Benchmark/ClassBenchmark.cs b/Jint.Benchmark/ClassBenchmark.cs index 2359b319c8..87b3879472 100644 --- a/Jint.Benchmark/ClassBenchmark.cs +++ b/Jint.Benchmark/ClassBenchmark.cs @@ -51,4 +51,38 @@ public void GetSet() _engine.Evaluate(script); } } + + [Benchmark] + public void ReEvaluateClassDeclarations() + { + // the dom.js / class-factory shape: one engine re-evaluates the same prepared script that + // declares classes; member definitions come from the per-engine cache while class + // identities, prototypes, private state and static-block effects stay per-evaluation + var script = Engine.PrepareScript(""" + (function () { + class Node { + #tag = 'node'; + static VERSION = 1; + static { this.registry = []; } + constructor(name) { this.name = name; this.children = []; } + appendChild(child) { this.children.push(child); return child; } + get childCount() { return this.children.length; } + set alias(value) { this.name = value; } + describe() { return this.#tag + ':' + this.name; } + static create(name) { return new Node(name); } + } + class Element extends Node { + describe() { return 'element:' + super.describe(); } + } + const e = new Element('div'); + e.appendChild(Node.create('span')); + e.alias = 'main'; + return e.describe() + '/' + e.childCount + '/' + Element.VERSION; + })(); + """); + for (var i = 0; i < 40_000; ++i) + { + _engine.Evaluate(script); + } + } } diff --git a/Jint.Tests/Runtime/ClassReEvaluationTests.cs b/Jint.Tests/Runtime/ClassReEvaluationTests.cs new file mode 100644 index 0000000000..748f0bb157 --- /dev/null +++ b/Jint.Tests/Runtime/ClassReEvaluationTests.cs @@ -0,0 +1,80 @@ +namespace Jint.Tests.Runtime; + +/// +/// Pins the semantics of the per-engine function-definition cache applied to class evaluation: +/// re-evaluating the same class AST (factory functions, re-run prepared scripts) reuses the +/// members' interpreter definitions, while closures, home objects, environments and private +/// state stay strictly per evaluation. +/// +public class ClassReEvaluationTests +{ + [Fact] + public void ClassFactoriesProduceIndependentClasses() + { + var engine = new Engine(); + var result = engine.Evaluate(""" + (function () { + function make(tag) { + return class { + static origin = tag; + #secret = tag + '!'; + constructor() { this.made = tag; } + get secret() { return this.#secret; } + describe() { return tag + ':' + this.made; } + static { this.stamped = tag.toUpperCase(); } + }; + } + const A = make('a'), B = make('b'); + const a = new A(), b = new B(); + return [ + A !== B, A.prototype !== B.prototype, + A.prototype.describe !== B.prototype.describe, + a.describe(), b.describe(), + a.secret, b.secret, + A.origin, B.origin, A.stamped, B.stamped, + a instanceof A, a instanceof B + ].join(','); + })() + """).AsString(); + + Assert.Equal("true,true,true,a:a,b:b,a!,b!,a,b,A,B,true,false", result); + } + + [Fact] + public void SuperAndHomeObjectStayPerEvaluation() + { + var engine = new Engine(); + var result = engine.Evaluate(""" + (function () { + function make(base) { + return class extends base { hello() { return 'sub-' + super.hello(); } }; + } + class B1 { hello() { return 'one'; } } + class B2 { hello() { return 'two'; } } + const S1 = make(B1), S2 = make(B2); + return new S1().hello() + '|' + new S2().hello(); + })() + """).AsString(); + + Assert.Equal("sub-one|sub-two", result); + } + + [Fact] + public void RepeatedPreparedScriptEvaluationStaysCorrect() + { + var engine = new Engine(); + // top-level `class` is a lexical declaration and correctly throws on re-declaration, so the + // re-evaluation pattern wraps it — the class AST (and its cached member definitions) still + // repeats across evaluations + var prepared = Engine.PrepareScript(""" + (function () { + class Point { #x; constructor(x) { this.#x = x; } get x() { return this.#x; } double() { return this.x * 2; } } + return new Point(21).double(); + })(); + """); + + Assert.Equal(42, engine.Evaluate(prepared).AsNumber()); + Assert.Equal(42, engine.Evaluate(prepared).AsNumber()); + Assert.Equal(42, engine.Evaluate(prepared).AsNumber()); + } +} diff --git a/Jint/AstExtensions.cs b/Jint/AstExtensions.cs index 5bf213203e..2f601e44f6 100644 --- a/Jint/AstExtensions.cs +++ b/Jint/AstExtensions.cs @@ -329,7 +329,14 @@ internal static Record DefineMethod(this T m, ObjectInstance obj, ObjectInsta Throw.SyntaxError(engine.Realm); } - var definition = new JintFunctionDefinition(function, sourceTextNode); + // re-evaluating the same class (factory functions, re-run prepared scripts) reuses the + // method's interpreter definition and its warm body handler tree; the closure, home object + // and environments stay per-evaluation + if (!engine.TryGetFunctionDefinition((Node) function, out var definition)) + { + engine.CacheFunctionDefinition((Node) function, definition = new JintFunctionDefinition(function, sourceTextNode)); + } + var closure = intrinsics.Function.OrdinaryFunctionCreate(prototype, definition, definition.ThisMode, env, privateEnv); closure.MakeMethod(obj); diff --git a/Jint/Engine.cs b/Jint/Engine.cs index e31c2fc855..d601915669 100644 --- a/Jint/Engine.cs +++ b/Jint/Engine.cs @@ -54,10 +54,11 @@ public sealed partial class Engine : IDisposable internal EvaluationContext? _activeEvaluationContext; internal ErrorDispatchInfo? _error; - // Per-engine cache of function declarations' interpreter definitions, keyed on the AST node. - // The definition owns the lazily-built body handler tree — and through it every per-node inline - // cache — so reusing it across evaluations and calls keeps a prepared script's functions warm - // instead of rebuilding the subtree each time declaration instantiation runs. + // Per-engine cache of interpreter function definitions — hoisted declarations, class methods, + // class field initializers and static blocks — keyed on the stable AST node. The definition + // owns the lazily-built body handler tree — and through it every per-node inline cache — so + // reusing it across evaluations and calls keeps a prepared script's functions warm instead of + // rebuilding the subtree each time declaration instantiation or class evaluation runs. // // Lifetime notes. Engine-owned rather than shared via the AST's UserData State because handler // trees accumulate engine-affine cache entries, and sharing them across engines retains dead @@ -68,7 +69,7 @@ public sealed partial class Engine : IDisposable // failure the weak table was meant to avoid. The strong form instead retains definitions for // scripts this engine evaluated until the engine dies, mirroring Realm._templateMap's existing // behavior. - private readonly Dictionary _hoistedFunctionDefinitions = new(); + private readonly Dictionary _functionDefinitions = new(); // Scripts this engine has run global declaration instantiation for, so the definition cache // above only engages on RE-evaluation (see GlobalDeclarationInstantiation). @@ -76,22 +77,30 @@ public sealed partial class Engine : IDisposable internal JintFunctionDefinition GetOrCreateFunctionDefinition(FunctionDeclaration declaration) { - if (!_hoistedFunctionDefinitions.TryGetValue(declaration, out var definition)) + if (!TryGetFunctionDefinition(declaration, out var definition)) { - // backstop for hosts streaming endless distinct sources (unique eval/script texts) - // through one engine: reset rather than grow without bound - steady-state reuse of a - // sane number of scripts never reaches this - if (_hoistedFunctionDefinitions.Count >= 2048) - { - _hoistedFunctionDefinitions.Clear(); - } - - _hoistedFunctionDefinitions[declaration] = definition = new JintFunctionDefinition(declaration); + CacheFunctionDefinition(declaration, definition = new JintFunctionDefinition(declaration)); } return definition; } + internal bool TryGetFunctionDefinition(Node key, [NotNullWhen(true)] out JintFunctionDefinition? definition) + => _functionDefinitions.TryGetValue(key, out definition); + + internal void CacheFunctionDefinition(Node key, JintFunctionDefinition definition) + { + // backstop for hosts streaming endless distinct sources (unique eval/script texts) + // through one engine: reset rather than grow without bound - steady-state reuse of a + // sane number of scripts never reaches this + if (_functionDefinitions.Count >= 2048) + { + _functionDefinitions.Clear(); + } + + _functionDefinitions[key] = definition; + } + private readonly EventLoop _eventLoop = new(); internal EventLoop EventLoop => _eventLoop; diff --git a/Jint/Native/Function/ClassDefinition.cs b/Jint/Native/Function/ClassDefinition.cs index a59542d084..e44e89c39d 100644 --- a/Jint/Native/Function/ClassDefinition.cs +++ b/Jint/Native/Function/ClassDefinition.cs @@ -613,7 +613,11 @@ private static AutoAccessorResult AutoAccessorDefinitionEvaluation( var env = engine.ExecutionContext.LexicalEnvironment; var privEnv = engine.ExecutionContext.PrivateEnvironment; - var definition = new JintFunctionDefinition(new ClassFieldFunction(accessorProperty.Value)); + if (!engine.TryGetFunctionDefinition(accessorProperty.Value, out var definition)) + { + engine.CacheFunctionDefinition(accessorProperty.Value, definition = new JintFunctionDefinition(new ClassFieldFunction(accessorProperty.Value))); + } + initializer = intrinsics.Function.OrdinaryFunctionCreate(intrinsics.Function.PrototypeObject, definition, FunctionThisMode.Global, env, privEnv); initializer.MakeMethod(target); initializer._classFieldInitializerName = name; @@ -658,7 +662,11 @@ private static AutoAccessorResult AutoAccessorDefinitionEvaluation( var env = engine.ExecutionContext.LexicalEnvironment; var privEnv = engine.ExecutionContext.PrivateEnvironment; - var definition = new JintFunctionDefinition(new ClassFieldFunction(accessorProperty.Value)); + if (!engine.TryGetFunctionDefinition(accessorProperty.Value, out var definition)) + { + engine.CacheFunctionDefinition(accessorProperty.Value, definition = new JintFunctionDefinition(new ClassFieldFunction(accessorProperty.Value))); + } + initializer = intrinsics.Function.OrdinaryFunctionCreate(intrinsics.Function.PrototypeObject, definition, FunctionThisMode.Global, env, privEnv); initializer.MakeMethod(target); initializer._classFieldInitializerName = name; @@ -689,7 +697,11 @@ private static ClassFieldDefinition ClassFieldDefinitionEvaluation(Engine engine var env = engine.ExecutionContext.LexicalEnvironment; var privateEnv = engine.ExecutionContext.PrivateEnvironment; - var definition = new JintFunctionDefinition(new ClassFieldFunction(fieldDefinition.Value)); + if (!engine.TryGetFunctionDefinition(fieldDefinition.Value, out var definition)) + { + engine.CacheFunctionDefinition(fieldDefinition.Value, definition = new JintFunctionDefinition(new ClassFieldFunction(fieldDefinition.Value))); + } + initializer = intrinsics.Function.OrdinaryFunctionCreate(intrinsics.Function.PrototypeObject, definition, FunctionThisMode.Global, env, privateEnv); initializer.MakeMethod(homeObject); @@ -729,7 +741,10 @@ private static ClassStaticBlockDefinition ClassStaticBlockDefinitionEvaluation(E { var intrinsics = engine.Realm.Intrinsics; - var definition = new JintFunctionDefinition(new ClassStaticBlockFunction(o)); + if (!engine.TryGetFunctionDefinition(o, out var definition)) + { + engine.CacheFunctionDefinition(o, definition = new JintFunctionDefinition(new ClassStaticBlockFunction(o))); + } var lex = engine.ExecutionContext.LexicalEnvironment; var privateEnv = engine.ExecutionContext.PrivateEnvironment; @@ -799,7 +814,11 @@ public ClassStaticBlockFunction(StaticBlock staticBlock) : base(NodeType.StaticB return DefineMethodProperty(obj, methodDef.Key, methodDef.Closure, enumerable); } - var definition = new JintFunctionDefinition(function, sourceTextNode: method); + if (!engine.TryGetFunctionDefinition((Node) function, out var definition)) + { + engine.CacheFunctionDefinition((Node) function, definition = new JintFunctionDefinition(function, sourceTextNode: method)); + } + var intrinsics = engine.Realm.Intrinsics; var value = method.TryGetKey(engine);