Skip to content

Conversation

Copy link
Contributor

Copilot AI commented Jan 3, 2026

  • Understand the issue: virtual property with JsonPropertyName on base class is serialized twice when overridden in derived class
  • Modify PropertyHierarchyResolutionState to track properties by CLR member name for override/shadow detection
  • Modify AddPropertyWithConflictResolution to skip base class properties that have been overridden or shadowed
  • Apply similar fix to source generator in JsonSourceGenerator.Parser.cs
  • Fix CreatePropertyInfoCore in JsonMetadataServices.Helpers.cs to set IsVirtual property
  • Add tests to validate the fix
  • Add test types to source generation context
  • Build and run tests
  • Run code review and codeql check
  • Address PR feedback: removed regression test comment
  • Address PR feedback: remove IsVirtual check, track all members for override/shadow detection
Original prompt

This section details on the original issue you should resolve

<issue_title>System.text.json serializes virtual property decorated with JsonPropertyName attribute twice.</issue_title>
<issue_description>### Description

System.text.json serializes property decorated with JsonPropertyName attribute in the base class is recording 2 records in the output json , one with property name as key and a second with name specified in the attribute.

Reproduction Steps

Console.WriteLine(JsonSerializer.Serialize(new Derived()));

public class Base
{
    [JsonPropertyName("test")]
    public virtual string Id { get => "base"; set { } }
}

public class Derived : Base
{
    public override string Id { get => "derived"; set { } }
}

Expected behavior

only single entry with name specified in the JsonPropertyName should be adde to json.

Actual behavior

{"Id":"derived","test":"derived"} -> added twice with different names.

Regression?

We have seen this since the initial version.

Known Workarounds

I see two possible workarounds:

  1. Try using the base type as the generic parameter when creating your container object. Unlike Json.NET, STJ is sensitive when it comes to what type is being declared statically at the serialization callsite, or
  2. Replicate the JsonPropertyName property in the overrides. It's more boilerplate, but at least it's the best way to get the desired behavior while we ship a fix.

Configuration

.NET 7

Other information

No response</issue_description>

Comments on the Issue (you are @copilot in this section)

