diff --git a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs index ab54a7278679c3..329a369b4a0135 100644 --- a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs +++ b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs @@ -101,6 +101,15 @@ public Parser(KnownTypeSymbols knownSymbols) return null; } + // When a context class is split across multiple partial declarations with + // [JsonSerializable] attributes on different partials, we only want to + // generate code once (from the canonical partial) to avoid duplicate hintNames. + if (!IsCanonicalPartialDeclaration(contextTypeSymbol, contextClassDeclaration)) + { + _contextClassLocation = null; + return null; + } + ParseJsonSerializerContextAttributes(contextTypeSymbol, out List? rootSerializableTypes, out SourceGenerationOptionsSpec? options); @@ -258,6 +267,54 @@ private void ParseJsonSerializerContextAttributes( } } + /// + /// Determines if the given class declaration is the canonical partial declaration + /// for the context type. When a context class is split across multiple partial + /// declarations with [JsonSerializable] attributes on different partials, we only + /// want to generate code once (from the canonical partial) to avoid duplicate hintNames. + /// The canonical partial is determined by picking the first syntax tree alphabetically + /// by file path among all trees that have at least one [JsonSerializable] attribute. + /// If file paths are empty or identical, comparison falls back to ordinal string order + /// which provides deterministic behavior. If no attributes are found (edge case that + /// shouldn't occur since this method is called from a context triggered by the attribute), + /// the current partial is treated as canonical. + /// + private bool IsCanonicalPartialDeclaration(INamedTypeSymbol contextTypeSymbol, ClassDeclarationSyntax contextClassDeclaration) + { + Debug.Assert(_knownSymbols.JsonSerializableAttributeType != null); + + // Collect all distinct syntax trees that have [JsonSerializable] attributes for this type + SyntaxTree? canonicalTree = null; + + foreach (AttributeData attributeData in contextTypeSymbol.GetAttributes()) + { + if (!SymbolEqualityComparer.Default.Equals(attributeData.AttributeClass, _knownSymbols.JsonSerializableAttributeType)) + { + continue; + } + + SyntaxTree? attributeTree = attributeData.ApplicationSyntaxReference?.SyntaxTree; + if (attributeTree is null) + { + continue; + } + + // Pick the first tree alphabetically by file path. + // Empty file paths compare as less than non-empty paths with ordinal comparison. + if (canonicalTree is null || + string.Compare(attributeTree.FilePath, canonicalTree.FilePath, StringComparison.Ordinal) < 0) + { + canonicalTree = attributeTree; + } + } + + // This partial is canonical if its syntax tree is the canonical tree. + // If canonicalTree is null (no attributes found), treat current partial as canonical. + // This is a fallback that shouldn't normally occur since this method is called + // from a context triggered by ForAttributeWithMetadataName for JsonSerializableAttribute. + return canonicalTree is null || canonicalTree == contextClassDeclaration.SyntaxTree; + } + private SourceGenerationOptionsSpec ParseJsonSourceGenerationOptionsAttribute(INamedTypeSymbol contextType, AttributeData attributeData) { JsonSourceGenerationMode? generationMode = null; diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/JsonSerializerContextTests.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/JsonSerializerContextTests.cs index df27fc881b9229..dc77b12d7f96d6 100644 --- a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/JsonSerializerContextTests.cs +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/JsonSerializerContextTests.cs @@ -991,5 +991,38 @@ public static void SupportsDisallowDuplicateProperty() internal partial class ContextWithAllowDuplicateProperties : JsonSerializerContext { } + + // Test for https://github.com/dotnet/runtime/issues/99669 + // Verifies that partial contexts with [JsonSerializable] attributes on multiple declarations + // work correctly at runtime - all types from all partial declarations should be available. + [Fact] + public static void PartialContextWithAttributesOnMultipleDeclarations_RuntimeBehavior() + { + // Verify both types from both partial declarations are available + Assert.NotNull(MultiplePartialDeclarationsContext.Default.TypeFromPartial1); + Assert.NotNull(MultiplePartialDeclarationsContext.Default.TypeFromPartial2); + + // Test serialization of type from first partial + var obj1 = new TypeFromPartial1 { Id = 42, Name = "Test" }; + string json1 = JsonSerializer.Serialize(obj1, MultiplePartialDeclarationsContext.Default.TypeFromPartial1); + Assert.Contains("42", json1); + Assert.Contains("Test", json1); + + // Test deserialization of type from first partial + var deserialized1 = JsonSerializer.Deserialize(json1, MultiplePartialDeclarationsContext.Default.TypeFromPartial1); + Assert.Equal(42, deserialized1.Id); + Assert.Equal("Test", deserialized1.Name); + + // Test serialization of type from second partial + var obj2 = new TypeFromPartial2 { Value = 3.14, IsActive = true }; + string json2 = JsonSerializer.Serialize(obj2, MultiplePartialDeclarationsContext.Default.TypeFromPartial2); + Assert.Contains("3.14", json2); + Assert.Contains("true", json2); + + // Test deserialization of type from second partial + var deserialized2 = JsonSerializer.Deserialize(json2, MultiplePartialDeclarationsContext.Default.TypeFromPartial2); + Assert.Equal(3.14, deserialized2.Value); + Assert.True(deserialized2.IsActive); + } } } diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/PartialContextTests.Part1.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/PartialContextTests.Part1.cs new file mode 100644 index 00000000000000..68662a5f9613da --- /dev/null +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/PartialContextTests.Part1.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json.Serialization; + +namespace System.Text.Json.SourceGeneration.Tests +{ + // Test for https://github.com/dotnet/runtime/issues/99669 + // Types and first partial declaration for testing partial contexts with + // [JsonSerializable] attributes on multiple declarations. + + public class TypeFromPartial1 + { + public int Id { get; set; } + public string Name { get; set; } + } + + // First partial declaration - declares TypeFromPartial1 + [JsonSerializable(typeof(TypeFromPartial1))] + internal partial class MultiplePartialDeclarationsContext : JsonSerializerContext { } +} diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/PartialContextTests.Part2.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/PartialContextTests.Part2.cs new file mode 100644 index 00000000000000..cdf84bd70478d2 --- /dev/null +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/PartialContextTests.Part2.cs @@ -0,0 +1,20 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json.Serialization; + +namespace System.Text.Json.SourceGeneration.Tests +{ + // Second type for testing partial contexts with + // [JsonSerializable] attributes on multiple declarations. + + public class TypeFromPartial2 + { + public double Value { get; set; } + public bool IsActive { get; set; } + } + + // Second partial declaration - declares TypeFromPartial2 + [JsonSerializable(typeof(TypeFromPartial2))] + internal partial class MultiplePartialDeclarationsContext { } +} diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/System.Text.Json.SourceGeneration.Tests.targets b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/System.Text.Json.SourceGeneration.Tests.targets index 5a26cadb6f66e5..2e8ae1c9508962 100644 --- a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/System.Text.Json.SourceGeneration.Tests.targets +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/System.Text.Json.SourceGeneration.Tests.targets @@ -50,6 +50,8 @@ + + diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Unit.Tests/JsonSourceGeneratorTests.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Unit.Tests/JsonSourceGeneratorTests.cs index 18b171620548d8..5a1f2f03618978 100644 --- a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Unit.Tests/JsonSourceGeneratorTests.cs +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Unit.Tests/JsonSourceGeneratorTests.cs @@ -1035,5 +1035,87 @@ public partial class MyContext : JsonSerializerContext Assert.Empty(errors); } } + + [Fact] + public void PartialContextClassWithAttributesOnMultipleDeclarations() + { + // Test for https://github.com/dotnet/runtime/issues/99669 + // When a JsonSerializerContext is defined across multiple partial class declarations + // with [JsonSerializable] attributes on different declarations, the generator should + // successfully generate code without duplicate hintName errors. + + string source1 = """ + using System.Text.Json.Serialization; + + namespace HelloWorld + { + public class MyClass1 + { + public int Value { get; set; } + } + + public class MyClass2 + { + public string Name { get; set; } + } + + [JsonSerializable(typeof(MyClass1))] + internal partial class SerializerContext : JsonSerializerContext + { + } + } + """; + + string source2 = """ + using System.Text.Json.Serialization; + + namespace HelloWorld + { + [JsonSerializable(typeof(MyClass2))] + internal partial class SerializerContext + { + } + } + """; + + // Create a base compilation to get proper references (including netfx polyfill attributes). + // File paths are explicitly set to verify the canonical partial selection (first alphabetically). + // "File1.cs" comes before "File2.cs" alphabetically, so source1 declares the canonical partial. + Compilation baseCompilation = CompilationHelper.CreateCompilation(""); + + // Add our syntax trees with explicit file paths. Keep any existing polyfill trees from base compilation. + var polyfillTrees = baseCompilation.SyntaxTrees.Where(t => string.IsNullOrEmpty(t.FilePath) == false || !t.ToString().Contains("namespace HelloWorld")); + Compilation compilation = CSharpCompilation.Create( + "TestAssembly", + syntaxTrees: baseCompilation.SyntaxTrees.Concat(new[] + { + CSharpSyntaxTree.ParseText(source1, CompilationHelper.CreateParseOptions()).WithFilePath("File1.cs"), + CSharpSyntaxTree.ParseText(source2, CompilationHelper.CreateParseOptions()).WithFilePath("File2.cs") + }), + references: baseCompilation.References, + options: (CSharpCompilationOptions)baseCompilation.Options); + + JsonSourceGeneratorResult result = CompilationHelper.RunJsonSourceGenerator(compilation, logger: logger); + + // Verify no errors from the source generator + var errors = result.Diagnostics + .Where(d => d.Severity == DiagnosticSeverity.Error) + .ToList(); + + Assert.Empty(errors); + + // Verify a single combined context was generated containing both types + // (not two separate contexts, which would cause duplicate hintName errors) + Assert.Equal(1, result.ContextGenerationSpecs.Length); + result.AssertContainsType("global::HelloWorld.MyClass1"); + result.AssertContainsType("global::HelloWorld.MyClass2"); + + // Verify the generated code compiles without errors + var compilationErrors = result.NewCompilation.GetDiagnostics() + .Where(d => d.Severity == DiagnosticSeverity.Error) + .ToList(); + + Assert.Empty(compilationErrors); + } } }