diff --git a/Compilation/AsyncGeneratorMoveNextEmitter.cs b/Compilation/AsyncGeneratorMoveNextEmitter.cs
index b3f9a5c0..49d6e2c1 100644
--- a/Compilation/AsyncGeneratorMoveNextEmitter.cs
+++ b/Compilation/AsyncGeneratorMoveNextEmitter.cs
@@ -88,7 +88,7 @@ public AsyncGeneratorMoveNextEmitter(AsyncGeneratorStateMachineBuilder builder,
///
/// Emits the complete MoveNextAsync method body.
///
- public void EmitMoveNextAsync(List? body, CompilationContext ctx)
+ public void EmitMoveNextAsync(List? body, CompilationContext ctx, List? parameters = null)
{
if (body == null)
{
@@ -126,6 +126,13 @@ public void EmitMoveNextAsync(List? body, CompilationContext ctx)
// Emit state dispatch switch
EmitStateSwitch();
+ // Apply parameter defaults on initial entry only (state -1 falls through here; resume
+ // states jump past via the switch, the completed state short-circuits above). (#737)
+ if (parameters != null)
+ {
+ EmitDefaultParameters(parameters);
+ }
+
// Emit the function body (will emit yield/await points inline)
foreach (var stmt in body)
{
@@ -169,6 +176,56 @@ public void EmitMoveNextAsync(List? body, CompilationContext ctx)
EmitReturnValueTaskBool(false);
}
+ ///
+ /// Applies parameter defaults at async-generator entry — the async-generator analogue of the sync
+ /// generator and async function versions (this state machine previously ran no default prologue, so
+ /// defaults never fired in compiled mode). A defaulted parameter whose argument was omitted or
+ /// explicitly undefined arrives in its hoisted field as null / the $Undefined sentinel
+ /// and is replaced with the evaluated default. Evaluated in declaration order so a later default may
+ /// reference an earlier (already-defaulted) parameter. (#737)
+ ///
+ private void EmitDefaultParameters(List parameters)
+ {
+ if (!parameters.Any(p => p.DefaultValue != null))
+ return;
+
+ foreach (var param in parameters)
+ {
+ if (param.DefaultValue == null) continue;
+
+ var field = _builder.GetVariableField(param.Name.Lexeme);
+ if (field == null) continue; // Parameter not hoisted (unused in body) — default is moot.
+
+ var applyDefault = _il.DefineLabel();
+ var checkUndefined = _il.DefineLabel();
+ var skipDefault = _il.DefineLabel();
+
+ // if (field == null) apply; else if (field is $Undefined) apply; else keep.
+ _il.Emit(OpCodes.Ldarg_0);
+ _il.Emit(OpCodes.Ldfld, field);
+ _il.Emit(OpCodes.Dup);
+ _il.Emit(OpCodes.Brtrue, checkUndefined);
+ _il.Emit(OpCodes.Pop); // pop the null
+ _il.Emit(OpCodes.Br, applyDefault);
+
+ _il.MarkLabel(checkUndefined);
+ _il.Emit(OpCodes.Isinst, _ctx!.Runtime!.UndefinedType);
+ _il.Emit(OpCodes.Brtrue, applyDefault);
+ _il.Emit(OpCodes.Br, skipDefault);
+
+ _il.MarkLabel(applyDefault);
+ EmitExpression(param.DefaultValue);
+ EnsureBoxed();
+ var temp = _il.DeclareLocal(_types.Object);
+ _il.Emit(OpCodes.Stloc, temp);
+ _il.Emit(OpCodes.Ldarg_0);
+ _il.Emit(OpCodes.Ldloc, temp);
+ _il.Emit(OpCodes.Stfld, field);
+
+ _il.MarkLabel(skipDefault);
+ }
+ }
+
private void EmitStateSwitch()
{
if (_analysis.SuspensionPointCount == 0) return;
diff --git a/Compilation/CallHandlers/AsyncFunctionCallHandler.cs b/Compilation/CallHandlers/AsyncFunctionCallHandler.cs
index cb0dd1c6..50e346c5 100644
--- a/Compilation/CallHandlers/AsyncFunctionCallHandler.cs
+++ b/Compilation/CallHandlers/AsyncFunctionCallHandler.cs
@@ -35,7 +35,7 @@ public bool TryHandle(IEmitterContext emitter, Expr.Call call)
}
for (int i = call.Arguments.Count; i < paramCount; i++)
- emitter.EmitDefaultForType(asyncMethodParams[i].ParameterType);
+ emitter.EmitOmittedArgument(asyncMethodParams[i].ParameterType);
il.Emit(OpCodes.Call, asyncMethod);
diff --git a/Compilation/CallHandlers/ClassExprStaticHandler.cs b/Compilation/CallHandlers/ClassExprStaticHandler.cs
index 871b6369..c9043660 100644
--- a/Compilation/CallHandlers/ClassExprStaticHandler.cs
+++ b/Compilation/CallHandlers/ClassExprStaticHandler.cs
@@ -39,7 +39,7 @@ public bool TryHandle(IEmitterContext emitter, Expr.Call call)
}
for (int i = call.Arguments.Count; i < paramCount; i++)
- emitter.EmitDefaultForType(methodParams[i].ParameterType);
+ emitter.EmitOmittedArgument(methodParams[i].ParameterType);
il.Emit(OpCodes.Call, exprStaticMethod);
emitter.SetStackUnknown();
diff --git a/Compilation/CallHandlers/ImportedClassStaticHandler.cs b/Compilation/CallHandlers/ImportedClassStaticHandler.cs
index e96516ba..ab0e1464 100644
--- a/Compilation/CallHandlers/ImportedClassStaticHandler.cs
+++ b/Compilation/CallHandlers/ImportedClassStaticHandler.cs
@@ -40,7 +40,7 @@ public bool TryHandle(IEmitterContext emitter, Expr.Call call)
}
for (int i = call.Arguments.Count; i < paramCount; i++)
- emitter.EmitDefaultForType(methodParams[i].ParameterType);
+ emitter.EmitOmittedArgument(methodParams[i].ParameterType);
il.Emit(OpCodes.Call, callableMethod);
emitter.SetStackUnknown();
diff --git a/Compilation/CallHandlers/SuperConstructorHandler.cs b/Compilation/CallHandlers/SuperConstructorHandler.cs
index 8667a663..47e97b01 100644
--- a/Compilation/CallHandlers/SuperConstructorHandler.cs
+++ b/Compilation/CallHandlers/SuperConstructorHandler.cs
@@ -234,7 +234,7 @@ private static void EmitSuperCtorCall(IEmitterContext emitter, ConstructorBuilde
}
for (int i = arguments.Count; i < ctorParams.Length; i++)
- emitter.EmitDefaultForType(ctorParams[i].ParameterType);
+ emitter.EmitOmittedArgument(ctorParams[i].ParameterType);
System.Reflection.ConstructorInfo ctorToCall = parentCtor;
Type? baseType = ctx.CurrentClassBuilder?.BaseType;
diff --git a/Compilation/CallHandlers/ThisStaticContextHandler.cs b/Compilation/CallHandlers/ThisStaticContextHandler.cs
index 71aee189..d32c0fbb 100644
--- a/Compilation/CallHandlers/ThisStaticContextHandler.cs
+++ b/Compilation/CallHandlers/ThisStaticContextHandler.cs
@@ -45,7 +45,7 @@ public bool TryHandle(IEmitterContext emitter, Expr.Call call)
}
for (int i = call.Arguments.Count; i < paramCount; i++)
- emitter.EmitDefaultForType(methodParams[i].ParameterType);
+ emitter.EmitOmittedArgument(methodParams[i].ParameterType);
il.Emit(OpCodes.Call, thisStaticMethod);
emitter.SetStackUnknown();
diff --git a/Compilation/EmittedRuntime.cs b/Compilation/EmittedRuntime.cs
index 830e4bf7..1ad8ef0b 100644
--- a/Compilation/EmittedRuntime.cs
+++ b/Compilation/EmittedRuntime.cs
@@ -274,6 +274,13 @@ public class EmittedRuntime
public TypeBuilder PadUndefinedAttrType { get; set; } = null!;
public ConstructorBuilder PadUndefinedAttrCtor { get; set; } = null!;
+ // Marker attribute applied to a user function-expression / `this`-bearing arrow method whose
+ // first emitted parameter is the synthetic `__this` receiver slot. $TSFunction reads it back via
+ // IsDefined so it can detect that slot without the parameter name (a --ref-asm rewrite strips
+ // parameter names, breaking the name-based check and shifting value-call arguments). (#738)
+ public TypeBuilder ExpectsThisAttrType { get; set; } = null!;
+ public ConstructorBuilder ExpectsThisAttrCtor { get; set; } = null!;
+
// String methods
public MethodBuilder StringCharAt { get; set; } = null!;
public MethodBuilder StringSubstring { get; set; } = null!;
diff --git a/Compilation/Emitters/IEmitterContext.cs b/Compilation/Emitters/IEmitterContext.cs
index 7f010e60..3a0ad0da 100644
--- a/Compilation/Emitters/IEmitterContext.cs
+++ b/Compilation/Emitters/IEmitterContext.cs
@@ -78,6 +78,14 @@ public interface IEmitterContext
///
void EmitDefaultForType(Type type);
+ ///
+ /// Emits the value for a trailing parameter slot the call site omits: the $Undefined
+ /// sentinel for an object slot (JS: an omitted argument is undefined), else the
+ /// type's CLR default. Use this instead of when padding omitted
+ /// call arguments. (#739/#705)
+ ///
+ void EmitOmittedArgument(Type slotType);
+
///
/// Emits IL that constructs a delegate of the given type pointing at the
/// arrow's compiled body. For non-capturing arrows: new Func(null, ldftn staticMethod).
diff --git a/Compilation/ExpressionEmitterBase.CallHelpers.cs b/Compilation/ExpressionEmitterBase.CallHelpers.cs
index aafb50b0..421b13dd 100644
--- a/Compilation/ExpressionEmitterBase.CallHelpers.cs
+++ b/Compilation/ExpressionEmitterBase.CallHelpers.cs
@@ -862,7 +862,7 @@ promisesGet.Object is Expr.Variable promisesModuleVar &&
}
for (int i = c.Arguments.Count; i < paramCount; i++)
- EmitDefaultForType(staticMethodParams[i].ParameterType);
+ EmitOmittedArgument(staticMethodParams[i].ParameterType);
IL.Emit(OpCodes.Call, callableMethod);
SetStackUnknown();
@@ -1525,7 +1525,7 @@ protected bool TryEmitDirectMethodCall(Expr receiver, string methodName, List argu
for (int i = arguments.Count; i < methodParams.Length; i++)
{
- EmitDefaultForType(methodParams[i].ParameterType);
+ EmitOmittedArgument(methodParams[i].ParameterType);
}
IL.Emit(OpCodes.Call, superTarget);
@@ -1802,7 +1802,7 @@ protected bool TryEmitDerivedPromiseStatic(string resolvedClassName, Type classB
}
for (int i = 1; i < ctorParams.Length; i++)
- EmitDefaultForType(ctorParams[i].ParameterType);
+ EmitOmittedArgument(ctorParams[i].ParameterType);
IL.Emit(OpCodes.Newobj, ctorToCall);
SetStackUnknown();
@@ -1983,6 +1983,27 @@ protected void EmitDefaultForType(Type type)
else { IL.Emit(OpCodes.Ldnull); }
}
+ ///
+ /// Emits the value for a trailing parameter slot that the call site omits. JS semantics: an
+ /// omitted argument is undefined, so an object slot — which every optional or
+ /// value-type-defaulted parameter uses after widening (#705/#739) — is padded with the
+ /// $Undefined sentinel. That is observable through typeof/=== undefined for a
+ /// plain optional (#739) and fires the default-parameter prologue for a defaulted one
+ /// (#705/#723/#737). A non-object slot (a required value-type slot, or a not-widened
+ /// reference-typed default such as string) takes its CLR default — for a defaulted
+ /// reference slot the prologue still fires on the resulting null. Mirrors the existing
+ /// $TSFunction.Invoke value-call padding and .
+ /// Public (unlike the sibling ) so the ILCompiler can pad an
+ /// implicit base-constructor call directly; also satisfies .
+ ///
+ public void EmitOmittedArgument(Type slotType)
+ {
+ if (slotType == Types.Object)
+ EmitUndefinedConstant();
+ else
+ EmitDefaultForType(slotType);
+ }
+
///
/// Emits conversion from the current stack value to the target parameter type.
/// Handles boxing for object, unboxing for value types, union types, and pass-through for matching types.
@@ -2119,7 +2140,7 @@ protected void EmitSuperConstructorCall(ConstructorBuilder parentCtor, List namespaceParts, string clas
}
}
- // Pad missing optional arguments
+ // Pad omitted trailing arguments (object slot → `undefined` sentinel). (#739/#705)
for (int i = n.Arguments.Count; i < expectedParamCount; i++)
{
- EmitDefaultForType(ctorParams[i].ParameterType);
+ EmitOmittedArgument(ctorParams[i].ParameterType);
}
IL.Emit(OpCodes.Newobj, targetCtor);
@@ -1512,7 +1512,7 @@ private void EmitClassExprConstruction(ConstructorBuilder classExprCtor, Expr.Ne
}
for (int i = n.Arguments.Count; i < expectedParamCount; i++)
- EmitDefaultForType(ctorParams[i].ParameterType);
+ EmitOmittedArgument(ctorParams[i].ParameterType);
IL.Emit(OpCodes.Newobj, classExprCtor);
SetStackUnknown();
diff --git a/Compilation/GeneratorMoveNextEmitter.cs b/Compilation/GeneratorMoveNextEmitter.cs
index d0cb800e..e788145f 100644
--- a/Compilation/GeneratorMoveNextEmitter.cs
+++ b/Compilation/GeneratorMoveNextEmitter.cs
@@ -68,7 +68,10 @@ public GeneratorMoveNextEmitter(GeneratorStateMachineBuilder builder, GeneratorS
///
/// Emits the complete MoveNext method body.
///
- public void EmitMoveNext(List? body, CompilationContext ctx)
+ /// The generator's declared parameters. When supplied, a default-parameter
+ /// prologue runs on initial entry so an omitted or explicit-undefined argument fires its
+ /// default (#737). Null skips it (callers with no params).
+ public void EmitMoveNext(List? body, CompilationContext ctx, List? parameters = null)
{
if (body == null) return;
@@ -101,6 +104,15 @@ public void EmitMoveNext(List? body, CompilationContext ctx)
// Emit state dispatch switch
EmitStateSwitch();
+ // Apply parameter defaults on initial entry. The state switch jumps every resume state
+ // (>= 0) to its yield label and the completed state (-2) short-circuits above, so only the
+ // initial entry (state -1) falls through to here — the defaults run exactly once, before the
+ // body, with no extra guard field needed. (#737)
+ if (parameters != null)
+ {
+ EmitDefaultParameters(parameters);
+ }
+
// Emit the function body (will emit yield points inline)
foreach (var stmt in body)
{
@@ -152,6 +164,57 @@ public void EmitMoveNext(List? body, CompilationContext ctx)
_il.Emit(OpCodes.Ret);
}
+ ///
+ /// Applies parameter defaults at generator entry. A defaulted parameter whose argument was
+ /// omitted or explicitly undefined arrives in its hoisted state-machine field as null /
+ /// the $Undefined sentinel; this replaces it with the evaluated default expression — the
+ /// generator analogue of and the async emitter's
+ /// own version, both of which a generator's state machine previously lacked (defaults never
+ /// fired in compiled mode). Defaults are evaluated in declaration order, so a later default may
+ /// reference an earlier (already-defaulted) parameter via its field. (#737)
+ ///
+ private void EmitDefaultParameters(List parameters)
+ {
+ if (!parameters.Any(p => p.DefaultValue != null))
+ return;
+
+ foreach (var param in parameters)
+ {
+ if (param.DefaultValue == null) continue;
+
+ var field = _builder.GetVariableField(param.Name.Lexeme);
+ if (field == null) continue; // Parameter not hoisted (unused in body) — default is moot.
+
+ var applyDefault = _il.DefineLabel();
+ var checkUndefined = _il.DefineLabel();
+ var skipDefault = _il.DefineLabel();
+
+ // if (field == null) apply; else if (field is $Undefined) apply; else keep.
+ _il.Emit(OpCodes.Ldarg_0);
+ _il.Emit(OpCodes.Ldfld, field);
+ _il.Emit(OpCodes.Dup);
+ _il.Emit(OpCodes.Brtrue, checkUndefined);
+ _il.Emit(OpCodes.Pop); // pop the null
+ _il.Emit(OpCodes.Br, applyDefault);
+
+ _il.MarkLabel(checkUndefined);
+ _il.Emit(OpCodes.Isinst, _ctx!.Runtime!.UndefinedType);
+ _il.Emit(OpCodes.Brtrue, applyDefault);
+ _il.Emit(OpCodes.Br, skipDefault);
+
+ _il.MarkLabel(applyDefault);
+ EmitExpression(param.DefaultValue);
+ EnsureBoxed();
+ var temp = _il.DeclareLocal(_types.Object);
+ _il.Emit(OpCodes.Stloc, temp);
+ _il.Emit(OpCodes.Ldarg_0);
+ _il.Emit(OpCodes.Ldloc, temp);
+ _il.Emit(OpCodes.Stfld, field);
+
+ _il.MarkLabel(skipDefault);
+ }
+ }
+
private void EmitStateSwitch()
{
if (_analysis.YieldPointCount == 0) return;
diff --git a/Compilation/ILCompiler.ArrowFunctions.cs b/Compilation/ILCompiler.ArrowFunctions.cs
index bb5b6742..da117b30 100644
--- a/Compilation/ILCompiler.ArrowFunctions.cs
+++ b/Compilation/ILCompiler.ArrowFunctions.cs
@@ -241,6 +241,9 @@ private void FinalizeArrowFunctionCollection()
methodBuilder.DefineParameter(1, ParameterAttributes.None, "__this");
for (int i = 0; i < arrow.Parameters.Count; i++)
methodBuilder.DefineParameter(i + 2, ParameterAttributes.None, arrow.Parameters[i].Name.Lexeme);
+ // Back the name-based __this detection with an attribute that survives a --ref-asm
+ // parameter-name strip, so a value-call doesn't shift its arguments. (#738)
+ MarkExpectsThis(methodBuilder);
}
else
{
@@ -395,6 +398,8 @@ private void FinalizeArrowFunctionCollection()
invokeMethod.DefineParameter(1, ParameterAttributes.None, "__this");
for (int i = 0; i < arrow.Parameters.Count; i++)
invokeMethod.DefineParameter(i + 2, ParameterAttributes.None, arrow.Parameters[i].Name.Lexeme);
+ // Survive a --ref-asm parameter-name strip (see the sibling site above). (#738)
+ MarkExpectsThis(invokeMethod);
}
else
{
diff --git a/Compilation/ILCompiler.AsyncGenerators.cs b/Compilation/ILCompiler.AsyncGenerators.cs
index 092f8ae6..20246683 100644
--- a/Compilation/ILCompiler.AsyncGenerators.cs
+++ b/Compilation/ILCompiler.AsyncGenerators.cs
@@ -171,7 +171,7 @@ private void EmitAsyncGeneratorMoveNextAsyncBody(AsyncGeneratorStateMachineBuild
// Use the new emitter for full async generator body emission
var emitter = new AsyncGeneratorMoveNextEmitter(smBuilder, analysis, _types);
- emitter.EmitMoveNextAsync(funcStmt.Body, ctx);
+ emitter.EmitMoveNextAsync(funcStmt.Body, ctx, funcStmt.Parameters);
}
///
@@ -246,7 +246,7 @@ private void EmitAsyncGeneratorMethodBody(MethodBuilder methodBuilder, Stmt.Func
// Emit MoveNextAsync body
var moveNextEmitter = new AsyncGeneratorMoveNextEmitter(smBuilder, analysis, _types);
- moveNextEmitter.EmitMoveNextAsync(method.Body, ctx);
+ moveNextEmitter.EmitMoveNextAsync(method.Body, ctx, method.Parameters);
// Finalize the state machine type
smBuilder.CreateType();
diff --git a/Compilation/ILCompiler.Classes.ClassExpressions.cs b/Compilation/ILCompiler.Classes.ClassExpressions.cs
index 23f6988e..ab7dc4bb 100644
--- a/Compilation/ILCompiler.Classes.ClassExpressions.cs
+++ b/Compilation/ILCompiler.Classes.ClassExpressions.cs
@@ -653,7 +653,7 @@ private void EmitClassExpressionConstructor(Expr.ClassExpr classExpr, TypeBuilde
if (baseCtor != null)
{
foreach (var p in baseCtor.GetParameters())
- emitter.EmitDefaultForType(p.ParameterType);
+ emitter.EmitOmittedArgument(p.ParameterType);
il.Emit(OpCodes.Call, baseCtor);
}
else
diff --git a/Compilation/ILCompiler.Functions.cs b/Compilation/ILCompiler.Functions.cs
index 7b3b4413..b4bcdb4d 100644
--- a/Compilation/ILCompiler.Functions.cs
+++ b/Compilation/ILCompiler.Functions.cs
@@ -1461,6 +1461,20 @@ internal void MarkPadsUndefined(MethodBuilder method)
new System.Reflection.Emit.CustomAttributeBuilder(_runtime.PadUndefinedAttrCtor, []));
}
+ ///
+ /// Marks a method whose first emitted parameter is the synthetic __this receiver slot
+ /// (a user function expression or this-bearing arrow) with the $ExpectsThis
+ /// attribute, so $TSFunction can detect that slot via IsDefined instead of the
+ /// parameter name — which a --ref-asm rewrite strips, otherwise shifting value-call
+ /// arguments by one. No-op when the runtime attribute is unavailable. (#738)
+ ///
+ internal void MarkExpectsThis(MethodBuilder method)
+ {
+ if (_runtime?.ExpectsThisAttrCtor != null)
+ method.SetCustomAttribute(
+ new System.Reflection.Emit.CustomAttributeBuilder(_runtime.ExpectsThisAttrCtor, []));
+ }
+
///
/// Gets the index of the first parameter with a default value.
/// Returns -1 if no default parameters exist.
diff --git a/Compilation/ILCompiler.Generators.cs b/Compilation/ILCompiler.Generators.cs
index a858f6ce..94eaa2e5 100644
--- a/Compilation/ILCompiler.Generators.cs
+++ b/Compilation/ILCompiler.Generators.cs
@@ -323,7 +323,7 @@ private void EmitGeneratorMoveNextBody(GeneratorStateMachineBuilder smBuilder, S
// Use the new emitter for full generator body emission
var emitter = new GeneratorMoveNextEmitter(smBuilder, analysis, _types);
- emitter.EmitMoveNext(funcStmt.Body, ctx);
+ emitter.EmitMoveNext(funcStmt.Body, ctx, funcStmt.Parameters);
ILLabelValidator.Validate(smBuilder.MoveNextMethod.GetILGenerator(),
$"generator MoveNext {funcStmt.Name.Lexeme}");
@@ -402,7 +402,7 @@ private void EmitGeneratorMethodBody(MethodBuilder methodBuilder, Stmt.Function
// Emit MoveNext body
var moveNextEmitter = new GeneratorMoveNextEmitter(smBuilder, analysis, _types);
- moveNextEmitter.EmitMoveNext(method.Body, ctx);
+ moveNextEmitter.EmitMoveNext(method.Body, ctx, method.Parameters);
ILLabelValidator.Validate(il,
$"generator method MoveNext {methodBuilder.DeclaringType?.Name}::{method.Name.Lexeme}");
diff --git a/Compilation/ILEmitter.Calls.Constructors.cs b/Compilation/ILEmitter.Calls.Constructors.cs
index 63e92c84..8b47ed95 100644
--- a/Compilation/ILEmitter.Calls.Constructors.cs
+++ b/Compilation/ILEmitter.Calls.Constructors.cs
@@ -149,7 +149,7 @@ private void EmitTypedClassConstruction(TypeBuilder typeBuilder, ConstructorBuil
}
for (int i = n.Arguments.Count; i < expectedParamCount; i++)
- EmitDefaultForType(ctorParams[i].ParameterType);
+ EmitOmittedArgument(ctorParams[i].ParameterType);
IL.Emit(OpCodes.Newobj, targetCtor);
SetStackUnknown();
@@ -230,12 +230,13 @@ private void EmitInferredGenericClassConstruction(
}
else if (pt.IsGenericParameter)
{
- // Under-applied generic-typed parameter: default for the closed type.
- EmitDefaultForType(inferred[pt.GenericParameterPosition]!);
+ // Under-applied generic-typed parameter: pad with undefined for an object closure,
+ // else the closed type's CLR default. (#739)
+ EmitOmittedArgument(inferred[pt.GenericParameterPosition]!);
}
else
{
- EmitDefaultForType(pt);
+ EmitOmittedArgument(pt);
}
}
@@ -294,7 +295,7 @@ private void EmitClassExprCtorCall(ConstructorInfo ctor, ParameterInfo[] ctorPar
}
for (int i = n.Arguments.Count; i < expectedParamCount; i++)
- EmitDefaultForType(ctorParams[i].ParameterType);
+ EmitOmittedArgument(ctorParams[i].ParameterType);
IL.Emit(OpCodes.Newobj, ctor);
SetStackUnknown();
diff --git a/Compilation/ILEmitter.Calls.MethodDispatch.cs b/Compilation/ILEmitter.Calls.MethodDispatch.cs
index 3d1b24c1..b349bcd5 100644
--- a/Compilation/ILEmitter.Calls.MethodDispatch.cs
+++ b/Compilation/ILEmitter.Calls.MethodDispatch.cs
@@ -312,10 +312,12 @@ private bool TryEmitDirectMethodCall(Expr receiver, TypeSystem.TypeInfo? receive
EmitConversionForParameter(arg, targetParams[i].ParameterType);
}
- // Pad missing required-position arguments with defaults.
+ // Pad omitted trailing arguments. An optional/defaulted param uses an object slot, so it is
+ // padded with the `undefined` sentinel — observable via typeof for a plain optional and
+ // firing the entry prologue for a defaulted one. (#739/#705)
for (int i = regularArgsToEmit; i < regularParamCount; i++)
{
- EmitDefaultForType(targetParams[i].ParameterType);
+ EmitOmittedArgument(targetParams[i].ParameterType);
}
if (hasRestParam)
@@ -416,10 +418,10 @@ protected override bool TryEmitSuperMethodCall(string methodName, List arg
}
}
- // Pad missing optional arguments
+ // Pad omitted trailing arguments (object slot → `undefined` sentinel). (#739/#705)
for (int i = arguments.Count; i < methodParams.Length; i++)
{
- EmitDefaultForType(methodParams[i].ParameterType);
+ EmitOmittedArgument(methodParams[i].ParameterType);
}
// Use Call (NOT Callvirt) to bypass virtual dispatch
diff --git a/Compilation/ILEmitter.Calls.cs b/Compilation/ILEmitter.Calls.cs
index 0b1b74de..023777a8 100644
--- a/Compilation/ILEmitter.Calls.cs
+++ b/Compilation/ILEmitter.Calls.cs
@@ -196,7 +196,7 @@ promisesGet.Object is Expr.Variable promisesModuleVar &&
EmitBoxIfNeeded(c.Arguments[i]);
}
for (int i = c.Arguments.Count; i < staticMethodParams.Length; i++)
- EmitDefaultForType(staticMethodParams[i].ParameterType);
+ EmitOmittedArgument(staticMethodParams[i].ParameterType);
IL.Emit(OpCodes.Call, callableMethod);
SetStackUnknown();
return;
diff --git a/Compilation/ParameterTypeResolver.cs b/Compilation/ParameterTypeResolver.cs
index 87e98f0b..e5d0e3ae 100644
--- a/Compilation/ParameterTypeResolver.cs
+++ b/Compilation/ParameterTypeResolver.cs
@@ -311,15 +311,21 @@ public static Type ResolveReturnType(
/// Resolves parameter types for a class method.
///
///
- /// Deliberately does NOT widen value-type-defaulted params to object (unlike free
- /// functions and constructors, which do). Instance methods are virtual: changing a
- /// defaulted param's slot from double/bool to object would change the CLR
- /// signature and break override matching against a base method that declares the same param
- /// without a default (the derived override silently lands in a new vtable slot, so a
- /// base-typed call dispatches to the base method). Reference-type defaults still fire via the
- /// entry prologue without any slot change (a reference slot already holds null). Firing
- /// value-type method defaults override-safely needs hierarchy-consistent widening or bridge
- /// methods — tracked as #737.
+ /// A parameter with a value-type default (x: number = N) needs an object
+ /// slot so the entry prologue can observe the $Undefined sentinel and fire the default
+ /// (a double/bool slot cannot — it coerces the sentinel to NaN/false).
+ ///
+ /// - Static methods are non-virtual, so widening such a param is always safe — done
+ /// directly (#705/#723).
+ /// - Instance methods are virtual: widening one override's slot would change its CLR
+ /// signature and silently break override matching (the derived method lands in a new vtable
+ /// slot). So the decision is made hierarchy-consistently — a position is widened across
+ /// the WHOLE override group when any member makes it an optional value-type parameter, keeping
+ /// every override's signature identical (see ).
+ /// (#737)
+ ///
+ /// Reference-type defaults need no slot change (a reference slot already holds null, which the
+ /// prologue treats as undefined), so they are left alone in both cases.
///
public static Type[] ResolveMethodParameters(
string className,
@@ -336,6 +342,7 @@ public static Type[] ResolveMethodParameters(
return parameters.Select(p => ResolveParameterType(p, typeMapper)).ToArray();
TSTypeInfo.Function? funcType = null;
+ bool isStatic = false;
// Check instance methods
if (classType.Methods.TryGetValue(methodName, out var methodType))
@@ -346,48 +353,202 @@ public static Type[] ResolveMethodParameters(
else if (classType.StaticMethods.TryGetValue(methodName, out var staticMethodType))
{
funcType = ExtractFunctionType(staticMethodType);
+ isStatic = true;
}
+ Type[] resolved;
if (funcType == null || funcType.ParamTypes.Count != parameters.Count)
- return parameters.Select(p => WidenIfUndefinedReachableParam(ResolveParameterType(p, typeMapper), p, typeMap)).ToArray();
+ {
+ resolved = parameters.Select(p => WidenIfUndefinedReachableParam(ResolveParameterType(p, typeMapper), p, typeMap)).ToArray();
+ }
+ else
+ {
+ // Map each parameter type, handling optional parameters and BigInteger
+ resolved = funcType.ParamTypes
+ .Select((pt, i) =>
+ {
+ Type mappedType;
+ try
+ {
+ mappedType = typeMapper.MapTypeInfoStrict(pt);
+ }
+ catch
+ {
+ // Union types may throw during early method definition phase
+ // when TypeBuilder isn't finalized yet. Fall back to object.
+ return typeof(object);
+ }
- // Map each parameter type, handling optional parameters and BigInteger
- return funcType.ParamTypes
- .Select((pt, i) =>
+ // BigInteger parameters need to stay as object because BigInt operations
+ // in the emitter expect boxed values
+ if (mappedType == typeof(System.Numerics.BigInteger))
+ {
+ return typeof(object);
+ }
+
+ // If parameter is optional (no explicit default), use object so undefined
+ // can be passed as the missing-argument sentinel (JS spec)
+ if (i < parameters.Count &&
+ parameters[i].DefaultValue == null &&
+ parameters[i].IsOptional)
+ {
+ return typeof(object);
+ }
+
+ return i < parameters.Count
+ ? CoerceParamSlotType(WidenIfUndefinedReachableParam(mappedType, parameters[i], typeMap), pt, typeMapper)
+ : CoerceParamSlotType(mappedType, pt, typeMapper);
+ })
+ .ToArray();
+ }
+
+ WidenValueTypeDefaultedMethodParams(resolved, parameters, className, methodName, isStatic, typeMapper, typeMap);
+ return resolved;
+ }
+
+ ///
+ /// Widens a method parameter slot to object wherever a value-type default needs to be able
+ /// to hold the $Undefined sentinel — non-virtually for static methods, and
+ /// hierarchy-consistently for (virtual) instance methods. No-op when no value-type defaults are
+ /// involved, so the common method keeps its fast unboxed slots. (#705/#723/#737)
+ ///
+ private static void WidenValueTypeDefaultedMethodParams(
+ Type[] resolved,
+ List parameters,
+ string className,
+ string methodName,
+ bool isStatic,
+ TypeMapper typeMapper,
+ TypeMap typeMap)
+ {
+ if (isStatic)
+ {
+ // Non-virtual: widening a value-type-defaulted param can never break override matching.
+ for (int i = 0; i < parameters.Count && i < resolved.Length; i++)
+ if (parameters[i].DefaultValue != null && !parameters[i].IsRest && resolved[i].IsValueType)
+ resolved[i] = typeof(object);
+ return;
+ }
+
+ // Virtual: widen each value-type slot the override group needs as object, so every override
+ // keeps an identical CLR signature (preserving vtable dispatch).
+ var mask = ComputeInstanceMethodWidenMask(className, methodName, resolved.Length, typeMapper, typeMap);
+ for (int i = 0; i < resolved.Length; i++)
+ if (mask[i] && resolved[i].IsValueType)
+ resolved[i] = typeof(object);
+ }
+
+ ///
+ /// Computes, for an instance (virtual) method, which parameter positions must use an
+ /// object slot so a value-type default can fire via the entry prologue. The decision is
+ /// hierarchy-consistent: a position is flagged when ANY member of the method's override
+ /// group (its root declaration plus every class that overrides it) makes that position an
+ /// optional value-type parameter. Flagging the whole group keeps every override's CLR signature
+ /// identical, so a derived override that adds a default still lands in the base's vtable slot
+ /// and a base-typed call dispatches to it correctly. (#737)
+ ///
+ private static bool[] ComputeInstanceMethodWidenMask(
+ string className, string methodName, int paramCount, TypeMapper typeMapper, TypeMap typeMap)
+ {
+ var mask = new bool[paramCount];
+ if (paramCount == 0)
+ return mask;
+
+ var root = FindMethodRootDeclarer(className, methodName, typeMap);
+ if (root == null)
+ return mask;
+
+ // Union the optional-value-type positions over every class whose method shares this root
+ // declaration (i.e. the override group). A class only declares the method if it is a key in
+ // its OWN Methods map (lookup walks the chain, but Methods holds own-declared entries only).
+ foreach (var (otherName, otherClass) in typeMap.ClassTypes)
+ {
+ if (!otherClass.Methods.TryGetValue(methodName, out var otherMethodType))
+ continue;
+ if (FindMethodRootDeclarer(otherName, methodName, typeMap) != root)
+ continue;
+ if (ExtractFunctionType(otherMethodType) is not { } f)
+ continue;
+
+ int minArity = f.MinArity;
+ int count = Math.Min(paramCount, f.ParamTypes.Count);
+ for (int i = 0; i < count; i++)
{
- Type mappedType;
- try
- {
- mappedType = typeMapper.MapTypeInfoStrict(pt);
- }
- catch
- {
- // Union types may throw during early method definition phase
- // when TypeBuilder isn't finalized yet. Fall back to object.
- return typeof(object);
- }
+ // Positions below the member's arity are required there — a required param is always
+ // supplied, so it never needs an undefined-capable slot on this member's behalf.
+ if (i < minArity)
+ continue;
+ if (IsValueTypeParamSlot(typeMapper, f.ParamTypes[i]))
+ mask[i] = true;
+ }
+ }
- // BigInteger parameters need to stay as object because BigInt operations
- // in the emitter expect boxed values
- if (mappedType == typeof(System.Numerics.BigInteger))
- {
- return typeof(object);
- }
+ return mask;
+ }
- // If parameter is optional (no explicit default), use object so undefined
- // can be passed as the missing-argument sentinel (JS spec)
- if (i < parameters.Count &&
- parameters[i].DefaultValue == null &&
- parameters[i].IsOptional)
- {
- return typeof(object);
- }
+ ///
+ /// Returns the name of the topmost class in 's ancestry (inclusive)
+ /// that declares — the class that owns the method's vtable slot.
+ /// Two classes share an override group iff they have the same root declarer. Returns null if the
+ /// method is not declared anywhere in the chain.
+ ///
+ private static string? FindMethodRootDeclarer(string className, string methodName, TypeMap typeMap)
+ {
+ var cls = typeMap.GetClassType(className);
+ if (cls == null)
+ return null;
+
+ string? root = cls.Methods.ContainsKey(methodName) ? className : null;
+ var visited = new HashSet(StringComparer.Ordinal) { className };
+ string? superName = GetSuperclassName(cls);
+ while (superName != null && visited.Add(superName))
+ {
+ var super = typeMap.GetClassType(superName);
+ if (super == null)
+ break;
+ if (super.Methods.ContainsKey(methodName))
+ root = superName;
+ superName = GetSuperclassName(super);
+ }
+ return root;
+ }
- return i < parameters.Count
- ? CoerceParamSlotType(WidenIfUndefinedReachableParam(mappedType, parameters[i], typeMap), pt, typeMapper)
- : CoerceParamSlotType(mappedType, pt, typeMapper);
- })
- .ToArray();
+ ///
+ /// Extracts the (simple) name of a class's direct superclass, unwrapping an instantiated generic
+ /// base (extends Box<number>) to its generic definition's name. Returns null when the
+ /// class has no superclass.
+ ///
+ private static string? GetSuperclassName(TSTypeInfo.Class cls) => cls.Superclass switch
+ {
+ TSTypeInfo.Class c => c.Name,
+ TSTypeInfo.MutableClass mc => mc.Name,
+ TSTypeInfo.GenericClass gc => gc.Name,
+ TSTypeInfo.InstantiatedGeneric ig => ig.GenericDefinition switch
+ {
+ TSTypeInfo.Class c => c.Name,
+ TSTypeInfo.MutableClass mc => mc.Name,
+ TSTypeInfo.GenericClass gc => gc.Name,
+ _ => null
+ },
+ _ => null
+ };
+
+ ///
+ /// True when a parameter's TS type maps to an unboxed CLR value-type slot (e.g. number →
+ /// double, boolean → bool) — the slots that cannot hold the $Undefined
+ /// sentinel and so must be widened to object when defaulted. Maps defensively (a mapping
+ /// can throw during early definition) and treats failures as non-value-type (do not widen).
+ ///
+ private static bool IsValueTypeParamSlot(TypeMapper typeMapper, TSTypeInfo paramType)
+ {
+ try
+ {
+ return typeMapper.MapTypeInfoStrict(paramType).IsValueType;
+ }
+ catch
+ {
+ return false;
+ }
}
///
diff --git a/Compilation/RuntimeEmitter.TSFunction.cs b/Compilation/RuntimeEmitter.TSFunction.cs
index 26f4e656..994b2288 100644
--- a/Compilation/RuntimeEmitter.TSFunction.cs
+++ b/Compilation/RuntimeEmitter.TSFunction.cs
@@ -83,6 +83,35 @@ private void EmitPadUndefinedAttribute(ModuleBuilder moduleBuilder, EmittedRunti
typeBuilder.CreateType();
}
+ ///
+ /// Emits a minimal marker attribute $ExpectsThis (empty
+ /// subclass) applied to a user function-expression / this-bearing arrow method, whose
+ /// first emitted parameter is the synthetic __this receiver slot. $TSFunction
+ /// normally detects that slot by parameter name (params[0].Name == "__this"), but a
+ /// reference-assembly rewrite (--compile --ref-asm) strips parameter names, so the name
+ /// check fails and the receiver slot is miscounted as a real argument — shifting a value-call's
+ /// arguments by one. Custom attributes survive the rewrite, so reading this marker back via
+ /// restores correct detection.
+ /// Lives in the output assembly so the compiled DLL stays standalone. (#738)
+ ///
+ private void EmitExpectsThisAttribute(ModuleBuilder moduleBuilder, EmittedRuntime runtime)
+ {
+ var typeBuilder = moduleBuilder.DefineType(
+ "$ExpectsThis",
+ TypeAttributes.Public | TypeAttributes.Sealed | TypeAttributes.BeforeFieldInit,
+ typeof(System.Attribute));
+ var ctor = typeBuilder.DefineConstructor(
+ MethodAttributes.Public, CallingConventions.Standard, Type.EmptyTypes);
+ var ctorIl = ctor.GetILGenerator();
+ ctorIl.Emit(OpCodes.Ldarg_0);
+ ctorIl.Emit(OpCodes.Call, typeof(System.Attribute).GetConstructor(
+ BindingFlags.Instance | BindingFlags.NonPublic, null, Type.EmptyTypes, null)!);
+ ctorIl.Emit(OpCodes.Ret);
+ runtime.ExpectsThisAttrCtor = ctor;
+ runtime.ExpectsThisAttrType = typeBuilder;
+ typeBuilder.CreateType();
+ }
+
private void EmitTSFunctionClass(ModuleBuilder moduleBuilder, EmittedRuntime runtime)
{
// Define class: public sealed class $TSFunction
@@ -268,7 +297,7 @@ private void EmitTSFunctionClass(ModuleBuilder moduleBuilder, EmittedRuntime run
ctorIL.Emit(OpCodes.Ldnull);
ctorIL.Emit(OpCodes.Stfld, cachedNameField);
// this._expectsThis = (method.GetParameters().Length > 0 && params[0].Name == "__this")
- EmitComputeExpectsThis(ctorIL, expectsThisField, methodArgIndex: 2);
+ EmitComputeExpectsThis(ctorIL, expectsThisField, runtime, methodArgIndex: 2);
// this._capturesArguments = method.IsDefined($CapturesArguments)
EmitComputeCapturesArguments(ctorIL, capturesArgumentsField, runtime, methodArgIndex: 2);
// this._padUndefinedMask = $PadUndefined ? (object-param bits) : 0
@@ -310,7 +339,7 @@ private void EmitTSFunctionClass(ModuleBuilder moduleBuilder, EmittedRuntime run
ctorCacheIL.Emit(OpCodes.Ldarg, 4); // 4th argument (0-indexed: 0=this, 1=target, 2=method, 3=name, 4=length)
ctorCacheIL.Emit(OpCodes.Stfld, cachedLengthField);
// this._expectsThis = (method.GetParameters().Length > 0 && params[0].Name == "__this")
- EmitComputeExpectsThis(ctorCacheIL, expectsThisField, methodArgIndex: 2);
+ EmitComputeExpectsThis(ctorCacheIL, expectsThisField, runtime, methodArgIndex: 2);
EmitComputeCapturesArguments(ctorCacheIL, capturesArgumentsField, runtime, methodArgIndex: 2);
EmitComputePadUndefinedMask(ctorCacheIL, padUndefinedMaskField, runtime, methodArgIndex: 2);
EmitComputeAdjustArgsCache(ctorCacheIL, paramCountField, hasListRestField, hasArrayRestField, methodArgIndex: 2);
@@ -1264,46 +1293,55 @@ private void EmitTypeEqAndBranchTrue(ILGenerator il, LocalBuilder paramTypeLocal
}
///
- /// Emits IL inside a constructor to compute and store the cached
- /// _expectsThis bool field. Logic:
- /// this._expectsThis = (method.GetParameters().Length > 0 && params[0].Name == "__this").
- /// Caller must provide the field and the constructor's argument index of
- /// the MethodInfo param (typically 2).
+ /// Emits IL inside a constructor to compute and store the cached _expectsThis bool field:
+ /// this._expectsThis = (params.Length > 0 && params[0].Name == "__this")
+ /// || method.IsDefined(typeof($ExpectsThis), false). The name check is the primary path; the
+ /// $ExpectsThis attribute backstops it because a --ref-asm rewrite strips parameter
+ /// names (so the name check fails there, otherwise shifting a value-call's arguments). Caller
+ /// provides the field and the constructor argument index of the MethodInfo param. (#738)
///
- private void EmitComputeExpectsThis(ILGenerator il, FieldBuilder expectsThisField, int methodArgIndex)
+ private void EmitComputeExpectsThis(ILGenerator il, FieldBuilder expectsThisField, EmittedRuntime runtime, int methodArgIndex)
{
var paramsLocal = il.DeclareLocal(_types.MakeArrayType(_types.ParameterInfo));
- var falseLabel = il.DefineLabel();
- var doneLabel = il.DefineLabel();
+ var resultLocal = il.DeclareLocal(_types.Boolean);
+ var checkAttr = il.DefineLabel();
+ var store = il.DefineLabel();
// params = method.GetParameters()
il.Emit(OpCodes.Ldarg, methodArgIndex);
il.Emit(OpCodes.Callvirt, _types.MethodInfo.GetMethod("GetParameters")!);
il.Emit(OpCodes.Stloc, paramsLocal);
- // [this] for the eventual stfld
- il.Emit(OpCodes.Ldarg_0);
-
- // if (params.Length == 0) goto falseLabel
+ // nameMatch = params.Length > 0 && params[0].Name == "__this"
il.Emit(OpCodes.Ldloc, paramsLocal);
il.Emit(OpCodes.Ldlen);
il.Emit(OpCodes.Conv_I4);
- il.Emit(OpCodes.Brfalse, falseLabel);
+ il.Emit(OpCodes.Brfalse, checkAttr); // 0 params → fall back to the attribute
- // params[0].Name == "__this" — leaves bool on stack
il.Emit(OpCodes.Ldloc, paramsLocal);
il.Emit(OpCodes.Ldc_I4_0);
il.Emit(OpCodes.Ldelem_Ref);
il.Emit(OpCodes.Callvirt, _types.ParameterInfo.GetProperty("Name")!.GetGetMethod()!);
il.Emit(OpCodes.Ldstr, "__this");
il.Emit(OpCodes.Call, _types.String.GetMethod("op_Equality", [_types.String, _types.String])!);
- il.Emit(OpCodes.Br, doneLabel);
+ il.Emit(OpCodes.Stloc, resultLocal);
+
+ il.Emit(OpCodes.Ldloc, resultLocal);
+ il.Emit(OpCodes.Brtrue, store); // name matched → done
- il.MarkLabel(falseLabel);
+ // Backstop: resultLocal = method.IsDefined(typeof($ExpectsThis), false) — survives the
+ // parameter-name strip the name check above does not.
+ il.MarkLabel(checkAttr);
+ il.Emit(OpCodes.Ldarg, methodArgIndex);
+ il.Emit(OpCodes.Ldtoken, runtime.ExpectsThisAttrType);
+ il.Emit(OpCodes.Call, _types.GetMethod(_types.Type, "GetTypeFromHandle", _types.RuntimeTypeHandle));
il.Emit(OpCodes.Ldc_I4_0);
+ il.Emit(OpCodes.Callvirt, _types.GetMethod(_types.MethodInfo, "IsDefined", _types.Type, _types.Boolean));
+ il.Emit(OpCodes.Stloc, resultLocal);
- il.MarkLabel(doneLabel);
- // Stack: [this, bool] → stfld
+ il.MarkLabel(store);
+ il.Emit(OpCodes.Ldarg_0);
+ il.Emit(OpCodes.Ldloc, resultLocal);
il.Emit(OpCodes.Stfld, expectsThisField);
}
diff --git a/Compilation/RuntimeEmitter.cs b/Compilation/RuntimeEmitter.cs
index b087b711..e5943772 100644
--- a/Compilation/RuntimeEmitter.cs
+++ b/Compilation/RuntimeEmitter.cs
@@ -67,6 +67,11 @@ public EmittedRuntime EmitAll(ModuleBuilder moduleBuilder, RuntimeFeatureSet fea
// can ldtoken the type for the IsDefined read in AdjustArgs caching. (#640)
EmitPadUndefinedAttribute(moduleBuilder, runtime);
+ // Marker attribute for "this method's first parameter is the synthetic `__this` receiver".
+ // Defined+created before EmitTSFunctionClass so the ctor IL can ldtoken the type for the
+ // IsDefined read that backstops the (ref-asm-fragile) parameter-name check. (#738)
+ EmitExpectsThisAttribute(moduleBuilder, runtime);
+
// Emit TSFunction class first (other methods depend on it)
EmitTSFunctionClass(moduleBuilder, runtime);
diff --git a/Compilation/StateMachineEmitHelpers.cs b/Compilation/StateMachineEmitHelpers.cs
index 99d7b1b7..17f21433 100644
--- a/Compilation/StateMachineEmitHelpers.cs
+++ b/Compilation/StateMachineEmitHelpers.cs
@@ -807,7 +807,21 @@ public void EmitNullishCoalescing(Action emitLeft, Action emitRight)
emitLeft();
EnsureBoxed();
_il.Emit(OpCodes.Dup);
- _il.Emit(OpCodes.Brfalse, rightLabel);
+ _il.Emit(OpCodes.Brfalse, rightLabel); // CLR null → use right
+
+ // The `$Undefined` sentinel is a non-null reference, so `brfalse` does not catch it — test
+ // it explicitly so `undefined ?? right` evaluates the right side. Without this, a state
+ // machine (generator/async) treated the sentinel as a present value and returned it, which a
+ // value omitted from an optional parameter now produces (an omitted arg pads `$Undefined`,
+ // not null) — manifesting e.g. as an infinite `while (x.length) yield* this.pop()` when
+ // `pop(error)` does `error ?? stack.pop()`. Mirrors the non-state-machine ILEmitter override.
+ if (_runtime?.UndefinedType != null)
+ {
+ _il.Emit(OpCodes.Dup);
+ _il.Emit(OpCodes.Isinst, _runtime.UndefinedType);
+ _il.Emit(OpCodes.Brtrue, rightLabel); // undefined → use right
+ }
+
_il.Emit(OpCodes.Br, endLabel);
MarkLabel(rightLabel);
diff --git a/SharpTS.Tests/CompilerTests/ClassMethodDefaultParameterTests.cs b/SharpTS.Tests/CompilerTests/ClassMethodDefaultParameterTests.cs
index c8ac3ac6..1b82c0f9 100644
--- a/SharpTS.Tests/CompilerTests/ClassMethodDefaultParameterTests.cs
+++ b/SharpTS.Tests/CompilerTests/ClassMethodDefaultParameterTests.cs
@@ -4,15 +4,21 @@
namespace SharpTS.Tests.CompilerTests;
///
-/// #705: in compiled mode, default parameters on class-declaration members were never applied —
-/// the body emitters skipped the runtime entry prologue entirely, and value-type-defaulted params
-/// kept an unboxed slot that cannot hold the undefined sentinel. This pins the fixes for the
-/// non-virtual members from the issue's repros — constructors, private methods, and free
-/// functions — whose value-type defaults are now widened to an object slot so an omitted or explicit
-/// undefined argument fires the default (omit → default, explicit undefined → default, present
-/// → used). Reference-type defaults on (virtual) instance / static methods also fire now, override-safely
-/// (no slot change). Value-type defaults on virtual instance/static methods remain a tracked follow-up
-/// (widening their slot would break override matching), and are intentionally not asserted here.
+/// #705/#723/#737/#739: in compiled mode, default/optional parameters on class-declaration members
+/// were mishandled — the body emitters skipped the runtime entry prologue, value-type-defaulted
+/// params kept an unboxed slot that cannot hold the undefined sentinel, and direct calls
+/// padded omitted optionals with CLR null instead of undefined. This pins the fixes:
+///
+/// - #705/#723: value-type defaults fire (omit → default, explicit undefined →
+/// default, present → used) for free functions, constructors, private methods, and now instance
+/// and static methods — the latter via value-type slot widening (static: direct; instance:
+/// hierarchy-consistent, so override matching is preserved).
+/// - #737: defaults also fire on generator and async-generator methods (their
+/// state machines now run a default-parameter prologue).
+/// - #739: a direct instance/static method or constructor call pads an omitted trailing
+/// optional (no-default) param with the undefined sentinel, so typeof reads
+/// "undefined" (not "object").
+///
/// All run in both modes to pin interpreter/compiler parity.
///
public class ClassMethodDefaultParameterTests
@@ -240,6 +246,170 @@ public void Override_BothDefault_DispatchesToDerived(ExecutionMode mode)
Assert.Equal("D:D\n", TestHarness.Run(source, mode));
}
+ // ---- Value-type defaults on (virtual) instance & static methods (#723/#737) ----
+
+ [Theory]
+ [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))]
+ public void InstanceMethod_NumericDefault_Omitted(ExecutionMode mode)
+ {
+ var source = """
+ class C { add(a: number, b: number = 10): number { return a + b; } }
+ console.log(new C().add(5));
+ """;
+ Assert.Equal("15\n", TestHarness.Run(source, mode));
+ }
+
+ [Theory]
+ [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))]
+ public void InstanceMethod_NumericDefault_ExplicitUndefined(ExecutionMode mode)
+ {
+ var source = """
+ class C { add(a: number, b: number = 10): number { return a + b; } }
+ console.log(new C().add(5, undefined));
+ """;
+ Assert.Equal("15\n", TestHarness.Run(source, mode));
+ }
+
+ [Theory]
+ [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))]
+ public void StaticMethod_NumericDefault_Omitted(ExecutionMode mode)
+ {
+ var source = """
+ class C { static add(a: number, b: number = 10): number { return a + b; } }
+ console.log(C.add(5));
+ console.log(C.add(5, undefined));
+ """;
+ Assert.Equal("15\n15\n", TestHarness.Run(source, mode));
+ }
+
+ [Theory]
+ [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))]
+ public void InstanceMethod_BooleanDefault_Omitted(ExecutionMode mode)
+ {
+ var source = """
+ class C { flag(on: boolean = true): boolean { return on; } }
+ console.log(new C().flag());
+ console.log(new C().flag(false));
+ """;
+ Assert.Equal("true\nfalse\n", TestHarness.Run(source, mode));
+ }
+
+ [Theory]
+ [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))]
+ public void Override_DerivedAddsDefault_OmittedFiresDerivedDefault(ExecutionMode mode)
+ {
+ // The derived override adds a value-type default the base declares as required; calling it
+ // with the argument omitted must fire the derived default. The hierarchy-consistent widening
+ // makes both `m` slots `object`, so the derived prologue can observe `undefined`. (#737)
+ var source = """
+ class B { m(x: number): number { return x; } }
+ class D extends B { m(x: number = 5): number { return x * 2; } }
+ console.log(new D().m());
+ """;
+ Assert.Equal("10\n", TestHarness.Run(source, mode));
+ }
+
+ // ---- Generator / async-generator method defaults (#737) ----
+
+ [Theory]
+ [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))]
+ public void GeneratorMethod_NumericDefault_Omitted(ExecutionMode mode)
+ {
+ var source = """
+ class C { *gen(a: number, b: number = 10): Generator { yield a; yield b; } }
+ console.log([...new C().gen(1)].join(","));
+ console.log([...new C().gen(1, 2)].join(","));
+ console.log([...new C().gen(1, undefined)].join(","));
+ """;
+ Assert.Equal("1,10\n1,2\n1,10\n", TestHarness.Run(source, mode));
+ }
+
+ [Theory]
+ [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))]
+ public void FreeGenerator_NumericDefault_Omitted(ExecutionMode mode)
+ {
+ var source = """
+ function* gen(a: number, b: number = 7): Generator { yield a; yield b; }
+ console.log([...gen(3)].join(","));
+ """;
+ Assert.Equal("3,7\n", TestHarness.Run(source, mode));
+ }
+
+ [Theory]
+ [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))]
+ public void AsyncGeneratorMethod_NumericDefault_Omitted(ExecutionMode mode)
+ {
+ var source = """
+ class C { async *agen(a: number, b: number = 3): AsyncGenerator { yield a; yield b; } }
+ (async () => {
+ const out: number[] = [];
+ for await (const v of new C().agen(100)) out.push(v);
+ console.log(out.join(","));
+ })();
+ """;
+ Assert.Equal("100,3\n", TestHarness.Run(source, mode));
+ }
+
+ // ---- #739: direct call pads omitted optional with `undefined`, not null ----
+
+ [Theory]
+ [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))]
+ public void InstanceMethod_OmittedOptional_IsUndefined(ExecutionMode mode)
+ {
+ var source = """
+ class C { m(x?: any): string { return typeof x; } }
+ console.log(new C().m());
+ """;
+ Assert.Equal("undefined\n", TestHarness.Run(source, mode));
+ }
+
+ [Theory]
+ [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))]
+ public void StaticMethod_OmittedOptional_IsUndefined(ExecutionMode mode)
+ {
+ var source = """
+ class C { static s(x?: any): string { return typeof x; } }
+ console.log(C.s());
+ """;
+ Assert.Equal("undefined\n", TestHarness.Run(source, mode));
+ }
+
+ [Theory]
+ [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))]
+ public void Constructor_OmittedOptional_IsUndefined(ExecutionMode mode)
+ {
+ var source = """
+ class C { kind: string; constructor(x?: any) { this.kind = typeof x; } }
+ console.log(new C().kind);
+ """;
+ Assert.Equal("undefined\n", TestHarness.Run(source, mode));
+ }
+
+ // ---- Generator `??` over an omitted optional (regression: #739 padding exposed a latent
+ // state-machine nullish-coalescing bug that ignored the `$Undefined` sentinel) ----
+
+ [Theory]
+ [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))]
+ public void GeneratorNullishCoalescing_OmittedOptional_EvaluatesRight(ExecutionMode mode)
+ {
+ // `pop(error?)` does `error ?? this.arr.pop()`. With `error` omitted (→ `undefined`), the
+ // right side MUST run so the stack drains and the driving `while` terminates — otherwise the
+ // generator spins forever (the real-world yaml-parser hang). The compiled state-machine `??`
+ // previously caught only CLR null, not the `$Undefined` sentinel.
+ var source = """
+ class P {
+ arr: number[] = [1, 2, 3];
+ *pop(error?: any): Generator {
+ const token = error ?? this.arr.pop();
+ yield "t" + token;
+ }
+ *drain(): Generator { while (this.arr.length > 0) yield* this.pop(); }
+ }
+ console.log([...new P().drain()].join(","));
+ """;
+ Assert.Equal("t3,t2,t1\n", TestHarness.Run(source, mode));
+ }
+
// ---- IL verification guard ---------------------------------------------
[Fact]
@@ -260,4 +430,26 @@ class D { constructor(public age: number = 99) {} }
var errors = TestHarness.CompileAndVerifyOnly(source);
Assert.Empty(errors);
}
+
+ [Fact]
+ public void InstanceStaticAndGeneratorDefaults_ProduceVerifiableIL()
+ {
+ // Widening value-type defaults on instance (hierarchy-consistent) and static methods,
+ // padding omitted optionals with the undefined sentinel, and the generator/async-generator
+ // default prologues must all emit verifiable IL.
+ var source = """
+ class B { m(x: number): number { return x; } }
+ class D extends B { m(x: number = 5): number { return x * 2; } }
+ class C {
+ add(a: number, b: number = 10): number { return a + b; }
+ static st(a: number, b: number = 2): number { return a + b; }
+ opt(x?: any): string { return typeof x; }
+ *gen(a: number, b: number = 1): Generator { yield a; yield b; }
+ async *agen(a: number, b: number = 1): AsyncGenerator { yield a; yield b; }
+ }
+ console.log(new D().m() + C.st(1) + new C().add(1) + new C().opt() + [...new C().gen(1)].length);
+ """;
+ var errors = TestHarness.CompileAndVerifyOnly(source);
+ Assert.Empty(errors);
+ }
}
diff --git a/SharpTS.Tests/CompilerTests/ReferenceAssemblyTests.cs b/SharpTS.Tests/CompilerTests/ReferenceAssemblyTests.cs
index 610abc8e..79ea1c9a 100644
--- a/SharpTS.Tests/CompilerTests/ReferenceAssemblyTests.cs
+++ b/SharpTS.Tests/CompilerTests/ReferenceAssemblyTests.cs
@@ -12,6 +12,35 @@ namespace SharpTS.Tests.CompilerTests;
///
public class ReferenceAssemblyTests
{
+ ///
+ /// #738: a function expression invoked as a value must not shift its arguments under --ref-asm.
+ /// The reference-assembly rewrite strips parameter names, which used to break the runtime
+ /// params[0].Name == "__this" receiver-slot detection — so a value-call mapped the first
+ /// real argument onto the synthetic __this slot, shifting everything by one (e.g.
+ /// f(4) returned the default 3 instead of 7). The $ExpectsThis marker
+ /// attribute survives the rewrite and restores correct detection.
+ ///
+ [Fact]
+ public void RefAsm_FunctionExpressionValueCall_DoesNotShiftArguments()
+ {
+ var source = """
+ const f = function (x: number, y: number = 3) { return x + y; };
+ console.log(f(4));
+ console.log(f(4, 10));
+ """;
+
+ var (tempDir, dllPath) = TestHarness.CompileWithRefAsm(source);
+ try
+ {
+ var output = TestHarness.ExecuteCompiledDll(dllPath);
+ Assert.Equal("7\n14\n", output.Replace("\r\n", "\n"));
+ }
+ finally
+ {
+ CleanupTempDir(tempDir);
+ }
+ }
+
///
/// Verifies that an async function compiled with --ref-asm references System.Runtime.
///
diff --git a/TypeSystem/TypeMap.cs b/TypeSystem/TypeMap.cs
index d2a15a48..edf982ac 100644
--- a/TypeSystem/TypeMap.cs
+++ b/TypeSystem/TypeMap.cs
@@ -36,6 +36,13 @@ public class TypeMap
///
public TypeInfo.Class? GetClassType(string className) => _classTypes.GetValueOrDefault(className);
+ ///
+ /// All registered class types, keyed by (simple) class name. Used to walk the inheritance graph
+ /// — e.g. to find every override of a method so the compiler can give them a hierarchy-consistent
+ /// CLR signature (override-safe value-type default-parameter widening, #737).
+ ///
+ public IReadOnlyDictionary ClassTypes => _classTypes;
+
///
/// Registers a class expression type by expression reference for IL compiler lookup.
///