@ Tagging subscribers to this area: @dotnet/area-system-text-json, @gregsdennis See info in [area-owners.md](https://github.com/dotnet/runtime/blob/main/docs/area-owners.md) if you want to be subscribed.
Issue Details

Description

System.text.json serializes property decorated with JsonPropertyName attribute in the base class is recording 2 records in the output json , one with property name as key and a second with name specified in the attribute.

Reproduction Steps

Console.WriteLine(JsonSerializer.Serialize(new Derived()));

public class Base
{
[JsonPropertyName("test")]
public virtual string Id { get => "base"; set { } }
}

public class Derived : Base
{
public override string Id { get => "derived"; set { } }
}

Expected behavior

only single entry with name specified in the JsonPropertyName should be adde to json.

Actual behavior

{"Id":"derived","test":"derived"} -> added twice with different names.

Regression?

We have seen this since the initial version.

Known Workarounds

I see two possible workarounds:

  1. Try using the base type as the generic parameter when creating your container object. Unlike Json.NET, STJ is sensitive when it comes to what type is being declared statically at the serialization callsite, or
  2. Replicate the JsonPropertyName property in the overrides. It's more boilerplate, but at least it's the best way to get the desired behavior while we ship a fix.

Configuration

.NET 7

Other information

No response

Author: alexaloni
Assignees: -
Labels:

area-System.Text.Json

Milestone: -
@eiriktsarpalis I can reproduce in .NET 8. This falls under the same umbrella of issues described in https://github.com//issues/73255, namely it concerns attribute resolution of virtual properties. We should try to address all these issues in unison, although it would admit the probability of breaking changes.

In the meantime, you could try the following workarounds:

  1. Serialize the value using the base type as the generic parameter: JsonSerializer.Serialize<Base>(new Derived()); or
  2. Apply the same JsonPropertyName attribute to the overridden property.</comment_new>

💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.

Copilot AI and others added 2 commits January 3, 2026 22:36
…me on base class

When a virtual property with [JsonPropertyName] on the base class is overridden
in a derived class without the attribute, the property was being serialized twice
with different JSON names (once from base class's attribute, once from derived class).

The fix tracks virtual properties by their CLR member name during property
resolution, so that when processing base class properties, we can detect if
they have already been overridden by a derived class property - even when the
JSON property names differ.

This affects both the runtime (DefaultJsonTypeInfoResolver) and the source
generator (JsonSourceGenerator).

Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix double serialization of virtual property with JsonPropertyName Fix duplicate serialization of virtual properties with JsonPropertyName on base class Jan 3, 2026
Copilot AI requested a review from stephentoub January 3, 2026 22:48
@stephentoub stephentoub marked this pull request as ready for review January 4, 2026 00:32
Copilot AI review requested due to automatic review settings January 4, 2026 00:32
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes a bug where virtual properties with [JsonPropertyName] on a base class were serialized twice when overridden in a derived class without the attribute. The root cause was that property conflict resolution only tracked properties by JSON name, so when the derived override lacked the attribute, the different JSON names ("Id" vs "test") prevented collision detection.

Key Changes

  • Added OverriddenVirtualProperties dictionary to track virtual properties by CLR member name
  • Added early-exit check in AddPropertyWithConflictResolution to detect virtual property overrides even when JSON names differ
  • Applied the same fix to both runtime (JsonTypeInfo.cs) and source generator (JsonSourceGenerator.Parser.cs)
  • Set IsVirtual property in CreatePropertyInfoCore for source-generated metadata
  • Added comprehensive tests covering the bug scenario and related edge cases

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonTypeInfo.cs Added OverriddenVirtualProperties dictionary to PropertyHierarchyResolutionState and implemented virtual property override detection logic in AddPropertyWithConflictResolution
src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs Mirrored the runtime changes in the source generator: added OverriddenVirtualMembers dictionary and override detection logic
src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonMetadataServices.Helpers.cs Set IsVirtual property in CreatePropertyInfoCore to ensure source-generated metadata has this flag set correctly
src/libraries/System.Text.Json/tests/Common/PropertyNameTests.cs Added three test methods covering virtual property scenarios and test class definitions
src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/PropertyNameTests.cs Registered new test classes in both Metadata and Default source generation contexts

[Fact]
public async Task VirtualPropertyWithJsonPropertyNameOnBaseClass_SerializedOnce()
{
// Regression test for https://github.com/dotnet/runtime/issues/96998
Copy link

Copilot AI Jan 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment references issue #96998, but the PR description indicates this fixes issue #92780. Please update the issue number in the comment to match the correct issue being fixed.

Copilot uses AI. Check for mistakes.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot, you can remove the comment about this being a regression test.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed in 7954786.

andreanadr

This comment was marked as spam.

andreanadr

This comment was marked as spam.

andreanadr

This comment was marked as spam.

andreanadr

This comment was marked as spam.

[Fact]
public async Task VirtualPropertyWithJsonPropertyNameOnBaseClass_SerializedOnce()
{
// Regression test for https://github.com/dotnet/runtime/issues/96998
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot, you can remove the comment about this being a regression test.


propertyInfo.MemberName = propertyInfoValues.PropertyName;
propertyInfo.MemberType = propertyInfoValues.IsProperty ? MemberTypes.Property : MemberTypes.Field;
propertyInfo.IsVirtual = propertyInfoValues.IsVirtual;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot, was this a bug on its own? Are there other issues that stemmed from this not being copied, or was it otherwise benign? Are there other properties that should be copied but aren't?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this was a latent bug - IsVirtual was populated by the source generator into JsonPropertyInfoValues<T>.IsVirtual but never copied to JsonPropertyInfo.IsVirtual. This was benign before because IsVirtual on JsonPropertyInfo was only checked when there was a JSON name collision (handled by the existing conflict resolution logic). My fix introduced a new check that uses IsVirtual even when JSON names differ, which exposed the missing copy.

I verified all other properties in JsonPropertyInfoValues<T> are being copied: IsProperty, IsPublic, DeclaringType, PropertyTypeInfo, Converter, Getter, Setter, IgnoreCondition, HasJsonInclude, IsExtensionData, NumberHandling, PropertyName, JsonPropertyName, and AttributeProviderFactory are all handled.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If memory serves IsVirtual is a no-op setting that was added in the public source generator models and never ended up being used.

…meOnBaseClass_SerializedOnce

Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
// regardless of the JSON property name they use. This handles the case where a derived class
// overrides a virtual property but uses a different JSON property name (e.g., due to the base
// class having [JsonPropertyName] that the derived class doesn't inherit).
if (jsonPropertyInfo.IsVirtual &&
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot this check isn't necessary, this class of bugs also impacts shadowed members that aren't necessarily virtual.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@eiriktsarpalis, if a base and a derived type have the same named property but two different json names, they should be different, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed the IsVirtual check in 171bc5a. The fix now tracks all properties by member name and applies to both virtual overrides and new shadowed members.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if a base and a derived type have the same named property but two different json names, they should be different, right?

From the perspective of C# the property on the base type is rendered inaccessible on the derived type unless a cast is performed. Shouldn't this convention also apply to the JSON contract of the derived type? The presence of a JsonPropertyName shouldn't have any impact on that.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From the perspective of C# the property on the base type is rendered inaccessible on the derived type unless a cast is performed.

They're two completely different properties, with two completely different JSON names; the only happen to have the same C# name, and for that to happen, the developer of the derived type explicitly said they were different by adding new to it. I don't see why the JSON should then pretend that they're the same.

Copy link
Member

@stephentoub stephentoub Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why? Those are two completely distinct properties, both in expressed developer intent (new) and in JsonPropertyName, which is "bar" in the base and "MyProp" by default in the derived. When it's virtual/override, it's a single property, so you need to factor in relationship between attributes, but when not virtual, they're entirely distinct. The only relationship would be that when there's no attribute, they'd default to conflicting names.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only relationship would be that when there's no attribute, they'd default to conflicting names.

STJ will give out errors when you have conflicting JSON property names. This doesn't impact shadowed members because they are always being ignored. In other words, STJ has historically treated shadowing members as if they were overrides.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

STJ has historically treated shadowing members as if they were overrides.

Well that's unfortunate. We're happy with that? Or we don't like it but it'd be too breaking to change now?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given that, I'm unclear as to your original comment in this thread. What bugs does removing the IsVirtual check fix?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well that's unfortunate. We're happy with that? Or we don't like it but it'd be too breaking to change now?

It's not great, but yeah it would be a major breaking change.

What bugs does removing the IsVirtual check fix?

I haven't looked, but given that we haven't used IsVirtual as a distinguishing factor elsewhere in the algorithm I'm concerned about doing it here.

Copy link
Member

@eiriktsarpalis eiriktsarpalis left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a bit concerned with targeted fixes touching this logic, as it is particularly prone to introducing breaking changes in the generated serialization contracts. I've created a parent issue tracking all bugs related to this, I think we want to take a more principled approach about what fixes we should be making and how.

Per review feedback, the IsVirtual check was unnecessary since the same
bug affects shadowed members using 'new' keyword, not just virtual
overrides. The IsOverriddenOrShadowedBy check already handles both cases.

Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

System.text.json serializes virtual property decorated with JsonPropertyName attribute twice.

4 participants