diff --git a/AspNetCore.sln b/AspNetCore.sln index 403bbfaa9977..74b293f9b78f 100644 --- a/AspNetCore.sln +++ b/AspNetCore.sln @@ -1726,8 +1726,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "TestInfrastructure", "TestI EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Templates.Mvc.Tests", "src\ProjectTemplates\test\Templates.Mvc.Tests\Templates.Mvc.Tests.csproj", "{AA7445F5-BD28-400C-8507-E2E0D3CF7D7E}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Templates.Blazor.Server.Tests", "src\ProjectTemplates\test\Templates.Blazor.Server.Tests\Templates.Blazor.Server.Tests.csproj", "{281BF9DB-7B8A-446B-9611-10A60903F125}" -EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "stress", "stress", "{A5946454-4788-4871-8F23-A9471D55F115}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Components.CustomElements", "src\Components\CustomElements\src\Microsoft.AspNetCore.Components.CustomElements.csproj", "{76C3E22D-092B-4E8A-81F0-DCF071BFF4CD}" @@ -10405,22 +10403,6 @@ Global {AA7445F5-BD28-400C-8507-E2E0D3CF7D7E}.Release|x64.Build.0 = Release|Any CPU {AA7445F5-BD28-400C-8507-E2E0D3CF7D7E}.Release|x86.ActiveCfg = Release|Any CPU {AA7445F5-BD28-400C-8507-E2E0D3CF7D7E}.Release|x86.Build.0 = Release|Any CPU - {281BF9DB-7B8A-446B-9611-10A60903F125}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {281BF9DB-7B8A-446B-9611-10A60903F125}.Debug|Any CPU.Build.0 = Debug|Any CPU - {281BF9DB-7B8A-446B-9611-10A60903F125}.Debug|arm64.ActiveCfg = Debug|Any CPU - {281BF9DB-7B8A-446B-9611-10A60903F125}.Debug|arm64.Build.0 = Debug|Any CPU - {281BF9DB-7B8A-446B-9611-10A60903F125}.Debug|x64.ActiveCfg = Debug|Any CPU - {281BF9DB-7B8A-446B-9611-10A60903F125}.Debug|x64.Build.0 = Debug|Any CPU - {281BF9DB-7B8A-446B-9611-10A60903F125}.Debug|x86.ActiveCfg = Debug|Any CPU - {281BF9DB-7B8A-446B-9611-10A60903F125}.Debug|x86.Build.0 = Debug|Any CPU - {281BF9DB-7B8A-446B-9611-10A60903F125}.Release|Any CPU.ActiveCfg = Release|Any CPU - {281BF9DB-7B8A-446B-9611-10A60903F125}.Release|Any CPU.Build.0 = Release|Any CPU - {281BF9DB-7B8A-446B-9611-10A60903F125}.Release|arm64.ActiveCfg = Release|Any CPU - {281BF9DB-7B8A-446B-9611-10A60903F125}.Release|arm64.Build.0 = Release|Any CPU - {281BF9DB-7B8A-446B-9611-10A60903F125}.Release|x64.ActiveCfg = Release|Any CPU - {281BF9DB-7B8A-446B-9611-10A60903F125}.Release|x64.Build.0 = Release|Any CPU - {281BF9DB-7B8A-446B-9611-10A60903F125}.Release|x86.ActiveCfg = Release|Any CPU - {281BF9DB-7B8A-446B-9611-10A60903F125}.Release|x86.Build.0 = Release|Any CPU {76C3E22D-092B-4E8A-81F0-DCF071BFF4CD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {76C3E22D-092B-4E8A-81F0-DCF071BFF4CD}.Debug|Any CPU.Build.0 = Debug|Any CPU {76C3E22D-092B-4E8A-81F0-DCF071BFF4CD}.Debug|arm64.ActiveCfg = Debug|Any CPU @@ -11609,7 +11591,6 @@ Global {89896261-C5DD-4901-BCA7-7A5F718BC008} = {AB4B9E75-719C-4589-B852-20FBFD727730} {F0FBA346-D8BC-4FAE-A4B2-85B33FA23055} = {08D53E58-4AAE-40C4-8497-63EC8664F304} {AA7445F5-BD28-400C-8507-E2E0D3CF7D7E} = {08D53E58-4AAE-40C4-8497-63EC8664F304} - {281BF9DB-7B8A-446B-9611-10A60903F125} = {08D53E58-4AAE-40C4-8497-63EC8664F304} {A5946454-4788-4871-8F23-A9471D55F115} = {4FDDC525-4E60-4CAF-83A3-261C5B43721F} {76C3E22D-092B-4E8A-81F0-DCF071BFF4CD} = {0BB58FB6-8B66-4C6D-BA8A-DF3AFAF9AB8F} {0BB58FB6-8B66-4C6D-BA8A-DF3AFAF9AB8F} = {60D51C98-2CC0-40DF-B338-44154EFEE2FF} diff --git a/eng/Dependencies.props b/eng/Dependencies.props index 0413b558a486..e6807a616d9c 100644 --- a/eng/Dependencies.props +++ b/eng/Dependencies.props @@ -62,6 +62,7 @@ and are generated based on the last package release. + diff --git a/eng/SourceBuildPrebuiltBaseline.xml b/eng/SourceBuildPrebuiltBaseline.xml index ce0ed56ca4a6..ea5a86530de6 100644 --- a/eng/SourceBuildPrebuiltBaseline.xml +++ b/eng/SourceBuildPrebuiltBaseline.xml @@ -6,11 +6,11 @@ - - - - - + + + + + diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index 76b13dbca737..0765b4f0807b 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -9,329 +9,329 @@ --> - + https://github.com/dotnet/efcore - ef30eb29656f1924c289c29278192de79ea98b42 + 26f30ce4f57ff08453662b485af82afadb90a808 - + https://github.com/dotnet/efcore - ef30eb29656f1924c289c29278192de79ea98b42 + 26f30ce4f57ff08453662b485af82afadb90a808 - + https://github.com/dotnet/efcore - ef30eb29656f1924c289c29278192de79ea98b42 + 26f30ce4f57ff08453662b485af82afadb90a808 - + https://github.com/dotnet/efcore - ef30eb29656f1924c289c29278192de79ea98b42 + 26f30ce4f57ff08453662b485af82afadb90a808 - + https://github.com/dotnet/efcore - ef30eb29656f1924c289c29278192de79ea98b42 + 26f30ce4f57ff08453662b485af82afadb90a808 - + https://github.com/dotnet/efcore - ef30eb29656f1924c289c29278192de79ea98b42 + 26f30ce4f57ff08453662b485af82afadb90a808 - + https://github.com/dotnet/efcore - ef30eb29656f1924c289c29278192de79ea98b42 + 26f30ce4f57ff08453662b485af82afadb90a808 - + https://github.com/dotnet/efcore - ef30eb29656f1924c289c29278192de79ea98b42 + 26f30ce4f57ff08453662b485af82afadb90a808 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/source-build-externals - 7f9ae67f86a5adc1d9bf2f22f4bf3ec05b6d7b68 + 06913fc4c3fcb0065ee390d135fb766870d2c38a - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 https://github.com/dotnet/xdt 9a1c3e1b7f0c8763d4c96e593961a61a72679a7b - + https://github.com/dotnet/source-build-reference-packages - f8ebadcc83f7fc8cfd5147078c87d6e583cb32f1 + 3e92e7cead1e08476973f637007bb22cde7843ca @@ -355,47 +355,51 @@ - + https://github.com/dotnet/runtime - 171a525880315369e48c6adf6c181f98357352a5 + edfe49168f3e6f3d30237a0afeabf5dcae2abad5 - + https://github.com/dotnet/arcade - f3952775e6d00a5b6f43b0615a8a766e095185eb + e2334b2be36919347923d0ec872a46acddb1e385 - + https://github.com/dotnet/sourcelink - 23bda65700e70b6697390dcc4e0f87e2dfbce63a + 4d2c8bf58e8cb7900ec2d9077c155572e2d3cd88 - + https://github.com/dotnet/arcade - f3952775e6d00a5b6f43b0615a8a766e095185eb + e2334b2be36919347923d0ec872a46acddb1e385 - + https://github.com/dotnet/arcade - f3952775e6d00a5b6f43b0615a8a766e095185eb + e2334b2be36919347923d0ec872a46acddb1e385 - + https://github.com/dotnet/arcade - f3952775e6d00a5b6f43b0615a8a766e095185eb + e2334b2be36919347923d0ec872a46acddb1e385 - + https://github.com/dotnet/arcade - f3952775e6d00a5b6f43b0615a8a766e095185eb + e2334b2be36919347923d0ec872a46acddb1e385 - + + https://github.com/dotnet/extensions + a0e9c8794e3e0ba27033a9f54a545385228d0876 + + https://github.com/nuget/nuget.client - 027ca8b8ef4b4dc94995f87b9c441d2bcf742c1d + 8fef55f5a55a3b4f2c96cd1a9b5ddc51d4b927f8 - + https://github.com/nuget/nuget.client - 027ca8b8ef4b4dc94995f87b9c441d2bcf742c1d + 8fef55f5a55a3b4f2c96cd1a9b5ddc51d4b927f8 - + https://github.com/nuget/nuget.client - 027ca8b8ef4b4dc94995f87b9c441d2bcf742c1d + 8fef55f5a55a3b4f2c96cd1a9b5ddc51d4b927f8 diff --git a/eng/Versions.props b/eng/Versions.props index d103a72997da..62a656e3dcdf 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -63,86 +63,88 @@ --> - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 - 8.0.0-preview.6.23314.15 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 + 8.0.0-preview.6.23323.4 + + 8.0.0-preview.6.23320.3 - 8.0.0-preview.6.23315.1 - 8.0.0-preview.6.23315.1 - 8.0.0-preview.6.23315.1 - 8.0.0-preview.6.23315.1 - 8.0.0-preview.6.23315.1 - 8.0.0-preview.6.23315.1 - 8.0.0-preview.6.23315.1 - 8.0.0-preview.6.23315.1 + 8.0.0-preview.6.23323.1 + 8.0.0-preview.6.23323.1 + 8.0.0-preview.6.23323.1 + 8.0.0-preview.6.23323.1 + 8.0.0-preview.6.23323.1 + 8.0.0-preview.6.23323.1 + 8.0.0-preview.6.23323.1 + 8.0.0-preview.6.23323.1 4.4.0-4.22520.2 4.4.0-4.22520.2 @@ -150,19 +152,19 @@ 4.4.0-4.22520.2 - 6.2.2 - 6.2.2 - 6.2.2 + 6.2.4 + 6.2.4 + 6.2.4 - 8.0.0-beta.23312.4 - 8.0.0-beta.23312.4 - 8.0.0-beta.23312.4 + 8.0.0-beta.23316.6 + 8.0.0-beta.23316.6 + 8.0.0-beta.23316.6 - 8.0.0-beta.23309.3 + 8.0.0-beta.23314.2 - 8.0.0-alpha.1.23305.2 + 8.0.0-alpha.1.23315.1 - 8.0.0-alpha.1.23314.1 + 8.0.0-alpha.1.23316.2 7.0.0-preview.22423.2 diff --git a/eng/common/dotnet-install.sh b/eng/common/dotnet-install.sh index abd045a3247f..7e69e3a9e24a 100755 --- a/eng/common/dotnet-install.sh +++ b/eng/common/dotnet-install.sh @@ -54,6 +54,10 @@ cpuname=$(uname -m) case $cpuname in arm64|aarch64) buildarch=arm64 + if [ "$(getconf LONG_BIT)" -lt 64 ]; then + # This is 32-bit OS running on 64-bit CPU (for example Raspberry Pi OS) + buildarch=arm + fi ;; loongarch64) buildarch=loongarch64 diff --git a/global.json b/global.json index 7089e5870f56..2a4bd537021d 100644 --- a/global.json +++ b/global.json @@ -27,7 +27,7 @@ }, "msbuild-sdks": { "Yarn.MSBuild": "1.22.10", - "Microsoft.DotNet.Arcade.Sdk": "8.0.0-beta.23312.4", - "Microsoft.DotNet.Helix.Sdk": "8.0.0-beta.23312.4" + "Microsoft.DotNet.Arcade.Sdk": "8.0.0-beta.23316.6", + "Microsoft.DotNet.Helix.Sdk": "8.0.0-beta.23316.6" } } diff --git a/src/Caching/Caching.slnf b/src/Caching/Caching.slnf index ebc2330a777e..dcecdb8a91c7 100644 --- a/src/Caching/Caching.slnf +++ b/src/Caching/Caching.slnf @@ -5,7 +5,8 @@ "src\\Caching\\SqlServer\\src\\Microsoft.Extensions.Caching.SqlServer.csproj", "src\\Caching\\SqlServer\\test\\Microsoft.Extensions.Caching.SqlServer.Tests.csproj", "src\\Caching\\StackExchangeRedis\\src\\Microsoft.Extensions.Caching.StackExchangeRedis.csproj", - "src\\Caching\\StackExchangeRedis\\test\\Microsoft.Extensions.Caching.StackExchangeRedis.Tests.csproj" + "src\\Caching\\StackExchangeRedis\\test\\Microsoft.Extensions.Caching.StackExchangeRedis.Tests.csproj", + "src\\Middleware\\OutputCaching\\src\\Microsoft.AspNetCore.OutputCaching.csproj" ] } -} +} \ No newline at end of file diff --git a/src/Caching/StackExchangeRedis/src/Microsoft.Extensions.Caching.StackExchangeRedis.csproj b/src/Caching/StackExchangeRedis/src/Microsoft.Extensions.Caching.StackExchangeRedis.csproj index b73694371d1e..b5a3d703a4ab 100644 --- a/src/Caching/StackExchangeRedis/src/Microsoft.Extensions.Caching.StackExchangeRedis.csproj +++ b/src/Caching/StackExchangeRedis/src/Microsoft.Extensions.Caching.StackExchangeRedis.csproj @@ -13,14 +13,19 @@ + + + + + diff --git a/src/Caching/StackExchangeRedis/src/PublicAPI.Shipped.txt b/src/Caching/StackExchangeRedis/src/PublicAPI/net462/PublicAPI.Shipped.txt similarity index 100% rename from src/Caching/StackExchangeRedis/src/PublicAPI.Shipped.txt rename to src/Caching/StackExchangeRedis/src/PublicAPI/net462/PublicAPI.Shipped.txt diff --git a/src/Caching/StackExchangeRedis/src/PublicAPI.Unshipped.txt b/src/Caching/StackExchangeRedis/src/PublicAPI/net462/PublicAPI.Unshipped.txt similarity index 100% rename from src/Caching/StackExchangeRedis/src/PublicAPI.Unshipped.txt rename to src/Caching/StackExchangeRedis/src/PublicAPI/net462/PublicAPI.Unshipped.txt diff --git a/src/Caching/StackExchangeRedis/src/PublicAPI/net8.0/PublicAPI.Shipped.txt b/src/Caching/StackExchangeRedis/src/PublicAPI/net8.0/PublicAPI.Shipped.txt new file mode 100644 index 000000000000..bb997fdc8643 --- /dev/null +++ b/src/Caching/StackExchangeRedis/src/PublicAPI/net8.0/PublicAPI.Shipped.txt @@ -0,0 +1,26 @@ +#nullable enable +Microsoft.Extensions.Caching.StackExchangeRedis.RedisCache +Microsoft.Extensions.Caching.StackExchangeRedis.RedisCache.Dispose() -> void +Microsoft.Extensions.Caching.StackExchangeRedis.RedisCache.Get(string! key) -> byte[]? +Microsoft.Extensions.Caching.StackExchangeRedis.RedisCache.GetAsync(string! key, System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +Microsoft.Extensions.Caching.StackExchangeRedis.RedisCache.RedisCache(Microsoft.Extensions.Options.IOptions! optionsAccessor) -> void +Microsoft.Extensions.Caching.StackExchangeRedis.RedisCache.Refresh(string! key) -> void +Microsoft.Extensions.Caching.StackExchangeRedis.RedisCache.RefreshAsync(string! key, System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +Microsoft.Extensions.Caching.StackExchangeRedis.RedisCache.Remove(string! key) -> void +Microsoft.Extensions.Caching.StackExchangeRedis.RedisCache.RemoveAsync(string! key, System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +Microsoft.Extensions.Caching.StackExchangeRedis.RedisCache.Set(string! key, byte[]! value, Microsoft.Extensions.Caching.Distributed.DistributedCacheEntryOptions! options) -> void +Microsoft.Extensions.Caching.StackExchangeRedis.RedisCache.SetAsync(string! key, byte[]! value, Microsoft.Extensions.Caching.Distributed.DistributedCacheEntryOptions! options, System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +Microsoft.Extensions.Caching.StackExchangeRedis.RedisCacheOptions +Microsoft.Extensions.Caching.StackExchangeRedis.RedisCacheOptions.Configuration.get -> string? +Microsoft.Extensions.Caching.StackExchangeRedis.RedisCacheOptions.Configuration.set -> void +Microsoft.Extensions.Caching.StackExchangeRedis.RedisCacheOptions.ConfigurationOptions.get -> StackExchange.Redis.ConfigurationOptions? +Microsoft.Extensions.Caching.StackExchangeRedis.RedisCacheOptions.ConfigurationOptions.set -> void +Microsoft.Extensions.Caching.StackExchangeRedis.RedisCacheOptions.ConnectionMultiplexerFactory.get -> System.Func!>? +Microsoft.Extensions.Caching.StackExchangeRedis.RedisCacheOptions.ConnectionMultiplexerFactory.set -> void +Microsoft.Extensions.Caching.StackExchangeRedis.RedisCacheOptions.InstanceName.get -> string? +Microsoft.Extensions.Caching.StackExchangeRedis.RedisCacheOptions.InstanceName.set -> void +Microsoft.Extensions.Caching.StackExchangeRedis.RedisCacheOptions.ProfilingSession.get -> System.Func? +Microsoft.Extensions.Caching.StackExchangeRedis.RedisCacheOptions.ProfilingSession.set -> void +Microsoft.Extensions.Caching.StackExchangeRedis.RedisCacheOptions.RedisCacheOptions() -> void +Microsoft.Extensions.DependencyInjection.StackExchangeRedisCacheServiceCollectionExtensions +static Microsoft.Extensions.DependencyInjection.StackExchangeRedisCacheServiceCollectionExtensions.AddStackExchangeRedisCache(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Action! setupAction) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! diff --git a/src/Caching/StackExchangeRedis/src/PublicAPI/net8.0/PublicAPI.Unshipped.txt b/src/Caching/StackExchangeRedis/src/PublicAPI/net8.0/PublicAPI.Unshipped.txt new file mode 100644 index 000000000000..a38dbc1bfe7b --- /dev/null +++ b/src/Caching/StackExchangeRedis/src/PublicAPI/net8.0/PublicAPI.Unshipped.txt @@ -0,0 +1,2 @@ +#nullable enable +static Microsoft.Extensions.DependencyInjection.StackExchangeRedisCacheServiceCollectionExtensions.AddStackExchangeRedisOutputCache(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Action! setupAction) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! diff --git a/src/Caching/StackExchangeRedis/src/PublicAPI/netstandard2.0/PublicAPI.Shipped.txt b/src/Caching/StackExchangeRedis/src/PublicAPI/netstandard2.0/PublicAPI.Shipped.txt new file mode 100644 index 000000000000..bb997fdc8643 --- /dev/null +++ b/src/Caching/StackExchangeRedis/src/PublicAPI/netstandard2.0/PublicAPI.Shipped.txt @@ -0,0 +1,26 @@ +#nullable enable +Microsoft.Extensions.Caching.StackExchangeRedis.RedisCache +Microsoft.Extensions.Caching.StackExchangeRedis.RedisCache.Dispose() -> void +Microsoft.Extensions.Caching.StackExchangeRedis.RedisCache.Get(string! key) -> byte[]? +Microsoft.Extensions.Caching.StackExchangeRedis.RedisCache.GetAsync(string! key, System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +Microsoft.Extensions.Caching.StackExchangeRedis.RedisCache.RedisCache(Microsoft.Extensions.Options.IOptions! optionsAccessor) -> void +Microsoft.Extensions.Caching.StackExchangeRedis.RedisCache.Refresh(string! key) -> void +Microsoft.Extensions.Caching.StackExchangeRedis.RedisCache.RefreshAsync(string! key, System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +Microsoft.Extensions.Caching.StackExchangeRedis.RedisCache.Remove(string! key) -> void +Microsoft.Extensions.Caching.StackExchangeRedis.RedisCache.RemoveAsync(string! key, System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +Microsoft.Extensions.Caching.StackExchangeRedis.RedisCache.Set(string! key, byte[]! value, Microsoft.Extensions.Caching.Distributed.DistributedCacheEntryOptions! options) -> void +Microsoft.Extensions.Caching.StackExchangeRedis.RedisCache.SetAsync(string! key, byte[]! value, Microsoft.Extensions.Caching.Distributed.DistributedCacheEntryOptions! options, System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +Microsoft.Extensions.Caching.StackExchangeRedis.RedisCacheOptions +Microsoft.Extensions.Caching.StackExchangeRedis.RedisCacheOptions.Configuration.get -> string? +Microsoft.Extensions.Caching.StackExchangeRedis.RedisCacheOptions.Configuration.set -> void +Microsoft.Extensions.Caching.StackExchangeRedis.RedisCacheOptions.ConfigurationOptions.get -> StackExchange.Redis.ConfigurationOptions? +Microsoft.Extensions.Caching.StackExchangeRedis.RedisCacheOptions.ConfigurationOptions.set -> void +Microsoft.Extensions.Caching.StackExchangeRedis.RedisCacheOptions.ConnectionMultiplexerFactory.get -> System.Func!>? +Microsoft.Extensions.Caching.StackExchangeRedis.RedisCacheOptions.ConnectionMultiplexerFactory.set -> void +Microsoft.Extensions.Caching.StackExchangeRedis.RedisCacheOptions.InstanceName.get -> string? +Microsoft.Extensions.Caching.StackExchangeRedis.RedisCacheOptions.InstanceName.set -> void +Microsoft.Extensions.Caching.StackExchangeRedis.RedisCacheOptions.ProfilingSession.get -> System.Func? +Microsoft.Extensions.Caching.StackExchangeRedis.RedisCacheOptions.ProfilingSession.set -> void +Microsoft.Extensions.Caching.StackExchangeRedis.RedisCacheOptions.RedisCacheOptions() -> void +Microsoft.Extensions.DependencyInjection.StackExchangeRedisCacheServiceCollectionExtensions +static Microsoft.Extensions.DependencyInjection.StackExchangeRedisCacheServiceCollectionExtensions.AddStackExchangeRedisCache(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Action! setupAction) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! diff --git a/src/Caching/StackExchangeRedis/src/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/src/Caching/StackExchangeRedis/src/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt new file mode 100644 index 000000000000..7dc5c58110bf --- /dev/null +++ b/src/Caching/StackExchangeRedis/src/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/Caching/StackExchangeRedis/src/RedisOutputCacheStore.cs b/src/Caching/StackExchangeRedis/src/RedisOutputCacheStore.cs new file mode 100644 index 000000000000..5e4905c0cc05 --- /dev/null +++ b/src/Caching/StackExchangeRedis/src/RedisOutputCacheStore.cs @@ -0,0 +1,497 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#if NET7_0_OR_GREATER // IOutputCacheStore only exists from net7 + +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO.Pipelines; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.OutputCaching; +using Microsoft.AspNetCore.Shared; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using StackExchange.Redis; + +namespace Microsoft.Extensions.Caching.StackExchangeRedis; + +internal class RedisOutputCacheStore : IOutputCacheStore, IOutputCacheBufferStore, IDisposable +{ + private readonly RedisCacheOptions _options; + private readonly ILogger _logger; + private readonly RedisKey _valueKeyPrefix; + private readonly RedisKey _tagKeyPrefix; + private readonly RedisKey _tagMasterKey; + private readonly RedisKey[] _tagMasterKeyArray; // for use with Lua if needed (to avoid array allocs) + private readonly SemaphoreSlim _connectionLock = new SemaphoreSlim(initialCount: 1, maxCount: 1); + private readonly CancellationTokenSource _disposalCancellation = new(); + + private bool _disposed; + private volatile IDatabase? _cache; + private long _lastConnectTicks = DateTimeOffset.UtcNow.Ticks; + private long _firstErrorTimeTicks; + private long _previousErrorTimeTicks; + private bool _useMultiExec, _use62Features; + + internal bool GarbageCollectionEnabled { get; set; } = true; + + // Never reconnect within 60 seconds of the last attempt to connect or reconnect. + private readonly TimeSpan ReconnectMinInterval = TimeSpan.FromSeconds(60); + // Only reconnect if errors have occurred for at least the last 30 seconds. + // This count resets if there are no errors for 30 seconds + private readonly TimeSpan ReconnectErrorThreshold = TimeSpan.FromSeconds(30); + + /// + /// Initializes a new instance of . + /// + /// The configuration options. + public RedisOutputCacheStore(IOptions optionsAccessor) // TODO: OC-specific options? + : this(optionsAccessor, Logging.Abstractions.NullLoggerFactory.Instance.CreateLogger()) + { + } + +#if DEBUG + internal async ValueTask GetConfigurationInfoAsync(CancellationToken cancellationToken = default) + { + await ConnectAsync(cancellationToken).ConfigureAwait(false); + return $"redis output-cache; MULTI/EXEC: {_useMultiExec}, v6.2+: {_use62Features}"; + } +#endif + + /// + /// Initializes a new instance of . + /// + /// The configuration options. + /// The logger. + internal RedisOutputCacheStore(IOptions optionsAccessor, ILogger logger) + { + ArgumentNullThrowHelper.ThrowIfNull(optionsAccessor); + ArgumentNullThrowHelper.ThrowIfNull(logger); + + _options = optionsAccessor.Value; + _logger = logger; + + // This allows partitioning a single backend cache for use with multiple apps/services. + + // SE.Redis allows efficient append of key-prefix scenarios, but we can help it + // avoid some work/allocations by forcing the key-prefix to be a byte[]; SE.Redis + // would do this itself anyway, using UTF8 + _valueKeyPrefix = (RedisKey)Encoding.UTF8.GetBytes(_options.InstanceName + "__MSOCV_"); + _tagKeyPrefix = (RedisKey)Encoding.UTF8.GetBytes(_options.InstanceName + "__MSOCT_"); + _tagMasterKey = (RedisKey)Encoding.UTF8.GetBytes(_options.InstanceName + "__MSOCT"); + _tagMasterKeyArray = new[] { _tagMasterKey }; + + _ = Task.Factory.StartNew(RunGarbageCollectionLoopAsync, default, TaskCreationOptions.LongRunning, TaskScheduler.Current); + } + + private async Task RunGarbageCollectionLoopAsync() + { + try + { + while (!Volatile.Read(ref _disposed)) + { + // approx every 5 minutes, with some randomization to prevent spikes of pile-on + var secondsWithJitter = 300 + Random.Shared.Next(-30, 30); + Debug.Assert(secondsWithJitter >= 270 && secondsWithJitter <= 330); + await Task.Delay(TimeSpan.FromSeconds(secondsWithJitter)).ConfigureAwait(false); + try + { + if (GarbageCollectionEnabled) + { + await ExecuteGarbageCollectionAsync(GetExpirationTimestamp(TimeSpan.Zero), _disposalCancellation.Token).ConfigureAwait(false); + } + } + catch (OperationCanceledException) when (_disposed) + { + // fine, service exiting + } + catch (Exception ex) + { + // this sweep failed; log it + _logger.LogDebug(ex, "Transient error occurred executing redis output-cache GC loop"); + } + } + } + catch (Exception ex) + { + // the entire loop is dead + _logger.LogDebug(ex, "Fatal error occurred executing redis output-cache GC loop"); + } + } + + internal async ValueTask ExecuteGarbageCollectionAsync(long keepValuesGreaterThan, CancellationToken cancellationToken = default) + { + var cache = await ConnectAsync(CancellationToken.None).ConfigureAwait(false); + + var gcKey = _tagMasterKey.Append("GC"); + var gcLifetime = TimeSpan.FromMinutes(5); + // value is purely placeholder; it is the existence that matters + if (!await cache.StringSetAsync(gcKey, DateTime.UtcNow.ToString(CultureInfo.InvariantCulture), gcLifetime, when: When.NotExists).ConfigureAwait(false)) + { + return null; // competition from another node; not even "nothing" + } + try + { + // we'll rely on the enumeration of ZSCAN to spot connection failures, and use "best efforts" + // on the individual operations - this avoids per-call latency + const CommandFlags GarbageCollectionFlags = CommandFlags.FireAndForget; + + // the score is the effective timeout, so we simply need to cull everything with scores below "cull", + // for the individual tag sorted-sets, and also the master sorted-set + const int EXTEND_EVERY = 250; // some non-trivial number of work + int extendCountdown = EXTEND_EVERY; + await foreach (var entry in cache.SortedSetScanAsync(_tagMasterKey).WithCancellation(cancellationToken)) + { + await cache.SortedSetRemoveRangeByScoreAsync(GetTagKey((string)entry.Element!), start: 0, stop: keepValuesGreaterThan, flags: GarbageCollectionFlags).ConfigureAwait(false); + if (--extendCountdown <= 0) + { + await cache.KeyExpireAsync(gcKey, gcLifetime).ConfigureAwait(false); + extendCountdown = EXTEND_EVERY; + } + } + // paying latency on the final master-tag purge: is fine + return await cache.SortedSetRemoveRangeByScoreAsync(_tagMasterKey, start: 0, stop: keepValuesGreaterThan).ConfigureAwait(false); + } + finally + { + await cache.KeyDeleteAsync(gcKey, CommandFlags.FireAndForget).ConfigureAwait(false); + } + } + + async ValueTask IOutputCacheStore.EvictByTagAsync(string tag, CancellationToken cancellationToken) + { + var cache = await ConnectAsync(cancellationToken).ConfigureAwait(false); + Debug.Assert(cache is not null); + + // we'll use fire-and-forget on individual deletes, relying on the paging mechanism + // of ZSCAN to detect fundamental connection problems - so failure will still be reported + const CommandFlags DeleteFlags = CommandFlags.FireAndForget; + + var tagKey = GetTagKey(tag); + await foreach (var entry in cache.SortedSetScanAsync(tagKey).WithCancellation(cancellationToken)) + { + await cache.KeyDeleteAsync(GetValueKey((string)entry.Element!), DeleteFlags).ConfigureAwait(false); + await cache.SortedSetRemoveAsync(tagKey, entry.Element, DeleteFlags).ConfigureAwait(false); + } + } + + private RedisKey GetValueKey(string key) + => _valueKeyPrefix.Append(key); + + private RedisKey GetTagKey(string tag) + => _tagKeyPrefix.Append(tag); + + async ValueTask IOutputCacheStore.GetAsync(string key, CancellationToken cancellationToken) + { + ArgumentNullThrowHelper.ThrowIfNull(key); + + var cache = await ConnectAsync(cancellationToken).ConfigureAwait(false); + Debug.Assert(cache is not null); + + try + { + return (byte[]?)(await cache.StringGetAsync(GetValueKey(key)).ConfigureAwait(false)); + } + catch (Exception ex) + { + OnRedisError(ex, cache); + throw; + } + } + + async ValueTask IOutputCacheBufferStore.TryGetAsync(string key, PipeWriter destination, CancellationToken cancellationToken) + { + ArgumentNullThrowHelper.ThrowIfNull(key); + ArgumentNullThrowHelper.ThrowIfNull(destination); + + var cache = await ConnectAsync(cancellationToken).ConfigureAwait(false); + Debug.Assert(cache is not null); + + Lease? result = null; + try + { + result = await cache.StringGetLeaseAsync(GetValueKey(key)).ConfigureAwait(false); + if (result is null) + { + return false; + } + + // future implementation will pass PipeWriter all the way down through redis, + // to allow end-to-end back-pressure; new SE.Redis API required + destination.Write(result.Span); + await destination.FlushAsync(cancellationToken).ConfigureAwait(false); + return true; + } + catch (Exception ex) + { + result?.Dispose(); + OnRedisError(ex, cache); + throw; + } + } + + ValueTask IOutputCacheStore.SetAsync(string key, byte[] value, string[]? tags, TimeSpan validFor, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(value); + return ((IOutputCacheBufferStore)this).SetAsync(key, new ReadOnlySequence(value), tags.AsMemory(), validFor, cancellationToken); + } + + async ValueTask IOutputCacheBufferStore.SetAsync(string key, ReadOnlySequence value, ReadOnlyMemory tags, TimeSpan validFor, CancellationToken cancellationToken) + { + var cache = await ConnectAsync(cancellationToken).ConfigureAwait(false); + Debug.Assert(cache is not null); + + byte[]? leased = null; + ReadOnlyMemory singleChunk; + if (value.IsSingleSegment) + { + singleChunk = value.First; + } + else + { + int len = checked((int)value.Length); + leased = ArrayPool.Shared.Rent(len); + value.CopyTo(leased); + singleChunk = new(leased, 0, len); + } + + await cache.StringSetAsync(GetValueKey(key), singleChunk, validFor).ConfigureAwait(false); + // only return lease on success + if (leased is not null) + { + ArrayPool.Shared.Return(leased); + } + + if (!tags.IsEmpty) + { + long expiryTimestamp = GetExpirationTimestamp(validFor); + var len = tags.Length; + + // tags are secondary; to avoid latency costs, we'll use fire-and-forget when adding tags - this does + // mean that in theory tag-related error may go undetected, but: this is an acceptable trade-off + const CommandFlags TagCommandFlags = CommandFlags.FireAndForget; + + for (var i = 0; i < len; i++) // can't use span in async method, so: eat a little overhead here + { + var tag = tags.Span[i]; + if (_use62Features) + { + await cache.SortedSetAddAsync(_tagMasterKey, tag, expiryTimestamp, SortedSetWhen.GreaterThan, TagCommandFlags).ConfigureAwait(false); + } + else + { + // semantic equivalent of ZADD GT + const string ZADD_GT = """ + local oldScore = tonumber(redis.call('ZSCORE', KEYS[1], ARGV[2])) + if oldScore == nil or oldScore < tonumber(ARGV[1]) then + redis.call('ZADD', KEYS[1], ARGV[1], ARGV[2]) + end + """; + + // note we're not sharing an ARGV array between tags here because then we'd need to wait on latency to avoid conflicts; + // in reality most caches have very limited tags (if any), so this is not perceived as an issue + await cache.ScriptEvaluateAsync(ZADD_GT, _tagMasterKeyArray, new RedisValue[] { expiryTimestamp, tag }, TagCommandFlags).ConfigureAwait(false); + } + await cache.SortedSetAddAsync(GetTagKey(tag), key, expiryTimestamp, SortedSetWhen.Always, TagCommandFlags).ConfigureAwait(false); + } + } + } + + // note that by necessity we're interleaving two time systems here; the local time, and the + // time according to redis (and used internally for redis TTLs); in reality, if we disagree + // on time, we have bigger problems, so: this will have to suffice - we cannot reasonably + // push our in-proc time into out-of-proc redis + // TODO: TimeProvider? ISystemClock? + private static long GetExpirationTimestamp(TimeSpan timeout) => + (long)((DateTime.UtcNow + timeout) - DateTime.UnixEpoch).TotalMilliseconds; + + private ValueTask ConnectAsync(CancellationToken token = default) + { + CheckDisposed(); + token.ThrowIfCancellationRequested(); + + var cache = _cache; + if (cache is not null) + { + return new(cache); + } + return ConnectSlowAsync(token); + } + private async ValueTask ConnectSlowAsync(CancellationToken token) + { + await _connectionLock.WaitAsync(token).ConfigureAwait(false); + try + { + var cache = _cache; + if (cache is null) + { + IConnectionMultiplexer connection; + if (_options.ConnectionMultiplexerFactory is null) + { + if (_options.ConfigurationOptions is not null) + { + connection = await ConnectionMultiplexer.ConnectAsync(_options.ConfigurationOptions).ConfigureAwait(false); + } + else + { + connection = await ConnectionMultiplexer.ConnectAsync(_options.Configuration!).ConfigureAwait(false); + } + } + else + { + connection = await _options.ConnectionMultiplexerFactory().ConfigureAwait(false); + } + + PrepareConnection(connection); + cache = _cache = connection.GetDatabase(); + } + Debug.Assert(_cache is not null); + return cache; + } + finally + { + _connectionLock.Release(); + } + } + + /// + public void Dispose() + { + if (_disposed) + { + return; + } + _disposed = true; + _disposalCancellation.Cancel(); + ReleaseConnection(Interlocked.Exchange(ref _cache, null)); + } + + private void OnRedisError(Exception exception, IDatabase cache) + { + if (_options.UseForceReconnect && (exception is RedisConnectionException or SocketException)) + { + var utcNow = DateTimeOffset.UtcNow; + var previousConnectTime = ReadTimeTicks(ref _lastConnectTicks); + TimeSpan elapsedSinceLastReconnect = utcNow - previousConnectTime; + + // We want to limit how often we perform this top-level reconnect, so we check how long it's been since our last attempt. + if (elapsedSinceLastReconnect < ReconnectMinInterval) + { + return; + } + + var firstErrorTime = ReadTimeTicks(ref _firstErrorTimeTicks); + if (firstErrorTime == DateTimeOffset.MinValue) + { + // note: order/timing here (between the two fields) is not critical + WriteTimeTicks(ref _firstErrorTimeTicks, utcNow); + WriteTimeTicks(ref _previousErrorTimeTicks, utcNow); + return; + } + + TimeSpan elapsedSinceFirstError = utcNow - firstErrorTime; + TimeSpan elapsedSinceMostRecentError = utcNow - ReadTimeTicks(ref _previousErrorTimeTicks); + + bool shouldReconnect = + elapsedSinceFirstError >= ReconnectErrorThreshold // Make sure we gave the multiplexer enough time to reconnect on its own if it could. + && elapsedSinceMostRecentError <= ReconnectErrorThreshold; // Make sure we aren't working on stale data (e.g. if there was a gap in errors, don't reconnect yet). + + // Update the previousErrorTime timestamp to be now (e.g. this reconnect request). + WriteTimeTicks(ref _previousErrorTimeTicks, utcNow); + + if (!shouldReconnect) + { + return; + } + + WriteTimeTicks(ref _firstErrorTimeTicks, DateTimeOffset.MinValue); + WriteTimeTicks(ref _previousErrorTimeTicks, DateTimeOffset.MinValue); + + // wipe the shared field, but *only* if it is still the cache we were + // thinking about (once it is null, the next caller will reconnect) + ReleaseConnection(Interlocked.CompareExchange(ref _cache, null, cache)); + } + } + + private void PrepareConnection(IConnectionMultiplexer connection) + { + WriteTimeTicks(ref _lastConnectTicks, DateTimeOffset.UtcNow); + ValidateServerFeatures(connection); + TryRegisterProfiler(connection); + } + + private void ValidateServerFeatures(IConnectionMultiplexer connection) + { + int serverCount = 0, standaloneCount = 0, v62_Count = 0; + foreach (var ep in connection.GetEndPoints()) + { + var server = connection.GetServer(ep); + if (server is null) + { + continue; // wat? + } + serverCount++; + if (server.ServerType == ServerType.Standalone) + { + standaloneCount++; + } + if (server.Features.SortedSetRangeStore) // just a random v6.2 feature + { + v62_Count++; + } + } + _useMultiExec = serverCount == standaloneCount; + _use62Features = serverCount == v62_Count; + } + + private void TryRegisterProfiler(IConnectionMultiplexer connection) + { + _ = connection ?? throw new InvalidOperationException($"{nameof(connection)} cannot be null."); + + if (_options.ProfilingSession is not null) + { + connection.RegisterProfiler(_options.ProfilingSession); + } + } + + private static void WriteTimeTicks(ref long field, DateTimeOffset value) + { + var ticks = value == DateTimeOffset.MinValue ? 0L : value.UtcTicks; + Volatile.Write(ref field, ticks); // avoid torn values + } + + private void CheckDisposed() + { + ObjectDisposedThrowHelper.ThrowIf(_disposed, this); + } + + private static DateTimeOffset ReadTimeTicks(ref long field) + { + var ticks = Volatile.Read(ref field); // avoid torn values + return ticks == 0 ? DateTimeOffset.MinValue : new DateTimeOffset(ticks, TimeSpan.Zero); + } + + static void ReleaseConnection(IDatabase? cache) + { + var connection = cache?.Multiplexer; + if (connection is not null) + { + try + { + connection.Close(); + connection.Dispose(); + } + catch (Exception ex) + { + Debug.WriteLine(ex); + } + } + } +} +#endif diff --git a/src/Caching/StackExchangeRedis/src/RedisOutputCacheStoreImpl.cs b/src/Caching/StackExchangeRedis/src/RedisOutputCacheStoreImpl.cs new file mode 100644 index 000000000000..e27638aa6400 --- /dev/null +++ b/src/Caching/StackExchangeRedis/src/RedisOutputCacheStoreImpl.cs @@ -0,0 +1,24 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#if NET7_0_OR_GREATER // IOutputCacheStore only exists from net7 + +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Microsoft.Extensions.Caching.StackExchangeRedis; + +internal sealed class RedisOutputCacheStoreImpl : RedisOutputCacheStore +{ + public RedisOutputCacheStoreImpl(IOptions optionsAccessor, ILogger logger) + : base(optionsAccessor, logger) + { + } + + public RedisOutputCacheStoreImpl(IOptions optionsAccessor) + : base(optionsAccessor) + { + } +} + +#endif diff --git a/src/Caching/StackExchangeRedis/src/StackExchangeRedisCacheServiceCollectionExtensions.cs b/src/Caching/StackExchangeRedis/src/StackExchangeRedisCacheServiceCollectionExtensions.cs index dc9bc9ca5357..d6fca729bf20 100644 --- a/src/Caching/StackExchangeRedis/src/StackExchangeRedisCacheServiceCollectionExtensions.cs +++ b/src/Caching/StackExchangeRedis/src/StackExchangeRedisCacheServiceCollectionExtensions.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Shared; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.StackExchangeRedis; +using Microsoft.Extensions.DependencyInjection.Extensions; namespace Microsoft.Extensions.DependencyInjection; @@ -32,4 +33,27 @@ public static IServiceCollection AddStackExchangeRedisCache(this IServiceCollect return services; } + +#if NET7_0_OR_GREATER + /// + /// Adds Redis distributed caching services to the specified . + /// + /// The to add services to. + /// An to configure the provided + /// . + /// The so that additional calls can be chained. + public static IServiceCollection AddStackExchangeRedisOutputCache(this IServiceCollection services, Action setupAction) + { + ArgumentNullThrowHelper.ThrowIfNull(services); + ArgumentNullThrowHelper.ThrowIfNull(setupAction); + + services.AddOptions(); + + services.Configure(setupAction); + // replace here (Add vs TryAdd) is intentional and part of test conditions + services.AddSingleton(); + + return services; + } +#endif } diff --git a/src/Caching/StackExchangeRedis/test/OutputCache/OutputCacheGetSetTests.cs b/src/Caching/StackExchangeRedis/test/OutputCache/OutputCacheGetSetTests.cs new file mode 100644 index 000000000000..088d7c76822d --- /dev/null +++ b/src/Caching/StackExchangeRedis/test/OutputCache/OutputCacheGetSetTests.cs @@ -0,0 +1,449 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#if NET7_0_OR_GREATER + +using System; +using System.Buffers; +using System.IO.Pipelines; +using System.Runtime.CompilerServices; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.OutputCaching; +using Pipelines.Sockets.Unofficial.Buffers; +using StackExchange.Redis; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.Extensions.Caching.StackExchangeRedis; + +public class OutputCacheGetSetTests : IClassFixture +{ + private const string SkipReason = "TODO: Disabled due to CI failure. " + + "These tests require Redis server to be started on the machine. Make sure to change the value of" + + "\"RedisTestConfig.RedisPort\" accordingly."; + + private readonly IOutputCacheBufferStore _cache; + private readonly RedisConnectionFixture _fixture; + private readonly ITestOutputHelper Log; + + public OutputCacheGetSetTests(RedisConnectionFixture connection, ITestOutputHelper log) + { + _fixture = connection; + _cache = new RedisOutputCacheStore(new RedisCacheOptions + { + ConnectionMultiplexerFactory = () => Task.FromResult(_fixture.Connection), + InstanceName = "TestPrefix", + }) + { + GarbageCollectionEnabled = false, + }; + Log = log; + } + +#if DEBUG + private async ValueTask Cache() + { + if (_cache is RedisOutputCacheStore real) + { + Log.WriteLine(await real.GetConfigurationInfoAsync().ConfigureAwait(false)); + } + return _cache; + } +#else + private ValueTask Cache() => new(_cache); // avoid CS1998 - no "await" +#endif + + [Fact(Skip = SkipReason)] + public async Task GetMissingKeyReturnsNull() + { + var cache = await Cache().ConfigureAwait(false); + var result = await cache.GetAsync("non-existent-key", CancellationToken.None); + Assert.Null(result); + } + + [Theory(Skip = SkipReason)] + [InlineData(true, false)] + [InlineData(true, true)] + [InlineData(false, false)] + [InlineData(false, true)] + public async Task SetStoresValueWithPrefixAndTimeout(bool useReadOnlySequence, bool withTags) + { + var cache = await Cache().ConfigureAwait(false); + var key = Guid.NewGuid().ToString(); + byte[] storedValue = new byte[1017]; + Random.Shared.NextBytes(storedValue); + RedisKey underlyingKey = "TestPrefix__MSOCV_" + key; + + // pre-check + await _fixture.Database.KeyDeleteAsync(new RedisKey[] { "TestPrefix__MSOCT", "TestPrefix__MSOCT_tagA", "TestPrefix__MSOCT_tagB" }); + var timeout = await _fixture.Database.KeyTimeToLiveAsync(underlyingKey); + Assert.Null(timeout); // means doesn't exist + Assert.False(await _fixture.Database.KeyExistsAsync("TestPrefix__MSOCT")); + Assert.False(await _fixture.Database.KeyExistsAsync("TestPrefix__MSOCT_tagA")); + Assert.False(await _fixture.Database.KeyExistsAsync("TestPrefix__MSOCT_tagB")); + + // act + var actTime = DateTime.UtcNow; + var ttl = TimeSpan.FromSeconds(30); + var tags = withTags ? new[] { "tagA", "tagB" } : null; + if (useReadOnlySequence) + { + await cache.SetAsync(key, new ReadOnlySequence(storedValue), tags, ttl, CancellationToken.None); + } + else + { + await cache.SetAsync(key, storedValue, tags, ttl, CancellationToken.None); + } + + // validate via redis direct + timeout = await _fixture.Database.KeyTimeToLiveAsync(underlyingKey); + Assert.NotNull(timeout); // means exists + var seconds = timeout.Value.TotalSeconds; + Assert.True(seconds >= 28 && seconds <= 32, "timeout should be in range"); + var redisValue = (byte[])(await _fixture.Database.StringGetAsync(underlyingKey)); + Assert.True(((ReadOnlySpan)storedValue).SequenceEqual(redisValue), "payload should match"); + + double expected = (long)((actTime + ttl) - DateTime.UnixEpoch).TotalMilliseconds; + if (withTags) + { + // we expect the tag structure to now exist, with the scores within a bit of a second + const double Tolerance = 100.0; + Assert.Equal((await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT", "tagA")).Value, expected, Tolerance); + Assert.Equal((await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT", "tagB")).Value, expected, Tolerance); + Assert.Equal((await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT_tagA", key)).Value, expected, Tolerance); + Assert.Equal((await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT_tagB", key)).Value, expected, Tolerance); + } + else + { + // we do *not* expect the tag structure to exist + Assert.False(await _fixture.Database.KeyExistsAsync("TestPrefix__MSOCT")); + Assert.False(await _fixture.Database.KeyExistsAsync("TestPrefix__MSOCT_tagA")); + Assert.False(await _fixture.Database.KeyExistsAsync("TestPrefix__MSOCT_tagB")); + } + } + + [Theory(Skip = SkipReason)] + [InlineData(true)] + [InlineData(false)] + public async Task CanFetchStoredValue(bool useReadOnlySequence) + { + var cache = await Cache().ConfigureAwait(false); + var key = Guid.NewGuid().ToString(); + byte[] storedValue = new byte[1017]; + Random.Shared.NextBytes(storedValue); + + // pre-check + var fetchedValue = await cache.GetAsync(key, CancellationToken.None); + Assert.Null(fetchedValue); + + // store and fetch via service + if (useReadOnlySequence) + { + await cache.SetAsync(key, new ReadOnlySequence(storedValue), null, TimeSpan.FromSeconds(30), CancellationToken.None); + } + else + { + await cache.SetAsync(key, storedValue, null, TimeSpan.FromSeconds(30), CancellationToken.None); + } + fetchedValue = await cache.GetAsync(key, CancellationToken.None); + Assert.NotNull(fetchedValue); + + Assert.True(((ReadOnlySpan)storedValue).SequenceEqual(fetchedValue), "payload should match"); + } + + [Fact(Skip = SkipReason)] + public async Task GetMissingKeyReturnsFalse_BufferWriter() // "true" result checked in MultiSegmentWriteWorksAsExpected_BufferWriter + { + var cache = Assert.IsAssignableFrom(await Cache().ConfigureAwait(false)); + var key = Guid.NewGuid().ToString(); + + var pipe = new Pipe(); + Assert.False(await cache.TryGetAsync(key, pipe.Writer, CancellationToken.None)); + pipe.Writer.Complete(); + var read = await pipe.Reader.ReadAsync(); + Assert.True(read.IsCompleted); + Assert.True(read.Buffer.IsEmpty); + pipe.Reader.AdvanceTo(read.Buffer.End); + } + + [Fact(Skip = SkipReason)] + public async Task MasterTagScoreShouldOnlyIncrease() + { + // store some data + var cache = await Cache().ConfigureAwait(false); + byte[] storedValue = new byte[1017]; + Random.Shared.NextBytes(storedValue); + var tags = new[] { "gtonly" }; + await _fixture.Database.KeyDeleteAsync("TestPrefix__MSOCT"); // start from nil state + + await cache.SetAsync(Guid.NewGuid().ToString(), storedValue, tags, TimeSpan.FromSeconds(30), CancellationToken.None); + var originalScore = await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT", "gtonly"); + Assert.NotNull(originalScore); + + // now store something with a shorter ttl; the score should not change + await cache.SetAsync(Guid.NewGuid().ToString(), storedValue, tags, TimeSpan.FromSeconds(15), CancellationToken.None); + var newScore = await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT", "gtonly"); + Assert.NotNull(newScore); + Assert.Equal(originalScore, newScore); + + // now store something with a longer ttl; the score should increase + await cache.SetAsync(Guid.NewGuid().ToString(), storedValue, tags, TimeSpan.FromSeconds(45), CancellationToken.None); + newScore = await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT", "gtonly"); + Assert.NotNull(newScore); + Assert.True(newScore > originalScore, "should increase"); + } + + [Fact(Skip = SkipReason)] + public async Task CanEvictByTag() + { + // store some data + var cache = await Cache().ConfigureAwait(false); + byte[] storedValue = new byte[1017]; + Random.Shared.NextBytes(storedValue); + var ttl = TimeSpan.FromSeconds(30); + + var noTags = Guid.NewGuid().ToString(); + await cache.SetAsync(noTags, storedValue, null, ttl, CancellationToken.None); + + var foo = Guid.NewGuid().ToString(); + await cache.SetAsync(foo, storedValue, new[] { "foo" }, ttl, CancellationToken.None); + + var bar = Guid.NewGuid().ToString(); + await cache.SetAsync(bar, storedValue, new[] { "bar" }, ttl, CancellationToken.None); + + var fooBar = Guid.NewGuid().ToString(); + await cache.SetAsync(fooBar, storedValue, new[] { "foo", "bar" }, ttl, CancellationToken.None); + + // assert prior state + Assert.NotNull(await cache.GetAsync(noTags, CancellationToken.None)); + Assert.NotNull(await cache.GetAsync(foo, CancellationToken.None)); + Assert.NotNull(await cache.GetAsync(bar, CancellationToken.None)); + Assert.NotNull(await cache.GetAsync(fooBar, CancellationToken.None)); + Assert.Null(await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT_foo", noTags)); + Assert.NotNull(await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT_foo", foo)); + Assert.Null(await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT_foo", bar)); + Assert.NotNull(await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT_foo", fooBar)); + Assert.Null(await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT_bar", noTags)); + Assert.Null(await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT_bar", foo)); + Assert.NotNull(await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT_bar", bar)); + Assert.NotNull(await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT_bar", fooBar)); + + // act + for (int i = 0; i < 2; i++) // loop is to ensure no oddity when working on tags that *don't* have entries + { + await cache.EvictByTagAsync("foo", CancellationToken.None); + Assert.NotNull(await cache.GetAsync(noTags, CancellationToken.None)); + Assert.Null(await cache.GetAsync(foo, CancellationToken.None)); + Assert.NotNull(await cache.GetAsync(bar, CancellationToken.None)); + Assert.Null(await cache.GetAsync(fooBar, CancellationToken.None)); + } + Assert.Null(await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT_foo", noTags)); + Assert.Null(await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT_foo", foo)); + Assert.Null(await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT_foo", bar)); + Assert.Null(await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT_foo", fooBar)); + Assert.Null(await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT_bar", noTags)); + Assert.Null(await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT_bar", foo)); + Assert.NotNull(await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT_bar", bar)); + Assert.NotNull(await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT_bar", fooBar)); + + for (int i = 0; i < 2; i++) // loop is to ensure no oddity when working on tags that *don't* have entries + { + await cache.EvictByTagAsync("bar", CancellationToken.None); + Assert.NotNull(await cache.GetAsync(noTags, CancellationToken.None)); + Assert.Null(await cache.GetAsync(foo, CancellationToken.None)); + Assert.Null(await cache.GetAsync(bar, CancellationToken.None)); + Assert.Null(await cache.GetAsync(fooBar, CancellationToken.None)); + } + Assert.Null(await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT_foo", noTags)); + Assert.Null(await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT_foo", foo)); + Assert.Null(await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT_foo", bar)); + Assert.Null(await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT_foo", fooBar)); + Assert.Null(await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT_bar", noTags)); + Assert.Null(await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT_bar", foo)); + Assert.Null(await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT_bar", bar)); + Assert.Null(await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT_bar", fooBar)); + + // assert expected state + Assert.NotNull(await cache.GetAsync(noTags, CancellationToken.None)); + Assert.Null(await cache.GetAsync(foo, CancellationToken.None)); + Assert.Null(await cache.GetAsync(bar, CancellationToken.None)); + Assert.Null(await cache.GetAsync(fooBar, CancellationToken.None)); + } + + [Fact(Skip = SkipReason)] + public async Task MultiSegmentWriteWorksAsExpected_Array() + { + // store some data + var first = new Segment(1024, null); + var second = new Segment(1024, first); + var third = new Segment(1024, second); + + Random.Shared.NextBytes(first.Array); + Random.Shared.NextBytes(second.Array); + Random.Shared.NextBytes(third.Array); + var payload = new ReadOnlySequence(first, 800, third, 42); + Assert.False(payload.IsSingleSegment, "multi-segment"); + Assert.Equal(1290, payload.Length); // partial from first and last + + var cache = await Cache().ConfigureAwait(false); + var key = Guid.NewGuid().ToString(); + await cache.SetAsync(key, payload, default, TimeSpan.FromSeconds(30), CancellationToken.None); + + var fetched = await cache.GetAsync(key, CancellationToken.None); + Assert.NotNull(fetched); + ReadOnlyMemory linear = payload.ToArray(); + Assert.True(linear.Span.SequenceEqual(fetched), "payload match"); + } + + [Fact(Skip = SkipReason)] + public async Task MultiSegmentWriteWorksAsExpected_BufferWriter() + { + // store some data + var first = new Segment(1024, null); + var second = new Segment(1024, first); + var third = new Segment(1024, second); + + Random.Shared.NextBytes(first.Array); + Random.Shared.NextBytes(second.Array); + Random.Shared.NextBytes(third.Array); + var payload = new ReadOnlySequence(first, 800, third, 42); + Assert.False(payload.IsSingleSegment, "multi-segment"); + Assert.Equal(1290, payload.Length); // partial from first and last + + var cache = Assert.IsAssignableFrom(await Cache().ConfigureAwait(false)); + var key = Guid.NewGuid().ToString(); + await cache.SetAsync(key, payload, default, TimeSpan.FromSeconds(30), CancellationToken.None); + + var pipe = new Pipe(); + Assert.True(await cache.TryGetAsync(key, pipe.Writer, CancellationToken.None)); + pipe.Writer.Complete(); + var read = await pipe.Reader.ReadAsync(); + Assert.True(read.IsCompleted); + Assert.Equal(1290, read.Buffer.Length); + + using (Linearize(payload, out var linearPayload)) + using (Linearize(read.Buffer, out var linearRead)) + { + Assert.True(linearPayload.Span.SequenceEqual(linearRead.Span), "payload match"); + } + pipe.Reader.AdvanceTo(read.Buffer.End); + + static IMemoryOwner Linearize(ReadOnlySequence payload, out ReadOnlyMemory linear) + { + if (payload.IsEmpty) + { + linear = default; + return null; + } + if (payload.IsSingleSegment) + { + linear = payload.First; + return null; + } + var len = checked((int)payload.Length); + var lease = MemoryPool.Shared.Rent(len); + var memory = lease.Memory.Slice(0, len); + payload.CopyTo(memory.Span); + linear = memory; + return lease; + } + } + + [Fact(Skip = SkipReason)] + public async Task GarbageCollectionDoesNotRunWhenGCKeyHeld() + { + var cache = await Cache().ConfigureAwait(false); + var impl = Assert.IsAssignableFrom(cache); + await _fixture.Database.StringSetAsync("TestPrefix__MSOCTGC", "dummy", TimeSpan.FromMinutes(1)); + try + { + Assert.Null(await impl.ExecuteGarbageCollectionAsync(42)); + } + finally + { + await _fixture.Database.KeyDeleteAsync("TestPrefix__MSOCTGC"); + } + } + + [Fact(Skip = SkipReason)] + public async Task GarbageCollectionCleansUpTagData() + { + // importantly, we're not interested in the lifetime of the *values* - redis deals with that + // itself; we're only interested in the tag-expiry metadata + var blob = new byte[16]; + Random.Shared.NextBytes(blob); + var cache = await Cache().ConfigureAwait(false); + var impl = Assert.IsAssignableFrom(cache); + + // start vanilla + await _fixture.Database.KeyDeleteAsync(new RedisKey[] { "TestPrefix__MSOCT", + "TestPrefix__MSOCT_a", "TestPrefix__MSOCT_b", + "TestPrefix__MSOCT_c", "TestPrefix__MSOCT_d" }); + + await cache.SetAsync(Guid.NewGuid().ToString(), blob, new[] { "a", "b" }, TimeSpan.FromSeconds(5), CancellationToken.None); // a=b=5 + await cache.SetAsync(Guid.NewGuid().ToString(), blob, new[] { "b", "c" }, TimeSpan.FromSeconds(10), CancellationToken.None); // a=5, b=c=10 + await cache.SetAsync(Guid.NewGuid().ToString(), blob, new[] { "c", "d" }, TimeSpan.FromSeconds(15), CancellationToken.None); // a=5, b=10, c=d=15 + + long aScore = (long)await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT", "a"), + bScore = (long)await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT", "b"), + cScore = (long)await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT", "c"), + dScore = (long)await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT", "d"); + + Assert.False(await _fixture.Database.KeyExistsAsync("TestPrefix__MSOCTGC"), "GC key should not exist"); + Assert.True(await _fixture.Database.KeyExistsAsync("TestPrefix__MSOCT"), "master tag should exist"); + Assert.True(await _fixture.Database.KeyExistsAsync("TestPrefix__MSOCT_a"), "tag a should exist"); + Assert.True(await _fixture.Database.KeyExistsAsync("TestPrefix__MSOCT_b"), "tag b should exist"); + Assert.True(await _fixture.Database.KeyExistsAsync("TestPrefix__MSOCT_c"), "tag c should exist"); + Assert.True(await _fixture.Database.KeyExistsAsync("TestPrefix__MSOCT_d"), "tag d should exist"); + + await CheckCounts(4, 1, 2, 2, 1); + Assert.Equal(0, await impl.ExecuteGarbageCollectionAsync(0)); // should not change anything + await CheckCounts(4, 1, 2, 2, 1); + Assert.Equal(1, await impl.ExecuteGarbageCollectionAsync(aScore)); // 1=removes a + await CheckCounts(3, 0, 1, 2, 1); + Assert.Equal(1, await impl.ExecuteGarbageCollectionAsync(bScore)); // 1=removes b + await CheckCounts(2, 0, 0, 1, 1); + Assert.Equal(2, await impl.ExecuteGarbageCollectionAsync(cScore)); // 2=removes c+d + await CheckCounts(0, 0, 0, 0, 0); + Assert.Equal(0, await impl.ExecuteGarbageCollectionAsync(dScore)); + await CheckCounts(0, 0, 0, 0, 0); + Assert.Equal(0, await impl.ExecuteGarbageCollectionAsync(dScore + 1000)); // should have nothing left to do + await CheckCounts(0, 0, 0, 0, 0); + + // we should now not have any of these left + Assert.False(await _fixture.Database.KeyExistsAsync("TestPrefix__MSOCTGC"), "GC key should not exist"); + Assert.False(await _fixture.Database.KeyExistsAsync("TestPrefix__MSOCT"), "master tag still exists"); + Assert.False(await _fixture.Database.KeyExistsAsync("TestPrefix__MSOCT_a"), "tag a still exists"); + Assert.False(await _fixture.Database.KeyExistsAsync("TestPrefix__MSOCT_b"), "tag b still exists"); + Assert.False(await _fixture.Database.KeyExistsAsync("TestPrefix__MSOCT_c"), "tag c still exists"); + Assert.False(await _fixture.Database.KeyExistsAsync("TestPrefix__MSOCT_d"), "tag d still exists"); + + async Task CheckCounts(int master, int a, int b, int c, int d) + { + Assert.Equal(master, (int)await _fixture.Database.SortedSetLengthAsync("TestPrefix__MSOCT")); + Assert.Equal(a, (int)await _fixture.Database.SortedSetLengthAsync("TestPrefix__MSOCT_a")); + Assert.Equal(b, (int)await _fixture.Database.SortedSetLengthAsync("TestPrefix__MSOCT_b")); + Assert.Equal(c, (int)await _fixture.Database.SortedSetLengthAsync("TestPrefix__MSOCT_c")); + Assert.Equal(d, (int)await _fixture.Database.SortedSetLengthAsync("TestPrefix__MSOCT_d")); + } + } + + private class Segment : ReadOnlySequenceSegment + { + public Segment(int length, Segment previous = null) + { + if (previous is not null) + { + previous.Next = this; + RunningIndex = previous.RunningIndex + previous.Memory.Length; + } + Array = new byte[length]; + Memory = Array; + } + public byte[] Array { get; } + } +} + +#endif diff --git a/src/Caching/StackExchangeRedis/test/OutputCache/OutputCacheServiceExtensionsTests.cs b/src/Caching/StackExchangeRedis/test/OutputCache/OutputCacheServiceExtensionsTests.cs new file mode 100644 index 000000000000..37efde7c19da --- /dev/null +++ b/src/Caching/StackExchangeRedis/test/OutputCache/OutputCacheServiceExtensionsTests.cs @@ -0,0 +1,129 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#if NET7_0_OR_GREATER + +using System.Linq; +using Microsoft.AspNetCore.OutputCaching; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Microsoft.Extensions.Caching.StackExchangeRedis; + +public class OutputCacheServiceExtensionsTests +{ + [Fact] + public void AddStackExchangeRedisOutputCache_RegistersOutputCacheStoreAsSingleton() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddStackExchangeRedisOutputCache(options => { }); + + // Assert + var outputCacheStore = services.FirstOrDefault(desc => desc.ServiceType == typeof(IOutputCacheStore)); + + Assert.NotNull(outputCacheStore); + Assert.Equal(ServiceLifetime.Singleton, outputCacheStore.Lifetime); + } + + [Fact] + public void AddStackExchangeRedisOutputCache_ReplacesPreviouslyUserRegisteredServices() + { + // Arrange + var services = new ServiceCollection(); + services.AddScoped(typeof(IOutputCacheStore), sp => Mock.Of()); + + // Act + services.AddStackExchangeRedisOutputCache(options => { }); + + // Assert + var serviceProvider = services.BuildServiceProvider(); + + var distributedCache = services.FirstOrDefault(desc => desc.ServiceType == typeof(IOutputCacheStore)); + + Assert.NotNull(distributedCache); + Assert.Equal(ServiceLifetime.Scoped, distributedCache.Lifetime); + Assert.IsAssignableFrom(serviceProvider.GetRequiredService()); + } + + [Fact] + public void AddStackExchangeRedisOutputCache_AllowsChaining() + { + var services = new ServiceCollection(); + + Assert.Same(services, services.AddStackExchangeRedisOutputCache(_ => { })); + } + + [Fact] + public void AddStackExchangeRedisOutputCache_IOutputCacheStoreWithoutLoggingCanBeResolved() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddStackExchangeRedisOutputCache(options => { }); + + // Assert + using var serviceProvider = services.BuildServiceProvider(); + var outputCacheStore = serviceProvider.GetRequiredService(); + + Assert.NotNull(outputCacheStore); + } + + [Fact] + public void AddStackExchangeRedisOutputCache_IOutputCacheStoreWithLoggingCanBeResolved() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddStackExchangeRedisOutputCache(options => { }); + services.AddLogging(); + + // Assert + using var serviceProvider = services.BuildServiceProvider(); + var outputCacheStore = serviceProvider.GetRequiredService(); + + Assert.NotNull(outputCacheStore); + } + + [Fact] + public void AddStackExchangeRedisOutputCache_UsesLoggerFactoryAlreadyRegisteredWithServiceCollection() + { + // Arrange + var services = new ServiceCollection(); + services.AddScoped(typeof(IOutputCacheStore), sp => Mock.Of()); + + var loggerFactory = new Mock(); + + loggerFactory + .Setup(lf => lf.CreateLogger(It.IsAny())) + .Returns((string name) => NullLoggerFactory.Instance.CreateLogger(name)) + .Verifiable(); + + services.AddScoped(typeof(ILoggerFactory), _ => loggerFactory.Object); + + // Act + services.AddLogging(); + services.AddStackExchangeRedisOutputCache(options => { }); + + // Assert + var serviceProvider = services.BuildServiceProvider(); + + var distributedCache = services.FirstOrDefault(desc => desc.ServiceType == typeof(IOutputCacheStore)); + + Assert.NotNull(distributedCache); + Assert.Equal(ServiceLifetime.Scoped, distributedCache.Lifetime); + Assert.IsAssignableFrom(serviceProvider.GetRequiredService()); + + loggerFactory.Verify(); + } +} + +#endif diff --git a/src/Caching/StackExchangeRedis/test/OutputCache/RedisConnectionFixture.cs b/src/Caching/StackExchangeRedis/test/OutputCache/RedisConnectionFixture.cs new file mode 100644 index 000000000000..6188beb0d691 --- /dev/null +++ b/src/Caching/StackExchangeRedis/test/OutputCache/RedisConnectionFixture.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#if NET7_0_OR_GREATER + +using System; +using StackExchange.Redis; +using Xunit; + +namespace Microsoft.Extensions.Caching.StackExchangeRedis; + +public class RedisConnectionFixture : IDisposable +{ + private readonly ConnectionMultiplexer _muxer; + public RedisConnectionFixture() + { + _muxer = ConnectionMultiplexer.Connect("127.0.0.1:6379"); + } + + public IDatabase Database => _muxer.GetDatabase(); + + public IConnectionMultiplexer Connection => _muxer; + + public void Dispose() => _muxer.Dispose(); +} + +#endif diff --git a/src/Components/Authorization/test/AuthorizeRouteViewTest.cs b/src/Components/Authorization/test/AuthorizeRouteViewTest.cs index 05609873187d..14823588baf8 100644 --- a/src/Components/Authorization/test/AuthorizeRouteViewTest.cs +++ b/src/Components/Authorization/test/AuthorizeRouteViewTest.cs @@ -34,12 +34,11 @@ public AuthorizeRouteViewTest() serviceCollection.AddSingleton(); serviceCollection.AddSingleton(_testAuthorizationService); serviceCollection.AddSingleton(); - serviceCollection.AddSingleton(); var services = serviceCollection.BuildServiceProvider(); _renderer = new TestRenderer(services); var componentFactory = new ComponentFactory(new DefaultComponentActivator(), _renderer); - _authorizeRouteViewComponent = (AuthorizeRouteView)componentFactory.InstantiateComponent(services, typeof(AuthorizeRouteView), null); + _authorizeRouteViewComponent = (AuthorizeRouteView)componentFactory.InstantiateComponent(services, typeof(AuthorizeRouteView), null, null); _authorizeRouteViewComponentId = _renderer.AssignRootComponentId(_authorizeRouteViewComponent); } diff --git a/src/Components/Components/src/Binding/CascadingModelBinder.cs b/src/Components/Components/src/Binding/CascadingModelBinder.cs index 994e8430c3d6..0b1b0e2a5d4e 100644 --- a/src/Components/Components/src/Binding/CascadingModelBinder.cs +++ b/src/Components/Components/src/Binding/CascadingModelBinder.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics.CodeAnalysis; +using System.Linq; using System.Reflection.Metadata; using Microsoft.AspNetCore.Components.Binding; using Microsoft.AspNetCore.Components.Rendering; @@ -11,12 +13,13 @@ namespace Microsoft.AspNetCore.Components; /// /// Defines the binding context for data bound from external sources. /// -public sealed class CascadingModelBinder : IComponent, ICascadingValueComponent, IDisposable +public sealed class CascadingModelBinder : IComponent, ICascadingValueSupplier, IDisposable { + private readonly Dictionary _providersByCascadingParameterAttributeType = new(); + private RenderHandle _handle; private ModelBindingContext? _bindingContext; private bool _hasPendingQueuedRender; - private BindingInfo? _bindingInfo; /// /// The binding context name. @@ -40,7 +43,9 @@ public sealed class CascadingModelBinder : IComponent, ICascadingValueComponent, [Inject] internal NavigationManager Navigation { get; set; } = null!; - [Inject] internal IFormValueSupplier FormValueSupplier { get; set; } = null!; + [Inject] internal IEnumerable ModelBindingProviders { get; set; } = Enumerable.Empty(); + + internal ModelBindingContext? BindingContext => _bindingContext; void IComponent.Attach(RenderHandle renderHandle) { @@ -110,24 +115,25 @@ internal void UpdateBindingInformation(string url) // BindingContextId = <>((<>&)|?)handler=my-handler var name = ModelBindingContext.Combine(ParentContext, Name); var bindingId = string.IsNullOrEmpty(name) ? "" : GenerateBindingContextId(name); + var bindingContextDidChange = + _bindingContext is null || + !string.Equals(_bindingContext.Name, name, StringComparison.Ordinal) || + !string.Equals(_bindingContext.BindingContextId, bindingId, StringComparison.Ordinal); - var bindingContext = _bindingContext != null && - string.Equals(_bindingContext.Name, name, StringComparison.Ordinal) && - string.Equals(_bindingContext.BindingContextId, bindingId, StringComparison.Ordinal) ? - _bindingContext : new ModelBindingContext(name, bindingId, FormValueSupplier.CanConvertSingleValue); - - // It doesn't matter that we don't check IsFixed, since the CascadingValue we are setting up will throw if the app changes. - if (IsFixed && _bindingContext != null && _bindingContext != bindingContext) + if (bindingContextDidChange) { - // Throw an exception if either the Name or the BindingContextId changed. Once a CascadingModelBinder has been initialized - // as fixed, it can't change it's name nor its BindingContextId. This can happen in several situations: - // * Component ParentContext hierarchy changes. - // * Technically, the component won't be retained in this case and will be destroyed instead. - // * A parent changes Name. - throw new InvalidOperationException($"'{nameof(CascadingModelBinder)}' 'Name' can't change after initialized."); - } + if (IsFixed && _bindingContext is not null) + { + // Throw an exception if either the Name or the BindingContextId changed. Once a CascadingModelBinder has been initialized + // as fixed, it can't change it's name nor its BindingContextId. This can happen in several situations: + // * Component ParentContext hierarchy changes. + // * Technically, the component won't be retained in this case and will be destroyed instead. + // * A parent changes Name. + throw new InvalidOperationException($"'{nameof(CascadingModelBinder)}' 'Name' can't change after initialized."); + } - _bindingContext = bindingContext; + _bindingContext = new ModelBindingContext(name, bindingId, CanBind); + } string GenerateBindingContextId(string name) { @@ -135,60 +141,92 @@ string GenerateBindingContextId(string name) var hashIndex = bindingId.IndexOf('#'); return hashIndex == -1 ? bindingId : new string(bindingId.AsSpan(0, hashIndex)); } + + bool CanBind(Type type) + { + foreach (var provider in ModelBindingProviders) + { + if (provider.SupportsParameterType(type)) + { + return true; + } + } + + return false; + } } - void IDisposable.Dispose() + bool ICascadingValueSupplier.CanSupplyValue(in CascadingParameterInfo parameterInfo) + => TryGetProvider(in parameterInfo, out var provider) + && provider.CanSupplyValue(_bindingContext, parameterInfo); + + void ICascadingValueSupplier.Subscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo) { - Navigation.LocationChanged -= HandleLocationChanged; + // We expect there to always be a provider at this point, because CanSupplyValue must have returned true. + var provider = GetProviderOrThrow(parameterInfo); + + if (!provider.AreValuesFixed) + { + provider.Subscribe(subscriber); + } } - bool ICascadingValueComponent.CanSupplyValue(Type valueType, string? valueName) + void ICascadingValueSupplier.Unsubscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo) { - var formName = string.IsNullOrEmpty(valueName) ? - (_bindingContext?.Name) : - ModelBindingContext.Combine(_bindingContext, valueName); + // We expect there to always be a provider at this point, because CanSupplyValue must have returned true. + var provider = GetProviderOrThrow(parameterInfo); - if (_bindingInfo != null && - string.Equals(_bindingInfo.FormName, formName, StringComparison.Ordinal) && - _bindingInfo.ValueType.Equals(valueType)) + if (!provider.AreValuesFixed) { - // We already bound the value, but some component might have been destroyed and - // re-created. If the type and name of the value that we bound are the same, - // we can provide the value that we bound. - return true; + provider.Unsubscribe(subscriber); } + } - // Can't supply the value if this context is for a form with a different name. - if (FormValueSupplier.CanBind(formName!, valueType)) - { - var bindingSucceeded = FormValueSupplier.TryBind(formName!, valueType, out var boundValue); - _bindingInfo = new BindingInfo(formName, valueType, bindingSucceeded, boundValue); - if (!bindingSucceeded) - { - // Report errors - } + object? ICascadingValueSupplier.GetCurrentValue(in CascadingParameterInfo parameterInfo) + => TryGetProvider(in parameterInfo, out var provider) + ? provider.GetCurrentValue(_bindingContext, parameterInfo) + : null; - return true; + private CascadingModelBindingProvider GetProviderOrThrow(in CascadingParameterInfo parameterInfo) + { + if (!TryGetProvider(parameterInfo, out var provider)) + { + throw new InvalidOperationException($"No model binding provider could be found for parameter '{parameterInfo.PropertyName}'."); } - return false; + return provider; } - void ICascadingValueComponent.Subscribe(ComponentState subscriber) + private bool TryGetProvider(in CascadingParameterInfo parameterInfo, [NotNullWhen(true)] out CascadingModelBindingProvider? result) { - throw new InvalidOperationException("Form values are always fixed."); - } + var attributeType = parameterInfo.Attribute.GetType(); - void ICascadingValueComponent.Unsubscribe(ComponentState subscriber) - { - throw new InvalidOperationException("Form values are always fixed."); - } + if (_providersByCascadingParameterAttributeType.TryGetValue(attributeType, out result)) + { + return result is not null; + } - object? ICascadingValueComponent.CurrentValue => _bindingInfo == null ? - throw new InvalidOperationException("Tried to access form value before it was bound.") : - _bindingInfo.BoundValue; + // We deliberately cache 'null' results to avoid searching for the same attribute type multiple times. + result = FindProviderForAttributeType(attributeType); + _providersByCascadingParameterAttributeType[attributeType] = result; + return result is not null; - bool ICascadingValueComponent.CurrentValueIsFixed => true; + CascadingModelBindingProvider? FindProviderForAttributeType(Type attributeType) + { + foreach (var provider in ModelBindingProviders) + { + if (provider.SupportsCascadingParameterAttributeType(attributeType)) + { + return provider; + } + } + + return null; + } + } - private record BindingInfo(string? FormName, Type ValueType, bool BindingResult, object? BoundValue); + void IDisposable.Dispose() + { + Navigation.LocationChanged -= HandleLocationChanged; + } } diff --git a/src/Components/Components/src/Binding/CascadingModelBindingProvider.cs b/src/Components/Components/src/Binding/CascadingModelBindingProvider.cs new file mode 100644 index 000000000000..4b75e496a3ce --- /dev/null +++ b/src/Components/Components/src/Binding/CascadingModelBindingProvider.cs @@ -0,0 +1,69 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Components.Rendering; + +namespace Microsoft.AspNetCore.Components.Binding; + +/// +/// Provides values that get supplied to cascading parameters with . +/// +public abstract class CascadingModelBindingProvider +{ + /// + /// Gets whether values supplied by this instance will not change. + /// + protected internal abstract bool AreValuesFixed { get; } + + /// + /// Determines whether this instance can provide values for parameters annotated with the specified attribute type. + /// + /// The attribute type. + /// true if this instance can provide values for parameters annotated with the specified attribute type, otherwise false. + protected internal abstract bool SupportsCascadingParameterAttributeType(Type attributeType); + + /// + /// Determines whether this instance can provide values to parameters with the specified type. + /// + /// The parameter type. + /// true if this instance can provide values to parameters with the specified type, otherwise false. + protected internal abstract bool SupportsParameterType(Type parameterType); + + /// + /// Determines whether this instance can supply a value for the specified parameter. + /// + /// The current . + /// The for the component parameter. + /// true if a value can be supplied, otherwise false. + protected internal abstract bool CanSupplyValue(ModelBindingContext? bindingContext, in CascadingParameterInfo parameterInfo); + + /// + /// Gets the value for the specified parameter. + /// + /// The current . + /// The for the component parameter. + /// The value to supply to the parameter. + protected internal abstract object? GetCurrentValue(ModelBindingContext? bindingContext, in CascadingParameterInfo parameterInfo); + + /// + /// Subscribes to changes in supplied values, if they can change. + /// + /// + /// This method must be implemented if is false. + /// + /// The for the subscribing component. + protected internal virtual void Subscribe(ComponentState subscriber) + => throw new InvalidOperationException( + $"'{nameof(CascadingModelBindingProvider)}' instances that have non-fixed values must provide an implementation for '{nameof(Subscribe)}'."); + + /// + /// Unsubscribes from changes in supplied values, if they can change. + /// + /// + /// This method must be implemented if is false. + /// + /// The for the unsubscribing component. + protected internal virtual void Unsubscribe(ComponentState subscriber) + => throw new InvalidOperationException( + $"'{nameof(CascadingModelBindingProvider)}' instances that have non-fixed values must provide an implementation for '{nameof(Unsubscribe)}'."); +} diff --git a/src/Components/Components/src/Binding/CascadingQueryModelBindingProvider.cs b/src/Components/Components/src/Binding/CascadingQueryModelBindingProvider.cs new file mode 100644 index 000000000000..e6e9c976016b --- /dev/null +++ b/src/Components/Components/src/Binding/CascadingQueryModelBindingProvider.cs @@ -0,0 +1,138 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Components.Rendering; +using Microsoft.AspNetCore.Components.Routing; + +namespace Microsoft.AspNetCore.Components.Binding; + +/// +/// Enables component parameters to be supplied from the query string with . +/// +public sealed class CascadingQueryModelBindingProvider : CascadingModelBindingProvider, IDisposable +{ + private readonly QueryParameterValueSupplier _queryParameterValueSupplier = new(); + private readonly NavigationManager _navigationManager; + + private HashSet? _subscribers; + private bool _isSubscribedToLocationChanges; + private bool _queryParametersMightHaveChanged = true; + + /// + protected internal override bool AreValuesFixed => false; + + /// + /// Constructs a new instance of . + /// + public CascadingQueryModelBindingProvider(NavigationManager navigationManager) + { + _navigationManager = navigationManager; + } + + /// + protected internal override bool SupportsCascadingParameterAttributeType(Type attributeType) + => attributeType == typeof(SupplyParameterFromQueryAttribute); + + /// + protected internal override bool SupportsParameterType(Type type) + => QueryParameterValueSupplier.CanSupplyValueForType(type); + + /// + protected internal override bool CanSupplyValue(ModelBindingContext? bindingContext, in CascadingParameterInfo parameterInfo) + // We can always supply a value; it'll just be null if there's no match. + => true; + + /// + protected internal override object? GetCurrentValue(ModelBindingContext? bindingContext, in CascadingParameterInfo parameterInfo) + { + if (_queryParametersMightHaveChanged) + { + _queryParametersMightHaveChanged = false; + UpdateQueryParameters(); + } + + var queryParameterName = parameterInfo.Attribute.Name ?? parameterInfo.PropertyName; + return _queryParameterValueSupplier.GetQueryParameterValue(parameterInfo.PropertyType, queryParameterName); + } + + /// + protected internal override void Subscribe(ComponentState subscriber) + { + SubscribeToLocationChanges(); + + _subscribers ??= new(); + _subscribers.Add(subscriber); + } + + /// + protected internal override void Unsubscribe(ComponentState subscriber) + { + _subscribers!.Remove(subscriber); + + if (_subscribers.Count == 0) + { + UnsubscribeFromLocationChanges(); + } + } + + private void UpdateQueryParameters() + { + var query = GetQueryString(_navigationManager.Uri); + + _queryParameterValueSupplier.ReadParametersFromQuery(query); + + static ReadOnlyMemory GetQueryString(string url) + { + var queryStartPos = url.IndexOf('?'); + + if (queryStartPos < 0) + { + return default; + } + + var queryEndPos = url.IndexOf('#', queryStartPos); + return url.AsMemory(queryStartPos..(queryEndPos < 0 ? url.Length : queryEndPos)); + } + } + + private void SubscribeToLocationChanges() + { + if (_isSubscribedToLocationChanges) + { + return; + } + + _isSubscribedToLocationChanges = true; + _queryParametersMightHaveChanged = true; + _navigationManager.LocationChanged += OnLocationChanged; + } + + private void UnsubscribeFromLocationChanges() + { + if (!_isSubscribedToLocationChanges) + { + return; + } + + _isSubscribedToLocationChanges = false; + _navigationManager.LocationChanged -= OnLocationChanged; + } + + private void OnLocationChanged(object? sender, LocationChangedEventArgs args) + { + _queryParametersMightHaveChanged = true; + + if (_subscribers is not null) + { + foreach (var subscriber in _subscribers) + { + subscriber.NotifyCascadingValueChanged(ParameterViewLifetime.Unbound); + } + } + } + + void IDisposable.Dispose() + { + UnsubscribeFromLocationChanges(); + } +} diff --git a/src/Components/Components/src/Binding/IFormValueSupplier.cs b/src/Components/Components/src/Binding/IFormValueSupplier.cs index b8696a224339..4b5403222409 100644 --- a/src/Components/Components/src/Binding/IFormValueSupplier.cs +++ b/src/Components/Components/src/Binding/IFormValueSupplier.cs @@ -6,7 +6,7 @@ namespace Microsoft.AspNetCore.Components.Binding; /// -/// Binds form data valuesto a model. +/// Binds form data values to a model. /// public interface IFormValueSupplier { diff --git a/src/Components/Components/src/CascadingParameterAttribute.cs b/src/Components/Components/src/CascadingParameterAttribute.cs index 70cb5998ff72..bb9be43a5b08 100644 --- a/src/Components/Components/src/CascadingParameterAttribute.cs +++ b/src/Components/Components/src/CascadingParameterAttribute.cs @@ -9,7 +9,7 @@ namespace Microsoft.AspNetCore.Components; /// supplies values with a compatible type and name. /// [AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)] -public sealed class CascadingParameterAttribute : Attribute +public sealed class CascadingParameterAttribute : CascadingParameterAttributeBase { /// /// If specified, the parameter value will be supplied by the closest @@ -20,5 +20,5 @@ public sealed class CascadingParameterAttribute : Attribute /// that supplies a value with a compatible /// type. /// - public string? Name { get; set; } + public override string? Name { get; set; } } diff --git a/src/Components/Components/src/CascadingParameterAttributeBase.cs b/src/Components/Components/src/CascadingParameterAttributeBase.cs new file mode 100644 index 000000000000..f85fbaa6cffc --- /dev/null +++ b/src/Components/Components/src/CascadingParameterAttributeBase.cs @@ -0,0 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Components; + +/// +/// Represents a parameter whose value cascades down the component hierarchy. +/// +public abstract class CascadingParameterAttributeBase : Attribute +{ + /// + /// Gets or sets the name for the parameter, which correlates to the name + /// of a cascading value. + /// + public abstract string? Name { get; set; } +} diff --git a/src/Components/Components/src/CascadingParameterInfo.cs b/src/Components/Components/src/CascadingParameterInfo.cs new file mode 100644 index 000000000000..2d8493ff70f4 --- /dev/null +++ b/src/Components/Components/src/CascadingParameterInfo.cs @@ -0,0 +1,32 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Components; + +/// +/// Contains information about a cascading parameter. +/// +public readonly struct CascadingParameterInfo +{ + /// + /// Gets the property's attribute. + /// + public CascadingParameterAttributeBase Attribute { get; } + + /// + /// Gets the name of the parameter's property. + /// + public string PropertyName { get; } + + /// + /// Gets the type of the parameter's property. + /// + public Type PropertyType { get; } + + internal CascadingParameterInfo(CascadingParameterAttributeBase attribute, string propertyName, Type propertyType) + { + Attribute = attribute; + PropertyName = propertyName; + PropertyType = propertyType; + } +} diff --git a/src/Components/Components/src/CascadingParameterState.cs b/src/Components/Components/src/CascadingParameterState.cs index 7af4dc1b9cce..da3ba05c5dc4 100644 --- a/src/Components/Components/src/CascadingParameterState.cs +++ b/src/Components/Components/src/CascadingParameterState.cs @@ -13,21 +13,21 @@ namespace Microsoft.AspNetCore.Components; internal readonly struct CascadingParameterState { - private static readonly ConcurrentDictionary _cachedInfos = new(); + private static readonly ConcurrentDictionary _cachedInfos = new(); - public string LocalValueName { get; } - public ICascadingValueComponent ValueSupplier { get; } + public CascadingParameterInfo ParameterInfo { get; } + public ICascadingValueSupplier ValueSupplier { get; } - public CascadingParameterState(string localValueName, ICascadingValueComponent valueSupplier) + public CascadingParameterState(in CascadingParameterInfo parameterInfo, ICascadingValueSupplier valueSupplier) { - LocalValueName = localValueName; + ParameterInfo = parameterInfo; ValueSupplier = valueSupplier; } public static IReadOnlyList FindCascadingParameters(ComponentState componentState) { var componentType = componentState.Component.GetType(); - var infos = GetReflectedCascadingParameterInfos(componentType); + var infos = GetCascadingParameterInfos(componentType); // For components known not to have any cascading parameters, bail out early if (infos.Length == 0) @@ -48,90 +48,61 @@ public static IReadOnlyList FindCascadingParameters(Com { // Although not all parameters might be matched, we know the maximum number resultStates ??= new List(infos.Length - infoIndex); - - resultStates.Add(new CascadingParameterState(info.ConsumerValueName, supplier)); + resultStates.Add(new CascadingParameterState(info, supplier)); } } return resultStates ?? (IReadOnlyList)Array.Empty(); } - private static ICascadingValueComponent? GetMatchingCascadingValueSupplier(in ReflectedCascadingParameterInfo info, ComponentState componentState) + private static ICascadingValueSupplier? GetMatchingCascadingValueSupplier(in CascadingParameterInfo info, ComponentState componentState) { var candidate = componentState; do { - if (candidate.Component is ICascadingValueComponent candidateSupplier - && candidateSupplier.CanSupplyValue(info.ValueType, info.SupplierValueName)) + if (candidate.Component is ICascadingValueSupplier valueSupplier && valueSupplier.CanSupplyValue(info)) { - return candidateSupplier; + return valueSupplier; } - candidate = candidate.ParentComponentState; + candidate = candidate.LogicalParentComponentState; } while (candidate != null); // No match return null; } - private static ReflectedCascadingParameterInfo[] GetReflectedCascadingParameterInfos( + private static CascadingParameterInfo[] GetCascadingParameterInfos( [DynamicallyAccessedMembers(Component)] Type componentType) { if (!_cachedInfos.TryGetValue(componentType, out var infos)) { - infos = CreateReflectedCascadingParameterInfos(componentType); + infos = CreateCascadingParameterInfos(componentType); _cachedInfos[componentType] = infos; } return infos; } - private static ReflectedCascadingParameterInfo[] CreateReflectedCascadingParameterInfos( + private static CascadingParameterInfo[] CreateCascadingParameterInfos( [DynamicallyAccessedMembers(Component)] Type componentType) { - List? result = null; + List? result = null; var candidateProps = ComponentProperties.GetCandidateBindableProperties(componentType); foreach (var prop in candidateProps) { - var attribute = prop.GetCustomAttribute(); - if (attribute != null) + var cascadingParameterAttribute = prop.GetCustomAttributes() + .OfType().SingleOrDefault(); + if (cascadingParameterAttribute != null) { - result ??= new List(); - - result.Add(new ReflectedCascadingParameterInfo( - prop.Name, - prop.PropertyType, - attribute.Name)); - } - - var hostParameterAttribute = prop.GetCustomAttributes() - .OfType().SingleOrDefault(); - if (hostParameterAttribute != null) - { - result ??= new List(); - - result.Add(new ReflectedCascadingParameterInfo( + result ??= new List(); + result.Add(new CascadingParameterInfo( + cascadingParameterAttribute, prop.Name, - prop.PropertyType, - hostParameterAttribute.Name)); + prop.PropertyType)); } } - return result?.ToArray() ?? Array.Empty(); - } - - readonly struct ReflectedCascadingParameterInfo - { - public string ConsumerValueName { get; } - public string? SupplierValueName { get; } - public Type ValueType { get; } - - public ReflectedCascadingParameterInfo( - string consumerValueName, Type valueType, string? supplierValueName) - { - ConsumerValueName = consumerValueName; - SupplierValueName = supplierValueName; - ValueType = valueType; - } + return result?.ToArray() ?? Array.Empty(); } } diff --git a/src/Components/Components/src/CascadingValue.cs b/src/Components/Components/src/CascadingValue.cs index facb9821e415..a040894ac57c 100644 --- a/src/Components/Components/src/CascadingValue.cs +++ b/src/Components/Components/src/CascadingValue.cs @@ -8,7 +8,7 @@ namespace Microsoft.AspNetCore.Components; /// /// A component that provides a cascading value to all descendant components. /// -public class CascadingValue : ICascadingValueComponent, IComponent +public class CascadingValue : ICascadingValueSupplier, IComponent { private RenderHandle _renderHandle; private HashSet? _subscribers; // Lazily instantiated @@ -41,10 +41,6 @@ public class CascadingValue : ICascadingValueComponent, IComponent /// [Parameter] public bool IsFixed { get; set; } - object? ICascadingValueComponent.CurrentValue => Value; - - bool ICascadingValueComponent.CurrentValueIsFixed => IsFixed; - /// public void Attach(RenderHandle renderHandle) { @@ -130,37 +126,39 @@ public Task SetParametersAsync(ParameterView parameters) return Task.CompletedTask; } - bool ICascadingValueComponent.CanSupplyValue(Type requestedType, string? requestedName) + bool ICascadingValueSupplier.CanSupplyValue(in CascadingParameterInfo parameterInfo) { - if (!requestedType.IsAssignableFrom(typeof(TValue))) + if (parameterInfo.Attribute is not CascadingParameterAttribute cascadingParameterAttribute || !parameterInfo.PropertyType.IsAssignableFrom(typeof(TValue))) { return false; } + // We only consider explicitly requested names, not the property name. + var requestedName = cascadingParameterAttribute.Name; + return (requestedName == null && Name == null) // Match on type alone || string.Equals(requestedName, Name, StringComparison.OrdinalIgnoreCase); // Also match on name } - void ICascadingValueComponent.Subscribe(ComponentState subscriber) + object? ICascadingValueSupplier.GetCurrentValue(in CascadingParameterInfo parameterInfo) + { + return Value; + } + + void ICascadingValueSupplier.Subscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo) { -#if DEBUG if (IsFixed) { // Should not be possible. User code cannot trigger this. // Checking only to catch possible future framework bugs. throw new InvalidOperationException($"Cannot subscribe to a {typeof(CascadingValue<>).Name} when {nameof(IsFixed)} is true."); } -#endif - - if (_subscribers == null) - { - _subscribers = new HashSet(); - } + _subscribers ??= new HashSet(); _subscribers.Add(subscriber); } - void ICascadingValueComponent.Unsubscribe(ComponentState subscriber) + void ICascadingValueSupplier.Unsubscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo) { _subscribers?.Remove(subscriber); } diff --git a/src/Components/Components/src/ComponentFactory.cs b/src/Components/Components/src/ComponentFactory.cs index e71c61d909e9..b515982a8d1d 100644 --- a/src/Components/Components/src/ComponentFactory.cs +++ b/src/Components/Components/src/ComponentFactory.cs @@ -45,12 +45,26 @@ private static ComponentTypeInfoCacheEntry GetComponentTypeInfo([DynamicallyAcce return cacheEntry; } - public IComponent InstantiateComponent(IServiceProvider serviceProvider, [DynamicallyAccessedMembers(Component)] Type componentType, int? parentComponentId) + public IComponent InstantiateComponent(IServiceProvider serviceProvider, [DynamicallyAccessedMembers(Component)] Type componentType, IComponentRenderMode? callerSpecifiedRenderMode, int? parentComponentId) { - var componentTypeInfo = GetComponentTypeInfo(componentType); - var component = componentTypeInfo.ComponentTypeRenderMode is null - ? _componentActivator.CreateInstance(componentType) - : _renderer.ResolveComponentForRenderMode(componentType, parentComponentId, _componentActivator, componentTypeInfo.ComponentTypeRenderMode); + var (componentTypeRenderMode, propertyInjector) = GetComponentTypeInfo(componentType); + IComponent component; + + if (componentTypeRenderMode is null && callerSpecifiedRenderMode is null) + { + // Typical case where no rendermode is specified in either location. We don't call ResolveComponentForRenderMode in this case. + component = _componentActivator.CreateInstance(componentType); + } + else + { + // At least one rendermode is specified. We require that it's exactly one, and use ResolveComponentForRenderMode with it. + var effectiveRenderMode = callerSpecifiedRenderMode is null + ? componentTypeRenderMode! + : componentTypeRenderMode is null + ? callerSpecifiedRenderMode + : throw new InvalidOperationException($"The component type '{componentType}' has a fixed rendermode of '{componentTypeRenderMode}', so it is not valid to specify any rendermode when using this component."); + component = _renderer.ResolveComponentForRenderMode(componentType, parentComponentId, _componentActivator, effectiveRenderMode); + } if (component is null) { @@ -61,7 +75,7 @@ public IComponent InstantiateComponent(IServiceProvider serviceProvider, [Dynami if (component.GetType() == componentType) { // Fast, common case: use the cached data we already looked up - componentTypeInfo.PerformPropertyInjection(serviceProvider, component); + propertyInjector(serviceProvider, component); } else { diff --git a/src/Components/Components/src/ICascadingValueComponent.cs b/src/Components/Components/src/ICascadingValueComponent.cs deleted file mode 100644 index b18735c86a9e..000000000000 --- a/src/Components/Components/src/ICascadingValueComponent.cs +++ /dev/null @@ -1,22 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Microsoft.AspNetCore.Components.Rendering; - -namespace Microsoft.AspNetCore.Components; - -internal interface ICascadingValueComponent -{ - // This interface exists only so that CascadingParameterState has a way - // to work with all CascadingValue types regardless of T. - - bool CanSupplyValue(Type valueType, string? valueName); - - object? CurrentValue { get; } - - bool CurrentValueIsFixed { get; } - - void Subscribe(ComponentState subscriber); - - void Unsubscribe(ComponentState subscriber); -} diff --git a/src/Components/Components/src/ICascadingValueSupplier.cs b/src/Components/Components/src/ICascadingValueSupplier.cs new file mode 100644 index 000000000000..c535d9cfda16 --- /dev/null +++ b/src/Components/Components/src/ICascadingValueSupplier.cs @@ -0,0 +1,19 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Components.Rendering; + +namespace Microsoft.AspNetCore.Components; + +internal interface ICascadingValueSupplier +{ + bool IsFixed { get; } + + bool CanSupplyValue(in CascadingParameterInfo parameterInfo); + + object? GetCurrentValue(in CascadingParameterInfo parameterInfo); + + void Subscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo); + + void Unsubscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo); +} diff --git a/src/Components/Components/src/IHostEnvironmentCascadingParameter.cs b/src/Components/Components/src/IHostEnvironmentCascadingParameter.cs deleted file mode 100644 index 8f407e0cdd5e..000000000000 --- a/src/Components/Components/src/IHostEnvironmentCascadingParameter.cs +++ /dev/null @@ -1,11 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace Microsoft.AspNetCore.Components; - -// Marks a cascading parameter that can be offered via an attribute that is not -// directly defined in the Components assembly. For example [SupplyParameterFromForm]. -internal interface IHostEnvironmentCascadingParameter -{ - public string? Name { get; set; } -} diff --git a/src/Components/Components/src/ParameterView.cs b/src/Components/Components/src/ParameterView.cs index 37ba9c2dfbcd..1b3a25d635d1 100644 --- a/src/Components/Components/src/ParameterView.cs +++ b/src/Components/Components/src/ParameterView.cs @@ -423,7 +423,8 @@ public bool MoveNext() _currentIndex = nextIndex; var state = _cascadingParameters[_currentIndex]; - _current = new ParameterValue(state.LocalValueName, state.ValueSupplier.CurrentValue!, true); + var currentValue = state.ValueSupplier.GetCurrentValue(state.ParameterInfo); + _current = new ParameterValue(state.ParameterInfo.PropertyName, currentValue!, true); return true; } else diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt index 625c0e05e595..2ac249f7283c 100644 --- a/src/Components/Components/src/PublicAPI.Unshipped.txt +++ b/src/Components/Components/src/PublicAPI.Unshipped.txt @@ -1,5 +1,16 @@ #nullable enable +abstract Microsoft.AspNetCore.Components.Binding.CascadingModelBindingProvider.AreValuesFixed.get -> bool +abstract Microsoft.AspNetCore.Components.Binding.CascadingModelBindingProvider.CanSupplyValue(Microsoft.AspNetCore.Components.ModelBindingContext? bindingContext, in Microsoft.AspNetCore.Components.CascadingParameterInfo parameterInfo) -> bool +abstract Microsoft.AspNetCore.Components.Binding.CascadingModelBindingProvider.GetCurrentValue(Microsoft.AspNetCore.Components.ModelBindingContext? bindingContext, in Microsoft.AspNetCore.Components.CascadingParameterInfo parameterInfo) -> object? +abstract Microsoft.AspNetCore.Components.Binding.CascadingModelBindingProvider.SupportsCascadingParameterAttributeType(System.Type! attributeType) -> bool +abstract Microsoft.AspNetCore.Components.Binding.CascadingModelBindingProvider.SupportsParameterType(System.Type! parameterType) -> bool +abstract Microsoft.AspNetCore.Components.CascadingParameterAttributeBase.Name.get -> string? +abstract Microsoft.AspNetCore.Components.CascadingParameterAttributeBase.Name.set -> void abstract Microsoft.AspNetCore.Components.RenderModeAttribute.Mode.get -> Microsoft.AspNetCore.Components.IComponentRenderMode! +Microsoft.AspNetCore.Components.Binding.CascadingModelBindingProvider +Microsoft.AspNetCore.Components.Binding.CascadingModelBindingProvider.CascadingModelBindingProvider() -> void +Microsoft.AspNetCore.Components.Binding.CascadingQueryModelBindingProvider +Microsoft.AspNetCore.Components.Binding.CascadingQueryModelBindingProvider.CascadingQueryModelBindingProvider(Microsoft.AspNetCore.Components.NavigationManager! navigationManager) -> void Microsoft.AspNetCore.Components.Binding.IFormValueSupplier Microsoft.AspNetCore.Components.Binding.IFormValueSupplier.CanBind(string! formName, System.Type! valueType) -> bool Microsoft.AspNetCore.Components.Binding.IFormValueSupplier.CanConvertSingleValue(System.Type! type) -> bool @@ -12,6 +23,13 @@ Microsoft.AspNetCore.Components.CascadingModelBinder.IsFixed.get -> bool Microsoft.AspNetCore.Components.CascadingModelBinder.IsFixed.set -> void Microsoft.AspNetCore.Components.CascadingModelBinder.Name.get -> string! Microsoft.AspNetCore.Components.CascadingModelBinder.Name.set -> void +Microsoft.AspNetCore.Components.CascadingParameterAttributeBase +Microsoft.AspNetCore.Components.CascadingParameterAttributeBase.CascadingParameterAttributeBase() -> void +Microsoft.AspNetCore.Components.CascadingParameterInfo +Microsoft.AspNetCore.Components.CascadingParameterInfo.Attribute.get -> Microsoft.AspNetCore.Components.CascadingParameterAttributeBase! +Microsoft.AspNetCore.Components.CascadingParameterInfo.CascadingParameterInfo() -> void +Microsoft.AspNetCore.Components.CascadingParameterInfo.PropertyName.get -> string! +Microsoft.AspNetCore.Components.CascadingParameterInfo.PropertyType.get -> System.Type! Microsoft.AspNetCore.Components.ComponentBase.DispatchExceptionAsync(System.Exception! exception) -> System.Threading.Tasks.Task! Microsoft.AspNetCore.Components.IComponentRenderMode Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager.PersistStateAsync(Microsoft.AspNetCore.Components.IPersistentComponentStateStore! store, Microsoft.AspNetCore.Components.Dispatcher! dispatcher) -> System.Threading.Tasks.Task! @@ -23,9 +41,16 @@ Microsoft.AspNetCore.Components.ParameterView.ToDictionary() -> System.Collectio Microsoft.AspNetCore.Components.RenderHandle.DispatchExceptionAsync(System.Exception! exception) -> System.Threading.Tasks.Task! *REMOVED*Microsoft.AspNetCore.Components.NavigationManager.ToAbsoluteUri(string! relativeUri) -> System.Uri! Microsoft.AspNetCore.Components.NavigationManager.ToAbsoluteUri(string? relativeUri) -> System.Uri! +Microsoft.AspNetCore.Components.Rendering.ComponentState.LogicalParentComponentState.get -> Microsoft.AspNetCore.Components.Rendering.ComponentState? +Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder.AddComponentParameter(int sequence, string! name, Microsoft.AspNetCore.Components.IComponentRenderMode! renderMode) -> void +Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder.AddComponentRenderMode(int sequence, Microsoft.AspNetCore.Components.IComponentRenderMode! renderMode) -> void Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder.SetEventHandlerName(string! eventHandlerName) -> void *REMOVED*Microsoft.AspNetCore.Components.RouteData.RouteData(System.Type! pageType, System.Collections.Generic.IReadOnlyDictionary! routeValues) -> void *REMOVED*Microsoft.AspNetCore.Components.RouteData.RouteValues.get -> System.Collections.Generic.IReadOnlyDictionary! +Microsoft.AspNetCore.Components.RenderTree.ComponentFrameFlags +Microsoft.AspNetCore.Components.RenderTree.ComponentFrameFlags.HasCallerSpecifiedRenderMode = 1 -> Microsoft.AspNetCore.Components.RenderTree.ComponentFrameFlags +Microsoft.AspNetCore.Components.RenderTree.RenderTreeFrame.ComponentFrameFlags.get -> Microsoft.AspNetCore.Components.RenderTree.ComponentFrameFlags +Microsoft.AspNetCore.Components.RenderTree.RenderTreeFrameType.ComponentRenderMode = 9 -> Microsoft.AspNetCore.Components.RenderTree.RenderTreeFrameType Microsoft.AspNetCore.Components.RouteData.RouteData(System.Type! pageType, System.Collections.Generic.IReadOnlyDictionary! routeValues) -> void Microsoft.AspNetCore.Components.RouteData.RouteValues.get -> System.Collections.Generic.IReadOnlyDictionary! Microsoft.AspNetCore.Components.Routing.IRoutingStateProvider @@ -60,14 +85,25 @@ Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder.AddComponentParamete Microsoft.AspNetCore.Components.StreamRenderingAttribute Microsoft.AspNetCore.Components.StreamRenderingAttribute.Enabled.get -> bool Microsoft.AspNetCore.Components.StreamRenderingAttribute.StreamRenderingAttribute(bool enabled) -> void +*REMOVED*Microsoft.AspNetCore.Components.CascadingParameterAttribute.Name.get -> string? +*REMOVED*Microsoft.AspNetCore.Components.CascadingParameterAttribute.Name.set -> void +override Microsoft.AspNetCore.Components.CascadingParameterAttribute.Name.get -> string? +override Microsoft.AspNetCore.Components.CascadingParameterAttribute.Name.set -> void override Microsoft.AspNetCore.Components.EventCallback.GetHashCode() -> int override Microsoft.AspNetCore.Components.EventCallback.Equals(object? obj) -> bool override Microsoft.AspNetCore.Components.EventCallback.GetHashCode() -> int override Microsoft.AspNetCore.Components.EventCallback.Equals(object? obj) -> bool +*REMOVED*Microsoft.AspNetCore.Components.SupplyParameterFromQueryAttribute.Name.get -> string? +*REMOVED*Microsoft.AspNetCore.Components.SupplyParameterFromQueryAttribute.Name.set -> void +override Microsoft.AspNetCore.Components.SupplyParameterFromQueryAttribute.Name.get -> string? +override Microsoft.AspNetCore.Components.SupplyParameterFromQueryAttribute.Name.set -> void +virtual Microsoft.AspNetCore.Components.Binding.CascadingModelBindingProvider.Subscribe(Microsoft.AspNetCore.Components.Rendering.ComponentState! subscriber) -> void +virtual Microsoft.AspNetCore.Components.Binding.CascadingModelBindingProvider.Unsubscribe(Microsoft.AspNetCore.Components.Rendering.ComponentState! subscriber) -> void virtual Microsoft.AspNetCore.Components.Rendering.ComponentState.DisposeAsync() -> System.Threading.Tasks.ValueTask virtual Microsoft.AspNetCore.Components.RenderTree.Renderer.AddPendingTask(Microsoft.AspNetCore.Components.Rendering.ComponentState? componentState, System.Threading.Tasks.Task! task) -> void virtual Microsoft.AspNetCore.Components.RenderTree.Renderer.CreateComponentState(int componentId, Microsoft.AspNetCore.Components.IComponent! component, Microsoft.AspNetCore.Components.Rendering.ComponentState? parentComponentState) -> Microsoft.AspNetCore.Components.Rendering.ComponentState! virtual Microsoft.AspNetCore.Components.RenderTree.Renderer.DispatchEventAsync(ulong eventHandlerId, Microsoft.AspNetCore.Components.RenderTree.EventFieldInfo? fieldInfo, System.EventArgs! eventArgs, bool quiesce) -> System.Threading.Tasks.Task! -virtual Microsoft.AspNetCore.Components.RenderTree.Renderer.ResolveComponentForRenderMode(System.Type! componentType, int? parentComponentId, Microsoft.AspNetCore.Components.IComponentActivator! componentActivator, Microsoft.AspNetCore.Components.IComponentRenderMode! componentTypeRenderMode) -> Microsoft.AspNetCore.Components.IComponent! +virtual Microsoft.AspNetCore.Components.RenderTree.Renderer.ResolveComponentForRenderMode(System.Type! componentType, int? parentComponentId, Microsoft.AspNetCore.Components.IComponentActivator! componentActivator, Microsoft.AspNetCore.Components.IComponentRenderMode! renderMode) -> Microsoft.AspNetCore.Components.IComponent! virtual Microsoft.AspNetCore.Components.RenderTree.Renderer.ShouldTrackNamedEventHandlers() -> bool virtual Microsoft.AspNetCore.Components.RenderTree.Renderer.TrackNamedEventId(ulong eventHandlerId, int componentId, string! eventHandlerName) -> void +~Microsoft.AspNetCore.Components.RenderTree.RenderTreeFrame.ComponentRenderMode.get -> Microsoft.AspNetCore.Components.IComponentRenderMode diff --git a/src/Components/Components/src/Reflection/ComponentProperties.cs b/src/Components/Components/src/Reflection/ComponentProperties.cs index 9569e8e6a84e..507d5bcd353a 100644 --- a/src/Components/Components/src/Reflection/ComponentProperties.cs +++ b/src/Components/Components/src/Reflection/ComponentProperties.cs @@ -170,8 +170,7 @@ private static void ThrowForUnknownIncomingParameterName([DynamicallyAccessedMem if (propertyInfo != null) { if (!propertyInfo.IsDefined(typeof(ParameterAttribute)) && - !propertyInfo.IsDefined(typeof(CascadingParameterAttribute)) && - !propertyInfo.GetCustomAttributes().OfType().Any()) + !propertyInfo.GetCustomAttributes().OfType().Any()) { throw new InvalidOperationException( $"Object of type '{targetType.FullName}' has a property matching the name '{parameterName}', " + @@ -262,8 +261,7 @@ public WritersForType([DynamicallyAccessedMembers(Component)] Type targetType) foreach (var propertyInfo in GetCandidateBindableProperties(targetType)) { ParameterAttribute? parameterAttribute = null; - CascadingParameterAttribute? cascadingParameterAttribute = null; - IHostEnvironmentCascadingParameter? hostEnvironmentCascadingParameter = null; + CascadingParameterAttributeBase? cascadingParameterAttribute = null; var attributes = propertyInfo.GetCustomAttributes(); foreach (var attribute in attributes) @@ -273,18 +271,15 @@ public WritersForType([DynamicallyAccessedMembers(Component)] Type targetType) case ParameterAttribute parameter: parameterAttribute = parameter; break; - case CascadingParameterAttribute cascadingParameter: + case CascadingParameterAttributeBase cascadingParameter: cascadingParameterAttribute = cascadingParameter; break; - case IHostEnvironmentCascadingParameter hostEnvironmentAttribute: - hostEnvironmentCascadingParameter = hostEnvironmentAttribute; - break; default: break; } } - var isParameter = parameterAttribute != null || cascadingParameterAttribute != null || hostEnvironmentCascadingParameter != null; + var isParameter = parameterAttribute != null || cascadingParameterAttribute != null; if (!isParameter) { continue; @@ -299,7 +294,7 @@ public WritersForType([DynamicallyAccessedMembers(Component)] Type targetType) var propertySetter = new PropertySetter(targetType, propertyInfo) { - Cascading = cascadingParameterAttribute != null || hostEnvironmentCascadingParameter != null, + Cascading = cascadingParameterAttribute != null, }; if (_underlyingWriters.ContainsKey(propertyName)) diff --git a/src/Components/Components/src/RenderTree/ComponentFrameFlags.cs b/src/Components/Components/src/RenderTree/ComponentFrameFlags.cs new file mode 100644 index 000000000000..2fb60cde5518 --- /dev/null +++ b/src/Components/Components/src/RenderTree/ComponentFrameFlags.cs @@ -0,0 +1,17 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Components.RenderTree; + +/// +/// Types in the Microsoft.AspNetCore.Components.RenderTree namespace are not recommended for use outside +/// of the Blazor framework. These types will change in future release. +/// +[Flags] +public enum ComponentFrameFlags : byte +{ + /// + /// Indicates that the caller has specified a render mode. + /// + HasCallerSpecifiedRenderMode = 1, +} diff --git a/src/Components/Components/src/RenderTree/RenderTreeDiffBuilder.cs b/src/Components/Components/src/RenderTree/RenderTreeDiffBuilder.cs index f51431100e37..c310c5856cfd 100644 --- a/src/Components/Components/src/RenderTree/RenderTreeDiffBuilder.cs +++ b/src/Components/Components/src/RenderTree/RenderTreeDiffBuilder.cs @@ -953,7 +953,7 @@ private static void InitializeNewComponentFrame(ref DiffContext diffContext, int var frames = diffContext.NewTree; ref var frame = ref frames[frameIndex]; var parentComponentId = diffContext.ComponentId; - var childComponentState = diffContext.Renderer.InstantiateChildComponentOnFrame(ref frame, parentComponentId); + var childComponentState = diffContext.Renderer.InstantiateChildComponentOnFrame(frames, frameIndex, parentComponentId); // Set initial parameters var initialParametersLifetime = new ParameterViewLifetime(diffContext.BatchBuilder); diff --git a/src/Components/Components/src/RenderTree/RenderTreeFrame.cs b/src/Components/Components/src/RenderTree/RenderTreeFrame.cs index 7d768ce394cd..e85764518942 100644 --- a/src/Components/Components/src/RenderTree/RenderTreeFrame.cs +++ b/src/Components/Components/src/RenderTree/RenderTreeFrame.cs @@ -136,6 +136,7 @@ public struct RenderTreeFrame // RenderTreeFrameType.Component // -------------------------------------------------------------------------------- + [FieldOffset(6)] internal ComponentFrameFlags ComponentFrameFlagsField; [FieldOffset(8)] internal int ComponentSubtreeLengthField; [FieldOffset(12)] internal int ComponentIdField; [FieldOffset(16)] @@ -144,6 +145,12 @@ public struct RenderTreeFrame [FieldOffset(24)] internal ComponentState ComponentStateField; [FieldOffset(32)] internal object ComponentKeyField; + /// + /// If the property equals + /// gets the for the component frame. + /// + public ComponentFrameFlags ComponentFrameFlags => ComponentFrameFlagsField; + /// /// If the property equals /// gets the number of frames in the subtree for which this frame is the root. @@ -250,6 +257,31 @@ public struct RenderTreeFrame /// public string MarkupContent => MarkupContentField; + // -------------------------------------------------------------------------------- + // RenderTreeFrameType.ComponentRenderMode + // -------------------------------------------------------------------------------- + + [FieldOffset(16)] internal IComponentRenderMode ComponentRenderModeField; + + /// + /// If the property equals , + /// gets the specified . Otherwise, the value is undefined. + /// + public IComponentRenderMode ComponentRenderMode + { + get + { + // Normally we don't check the frame type matches, and leave it to the caller to be responsible for only evaluating the correct properties. + // However the name "ComponentRenderMode" sounds so much like it would be a field on Component frames that we'll explicitly check to avoid mistakes. + if (FrameType != RenderTreeFrameType.ComponentRenderMode) + { + throw new InvalidOperationException($"The {nameof(ComponentRenderMode)} field only exists on frames of type {nameof(RenderTreeFrameType.ComponentRenderMode)}."); + } + + return ComponentRenderModeField; + } + } + // Element constructor private RenderTreeFrame(int sequence, int elementSubtreeLength, string elementName, object elementKey) : this() diff --git a/src/Components/Components/src/RenderTree/RenderTreeFrameArrayBuilder.cs b/src/Components/Components/src/RenderTree/RenderTreeFrameArrayBuilder.cs index fad0fddc5c8d..17f06c245c7a 100644 --- a/src/Components/Components/src/RenderTree/RenderTreeFrameArrayBuilder.cs +++ b/src/Components/Components/src/RenderTree/RenderTreeFrameArrayBuilder.cs @@ -134,4 +134,19 @@ public void AppendRegion(int sequence) FrameTypeField = RenderTreeFrameType.Region, }; } + + public void AppendComponentRenderMode(int sequence, IComponentRenderMode renderMode) + { + if (_itemsInUse == _items.Length) + { + GrowBuffer(_items.Length * 2); + } + + _items[_itemsInUse++] = new RenderTreeFrame + { + SequenceField = sequence, + FrameTypeField = RenderTreeFrameType.ComponentRenderMode, + ComponentRenderModeField = renderMode, + }; + } } diff --git a/src/Components/Components/src/RenderTree/RenderTreeFrameType.cs b/src/Components/Components/src/RenderTree/RenderTreeFrameType.cs index 136c41ed3359..08d734bce58d 100644 --- a/src/Components/Components/src/RenderTree/RenderTreeFrameType.cs +++ b/src/Components/Components/src/RenderTree/RenderTreeFrameType.cs @@ -58,4 +58,9 @@ public enum RenderTreeFrameType : short /// Represents a block of markup content. /// Markup = 8, + + /// + /// Represents an instruction to use a specified render mode for the component. + /// + ComponentRenderMode = 9, } diff --git a/src/Components/Components/src/RenderTree/Renderer.cs b/src/Components/Components/src/RenderTree/Renderer.cs index 187ffb6d8f79..cc9a4ec91f6f 100644 --- a/src/Components/Components/src/RenderTree/Renderer.cs +++ b/src/Components/Components/src/RenderTree/Renderer.cs @@ -126,12 +126,14 @@ private static IComponentActivator GetComponentActivatorOrDefault(IServiceProvid protected ComponentState GetComponentState(int componentId) => GetRequiredComponentState(componentId); + internal ComponentState GetComponentState(IComponent component) + => _componentStateByComponent.GetValueOrDefault(component); + private async void RenderRootComponentsOnHotReload() { // Before re-rendering the root component, also clear any well-known caches in the framework ComponentFactory.ClearCache(); ComponentProperties.ClearCache(); - Routing.QueryParameterValueSupplier.ClearCache(); await Dispatcher.InvokeAsync(() => { @@ -162,7 +164,7 @@ await Dispatcher.InvokeAsync(() => /// The type of the component to instantiate. /// The component instance. protected IComponent InstantiateComponent([DynamicallyAccessedMembers(Component)] Type componentType) - => _componentFactory.InstantiateComponent(_serviceProvider, componentType, null); + => _componentFactory.InstantiateComponent(_serviceProvider, componentType, null, null); /// /// Associates the with the , assigning @@ -473,19 +475,24 @@ public Type GetEventArgsType(ulong eventHandlerId) : EventArgsTypeCache.GetEventArgsType(methodInfo); } - internal ComponentState InstantiateChildComponentOnFrame(ref RenderTreeFrame frame, int parentComponentId) + internal ComponentState InstantiateChildComponentOnFrame(RenderTreeFrame[] frames, int frameIndex, int parentComponentId) { + ref var frame = ref frames[frameIndex]; if (frame.FrameTypeField != RenderTreeFrameType.Component) { - throw new ArgumentException($"The frame's {nameof(RenderTreeFrame.FrameType)} property must equal {RenderTreeFrameType.Component}", nameof(frame)); + throw new ArgumentException($"The frame's {nameof(RenderTreeFrame.FrameType)} property must equal {RenderTreeFrameType.Component}", nameof(frameIndex)); } if (frame.ComponentStateField != null) { - throw new ArgumentException($"The frame already has a non-null component instance", nameof(frame)); + throw new ArgumentException($"The frame already has a non-null component instance", nameof(frameIndex)); } - var newComponent = _componentFactory.InstantiateComponent(_serviceProvider, frame.ComponentTypeField, parentComponentId); + var callerSpecifiedRenderMode = frame.ComponentFrameFlags.HasFlag(ComponentFrameFlags.HasCallerSpecifiedRenderMode) + ? FindCallerSpecifiedRenderMode(frames, frameIndex) + : null; + + var newComponent = _componentFactory.InstantiateComponent(_serviceProvider, frame.ComponentTypeField, callerSpecifiedRenderMode, parentComponentId); var newComponentState = AttachAndInitComponent(newComponent, parentComponentId); frame.ComponentStateField = newComponentState; frame.ComponentIdField = newComponentState.ComponentId; @@ -493,6 +500,30 @@ internal ComponentState InstantiateChildComponentOnFrame(ref RenderTreeFrame fra return newComponentState; } + private static IComponentRenderMode? FindCallerSpecifiedRenderMode(RenderTreeFrame[] frames, int componentFrameIndex) + { + // ComponentRenderMode frames are immediate children of Component frames. So, they have to appear after any parameter + // attributes (since attributes must always immediately follow Component frames), but before anything that would + // represent a different child node, such as text/element or another component. It's OK to do this linear scan + // because we consider it uncommon to specify a rendermode, and none of this happens if you don't. + var endIndex = componentFrameIndex + frames[componentFrameIndex].ComponentSubtreeLengthField; + for (var index = componentFrameIndex + 1; index <= endIndex; index++) + { + ref var frame = ref frames[index]; + switch (frame.FrameType) + { + case RenderTreeFrameType.Attribute: + continue; + case RenderTreeFrameType.ComponentRenderMode: + return frame.ComponentRenderMode; + default: + break; + } + } + + return null; + } + internal void AddToPendingTasksWithErrorHandling(Task task, ComponentState? owningComponentState) { switch (task == null ? TaskStatus.RanToCompletion : task.Status) @@ -1039,7 +1070,7 @@ private void HandleExceptionViaErrorBoundary(Exception error, ComponentState? er return; // Handled successfully } - candidate = candidate.ParentComponentState; + candidate = candidate.LogicalParentComponentState; } // It's unhandled, so treat as fatal @@ -1144,23 +1175,24 @@ void NotifyExceptions(List exceptions) /// /// Determines how to handle an when obtaining a component instance. - /// This is only called for components that have specified a render mode. Subclasses may override this - /// method to return a component of a different type, or throw, depending on whether the renderer + /// This is only called when a render mode is specified either at the call site or on the component type. + /// + /// Subclasses may override this method to return a component of a different type, or throw, depending on whether the renderer /// supports the render mode and how it implements that support. /// /// The type of component that was requested. /// The parent component ID, or null if it is a root component. /// An that should be used when instantiating component objects. - /// The declared on . + /// The declared on or at the call site (for example, by the parent component). /// An instance. protected internal virtual IComponent ResolveComponentForRenderMode( [DynamicallyAccessedMembers(Component)] Type componentType, int? parentComponentId, IComponentActivator componentActivator, - IComponentRenderMode componentTypeRenderMode) + IComponentRenderMode renderMode) { // Nothing is supported by default. Subclasses must override this to opt into supporting specific render modes. - throw new NotSupportedException($"Cannot supply a component of type '{componentType}' because the current platform does not support the render mode '{componentTypeRenderMode}'."); + throw new NotSupportedException($"Cannot supply a component of type '{componentType}' because the current platform does not support the render mode '{renderMode}'."); } /// diff --git a/src/Components/Components/src/Rendering/ComponentState.cs b/src/Components/Components/src/Rendering/ComponentState.cs index f7de9f106215..a98adba82b0f 100644 --- a/src/Components/Components/src/Rendering/ComponentState.cs +++ b/src/Components/Components/src/Rendering/ComponentState.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using Microsoft.AspNetCore.Components.RenderTree; +using Microsoft.AspNetCore.Components.Sections; namespace Microsoft.AspNetCore.Components.Rendering; @@ -34,6 +35,9 @@ public ComponentState(Renderer renderer, int componentId, IComponent component, ComponentId = componentId; ParentComponentState = parentComponentState; Component = component ?? throw new ArgumentNullException(nameof(component)); + LogicalParentComponentState = component is SectionOutlet.SectionOutletContentRenderer + ? (GetSectionOutletLogicalParent(renderer, (SectionOutlet)parentComponentState!.Component) ?? parentComponentState) + : parentComponentState; _renderer = renderer ?? throw new ArgumentNullException(nameof(renderer)); _cascadingParameters = CascadingParameterState.FindCascadingParameters(this); CurrentRenderTree = new RenderTreeBuilder(); @@ -46,6 +50,18 @@ public ComponentState(Renderer renderer, int componentId, IComponent component, } } + private static ComponentState? GetSectionOutletLogicalParent(Renderer renderer, SectionOutlet sectionOutlet) + { + // This will return null if the SectionOutlet is not currently rendering any content + if (sectionOutlet.CurrentLogicalParent is { } logicalParent + && renderer.GetComponentState(logicalParent) is { } logicalParentComponentState) + { + return logicalParentComponentState; + } + + return null; + } + /// /// Gets the component ID. /// @@ -61,6 +77,11 @@ public ComponentState(Renderer renderer, int componentId, IComponent component, /// public ComponentState? ParentComponentState { get; } + /// + /// Gets the of the logical parent component, or null if this is a root component. + /// + public ComponentState? LogicalParentComponentState { get; } + internal RenderTreeBuilder CurrentRenderTree { get; set; } internal void RenderIntoBatch(RenderBatchBuilder batchBuilder, RenderFragment renderFragment, out Exception? renderFragmentException) @@ -194,9 +215,9 @@ private bool AddCascadingParameterSubscriptions() for (var i = 0; i < numCascadingParameters; i++) { var valueSupplier = _cascadingParameters[i].ValueSupplier; - if (!valueSupplier.CurrentValueIsFixed) + if (!valueSupplier.IsFixed) { - valueSupplier.Subscribe(this); + valueSupplier.Subscribe(this, _cascadingParameters[i].ParameterInfo); hasSubscription = true; } } @@ -210,9 +231,9 @@ private void RemoveCascadingParameterSubscriptions() for (var i = 0; i < numCascadingParameters; i++) { var supplier = _cascadingParameters[i].ValueSupplier; - if (!supplier.CurrentValueIsFixed) + if (!supplier.IsFixed) { - supplier.Unsubscribe(this); + supplier.Unsubscribe(this, _cascadingParameters[i].ParameterInfo); } } } diff --git a/src/Components/Components/src/Rendering/RenderTreeBuilder.cs b/src/Components/Components/src/Rendering/RenderTreeBuilder.cs index 874a32a99a39..cca97d250c24 100644 --- a/src/Components/Components/src/Rendering/RenderTreeBuilder.cs +++ b/src/Components/Components/src/Rendering/RenderTreeBuilder.cs @@ -28,6 +28,7 @@ public sealed class RenderTreeBuilder : IDisposable private bool _hasSeenAddMultipleAttributes; private Dictionary? _seenAttributeNames; private Dictionary? _seenEventHandlerNames; + private IComponentRenderMode? _pendingComponentCallSiteRenderMode; // TODO: Remove when Razor compiler supports call-site @rendermode // Configure the render tree builder to capture the event handler names. internal bool TrackNamedEventHandlers { get; set; } @@ -545,6 +546,28 @@ public void OpenComponent(int sequence, [DynamicallyAccessedMembers(Component)] OpenComponentUnchecked(sequence, componentType); } + /// + /// Temporary API until Razor compiler is updated. This will be removed before .NET 8 ships. + /// + public void AddComponentParameter(int sequence, string name, IComponentRenderMode renderMode) + { + if (string.Equals(name, "@rendermode", StringComparison.Ordinal)) + { + // When the Razor compiler is updated, would compile directly as a call + // to AddComponentRenderMode(RenderMode.WebAssembly), which must appear after all attributes. Until then we'll intercept regular + // parameters with this name and IComponentRenderMode values. Unfortunately we can't guarantee that the parameter will appear after + // all other parameters (e.g., ChildContent would always go later), so use this inefficient trick to defer adding it. + // It won't be needed once the Razor compiler supports @rendermode. + _pendingComponentCallSiteRenderMode = renderMode; + } + else + { + // For other parameter names, the developer is doing something custom so just pass the parameter as normal + // This special case will also not be relevant once we have @rendermode + AddComponentParameter(sequence, name, (object)renderMode); + } + } + /// /// Appends a frame representing a component parameter. /// @@ -612,6 +635,12 @@ private void OpenComponentUnchecked(int sequence, [DynamicallyAccessedMembers(Co /// public void CloseComponent() { + if (_pendingComponentCallSiteRenderMode is not null) + { + AddComponentRenderMode(0, _pendingComponentCallSiteRenderMode); + _pendingComponentCallSiteRenderMode = null; + } + var indexOfEntryBeingClosed = _openElementIndices.Pop(); // We might be closing a component with only attributes. Run the attribute cleanup pass @@ -663,6 +692,41 @@ public void AddComponentReferenceCapture(int sequence, Action componentR _lastNonAttributeFrameType = RenderTreeFrameType.ComponentReferenceCapture; } + /// + /// Adds a frame indicating the render mode on the enclosing component frame. + /// + /// An integer that represents the position of the instruction in the source code. + /// The . + public void AddComponentRenderMode(int sequence, IComponentRenderMode renderMode) + { + ArgumentNullException.ThrowIfNull(renderMode); + + // Note that a ComponentRenderMode frame is technically a child of the Component frame to which it applies, + // hence the terminology of "adding" it rather than "setting" it. For performance reasons, the diffing system + // will only look for ComponentRenderMode frames: + // [a] when the HasCallerSpecifiedRenderMode flag is set on the Component frame + // [b] up until the first child that is *not* a ComponentRenderMode frame or any other header frame type + // that we may define in the future + + var parentFrameIndex = GetCurrentParentFrameIndex(); + if (!parentFrameIndex.HasValue) + { + throw new InvalidOperationException("There is no enclosing component frame."); + } + + var parentFrameIndexValue = parentFrameIndex.Value; + ref var parentFrame = ref _entries.Buffer[parentFrameIndexValue]; + if (parentFrame.FrameTypeField != RenderTreeFrameType.Component) + { + throw new InvalidOperationException($"The enclosing frame is not of the required type '{nameof(RenderTreeFrameType.Component)}'."); + } + + parentFrame.ComponentFrameFlagsField |= ComponentFrameFlags.HasCallerSpecifiedRenderMode; + + _entries.AppendComponentRenderMode(sequence, renderMode); + _lastNonAttributeFrameType = RenderTreeFrameType.ComponentRenderMode; + } + /// /// Appends a frame representing a region of frames. /// diff --git a/src/Components/Components/src/RouteView.cs b/src/Components/Components/src/RouteView.cs index 3d07cbc4f903..bb778771a09f 100644 --- a/src/Components/Components/src/RouteView.cs +++ b/src/Components/Components/src/RouteView.cs @@ -8,7 +8,6 @@ using System.Reflection; using Microsoft.AspNetCore.Components.HotReload; using Microsoft.AspNetCore.Components.Rendering; -using Microsoft.AspNetCore.Components.Routing; namespace Microsoft.AspNetCore.Components; @@ -103,23 +102,6 @@ void RenderPageCore(RenderTreeBuilder builder) builder.AddComponentParameter(1, kvp.Key, kvp.Value); } - var queryParameterSupplier = QueryParameterValueSupplier.ForType(RouteData.PageType); - if (queryParameterSupplier is not null) - { - // Since this component does accept some parameters from query, we must supply values for all of them, - // even if the querystring in the URI is empty. So don't skip the following logic. - var relativeUrl = NavigationManager.ToBaseRelativePath(NavigationManager.Uri); - var url = NavigationManager.Uri; - ReadOnlyMemory query = default; - var queryStartPos = url.IndexOf('?'); - if (queryStartPos >= 0) - { - var queryEndPos = url.IndexOf('#', queryStartPos); - query = url.AsMemory(queryStartPos..(queryEndPos < 0 ? url.Length : queryEndPos)); - } - queryParameterSupplier.RenderParametersFromQueryString(builder, query); - } - builder.CloseComponent(); } } diff --git a/src/Components/Components/src/Routing/QueryParameterValueSupplier.cs b/src/Components/Components/src/Routing/QueryParameterValueSupplier.cs index b217d09878ff..967169ee0191 100644 --- a/src/Components/Components/src/Routing/QueryParameterValueSupplier.cs +++ b/src/Components/Components/src/Routing/QueryParameterValueSupplier.cs @@ -1,189 +1,60 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Buffers; -using System.Collections.Concurrent; -using System.Diagnostics.CodeAnalysis; -using System.Reflection; -using Microsoft.AspNetCore.Components.Reflection; -using Microsoft.AspNetCore.Components.Rendering; +using System.Runtime.InteropServices; using Microsoft.AspNetCore.Internal; -using static Microsoft.AspNetCore.Internal.LinkerFlags; namespace Microsoft.AspNetCore.Components.Routing; internal sealed class QueryParameterValueSupplier { - public static void ClearCache() => _cacheByType.Clear(); + private readonly Dictionary, StringSegmentAccumulator> _queryParameterValuesByName = new(QueryParameterNameComparer.Instance); - private static readonly ConcurrentDictionary _cacheByType = new(); - - // These two arrays contain the same number of entries, and their corresponding positions refer to each other. - // Holding the info like this means we can use Array.BinarySearch with less custom implementation. - private readonly ReadOnlyMemory[] _queryParameterNames; - private readonly QueryParameterDestination[] _destinations; - - public static QueryParameterValueSupplier? ForType([DynamicallyAccessedMembers(Component)] Type componentType) + public void ReadParametersFromQuery(ReadOnlyMemory query) { - if (!_cacheByType.TryGetValue(componentType, out var instanceOrNull)) - { - // If the component doesn't have any query parameters, store a null value for it - // so we know the upstream code can't try to render query parameter frames for it. - var sortedMappings = GetSortedMappings(componentType); - instanceOrNull = sortedMappings == null ? null : new QueryParameterValueSupplier(sortedMappings); - _cacheByType.TryAdd(componentType, instanceOrNull); - } + _queryParameterValuesByName.Clear(); - return instanceOrNull; - } + var queryStringEnumerable = new QueryStringEnumerable(query); - private QueryParameterValueSupplier(QueryParameterMapping[] sortedMappings) - { - _queryParameterNames = new ReadOnlyMemory[sortedMappings.Length]; - _destinations = new QueryParameterDestination[sortedMappings.Length]; - for (var i = 0; i < sortedMappings.Length; i++) + foreach (var suppliedPair in queryStringEnumerable) { - ref var mapping = ref sortedMappings[i]; - _queryParameterNames[i] = mapping.QueryParameterName; - _destinations[i] = mapping.Destination; + var decodedName = suppliedPair.DecodeName(); + var decodedValue = suppliedPair.DecodeValue(); + + // This is safe because we don't mutate the dictionary while the ref local is in scope. + ref var values = ref CollectionsMarshal.GetValueRefOrAddDefault(_queryParameterValuesByName, decodedName, out _); + values.Add(decodedValue); } } - public void RenderParametersFromQueryString(RenderTreeBuilder builder, ReadOnlyMemory queryString) + public object? GetQueryParameterValue(Type targetType, string queryParameterName) { - // If there's no querystring contents, we can skip renting from the pool - if (queryString.IsEmpty) - { - for (var destinationIndex = 0; destinationIndex < _destinations.Length; destinationIndex++) - { - ref var destination = ref _destinations[destinationIndex]; - var blankValue = destination.IsArray ? destination.Parser.ParseMultiple(default, string.Empty) : null; - builder.AddComponentParameter(0, destination.ComponentParameterName, blankValue); - } - return; - } + var isArray = targetType.IsArray; + var elementType = isArray ? targetType.GetElementType()! : targetType; - // Temporary workspace in which we accumulate the data while walking the querystring. - var valuesByMapping = ArrayPool.Shared.Rent(_destinations.Length); - - try + if (!UrlValueConstraint.TryGetByTargetType(elementType, out var parser)) { - // Capture values by destination in a single pass through the querystring - var queryStringEnumerable = new QueryStringEnumerable(queryString); - foreach (var suppliedPair in queryStringEnumerable) - { - var decodedName = suppliedPair.DecodeName(); - var mappingIndex = Array.BinarySearch(_queryParameterNames, decodedName, QueryParameterNameComparer.Instance); - if (mappingIndex >= 0) - { - var decodedValue = suppliedPair.DecodeValue(); - - if (_destinations[mappingIndex].IsArray) - { - valuesByMapping[mappingIndex].Add(decodedValue); - } - else - { - valuesByMapping[mappingIndex].SetSingle(decodedValue); - } - } - } - - // Finally, emit the parameter attributes by parsing all the string segments and building arrays - for (var mappingIndex = 0; mappingIndex < _destinations.Length; mappingIndex++) - { - ref var destination = ref _destinations[mappingIndex]; - ref var values = ref valuesByMapping[mappingIndex]; + throw new InvalidOperationException($"Querystring values cannot be parsed as type '{elementType}'."); + } - var parsedValue = destination.IsArray - ? destination.Parser.ParseMultiple(values, destination.ComponentParameterName) - : values.Count == 0 - ? default - : destination.Parser.Parse(values[0].Span, destination.ComponentParameterName); + var values = _queryParameterValuesByName.GetValueOrDefault(queryParameterName.AsMemory()); - builder.AddComponentParameter(0, destination.ComponentParameterName, parsedValue); - } - } - finally + if (isArray) { - ArrayPool.Shared.Return(valuesByMapping, true); + return parser.ParseMultiple(values, queryParameterName); } - } - - private static QueryParameterMapping[]? GetSortedMappings([DynamicallyAccessedMembers(Component)] Type componentType) - { - var candidateProperties = MemberAssignment.GetPropertiesIncludingInherited(componentType, ComponentProperties.BindablePropertyFlags); - HashSet>? usedQueryParameterNames = null; - List? mappings = null; - foreach (var propertyInfo in candidateProperties) + if (values.Count > 0) { - if (!propertyInfo.IsDefined(typeof(ParameterAttribute))) - { - continue; - } - - var fromQueryAttribute = propertyInfo.GetCustomAttribute(); - if (fromQueryAttribute is not null) - { - // Found a parameter that's assignable from querystring - var componentParameterName = propertyInfo.Name; - var queryParameterName = (string.IsNullOrEmpty(fromQueryAttribute.Name) - ? componentParameterName - : fromQueryAttribute.Name).AsMemory(); - - // If it's an array type, capture that info and prepare to parse the element type - Type effectiveType = propertyInfo.PropertyType; - var isArray = false; - if (effectiveType.IsArray) - { - isArray = true; - effectiveType = effectiveType.GetElementType()!; - } - - if (!UrlValueConstraint.TryGetByTargetType(effectiveType, out var parser)) - { - throw new NotSupportedException($"Querystring values cannot be parsed as type '{propertyInfo.PropertyType}'."); - } - - // Add the destination for this component parameter name - usedQueryParameterNames ??= new(QueryParameterNameComparer.Instance); - if (usedQueryParameterNames.Contains(queryParameterName)) - { - throw new InvalidOperationException($"The component '{componentType}' declares more than one mapping for the query parameter '{queryParameterName}'."); - } - usedQueryParameterNames.Add(queryParameterName); - - mappings ??= new(); - mappings.Add(new QueryParameterMapping - { - QueryParameterName = queryParameterName, - Destination = new QueryParameterDestination(componentParameterName, parser, isArray) - }); - } + return parser.Parse(values[0].Span, queryParameterName); } - mappings?.Sort((a, b) => QueryParameterNameComparer.Instance.Compare(a.QueryParameterName, b.QueryParameterName)); - return mappings?.ToArray(); - } - - private readonly struct QueryParameterMapping - { - public ReadOnlyMemory QueryParameterName { get; init; } - public QueryParameterDestination Destination { get; init; } + return default; } - private readonly struct QueryParameterDestination + public static bool CanSupplyValueForType(Type targetType) { - public readonly string ComponentParameterName; - public readonly UrlValueConstraint Parser; - public readonly bool IsArray; - - public QueryParameterDestination(string componentParameterName, UrlValueConstraint parser, bool isArray) - { - ComponentParameterName = componentParameterName; - Parser = parser; - IsArray = isArray; - } + var elementType = targetType.IsArray ? targetType.GetElementType()! : targetType; + return UrlValueConstraint.TryGetByTargetType(elementType, out _); } } diff --git a/src/Components/Components/src/Sections/ISectionContentSubscriber.cs b/src/Components/Components/src/Sections/ISectionContentSubscriber.cs deleted file mode 100644 index eb87f5557465..000000000000 --- a/src/Components/Components/src/Sections/ISectionContentSubscriber.cs +++ /dev/null @@ -1,9 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace Microsoft.AspNetCore.Components.Sections; - -internal interface ISectionContentSubscriber -{ - void ContentChanged(RenderFragment? content); -} diff --git a/src/Components/Components/src/Sections/SectionContent.cs b/src/Components/Components/src/Sections/SectionContent.cs index 6929434b4292..c2d402ff6d97 100644 --- a/src/Components/Components/src/Sections/SectionContent.cs +++ b/src/Components/Components/src/Sections/SectionContent.cs @@ -6,7 +6,7 @@ namespace Microsoft.AspNetCore.Components.Sections; /// /// Provides content to components with matching s. /// -public sealed class SectionContent : ISectionContentProvider, IComponent, IDisposable +public sealed class SectionContent : IComponent, IDisposable { private object? _registeredIdentifier; private bool? _registeredIsDefaultContent; @@ -35,8 +35,6 @@ public sealed class SectionContent : ISectionContentProvider, IComponent, IDispo /// [Parameter] public RenderFragment? ChildContent { get; set; } - RenderFragment? ISectionContentProvider.Content => ChildContent; - void IComponent.Attach(RenderHandle renderHandle) { _registry = renderHandle.Dispatcher.SectionRegistry; @@ -79,7 +77,7 @@ Task IComponent.SetParametersAsync(ParameterView parameters) _registeredIsDefaultContent = IsDefaultContent; } - _registry.NotifyContentChanged(identifier, this); + _registry.NotifyContentProviderChanged(identifier, this); return Task.CompletedTask; } diff --git a/src/Components/Components/src/Sections/SectionOutlet.cs b/src/Components/Components/src/Sections/SectionOutlet.cs index f8595f8f1d35..89b014d907d7 100644 --- a/src/Components/Components/src/Sections/SectionOutlet.cs +++ b/src/Components/Components/src/Sections/SectionOutlet.cs @@ -1,20 +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 Microsoft.AspNetCore.Components.Rendering; + namespace Microsoft.AspNetCore.Components.Sections; /// /// Renders content provided by components with matching s. /// -[StreamRendering(true)] // Because the content may be provided by a streaming component -public sealed class SectionOutlet : ISectionContentSubscriber, IComponent, IDisposable +public sealed class SectionOutlet : IComponent, IDisposable { private static readonly RenderFragment _emptyRenderFragment = _ => { }; - private object? _subscribedIdentifier; private RenderHandle _renderHandle; private SectionRegistry _registry = default!; - private RenderFragment? _content; + private SectionContent? _currentContentProvider; /// /// Gets or sets the ID that determines which instances will provide @@ -28,6 +28,8 @@ public sealed class SectionOutlet : ISectionContentSubscriber, IComponent, IDisp /// [Parameter] public object? SectionId { get; set; } + internal IComponent? CurrentLogicalParent => _currentContentProvider; + void IComponent.Attach(RenderHandle renderHandle) { _renderHandle = renderHandle; @@ -73,9 +75,9 @@ Task IComponent.SetParametersAsync(ParameterView parameters) return Task.CompletedTask; } - void ISectionContentSubscriber.ContentChanged(RenderFragment? content) + internal void ContentUpdated(SectionContent? provider) { - _content = content; + _currentContentProvider = provider; RenderContent(); } @@ -89,7 +91,17 @@ private void RenderContent() return; } - _renderHandle.Render(_content ?? _emptyRenderFragment); + _renderHandle.Render(BuildRenderTree); + } + + private void BuildRenderTree(RenderTreeBuilder builder) + { + var fragment = _currentContentProvider?.ChildContent ?? _emptyRenderFragment; + + builder.OpenComponent(0); + builder.SetKey(fragment); + builder.AddComponentParameter(1, SectionOutletContentRenderer.ContentParameterName, fragment); + builder.CloseComponent(); } /// @@ -100,4 +112,33 @@ public void Dispose() _registry.Unsubscribe(_subscribedIdentifier); } } + + // This component simply renders the RenderFragment it is given + // The reason for rendering SectionOutlet output via this component is so that + // [1] We can use @key to guarantee that we only preserve descendant component + // instances when they come from the same SectionContent, not unrelated ones + // [2] We know that whenever the SectionContent is changed to another one, there + // will be a new ComponentState established to represent this intermediate + // component, and it will already have the correct LogicalParentComponentState + // so anything computed from this (e.g., whether or not streaming rendering is + // enabled) will be freshly re-evaluated, without that information having to + // change in place on an existing ComponentState. + internal sealed class SectionOutletContentRenderer : IComponent + { + public const string ContentParameterName = "content"; + + private RenderHandle _renderHandle; + + public void Attach(RenderHandle renderHandle) + { + _renderHandle = renderHandle; + } + + public Task SetParametersAsync(ParameterView parameters) + { + var fragment = parameters.GetValueOrDefault(ContentParameterName)!; + _renderHandle.Render(fragment); + return Task.CompletedTask; + } + } } diff --git a/src/Components/Components/src/Sections/SectionRegistry.cs b/src/Components/Components/src/Sections/SectionRegistry.cs index 3e5a0908c5d1..d722034cf993 100644 --- a/src/Components/Components/src/Sections/SectionRegistry.cs +++ b/src/Components/Components/src/Sections/SectionRegistry.cs @@ -5,10 +5,10 @@ namespace Microsoft.AspNetCore.Components.Sections; internal sealed class SectionRegistry { - private readonly Dictionary _subscribersByIdentifier = new(); - private readonly Dictionary> _providersByIdentifier = new(); + private readonly Dictionary _subscribersByIdentifier = new(); + private readonly Dictionary> _providersByIdentifier = new(); - public void AddProvider(object identifier, ISectionContentProvider provider, bool isDefaultProvider) + public void AddProvider(object identifier, SectionContent provider, bool isDefaultProvider) { if (!_providersByIdentifier.TryGetValue(identifier, out var providers)) { @@ -26,7 +26,7 @@ public void AddProvider(object identifier, ISectionContentProvider provider, boo } } - public void RemoveProvider(object identifier, ISectionContentProvider provider) + public void RemoveProvider(object identifier, SectionContent provider) { if (!_providersByIdentifier.TryGetValue(identifier, out var providers)) { @@ -46,12 +46,12 @@ public void RemoveProvider(object identifier, ISectionContentProvider provider) { // We just removed the most recently added provider, meaning we need to change // the current content to that of second most recently added provider. - var content = GetCurrentProviderContentOrDefault(providers); - NotifyContentChangedForSubscriber(identifier, content); + var contentProvider = GetCurrentProviderContentOrDefault(providers); + NotifyContentChangedForSubscriber(identifier, contentProvider); } } - public void Subscribe(object identifier, ISectionContentSubscriber subscriber) + public void Subscribe(object identifier, SectionOutlet subscriber) { if (_subscribersByIdentifier.ContainsKey(identifier)) { @@ -59,8 +59,8 @@ public void Subscribe(object identifier, ISectionContentSubscriber subscriber) } // Notify the new subscriber with any existing content. - var content = GetCurrentProviderContentOrDefault(identifier); - subscriber.ContentChanged(content); + var provider = GetCurrentProviderContentOrDefault(identifier); + subscriber.ContentUpdated(provider); _subscribersByIdentifier.Add(identifier, subscriber); } @@ -73,7 +73,7 @@ public void Unsubscribe(object identifier) } } - public void NotifyContentChanged(object identifier, ISectionContentProvider provider) + public void NotifyContentProviderChanged(object identifier, SectionContent provider) { if (!_providersByIdentifier.TryGetValue(identifier, out var providers)) { @@ -84,25 +84,25 @@ public void NotifyContentChanged(object identifier, ISectionContentProvider prov // most recently added provider changes. if (providers.Count != 0 && providers[^1] == provider) { - NotifyContentChangedForSubscriber(identifier, provider.Content); + NotifyContentChangedForSubscriber(identifier, provider); } } - private static RenderFragment? GetCurrentProviderContentOrDefault(List providers) + private static SectionContent? GetCurrentProviderContentOrDefault(List providers) => providers.Count != 0 - ? providers[^1].Content + ? providers[^1] : null; - private RenderFragment? GetCurrentProviderContentOrDefault(object identifier) + private SectionContent? GetCurrentProviderContentOrDefault(object identifier) => _providersByIdentifier.TryGetValue(identifier, out var existingList) ? GetCurrentProviderContentOrDefault(existingList) : null; - private void NotifyContentChangedForSubscriber(object identifier, RenderFragment? content) + private void NotifyContentChangedForSubscriber(object identifier, SectionContent? provider) { if (_subscribersByIdentifier.TryGetValue(identifier, out var subscriber)) { - subscriber.ContentChanged(content); + subscriber.ContentUpdated(provider); } } } diff --git a/src/Components/Components/src/SupplyParameterFromQueryAttribute.cs b/src/Components/Components/src/SupplyParameterFromQueryAttribute.cs index f88fe737c598..ffae75576ff7 100644 --- a/src/Components/Components/src/SupplyParameterFromQueryAttribute.cs +++ b/src/Components/Components/src/SupplyParameterFromQueryAttribute.cs @@ -8,11 +8,11 @@ namespace Microsoft.AspNetCore.Components; /// current URL querystring. They may also supply further values if the URL querystring changes. /// [AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)] -public sealed class SupplyParameterFromQueryAttribute : Attribute +public sealed class SupplyParameterFromQueryAttribute : CascadingParameterAttributeBase { /// /// Gets or sets the name of the querystring parameter. If null, the querystring /// parameter is assumed to have the same name as the associated property. /// - public string? Name { get; set; } + public override string? Name { get; set; } } diff --git a/src/Components/Components/test/CascadingModelBinderTest.cs b/src/Components/Components/test/CascadingModelBinderTest.cs index c20703b51d8f..48a6f5ecff15 100644 --- a/src/Components/Components/test/CascadingModelBinderTest.cs +++ b/src/Components/Components/test/CascadingModelBinderTest.cs @@ -20,6 +20,7 @@ public CascadingModelBinderTest() _navigationManager = new TestNavigationManager(); serviceCollection.AddSingleton(_navigationManager); serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); var services = serviceCollection.BuildServiceProvider(); _renderer = new TestRenderer(services); } diff --git a/src/Components/Components/test/CascadingParameterStateTest.cs b/src/Components/Components/test/CascadingParameterStateTest.cs index 6edf09f31e21..89a1a0148402 100644 --- a/src/Components/Components/test/CascadingParameterStateTest.cs +++ b/src/Components/Components/test/CascadingParameterStateTest.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Diagnostics.CodeAnalysis; using Microsoft.AspNetCore.Components.Binding; using Microsoft.AspNetCore.Components.Rendering; using Microsoft.AspNetCore.Components.Test.Helpers; @@ -83,7 +82,7 @@ public void FindCascadingParameters_IfHasPartialMatchesInAncestors_ReturnsMatche // Assert Assert.Collection(result, match => { - Assert.Equal(nameof(ComponentWithCascadingParams.CascadingParam2), match.LocalValueName); + Assert.Equal(nameof(ComponentWithCascadingParams.CascadingParam2), match.ParameterInfo.PropertyName); Assert.Same(states[1].Component, match.ValueSupplier); }); } @@ -103,15 +102,15 @@ public void FindCascadingParameters_IfHasMultipleMatchesInAncestors_ReturnsMatch var result = CascadingParameterState.FindCascadingParameters(states.Last()); // Assert - Assert.Collection(result.OrderBy(x => x.LocalValueName), + Assert.Collection(result.OrderBy(x => x.ParameterInfo.PropertyName), match => { - Assert.Equal(nameof(ComponentWithCascadingParams.CascadingParam1), match.LocalValueName); + Assert.Equal(nameof(ComponentWithCascadingParams.CascadingParam1), match.ParameterInfo.PropertyName); Assert.Same(states[3].Component, match.ValueSupplier); }, match => { - Assert.Equal(nameof(ComponentWithCascadingParams.CascadingParam2), match.LocalValueName); + Assert.Equal(nameof(ComponentWithCascadingParams.CascadingParam2), match.ParameterInfo.PropertyName); Assert.Same(states[1].Component, match.ValueSupplier); }); } @@ -129,15 +128,15 @@ public void FindCascadingParameters_InheritedParameters_ReturnsMatches() var result = CascadingParameterState.FindCascadingParameters(states.Last()); // Assert - Assert.Collection(result.OrderBy(x => x.LocalValueName), + Assert.Collection(result.OrderBy(x => x.ParameterInfo.PropertyName), match => { - Assert.Equal(nameof(ComponentWithCascadingParams.CascadingParam1), match.LocalValueName); + Assert.Equal(nameof(ComponentWithCascadingParams.CascadingParam1), match.ParameterInfo.PropertyName); Assert.Same(states[0].Component, match.ValueSupplier); }, match => { - Assert.Equal(nameof(ComponentWithInheritedCascadingParams.CascadingParam3), match.LocalValueName); + Assert.Equal(nameof(ComponentWithInheritedCascadingParams.CascadingParam3), match.ParameterInfo.PropertyName); Assert.Same(states[1].Component, match.ValueSupplier); }); } @@ -156,7 +155,7 @@ public void FindCascadingParameters_ComponentRequestsBaseType_ReturnsMatches() // Assert Assert.Collection(result, match => { - Assert.Equal(nameof(ComponentWithGenericCascadingParam.LocalName), match.LocalValueName); + Assert.Equal(nameof(ComponentWithGenericCascadingParam.LocalName), match.ParameterInfo.PropertyName); Assert.Same(states[0].Component, match.ValueSupplier); }); } @@ -175,7 +174,7 @@ public void FindCascadingParameters_ComponentRequestsImplementedInterface_Return // Assert Assert.Collection(result, match => { - Assert.Equal(nameof(ComponentWithGenericCascadingParam.LocalName), match.LocalValueName); + Assert.Equal(nameof(ComponentWithGenericCascadingParam.LocalName), match.ParameterInfo.PropertyName); Assert.Same(states[0].Component, match.ValueSupplier); }); } @@ -209,7 +208,7 @@ public void FindCascadingParameters_TypeAssignmentIsValidForNullValue_ReturnsMat // Assert Assert.Collection(result, match => { - Assert.Equal(nameof(ComponentWithGenericCascadingParam.LocalName), match.LocalValueName); + Assert.Equal(nameof(ComponentWithGenericCascadingParam.LocalName), match.ParameterInfo.PropertyName); Assert.Same(states[0].Component, match.ValueSupplier); }); } @@ -303,7 +302,7 @@ public void FindCascadingParameters_MatchingNameAndType_ReturnsMatches() // Assert Assert.Collection(result, match => { - Assert.Equal(nameof(ComponentWithNamedCascadingParam.SomeLocalName), match.LocalValueName); + Assert.Equal(nameof(ComponentWithNamedCascadingParam.SomeLocalName), match.ParameterInfo.PropertyName); Assert.Same(states[0].Component, match.ValueSupplier); }); } @@ -323,15 +322,15 @@ public void FindCascadingParameters_MultipleMatchingAncestors_ReturnsClosestMatc var result = CascadingParameterState.FindCascadingParameters(states.Last()); // Assert - Assert.Collection(result.OrderBy(x => x.LocalValueName), + Assert.Collection(result.OrderBy(x => x.ParameterInfo.PropertyName), match => { - Assert.Equal(nameof(ComponentWithCascadingParams.CascadingParam1), match.LocalValueName); + Assert.Equal(nameof(ComponentWithCascadingParams.CascadingParam1), match.ParameterInfo.PropertyName); Assert.Same(states[2].Component, match.ValueSupplier); }, match => { - Assert.Equal(nameof(ComponentWithCascadingParams.CascadingParam2), match.LocalValueName); + Assert.Equal(nameof(ComponentWithCascadingParams.CascadingParam2), match.ParameterInfo.PropertyName); Assert.Same(states[3].Component, match.ValueSupplier); }); } @@ -349,12 +348,12 @@ public void FindCascadingParameters_CanOverrideNonNullValueWithNull() var result = CascadingParameterState.FindCascadingParameters(states.Last()); // Assert - Assert.Collection(result.OrderBy(x => x.LocalValueName), + Assert.Collection(result.OrderBy(x => x.ParameterInfo.PropertyName), match => { - Assert.Equal(nameof(ComponentWithCascadingParams.CascadingParam1), match.LocalValueName); + Assert.Equal(nameof(ComponentWithCascadingParams.CascadingParam1), match.ParameterInfo.PropertyName); Assert.Same(states[1].Component, match.ValueSupplier); - Assert.Null(match.ValueSupplier.CurrentValue); + Assert.Null(match.ValueSupplier.GetCurrentValue(match.ParameterInfo)); }); } @@ -362,15 +361,14 @@ public void FindCascadingParameters_CanOverrideNonNullValueWithNull() public void FindCascadingParameters_HandlesSupplyParameterFromFormValues() { // Arrange + var provider = new TestCascadingFormModelBindingProvider + { + FormName = "", + CurrentValue = "some value", + }; var cascadingModelBinder = new CascadingModelBinder { - FormValueSupplier = new TestFormValueSupplier() - { - FormName = "", - ValueType = typeof(string), - BindResult = true, - BoundValue = "some value" - }, + ModelBindingProviders = new[] { provider }, Navigation = Mock.Of(), Name = "" }; @@ -393,17 +391,16 @@ public void FindCascadingParameters_HandlesSupplyParameterFromFormValues() public void FindCascadingParameters_HandlesSupplyParameterFromFormValues_WithName() { // Arrange + var provider = new TestCascadingFormModelBindingProvider + { + FormName = "some-name", + CurrentValue = "some value", + }; var cascadingModelBinder = new CascadingModelBinder { - FormValueSupplier = new TestFormValueSupplier() - { - FormName = "some-name", - ValueType = typeof(string), - BindResult = true, - BoundValue = "some value" - }, + ModelBindingProviders = new[] { provider }, Navigation = new TestNavigationManager(), - Name = "" + Name = "some-name" }; cascadingModelBinder.UpdateBindingInformation("https://localhost/"); @@ -519,32 +516,25 @@ class CascadingValueTypeBaseClass { } class CascadingValueTypeDerivedClass : CascadingValueTypeBaseClass, ICascadingValueTypeDerivedClassInterface { } interface ICascadingValueTypeDerivedClassInterface { } - private class TestFormValueSupplier : IFormValueSupplier + private class TestCascadingFormModelBindingProvider : CascadingModelBindingProvider { - public string FormName { get; set; } + public required string FormName { get; init; } - public Type ValueType { get; set; } + public required string CurrentValue { get; init; } - public object BoundValue { get; set; } + protected internal override bool AreValuesFixed => true; - public bool BindResult { get; set; } + protected internal override bool CanSupplyValue(ModelBindingContext bindingContext, in CascadingParameterInfo parameterInfo) + => string.Equals(bindingContext.Name, FormName, StringComparison.Ordinal); - public bool CanBind(string formName, Type valueType) - { - return string.Equals(formName, FormName, StringComparison.Ordinal) && - valueType == ValueType; - } + protected internal override object GetCurrentValue(ModelBindingContext bindingContext, in CascadingParameterInfo parameterInfo) + => CurrentValue; - public bool CanConvertSingleValue(Type type) - { - return type == ValueType; - } + protected internal override bool SupportsCascadingParameterAttributeType(Type attributeType) + => attributeType == typeof(SupplyParameterFromFormAttribute); - public bool TryBind(string formName, Type valueType, [NotNullWhen(true)] out object boundValue) - { - boundValue = BoundValue; - return BindResult; - } + protected internal override bool SupportsParameterType(Type parameterType) + => parameterType == typeof(string); } class TestNavigationManager : NavigationManager @@ -557,11 +547,11 @@ public TestNavigationManager() } [AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)] -public sealed class SupplyParameterFromFormAttribute : Attribute, IHostEnvironmentCascadingParameter +public sealed class SupplyParameterFromFormAttribute : CascadingParameterAttributeBase { /// /// Gets or sets the name for the parameter. The name is used to match /// the form data and decide whether or not the value needs to be bound. /// - public string Name { get; set; } + public override string Name { get; set; } } diff --git a/src/Components/Components/test/CascadingParameterTest.cs b/src/Components/Components/test/CascadingParameterTest.cs index 0330b92fede7..ba97eeb110f0 100644 --- a/src/Components/Components/test/CascadingParameterTest.cs +++ b/src/Components/Components/test/CascadingParameterTest.cs @@ -384,6 +384,59 @@ public void ParameterViewSuppliedWithCascadingParametersCannotBeUsedAfterSynchro Assert.Equal($"The {nameof(ParameterView)} instance can no longer be read because it has expired. {nameof(ParameterView)} can only be read synchronously and must not be stored for later use.", ex.Message); } + [Fact] + public void CanSupplyCascadingValuesForSpecificCascadingParameterAttributeType() + { + // Arrange + var renderer = new TestRenderer(); + var component = new TestComponent(builder => + { + builder.OpenComponent>(0); + builder.AddComponentParameter(1, "Value", "Hello 1"); + builder.AddComponentParameter(2, "ChildContent", new RenderFragment(builder => + { + builder.OpenComponent>(0); + builder.AddComponentParameter(1, "Value", "Hello 2"); + builder.AddComponentParameter(2, "ChildContent", new RenderFragment(builder => + { + builder.OpenComponent(0); + builder.CloseComponent(); + builder.OpenComponent(1); + builder.CloseComponent(); + })); + builder.CloseComponent(); + })); + builder.CloseComponent(); + }); + + // Act/Assert + var componentId = renderer.AssignRootComponentId(component); + component.TriggerRender(); + var batch = renderer.Batches.Single(); + var nestedComponent1 = FindComponent(batch, out var nestedComponentId1); + var nestedComponent2 = FindComponent(batch, out var nestedComponentId2); + var nestedComponentDiff1 = batch.DiffsByComponentId[nestedComponentId1].Single(); + var nestedComponentDiff2 = batch.DiffsByComponentId[nestedComponentId2].Single(); + + // The nested components were rendered with the correct parameters + Assert.Collection(nestedComponentDiff1.Edits, + edit => + { + Assert.Equal(RenderTreeEditType.PrependFrame, edit.Type); + AssertFrame.Text( + batch.ReferenceFrames[edit.ReferenceFrameIndex], + "Value 1 is 'Hello 1'."); + }); + Assert.Collection(nestedComponentDiff2.Edits, + edit => + { + Assert.Equal(RenderTreeEditType.PrependFrame, edit.Type); + AssertFrame.Text( + batch.ReferenceFrames[edit.ReferenceFrameIndex], + "Value 2 is 'Hello 2'."); + }); + } + private static T FindComponent(CapturedBatch batch, out int componentId) { var componentFrame = batch.ReferenceFrames.Single( @@ -441,4 +494,79 @@ class SecondCascadingParameterConsumerComponent : CascadingParameterCons { [CascadingParameter] T2 SecondCascadingParameter { get; set; } } + + [AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)] + class CustomCascadingParameter1Attribute : CascadingParameterAttributeBase + { + public override string Name { get; set; } + } + + [AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)] + class CustomCascadingParameter2Attribute : CascadingParameterAttributeBase + { + public override string Name { get; set; } + } + + class CustomCascadingValueProducer : AutoRenderComponent, ICascadingValueSupplier + { + [Parameter] public object Value { get; set; } + + [Parameter] public RenderFragment ChildContent { get; set; } + + bool ICascadingValueSupplier.IsFixed => true; + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + builder.AddContent(0, ChildContent); + } + + bool ICascadingValueSupplier.CanSupplyValue(in CascadingParameterInfo parameterInfo) + { + if (parameterInfo.Attribute is not TAttribute || + parameterInfo.PropertyType != typeof(object) || + parameterInfo.PropertyName != nameof(Value)) + { + return false; + } + + return true; + } + + object ICascadingValueSupplier.GetCurrentValue(in CascadingParameterInfo cascadingParameterState) + { + return Value; + } + + void ICascadingValueSupplier.Subscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo) + { + throw new NotImplementedException(); + } + + void ICascadingValueSupplier.Unsubscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo) + { + throw new NotImplementedException(); + } + } + + class CustomCascadingValueConsumer1 : AutoRenderComponent + { + [CustomCascadingParameter1(Name = nameof(Value))] + public object Value { get; set; } + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + builder.AddContent(0, $"Value 1 is '{Value}'."); + } + } + + class CustomCascadingValueConsumer2 : AutoRenderComponent + { + [CustomCascadingParameter2(Name = nameof(Value))] + public object Value { get; set; } + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + builder.AddContent(0, $"Value 2 is '{Value}'."); + } + } } diff --git a/src/Components/Components/test/ComponentFactoryTest.cs b/src/Components/Components/test/ComponentFactoryTest.cs index 6c8ed10fcf28..7eca91d4471a 100644 --- a/src/Components/Components/test/ComponentFactoryTest.cs +++ b/src/Components/Components/test/ComponentFactoryTest.cs @@ -1,11 +1,10 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Microsoft.AspNetCore.Components.Rendering; +using System.Reflection; using Microsoft.AspNetCore.Components.RenderTree; using Microsoft.AspNetCore.Components.Test.Helpers; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; namespace Microsoft.AspNetCore.Components; @@ -19,7 +18,7 @@ public void InstantiateComponent_CreatesInstance() var factory = new ComponentFactory(new DefaultComponentActivator(), new TestRenderer()); // Act - var instance = factory.InstantiateComponent(GetServiceProvider(), componentType, null); + var instance = factory.InstantiateComponent(GetServiceProvider(), componentType, null, null); // Assert Assert.NotNull(instance); @@ -34,7 +33,7 @@ public void InstantiateComponent_CreatesInstance_NonComponent() var factory = new ComponentFactory(new DefaultComponentActivator(), new TestRenderer()); // Assert - var ex = Assert.Throws(() => factory.InstantiateComponent(GetServiceProvider(), componentType, null)); + var ex = Assert.Throws(() => factory.InstantiateComponent(GetServiceProvider(), componentType, null, null)); Assert.StartsWith($"The type {componentType.FullName} does not implement {nameof(IComponent)}.", ex.Message); } @@ -46,7 +45,7 @@ public void InstantiateComponent_CreatesInstance_WithCustomActivator() var factory = new ComponentFactory(new CustomComponentActivator(), new TestRenderer()); // Act - var instance = factory.InstantiateComponent(GetServiceProvider(), componentType, null); + var instance = factory.InstantiateComponent(GetServiceProvider(), componentType, null, null); // Assert Assert.NotNull(instance); @@ -67,7 +66,7 @@ public void InstantiateComponent_ThrowsForNullInstance() var factory = new ComponentFactory(new NullResultComponentActivator(), new TestRenderer()); // Act - var ex = Assert.Throws(() => factory.InstantiateComponent(GetServiceProvider(), componentType, null)); + var ex = Assert.Throws(() => factory.InstantiateComponent(GetServiceProvider(), componentType, null, null)); Assert.Equal($"The component activator returned a null value for a component of type {componentType.FullName}.", ex.Message); } @@ -79,7 +78,7 @@ public void InstantiateComponent_AssignsPropertiesWithInjectAttributeOnBaseType( var factory = new ComponentFactory(new CustomComponentActivator(), new TestRenderer()); // Act - var instance = factory.InstantiateComponent(GetServiceProvider(), componentType, null); + var instance = factory.InstantiateComponent(GetServiceProvider(), componentType, null, null); // Assert Assert.NotNull(instance); @@ -102,7 +101,7 @@ public void InstantiateComponent_IgnoresPropertiesWithoutInjectAttribute() var factory = new ComponentFactory(new DefaultComponentActivator(), new TestRenderer()); // Act - var instance = factory.InstantiateComponent(GetServiceProvider(), componentType, null); + var instance = factory.InstantiateComponent(GetServiceProvider(), componentType, null, null); // Assert Assert.NotNull(instance); @@ -122,7 +121,7 @@ public void InstantiateComponent_WithNoRenderMode_DoesNotUseRenderModeResolver() var factory = new ComponentFactory(new DefaultComponentActivator(), renderer); // Act - var instance = factory.InstantiateComponent(GetServiceProvider(), componentType, null); + var instance = factory.InstantiateComponent(GetServiceProvider(), componentType, null, null); // Assert Assert.IsType(instance); @@ -140,7 +139,7 @@ public void InstantiateComponent_WithRenderModeOnComponent_UsesRenderModeResolve var factory = new ComponentFactory(componentActivator, renderer); // Act - var instance = (ComponentWithInjectProperties)factory.InstantiateComponent(GetServiceProvider(), componentType, 1234); + var instance = (ComponentWithInjectProperties)factory.InstantiateComponent(GetServiceProvider(), componentType, null, 1234); // Assert Assert.True(renderer.ResolverWasCalled); @@ -149,7 +148,52 @@ public void InstantiateComponent_WithRenderModeOnComponent_UsesRenderModeResolve Assert.Equal(componentType, renderer.RequestedComponentType); Assert.Equal(1234, renderer.SuppliedParentComponentId); Assert.Same(componentActivator, renderer.SuppliedActivator); - Assert.IsType(renderer.SuppliedComponentTypeRenderMode); + Assert.IsType(renderer.SuppliedRenderMode); + } + + [Fact] + public void InstantiateComponent_WithRenderModeOnCallSite_UsesRenderModeResolver() + { + // Arrange + // Notice that the requested component type is not the same as the resolved component type. This + // is intentional and shows that component factories are allowed to substitute other component types. + var resolvedComponent = new ComponentWithInjectProperties(); + var componentType = typeof(ComponentWithNonInjectableProperties); + var callSiteRenderMode = new TestRenderMode(); + var renderer = new RendererWithResolveComponentForRenderMode(resolvedComponent); + var componentActivator = new DefaultComponentActivator(); + var factory = new ComponentFactory(componentActivator, renderer); + + // Act + var instance = (ComponentWithInjectProperties)factory.InstantiateComponent(GetServiceProvider(), componentType, callSiteRenderMode, 1234); + + // Assert + Assert.Same(resolvedComponent, instance); + Assert.NotNull(instance.Property1); + Assert.Equal(componentType, renderer.RequestedComponentType); + Assert.Same(componentActivator, renderer.SuppliedActivator); + Assert.Same(callSiteRenderMode, renderer.SuppliedRenderMode); + Assert.Equal(1234, renderer.SuppliedParentComponentId); + } + + [Fact] + public void InstantiateComponent_WithRenderModeOnComponentAndCallSite_Throws() + { + // Arrange + var resolvedComponent = new ComponentWithInjectProperties(); + var componentType = typeof(ComponentWithRenderMode); + var renderer = new RendererWithResolveComponentForRenderMode(resolvedComponent); + var componentActivator = new DefaultComponentActivator(); + var factory = new ComponentFactory(componentActivator, renderer); + + // Even though the two rendermodes are literally the same object, we don't allow specifying any nonnull + // rendermode at the callsite if there's a nonnull fixed rendermode + var callsiteRenderMode = componentType.GetCustomAttribute().Mode; + + // Act/Assert + var ex = Assert.Throws(() => + factory.InstantiateComponent(GetServiceProvider(), componentType, callsiteRenderMode, 1234)); + Assert.Equal($"The component type '{componentType}' has a fixed rendermode of '{typeof(TestRenderMode)}', so it is not valid to specify any rendermode when using this component.", ex.Message); } private static IServiceProvider GetServiceProvider() @@ -279,7 +323,7 @@ public RendererWithResolveComponentForRenderMode(IComponent componentToReturn) : public Type RequestedComponentType { get; private set; } public int? SuppliedParentComponentId { get; private set; } public IComponentActivator SuppliedActivator { get; private set; } - public IComponentRenderMode SuppliedComponentTypeRenderMode { get; private set; } + public IComponentRenderMode SuppliedRenderMode { get; private set; } public override Dispatcher Dispatcher => throw new NotImplementedException(); @@ -293,13 +337,13 @@ protected override Task UpdateDisplayAsync(in RenderBatch renderBatch) throw new NotImplementedException(); } - protected internal override IComponent ResolveComponentForRenderMode(Type componentType, int? parentComponentId, IComponentActivator componentActivator, IComponentRenderMode componentTypeRenderMode) + protected internal override IComponent ResolveComponentForRenderMode(Type componentType, int? parentComponentId, IComponentActivator componentActivator, IComponentRenderMode renderMode) { ResolverWasCalled = true; RequestedComponentType = componentType; SuppliedParentComponentId = parentComponentId; SuppliedActivator = componentActivator; - SuppliedComponentTypeRenderMode = componentTypeRenderMode; + SuppliedRenderMode = renderMode; return _componentToReturn; } } diff --git a/src/Components/Components/test/ParameterViewTest.Assignment.cs b/src/Components/Components/test/ParameterViewTest.Assignment.cs index bcc032c7e080..a85b6d9cd03e 100644 --- a/src/Components/Components/test/ParameterViewTest.Assignment.cs +++ b/src/Components/Components/test/ParameterViewTest.Assignment.cs @@ -646,11 +646,10 @@ class HasNonPublicCascadingParameter class ParameterViewBuilder : IEnumerable { - private readonly List<(string Name, object Value, bool Cascading)> _keyValuePairs - = new List<(string, object, bool)>(); + private readonly List<(string Name, object Value, bool Cascading)> _parameters = new(); public void Add(string name, object value, bool cascading = false) - => _keyValuePairs.Add((name, value, cascading)); + => _parameters.Add((name, value, cascading)); public IEnumerator GetEnumerator() => throw new NotImplementedException(); @@ -660,11 +659,11 @@ public ParameterView Build() var builder = new RenderTreeBuilder(); builder.OpenComponent(0); - foreach (var kvp in _keyValuePairs) + foreach (var (name, value, cascading) in _parameters) { - if (!kvp.Cascading) + if (!cascading) { - builder.AddComponentParameter(1, kvp.Name, kvp.Value); + builder.AddComponentParameter(1, name, value); } } builder.CloseComponent(); @@ -672,11 +671,11 @@ public ParameterView Build() var view = new ParameterView(ParameterViewLifetime.Unbound, builder.GetFrames().Array, ownerIndex: 0); var cascadingParameters = new List(); - foreach (var kvp in _keyValuePairs) + foreach (var (name, value, cascading) in _parameters) { - if (kvp.Cascading) + if (cascading) { - cascadingParameters.Add(new CascadingParameterState(kvp.Name, new TestCascadingValueProvider(kvp.Value))); + cascadingParameters.Add(new CascadingParameterState(new(null, name, value.GetType()), new TestCascadingValueProvider(value))); } } @@ -684,28 +683,33 @@ public ParameterView Build() } } - private class TestCascadingValueProvider : ICascadingValueComponent + private class TestCascadingValueProvider : ICascadingValueSupplier { + private readonly object _value; + public TestCascadingValueProvider(object value) { - CurrentValue = value; + _value = value; } - public object CurrentValue { get; } - - public bool CurrentValueIsFixed => throw new NotImplementedException(); + public bool IsFixed => throw new NotImplementedException(); - public bool CanSupplyValue(Type valueType, string valueName) + public bool CanSupplyValue(in CascadingParameterInfo parameterInfo) { throw new NotImplementedException(); } - public void Subscribe(ComponentState subscriber) + public object GetCurrentValue(in CascadingParameterInfo parameterInfo) + { + return _value; + } + + public void Subscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo) { throw new NotImplementedException(); } - public void Unsubscribe(ComponentState subscriber) + public void Unsubscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo) { throw new NotImplementedException(); } diff --git a/src/Components/Components/test/ParameterViewTest.cs b/src/Components/Components/test/ParameterViewTest.cs index 740359e5cb0a..e4a358d491cc 100644 --- a/src/Components/Components/test/ParameterViewTest.cs +++ b/src/Components/Components/test/ParameterViewTest.cs @@ -95,8 +95,8 @@ public void EnumerationIncludesCascadingParameters() RenderTreeFrame.Attribute(1, "attribute 1", attribute1Value) }, 0).WithCascadingParameters(new List { - new CascadingParameterState("attribute 2", new TestCascadingValue(attribute2Value)), - new CascadingParameterState("attribute 3", new TestCascadingValue(attribute3Value)), + new CascadingParameterState(new(null, "attribute 2", attribute2Value.GetType()), new TestCascadingValue(attribute2Value)), + new CascadingParameterState(new(null, "attribute 3", attribute3Value.GetType()), new TestCascadingValue(attribute3Value)), }); // Assert @@ -190,7 +190,7 @@ public void CanGetValueOrDefault_WithNonExistingValue() RenderTreeFrame.Attribute(1, "some other entry", new object()) }, 0).WithCascadingParameters(new List { - new CascadingParameterState("another entry", new TestCascadingValue(null)) + new CascadingParameterState(new(null, "another entry", typeof(object)), new TestCascadingValue(null)) }); // Act @@ -305,9 +305,9 @@ public void CanGetValueOrDefault_WithMatchingCascadingParameter() RenderTreeFrame.Attribute(1, "unrelated value", new object()) }, 0).WithCascadingParameters(new List { - new CascadingParameterState("unrelated value 2", new TestCascadingValue(null)), - new CascadingParameterState("my entry", new TestCascadingValue(myEntryValue)), - new CascadingParameterState("unrelated value 3", new TestCascadingValue(null)), + new CascadingParameterState(new(null, "unrelated value 2", typeof(object)), new TestCascadingValue(null)), + new CascadingParameterState(new(null, "my entry", myEntryValue.GetType()), new TestCascadingValue(myEntryValue)), + new CascadingParameterState(new(null, "unrelated value 3", typeof(object)), new TestCascadingValue(null)), }); // Act @@ -595,24 +595,27 @@ public Task SetParametersAsync(ParameterView parameters) => throw new NotImplementedException(); } - private class TestCascadingValue : ICascadingValueComponent + private class TestCascadingValue : ICascadingValueSupplier { + private readonly object _value; + public TestCascadingValue(object value) { - CurrentValue = value; + _value = value; } - public object CurrentValue { get; } - - public bool CurrentValueIsFixed => false; + public bool IsFixed => false; - public bool CanSupplyValue(Type valueType, string valueName) + public bool CanSupplyValue(in CascadingParameterInfo parameterInfo) => throw new NotImplementedException(); - public void Subscribe(ComponentState subscriber) + public object GetCurrentValue(in CascadingParameterInfo parameterInfo) + => _value; + + public void Subscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo) => throw new NotImplementedException(); - public void Unsubscribe(ComponentState subscriber) + public void Unsubscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo) => throw new NotImplementedException(); } } diff --git a/src/Components/Components/test/RendererTest.cs b/src/Components/Components/test/RendererTest.cs index 02ecce2bbdcd..14520f21850f 100644 --- a/src/Components/Components/test/RendererTest.cs +++ b/src/Components/Components/test/RendererTest.cs @@ -3639,7 +3639,7 @@ public async Task ExceptionsDispatchedOffSyncContextCanBeHandledAsync() // Act renderer.AssignRootComponentId(component); await component.ExternalExceptionDispatch(exception); - + // Assert Assert.Same(exception, Assert.Single(renderer.HandledExceptions).GetBaseException()); } @@ -5232,6 +5232,24 @@ public void ThrowsForUnknownRenderMode_OnComponentType() Assert.Contains($"Cannot supply a component of type '{typeof(ComponentWithUnknownRenderMode)}' because the current platform does not support the render mode '{typeof(ComponentWithUnknownRenderMode.UnknownRenderMode)}'.", ex.Message); } + [Fact] + public void ThrowsForUnknownRenderMode_AtCallSite() + { + // Arrange + var renderer = new TestRenderer(); + var component = new TestComponent(builder => + { + builder.OpenComponent(0); + builder.AddComponentRenderMode(1, new ComponentWithUnknownRenderMode.UnknownRenderMode()); + builder.CloseComponent(); + }); + + // Act + var componentId = renderer.AssignRootComponentId(component); + var ex = Assert.Throws(component.TriggerRender); + Assert.Contains($"Cannot supply a component of type '{typeof(TestComponent)}' because the current platform does not support the render mode '{typeof(ComponentWithUnknownRenderMode.UnknownRenderMode)}'.", ex.Message); + } + [Fact] public void RenderModeResolverCanSupplyComponent_WithComponentTypeRenderMode() { @@ -5256,6 +5274,31 @@ public void RenderModeResolverCanSupplyComponent_WithComponentTypeRenderMode() Assert.Equal("Some message", resolvedComponent.Message); } + [Fact] + public void RenderModeResolverCanSupplyComponent_CallSiteRenderMode() + { + // Arrange + var renderer = new RendererWithRenderModeResolver(); + + var component = new TestComponent(builder => + { + builder.OpenComponent(0); + builder.AddComponentParameter(1, nameof(MessageComponent.Message), "Some message"); + builder.AddComponentRenderMode(2, new SubstituteComponentRenderMode()); + builder.CloseComponent(); + }); + + // Act + var componentId = renderer.AssignRootComponentId(component); + component.TriggerRender(); + + // Assert + var batch = renderer.Batches.Single(); + var componentFrames = batch.GetComponentFrames(); + var resolvedComponent = (MessageComponent)componentFrames.Single().Component; + Assert.Equal("Some message", resolvedComponent.Message); + } + [HasSubstituteComponentRenderMode] private class ComponentWithRenderMode : IComponent { @@ -5284,9 +5327,9 @@ public class UnknownRenderMode : IComponentRenderMode { } private class RendererWithRenderModeResolver : TestRenderer { - protected internal override IComponent ResolveComponentForRenderMode(Type componentType, int? parentComponentId, IComponentActivator componentActivator, IComponentRenderMode componentTypeRenderMode) + protected internal override IComponent ResolveComponentForRenderMode(Type componentType, int? parentComponentId, IComponentActivator componentActivator, IComponentRenderMode renderMode) { - return componentTypeRenderMode switch + return renderMode switch { SubstituteComponentRenderMode => componentActivator.CreateInstance(typeof(MessageComponent)), var other => throw new NotSupportedException($"{nameof(RendererWithRenderModeResolver)} should not have received rendermode {other}"), diff --git a/src/Components/Components/test/Rendering/RenderTreeBuilderTest.cs b/src/Components/Components/test/Rendering/RenderTreeBuilderTest.cs index 4a927561c38f..4c7d86aabea7 100644 --- a/src/Components/Components/test/Rendering/RenderTreeBuilderTest.cs +++ b/src/Components/Components/test/Rendering/RenderTreeBuilderTest.cs @@ -2080,6 +2080,130 @@ public void ReportsUnclosedRegionAsInvalid() Assert.StartsWith($"Render output is invalid for component of type '{typeof(TestComponent).FullName}'. A frame of type 'Region' was left unclosed.", ex.Message); } + [Fact] + public void ComponentHasNoFlagsByDefault() + { + // Arrange + var builder = new RenderTreeBuilder(); + + // Act + builder.OpenComponent(0); + builder.CloseComponent(); + + // Assert + Assert.Collection( + builder.GetFrames().AsEnumerable(), + frame => + { + AssertFrame.Component(frame, 1, 0); + Assert.Equal(default, frame.ComponentFrameFlags); + }); + } + + [Fact] + public void CanAddComponentRenderMode() + { + // Arrange + var builder = new RenderTreeBuilder(); + var renderMode = new TestRenderMode(); + + // Act + builder.OpenComponent(0); + builder.AddComponentParameter(1, "param", 123); + builder.AddComponentRenderMode(2, renderMode); + builder.CloseComponent(); + + // Assert + Assert.Collection( + builder.GetFrames().AsEnumerable(), + frame => + { + AssertFrame.Component(frame, 3, 0); + Assert.True(frame.ComponentFrameFlags.HasFlag(ComponentFrameFlags.HasCallerSpecifiedRenderMode)); + }, + frame => AssertFrame.Attribute(frame, "param", 123, 1), + frame => AssertFrame.ComponentRenderMode(frame, renderMode, 2)); + } + + [Fact] + public void CannotAddComponentRenderModeToElement() + { + // Arrange + var builder = new RenderTreeBuilder(); + builder.OpenElement(0, "something"); + + // Act/Assert + var ex = Assert.Throws(() => + { + builder.AddComponentRenderMode(1, new TestRenderMode()); + }); + Assert.Equal($"The enclosing frame is not of the required type '{nameof(RenderTreeFrameType.Component)}'.", ex.Message); + } + + [Fact] + public void CannotAddNullComponentRenderMode() + { + // Arrange + var builder = new RenderTreeBuilder(); + builder.OpenComponent(0); + + // Act/Assert + var ex = Assert.Throws(() => + { + builder.AddComponentRenderMode(1, null); + }); + Assert.Equal("renderMode", ex.ParamName); + } + + [Fact] + public void CannotAddParametersAfterComponentRenderMode() + { + // Arrange + var builder = new RenderTreeBuilder(); + builder.OpenComponent(0); + builder.AddComponentRenderMode(1, new TestRenderMode()); + + // Act/Assert + var ex = Assert.Throws(() => + { + builder.AddComponentParameter(2, "key", "value"); + }); + Assert.Equal($"Component parameters may only be added immediately after frames of type {RenderTreeFrameType.Component}", ex.Message); + } + + [Fact] + public void TemporaryApiForCallSiteComponentRenderModeWorksEvenIfOtherParameterAddedAfter() + { + // For the temporary syntax rendermode=@something (as opposed to @rendermode=@something), we can't guarantee + // the Razor compiler will emit the AddComponentParameter call for rendermode last. For example if there's a + // ChildContent it will actually emit that last. So for the temporary syntax to be usable it has to support + // other component parameters being added afterwards, even though that does not need to be supported for + // AddComponentRenderMode (since the compiler can be sure to add that after all component parameters). + + // Arrange + var builder = new RenderTreeBuilder(); + var renderMode = new TestRenderMode(); + + // Act + builder.OpenComponent(0); + builder.AddComponentParameter(1, "param", 123); + builder.AddComponentParameter(2, "@rendermode", renderMode); + builder.AddComponentParameter(3, "anotherparam", 456); + builder.CloseComponent(); + + // Assert + Assert.Collection( + builder.GetFrames().AsEnumerable(), + frame => + { + AssertFrame.Component(frame, 4, 0); + Assert.True(frame.ComponentFrameFlags.HasFlag(ComponentFrameFlags.HasCallerSpecifiedRenderMode)); + }, + frame => AssertFrame.Attribute(frame, "param", 123, 1), + frame => AssertFrame.Attribute(frame, "anotherparam", 456, 3), + frame => AssertFrame.ComponentRenderMode(frame, renderMode, 0)); + } + private class TestComponent : IComponent { public void Attach(RenderHandle renderHandle) { } diff --git a/src/Components/Components/test/RouteViewTest.cs b/src/Components/Components/test/RouteViewTest.cs index c791d7243363..3cccbd675a62 100644 --- a/src/Components/Components/test/RouteViewTest.cs +++ b/src/Components/Components/test/RouteViewTest.cs @@ -22,11 +22,12 @@ public RouteViewTest() _navigationManager = new RouteViewTestNavigationManager(); serviceCollection.AddSingleton(_navigationManager); serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); var services = serviceCollection.BuildServiceProvider(); _renderer = new TestRenderer(services); var componentFactory = new ComponentFactory(new DefaultComponentActivator(), _renderer); - _routeViewComponent = (RouteView)componentFactory.InstantiateComponent(services, typeof(RouteView), null); + _routeViewComponent = (RouteView)componentFactory.InstantiateComponent(services, typeof(RouteView), null, null); _routeViewComponentId = _renderer.AssignRootComponentId(_routeViewComponent); } diff --git a/src/Components/Components/test/Routing/QueryParameterValueSupplierTest.cs b/src/Components/Components/test/Routing/QueryParameterValueSupplierTest.cs index e96bd3a9deab..631b35b76ddd 100644 --- a/src/Components/Components/test/Routing/QueryParameterValueSupplierTest.cs +++ b/src/Components/Components/test/Routing/QueryParameterValueSupplierTest.cs @@ -1,98 +1,53 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Microsoft.AspNetCore.Components.Rendering; +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. namespace Microsoft.AspNetCore.Components.Routing; public class QueryParameterValueSupplierTest { - private class NoQueryParameters : ComponentBase { } - - [Fact] - public void ComponentWithNoQueryParametersHasNoSupplier() - { - Assert.Null(QueryParameterValueSupplier.ForType(typeof(NoQueryParameters))); - } - - private class IgnorableProperties : ComponentBase - { - [Parameter] public string Invalid1 { get; set; } - [SupplyParameterFromQuery] public string Invalid2 { get; set; } - [Parameter, SupplyParameterFromQuery] public string Valid { get; set; } - [Parameter] public object InvalidAndUnsupportedType { get; set; } - } - - [Fact] - public void SuppliesParametersOnlyForPropertiesWithMatchingAttributes() - { - var query = $"?{nameof(IgnorableProperties.Invalid1)}=a&{nameof(IgnorableProperties.Invalid2)}=b&{nameof(IgnorableProperties.Valid)}=c"; - Assert.Collection(GetSuppliedParameters(query), - AssertKeyValuePair(nameof(IgnorableProperties.Valid), "c")); - } - - private class ValidTypes : ComponentBase - { - [Parameter, SupplyParameterFromQuery] public bool BoolVal { get; set; } - [Parameter, SupplyParameterFromQuery] public DateTime DateTimeVal { get; set; } - [Parameter, SupplyParameterFromQuery] public decimal DecimalVal { get; set; } - [Parameter, SupplyParameterFromQuery] public double DoubleVal { get; set; } - [Parameter, SupplyParameterFromQuery] public float FloatVal { get; set; } - [Parameter, SupplyParameterFromQuery] public Guid GuidVal { get; set; } - [Parameter, SupplyParameterFromQuery] public int IntVal { get; set; } - [Parameter, SupplyParameterFromQuery] public long LongVal { get; set; } - [Parameter, SupplyParameterFromQuery] public string StringVal { get; set; } - - [Parameter, SupplyParameterFromQuery] public bool? NullableBoolVal { get; set; } - [Parameter, SupplyParameterFromQuery] public DateTime? NullableDateTimeVal { get; set; } - [Parameter, SupplyParameterFromQuery] public decimal? NullableDecimalVal { get; set; } - [Parameter, SupplyParameterFromQuery] public double? NullableDoubleVal { get; set; } - [Parameter, SupplyParameterFromQuery] public float? NullableFloatVal { get; set; } - [Parameter, SupplyParameterFromQuery] public Guid? NullableGuidVal { get; set; } - [Parameter, SupplyParameterFromQuery] public int? NullableIntVal { get; set; } - [Parameter, SupplyParameterFromQuery] public long? NullableLongVal { get; set; } - } + private readonly QueryParameterValueSupplier _supplier = new(); [Fact] public void SupportsExpectedValueTypes() { var query = - $"{nameof(ValidTypes.BoolVal)}=true&" + - $"{nameof(ValidTypes.DateTimeVal)}=2020-01-02+03:04:05.678-09:00&" + - $"{nameof(ValidTypes.DecimalVal)}=-1.234&" + - $"{nameof(ValidTypes.DoubleVal)}=-2.345&" + - $"{nameof(ValidTypes.FloatVal)}=-3.456&" + - $"{nameof(ValidTypes.GuidVal)}=9e7257ad-03aa-42c7-9819-be08b177fef9&" + - $"{nameof(ValidTypes.IntVal)}=-54321&" + - $"{nameof(ValidTypes.LongVal)}=-99987654321&" + - $"{nameof(ValidTypes.StringVal)}=Some+string+%26+more&" + - $"{nameof(ValidTypes.NullableBoolVal)}=true&" + - $"{nameof(ValidTypes.NullableDateTimeVal)}=2021-01-02+03:04:05.678Z&" + - $"{nameof(ValidTypes.NullableDecimalVal)}=1.234&" + - $"{nameof(ValidTypes.NullableDoubleVal)}=2.345&" + - $"{nameof(ValidTypes.NullableFloatVal)}=3.456&" + - $"{nameof(ValidTypes.NullableGuidVal)}=1e7257ad-03aa-42c7-9819-be08b177fef9&" + - $"{nameof(ValidTypes.NullableIntVal)}=54321&" + - $"{nameof(ValidTypes.NullableLongVal)}=99987654321&"; - - Assert.Collection(GetSuppliedParameters(query), - AssertKeyValuePair(nameof(ValidTypes.BoolVal), true), - AssertKeyValuePair(nameof(ValidTypes.DateTimeVal), new DateTimeOffset(2020, 1, 2, 3, 4, 5, 678, TimeSpan.FromHours(-9)).LocalDateTime), - AssertKeyValuePair(nameof(ValidTypes.DecimalVal), -1.234m), - AssertKeyValuePair(nameof(ValidTypes.DoubleVal), -2.345), - AssertKeyValuePair(nameof(ValidTypes.FloatVal), -3.456f), - AssertKeyValuePair(nameof(ValidTypes.GuidVal), new Guid("9e7257ad-03aa-42c7-9819-be08b177fef9")), - AssertKeyValuePair(nameof(ValidTypes.IntVal), -54321), - AssertKeyValuePair(nameof(ValidTypes.LongVal), -99987654321), - AssertKeyValuePair(nameof(ValidTypes.NullableBoolVal), true), - AssertKeyValuePair(nameof(ValidTypes.NullableDateTimeVal), new DateTime(2021, 1, 2, 3, 4, 5, 678, DateTimeKind.Utc).ToLocalTime()), - AssertKeyValuePair(nameof(ValidTypes.NullableDecimalVal), 1.234m), - AssertKeyValuePair(nameof(ValidTypes.NullableDoubleVal), 2.345), - AssertKeyValuePair(nameof(ValidTypes.NullableFloatVal), 3.456f), - AssertKeyValuePair(nameof(ValidTypes.NullableGuidVal), new Guid("1e7257ad-03aa-42c7-9819-be08b177fef9")), - AssertKeyValuePair(nameof(ValidTypes.NullableIntVal), 54321), - AssertKeyValuePair(nameof(ValidTypes.NullableLongVal), 99987654321), - AssertKeyValuePair(nameof(ValidTypes.StringVal), "Some string & more")); + $"BoolVal=true&" + + $"DateTimeVal=2020-01-02+03:04:05.678-09:00&" + + $"DecimalVal=-1.234&" + + $"DoubleVal=-2.345&" + + $"FloatVal=-3.456&" + + $"GuidVal=9e7257ad-03aa-42c7-9819-be08b177fef9&" + + $"IntVal=-54321&" + + $"LongVal=-99987654321&" + + $"StringVal=Some+string+%26+more&" + + $"NullableBoolVal=true&" + + $"NullableDateTimeVal=2021-01-02+03:04:05.678Z&" + + $"NullableDecimalVal=1.234&" + + $"NullableDoubleVal=2.345&" + + $"NullableFloatVal=3.456&" + + $"NullableGuidVal=1e7257ad-03aa-42c7-9819-be08b177fef9&" + + $"NullableIntVal=54321&" + + $"NullableLongVal=99987654321&"; + + ReadQuery(query); + + AssertKeyValuePair("BoolVal", true); + AssertKeyValuePair("DateTimeVal", new DateTimeOffset(2020, 1, 2, 3, 4, 5, 678, TimeSpan.FromHours(-9)).LocalDateTime); + AssertKeyValuePair("DecimalVal", -1.234m); + AssertKeyValuePair("DoubleVal", -2.345); + AssertKeyValuePair("FloatVal", -3.456f); + AssertKeyValuePair("GuidVal", new Guid("9e7257ad-03aa-42c7-9819-be08b177fef9")); + AssertKeyValuePair("IntVal", -54321); + AssertKeyValuePair("LongVal", -99987654321); + AssertKeyValuePair("NullableBoolVal", true); + AssertKeyValuePair("NullableDateTimeVal", new DateTime(2021, 1, 2, 3, 4, 5, 678, DateTimeKind.Utc).ToLocalTime()); + AssertKeyValuePair("NullableDecimalVal", 1.234m); + AssertKeyValuePair("NullableDoubleVal", 2.345); + AssertKeyValuePair("NullableFloatVal", 3.456f); + AssertKeyValuePair("NullableGuidVal", new Guid("1e7257ad-03aa-42c7-9819-be08b177fef9")); + AssertKeyValuePair("NullableIntVal", 54321); + AssertKeyValuePair("NullableLongVal", 99987654321); + AssertKeyValuePair("StringVal", "Some string & more"); } [Theory] @@ -101,92 +56,72 @@ public void SupportsExpectedValueTypes() [InlineData("?unrelated=123")] public void SuppliesNullForValueTypesIfNotSpecified(string query) { - // Although we could supply default(T) for missing values, there's precedent in the routing - // system for supplying null for missing route parameters. The component is then responsible - // for interpreting null as a blank value for the parameter, regardless of its type. To keep - // the rules aligned, we do the same thing for querystring parameters. - Assert.Collection(GetSuppliedParameters(query), - AssertKeyValuePair(nameof(ValidTypes.BoolVal), (object)null), - AssertKeyValuePair(nameof(ValidTypes.DateTimeVal), (object)null), - AssertKeyValuePair(nameof(ValidTypes.DecimalVal), (object)null), - AssertKeyValuePair(nameof(ValidTypes.DoubleVal), (object)null), - AssertKeyValuePair(nameof(ValidTypes.FloatVal), (object)null), - AssertKeyValuePair(nameof(ValidTypes.GuidVal), (object)null), - AssertKeyValuePair(nameof(ValidTypes.IntVal), (object)null), - AssertKeyValuePair(nameof(ValidTypes.LongVal), (object)null), - AssertKeyValuePair(nameof(ValidTypes.NullableBoolVal), (object)null), - AssertKeyValuePair(nameof(ValidTypes.NullableDateTimeVal), (object)null), - AssertKeyValuePair(nameof(ValidTypes.NullableDecimalVal), (object)null), - AssertKeyValuePair(nameof(ValidTypes.NullableDoubleVal), (object)null), - AssertKeyValuePair(nameof(ValidTypes.NullableFloatVal), (object)null), - AssertKeyValuePair(nameof(ValidTypes.NullableGuidVal), (object)null), - AssertKeyValuePair(nameof(ValidTypes.NullableIntVal), (object)null), - AssertKeyValuePair(nameof(ValidTypes.NullableLongVal), (object)null), - AssertKeyValuePair(nameof(ValidTypes.StringVal), (object)null)); - } - - private class ValidArrayTypes : ComponentBase - { - [Parameter, SupplyParameterFromQuery] public bool[] BoolVals { get; set; } - [Parameter, SupplyParameterFromQuery] public DateTime[] DateTimeVals { get; set; } - [Parameter, SupplyParameterFromQuery] public decimal[] DecimalVals { get; set; } - [Parameter, SupplyParameterFromQuery] public double[] DoubleVals { get; set; } - [Parameter, SupplyParameterFromQuery] public float[] FloatVals { get; set; } - [Parameter, SupplyParameterFromQuery] public Guid[] GuidVals { get; set; } - [Parameter, SupplyParameterFromQuery] public int[] IntVals { get; set; } - [Parameter, SupplyParameterFromQuery] public long[] LongVals { get; set; } - [Parameter, SupplyParameterFromQuery] public string[] StringVals { get; set; } - - [Parameter, SupplyParameterFromQuery] public bool?[] NullableBoolVals { get; set; } - [Parameter, SupplyParameterFromQuery] public DateTime?[] NullableDateTimeVals { get; set; } - [Parameter, SupplyParameterFromQuery] public decimal?[] NullableDecimalVals { get; set; } - [Parameter, SupplyParameterFromQuery] public double?[] NullableDoubleVals { get; set; } - [Parameter, SupplyParameterFromQuery] public float?[] NullableFloatVals { get; set; } - [Parameter, SupplyParameterFromQuery] public Guid?[] NullableGuidVals { get; set; } - [Parameter, SupplyParameterFromQuery] public int?[] NullableIntVals { get; set; } - [Parameter, SupplyParameterFromQuery] public long?[] NullableLongVals { get; set; } + ReadQuery(query); + + // Although we could supply default(T) for missing values, there's precedent in the routing + // system for supplying null for missing route parameters. The component is then responsible + // for interpreting null as a blank value for the parameter, regardless of its type. To keep + // the rules aligned, we do the same thing for querystring parameters. + AssertKeyValuePair("BoolVal", null); + AssertKeyValuePair("DateTimeVal", null); + AssertKeyValuePair("DecimalVal", null); + AssertKeyValuePair("DoubleVal", null); + AssertKeyValuePair("FloatVal", null); + AssertKeyValuePair("GuidVal", null); + AssertKeyValuePair("IntVal", null); + AssertKeyValuePair("LongVal", null); + AssertKeyValuePair("NullableBoolVal", null); + AssertKeyValuePair("NullableDateTimeVal", null); + AssertKeyValuePair("NullableDecimalVal", null); + AssertKeyValuePair("NullableDoubleVal", null); + AssertKeyValuePair("NullableFloatVal", null); + AssertKeyValuePair("NullableGuidVal", null); + AssertKeyValuePair("NullableIntVal", null); + AssertKeyValuePair("NullableLongVal", null); + AssertKeyValuePair("StringVal", null); } [Fact] public void SupportsExpectedArrayTypes() { var query = - $"{nameof(ValidArrayTypes.BoolVals)}=true&" + - $"{nameof(ValidArrayTypes.DateTimeVals)}=2020-01-02+03:04:05.678Z&" + - $"{nameof(ValidArrayTypes.DecimalVals)}=-1.234&" + - $"{nameof(ValidArrayTypes.DoubleVals)}=-2.345&" + - $"{nameof(ValidArrayTypes.FloatVals)}=-3.456&" + - $"{nameof(ValidArrayTypes.GuidVals)}=9e7257ad-03aa-42c7-9819-be08b177fef9&" + - $"{nameof(ValidArrayTypes.IntVals)}=-54321&" + - $"{nameof(ValidArrayTypes.LongVals)}=-99987654321&" + - $"{nameof(ValidArrayTypes.StringVals)}=Some+string+%26+more&" + - $"{nameof(ValidArrayTypes.NullableBoolVals)}=true&" + - $"{nameof(ValidArrayTypes.NullableDateTimeVals)}=2021-01-02+03:04:05.678Z&" + - $"{nameof(ValidArrayTypes.NullableDecimalVals)}=1.234&" + - $"{nameof(ValidArrayTypes.NullableDoubleVals)}=2.345&" + - $"{nameof(ValidArrayTypes.NullableFloatVals)}=3.456&" + - $"{nameof(ValidArrayTypes.NullableGuidVals)}=1e7257ad-03aa-42c7-9819-be08b177fef9&" + - $"{nameof(ValidArrayTypes.NullableIntVals)}=54321&" + - $"{nameof(ValidArrayTypes.NullableLongVals)}=99987654321&"; - - Assert.Collection(GetSuppliedParameters(query), - AssertKeyValuePair(nameof(ValidArrayTypes.BoolVals), new[] { true }), - AssertKeyValuePair(nameof(ValidArrayTypes.DateTimeVals), new[] { new DateTime(2020, 1, 2, 3, 4, 5, 678, DateTimeKind.Utc).ToLocalTime() }), - AssertKeyValuePair(nameof(ValidArrayTypes.DecimalVals), new[] { -1.234m }), - AssertKeyValuePair(nameof(ValidArrayTypes.DoubleVals), new[] { -2.345 }), - AssertKeyValuePair(nameof(ValidArrayTypes.FloatVals), new[] { -3.456f }), - AssertKeyValuePair(nameof(ValidArrayTypes.GuidVals), new[] { new Guid("9e7257ad-03aa-42c7-9819-be08b177fef9") }), - AssertKeyValuePair(nameof(ValidArrayTypes.IntVals), new[] { -54321 }), - AssertKeyValuePair(nameof(ValidArrayTypes.LongVals), new[] { -99987654321 }), - AssertKeyValuePair(nameof(ValidArrayTypes.NullableBoolVals), new[] { true }), - AssertKeyValuePair(nameof(ValidArrayTypes.NullableDateTimeVals), new[] { new DateTime(2021, 1, 2, 3, 4, 5, 678, DateTimeKind.Utc).ToLocalTime() }), - AssertKeyValuePair(nameof(ValidArrayTypes.NullableDecimalVals), new[] { 1.234m }), - AssertKeyValuePair(nameof(ValidArrayTypes.NullableDoubleVals), new[] { 2.345 }), - AssertKeyValuePair(nameof(ValidArrayTypes.NullableFloatVals), new[] { 3.456f }), - AssertKeyValuePair(nameof(ValidArrayTypes.NullableGuidVals), new[] { new Guid("1e7257ad-03aa-42c7-9819-be08b177fef9") }), - AssertKeyValuePair(nameof(ValidArrayTypes.NullableIntVals), new[] { 54321 }), - AssertKeyValuePair(nameof(ValidArrayTypes.NullableLongVals), new[] { 99987654321 }), - AssertKeyValuePair(nameof(ValidArrayTypes.StringVals), new[] { "Some string & more" })); + $"BoolVals=true&" + + $"DateTimeVals=2020-01-02+03:04:05.678Z&" + + $"DecimalVals=-1.234&" + + $"DoubleVals=-2.345&" + + $"FloatVals=-3.456&" + + $"GuidVals=9e7257ad-03aa-42c7-9819-be08b177fef9&" + + $"IntVals=-54321&" + + $"LongVals=-99987654321&" + + $"StringVals=Some+string+%26+more&" + + $"NullableBoolVals=true&" + + $"NullableDateTimeVals=2021-01-02+03:04:05.678Z&" + + $"NullableDecimalVals=1.234&" + + $"NullableDoubleVals=2.345&" + + $"NullableFloatVals=3.456&" + + $"NullableGuidVals=1e7257ad-03aa-42c7-9819-be08b177fef9&" + + $"NullableIntVals=54321&" + + $"NullableLongVals=99987654321&"; + + ReadQuery(query); + + AssertKeyValuePair("BoolVals", new[] { true }); + AssertKeyValuePair("DateTimeVals", new[] { new DateTime(2020, 1, 2, 3, 4, 5, 678, DateTimeKind.Utc).ToLocalTime() }); + AssertKeyValuePair("DecimalVals", new[] { -1.234m }); + AssertKeyValuePair("DoubleVals", new[] { -2.345 }); + AssertKeyValuePair("FloatVals", new[] { -3.456f }); + AssertKeyValuePair("GuidVals", new[] { new Guid("9e7257ad-03aa-42c7-9819-be08b177fef9") }); + AssertKeyValuePair("IntVals", new[] { -54321 }); + AssertKeyValuePair("LongVals", new[] { -99987654321 }); + AssertKeyValuePair("NullableBoolVals", new[] { true }); + AssertKeyValuePair("NullableDateTimeVals", new[] { new DateTime(2021, 1, 2, 3, 4, 5, 678, DateTimeKind.Utc).ToLocalTime() }); + AssertKeyValuePair("NullableDecimalVals", new[] { 1.234m }); + AssertKeyValuePair("NullableDoubleVals", new[] { 2.345 }); + AssertKeyValuePair("NullableFloatVals", new[] { 3.456f }); + AssertKeyValuePair("NullableGuidVals", new[] { new Guid("1e7257ad-03aa-42c7-9819-be08b177fef9") }); + AssertKeyValuePair("NullableIntVals", new[] { 54321 }); + AssertKeyValuePair("NullableLongVals", new[] { 99987654321 }); + AssertKeyValuePair("StringVals", new[] { "Some string & more" }); } [Theory] @@ -195,133 +130,91 @@ public void SupportsExpectedArrayTypes() [InlineData("?unrelated=123")] public void SuppliesEmptyArrayForArrayTypesIfNotSpecified(string query) { - Assert.Collection(GetSuppliedParameters(query), - AssertKeyValuePair(nameof(ValidArrayTypes.BoolVals), Array.Empty()), - AssertKeyValuePair(nameof(ValidArrayTypes.DateTimeVals), Array.Empty()), - AssertKeyValuePair(nameof(ValidArrayTypes.DecimalVals), Array.Empty()), - AssertKeyValuePair(nameof(ValidArrayTypes.DoubleVals), Array.Empty()), - AssertKeyValuePair(nameof(ValidArrayTypes.FloatVals), Array.Empty()), - AssertKeyValuePair(nameof(ValidArrayTypes.GuidVals), Array.Empty()), - AssertKeyValuePair(nameof(ValidArrayTypes.IntVals), Array.Empty()), - AssertKeyValuePair(nameof(ValidArrayTypes.LongVals), Array.Empty()), - AssertKeyValuePair(nameof(ValidArrayTypes.NullableBoolVals), Array.Empty()), - AssertKeyValuePair(nameof(ValidArrayTypes.NullableDateTimeVals), Array.Empty()), - AssertKeyValuePair(nameof(ValidArrayTypes.NullableDecimalVals), Array.Empty()), - AssertKeyValuePair(nameof(ValidArrayTypes.NullableDoubleVals), Array.Empty()), - AssertKeyValuePair(nameof(ValidArrayTypes.NullableFloatVals), Array.Empty()), - AssertKeyValuePair(nameof(ValidArrayTypes.NullableGuidVals), Array.Empty()), - AssertKeyValuePair(nameof(ValidArrayTypes.NullableIntVals), Array.Empty()), - AssertKeyValuePair(nameof(ValidArrayTypes.NullableLongVals), Array.Empty()), - AssertKeyValuePair(nameof(ValidArrayTypes.StringVals), Array.Empty())); - } - - class OverrideParameterName : ComponentBase - { - [Parameter, SupplyParameterFromQuery(Name = "anothername1")] public string Value1 { get; set; } - [Parameter, SupplyParameterFromQuery(Name = "anothername2")] public string Value2 { get; set; } - } - - [Fact] - public void CanOverrideParameterName() - { - var query = $"anothername1=Some+value+1&Value2=Some+value+2"; - Assert.Collection(GetSuppliedParameters(query), - // Because we specified the mapped name, we receive the value - AssertKeyValuePair(nameof(OverrideParameterName.Value1), "Some value 1"), - // If we specify the component parameter name directly, we do not receive the value - AssertKeyValuePair(nameof(OverrideParameterName.Value2), (object)null)); - } - - class MapSingleQueryParameterToMultipleProperties : ComponentBase - { - [Parameter, SupplyParameterFromQuery(Name = "a")] public int ValueAsInt { get; set; } - [Parameter, SupplyParameterFromQuery(Name = "b")] public DateTime ValueAsDateTime { get; set; } - [Parameter, SupplyParameterFromQuery(Name = "A")] public long ValueAsLong { get; set; } - } - - [Fact] - public void CannotMapSingleQueryParameterToMultipleProperties() - { - var ex = Assert.Throws( - () => QueryParameterValueSupplier.ForType(typeof(MapSingleQueryParameterToMultipleProperties))); - Assert.Contains("declares more than one mapping for the query parameter 'a'.", ex.Message, StringComparison.OrdinalIgnoreCase); - } - - class UnsupportedType : ComponentBase - { - [Parameter, SupplyParameterFromQuery] public int IntValid { get; set; } - [Parameter, SupplyParameterFromQuery] public object ObjectValue { get; set; } - } - - [Fact] - public void RejectsUnsupportedType() - { - var ex = Assert.Throws( - () => QueryParameterValueSupplier.ForType(typeof(UnsupportedType))); - Assert.Equal("Querystring values cannot be parsed as type 'System.Object'.", ex.Message); + ReadQuery(query); + + AssertKeyValuePair("BoolVals", Array.Empty()); + AssertKeyValuePair("DateTimeVals", Array.Empty()); + AssertKeyValuePair("DecimalVals", Array.Empty()); + AssertKeyValuePair("DoubleVals", Array.Empty()); + AssertKeyValuePair("FloatVals", Array.Empty()); + AssertKeyValuePair("GuidVals", Array.Empty()); + AssertKeyValuePair("IntVals", Array.Empty()); + AssertKeyValuePair("LongVals", Array.Empty()); + AssertKeyValuePair("NullableBoolVals", Array.Empty()); + AssertKeyValuePair("NullableDateTimeVals", Array.Empty()); + AssertKeyValuePair("NullableDecimalVals", Array.Empty()); + AssertKeyValuePair("NullableDoubleVals", Array.Empty()); + AssertKeyValuePair("NullableFloatVals", Array.Empty()); + AssertKeyValuePair("NullableGuidVals", Array.Empty()); + AssertKeyValuePair("NullableIntVals", Array.Empty()); + AssertKeyValuePair("NullableLongVals", Array.Empty()); + AssertKeyValuePair("StringVals", Array.Empty()); } [Theory] - [InlineData(nameof(ValidTypes.BoolVal), "abc", typeof(bool))] - [InlineData(nameof(ValidTypes.DateTimeVal), "2020-02-31", typeof(DateTime))] - [InlineData(nameof(ValidTypes.DecimalVal), "1.2.3", typeof(decimal))] - [InlineData(nameof(ValidTypes.DoubleVal), "1x", typeof(double))] - [InlineData(nameof(ValidTypes.FloatVal), "1e1000", typeof(float))] - [InlineData(nameof(ValidTypes.GuidVal), "123456-789-0", typeof(Guid))] - [InlineData(nameof(ValidTypes.IntVal), "5000000000", typeof(int))] - [InlineData(nameof(ValidTypes.LongVal), "this+is+a+long+value", typeof(long))] - [InlineData(nameof(ValidTypes.NullableBoolVal), "abc", typeof(bool?))] - [InlineData(nameof(ValidTypes.NullableDateTimeVal), "2020-02-31", typeof(DateTime?))] - [InlineData(nameof(ValidTypes.NullableDecimalVal), "1.2.3", typeof(decimal?))] - [InlineData(nameof(ValidTypes.NullableDoubleVal), "1x", typeof(double?))] - [InlineData(nameof(ValidTypes.NullableFloatVal), "1e1000", typeof(float?))] - [InlineData(nameof(ValidTypes.NullableGuidVal), "123456-789-0", typeof(Guid?))] - [InlineData(nameof(ValidTypes.NullableIntVal), "5000000000", typeof(int?))] - [InlineData(nameof(ValidTypes.NullableLongVal), "this+is+a+long+value", typeof(long?))] + [InlineData("BoolVal", "abc", typeof(bool))] + [InlineData("DateTimeVal", "2020-02-31", typeof(DateTime))] + [InlineData("DecimalVal", "1.2.3", typeof(decimal))] + [InlineData("DoubleVal", "1x", typeof(double))] + [InlineData("FloatVal", "1e1000", typeof(float))] + [InlineData("GuidVal", "123456-789-0", typeof(Guid))] + [InlineData("IntVal", "5000000000", typeof(int))] + [InlineData("LongVal", "this+is+a+long+value", typeof(long))] + [InlineData("NullableBoolVal", "abc", typeof(bool?))] + [InlineData("NullableDateTimeVal", "2020-02-31", typeof(DateTime?))] + [InlineData("NullableDecimalVal", "1.2.3", typeof(decimal?))] + [InlineData("NullableDoubleVal", "1x", typeof(double?))] + [InlineData("NullableFloatVal", "1e1000", typeof(float?))] + [InlineData("NullableGuidVal", "123456-789-0", typeof(Guid?))] + [InlineData("NullableIntVal", "5000000000", typeof(int?))] + [InlineData("NullableLongVal", "this+is+a+long+value", typeof(long?))] public void RejectsUnparseableValues(string key, string value, Type targetType) { - var ex = Assert.Throws( - () => GetSuppliedParameters($"?{key}={value}")); + ReadQuery($"?{key}={value}"); + + var ex = Assert.Throws(() => _supplier.GetQueryParameterValue(targetType, key)); Assert.Equal($"Cannot parse the value '{value.Replace('+', ' ')}' as type '{targetType}' for '{key}'.", ex.Message); } [Theory] - [InlineData(nameof(ValidArrayTypes.BoolVals), "true", "abc", typeof(bool))] - [InlineData(nameof(ValidArrayTypes.DateTimeVals), "2020-02-28", "2020-02-31", typeof(DateTime))] - [InlineData(nameof(ValidArrayTypes.DecimalVals), "1.23", "1.2.3", typeof(decimal))] - [InlineData(nameof(ValidArrayTypes.DoubleVals), "1", "1x", typeof(double))] - [InlineData(nameof(ValidArrayTypes.FloatVals), "1000", "1e1000", typeof(float))] - [InlineData(nameof(ValidArrayTypes.GuidVals), "9e7257ad-03aa-42c7-9819-be08b177fef9", "123456-789-0", typeof(Guid))] - [InlineData(nameof(ValidArrayTypes.IntVals), "5000000", "5000000000", typeof(int))] - [InlineData(nameof(ValidArrayTypes.LongVals), "-1234", "this+is+a+long+value", typeof(long))] - [InlineData(nameof(ValidArrayTypes.NullableBoolVals), "true", "abc", typeof(bool?))] - [InlineData(nameof(ValidArrayTypes.NullableDateTimeVals), "2020-02-28", "2020-02-31", typeof(DateTime?))] - [InlineData(nameof(ValidArrayTypes.NullableDecimalVals), "1.23", "1.2.3", typeof(decimal?))] - [InlineData(nameof(ValidArrayTypes.NullableDoubleVals), "1", "1x", typeof(double?))] - [InlineData(nameof(ValidArrayTypes.NullableFloatVals), "1000", "1e1000", typeof(float?))] - [InlineData(nameof(ValidArrayTypes.NullableGuidVals), "9e7257ad-03aa-42c7-9819-be08b177fef9", "123456-789-0", typeof(Guid?))] - [InlineData(nameof(ValidArrayTypes.NullableIntVals), "5000000", "5000000000", typeof(int?))] - [InlineData(nameof(ValidArrayTypes.NullableLongVals), "-1234", "this+is+a+long+value", typeof(long?))] + [InlineData("BoolVals", "true", "abc", typeof(bool))] + [InlineData("DateTimeVals", "2020-02-28", "2020-02-31", typeof(DateTime))] + [InlineData("DecimalVals", "1.23", "1.2.3", typeof(decimal))] + [InlineData("DoubleVals", "1", "1x", typeof(double))] + [InlineData("FloatVals", "1000", "1e1000", typeof(float))] + [InlineData("GuidVals", "9e7257ad-03aa-42c7-9819-be08b177fef9", "123456-789-0", typeof(Guid))] + [InlineData("IntVals", "5000000", "5000000000", typeof(int))] + [InlineData("LongVals", "-1234", "this+is+a+long+value", typeof(long))] + [InlineData("NullableBoolVals", "true", "abc", typeof(bool?))] + [InlineData("NullableDateTimeVals", "2020-02-28", "2020-02-31", typeof(DateTime?))] + [InlineData("NullableDecimalVals", "1.23", "1.2.3", typeof(decimal?))] + [InlineData("NullableDoubleVals", "1", "1x", typeof(double?))] + [InlineData("NullableFloatVals", "1000", "1e1000", typeof(float?))] + [InlineData("NullableGuidVals", "9e7257ad-03aa-42c7-9819-be08b177fef9", "123456-789-0", typeof(Guid?))] + [InlineData("NullableIntVals", "5000000", "5000000000", typeof(int?))] + [InlineData("NullableLongVals", "-1234", "this+is+a+long+value", typeof(long?))] public void RejectsUnparseableArrayEntries(string key, string validValue, string invalidValue, Type targetType) { - var ex = Assert.Throws( - () => GetSuppliedParameters($"?{key}={validValue}&{key}={invalidValue}")); + ReadQuery($"?{key}={validValue}&{key}={invalidValue}"); + + var ex = Assert.Throws(() => _supplier.GetQueryParameterValue(targetType.MakeArrayType(), key)); Assert.Equal($"Cannot parse the value '{invalidValue.Replace('+', ' ')}' as type '{targetType}' for '{key}'.", ex.Message); } [Theory] - [InlineData(nameof(ValidTypes.BoolVal), typeof(bool))] - [InlineData(nameof(ValidTypes.DateTimeVal), typeof(DateTime))] - [InlineData(nameof(ValidTypes.DecimalVal), typeof(decimal))] - [InlineData(nameof(ValidTypes.DoubleVal), typeof(double))] - [InlineData(nameof(ValidTypes.FloatVal), typeof(float))] - [InlineData(nameof(ValidTypes.GuidVal), typeof(Guid))] - [InlineData(nameof(ValidTypes.IntVal), typeof(int))] - [InlineData(nameof(ValidTypes.LongVal), typeof(long))] + [InlineData("BoolVal", typeof(bool))] + [InlineData("DateTimeVal", typeof(DateTime))] + [InlineData("DecimalVal", typeof(decimal))] + [InlineData("DoubleVal", typeof(double))] + [InlineData("FloatVal", typeof(float))] + [InlineData("GuidVal", typeof(Guid))] + [InlineData("IntVal", typeof(int))] + [InlineData("LongVal", typeof(long))] public void RejectsBlankValuesWhenNotNullable(string key, Type targetType) { - var ex = Assert.Throws( - () => GetSuppliedParameters($"?{nameof(ValidTypes.StringVal)}=somevalue&{key}=")); + ReadQuery($"?StringVal=somevalue&{key}="); + + var ex = Assert.Throws(() => _supplier.GetQueryParameterValue(targetType, key)); Assert.Equal($"Cannot parse the value '' as type '{targetType}' for '{key}'.", ex.Message); } @@ -329,23 +222,25 @@ public void RejectsBlankValuesWhenNotNullable(string key, Type targetType) public void AcceptsBlankValuesWhenNullable() { var query = - $"{nameof(ValidTypes.NullableBoolVal)}=&" + - $"{nameof(ValidTypes.NullableDateTimeVal)}=&" + - $"{nameof(ValidTypes.NullableDecimalVal)}=&" + - $"{nameof(ValidTypes.NullableDoubleVal)}=&" + - $"{nameof(ValidTypes.NullableFloatVal)}=&" + - $"{nameof(ValidTypes.NullableGuidVal)}=&" + - $"{nameof(ValidTypes.NullableIntVal)}=&" + - $"{nameof(ValidTypes.NullableLongVal)}=&"; - Assert.Collection(GetSuppliedParameters(query).Where(pair => pair.key.StartsWith("Nullable", StringComparison.Ordinal)), - AssertKeyValuePair(nameof(ValidTypes.NullableBoolVal), (object)null), - AssertKeyValuePair(nameof(ValidTypes.NullableDateTimeVal), (object)null), - AssertKeyValuePair(nameof(ValidTypes.NullableDecimalVal), (object)null), - AssertKeyValuePair(nameof(ValidTypes.NullableDoubleVal), (object)null), - AssertKeyValuePair(nameof(ValidTypes.NullableFloatVal), (object)null), - AssertKeyValuePair(nameof(ValidTypes.NullableGuidVal), (object)null), - AssertKeyValuePair(nameof(ValidTypes.NullableIntVal), (object)null), - AssertKeyValuePair(nameof(ValidTypes.NullableLongVal), (object)null)); + $"NullableBoolVal=&" + + $"NullableDateTimeVal=&" + + $"NullableDecimalVal=&" + + $"NullableDoubleVal=&" + + $"NullableFloatVal=&" + + $"NullableGuidVal=&" + + $"NullableIntVal=&" + + $"NullableLongVal=&"; + + ReadQuery(query); + + AssertKeyValuePair("NullableBoolVal", null); + AssertKeyValuePair("NullableDateTimeVal", null); + AssertKeyValuePair("NullableDecimalVal", null); + AssertKeyValuePair("NullableDoubleVal", null); + AssertKeyValuePair("NullableFloatVal", null); + AssertKeyValuePair("NullableGuidVal", null); + AssertKeyValuePair("NullableIntVal", null); + AssertKeyValuePair("NullableLongVal", null); } [Theory] @@ -353,35 +248,39 @@ public void AcceptsBlankValuesWhenNullable() [InlineData("=")] public void EmptyStringValuesAreSuppliedAsEmptyString(string queryPart) { - var query = $"?{nameof(ValidTypes.StringVal)}{queryPart}"; - var suppliedParameters = GetSuppliedParameters(query).ToDictionary(x => x.key, x => x.value); - Assert.Equal(string.Empty, suppliedParameters[nameof(ValidTypes.StringVal)]); + ReadQuery($"?StringVal{queryPart}"); + + Assert.Equal(string.Empty, _supplier.GetQueryParameterValue(typeof(string), "StringVal")); } [Fact] public void EmptyStringArrayValuesAreSuppliedAsEmptyStrings() { - var query = $"?{nameof(ValidArrayTypes.StringVals)}=a&" + - $"{nameof(ValidArrayTypes.StringVals)}&" + - $"{nameof(ValidArrayTypes.StringVals)}=&" + - $"{nameof(ValidArrayTypes.StringVals)}=b"; - var suppliedParameters = GetSuppliedParameters(query).ToDictionary(x => x.key, x => x.value); - Assert.Equal(new[] { "a", string.Empty, string.Empty, "b" }, suppliedParameters[nameof(ValidArrayTypes.StringVals)]); + var query = $"?StringVals=a&" + + $"StringVals&" + + $"StringVals=&" + + $"StringVals=b"; + + ReadQuery(query); + + Assert.Equal(new[] { "a", string.Empty, string.Empty, "b" }, _supplier.GetQueryParameterValue(typeof(string[]), "StringVals")); } [Theory] - [InlineData(nameof(ValidArrayTypes.BoolVals), typeof(bool))] - [InlineData(nameof(ValidArrayTypes.DateTimeVals), typeof(DateTime))] - [InlineData(nameof(ValidArrayTypes.DecimalVals), typeof(decimal))] - [InlineData(nameof(ValidArrayTypes.DoubleVals), typeof(double))] - [InlineData(nameof(ValidArrayTypes.FloatVals), typeof(float))] - [InlineData(nameof(ValidArrayTypes.GuidVals), typeof(Guid))] - [InlineData(nameof(ValidArrayTypes.IntVals), typeof(int))] - [InlineData(nameof(ValidArrayTypes.LongVals), typeof(long))] + [InlineData("BoolVals", typeof(bool))] + [InlineData("DateTimeVals", typeof(DateTime))] + [InlineData("DecimalVals", typeof(decimal))] + [InlineData("DoubleVals", typeof(double))] + [InlineData("FloatVals", typeof(float))] + [InlineData("GuidVals", typeof(Guid))] + [InlineData("IntVals", typeof(int))] + [InlineData("LongVals", typeof(long))] public void RejectsBlankArrayEntriesWhenNotNullable(string key, Type targetType) { + ReadQuery($"?StringVal=somevalue&{key}="); + var ex = Assert.Throws( - () => GetSuppliedParameters($"?{nameof(ValidTypes.StringVal)}=somevalue&{key}=")); + () => _supplier.GetQueryParameterValue(targetType, key)); Assert.Equal($"Cannot parse the value '' as type '{targetType}' for '{key}'.", ex.Message); } @@ -389,120 +288,65 @@ public void RejectsBlankArrayEntriesWhenNotNullable(string key, Type targetType) public void AcceptsBlankArrayEntriesWhenNullable() { var query = - $"{nameof(ValidArrayTypes.NullableBoolVals)}=&" + - $"{nameof(ValidArrayTypes.NullableDateTimeVals)}=&" + - $"{nameof(ValidArrayTypes.NullableDecimalVals)}=&" + - $"{nameof(ValidArrayTypes.NullableDoubleVals)}=&" + - $"{nameof(ValidArrayTypes.NullableFloatVals)}=&" + - $"{nameof(ValidArrayTypes.NullableGuidVals)}=&" + - $"{nameof(ValidArrayTypes.NullableIntVals)}=&" + - $"{nameof(ValidArrayTypes.NullableLongVals)}=&"; - Assert.Collection(GetSuppliedParameters(query).Where(pair => pair.key.StartsWith("Nullable", StringComparison.Ordinal)), - AssertKeyValuePair(nameof(ValidArrayTypes.NullableBoolVals), new bool?[] { null }), - AssertKeyValuePair(nameof(ValidArrayTypes.NullableDateTimeVals), new DateTime?[] { null }), - AssertKeyValuePair(nameof(ValidArrayTypes.NullableDecimalVals), new decimal?[] { null }), - AssertKeyValuePair(nameof(ValidArrayTypes.NullableDoubleVals), new double?[] { null }), - AssertKeyValuePair(nameof(ValidArrayTypes.NullableFloatVals), new float?[] { null }), - AssertKeyValuePair(nameof(ValidArrayTypes.NullableGuidVals), new Guid?[] { null }), - AssertKeyValuePair(nameof(ValidArrayTypes.NullableIntVals), new int?[] { null }), - AssertKeyValuePair(nameof(ValidArrayTypes.NullableLongVals), new long?[] { null })); - } - - private class SpecialQueryParameterName : ComponentBase - { - public const string NameThatLooksEncoded = "name+that+looks+%5Bencoded%5D"; - [Parameter, SupplyParameterFromQuery(Name = NameThatLooksEncoded)] public string Key { get; set; } + $"NullableBoolVals=&" + + $"NullableDateTimeVals=&" + + $"NullableDecimalVals=&" + + $"NullableDoubleVals=&" + + $"NullableFloatVals=&" + + $"NullableGuidVals=&" + + $"NullableIntVals=&" + + $"NullableLongVals=&"; + + ReadQuery(query); + + AssertKeyValuePair("NullableBoolVals", new bool?[] { null }); + AssertKeyValuePair("NullableDateTimeVals", new DateTime?[] { null }); + AssertKeyValuePair("NullableDecimalVals", new decimal?[] { null }); + AssertKeyValuePair("NullableDoubleVals", new double?[] { null }); + AssertKeyValuePair("NullableFloatVals", new float?[] { null }); + AssertKeyValuePair("NullableGuidVals", new Guid?[] { null }); + AssertKeyValuePair("NullableIntVals", new int?[] { null }); + AssertKeyValuePair("NullableLongVals", new long?[] { null }); } [Fact] public void DecodesKeysAndValues() { - var encodedName = Uri.EscapeDataString(SpecialQueryParameterName.NameThatLooksEncoded); + var nameThatLooksEncoded = "name+that+looks+%5Bencoded%5D"; + var encodedName = Uri.EscapeDataString(nameThatLooksEncoded); var query = $"?{encodedName}=Some+%5Bencoded%5D+value"; - Assert.Collection(GetSuppliedParameters(query), - AssertKeyValuePair(nameof(SpecialQueryParameterName.Key), "Some [encoded] value")); - } - private class KeyCaseMatching : ComponentBase - { - [Parameter, SupplyParameterFromQuery] public int KeyOne { get; set; } - [Parameter, SupplyParameterFromQuery(Name = "keytwo")] public int KeyTwo { get; set; } + ReadQuery(query); + + AssertKeyValuePair(nameThatLooksEncoded, "Some [encoded] value"); } [Fact] public void MatchesKeysCaseInsensitively() { - var query = $"?KEYONE=1&KEYTWO=2"; - Assert.Collection(GetSuppliedParameters(query), - AssertKeyValuePair(nameof(KeyCaseMatching.KeyOne), 1), - AssertKeyValuePair(nameof(KeyCaseMatching.KeyTwo), 2)); - } + ReadQuery($"?KEYONE=1&KEYTWO=2"); - private class KeysWithNonAsciiChars : ComponentBase - { - [Parameter, SupplyParameterFromQuery] public string Имя_моей_собственности { get; set; } - [Parameter, SupplyParameterFromQuery(Name = "خاصية_أخرى")] public string AnotherProperty { get; set; } + AssertKeyValuePair("KeyOne", 1); + AssertKeyValuePair("KeyTwo", 2); } [Fact] public void MatchesKeysWithNonAsciiChars() { - var query = $"?{nameof(KeysWithNonAsciiChars.Имя_моей_собственности)}=first&خاصية_أخرى=second"; - var result = GetSuppliedParameters(query); - Assert.Collection(result, - AssertKeyValuePair(nameof(KeysWithNonAsciiChars.AnotherProperty), "second"), - AssertKeyValuePair(nameof(KeysWithNonAsciiChars.Имя_моей_собственности), "first")); - } - - private class SingleValueOverwriting : ComponentBase - { - [Parameter, SupplyParameterFromQuery] public int Age { get; set; } - [Parameter, SupplyParameterFromQuery] public int? Id { get; set; } - [Parameter, SupplyParameterFromQuery] public string Name { get; set; } - } + ReadQuery($"?Имя_моей_собственности=first&خاصية_أخرى=second"); - [Fact] - public void ForNonArrayValuesOnlyOneValueIsSupplied() - { - // For simplicity and speed, the value assignment logic doesn't check if the a single-valued destination is - // already populated, and just overwrites in a left-to-right manner. For nullable values it's possible to - // overwrite a value with null, or a string with empty. - Assert.Collection(GetSuppliedParameters($"?age=123&age=456&age=789&id=1&id&name=Bobbins&name"), - AssertKeyValuePair(nameof(SingleValueOverwriting.Age), 789), - AssertKeyValuePair(nameof(SingleValueOverwriting.Id), (int?)null), - AssertKeyValuePair(nameof(SingleValueOverwriting.Name), string.Empty)); + AssertKeyValuePair("خاصية_أخرى", "second"); + AssertKeyValuePair("Имя_моей_собственности", "first"); } - private static IEnumerable<(string key, object value)> GetSuppliedParameters(string query) where TComponent : IComponent + private void ReadQuery(string query) { - var supplier = QueryParameterValueSupplier.ForType(typeof(TComponent)); - using var builder = new RenderTreeBuilder(); - builder.OpenComponent(0); - supplier.RenderParametersFromQueryString(builder, query.AsMemory()); - builder.CloseComponent(); - - var frames = builder.GetFrames(); - return frames.Array.Take(frames.Count) - .Where(frame => frame.FrameType == RenderTree.RenderTreeFrameType.Attribute) - .Select(frame => (frame.AttributeName, frame.AttributeValue)) - .OrderBy(pair => pair.AttributeName) // The order isn't defined, so use alphabetical for tests - .ToList(); + _supplier.ReadParametersFromQuery(query.AsMemory()); } - private Action<(string key, object value)> AssertKeyValuePair(string expectedKey, T expectedValue) + private void AssertKeyValuePair(string key, object expectedValue) { - return pair => - { - Assert.Equal(expectedKey, pair.key); - if (expectedValue is null) - { - Assert.Null(pair.value); - } - else - { - Assert.IsType(expectedValue); - Assert.Equal(expectedValue, pair.value); - } - }; + var actualValue = _supplier.GetQueryParameterValue(typeof(T), key); + Assert.Equal(expectedValue, actualValue); } } diff --git a/src/Components/Endpoints/src/Binding/Factories/CollectionConverterFactory.cs b/src/Components/Endpoints/src/Binding/Factories/CollectionConverterFactory.cs index 0a9c17840d0b..9ca5edcd68b4 100644 --- a/src/Components/Endpoints/src/Binding/Factories/CollectionConverterFactory.cs +++ b/src/Components/Endpoints/src/Binding/Factories/CollectionConverterFactory.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.Internal; namespace Microsoft.AspNetCore.Components.Endpoints.Binding; @@ -9,6 +10,8 @@ internal class CollectionConverterFactory : IFormDataConverterFactory { public static readonly CollectionConverterFactory Instance = new(); + [RequiresDynamicCode(FormBindingHelpers.RequiresDynamicCodeMessage)] + [RequiresUnreferencedCode(FormBindingHelpers.RequiresUnreferencedCodeMessage)] public bool CanConvert(Type type, FormDataMapperOptions options) { var enumerable = ClosedGenericMatcher.ExtractGenericInterface(type, typeof(IEnumerable<>)); @@ -28,6 +31,8 @@ public bool CanConvert(Type type, FormDataMapperOptions options) return factory.CanConvert(type, options); } + [RequiresDynamicCode(FormBindingHelpers.RequiresDynamicCodeMessage)] + [RequiresUnreferencedCode(FormBindingHelpers.RequiresUnreferencedCodeMessage)] public FormDataConverter CreateConverter(Type type, FormDataMapperOptions options) { ArgumentNullException.ThrowIfNull(type); diff --git a/src/Components/Endpoints/src/Binding/Factories/Collections/ConcreteTypeCollectionConverterFactory.cs b/src/Components/Endpoints/src/Binding/Factories/Collections/ConcreteTypeCollectionConverterFactory.cs index 667a76ffe3c2..1fa3bce2837b 100644 --- a/src/Components/Endpoints/src/Binding/Factories/Collections/ConcreteTypeCollectionConverterFactory.cs +++ b/src/Components/Endpoints/src/Binding/Factories/Collections/ConcreteTypeCollectionConverterFactory.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics.CodeAnalysis; + namespace Microsoft.AspNetCore.Components.Endpoints.Binding; internal class ConcreteTypeCollectionConverterFactory @@ -9,8 +11,12 @@ internal class ConcreteTypeCollectionConverterFactory public static readonly ConcreteTypeCollectionConverterFactory Instance = new(); + [UnconditionalSuppressMessage("Trimming", "IL2046", Justification = "This derived implementation doesn't require unreferenced code like other implementations of the interface.")] + [UnconditionalSuppressMessage("AOT", "IL3051", Justification = "This derived implementation doesn't use dynamic code like other implementations of the interface.")] public bool CanConvert(Type _, FormDataMapperOptions options) => true; + [RequiresDynamicCode(FormBindingHelpers.RequiresDynamicCodeMessage)] + [RequiresUnreferencedCode(FormBindingHelpers.RequiresUnreferencedCodeMessage)] public FormDataConverter CreateConverter(Type _, FormDataMapperOptions options) { // Resolve the element type converter diff --git a/src/Components/Endpoints/src/Binding/Factories/Collections/TypedCollectionConverterFactory.cs b/src/Components/Endpoints/src/Binding/Factories/Collections/TypedCollectionConverterFactory.cs index e3b922555748..29a491765909 100644 --- a/src/Components/Endpoints/src/Binding/Factories/Collections/TypedCollectionConverterFactory.cs +++ b/src/Components/Endpoints/src/Binding/Factories/Collections/TypedCollectionConverterFactory.cs @@ -4,18 +4,25 @@ using System.Collections.Concurrent; using System.Collections.Immutable; using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; namespace Microsoft.AspNetCore.Components.Endpoints.Binding; internal abstract class TypedCollectionConverterFactory : IFormDataConverterFactory { + [RequiresDynamicCode(FormBindingHelpers.RequiresDynamicCodeMessage)] + [RequiresUnreferencedCode(FormBindingHelpers.RequiresUnreferencedCodeMessage)] public abstract bool CanConvert(Type type, FormDataMapperOptions options); + [RequiresDynamicCode(FormBindingHelpers.RequiresDynamicCodeMessage)] + [RequiresUnreferencedCode(FormBindingHelpers.RequiresUnreferencedCodeMessage)] public abstract FormDataConverter CreateConverter(Type type, FormDataMapperOptions options); } internal sealed class TypedCollectionConverterFactory : TypedCollectionConverterFactory { + [RequiresDynamicCode(FormBindingHelpers.RequiresDynamicCodeMessage)] + [RequiresUnreferencedCode(FormBindingHelpers.RequiresUnreferencedCodeMessage)] public override bool CanConvert(Type _, FormDataMapperOptions options) { // Resolve the element type converter @@ -101,6 +108,8 @@ var _ when type.IsAssignableTo(typeof(ICollection)) && type.GetConstru // the Queue directly as the buffer (queues don't implement ICollection, so the adapter uses Push instead), // or for ImmutableXXX we either use ImmuttableXXX.CreateBuilder to create a builder we use as a buffer, // or collect the collection into an array buffer and call CreateRange to build the final collection. + [RequiresDynamicCode(FormBindingHelpers.RequiresDynamicCodeMessage)] + [RequiresUnreferencedCode(FormBindingHelpers.RequiresUnreferencedCodeMessage)] public override FormDataConverter CreateConverter(Type _, FormDataMapperOptions options) { // Resolve the element type converter diff --git a/src/Components/Endpoints/src/Binding/Factories/ComplexType/ComplexTypeExpressionConverterFactory.cs b/src/Components/Endpoints/src/Binding/Factories/ComplexType/ComplexTypeExpressionConverterFactory.cs index 68d22f323d09..12503309bb8f 100644 --- a/src/Components/Endpoints/src/Binding/Factories/ComplexType/ComplexTypeExpressionConverterFactory.cs +++ b/src/Components/Endpoints/src/Binding/Factories/ComplexType/ComplexTypeExpressionConverterFactory.cs @@ -1,9 +1,13 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics.CodeAnalysis; + namespace Microsoft.AspNetCore.Components.Endpoints.Binding; internal abstract class ComplexTypeExpressionConverterFactory { + [RequiresDynamicCode(FormBindingHelpers.RequiresDynamicCodeMessage)] + [RequiresUnreferencedCode(FormBindingHelpers.RequiresUnreferencedCodeMessage)] internal abstract FormDataConverter CreateConverter(Type type, FormDataMapperOptions options); } diff --git a/src/Components/Endpoints/src/Binding/Factories/ComplexType/ComplexTypeExpressionConverterFactoryOfT.cs b/src/Components/Endpoints/src/Binding/Factories/ComplexType/ComplexTypeExpressionConverterFactoryOfT.cs index 4b87ce6accff..91348bfbe642 100644 --- a/src/Components/Endpoints/src/Binding/Factories/ComplexType/ComplexTypeExpressionConverterFactoryOfT.cs +++ b/src/Components/Endpoints/src/Binding/Factories/ComplexType/ComplexTypeExpressionConverterFactoryOfT.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics.CodeAnalysis; using System.Linq.Expressions; using Microsoft.Extensions.Internal; @@ -8,12 +9,16 @@ namespace Microsoft.AspNetCore.Components.Endpoints.Binding; internal sealed class ComplexTypeExpressionConverterFactory : ComplexTypeExpressionConverterFactory { + [RequiresDynamicCode(FormBindingHelpers.RequiresDynamicCodeMessage)] + [RequiresUnreferencedCode(FormBindingHelpers.RequiresUnreferencedCodeMessage)] internal override CompiledComplexTypeConverter CreateConverter(Type type, FormDataMapperOptions options) { var body = CreateConverterBody(type, options); return new CompiledComplexTypeConverter(body); } + [RequiresDynamicCode(FormBindingHelpers.RequiresDynamicCodeMessage)] + [RequiresUnreferencedCode(FormBindingHelpers.RequiresUnreferencedCodeMessage)] private CompiledComplexTypeConverter.ConverterDelegate CreateConverterBody(Type type, FormDataMapperOptions options) { var properties = PropertyHelper.GetVisibleProperties(type); diff --git a/src/Components/Endpoints/src/Binding/Factories/ComplexTypeConverterFactory.cs b/src/Components/Endpoints/src/Binding/Factories/ComplexTypeConverterFactory.cs index f633aca53e1e..c67a521eea7c 100644 --- a/src/Components/Endpoints/src/Binding/Factories/ComplexTypeConverterFactory.cs +++ b/src/Components/Endpoints/src/Binding/Factories/ComplexTypeConverterFactory.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.Internal; namespace Microsoft.AspNetCore.Components.Endpoints.Binding; @@ -11,6 +12,8 @@ internal class ComplexTypeConverterFactory : IFormDataConverterFactory { internal static readonly ComplexTypeConverterFactory Instance = new(); + [RequiresDynamicCode(FormBindingHelpers.RequiresDynamicCodeMessage)] + [RequiresUnreferencedCode(FormBindingHelpers.RequiresUnreferencedCodeMessage)] public bool CanConvert(Type type, FormDataMapperOptions options) { if (type.GetConstructor(Type.EmptyTypes) == null && !type.IsValueType) @@ -105,7 +108,8 @@ public bool CanConvert(Type type, FormDataMapperOptions options) // return converterFunc(ref reader, type, options, out result, out found); // } // } - + [RequiresDynamicCode(FormBindingHelpers.RequiresDynamicCodeMessage)] + [RequiresUnreferencedCode(FormBindingHelpers.RequiresUnreferencedCodeMessage)] public FormDataConverter CreateConverter(Type type, FormDataMapperOptions options) { if (Activator.CreateInstance(typeof(ComplexTypeExpressionConverterFactory<>).MakeGenericType(type)) diff --git a/src/Components/Endpoints/src/Binding/Factories/Dictionary/ConcreteTypeDictionaryConverterFactory.cs b/src/Components/Endpoints/src/Binding/Factories/Dictionary/ConcreteTypeDictionaryConverterFactory.cs index a3f77d3600c6..fc83a37ae600 100644 --- a/src/Components/Endpoints/src/Binding/Factories/Dictionary/ConcreteTypeDictionaryConverterFactory.cs +++ b/src/Components/Endpoints/src/Binding/Factories/Dictionary/ConcreteTypeDictionaryConverterFactory.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics.CodeAnalysis; + namespace Microsoft.AspNetCore.Components.Endpoints.Binding; internal sealed class ConcreteTypeDictionaryConverterFactory : IFormDataConverterFactory @@ -8,8 +10,12 @@ internal sealed class ConcreteTypeDictionaryConverterFactory Instance = new(); + [UnconditionalSuppressMessage("Trimming", "IL2046", Justification = "This derived implementation doesn't require unreferenced code like other implementations of the interface.")] + [UnconditionalSuppressMessage("AOT", "IL3051", Justification = "This derived implementation doesn't use dynamic code like other implementations of the interface.")] public bool CanConvert(Type type, FormDataMapperOptions options) => true; + [RequiresDynamicCode(FormBindingHelpers.RequiresDynamicCodeMessage)] + [RequiresUnreferencedCode(FormBindingHelpers.RequiresUnreferencedCodeMessage)] public FormDataConverter CreateConverter(Type type, FormDataMapperOptions options) { // Resolve the element type converter diff --git a/src/Components/Endpoints/src/Binding/Factories/Dictionary/TypedDictionaryConverterFactory.cs b/src/Components/Endpoints/src/Binding/Factories/Dictionary/TypedDictionaryConverterFactory.cs index a2822b4b9364..def5d833725d 100644 --- a/src/Components/Endpoints/src/Binding/Factories/Dictionary/TypedDictionaryConverterFactory.cs +++ b/src/Components/Endpoints/src/Binding/Factories/Dictionary/TypedDictionaryConverterFactory.cs @@ -4,12 +4,15 @@ using System.Collections.Concurrent; using System.Collections.Immutable; using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; namespace Microsoft.AspNetCore.Components.Endpoints.Binding; internal sealed class TypedDictionaryConverterFactory : IFormDataConverterFactory where TKey : IParsable { + [RequiresDynamicCode(FormBindingHelpers.RequiresDynamicCodeMessage)] + [RequiresUnreferencedCode(FormBindingHelpers.RequiresUnreferencedCodeMessage)] public bool CanConvert(Type type, FormDataMapperOptions options) { // Resolve the value type converter @@ -70,6 +73,8 @@ var _ when type.IsAssignableTo(typeof(IDictionary)) && type.GetCon return false; } + [RequiresDynamicCode(FormBindingHelpers.RequiresDynamicCodeMessage)] + [RequiresUnreferencedCode(FormBindingHelpers.RequiresUnreferencedCodeMessage)] public FormDataConverter CreateConverter(Type type, FormDataMapperOptions options) { // Resolve the value type converter diff --git a/src/Components/Endpoints/src/Binding/Factories/DictionaryConverterFactory.cs b/src/Components/Endpoints/src/Binding/Factories/DictionaryConverterFactory.cs index ee92552e357d..cc237af2bae6 100644 --- a/src/Components/Endpoints/src/Binding/Factories/DictionaryConverterFactory.cs +++ b/src/Components/Endpoints/src/Binding/Factories/DictionaryConverterFactory.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.Internal; namespace Microsoft.AspNetCore.Components.Endpoints.Binding; @@ -9,6 +10,8 @@ internal class DictionaryConverterFactory : IFormDataConverterFactory { internal static readonly DictionaryConverterFactory Instance = new(); + [RequiresDynamicCode(FormBindingHelpers.RequiresDynamicCodeMessage)] + [RequiresUnreferencedCode(FormBindingHelpers.RequiresUnreferencedCodeMessage)] public bool CanConvert(Type type, FormDataMapperOptions options) { // Type must implement IDictionary IReadOnlyDictionary @@ -58,6 +61,8 @@ public bool CanConvert(Type type, FormDataMapperOptions options) return factory.CanConvert(type, options); } + [RequiresDynamicCode(FormBindingHelpers.RequiresDynamicCodeMessage)] + [RequiresUnreferencedCode(FormBindingHelpers.RequiresUnreferencedCodeMessage)] public FormDataConverter CreateConverter(Type type, FormDataMapperOptions options) { // Type must implement IDictionary IReadOnlyDictionary diff --git a/src/Components/Endpoints/src/Binding/Factories/NullableConverterFactory.cs b/src/Components/Endpoints/src/Binding/Factories/NullableConverterFactory.cs index ea635aab6cb4..c99bedfe6763 100644 --- a/src/Components/Endpoints/src/Binding/Factories/NullableConverterFactory.cs +++ b/src/Components/Endpoints/src/Binding/Factories/NullableConverterFactory.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; namespace Microsoft.AspNetCore.Components.Endpoints.Binding; @@ -9,12 +10,16 @@ internal sealed class NullableConverterFactory : IFormDataConverterFactory { public static readonly NullableConverterFactory Instance = new(); + [RequiresDynamicCode(FormBindingHelpers.RequiresDynamicCodeMessage)] + [RequiresUnreferencedCode(FormBindingHelpers.RequiresUnreferencedCodeMessage)] public bool CanConvert(Type type, FormDataMapperOptions options) { var underlyingType = Nullable.GetUnderlyingType(type); return underlyingType != null && options.ResolveConverter(underlyingType) != null; } + [RequiresDynamicCode(FormBindingHelpers.RequiresDynamicCodeMessage)] + [RequiresUnreferencedCode(FormBindingHelpers.RequiresUnreferencedCodeMessage)] public FormDataConverter CreateConverter(Type type, FormDataMapperOptions options) { var underlyingType = Nullable.GetUnderlyingType(type); diff --git a/src/Components/Endpoints/src/Binding/Factories/ParsableConverterFactory.cs b/src/Components/Endpoints/src/Binding/Factories/ParsableConverterFactory.cs index 73cc5fccf1b7..d6d151f71177 100644 --- a/src/Components/Endpoints/src/Binding/Factories/ParsableConverterFactory.cs +++ b/src/Components/Endpoints/src/Binding/Factories/ParsableConverterFactory.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.Internal; namespace Microsoft.AspNetCore.Components.Endpoints.Binding; @@ -9,11 +10,15 @@ internal sealed class ParsableConverterFactory : IFormDataConverterFactory { public static readonly ParsableConverterFactory Instance = new(); + [RequiresDynamicCode(FormBindingHelpers.RequiresDynamicCodeMessage)] + [RequiresUnreferencedCode(FormBindingHelpers.RequiresUnreferencedCodeMessage)] public bool CanConvert(Type type, FormDataMapperOptions options) { return ClosedGenericMatcher.ExtractGenericInterface(type, typeof(IParsable<>)) is not null; } + [RequiresDynamicCode(FormBindingHelpers.RequiresDynamicCodeMessage)] + [RequiresUnreferencedCode(FormBindingHelpers.RequiresUnreferencedCodeMessage)] public FormDataConverter CreateConverter(Type type, FormDataMapperOptions options) { return Activator.CreateInstance(typeof(ParsableConverter<>).MakeGenericType(type)) as FormDataConverter ?? diff --git a/src/Components/Endpoints/src/Binding/FormBindingHelpers.cs b/src/Components/Endpoints/src/Binding/FormBindingHelpers.cs new file mode 100644 index 000000000000..de9a6b61240d --- /dev/null +++ b/src/Components/Endpoints/src/Binding/FormBindingHelpers.cs @@ -0,0 +1,10 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Components.Endpoints.Binding; + +internal static class FormBindingHelpers +{ + public const string RequiresUnreferencedCodeMessage = "Form binding is not compatible with trimming, as it requires dynamic access to code that is not referenced statically."; + public const string RequiresDynamicCodeMessage = "Form binding may require dynamic code generation."; +} diff --git a/src/Components/Endpoints/src/Binding/FormDataMapperOptions.cs b/src/Components/Endpoints/src/Binding/FormDataMapperOptions.cs index f678a579ff0e..e28a5cf51574 100644 --- a/src/Components/Endpoints/src/Binding/FormDataMapperOptions.cs +++ b/src/Components/Endpoints/src/Binding/FormDataMapperOptions.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; namespace Microsoft.AspNetCore.Components.Endpoints.Binding; @@ -10,6 +11,8 @@ internal sealed class FormDataMapperOptions private readonly ConcurrentDictionary _converters = new(); private readonly List> _factories = new(); + [RequiresDynamicCode(FormBindingHelpers.RequiresDynamicCodeMessage)] + [RequiresUnreferencedCode(FormBindingHelpers.RequiresUnreferencedCodeMessage)] public FormDataMapperOptions() { _converters = new(WellKnownConverters.Converters); @@ -27,7 +30,7 @@ public FormDataMapperOptions() // Binding to collection using hashes, where the payload can be crafted to force the worst case on insertion // which is O(n). internal int MaxCollectionSize = 100; - + internal bool HasConverter(Type valueType) => _converters.ContainsKey(valueType); internal bool IsSingleValueConverter(Type type) diff --git a/src/Components/Endpoints/src/Binding/IFormDataConverterFactory.cs b/src/Components/Endpoints/src/Binding/IFormDataConverterFactory.cs index 04a77cf83c49..f128b8c2c1c6 100644 --- a/src/Components/Endpoints/src/Binding/IFormDataConverterFactory.cs +++ b/src/Components/Endpoints/src/Binding/IFormDataConverterFactory.cs @@ -1,11 +1,17 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics.CodeAnalysis; + namespace Microsoft.AspNetCore.Components.Endpoints.Binding; internal interface IFormDataConverterFactory { + [RequiresDynamicCode(FormBindingHelpers.RequiresDynamicCodeMessage)] + [RequiresUnreferencedCode(FormBindingHelpers.RequiresUnreferencedCodeMessage)] public bool CanConvert(Type type, FormDataMapperOptions options); + [RequiresDynamicCode(FormBindingHelpers.RequiresDynamicCodeMessage)] + [RequiresUnreferencedCode(FormBindingHelpers.RequiresUnreferencedCodeMessage)] public FormDataConverter CreateConverter(Type type, FormDataMapperOptions options); } diff --git a/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs b/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs index 8ef454f5bbf9..2e291faabbbf 100644 --- a/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs +++ b/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs @@ -58,6 +58,8 @@ public static IRazorComponentsBuilder AddRazorComponents(this IServiceCollection // Form handling services.TryAddScoped(); services.TryAddScoped(); + services.TryAddEnumerable(ServiceDescriptor.Scoped()); + services.TryAddEnumerable(ServiceDescriptor.Scoped()); return new DefaultRazorComponentsBuilder(services); } diff --git a/src/Components/Endpoints/src/RazorComponentEndpointHost.cs b/src/Components/Endpoints/src/RazorComponentEndpointHost.cs index 7eaddb877718..5ce1a4c4f0d3 100644 --- a/src/Components/Endpoints/src/RazorComponentEndpointHost.cs +++ b/src/Components/Endpoints/src/RazorComponentEndpointHost.cs @@ -19,7 +19,6 @@ internal class RazorComponentEndpointHost : IComponent { private RenderHandle _renderHandle; - [Parameter] public IComponentRenderMode? RenderMode { get; set; } [Parameter] public Type ComponentType { get; set; } = default!; [Parameter] public IReadOnlyDictionary? ComponentParameters { get; set; } @@ -45,16 +44,6 @@ private void BuildRenderTree(RenderTreeBuilder builder) private void RenderPageWithParameters(RenderTreeBuilder builder) { - // TODO: Once we support rendering Server/WebAssembly components into the page, implementation will - // go here. We need to switch into the rendermode given by RazorComponentResult.RenderMode for this - // child component. That will cause the developer-supplied parameters to be serialized into a marker - // but not attempt to serialize the RenderFragment that causes this to be hosted in its layout. - if (RenderMode is not null) - { - // Tracked by #46353 and #46354 - throw new NotSupportedException($"Currently, Razor Component endpoints don't support setting a render mode."); - } - builder.OpenComponent(0, ComponentType); if (ComponentParameters is not null) diff --git a/src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs b/src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs index 3dba4acecea6..5b5244aa23a1 100644 --- a/src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs +++ b/src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs @@ -34,6 +34,7 @@ public Task RenderComponent() private async Task RenderComponentCore() { _context.Response.ContentType = RazorComponentResultExecutor.DefaultContentType; + _renderer.InitializeStreamingRenderingFraming(_context); if (!await TryValidateRequestAsync(out var isPost, out var handler)) { diff --git a/src/Components/Endpoints/src/Rendering/EndpointComponentState.cs b/src/Components/Endpoints/src/Rendering/EndpointComponentState.cs index 784ec09bcb1d..5ff3e11be8fe 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointComponentState.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointComponentState.cs @@ -28,7 +28,7 @@ public EndpointComponentState(Renderer renderer, int componentId, IComponent com } else { - var parentEndpointComponentState = (EndpointComponentState?)parentComponentState; + var parentEndpointComponentState = (EndpointComponentState?)LogicalParentComponentState; StreamRendering = parentEndpointComponentState?.StreamRendering ?? false; } } diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs index 7337adb65e1b..ff7b84402fae 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs @@ -12,7 +12,7 @@ internal partial class EndpointHtmlRenderer { private static readonly object ComponentSequenceKey = new object(); - protected override IComponent ResolveComponentForRenderMode(Type componentType, int? parentComponentId, IComponentActivator componentActivator, IComponentRenderMode componentTypeRenderMode) + protected override IComponent ResolveComponentForRenderMode(Type componentType, int? parentComponentId, IComponentActivator componentActivator, IComponentRenderMode renderMode) { var closestRenderModeBoundary = parentComponentId.HasValue ? GetClosestRenderModeBoundary(parentComponentId.Value) @@ -27,7 +27,7 @@ protected override IComponent ResolveComponentForRenderMode(Type componentType, else { // This component is the start of a subtree with a rendermode, so introduce a new rendermode boundary here - return new SSRRenderModeBoundary(componentType, componentTypeRenderMode); + return new SSRRenderModeBoundary(componentType, renderMode); } } @@ -147,6 +147,15 @@ private static ValueTask HandleNavigationExcept "Navigation commands can not be issued during server-side prerendering after the response from the server has started. Applications must buffer the" + "response and avoid using features like FlushAsync() before all components on the page have been rendered to prevent failed navigation commands."); } + else if (IsPossibleExternalDestination(httpContext.Request, navigationException.Location) && httpContext.Request.Headers.ContainsKey("blazor-enhanced-nav")) + { + // It's unsafe to do a 301/302/etc to an external destination when this was requested via fetch, because + // assuming it doesn't expose CORS headers, we won't be allowed to follow the redirection nor will + // we even find out what the destination URL would have been. But since it's our own JS code making this + // fetch request, we can have a custom protocol for describing the URL we wanted to redirect to. + httpContext.Response.Headers.Add("blazor-enhanced-nav-redirect-location", navigationException.Location); + return new ValueTask(PrerenderedComponentHtmlContent.Empty); + } else { httpContext.Response.Redirect(navigationException.Location); @@ -154,6 +163,17 @@ private static ValueTask HandleNavigationExcept } } + private static bool IsPossibleExternalDestination(HttpRequest request, string destinationUrl) + { + if (!Uri.TryCreate(destinationUrl, UriKind.Absolute, out var absoluteUri)) + { + return false; + } + + return absoluteUri.Scheme != request.Scheme + || absoluteUri.Authority != request.Host.Value; + } + internal static ServerComponentInvocationSequence GetOrCreateInvocationId(HttpContext httpContext) { if (!httpContext.Items.TryGetValue(ComponentSequenceKey, out var result)) diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Streaming.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Streaming.cs index 90fabc379eb0..6301b123cdc4 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Streaming.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Streaming.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Runtime.InteropServices; +using System.Text.Encodings.Web; using Microsoft.AspNetCore.Components.RenderTree; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; @@ -13,8 +14,25 @@ namespace Microsoft.AspNetCore.Components.Endpoints; internal partial class EndpointHtmlRenderer { + private const string _progressivelyEnhancedNavRequestHeaderName = "blazor-enhanced-nav"; + private const string _streamingRenderingFramingHeaderName = "ssr-framing"; private TextWriter? _streamingUpdatesWriter; private HashSet? _visitedComponentIdsInCurrentStreamingBatch; + private string? _ssrFramingCommentMarkup; + + public void InitializeStreamingRenderingFraming(HttpContext httpContext) + { + if (httpContext.Request.Headers.ContainsKey(_progressivelyEnhancedNavRequestHeaderName)) + { + var id = Guid.NewGuid().ToString(); + httpContext.Response.Headers.Add(_streamingRenderingFramingHeaderName, id); + _ssrFramingCommentMarkup = $""; + } + else + { + _ssrFramingCommentMarkup = string.Empty; + } + } public async Task SendStreamingUpdatesAsync(HttpContext httpContext, Task untilTaskCompleted, TextWriter writer) { @@ -26,10 +44,16 @@ public async Task SendStreamingUpdatesAsync(HttpContext httpContext, Task untilT throw new InvalidOperationException($"{nameof(SendStreamingUpdatesAsync)} can only be called once."); } + if (_ssrFramingCommentMarkup is null) + { + throw new InvalidOperationException("Cannot begin streaming rendering because no framing header was set."); + } + _streamingUpdatesWriter = writer; try { + await writer.WriteAsync(_ssrFramingCommentMarkup); await writer.FlushAsync(); // Make sure the initial HTML was sent await untilTaskCompleted; } @@ -39,10 +63,12 @@ public async Task SendStreamingUpdatesAsync(HttpContext httpContext, Task untilT } catch (Exception ex) { + // Theoretically it might be possible to let the error middleware run, capture the output, + // then emit it in a special format so the JS code can display the error page. However + // for now we're not going to support that and will simply emit a message. HandleExceptionAfterResponseStarted(_httpContext, writer, ex); - - // The rest of the pipeline can treat this as a regular unhandled exception - // TODO: Is this really right? I think we'll terminate the response in an invalid way. + await writer.FlushAsync(); // Important otherwise the client won't receive the error message, as we're about to fail the pipeline + await _httpContext.Response.CompleteAsync(); throw; } } @@ -115,6 +141,7 @@ private void SendBatchAsStreamingUpdate(in RenderBatch renderBatch, TextWriter w } writer.Write(""); + writer.Write(_ssrFramingCommentMarkup); } } @@ -143,16 +170,16 @@ private static void HandleExceptionAfterResponseStarted(HttpContext httpContext, ? exception.ToString() : "There was an unhandled exception on the current request. For more details turn on detailed exceptions by setting 'DetailedErrors: true' in 'appSettings.Development.json'"; - writer.Write(""); + writer.Write(""); } private static void HandleNavigationAfterResponseStarted(TextWriter writer, string destinationUrl) { - writer.Write(""); + writer.Write(""); } protected override void WriteComponentHtml(int componentId, TextWriter output) diff --git a/src/Components/Endpoints/src/Rendering/SSRRenderModeBoundary.cs b/src/Components/Endpoints/src/Rendering/SSRRenderModeBoundary.cs index 0187815fb37d..26e639d856ce 100644 --- a/src/Components/Endpoints/src/Rendering/SSRRenderModeBoundary.cs +++ b/src/Components/Endpoints/src/Rendering/SSRRenderModeBoundary.cs @@ -44,6 +44,8 @@ public Task SetParametersAsync(ParameterView parameters) // call stack because the underlying buffer may get reused. This is enforced through a runtime check. _latestParameters = parameters.ToDictionary(); + ValidateParameters(_latestParameters); + if (_prerender) { _renderHandle.Render(Prerender); @@ -52,6 +54,32 @@ public Task SetParametersAsync(ParameterView parameters) return Task.CompletedTask; } + private void ValidateParameters(IReadOnlyDictionary latestParameters) + { + foreach (var (name, value) in latestParameters) + { + // There are many other things we can't serialize too, but give special errors for Delegate because + // it may be a common mistake to try passing ChildContent when crossing rendermode boundaries. + if (value is Delegate) + { + var valueType = value.GetType(); + if (valueType.IsGenericType && valueType.GetGenericTypeDefinition() == typeof(RenderFragment<>)) + { + throw new InvalidOperationException($"Cannot pass RenderFragment parameter '{name}' to component '{_componentType.Name}' with rendermode '{_renderMode.GetType().Name}'. Templated content can't be passed across a rendermode boundary, because it is arbitrary code and cannot be serialized."); + } + else + { + // TODO: Ideally we *should* support RenderFragment (the non-generic version) by prerendering it + // However it's very nontrivial since it means we have to execute it within the current renderer + // somehow without actually emitting its result directly, wait for quiescence, and then prerender + // the output into a separate buffer so we can serialize it in a special way. + // A prototype implementation is at https://github.com/dotnet/aspnetcore/commit/ed330ff5b143974d9060828a760ad486b1d386ac + throw new InvalidOperationException($"Cannot pass the parameter '{name}' to component '{_componentType.Name}' with rendermode '{_renderMode.GetType().Name}'. This is because the parameter is of the delegate type '{value.GetType()}', which is arbitrary code and cannot be serialized."); + } + } + } + } + private void Prerender(RenderTreeBuilder builder) { builder.OpenComponent(0, _componentType); diff --git a/src/Components/Endpoints/src/Results/RazorComponentResultExecutor.cs b/src/Components/Endpoints/src/Results/RazorComponentResultExecutor.cs index 9d61e92544a9..764ecdbe8d5f 100644 --- a/src/Components/Endpoints/src/Results/RazorComponentResultExecutor.cs +++ b/src/Components/Endpoints/src/Results/RazorComponentResultExecutor.cs @@ -51,6 +51,8 @@ internal static Task RenderComponentToResponse( var endpointHtmlRenderer = httpContext.RequestServices.GetRequiredService(); return endpointHtmlRenderer.Dispatcher.InvokeAsync(async () => { + endpointHtmlRenderer.InitializeStreamingRenderingFraming(httpContext); + // We could pool these dictionary instances if we wanted, and possibly even the ParameterView // backing buffers could come from a pool like they do during rendering. var hostParameters = ParameterView.FromDictionary(new Dictionary diff --git a/src/Components/Endpoints/test/RazorComponentResultExecutorTest.cs b/src/Components/Endpoints/test/RazorComponentResultExecutorTest.cs index 8a62f594a33b..7effacb7070e 100644 --- a/src/Components/Endpoints/test/RazorComponentResultExecutorTest.cs +++ b/src/Components/Endpoints/test/RazorComponentResultExecutorTest.cs @@ -224,7 +224,7 @@ await RazorComponentResultExecutor.RenderComponentToResponse( // Assert Assert.Equal( - $"Some output\n", + $"Some output\n", MaskComponentIds(GetStringContent(responseBody))); } @@ -269,8 +269,8 @@ public async Task OnUnhandledExceptionAfterResponseStarted_WithStreamingOn_Emits httpContext.Response.Body = responseBody; var expectedResponseExceptionInfo = isDevelopmentEnvironment - ? "System.InvalidTimeZoneException: Test message" - : "There was an unhandled exception on the current request. For more details turn on detailed exceptions by setting 'DetailedErrors: true' in 'appSettings.Development.json'"; + ? "System.InvalidTimeZoneException: Test message with <b>markup</b>" + : "There was an unhandled exception on the current request. For more details turn on detailed exceptions by setting 'DetailedErrors: true' in 'appSettings.Development.json'"; // Act var ex = await Assert.ThrowsAsync(() => RazorComponentResultExecutor.RenderComponentToResponse( @@ -278,9 +278,9 @@ public async Task OnUnhandledExceptionAfterResponseStarted_WithStreamingOn_Emits null, preventStreamingRendering: false)); // Assert - Assert.Contains("Test message", ex.Message); + Assert.Contains("Test message with markup", ex.Message); Assert.Contains( - $"Some output\n