From 549f799384b8ca67fec707c253d3eaef5aa11004 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 28 Jan 2026 08:38:36 +0000 Subject: [PATCH 1/8] Initial plan From 3075626f78951af50eabe89f760e7feca0670bf6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 28 Jan 2026 09:10:12 +0000 Subject: [PATCH 2/8] Preserve default values for init-only properties in source generation Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com> --- .../gen/JsonSourceGenerator.Emitter.cs | 39 +++++++++++++++---- .../gen/JsonSourceGenerator.Parser.cs | 1 + .../PropertyInitializerGenerationSpec.cs | 8 ++++ 3 files changed, 41 insertions(+), 7 deletions(-) diff --git a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Emitter.cs b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Emitter.cs index e6ed81f419539b..53bda8f33da644 100644 --- a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Emitter.cs +++ b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Emitter.cs @@ -636,8 +636,11 @@ property.DefaultIgnoreCondition is JsonIgnoreCondition.Always && string setterValue = property switch { { DefaultIgnoreCondition: JsonIgnoreCondition.Always } => "null", + // For init-only properties, use reflection to invoke the setter since we can't + // directly assign after the object is constructed. This preserves default values + // for init-only properties when they're not specified in the JSON. { CanUseSetter: true, IsInitOnlySetter: true } - => $"""static (obj, value) => throw new {InvalidOperationExceptionTypeRef}("{ExceptionMessages.InitOnlyPropertySetterNotSupported}")""", + => $"""static (obj, value) => typeof({declaringTypeFQN}).GetProperty({FormatStringLiteral(property.MemberName)})!.SetValue(obj, value)""", { CanUseSetter: true } when typeGenerationSpec.TypeRef.IsValueType => $"""static (obj, value) => {UnsafeTypeRef}.Unbox<{declaringTypeFQN}>(obj).{propertyName} = value!""", { CanUseSetter: true } @@ -729,8 +732,17 @@ private static void GenerateCtorParamMetadataInitFunc(SourceWriter writer, strin { ImmutableEquatableArray parameters = typeGenerationSpec.CtorParamGenSpecs; ImmutableEquatableArray propertyInitializers = typeGenerationSpec.PropertyInitializerSpecs; - int paramCount = parameters.Count + propertyInitializers.Count(propInit => !propInit.MatchesConstructorParameter); - Debug.Assert(paramCount > 0); + // Only count required property initializers that don't match constructor parameters. + // Non-required init-only properties are set via reflection setters, not the constructor delegate. + int requiredInitializerCount = propertyInitializers.Count(propInit => !propInit.MatchesConstructorParameter && propInit.IsRequired); + int paramCount = parameters.Count + requiredInitializerCount; + + // If there are no parameters to emit, generate an empty array. + if (paramCount == 0) + { + writer.WriteLine($"private static {JsonParameterInfoValuesTypeRef}[] {ctorParamMetadataInitMethodName}() => global::System.Array.Empty<{JsonParameterInfoValuesTypeRef}>();"); + return; + } writer.WriteLine($"private static {JsonParameterInfoValuesTypeRef}[] {ctorParamMetadataInitMethodName}() => new {JsonParameterInfoValuesTypeRef}[]"); writer.WriteLine('{'); @@ -757,9 +769,11 @@ private static void GenerateCtorParamMetadataInitFunc(SourceWriter writer, strin } } + // Only emit property initializers for required properties. + // Non-required init-only properties preserve default values and are set via reflection setters. foreach (PropertyInitializerGenerationSpec spec in propertyInitializers) { - if (spec.MatchesConstructorParameter) + if (spec.MatchesConstructorParameter || !spec.IsRequired) { continue; } @@ -950,14 +964,25 @@ private static string GetParameterizedCtorInvocationFunc(TypeGenerationSpec type sb.Append(')'); - if (propertyInitializers.Count > 0) + // Only include required properties in the object initializer. + // Non-required init-only properties should be set via unsafe accessors + // to preserve their default values when not specified in the JSON. + bool hasRequiredInitializers = false; + foreach (PropertyInitializerGenerationSpec property in propertyInitializers) { - sb.Append("{ "); - foreach (PropertyInitializerGenerationSpec property in propertyInitializers) + if (property.IsRequired) { + if (!hasRequiredInitializers) + { + sb.Append("{ "); + hasRequiredInitializers = true; + } sb.Append($"{property.Name} = {GetParamUnboxing(property.ParameterType, property.ParameterIndex)}, "); } + } + if (hasRequiredInitializers) + { sb.Length -= 2; // delete the last ", " token sb.Append(" }"); } diff --git a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs index 329a369b4a0135..fa150ae04b651a 100644 --- a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs +++ b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs @@ -1639,6 +1639,7 @@ private void ProcessMember( MatchesConstructorParameter = matchingConstructorParameter is not null, ParameterIndex = matchingConstructorParameter?.ParameterIndex ?? paramCount++, IsNullable = property.PropertyType.CanBeNull && !property.IsSetterNonNullableAnnotation, + IsRequired = property.IsRequired && !constructorSetsRequiredMembers, }; (propertyInitializers ??= new()).Add(propertyInitializer); diff --git a/src/libraries/System.Text.Json/gen/Model/PropertyInitializerGenerationSpec.cs b/src/libraries/System.Text.Json/gen/Model/PropertyInitializerGenerationSpec.cs index 189dac1784e4f1..84d78209be6dfc 100644 --- a/src/libraries/System.Text.Json/gen/Model/PropertyInitializerGenerationSpec.cs +++ b/src/libraries/System.Text.Json/gen/Model/PropertyInitializerGenerationSpec.cs @@ -33,5 +33,13 @@ public sealed record PropertyInitializerGenerationSpec public required bool MatchesConstructorParameter { get; init; } public required bool IsNullable { get; init; } + + /// + /// Indicates whether the property is a C# required member. + /// Required members must be set via object initializers, + /// while non-required init-only properties can preserve default values + /// by setting them via unsafe accessors after construction. + /// + public required bool IsRequired { get; init; } } } From 7a97c8512a4c3cc9f25255cbecd9381fb94fa741 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 28 Jan 2026 09:19:03 +0000 Subject: [PATCH 3/8] Add tests and fix reflection-based setter for internal init-only properties Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com> --- .../gen/JsonSourceGenerator.Emitter.cs | 2 +- .../gen/JsonSourceGenerator.Parser.cs | 13 ++++- .../PropertyVisibilityTests.InitOnly.cs | 56 +++++++++++++++++++ .../Serialization/PropertyVisibilityTests.cs | 4 ++ 4 files changed, 71 insertions(+), 4 deletions(-) diff --git a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Emitter.cs b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Emitter.cs index 53bda8f33da644..07ec1f16f33973 100644 --- a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Emitter.cs +++ b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Emitter.cs @@ -640,7 +640,7 @@ property.DefaultIgnoreCondition is JsonIgnoreCondition.Always && // directly assign after the object is constructed. This preserves default values // for init-only properties when they're not specified in the JSON. { CanUseSetter: true, IsInitOnlySetter: true } - => $"""static (obj, value) => typeof({declaringTypeFQN}).GetProperty({FormatStringLiteral(property.MemberName)})!.SetValue(obj, value)""", + => $"""static (obj, value) => typeof({declaringTypeFQN}).GetProperty({FormatStringLiteral(property.MemberName)}, {InstanceMemberBindingFlagsVariableName})!.SetValue(obj, value)""", { CanUseSetter: true } when typeGenerationSpec.TypeRef.IsValueType => $"""static (obj, value) => {UnsafeTypeRef}.Unbox<{declaringTypeFQN}>(obj).{propertyName} = value!""", { CanUseSetter: true } diff --git a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs index fa150ae04b651a..4753d418255ef7 100644 --- a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs +++ b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs @@ -1627,10 +1627,17 @@ private void ProcessMember( } ParameterGenerationSpec? matchingConstructorParameter = GetMatchingConstructorParameter(property, constructorParameters); + bool isRequired = property.IsRequired && !constructorSetsRequiredMembers; - if (property.IsRequired || matchingConstructorParameter is null) + // Only use ParameterizedConstructor strategy for required properties. + // Init-only non-required properties can be set via the setter delegate + // using reflection, which preserves their default values when not specified in JSON. + if (isRequired || matchingConstructorParameter is not null) { - constructionStrategy = ObjectConstructionStrategy.ParameterizedConstructor; + if (isRequired) + { + constructionStrategy = ObjectConstructionStrategy.ParameterizedConstructor; + } var propertyInitializer = new PropertyInitializerGenerationSpec { @@ -1639,7 +1646,7 @@ private void ProcessMember( MatchesConstructorParameter = matchingConstructorParameter is not null, ParameterIndex = matchingConstructorParameter?.ParameterIndex ?? paramCount++, IsNullable = property.PropertyType.CanBeNull && !property.IsSetterNonNullableAnnotation, - IsRequired = property.IsRequired && !constructorSetsRequiredMembers, + IsRequired = isRequired, }; (propertyInitializers ??= new()).Add(propertyInitializer); diff --git a/src/libraries/System.Text.Json/tests/Common/PropertyVisibilityTests.InitOnly.cs b/src/libraries/System.Text.Json/tests/Common/PropertyVisibilityTests.InitOnly.cs index 4cacc627710be2..bff1f5e3725971 100644 --- a/src/libraries/System.Text.Json/tests/Common/PropertyVisibilityTests.InitOnly.cs +++ b/src/libraries/System.Text.Json/tests/Common/PropertyVisibilityTests.InitOnly.cs @@ -192,5 +192,61 @@ public RecordWithIgnoredNestedInitOnlyProperty(int foo) public record InnerRecord(int Foo, string Bar); } + + [Fact] + public virtual async Task InitOnlyProperties_PreserveDefaultValues() + { + // Regression test for https://github.com/dotnet/runtime/issues/58770 + // and https://github.com/dotnet/runtime/issues/87488 + // Default values for init-only properties should be preserved when not specified in JSON. + + // When no properties are specified, default values should be used + ClassWithInitOnlyPropertyDefaults obj = await Serializer.DeserializeWrapper("{}"); + Assert.Equal("DefaultName", obj.Name); + Assert.Equal(42, obj.Number); + + // When only some properties are specified, unspecified ones should retain defaults + obj = await Serializer.DeserializeWrapper(@"{""Name"":""Custom""}"); + Assert.Equal("Custom", obj.Name); + Assert.Equal(42, obj.Number); + + obj = await Serializer.DeserializeWrapper(@"{""Number"":100}"); + Assert.Equal("DefaultName", obj.Name); + Assert.Equal(100, obj.Number); + + // When all properties are specified, they should be used + obj = await Serializer.DeserializeWrapper(@"{""Name"":""Custom"",""Number"":100}"); + Assert.Equal("Custom", obj.Name); + Assert.Equal(100, obj.Number); + } + + [Fact] + public virtual async Task InitOnlyProperties_PreserveDefaultValues_Struct() + { + // Regression test for value types with init-only properties with default values + + // When no properties are specified, default values should be used + StructWithInitOnlyPropertyDefaults obj = await Serializer.DeserializeWrapper("{}"); + Assert.Equal("DefaultName", obj.Name); + Assert.Equal(42, obj.Number); + + // When only some properties are specified, unspecified ones should retain defaults + obj = await Serializer.DeserializeWrapper(@"{""Name"":""Custom""}"); + Assert.Equal("Custom", obj.Name); + Assert.Equal(42, obj.Number); + } + + public class ClassWithInitOnlyPropertyDefaults + { + public string Name { get; init; } = "DefaultName"; + public int Number { get; init; } = 42; + } + + public struct StructWithInitOnlyPropertyDefaults + { + public StructWithInitOnlyPropertyDefaults() { } + public string Name { get; init; } = "DefaultName"; + public int Number { get; init; } = 42; + } } } diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/PropertyVisibilityTests.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/PropertyVisibilityTests.cs index 5c7a880870980d..a87ab2a63b0c42 100644 --- a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/PropertyVisibilityTests.cs +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/PropertyVisibilityTests.cs @@ -198,6 +198,8 @@ public override async Task ClassWithIgnoredAndPrivateMembers_DoesNotIncludeIgnor [JsonSerializable(typeof(ClassWithStructProperty_IgnoreConditionWhenWritingDefault))] [JsonSerializable(typeof(ClassWithMissingObjectProperty))] [JsonSerializable(typeof(ClassWithInitOnlyProperty))] + [JsonSerializable(typeof(ClassWithInitOnlyPropertyDefaults))] + [JsonSerializable(typeof(StructWithInitOnlyPropertyDefaults))] [JsonSerializable(typeof(Class_WithIgnoredInitOnlyProperty))] [JsonSerializable(typeof(Record_WithIgnoredPropertyInCtor))] [JsonSerializable(typeof(Class_WithIgnoredRequiredProperty))] @@ -477,6 +479,8 @@ partial class DefaultContextWithGlobalIgnoreSetting : JsonSerializerContext; [JsonSerializable(typeof(ClassWithStructProperty_IgnoreConditionWhenWritingDefault))] [JsonSerializable(typeof(ClassWithMissingObjectProperty))] [JsonSerializable(typeof(ClassWithInitOnlyProperty))] + [JsonSerializable(typeof(ClassWithInitOnlyPropertyDefaults))] + [JsonSerializable(typeof(StructWithInitOnlyPropertyDefaults))] [JsonSerializable(typeof(Class_WithIgnoredInitOnlyProperty))] [JsonSerializable(typeof(Record_WithIgnoredPropertyInCtor))] [JsonSerializable(typeof(Class_WithIgnoredRequiredProperty))] From fb637c46f6dd8cbd656abab84da6464693b66886 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 28 Jan 2026 09:22:58 +0000 Subject: [PATCH 4/8] Update test expectations for init-only non-required properties Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com> --- .../tests/Common/MetadataTests.cs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/libraries/System.Text.Json/tests/Common/MetadataTests.cs b/src/libraries/System.Text.Json/tests/Common/MetadataTests.cs index d355651eb5bd9c..4f0a4c85ebac68 100644 --- a/src/libraries/System.Text.Json/tests/Common/MetadataTests.cs +++ b/src/libraries/System.Text.Json/tests/Common/MetadataTests.cs @@ -172,8 +172,7 @@ public void TypeWithConstructor_JsonPropertyInfo_AssociatedParameter_MatchesCtor [Theory] [InlineData(typeof(ClassWithRequiredMember))] - [InlineData(typeof(ClassWithInitOnlyProperty))] - public void TypeWithRequiredOrInitMember_SourceGen_HasAssociatedParameterInfo(Type type) + public void TypeWithRequiredMember_SourceGen_HasAssociatedParameterInfo(Type type) { JsonTypeInfo typeInfo = Serializer.GetTypeInfo(type); JsonPropertyInfo propertyInfo = typeInfo.Properties.Single(); @@ -201,6 +200,20 @@ public void TypeWithRequiredOrInitMember_SourceGen_HasAssociatedParameterInfo(Ty } } + [Theory] + [InlineData(typeof(ClassWithInitOnlyProperty))] + public void TypeWithInitOnlyNonRequiredMember_SourceGen_NoAssociatedParameterInfo(Type type) + { + // Init-only non-required properties should NOT have associated parameter info + // because they are set via reflection-based setters, not the constructor delegate. + // This preserves their default values when not specified in the JSON. + JsonTypeInfo typeInfo = Serializer.GetTypeInfo(type); + JsonPropertyInfo propertyInfo = typeInfo.Properties.Single(); + + JsonParameterInfo? jsonParameter = propertyInfo.AssociatedParameter; + Assert.Null(jsonParameter); + } + [Theory] [InlineData(typeof(ClassWithDefaultCtor))] [InlineData(typeof(StructWithDefaultCtor))] From ab78d94b6fddbfd9ae5f1d2ff51d669ac23989d4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 28 Jan 2026 09:29:22 +0000 Subject: [PATCH 5/8] Address code review feedback: fix terminology and add test case Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com> --- .../System.Text.Json/gen/JsonSourceGenerator.Emitter.cs | 2 +- .../gen/Model/PropertyInitializerGenerationSpec.cs | 2 +- src/libraries/System.Text.Json/tests/Common/MetadataTests.cs | 2 +- .../tests/Common/PropertyVisibilityTests.InitOnly.cs | 5 +++++ 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Emitter.cs b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Emitter.cs index 07ec1f16f33973..ef4ceffda7138d 100644 --- a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Emitter.cs +++ b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Emitter.cs @@ -965,7 +965,7 @@ private static string GetParameterizedCtorInvocationFunc(TypeGenerationSpec type sb.Append(')'); // Only include required properties in the object initializer. - // Non-required init-only properties should be set via unsafe accessors + // Non-required init-only properties should be set via reflection-based setters // to preserve their default values when not specified in the JSON. bool hasRequiredInitializers = false; foreach (PropertyInitializerGenerationSpec property in propertyInitializers) diff --git a/src/libraries/System.Text.Json/gen/Model/PropertyInitializerGenerationSpec.cs b/src/libraries/System.Text.Json/gen/Model/PropertyInitializerGenerationSpec.cs index 84d78209be6dfc..bf717ad512ea43 100644 --- a/src/libraries/System.Text.Json/gen/Model/PropertyInitializerGenerationSpec.cs +++ b/src/libraries/System.Text.Json/gen/Model/PropertyInitializerGenerationSpec.cs @@ -38,7 +38,7 @@ public sealed record PropertyInitializerGenerationSpec /// Indicates whether the property is a C# required member. /// Required members must be set via object initializers, /// while non-required init-only properties can preserve default values - /// by setting them via unsafe accessors after construction. + /// by setting them via reflection-based setters after construction. /// public required bool IsRequired { get; init; } } diff --git a/src/libraries/System.Text.Json/tests/Common/MetadataTests.cs b/src/libraries/System.Text.Json/tests/Common/MetadataTests.cs index 4f0a4c85ebac68..a6c1f64538f6ba 100644 --- a/src/libraries/System.Text.Json/tests/Common/MetadataTests.cs +++ b/src/libraries/System.Text.Json/tests/Common/MetadataTests.cs @@ -202,7 +202,7 @@ public void TypeWithRequiredMember_SourceGen_HasAssociatedParameterInfo(Type typ [Theory] [InlineData(typeof(ClassWithInitOnlyProperty))] - public void TypeWithInitOnlyNonRequiredMember_SourceGen_NoAssociatedParameterInfo(Type type) + public void TypeWithInitOnlyNonRequiredMember_NoAssociatedParameterInfo(Type type) { // Init-only non-required properties should NOT have associated parameter info // because they are set via reflection-based setters, not the constructor delegate. diff --git a/src/libraries/System.Text.Json/tests/Common/PropertyVisibilityTests.InitOnly.cs b/src/libraries/System.Text.Json/tests/Common/PropertyVisibilityTests.InitOnly.cs index bff1f5e3725971..9cca77d34d4936 100644 --- a/src/libraries/System.Text.Json/tests/Common/PropertyVisibilityTests.InitOnly.cs +++ b/src/libraries/System.Text.Json/tests/Common/PropertyVisibilityTests.InitOnly.cs @@ -234,6 +234,11 @@ public virtual async Task InitOnlyProperties_PreserveDefaultValues_Struct() obj = await Serializer.DeserializeWrapper(@"{""Name"":""Custom""}"); Assert.Equal("Custom", obj.Name); Assert.Equal(42, obj.Number); + + // When all properties are specified, they should be used + obj = await Serializer.DeserializeWrapper(@"{""Name"":""Custom"",""Number"":100}"); + Assert.Equal("Custom", obj.Name); + Assert.Equal(100, obj.Number); } public class ClassWithInitOnlyPropertyDefaults From 8d99713a6a67e24c100912e55d3b4dce7411b04c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 28 Jan 2026 14:14:26 +0000 Subject: [PATCH 6/8] Use UnsafeAccessor for init-only property setters on .NET 8+ Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com> --- .../gen/JsonSourceGenerator.Emitter.cs | 109 +++++++++++++++--- 1 file changed, 95 insertions(+), 14 deletions(-) diff --git a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Emitter.cs b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Emitter.cs index ef4ceffda7138d..0510498a32fe56 100644 --- a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Emitter.cs +++ b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Emitter.cs @@ -47,6 +47,8 @@ private sealed partial class Emitter private const string JsonExceptionTypeRef = "global::System.Text.Json.JsonException"; private const string TypeTypeRef = "global::System.Type"; private const string UnsafeTypeRef = "global::System.Runtime.CompilerServices.Unsafe"; + private const string UnsafeAccessorTypeRef = "global::System.Runtime.CompilerServices.UnsafeAccessorAttribute"; + private const string UnsafeAccessorKindTypeRef = "global::System.Runtime.CompilerServices.UnsafeAccessorKind"; private const string EqualityComparerTypeRef = "global::System.Collections.Generic.EqualityComparer"; private const string KeyValuePairTypeRef = "global::System.Collections.Generic.KeyValuePair"; private const string JsonEncodedTextTypeRef = "global::System.Text.Json.JsonEncodedText"; @@ -590,6 +592,13 @@ private SourceText GenerateForObject(ContextGenerationSpec contextSpec, TypeGene GenerateCtorParamMetadataInitFunc(writer, ctorParamMetadataInitMethodName, typeMetadata); } + // Generate UnsafeAccessor methods for init-only properties + if (ShouldGenerateMetadata(typeMetadata)) + { + writer.WriteLine(); + GenerateUnsafeAccessorMethods(writer, typeMetadata); + } + writer.Indentation--; writer.WriteLine('}'); @@ -633,22 +642,45 @@ property.DefaultIgnoreCondition is JsonIgnoreCondition.Always && _ => "null" }; - string setterValue = property switch + string setterValue; + if (property.DefaultIgnoreCondition == JsonIgnoreCondition.Always) { - { DefaultIgnoreCondition: JsonIgnoreCondition.Always } => "null", - // For init-only properties, use reflection to invoke the setter since we can't - // directly assign after the object is constructed. This preserves default values + setterValue = "null"; + } + else if (property is { CanUseSetter: true, IsInitOnlySetter: true }) + { + // For init-only properties, use UnsafeAccessor on .NET 8+ for better performance, + // and fall back to reflection on older targets. This preserves default values // for init-only properties when they're not specified in the JSON. - { CanUseSetter: true, IsInitOnlySetter: true } - => $"""static (obj, value) => typeof({declaringTypeFQN}).GetProperty({FormatStringLiteral(property.MemberName)}, {InstanceMemberBindingFlagsVariableName})!.SetValue(obj, value)""", - { CanUseSetter: true } when typeGenerationSpec.TypeRef.IsValueType - => $"""static (obj, value) => {UnsafeTypeRef}.Unbox<{declaringTypeFQN}>(obj).{propertyName} = value!""", - { CanUseSetter: true } - => $"""static (obj, value) => (({declaringTypeFQN})obj).{propertyName} = value!""", - { CanUseSetter: false, HasJsonInclude: true } - => $"""static (obj, value) => throw new {InvalidOperationExceptionTypeRef}("{string.Format(ExceptionMessages.InaccessibleJsonIncludePropertiesNotSupported, typeGenerationSpec.TypeRef.Name, property.MemberName)}")""", - _ => "null", - }; + string unsafeAccessorSetter = $"{GetUnsafeAccessorName(property)}(({declaringTypeFQN})obj, value!)"; + string reflectionSetter = $"typeof({declaringTypeFQN}).GetProperty({FormatStringLiteral(property.MemberName)}, {InstanceMemberBindingFlagsVariableName})!.SetValue(obj, value)"; + setterValue = $$""" + static (obj, value) => + { + #if NET8_0_OR_GREATER + {{unsafeAccessorSetter}}; + #else + {{reflectionSetter}}; + #endif + } + """; + } + else if (property.CanUseSetter && typeGenerationSpec.TypeRef.IsValueType) + { + setterValue = $"""static (obj, value) => {UnsafeTypeRef}.Unbox<{declaringTypeFQN}>(obj).{propertyName} = value!"""; + } + else if (property.CanUseSetter) + { + setterValue = $"""static (obj, value) => (({declaringTypeFQN})obj).{propertyName} = value!"""; + } + else if (property.HasJsonInclude) + { + setterValue = $"""static (obj, value) => throw new {InvalidOperationExceptionTypeRef}("{string.Format(ExceptionMessages.InaccessibleJsonIncludePropertiesNotSupported, typeGenerationSpec.TypeRef.Name, property.MemberName)}")"""; + } + else + { + setterValue = "null"; + } string ignoreConditionNamedArg = property.DefaultIgnoreCondition.HasValue ? $"{JsonIgnoreConditionTypeRef}.{property.DefaultIgnoreCondition.Value}" @@ -1505,6 +1537,55 @@ private static string FormatJsonSerializerDefaults(JsonSerializerDefaults defaul private static string FormatStringLiteral(string? value) => value is null ? "null" : SymbolDisplay.FormatLiteral(value, quote: true); private static string FormatCharLiteral(char value) => SymbolDisplay.FormatLiteral(value, quote: true); + /// + /// Gets the name of the UnsafeAccessor method for setting an init-only property. + /// + private static string GetUnsafeAccessorName(PropertyGenerationSpec property) + => $"__UnsafeAccessor_Set_{property.DeclaringType.Name}_{property.MemberName}"; + + /// + /// Generates UnsafeAccessor methods for init-only properties in a type. + /// These methods use UnsafeAccessor on .NET 8+ for better performance. + /// + private static void GenerateUnsafeAccessorMethods(SourceWriter writer, TypeGenerationSpec typeGenerationSpec) + { + bool hasInitOnlyProperties = false; + foreach (PropertyGenerationSpec property in typeGenerationSpec.PropertyGenSpecs) + { + if (property.CanUseSetter && property.IsInitOnlySetter && property.DefaultIgnoreCondition != JsonIgnoreCondition.Always) + { + hasInitOnlyProperties = true; + break; + } + } + + if (!hasInitOnlyProperties) + { + return; + } + + writer.WriteLine("#if NET8_0_OR_GREATER"); + + foreach (PropertyGenerationSpec property in typeGenerationSpec.PropertyGenSpecs) + { + if (!property.CanUseSetter || !property.IsInitOnlySetter || property.DefaultIgnoreCondition == JsonIgnoreCondition.Always) + { + continue; + } + + string declaringTypeFQN = property.DeclaringType.FullyQualifiedName; + string propertyTypeFQN = property.PropertyType.FullyQualifiedName; + string accessorName = GetUnsafeAccessorName(property); + + writer.WriteLine($""" + [{UnsafeAccessorTypeRef}({UnsafeAccessorKindTypeRef}.Method, Name = "set_{property.MemberName}")] + private static extern void {accessorName}({declaringTypeFQN} obj, {propertyTypeFQN} value); + """); + } + + writer.WriteLine("#endif"); + } + /// /// Method used to generate JsonTypeInfo given options instance /// From f999aadf2f380aabd897f3e9195d2114b58bbb2f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 28 Jan 2026 14:43:00 +0000 Subject: [PATCH 7/8] Detect UnsafeAccessor support at compile time and remove preprocessor conditionals Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com> --- .../gen/Helpers/KnownTypeSymbols.cs | 4 +++ .../gen/JsonSourceGenerator.Emitter.cs | 34 ++++++++----------- .../gen/JsonSourceGenerator.Parser.cs | 1 + .../gen/Model/ContextGenerationSpec.cs | 5 +++ 4 files changed, 24 insertions(+), 20 deletions(-) diff --git a/src/libraries/System.Text.Json/gen/Helpers/KnownTypeSymbols.cs b/src/libraries/System.Text.Json/gen/Helpers/KnownTypeSymbols.cs index 7abf6d32cddbf1..fe7e082ebcacfd 100644 --- a/src/libraries/System.Text.Json/gen/Helpers/KnownTypeSymbols.cs +++ b/src/libraries/System.Text.Json/gen/Helpers/KnownTypeSymbols.cs @@ -255,6 +255,10 @@ public KnownTypeSymbols(Compilation compilation) public INamedTypeSymbol? IJsonOnSerializedType => GetOrResolveType(JsonConstants.IJsonOnSerializedFullName, ref _IJsonOnSerializedType); private Option _IJsonOnSerializedType; + // Runtime feature detection types + public INamedTypeSymbol? UnsafeAccessorAttributeType => GetOrResolveType("System.Runtime.CompilerServices.UnsafeAccessorAttribute", ref _UnsafeAccessorAttributeType); + private Option _UnsafeAccessorAttributeType; + // Unsupported types public INamedTypeSymbol? DelegateType => _DelegateType ??= Compilation.GetSpecialType(SpecialType.System_Delegate); private INamedTypeSymbol? _DelegateType; diff --git a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Emitter.cs b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Emitter.cs index 0510498a32fe56..31feb8c5aa4d79 100644 --- a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Emitter.cs +++ b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Emitter.cs @@ -577,7 +577,7 @@ private SourceText GenerateForObject(ContextGenerationSpec contextSpec, TypeGene if (propInitMethodName != null) { writer.WriteLine(); - GeneratePropMetadataInitFunc(writer, propInitMethodName, typeMetadata); + GeneratePropMetadataInitFunc(writer, contextSpec, propInitMethodName, typeMetadata); } if (serializeMethodName != null) @@ -593,7 +593,7 @@ private SourceText GenerateForObject(ContextGenerationSpec contextSpec, TypeGene } // Generate UnsafeAccessor methods for init-only properties - if (ShouldGenerateMetadata(typeMetadata)) + if (ShouldGenerateMetadata(typeMetadata) && contextSpec.SupportsUnsafeAccessor) { writer.WriteLine(); GenerateUnsafeAccessorMethods(writer, typeMetadata); @@ -605,7 +605,7 @@ private SourceText GenerateForObject(ContextGenerationSpec contextSpec, TypeGene return CompleteSourceFileAndReturnText(writer); } - private void GeneratePropMetadataInitFunc(SourceWriter writer, string propInitMethodName, TypeGenerationSpec typeGenerationSpec) + private void GeneratePropMetadataInitFunc(SourceWriter writer, ContextGenerationSpec contextSpec, string propInitMethodName, TypeGenerationSpec typeGenerationSpec) { ImmutableEquatableArray properties = typeGenerationSpec.PropertyGenSpecs; @@ -649,21 +649,19 @@ property.DefaultIgnoreCondition is JsonIgnoreCondition.Always && } else if (property is { CanUseSetter: true, IsInitOnlySetter: true }) { - // For init-only properties, use UnsafeAccessor on .NET 8+ for better performance, + // For init-only properties, use UnsafeAccessor on supported TFMs for better performance, // and fall back to reflection on older targets. This preserves default values // for init-only properties when they're not specified in the JSON. - string unsafeAccessorSetter = $"{GetUnsafeAccessorName(property)}(({declaringTypeFQN})obj, value!)"; - string reflectionSetter = $"typeof({declaringTypeFQN}).GetProperty({FormatStringLiteral(property.MemberName)}, {InstanceMemberBindingFlagsVariableName})!.SetValue(obj, value)"; - setterValue = $$""" - static (obj, value) => - { - #if NET8_0_OR_GREATER - {{unsafeAccessorSetter}}; - #else - {{reflectionSetter}}; - #endif - } - """; + if (contextSpec.SupportsUnsafeAccessor) + { + string unsafeAccessorSetter = $"{GetUnsafeAccessorName(property)}(({declaringTypeFQN})obj, value!)"; + setterValue = $"static (obj, value) => {unsafeAccessorSetter}"; + } + else + { + string reflectionSetter = $"typeof({declaringTypeFQN}).GetProperty({FormatStringLiteral(property.MemberName)}, {InstanceMemberBindingFlagsVariableName})!.SetValue(obj, value)"; + setterValue = $"static (obj, value) => {reflectionSetter}"; + } } else if (property.CanUseSetter && typeGenerationSpec.TypeRef.IsValueType) { @@ -1564,8 +1562,6 @@ private static void GenerateUnsafeAccessorMethods(SourceWriter writer, TypeGener return; } - writer.WriteLine("#if NET8_0_OR_GREATER"); - foreach (PropertyGenerationSpec property in typeGenerationSpec.PropertyGenSpecs) { if (!property.CanUseSetter || !property.IsInitOnlySetter || property.DefaultIgnoreCondition == JsonIgnoreCondition.Always) @@ -1582,8 +1578,6 @@ private static void GenerateUnsafeAccessorMethods(SourceWriter writer, TypeGener private static extern void {accessorName}({declaringTypeFQN} obj, {propertyTypeFQN} value); """); } - - writer.WriteLine("#endif"); } /// diff --git a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs index 4753d418255ef7..957965d16632a8 100644 --- a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs +++ b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs @@ -166,6 +166,7 @@ public Parser(KnownTypeSymbols knownSymbols) Namespace = contextTypeSymbol.ContainingNamespace is { IsGlobalNamespace: false } ns ? ns.ToDisplayString() : null, ContextClassDeclarations = classDeclarationList.ToImmutableEquatableArray(), GeneratedOptionsSpec = options, + SupportsUnsafeAccessor = _knownSymbols.UnsafeAccessorAttributeType is not null, }; // Clear the caches of generated metadata between the processing of context classes. diff --git a/src/libraries/System.Text.Json/gen/Model/ContextGenerationSpec.cs b/src/libraries/System.Text.Json/gen/Model/ContextGenerationSpec.cs index 00c7192c3ae58c..37b43f65e589bd 100644 --- a/src/libraries/System.Text.Json/gen/Model/ContextGenerationSpec.cs +++ b/src/libraries/System.Text.Json/gen/Model/ContextGenerationSpec.cs @@ -36,5 +36,10 @@ public sealed record ContextGenerationSpec public required ImmutableEquatableArray ContextClassDeclarations { get; init; } public required SourceGenerationOptionsSpec? GeneratedOptionsSpec { get; init; } + + /// + /// Whether the target framework supports UnsafeAccessor attribute (.NET 8+). + /// + public required bool SupportsUnsafeAccessor { get; init; } } } From 921884af341fb58e3df2ad58c563bdad0db4babb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 28 Jan 2026 16:45:03 +0000 Subject: [PATCH 8/8] Handle required members via UnsafeAccessor constructor invocation Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com> --- .../gen/JsonSourceGenerator.Emitter.cs | 89 +++++++++++++------ 1 file changed, 63 insertions(+), 26 deletions(-) diff --git a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Emitter.cs b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Emitter.cs index 31feb8c5aa4d79..b13761f4bf299f 100644 --- a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Emitter.cs +++ b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Emitter.cs @@ -502,7 +502,7 @@ private SourceText GenerateForObject(ContextGenerationSpec contextSpec, TypeGene string creatorInvocation = FormatDefaultConstructorExpr(typeMetadata); string parameterizedCreatorInvocation = constructionStrategy == ObjectConstructionStrategy.ParameterizedConstructor - ? GetParameterizedCtorInvocationFunc(typeMetadata) + ? GetParameterizedCtorInvocationFunc(contextSpec, typeMetadata) : "null"; string? propInitMethodName = null; @@ -972,14 +972,42 @@ static void ThrowPropertyNullException(string propertyName) writer.WriteLine('}'); } - private static string GetParameterizedCtorInvocationFunc(TypeGenerationSpec typeGenerationSpec) + private static string GetParameterizedCtorInvocationFunc(ContextGenerationSpec contextSpec, TypeGenerationSpec typeGenerationSpec) { ImmutableEquatableArray parameters = typeGenerationSpec.CtorParamGenSpecs; ImmutableEquatableArray propertyInitializers = typeGenerationSpec.PropertyInitializerSpecs; + // Check if the type has required members that need UnsafeAccessor to bypass C#'s required member validation + bool hasRequiredMembers = !typeGenerationSpec.ConstructorSetsRequiredParameters && + propertyInitializers.Any(p => p.IsRequired); + const string ArgsVarName = "args"; - StringBuilder sb = new($"static {ArgsVarName} => new {typeGenerationSpec.TypeRef.FullyQualifiedName}("); + StringBuilder sb = new(); + + if (hasRequiredMembers && contextSpec.SupportsUnsafeAccessor) + { + // Use UnsafeAccessor to invoke the constructor, bypassing required member validation + string ctorAccessorName = GetUnsafeAccessorCtorName(typeGenerationSpec); + sb.Append($"static {ArgsVarName} => {ctorAccessorName}("); + } + else if (hasRequiredMembers) + { + // Fall back to reflection for older TFMs + string argTypes = parameters.Count == 0 + ? EmptyTypeArray + : $$"""new[] {{{string.Join(", ", parameters.Select(p => $"typeof({p.ParameterType.FullyQualifiedName})"))}}}"""; + string argsArray = parameters.Count == 0 + ? "null" + : $$"""new object?[] {{{string.Join(", ", parameters.Select(p => GetParamUnboxing(p.ParameterType, p.ParameterIndex)))}}}"""; + sb.Append($"static {ArgsVarName} => ({typeGenerationSpec.TypeRef.FullyQualifiedName})typeof({typeGenerationSpec.TypeRef.FullyQualifiedName}).GetConstructor({InstanceMemberBindingFlagsVariableName}, binder: null, {argTypes}, modifiers: null)!.Invoke({argsArray})"); + return sb.ToString(); + } + else + { + // Normal constructor invocation without required member concerns + sb.Append($"static {ArgsVarName} => new {typeGenerationSpec.TypeRef.FullyQualifiedName}("); + } if (parameters.Count > 0) { @@ -994,28 +1022,9 @@ private static string GetParameterizedCtorInvocationFunc(TypeGenerationSpec type sb.Append(')'); - // Only include required properties in the object initializer. - // Non-required init-only properties should be set via reflection-based setters + // Do not include any properties in the object initializer. + // Both required and init-only properties should be set via their setter delegates // to preserve their default values when not specified in the JSON. - bool hasRequiredInitializers = false; - foreach (PropertyInitializerGenerationSpec property in propertyInitializers) - { - if (property.IsRequired) - { - if (!hasRequiredInitializers) - { - sb.Append("{ "); - hasRequiredInitializers = true; - } - sb.Append($"{property.Name} = {GetParamUnboxing(property.ParameterType, property.ParameterIndex)}, "); - } - } - - if (hasRequiredInitializers) - { - sb.Length -= 2; // delete the last ", " token - sb.Append(" }"); - } return sb.ToString(); @@ -1542,11 +1551,39 @@ private static string GetUnsafeAccessorName(PropertyGenerationSpec property) => $"__UnsafeAccessor_Set_{property.DeclaringType.Name}_{property.MemberName}"; /// - /// Generates UnsafeAccessor methods for init-only properties in a type. + /// Gets the name of the UnsafeAccessor method for invoking a constructor. + /// + private static string GetUnsafeAccessorCtorName(TypeGenerationSpec typeSpec) + => $"__UnsafeAccessor_Ctor_{typeSpec.TypeRef.Name}"; + + /// + /// Generates UnsafeAccessor methods for init-only properties and constructors in a type. /// These methods use UnsafeAccessor on .NET 8+ for better performance. /// private static void GenerateUnsafeAccessorMethods(SourceWriter writer, TypeGenerationSpec typeGenerationSpec) { + // Check if we need UnsafeAccessor for the constructor (types with required members) + bool needsCtorAccessor = !typeGenerationSpec.ConstructorSetsRequiredParameters && + typeGenerationSpec.PropertyInitializerSpecs.Any(p => p.IsRequired); + + // Generate constructor accessor if needed + if (needsCtorAccessor) + { + string ctorAccessorName = GetUnsafeAccessorCtorName(typeGenerationSpec); + string typeFQN = typeGenerationSpec.TypeRef.FullyQualifiedName; + ImmutableEquatableArray parameters = typeGenerationSpec.CtorParamGenSpecs; + + string paramList = parameters.Count == 0 + ? "" + : string.Join(", ", parameters.Select(p => $"{p.ParameterType.FullyQualifiedName} {p.Name}")); + + writer.WriteLine($""" + [{UnsafeAccessorTypeRef}({UnsafeAccessorKindTypeRef}.Constructor)] + private static extern {typeFQN} {ctorAccessorName}({paramList}); + """); + } + + // Generate property setter accessors for init-only properties bool hasInitOnlyProperties = false; foreach (PropertyGenerationSpec property in typeGenerationSpec.PropertyGenSpecs) { @@ -1557,7 +1594,7 @@ private static void GenerateUnsafeAccessorMethods(SourceWriter writer, TypeGener } } - if (!hasInitOnlyProperties) + if (!hasInitOnlyProperties && !needsCtorAccessor) { return; }