From 7a4ed64d7090a65b9b5b2a2ea99706e46ab1b731 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Herceg?= Date: Sun, 14 Jul 2024 09:53:19 +0200 Subject: [PATCH 01/40] .NET Core projects updated to .NET 6 --- ...M.Samples.BasicSamples.Api.AspNetCore.csproj | 4 +--- .../DotVVM.Samples.BasicSamples.Api.Owin.csproj | 4 ---- .../Properties/PublishProfiles/CI.pubxml | 17 ----------------- ...amples.ApplicationInsights.AspNetCore.csproj | 4 +--- ...otVVM.Samples.BasicSamples.AspNetCore.csproj | 2 +- src/Samples/Common/regen_api_client.cmd | 4 ++-- ...otVVM.Samples.MiniProfiler.AspNetCore.csproj | 8 +++----- .../DotVVM.Samples.BasicSamples.Owin.csproj | 4 ---- .../Owin/Properties/PublishProfiles/CI.pubxml | 17 ----------------- .../DotVVM.Tools.StartupPerfTester.csproj | 2 +- .../StartupPerfTester/TestBasicSamples.ps1 | 4 ++-- 11 files changed, 11 insertions(+), 59 deletions(-) delete mode 100644 src/Samples/Api.Owin/Properties/PublishProfiles/CI.pubxml delete mode 100644 src/Samples/Owin/Properties/PublishProfiles/CI.pubxml diff --git a/src/Samples/Api.AspNetCore/DotVVM.Samples.BasicSamples.Api.AspNetCore.csproj b/src/Samples/Api.AspNetCore/DotVVM.Samples.BasicSamples.Api.AspNetCore.csproj index 1e2f0ab8dc..b7a83ef441 100644 --- a/src/Samples/Api.AspNetCore/DotVVM.Samples.BasicSamples.Api.AspNetCore.csproj +++ b/src/Samples/Api.AspNetCore/DotVVM.Samples.BasicSamples.Api.AspNetCore.csproj @@ -1,7 +1,7 @@  - netcoreapp3.1 + net6.0 @@ -16,8 +16,6 @@ - - diff --git a/src/Samples/Api.Owin/DotVVM.Samples.BasicSamples.Api.Owin.csproj b/src/Samples/Api.Owin/DotVVM.Samples.BasicSamples.Api.Owin.csproj index 24b4b43f46..cb5c743144 100644 --- a/src/Samples/Api.Owin/DotVVM.Samples.BasicSamples.Api.Owin.csproj +++ b/src/Samples/Api.Owin/DotVVM.Samples.BasicSamples.Api.Owin.csproj @@ -6,10 +6,6 @@ latest - - - - diff --git a/src/Samples/Api.Owin/Properties/PublishProfiles/CI.pubxml b/src/Samples/Api.Owin/Properties/PublishProfiles/CI.pubxml deleted file mode 100644 index b01f98d69e..0000000000 --- a/src/Samples/Api.Owin/Properties/PublishProfiles/CI.pubxml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - FileSystem - Release - Any CPU - - True - False - c:\www\dotvvm.owin.api - False - - diff --git a/src/Samples/ApplicationInsights.AspNetCore/DotVVM.Samples.ApplicationInsights.AspNetCore.csproj b/src/Samples/ApplicationInsights.AspNetCore/DotVVM.Samples.ApplicationInsights.AspNetCore.csproj index be2f4496c7..89c956dd3c 100644 --- a/src/Samples/ApplicationInsights.AspNetCore/DotVVM.Samples.ApplicationInsights.AspNetCore.csproj +++ b/src/Samples/ApplicationInsights.AspNetCore/DotVVM.Samples.ApplicationInsights.AspNetCore.csproj @@ -1,6 +1,6 @@  - netcoreapp3.1 + netcoreapp6.0 /subscriptions/1f41fa3c-37d6-4b89-b5dc-913463de0d9a/resourcegroups/DotVVMWeb/providers/microsoft.insights/components/DotvvmTrackingPoints /subscriptions/1f41fa3c-37d6-4b89-b5dc-913463de0d9a/resourcegroups/DotVVMWeb/providers/microsoft.insights/components/DotvvmTrackingPoints @@ -12,8 +12,6 @@ - - diff --git a/src/Samples/AspNetCore/DotVVM.Samples.BasicSamples.AspNetCore.csproj b/src/Samples/AspNetCore/DotVVM.Samples.BasicSamples.AspNetCore.csproj index 3d8e953aba..1a1ec2e564 100644 --- a/src/Samples/AspNetCore/DotVVM.Samples.BasicSamples.AspNetCore.csproj +++ b/src/Samples/AspNetCore/DotVVM.Samples.BasicSamples.AspNetCore.csproj @@ -1,6 +1,6 @@  - netcoreapp3.1 + net6.0 Exe diff --git a/src/Samples/Common/regen_api_client.cmd b/src/Samples/Common/regen_api_client.cmd index 863d11cce7..db8198e123 100644 --- a/src/Samples/Common/regen_api_client.cmd +++ b/src/Samples/Common/regen_api_client.cmd @@ -1,5 +1,5 @@ -dotnet ..\..\Tools\CommandLine\bin\Debug\netcoreapp3.1\dotnet-dotvvm.dll api regen http://localhost:50001/swagger/v1/swagger.json +dotnet ..\..\Tools\CommandLine\bin\Debug\net6.0\dotnet-dotvvm.dll api regen http://localhost:50001/swagger/v1/swagger.json tsc .\Scripts\TestWebApiClientAspNetCore.ts -dotnet ..\..\Tools\CommandLine\bin\Debug\netcoreapp3.1\dotnet-dotvvm.dll api regen http://localhost:61453/swagger/v1/swagger.json +dotnet ..\..\Tools\CommandLine\bin\Debug\net6.0\dotnet-dotvvm.dll api regen http://localhost:61453/swagger/v1/swagger.json tsc .\Scripts\TestWebApiClientOwin.ts diff --git a/src/Samples/MiniProfiler.AspNetCore/DotVVM.Samples.MiniProfiler.AspNetCore.csproj b/src/Samples/MiniProfiler.AspNetCore/DotVVM.Samples.MiniProfiler.AspNetCore.csproj index f82f1a39f0..6c7198a40c 100644 --- a/src/Samples/MiniProfiler.AspNetCore/DotVVM.Samples.MiniProfiler.AspNetCore.csproj +++ b/src/Samples/MiniProfiler.AspNetCore/DotVVM.Samples.MiniProfiler.AspNetCore.csproj @@ -1,6 +1,6 @@  - netcoreapp3.1 + net6.0 @@ -14,10 +14,8 @@ - - - - + + diff --git a/src/Samples/Owin/DotVVM.Samples.BasicSamples.Owin.csproj b/src/Samples/Owin/DotVVM.Samples.BasicSamples.Owin.csproj index 1b85ac7325..d5e0b9666b 100644 --- a/src/Samples/Owin/DotVVM.Samples.BasicSamples.Owin.csproj +++ b/src/Samples/Owin/DotVVM.Samples.BasicSamples.Owin.csproj @@ -8,10 +8,6 @@ /path:"$(MSBuildProjectDirectory)" /port:65481 - - - - diff --git a/src/Samples/Owin/Properties/PublishProfiles/CI.pubxml b/src/Samples/Owin/Properties/PublishProfiles/CI.pubxml deleted file mode 100644 index 7f994db22e..0000000000 --- a/src/Samples/Owin/Properties/PublishProfiles/CI.pubxml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - FileSystem - Release - Any CPU - - True - False - c:\www\dotvvm.owin - True - - \ No newline at end of file diff --git a/src/Tools/StartupPerfTester/DotVVM.Tools.StartupPerfTester.csproj b/src/Tools/StartupPerfTester/DotVVM.Tools.StartupPerfTester.csproj index 4f67baefca..6ce9838322 100644 --- a/src/Tools/StartupPerfTester/DotVVM.Tools.StartupPerfTester.csproj +++ b/src/Tools/StartupPerfTester/DotVVM.Tools.StartupPerfTester.csproj @@ -1,6 +1,6 @@  - netcoreapp3.1 + net6.0 Exe dotvvmwizard.snk true diff --git a/src/Tools/StartupPerfTester/TestBasicSamples.ps1 b/src/Tools/StartupPerfTester/TestBasicSamples.ps1 index 3bc939a33e..d26682a092 100644 --- a/src/Tools/StartupPerfTester/TestBasicSamples.ps1 +++ b/src/Tools/StartupPerfTester/TestBasicSamples.ps1 @@ -10,7 +10,7 @@ copy ../DotVVM.Samples.Common/Views -Destination $temp/DotVVM.Samples.Common -Re copy ../DotVVM.Samples.Common/sampleConfig.json -Destination $temp/DotVVM.Samples.Common -Force # Run OWIN tests -./bin/Debug/netcoreapp3.1/DotVVM.Tools.StartupPerfTester.exe ../DotVVM.Samples.BasicSamples.Owin/DotVVM.Samples.BasicSamples.Owin.csproj -t owin -v -r 5 +./bin/Debug/net6.0/DotVVM.Tools.StartupPerfTester.exe ../DotVVM.Samples.BasicSamples.Owin/DotVVM.Samples.BasicSamples.Owin.csproj -t owin -v -r 5 # Run ASP.NET Core tests -./bin/Debug/netcoreapp3.1/DotVVM.Tools.StartupPerfTester.exe ../DotVVM.Samples.BasicSamples.AspNetCoreLatest/DotVVM.Samples.BasicSamples.AspNetCoreLatest.csproj -t aspnetcore -v -r 5 +./bin/Debug/net6.0/DotVVM.Tools.StartupPerfTester.exe ../DotVVM.Samples.BasicSamples.AspNetCoreLatest/DotVVM.Samples.BasicSamples.AspNetCoreLatest.csproj -t aspnetcore -v -r 5 From 0e9413574ca9eb8c88d5193afa0dd598b4cf504b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Herceg?= Date: Mon, 15 Jul 2024 08:58:48 +0200 Subject: [PATCH 02/40] Regenerated web.config files --- .github/uitest/uitest.ps1 | 16 ++-- src/Samples/Api.Owin/Web.config | 8 +- .../ApplicationInsights.Owin/Web.config | 82 ++++++++----------- src/Samples/MiniProfiler.Owin/Web.config | 12 +-- .../DotVVM.Samples.BasicSamples.Owin.csproj | 2 + src/Samples/Owin/Web.config | 2 +- 6 files changed, 56 insertions(+), 66 deletions(-) diff --git a/.github/uitest/uitest.ps1 b/.github/uitest/uitest.ps1 index 3a1b990223..11b8cd65e6 100644 --- a/.github/uitest/uitest.ps1 +++ b/.github/uitest/uitest.ps1 @@ -171,13 +171,17 @@ function Test-Sample { write-host $log get-content $log | write-host } - foreach ($log in dir c:\inetpub\logs\logfiles\*\*.log) { - write-host $log - get-content $log | write-host + if (test-path c:\inetpub\logs\logfiles) { + foreach ($log in dir c:\inetpub\logs\logfiles\*\*.log) { + write-host $log + get-content $log | write-host + } } - foreach ($log in dir $root\artifacts\**\*.log) { - write-host $log - get-content $log | write-host + if (test-path $root\artifacts) { + foreach ($log in dir $root\artifacts\**\*.log) { + write-host $log + get-content $log | write-host + } } throw "The sample '${sampleName}' failed to start." } diff --git a/src/Samples/Api.Owin/Web.config b/src/Samples/Api.Owin/Web.config index f00ebb64ea..e03d0127db 100644 --- a/src/Samples/Api.Owin/Web.config +++ b/src/Samples/Api.Owin/Web.config @@ -14,7 +14,7 @@ --> - + @@ -44,7 +44,7 @@ - + @@ -62,7 +62,7 @@ - + @@ -73,4 +73,4 @@ - + \ No newline at end of file diff --git a/src/Samples/ApplicationInsights.Owin/Web.config b/src/Samples/ApplicationInsights.Owin/Web.config index 6063647d33..49248345f6 100644 --- a/src/Samples/ApplicationInsights.Owin/Web.config +++ b/src/Samples/ApplicationInsights.Owin/Web.config @@ -31,79 +31,69 @@ - - - - - - + + + + - + + + - - - - - + + + - - + + + + - - + + + + - + + + - + + + - - - - - - + + + + - - + + + + - + - - - - - - - - - - - - - - - - - - + + - + \ No newline at end of file diff --git a/src/Samples/MiniProfiler.Owin/Web.config b/src/Samples/MiniProfiler.Owin/Web.config index 40cc6cbbf4..f1eec143ba 100644 --- a/src/Samples/MiniProfiler.Owin/Web.config +++ b/src/Samples/MiniProfiler.Owin/Web.config @@ -13,7 +13,7 @@ --> - + @@ -61,19 +61,13 @@ - + - - - - - - - + diff --git a/src/Samples/Owin/DotVVM.Samples.BasicSamples.Owin.csproj b/src/Samples/Owin/DotVVM.Samples.BasicSamples.Owin.csproj index d5e0b9666b..baca6492f1 100644 --- a/src/Samples/Owin/DotVVM.Samples.BasicSamples.Owin.csproj +++ b/src/Samples/Owin/DotVVM.Samples.BasicSamples.Owin.csproj @@ -35,5 +35,7 @@ + + diff --git a/src/Samples/Owin/Web.config b/src/Samples/Owin/Web.config index b5f4f6e328..76fe2ad837 100644 --- a/src/Samples/Owin/Web.config +++ b/src/Samples/Owin/Web.config @@ -13,7 +13,7 @@ --> - + From 16726610a98ebab7124f44174010c32efb262866 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Herceg?= Date: Thu, 1 Aug 2024 18:20:49 +0200 Subject: [PATCH 03/40] Removed unnecessary publish profiles and upgraded remaining projects --- .../Properties/PublishProfiles/CI.pubxml | 19 ------------------- .../Properties/PublishProfiles/CI.pubxml | 19 ------------------- ...ples.ApplicationInsights.AspNetCore.csproj | 2 +- .../Properties/PublishProfiles/CI.pubxml | 19 ------------------- .../Properties/PublishProfiles/CI.pubxml | 19 ------------------- .../DotVVM.Samples.BasicSamples.Owin.csproj | 2 +- .../DotVVM.HotReload.AspNetCore.csproj | 2 +- 7 files changed, 3 insertions(+), 79 deletions(-) delete mode 100644 src/Samples/Api.AspNetCore/Properties/PublishProfiles/CI.pubxml delete mode 100644 src/Samples/Api.AspNetCoreLatest/Properties/PublishProfiles/CI.pubxml delete mode 100644 src/Samples/AspNetCore/Properties/PublishProfiles/CI.pubxml delete mode 100644 src/Samples/AspNetCoreLatest/Properties/PublishProfiles/CI.pubxml diff --git a/src/Samples/Api.AspNetCore/Properties/PublishProfiles/CI.pubxml b/src/Samples/Api.AspNetCore/Properties/PublishProfiles/CI.pubxml deleted file mode 100644 index 8ff6d87719..0000000000 --- a/src/Samples/Api.AspNetCore/Properties/PublishProfiles/CI.pubxml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - FileSystem - Release - Any CPU - - True - False - c:\www\dotvvm.core.api - False - netcoreapp2.1 - False - - diff --git a/src/Samples/Api.AspNetCoreLatest/Properties/PublishProfiles/CI.pubxml b/src/Samples/Api.AspNetCoreLatest/Properties/PublishProfiles/CI.pubxml deleted file mode 100644 index 52829b39c8..0000000000 --- a/src/Samples/Api.AspNetCoreLatest/Properties/PublishProfiles/CI.pubxml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - FileSystem - Release - Any CPU - - True - False - c:\www\dotvvm.core.api - False - netcoreapp2.0 - False - - diff --git a/src/Samples/ApplicationInsights.AspNetCore/DotVVM.Samples.ApplicationInsights.AspNetCore.csproj b/src/Samples/ApplicationInsights.AspNetCore/DotVVM.Samples.ApplicationInsights.AspNetCore.csproj index 89c956dd3c..0af15fb09c 100644 --- a/src/Samples/ApplicationInsights.AspNetCore/DotVVM.Samples.ApplicationInsights.AspNetCore.csproj +++ b/src/Samples/ApplicationInsights.AspNetCore/DotVVM.Samples.ApplicationInsights.AspNetCore.csproj @@ -1,6 +1,6 @@  - netcoreapp6.0 + net6.0 /subscriptions/1f41fa3c-37d6-4b89-b5dc-913463de0d9a/resourcegroups/DotVVMWeb/providers/microsoft.insights/components/DotvvmTrackingPoints /subscriptions/1f41fa3c-37d6-4b89-b5dc-913463de0d9a/resourcegroups/DotVVMWeb/providers/microsoft.insights/components/DotvvmTrackingPoints diff --git a/src/Samples/AspNetCore/Properties/PublishProfiles/CI.pubxml b/src/Samples/AspNetCore/Properties/PublishProfiles/CI.pubxml deleted file mode 100644 index 89c87dd271..0000000000 --- a/src/Samples/AspNetCore/Properties/PublishProfiles/CI.pubxml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - FileSystem - Release - Any CPU - - True - False - c:\www\dotvvm.core - False - netcoreapp2.1 - False - - diff --git a/src/Samples/AspNetCoreLatest/Properties/PublishProfiles/CI.pubxml b/src/Samples/AspNetCoreLatest/Properties/PublishProfiles/CI.pubxml deleted file mode 100644 index 1a3adc8ce7..0000000000 --- a/src/Samples/AspNetCoreLatest/Properties/PublishProfiles/CI.pubxml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - FileSystem - Release - Any CPU - - True - False - c:\www\dotvvm.corelatest - False - net50 - False - - diff --git a/src/Samples/Owin/DotVVM.Samples.BasicSamples.Owin.csproj b/src/Samples/Owin/DotVVM.Samples.BasicSamples.Owin.csproj index baca6492f1..f5be6b26e0 100644 --- a/src/Samples/Owin/DotVVM.Samples.BasicSamples.Owin.csproj +++ b/src/Samples/Owin/DotVVM.Samples.BasicSamples.Owin.csproj @@ -36,6 +36,6 @@ - + diff --git a/src/Tools/HotReload/AspNetCore/DotVVM.HotReload.AspNetCore.csproj b/src/Tools/HotReload/AspNetCore/DotVVM.HotReload.AspNetCore.csproj index 0a6ced6b27..66edfe7232 100644 --- a/src/Tools/HotReload/AspNetCore/DotVVM.HotReload.AspNetCore.csproj +++ b/src/Tools/HotReload/AspNetCore/DotVVM.HotReload.AspNetCore.csproj @@ -2,7 +2,7 @@ DotVVM.HotReload.AspNetCore - netcoreapp3.1;net6.0 + net6.0 true dotvvmwizard.snk true From d2b3cd1650d4221e4fad7c47eb9efb5959f3c56d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Herceg?= Date: Wed, 29 May 2024 20:20:07 +0200 Subject: [PATCH 04/40] Support for localizable routes --- src/Framework/Framework/Controls/RouteLink.cs | 30 +++++- .../Framework/Controls/RouteLinkCapability.cs | 2 + .../Framework/Controls/RouteLinkHelpers.cs | 18 +++- .../Hosting/DotvvmRequestContextExtensions.cs | 17 ++- .../Framework/Hosting/HostingConstants.cs | 2 + .../Framework/Routing/DotvvmRoute.cs | 2 +- .../Framework/Routing/DotvvmRouteTable.cs | 39 +++++-- .../Framework/Routing/LocalizedDotvvmRoute.cs | 100 ++++++++++++++++++ .../Framework/Routing/LocalizedRouteUrl.cs | 35 ++++++ src/Framework/Framework/Routing/RouteBase.cs | 2 +- .../Routing/RouteTableJsonConverter.cs | 3 +- .../Hosting/Middlewares/DotvvmMiddleware.cs | 5 +- .../PrefixRequestCultureProvider.cs | 25 +++++ src/Samples/AspNetCore/Startup.cs | 11 ++ .../PrefixRequestCultureProvider.cs | 25 +++++ src/Samples/AspNetCoreLatest/Startup.cs | 14 ++- src/Samples/Common/DotvvmStartup.cs | 6 ++ .../Localization/LocalizableRouteViewModel.cs | 16 +++ .../Localization/LocalizableRoute.dothtml | 33 ++++++ src/Samples/Owin/Startup.cs | 19 +++- .../Abstractions/SamplesRouteUrls.designer.cs | 1 + .../Tests/Tests/Feature/LocalizationTests.cs | 43 ++++++++ src/Tests/Routing/RouteTableGroupTests.cs | 18 ++-- 23 files changed, 435 insertions(+), 31 deletions(-) create mode 100644 src/Framework/Framework/Routing/LocalizedDotvvmRoute.cs create mode 100644 src/Framework/Framework/Routing/LocalizedRouteUrl.cs create mode 100644 src/Samples/AspNetCore/PrefixRequestCultureProvider.cs create mode 100644 src/Samples/AspNetCoreLatest/PrefixRequestCultureProvider.cs create mode 100644 src/Samples/Common/ViewModels/FeatureSamples/Localization/LocalizableRouteViewModel.cs create mode 100644 src/Samples/Common/Views/FeatureSamples/Localization/LocalizableRoute.dothtml diff --git a/src/Framework/Framework/Controls/RouteLink.cs b/src/Framework/Framework/Controls/RouteLink.cs index d5f5b4c804..809ecfce51 100644 --- a/src/Framework/Framework/Controls/RouteLink.cs +++ b/src/Framework/Framework/Controls/RouteLink.cs @@ -8,6 +8,7 @@ using DotVVM.Framework.Compilation.Validation; using DotVVM.Framework.Configuration; using DotVVM.Framework.Hosting; +using DotVVM.Framework.Routing; using DotVVM.Framework.Runtime; using DotVVM.Framework.Utils; @@ -64,6 +65,18 @@ public string Text public static readonly DotvvmProperty TextProperty = DotvvmProperty.Register(c => c.Text, ""); + /// + /// Gets or sets the required culture of the page. This property is supported only when using localizable routes. + /// + [MarkupOptions(AllowBinding = false)] + public string Culture + { + get { return (string)GetValue(CultureProperty); } + set { SetValue(CultureProperty, value); } + } + public static readonly DotvvmProperty CultureProperty + = DotvvmProperty.Register(c => c.Culture, null); + /// /// Gets or sets a collection of parameters to be substituted in the route URL. If the current route contains a parameter with the same name, its value will be reused unless another value is specified here. /// @@ -185,7 +198,7 @@ public static IEnumerable ValidateUsage(ResolvedControl contr if (routeNameProperty is not ResolvedPropertyValue { Value: string routeName }) yield break; - if (!configuration.RouteTable.Contains(routeName)) + if (!configuration.RouteTable.TryGetValue(routeName, out var route)) { yield return new ControlUsageError( $"RouteName \"{routeName}\" does not exist.", @@ -193,7 +206,20 @@ public static IEnumerable ValidateUsage(ResolvedControl contr yield break; } - var parameterDefinitions = configuration.RouteTable[routeName].ParameterNames; + if (control.GetValue(CultureProperty) is ResolvedPropertyValue { Value: string culture } + && !string.IsNullOrEmpty(culture)) + { + if (route is not LocalizedDotvvmRoute localizedRoute) + { + yield return new ControlUsageError($"The route {routeName} must be localizable if the {nameof(Culture)} property is set!"); + } + else + { + route = localizedRoute.GetRouteForCulture(culture); + } + } + + var parameterDefinitions = route.ParameterNames; var parameterReferences = control.Properties.Where(i => i.Key is GroupedDotvvmProperty p && p.PropertyGroup == ParamsGroupDescriptor); var undefinedReferences = diff --git a/src/Framework/Framework/Controls/RouteLinkCapability.cs b/src/Framework/Framework/Controls/RouteLinkCapability.cs index e80e18bca8..294189c13c 100644 --- a/src/Framework/Framework/Controls/RouteLinkCapability.cs +++ b/src/Framework/Framework/Controls/RouteLinkCapability.cs @@ -19,5 +19,7 @@ public sealed record RouteLinkCapability [DefaultValue(null)] public ValueOrBinding? UrlSuffix { get; init; } + + public string? Culture { get; init; } } } diff --git a/src/Framework/Framework/Controls/RouteLinkHelpers.cs b/src/Framework/Framework/Controls/RouteLinkHelpers.cs index 4281866015..be8f4f7457 100644 --- a/src/Framework/Framework/Controls/RouteLinkHelpers.cs +++ b/src/Framework/Framework/Controls/RouteLinkHelpers.cs @@ -12,6 +12,7 @@ using DotVVM.Framework.Utils; using DotVVM.Framework.Configuration; using System.Collections.Immutable; +using System.ComponentModel.DataAnnotations; namespace DotVVM.Framework.Controls { @@ -87,7 +88,7 @@ public static string EvaluateRouteUrl(string routeName, RouteLink control, IDotv private static string GenerateRouteUrlCore(string routeName, RouteLink control, IDotvvmRequestContext context) { - var route = GetRoute(context, routeName); + var route = GetRoute(context, routeName, control.Culture); var parameters = ComposeNewRouteParameters(control, context, route); // evaluate bindings on server @@ -114,9 +115,18 @@ private static string GenerateUrlSuffixCore(string? urlSuffix, RouteLink control return UrlHelper.BuildUrlSuffix(urlSuffix, queryParams); } - private static RouteBase GetRoute(IDotvvmRequestContext context, string routeName) + private static RouteBase GetRoute(IDotvvmRequestContext context, string routeName, string? cultureIdentifier) { - return context.Configuration.RouteTable[routeName]; + var route = context.Configuration.RouteTable[routeName]; + if (!string.IsNullOrEmpty(cultureIdentifier)) + { + if (route is not LocalizedDotvvmRoute localizedRoute) + { + throw new DotvvmControlException($"The route {routeName} is not localizable, the Culture property cannot be used!"); + } + route = localizedRoute.GetRouteForCulture(cultureIdentifier!); + } + return route; } public static string GenerateKnockoutHrefExpression(string routeName, RouteLink control, IDotvvmRequestContext context) @@ -146,7 +156,7 @@ public static string GenerateKnockoutHrefExpression(string routeName, RouteLink private static string GenerateRouteLinkCore(string routeName, RouteLink control, IDotvvmRequestContext context) { - var route = GetRoute(context, routeName); + var route = GetRoute(context, routeName, control.Culture); var parameters = ComposeNewRouteParameters(control, context, route); var parametersExpression = parameters.Select(p => TranslateRouteParameter(control, p)).StringJoin(","); diff --git a/src/Framework/Framework/Hosting/DotvvmRequestContextExtensions.cs b/src/Framework/Framework/Hosting/DotvvmRequestContextExtensions.cs index 913347ffb7..875250cc2d 100644 --- a/src/Framework/Framework/Hosting/DotvvmRequestContextExtensions.cs +++ b/src/Framework/Framework/Hosting/DotvvmRequestContextExtensions.cs @@ -52,13 +52,22 @@ public static void ChangeCurrentCulture(this IDotvvmRequestContext context, stri [Obsolete("This method only assigns CultureInfo.CurrentCulture, which is not preserved in async methods. You should assign it manually, or use RequestLocalization middleware or LocalizablePresenter.")] public static void ChangeCurrentCulture(this IDotvvmRequestContext context, string cultureName, string uiCultureName) { + if (!string.IsNullOrEmpty(cultureName)) + { #if DotNetCore - CultureInfo.CurrentCulture = new CultureInfo(cultureName); - CultureInfo.CurrentUICulture = new CultureInfo(uiCultureName); + CultureInfo.CurrentCulture = new CultureInfo(cultureName); #else - Thread.CurrentThread.CurrentCulture = new CultureInfo(cultureName); - Thread.CurrentThread.CurrentUICulture = new CultureInfo(uiCultureName); + Thread.CurrentThread.CurrentCulture = new CultureInfo(cultureName); #endif + } + if (!string.IsNullOrEmpty(uiCultureName)) + { +#if DotNetCore + CultureInfo.CurrentUICulture = new CultureInfo(uiCultureName); +#else + Thread.CurrentThread.CurrentUICulture = new CultureInfo(uiCultureName); +#endif + } } /// diff --git a/src/Framework/Framework/Hosting/HostingConstants.cs b/src/Framework/Framework/Hosting/HostingConstants.cs index a25834d099..f2e4886d52 100644 --- a/src/Framework/Framework/Hosting/HostingConstants.cs +++ b/src/Framework/Framework/Hosting/HostingConstants.cs @@ -28,5 +28,7 @@ public class HostingConstants public const string DotvvmFileUploadAsyncHeaderName = "X-DotVVM-AsyncUpload"; public const string HostAppModeKey = "host.AppMode"; + + public const string OwinDoNotSetRequestCulture = "OwinDoNotSetRequestCulture"; } } diff --git a/src/Framework/Framework/Routing/DotvvmRoute.cs b/src/Framework/Framework/Routing/DotvvmRoute.cs index 41225a55d8..993929b848 100644 --- a/src/Framework/Framework/Routing/DotvvmRoute.cs +++ b/src/Framework/Framework/Routing/DotvvmRoute.cs @@ -123,7 +123,7 @@ public override bool IsMatch(string url, [MaybeNullWhen(false)] out IDictionary< /// /// Builds the URL core from the parameters. /// - protected override string BuildUrlCore(Dictionary values) + protected internal override string BuildUrlCore(Dictionary values) { var convertedValues = values.ToDictionary( diff --git a/src/Framework/Framework/Routing/DotvvmRouteTable.cs b/src/Framework/Framework/Routing/DotvvmRouteTable.cs index 7bf92707f9..572d071e99 100644 --- a/src/Framework/Framework/Routing/DotvvmRouteTable.cs +++ b/src/Framework/Framework/Routing/DotvvmRouteTable.cs @@ -109,10 +109,21 @@ public IDotvvmPresenter GetDefaultPresenter(IServiceProvider provider) /// The virtual path of the Dothtml file. /// The default values. /// Delegate creating the presenter handling this route - public void Add(string routeName, string? url, string virtualPath, object? defaultValues = null, Func? presenterFactory = null) + public void Add(string routeName, string? url, string virtualPath, object? defaultValues = null, Func? presenterFactory = null, LocalizedRouteUrl[]? localizedUrls = null) { ThrowIfFrozen(); - Add(group?.RouteNamePrefix + routeName, new DotvvmRoute(CombinePath(group?.UrlPrefix, url), CombinePath(group?.VirtualPathPrefix, virtualPath), defaultValues, presenterFactory ?? GetDefaultPresenter, configuration)); + + url = CombinePath(group?.UrlPrefix, url); + virtualPath = CombinePath(group?.VirtualPathPrefix, virtualPath); + presenterFactory ??= GetDefaultPresenter; + routeName = group?.RouteNamePrefix + routeName; + + RouteBase route = localizedUrls == null + ? new DotvvmRoute(url, virtualPath, defaultValues, presenterFactory, configuration) + : new LocalizedDotvvmRoute(url, + localizedUrls.Select(l => new LocalizedRouteUrl(l.CultureIdentifier, CombinePath(group?.UrlPrefix, l.RouteUrl))).ToArray(), + virtualPath, defaultValues, presenterFactory, configuration); + Add(routeName, route); } /// @@ -122,10 +133,21 @@ public void Add(string routeName, string? url, string virtualPath, object? defau /// The URL. /// The default values. /// The presenter factory. - public void Add(string routeName, string? url, Func? presenterFactory = null, object? defaultValues = null) + public void Add(string routeName, string? url, Func? presenterFactory = null, object? defaultValues = null, LocalizedRouteUrl[]? localizedUrls = null) { ThrowIfFrozen(); - Add(group?.RouteNamePrefix + routeName, new DotvvmRoute(CombinePath(group?.UrlPrefix, url), group?.VirtualPathPrefix ?? "", defaultValues, presenterFactory ?? GetDefaultPresenter, configuration)); + + url = CombinePath(group?.UrlPrefix, url); + presenterFactory ??= GetDefaultPresenter; + routeName = group?.RouteNamePrefix + routeName; + var virtualPath = group?.VirtualPathPrefix ?? ""; + + RouteBase route = localizedUrls == null + ? new DotvvmRoute(url, virtualPath, defaultValues, presenterFactory, configuration) + : new LocalizedDotvvmRoute(url, + localizedUrls.Select(l => new LocalizedRouteUrl(l.CultureIdentifier, CombinePath(group?.UrlPrefix, l.RouteUrl))).ToArray(), + virtualPath, defaultValues, presenterFactory, configuration); + Add(routeName, route); } /// @@ -203,7 +225,7 @@ public void AddRouteRedirection(string routeName, string urlPattern, FuncThe URL. /// The presenter factory. /// The default values. - public void Add(string routeName, string? url, Type presenterType, object? defaultValues = null) + public void Add(string routeName, string? url, Type presenterType, object? defaultValues = null, LocalizedRouteUrl[] localizedUrls = null) { ThrowIfFrozen(); if (!typeof(IDotvvmPresenter).IsAssignableFrom(presenterType)) @@ -211,7 +233,7 @@ public void Add(string routeName, string? url, Type presenterType, object? defau throw new ArgumentException($@"{nameof(presenterType)} has to inherit from DotVVM.Framework.Hosting.IDotvvmPresenter.", nameof(presenterType)); } Func presenterFactory = provider => (IDotvvmPresenter)provider.GetRequiredService(presenterType); - Add(routeName, url, presenterFactory, defaultValues); + Add(routeName, url, presenterFactory, defaultValues, localizedUrls); } /// @@ -239,6 +261,11 @@ public bool Contains(string routeName) return dictionary.ContainsKey(routeName); } + public bool TryGetValue(string routeName, out RouteBase? route) + { + return dictionary.TryGetValue(routeName, out route); + } + public RouteBase this[string routeName] { get diff --git a/src/Framework/Framework/Routing/LocalizedDotvvmRoute.cs b/src/Framework/Framework/Routing/LocalizedDotvvmRoute.cs new file mode 100644 index 0000000000..00e4adfd9c --- /dev/null +++ b/src/Framework/Framework/Routing/LocalizedDotvvmRoute.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Linq; +using System.Net.WebSockets; +using System.Text; +using System.Threading; +using DotVVM.Framework.Configuration; +using DotVVM.Framework.Hosting; + +namespace DotVVM.Framework.Routing +{ + /// + /// Represents a localizable route with different matching pattern for each culture. + /// Please note that the extraction of the culture from the URL and setting the culture must be done at the beginning of the request pipeline. + /// Therefore, the route only matches the URL for the current culture. + /// + public sealed class LocalizedDotvvmRoute : RouteBase + { + private static readonly HashSet AvailableCultureNames = CultureInfo.GetCultures(CultureTypes.AllCultures) + .Where(c => c != CultureInfo.InvariantCulture) + .Select(c => c.Name) + .ToHashSet(); + + private readonly SortedDictionary localizedRoutes = new(); + + public override string UrlWithoutTypes => GetRouteForCulture(CultureInfo.CurrentUICulture).UrlWithoutTypes; + + /// + /// Gets the names of the route parameters in the order in which they appear in the URL. + /// + public override IEnumerable ParameterNames => GetRouteForCulture(CultureInfo.CurrentUICulture).ParameterNames; + + /// + /// Initializes a new instance of the class. + /// + public LocalizedDotvvmRoute(string defaultLanguageUrl, LocalizedRouteUrl[] localizedUrls, string virtualPath, object? defaultValues, Func presenterFactory, DotvvmConfiguration configuration) + : base(defaultLanguageUrl, virtualPath, defaultValues) + { + if (!localizedUrls.Any()) + { + throw new ArgumentException("There must be at least one localized route URL!", nameof(localizedUrls)); + } + + foreach (var localizedUrl in localizedUrls) + { + var localizedRoute = new DotvvmRoute(localizedUrl.RouteUrl, virtualPath, defaultValues, presenterFactory, configuration); + localizedRoutes.Add(localizedUrl.CultureIdentifier, localizedRoute); + } + + var defaultRoute = new DotvvmRoute(defaultLanguageUrl, virtualPath, defaultValues, presenterFactory, configuration); + localizedRoutes.Add("", defaultRoute); + } + + public DotvvmRoute GetRouteForCulture(string cultureIdentifier) + { + ValidateCultureName(cultureIdentifier); + return GetRouteForCulture(CultureInfo.GetCultureInfo(cultureIdentifier)); + } + + public DotvvmRoute GetRouteForCulture(CultureInfo culture) + { + return localizedRoutes.TryGetValue(culture.Name, out var exactMatchRoute) ? exactMatchRoute + : localizedRoutes.TryGetValue(culture.TwoLetterISOLanguageName, out var languageMatchRoute) ? languageMatchRoute + : localizedRoutes.TryGetValue("", out var defaultRoute) ? defaultRoute + : throw new NotSupportedException("Invalid localized route - no default route found!"); + } + + public static void ValidateCultureName(string cultureIdentifier) + { + if (!AvailableCultureNames.Contains(cultureIdentifier)) + { + throw new ArgumentException($"Culture {cultureIdentifier} was not found!", nameof(cultureIdentifier)); + } + } + + /// + /// Processes the request. + /// + public override IDotvvmPresenter GetPresenter(IServiceProvider provider) => GetRouteForCulture(CultureInfo.CurrentCulture).GetPresenter(provider); + + /// + /// Determines whether the route matches to the specified URL and extracts the parameter values. + /// + public override bool IsMatch(string url, [MaybeNullWhen(false)] out IDictionary values) => GetRouteForCulture(CultureInfo.CurrentCulture).IsMatch(url, out values); + + protected internal override string BuildUrlCore(Dictionary values) => GetRouteForCulture(CultureInfo.CurrentCulture).BuildUrlCore(values); + + protected override void Freeze2() + { + foreach (var route in localizedRoutes) + { + route.Value.Freeze(); + } + } + } +} diff --git a/src/Framework/Framework/Routing/LocalizedRouteUrl.cs b/src/Framework/Framework/Routing/LocalizedRouteUrl.cs new file mode 100644 index 0000000000..8446b34afa --- /dev/null +++ b/src/Framework/Framework/Routing/LocalizedRouteUrl.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Globalization; +using System.Linq; + +namespace DotVVM.Framework.Routing +{ + public record LocalizedRouteUrl + { + /// + /// Gets or sets the culture identifier. Allowed formats are language-REGION (e.g. en-US) or language (e.g. en). + /// + public string CultureIdentifier { get; } + + /// + /// Get or sets the corresponding route URL. + /// + public string RouteUrl { get; } + + /// + /// Represents a localized route URL. + /// + /// Culture identifier. Allowed formats are language-REGION (e.g. en-US) or language (e.g. en) + /// Corresponding route URL for the culture. + public LocalizedRouteUrl(string cultureIdentifier, string routeUrl) + { + LocalizedDotvvmRoute.ValidateCultureName(cultureIdentifier); + + CultureIdentifier = cultureIdentifier; + RouteUrl = routeUrl; + } + + } +} diff --git a/src/Framework/Framework/Routing/RouteBase.cs b/src/Framework/Framework/Routing/RouteBase.cs index 329097f68a..9c17498015 100644 --- a/src/Framework/Framework/Routing/RouteBase.cs +++ b/src/Framework/Framework/Routing/RouteBase.cs @@ -157,7 +157,7 @@ public string BuildUrl(IDictionary routeValues) /// Builds the URL core from the parameters. /// /// The default values are already included in the collection. - protected abstract string BuildUrlCore(Dictionary values); + protected internal abstract string BuildUrlCore(Dictionary values); /// /// Adds or updates the parameter collection with the specified values from the anonymous object. diff --git a/src/Framework/Framework/Routing/RouteTableJsonConverter.cs b/src/Framework/Framework/Routing/RouteTableJsonConverter.cs index bd3e6154fe..c1fa741c9c 100644 --- a/src/Framework/Framework/Routing/RouteTableJsonConverter.cs +++ b/src/Framework/Framework/Routing/RouteTableJsonConverter.cs @@ -75,7 +75,8 @@ public ErrorRoute(string? url, string? virtualPath, string? name, IDictionary values) => throw new InvalidOperationException($"Could not create route {RouteName}", error); - protected override string BuildUrlCore(Dictionary values) => throw new InvalidOperationException($"Could not create route {RouteName}", error); + protected internal override string BuildUrlCore(Dictionary values) => throw new InvalidOperationException($"Could not create route {RouteName}", error); + protected override void Freeze2() { // no mutable properties in this class diff --git a/src/Framework/Hosting.Owin/Hosting/Middlewares/DotvvmMiddleware.cs b/src/Framework/Hosting.Owin/Hosting/Middlewares/DotvvmMiddleware.cs index 4641c9160f..21d09e57d2 100644 --- a/src/Framework/Hosting.Owin/Hosting/Middlewares/DotvvmMiddleware.cs +++ b/src/Framework/Hosting.Owin/Hosting/Middlewares/DotvvmMiddleware.cs @@ -49,7 +49,10 @@ public override async Task Invoke(IOwinContext context) var dotvvmContext = CreateDotvvmContext(context, scope); dotvvmContext.Services.GetRequiredService().Context = dotvvmContext; context.Set(HostingConstants.DotvvmRequestContextKey, dotvvmContext); - dotvvmContext.ChangeCurrentCulture(Configuration.DefaultCulture); + if (context.Get("OwinDoNotSetRequestCulture") != true) + { + dotvvmContext.ChangeCurrentCulture(Configuration.DefaultCulture); + } try { diff --git a/src/Samples/AspNetCore/PrefixRequestCultureProvider.cs b/src/Samples/AspNetCore/PrefixRequestCultureProvider.cs new file mode 100644 index 0000000000..65760ccb29 --- /dev/null +++ b/src/Samples/AspNetCore/PrefixRequestCultureProvider.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Http; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Localization; + +namespace DotVVM.Samples.BasicSamples +{ + public class PrefixRequestCultureProvider : RequestCultureProvider + { + public override Task DetermineProviderCultureResult(HttpContext httpContext) + { + if (httpContext.Request.Path.StartsWithSegments("/cs")) + { + return Task.FromResult(new ProviderCultureResult("cs-CZ")); + } + else if (httpContext.Request.Path.StartsWithSegments("/de")) + { + return Task.FromResult(new ProviderCultureResult("de")); + } + else + { + return Task.FromResult(new ProviderCultureResult("en-US")); + } + } + } +} diff --git a/src/Samples/AspNetCore/Startup.cs b/src/Samples/AspNetCore/Startup.cs index 097957df9d..b1273c033e 100644 --- a/src/Samples/AspNetCore/Startup.cs +++ b/src/Samples/AspNetCore/Startup.cs @@ -68,10 +68,21 @@ public void ConfigureServices(IServiceCollection services) services.AddScoped(); services.AddTransient(); + + services.Configure(options => { + var supportedCultures = new[] { "en-US", "cs-CZ", "de" }; + options + .SetDefaultCulture(supportedCultures[0]) + .AddSupportedCultures(supportedCultures) + .AddSupportedUICultures(supportedCultures) + .AddInitialRequestCultureProvider(new PrefixRequestCultureProvider()); + }); } public void Configure(IApplicationBuilder app, IWebHostEnvironment env, ILoggerFactory loggerFactory) { + app.UseRequestLocalization(); + app.UseRouting(); app.UseAuthentication(); diff --git a/src/Samples/AspNetCoreLatest/PrefixRequestCultureProvider.cs b/src/Samples/AspNetCoreLatest/PrefixRequestCultureProvider.cs new file mode 100644 index 0000000000..c2f08722de --- /dev/null +++ b/src/Samples/AspNetCoreLatest/PrefixRequestCultureProvider.cs @@ -0,0 +1,25 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Localization; + +namespace DotVVM.Samples.BasicSamples +{ + public class PrefixRequestCultureProvider : RequestCultureProvider + { + public override Task DetermineProviderCultureResult(HttpContext httpContext) + { + if (httpContext.Request.Path.StartsWithSegments("/cs")) + { + return Task.FromResult(new ProviderCultureResult("cs-CZ")); + } + else if (httpContext.Request.Path.StartsWithSegments("/de")) + { + return Task.FromResult(new ProviderCultureResult("de")); + } + else + { + return Task.FromResult(new ProviderCultureResult("en-US")); + } + } + } +} diff --git a/src/Samples/AspNetCoreLatest/Startup.cs b/src/Samples/AspNetCoreLatest/Startup.cs index 4342847d57..b73dae631f 100644 --- a/src/Samples/AspNetCoreLatest/Startup.cs +++ b/src/Samples/AspNetCoreLatest/Startup.cs @@ -59,7 +59,7 @@ public void ConfigureServices(IServiceCollection services) .AddCookie("Scheme3"); services.AddHealthChecks(); - + services.AddLocalization(o => o.ResourcesPath = "Resources"); services.AddDotVVM(); @@ -71,10 +71,22 @@ public void ConfigureServices(IServiceCollection services) services.AddSingleton(); services.AddScoped(); + + services.Configure(options => + { + var supportedCultures = new[] { "en-US", "cs-CZ", "de" }; + options + .SetDefaultCulture(supportedCultures[0]) + .AddSupportedCultures(supportedCultures) + .AddSupportedUICultures(supportedCultures) + .AddInitialRequestCultureProvider(new PrefixRequestCultureProvider()); + }); } public void Configure(IApplicationBuilder app, IWebHostEnvironment env, ILoggerFactory loggerFactory) { + app.UseRequestLocalization(); + app.UseRouting(); app.UseAuthentication(); diff --git a/src/Samples/Common/DotvvmStartup.cs b/src/Samples/Common/DotvvmStartup.cs index 8b8809f9f1..bd3b873553 100644 --- a/src/Samples/Common/DotvvmStartup.cs +++ b/src/Samples/Common/DotvvmStartup.cs @@ -235,6 +235,12 @@ private static void AddRoutes(DotvvmConfiguration config) config.RouteTable.Add("FeatureSamples_Localization_Globalize", "FeatureSamples/Localization/Globalize", "Views/FeatureSamples/Localization/Globalize.dothtml", presenterFactory: LocalizablePresenter.BasedOnQuery("lang")); config.RouteTable.Add("FeatureSamples_CustomPrimitiveTypes_Basic", "FeatureSamples/CustomPrimitiveTypes/Basic/{Id?}", "Views/FeatureSamples/CustomPrimitiveTypes/Basic.dothtml"); + config.RouteTable.Add("FeatureSamples_Localization_LocalizableRoute", "FeatureSamples/Localization/LocalizableRoute/{lang?}", "Views/FeatureSamples/Localization/LocalizableRoute.dothtml", + localizedUrls: new LocalizedRouteUrl[] { + new("cs-CZ", "cs/FeatureSamples/Localization/lokalizovana-routa"), + new("de", "de/FeatureSamples/Localization/lokalisierte-route"), + }); + config.RouteTable.AutoDiscoverRoutes(new DefaultRouteStrategy(config)); config.RouteTable.Add("ControlSamples_Repeater_RouteLinkQuery-PageDetail", "ControlSamples/Repeater/RouteLinkQuery/{Id}", "Views/ControlSamples/Repeater/RouteLinkQuery.dothtml", new { Id = 0 }); diff --git a/src/Samples/Common/ViewModels/FeatureSamples/Localization/LocalizableRouteViewModel.cs b/src/Samples/Common/ViewModels/FeatureSamples/Localization/LocalizableRouteViewModel.cs new file mode 100644 index 0000000000..eeb3812e71 --- /dev/null +++ b/src/Samples/Common/ViewModels/FeatureSamples/Localization/LocalizableRouteViewModel.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using DotVVM.Framework.ViewModel; +using DotVVM.Framework.Hosting; + +namespace DotVVM.Samples.Common.ViewModels.FeatureSamples.Localization +{ + public class LocalizableRouteViewModel : DotvvmViewModelBase + { + + } +} + diff --git a/src/Samples/Common/Views/FeatureSamples/Localization/LocalizableRoute.dothtml b/src/Samples/Common/Views/FeatureSamples/Localization/LocalizableRoute.dothtml new file mode 100644 index 0000000000..23394a4f4c --- /dev/null +++ b/src/Samples/Common/Views/FeatureSamples/Localization/LocalizableRoute.dothtml @@ -0,0 +1,33 @@ +@viewModel DotVVM.Samples.Common.ViewModels.FeatureSamples.Localization.LocalizableRouteViewModel, DotVVM.Samples.Common + + + + + + + + + + +

Localizable route test

+ +

Current culture: {{resource: System.Globalization.CultureInfo.CurrentUICulture.Name}}

+ + + + + + + + + + + + diff --git a/src/Samples/Owin/Startup.cs b/src/Samples/Owin/Startup.cs index a1dcf96596..e397ccd980 100644 --- a/src/Samples/Owin/Startup.cs +++ b/src/Samples/Owin/Startup.cs @@ -4,7 +4,6 @@ using DotVVM.Framework.Hosting; using DotVVM.Samples.BasicSamples; using DotVVM.Samples.BasicSamples.ViewModels.ComplexSamples.Auth; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Owin; using Microsoft.Owin.Security.Cookies; using Owin; @@ -17,6 +16,9 @@ using System.Configuration; using DotVVM.Framework.Utils; using System.Linq; +using System.Threading; +using System.Globalization; +using System.Runtime.Remoting.Contexts; [assembly: OwinStartup(typeof(Startup))] @@ -26,6 +28,21 @@ public class Startup { public void Configuration(IAppBuilder app) { + app.Use((context, next) => { + if (context.Request.Path.StartsWithSegments(new PathString("/cs"))) + { + CultureInfo.CurrentCulture = CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("cs-CZ"); + context.Set(HostingConstants.OwinDoNotSetRequestCulture, true); + } + else if (context.Request.Path.StartsWithSegments(new PathString("/de"))) + { + CultureInfo.CurrentCulture = CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("de"); + context.Set(HostingConstants.OwinDoNotSetRequestCulture, true); + } + + return next(); + }); + app.Use( new List { new SwitchMiddlewareCase( diff --git a/src/Samples/Tests/Abstractions/SamplesRouteUrls.designer.cs b/src/Samples/Tests/Abstractions/SamplesRouteUrls.designer.cs index eb5392fe03..95f4e37fea 100644 --- a/src/Samples/Tests/Abstractions/SamplesRouteUrls.designer.cs +++ b/src/Samples/Tests/Abstractions/SamplesRouteUrls.designer.cs @@ -279,6 +279,7 @@ public partial class SamplesRouteUrls public const string FeatureSamples_LambdaExpressions_StaticCommands = "FeatureSamples/LambdaExpressions/StaticCommands"; public const string FeatureSamples_LiteralBinding_LiteralBinding_Zero = "FeatureSamples/LiteralBinding/LiteralBinding_Zero"; public const string FeatureSamples_Localization_Globalize = "FeatureSamples/Localization/Globalize"; + public const string FeatureSamples_Localization_LocalizableRoute = "FeatureSamples/Localization/LocalizableRoute"; public const string FeatureSamples_Localization_Localization = "FeatureSamples/Localization/Localization"; public const string FeatureSamples_Localization_Localization_Control_Page = "FeatureSamples/Localization/Localization_Control_Page"; public const string FeatureSamples_Localization_Localization_FormatString = "FeatureSamples/Localization/Localization_FormatString"; diff --git a/src/Samples/Tests/Tests/Feature/LocalizationTests.cs b/src/Samples/Tests/Tests/Feature/LocalizationTests.cs index 80ebe38639..5751368528 100644 --- a/src/Samples/Tests/Tests/Feature/LocalizationTests.cs +++ b/src/Samples/Tests/Tests/Feature/LocalizationTests.cs @@ -133,6 +133,49 @@ void CheckForm(IBrowserWrapper browser) { }); } + [Fact] + public void Feature_Localization_LocalizableRoute() + { + RunInAllBrowsers(browser => { + browser.NavigateToUrl(SamplesRouteUrls.FeatureSamples_Localization_LocalizableRoute); + + var culture = browser.Single("span[data-ui=culture]"); + var links = browser.FindElements("a"); + AssertUI.TextEquals(culture, "en-US"); + AssertUI.Attribute(links[0], "href", v => v.EndsWith("/cs/FeatureSamples/Localization/lokalizovana-routa")); + AssertUI.Attribute(links[1], "href", v => v.EndsWith("/de/FeatureSamples/Localization/lokalisierte-route")); + AssertUI.Attribute(links[2], "href", v => v.EndsWith("/FeatureSamples/Localization/LocalizableRoute")); + AssertUI.Attribute(links[3], "href", links[2].GetAttribute("href")); + + links[0].Click().Wait(500); + culture = browser.Single("span[data-ui=culture]"); + links = browser.FindElements("a"); + AssertUI.TextEquals(culture, "cs-CZ"); + AssertUI.Attribute(links[0], "href", v => v.EndsWith("/cs/FeatureSamples/Localization/lokalizovana-routa")); + AssertUI.Attribute(links[1], "href", v => v.EndsWith("/de/FeatureSamples/Localization/lokalisierte-route")); + AssertUI.Attribute(links[2], "href", v => v.EndsWith("/FeatureSamples/Localization/LocalizableRoute")); + AssertUI.Attribute(links[3], "href", links[0].GetAttribute("href")); + + links[1].Click().Wait(500); + culture = browser.Single("span[data-ui=culture]"); + links = browser.FindElements("a"); + AssertUI.TextEquals(culture, "de"); + AssertUI.Attribute(links[0], "href", v => v.EndsWith("/cs/FeatureSamples/Localization/lokalizovana-routa")); + AssertUI.Attribute(links[1], "href", v => v.EndsWith("/de/FeatureSamples/Localization/lokalisierte-route")); + AssertUI.Attribute(links[2], "href", v => v.EndsWith("/FeatureSamples/Localization/LocalizableRoute")); + AssertUI.Attribute(links[3], "href", links[1].GetAttribute("href")); + + links[2].Click().Wait(500); + culture = browser.Single("span[data-ui=culture]"); + links = browser.FindElements("a"); + AssertUI.TextEquals(culture, "en-US"); + AssertUI.Attribute(links[0], "href", v => v.EndsWith("/cs/FeatureSamples/Localization/lokalizovana-routa")); + AssertUI.Attribute(links[1], "href", v => v.EndsWith("/de/FeatureSamples/Localization/lokalisierte-route")); + AssertUI.Attribute(links[2], "href", v => v.EndsWith("/FeatureSamples/Localization/LocalizableRoute")); + AssertUI.Attribute(links[3], "href", links[2].GetAttribute("href")); + }); + } + public LocalizationTests(ITestOutputHelper output) : base(output) { } diff --git a/src/Tests/Routing/RouteTableGroupTests.cs b/src/Tests/Routing/RouteTableGroupTests.cs index 27c316c01d..32868ddf7e 100644 --- a/src/Tests/Routing/RouteTableGroupTests.cs +++ b/src/Tests/Routing/RouteTableGroupTests.cs @@ -40,7 +40,7 @@ public void RouteTableGroup_EmptyRouteName() { var table = new DotvvmRouteTable(configuration); table.AddGroup("Group", "UrlPrefix/{Id}", null, opt => { - opt.Add("Default", "", null, null, null); + opt.Add("Default", "", null, null, null, null); }); var group = table.GetGroup("Group"); @@ -56,7 +56,7 @@ public void RouteTableGroup_DefaultValues() { var table = new DotvvmRouteTable(configuration); table.AddGroup("Group", "UrlPrefix/{Id}", null, opt => { - opt.Add("Route", "Article/{Title}", null, new { Title = "test" }, null); + opt.Add("Route", "Article/{Title}", null, new { Title = "test" }, null, null); }); var group = table.GetGroup("Group"); @@ -74,8 +74,8 @@ public void RouteTableGroup_MultipleRoutes() { var table = new DotvvmRouteTable(configuration); table.AddGroup("Group", "UrlPrefix/{Id}", null, opt => { - opt.Add("Route0", "Article0/{Title}", null, null, null); - opt.Add("Route1", "Article1/{Title}", null, null, null); + opt.Add("Route0", "Article0/{Title}", null, null, null, null); + opt.Add("Route1", "Article1/{Title}", null, null, null, null); }); var group = table.GetGroup("Group"); @@ -90,8 +90,8 @@ public void RouteTableGroup_MultipleRoutesWithParameters() { var table = new DotvvmRouteTable(configuration); table.AddGroup("Group", "UrlPrefix/{Id}", null, opt => { - opt.Add("Route0", "Article0/{Title}", null, null, null); - opt.Add("Route1", "Article1/{Title}", null, null, null); + opt.Add("Route0", "Article0/{Title}", null, null, null, null); + opt.Add("Route1", "Article1/{Title}", null, null, null, null); }); var group = table.GetGroup("Group"); @@ -110,11 +110,11 @@ public void RouteTableGroup_NestedGroups() table.AddGroup("Group1", "UrlPrefix1", null, opt1 => { opt1.AddGroup("Group2", "UrlPrefix2", null, opt2 => { opt2.AddGroup("Group3", "UrlPrefix3", null, opt3 => { - opt3.Add("Route3", "Article3", null, null, null); + opt3.Add("Route3", "Article3", null, null, null, null); }); - opt2.Add("Route2", "Article2", null, null, null); + opt2.Add("Route2", "Article2", null, null, null, null); }); - opt1.Add("Route1", "Article1", null, null, null); + opt1.Add("Route1", "Article1", null, null, null, null); }); var group = table.GetGroup("Group1"); From 989f9b4b463888260c9ba3beca169a8aa704c97d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Herceg?= Date: Wed, 29 May 2024 20:33:34 +0200 Subject: [PATCH 05/40] Reverted unnecessary file changes --- .../Framework/Controls/RouteLinkHelpers.cs | 1 - .../Hosting/DotvvmRequestContextExtensions.cs | 17 ++++------------- src/Samples/AspNetCoreLatest/Startup.cs | 2 +- src/Samples/Owin/Startup.cs | 3 +-- 4 files changed, 6 insertions(+), 17 deletions(-) diff --git a/src/Framework/Framework/Controls/RouteLinkHelpers.cs b/src/Framework/Framework/Controls/RouteLinkHelpers.cs index be8f4f7457..bf17ef36b8 100644 --- a/src/Framework/Framework/Controls/RouteLinkHelpers.cs +++ b/src/Framework/Framework/Controls/RouteLinkHelpers.cs @@ -12,7 +12,6 @@ using DotVVM.Framework.Utils; using DotVVM.Framework.Configuration; using System.Collections.Immutable; -using System.ComponentModel.DataAnnotations; namespace DotVVM.Framework.Controls { diff --git a/src/Framework/Framework/Hosting/DotvvmRequestContextExtensions.cs b/src/Framework/Framework/Hosting/DotvvmRequestContextExtensions.cs index 875250cc2d..913347ffb7 100644 --- a/src/Framework/Framework/Hosting/DotvvmRequestContextExtensions.cs +++ b/src/Framework/Framework/Hosting/DotvvmRequestContextExtensions.cs @@ -52,22 +52,13 @@ public static void ChangeCurrentCulture(this IDotvvmRequestContext context, stri [Obsolete("This method only assigns CultureInfo.CurrentCulture, which is not preserved in async methods. You should assign it manually, or use RequestLocalization middleware or LocalizablePresenter.")] public static void ChangeCurrentCulture(this IDotvvmRequestContext context, string cultureName, string uiCultureName) { - if (!string.IsNullOrEmpty(cultureName)) - { #if DotNetCore - CultureInfo.CurrentCulture = new CultureInfo(cultureName); + CultureInfo.CurrentCulture = new CultureInfo(cultureName); + CultureInfo.CurrentUICulture = new CultureInfo(uiCultureName); #else - Thread.CurrentThread.CurrentCulture = new CultureInfo(cultureName); + Thread.CurrentThread.CurrentCulture = new CultureInfo(cultureName); + Thread.CurrentThread.CurrentUICulture = new CultureInfo(uiCultureName); #endif - } - if (!string.IsNullOrEmpty(uiCultureName)) - { -#if DotNetCore - CultureInfo.CurrentUICulture = new CultureInfo(uiCultureName); -#else - Thread.CurrentThread.CurrentUICulture = new CultureInfo(uiCultureName); -#endif - } } /// diff --git a/src/Samples/AspNetCoreLatest/Startup.cs b/src/Samples/AspNetCoreLatest/Startup.cs index b73dae631f..43107a6baa 100644 --- a/src/Samples/AspNetCoreLatest/Startup.cs +++ b/src/Samples/AspNetCoreLatest/Startup.cs @@ -59,7 +59,7 @@ public void ConfigureServices(IServiceCollection services) .AddCookie("Scheme3"); services.AddHealthChecks(); - + services.AddLocalization(o => o.ResourcesPath = "Resources"); services.AddDotVVM(); diff --git a/src/Samples/Owin/Startup.cs b/src/Samples/Owin/Startup.cs index e397ccd980..bef3c95d16 100644 --- a/src/Samples/Owin/Startup.cs +++ b/src/Samples/Owin/Startup.cs @@ -4,6 +4,7 @@ using DotVVM.Framework.Hosting; using DotVVM.Samples.BasicSamples; using DotVVM.Samples.BasicSamples.ViewModels.ComplexSamples.Auth; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Owin; using Microsoft.Owin.Security.Cookies; using Owin; @@ -16,9 +17,7 @@ using System.Configuration; using DotVVM.Framework.Utils; using System.Linq; -using System.Threading; using System.Globalization; -using System.Runtime.Remoting.Contexts; [assembly: OwinStartup(typeof(Startup))] From 9a66b294c96583d098987fb8be30e6481826a0ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Herceg?= Date: Tue, 4 Jun 2024 09:47:08 +0200 Subject: [PATCH 06/40] Fixed build errors and unit tests --- src/Framework/Framework/Controls/RouteLink.cs | 8 ++++---- src/Framework/Framework/Routing/DotvvmRouteTable.cs | 2 +- ...gurationSerializationTests.SerializeDefaultConfig.json | 4 ++++ 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/Framework/Framework/Controls/RouteLink.cs b/src/Framework/Framework/Controls/RouteLink.cs index 809ecfce51..f4044a96c6 100644 --- a/src/Framework/Framework/Controls/RouteLink.cs +++ b/src/Framework/Framework/Controls/RouteLink.cs @@ -69,13 +69,13 @@ public string Text /// Gets or sets the required culture of the page. This property is supported only when using localizable routes. /// [MarkupOptions(AllowBinding = false)] - public string Culture + public string? Culture { - get { return (string)GetValue(CultureProperty); } + get { return (string?)GetValue(CultureProperty); } set { SetValue(CultureProperty, value); } } public static readonly DotvvmProperty CultureProperty - = DotvvmProperty.Register(c => c.Culture, null); + = DotvvmProperty.Register(c => c.Culture, null); /// /// Gets or sets a collection of parameters to be substituted in the route URL. If the current route contains a parameter with the same name, its value will be reused unless another value is specified here. @@ -219,7 +219,7 @@ public static IEnumerable ValidateUsage(ResolvedControl contr } } - var parameterDefinitions = route.ParameterNames; + var parameterDefinitions = route!.ParameterNames; var parameterReferences = control.Properties.Where(i => i.Key is GroupedDotvvmProperty p && p.PropertyGroup == ParamsGroupDescriptor); var undefinedReferences = diff --git a/src/Framework/Framework/Routing/DotvvmRouteTable.cs b/src/Framework/Framework/Routing/DotvvmRouteTable.cs index 572d071e99..aa0ccf65e7 100644 --- a/src/Framework/Framework/Routing/DotvvmRouteTable.cs +++ b/src/Framework/Framework/Routing/DotvvmRouteTable.cs @@ -225,7 +225,7 @@ public void AddRouteRedirection(string routeName, string urlPattern, FuncThe URL. /// The presenter factory. /// The default values. - public void Add(string routeName, string? url, Type presenterType, object? defaultValues = null, LocalizedRouteUrl[] localizedUrls = null) + public void Add(string routeName, string? url, Type presenterType, object? defaultValues = null, LocalizedRouteUrl[]? localizedUrls = null) { ThrowIfFrozen(); if (!typeof(IDotvvmPresenter).IsAssignableFrom(presenterType)) diff --git a/src/Tests/Runtime/config-tests/ConfigurationSerializationTests.SerializeDefaultConfig.json b/src/Tests/Runtime/config-tests/ConfigurationSerializationTests.SerializeDefaultConfig.json index ceb23e1b0a..a1ad38e131 100644 --- a/src/Tests/Runtime/config-tests/ConfigurationSerializationTests.SerializeDefaultConfig.json +++ b/src/Tests/Runtime/config-tests/ConfigurationSerializationTests.SerializeDefaultConfig.json @@ -1354,6 +1354,10 @@ } }, "DotVVM.Framework.Controls.RouteLink": { + "Culture": { + "type": "System.String", + "onlyHardcoded": true + }, "Enabled": { "type": "System.Boolean", "defaultValue": true From fed797c42ac09ed7282213e7a1136d41692c8f40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Herceg?= Date: Fri, 12 Jul 2024 10:13:46 +0200 Subject: [PATCH 07/40] Implemented partial route matching and handlers --- .../Middlewares/DotvvmRoutingMiddleware.cs | 46 ++++++++++++-- .../LocalResourceUrlManager.cs | 2 +- ...nonicalRedirectPartialMatchRouteHandler.cs | 18 ++++++ .../Framework/Routing/DotvvmRouteTable.cs | 28 +++++---- .../Framework/Routing/IPartialMatchRoute.cs | 9 +++ .../Routing/IPartialMatchRouteHandler.cs | 14 +++++ .../Framework/Routing/LocalizedDotvvmRoute.cs | 58 ++++++++++++++++- src/Framework/Framework/Routing/RouteBase.cs | 2 +- .../ApplicationInsights.Owin/Web.config | 14 ++++- src/Samples/AspNetCore/Startup.cs | 3 +- src/Samples/AspNetCoreLatest/Startup.cs | 3 +- src/Samples/Common/DotvvmStartup.cs | 1 + src/Samples/Owin/Startup.cs | 12 +++- .../Tests/Tests/Feature/LocalizationTests.cs | 13 ++++ src/Tests/Routing/DotvvmRouteTests.cs | 63 +++++++++++++++++++ 15 files changed, 263 insertions(+), 23 deletions(-) create mode 100644 src/Framework/Framework/Routing/CanonicalRedirectPartialMatchRouteHandler.cs create mode 100644 src/Framework/Framework/Routing/IPartialMatchRoute.cs create mode 100644 src/Framework/Framework/Routing/IPartialMatchRouteHandler.cs diff --git a/src/Framework/Framework/Hosting/Middlewares/DotvvmRoutingMiddleware.cs b/src/Framework/Framework/Hosting/Middlewares/DotvvmRoutingMiddleware.cs index 2f31f4edb3..def06012d2 100644 --- a/src/Framework/Framework/Hosting/Middlewares/DotvvmRoutingMiddleware.cs +++ b/src/Framework/Framework/Hosting/Middlewares/DotvvmRoutingMiddleware.cs @@ -38,7 +38,7 @@ private static bool TryParseGooglebotHashbangEscapedFragment(string queryString, return false; } - public static RouteBase? FindMatchingRoute(IEnumerable routes, IDotvvmRequestContext context, out IDictionary? parameters) + public static RouteBase? FindMatchingRoute(IEnumerable routes, IDotvvmRequestContext context, out IDictionary? parameters, out bool isPartialMatch) { string? url; if (!TryParseGooglebotHashbangEscapedFragment(context.HttpContext.Request.Url.Query, out url)) @@ -53,12 +53,35 @@ private static bool TryParseGooglebotHashbangEscapedFragment(string queryString, url = url.Substring(HostingConstants.SpaUrlIdentifier.Length).Trim('/'); } - // find the route + RouteBase? partialMatch = null; + IDictionary? partialMatchParameters = null; + foreach (var r in routes) { - if (r.IsMatch(url, out parameters)) return r; + if (r.IsMatch(url, out parameters)) + { + isPartialMatch = false; + return r; + } + + if (partialMatch == null + && r is IPartialMatchRoute partialMatchRoute + && partialMatchRoute.IsPartialMatch(url, out var partialMatchResult, out var partialMatchParametersResult)) + { + partialMatch = partialMatchResult; + partialMatchParameters = partialMatchParametersResult; + } + } + + if (partialMatch != null) + { + isPartialMatch = true; + parameters = partialMatchParameters; + return partialMatch; } + + isPartialMatch = false; parameters = null; return null; } @@ -70,11 +93,11 @@ public async Task Handle(IDotvvmRequestContext context) await requestTracer.TraceEvent(RequestTracingConstants.BeginRequest, context); - var route = FindMatchingRoute(context.Configuration.RouteTable, context, out var parameters); + var route = FindMatchingRoute(context.Configuration.RouteTable, context, out var parameters, out var isPartialMatch); //check if route exists if (route == null) return false; - + var timer = ValueStopwatch.StartNew(); context.Route = route; @@ -83,12 +106,25 @@ public async Task Handle(IDotvvmRequestContext context) WriteSecurityHeaders(context); + var filters = ActionFilterHelper.GetActionFilters(presenter.GetType().GetTypeInfo()) .Concat(context.Configuration.Runtime.GlobalFilters.OfType()); try { foreach (var f in filters) await f.OnPresenterExecutingAsync(context); + + if (isPartialMatch) + { + foreach (var handler in context.Configuration.RouteTable.PartialMatchHandlers) + { + if (await handler.TryHandlePartialMatch(context)) + { + break; + } + } + } + await presenter.ProcessRequest(context); foreach (var f in filters) await f.OnPresenterExecutedAsync(context); } diff --git a/src/Framework/Framework/ResourceManagement/LocalResourceUrlManager.cs b/src/Framework/Framework/ResourceManagement/LocalResourceUrlManager.cs index 1790e23ed1..e968eb8e91 100644 --- a/src/Framework/Framework/ResourceManagement/LocalResourceUrlManager.cs +++ b/src/Framework/Framework/ResourceManagement/LocalResourceUrlManager.cs @@ -65,7 +65,7 @@ protected virtual string GetVersionHash(ILocalResourceLocation location, IDotvvm public ILocalResourceLocation? FindResource(string url, IDotvvmRequestContext context, out string? mimeType) { mimeType = null; - if (DotvvmRoutingMiddleware.FindMatchingRoute(new[] { resourceRoute }, context, out var parameters) == null) + if (DotvvmRoutingMiddleware.FindMatchingRoute(new[] { resourceRoute }, context, out var parameters, out _) == null) { return null; } diff --git a/src/Framework/Framework/Routing/CanonicalRedirectPartialMatchRouteHandler.cs b/src/Framework/Framework/Routing/CanonicalRedirectPartialMatchRouteHandler.cs new file mode 100644 index 0000000000..3921f75ad6 --- /dev/null +++ b/src/Framework/Framework/Routing/CanonicalRedirectPartialMatchRouteHandler.cs @@ -0,0 +1,18 @@ +using System.Threading.Tasks; +using DotVVM.Framework.Hosting; + +namespace DotVVM.Framework.Routing; + +public class CanonicalRedirectPartialMatchRouteHandler : IPartialMatchRouteHandler +{ + /// + /// Indicates whether a permanent redirect shall be used. + /// + public bool IsPermanentRedirect { get; set; } + + public Task TryHandlePartialMatch(IDotvvmRequestContext context) + { + context.RedirectToRoute(context.Route!.RouteName, context.Parameters); + return Task.FromResult(true); + } +} diff --git a/src/Framework/Framework/Routing/DotvvmRouteTable.cs b/src/Framework/Framework/Routing/DotvvmRouteTable.cs index aa0ccf65e7..8b477d6d0e 100644 --- a/src/Framework/Framework/Routing/DotvvmRouteTable.cs +++ b/src/Framework/Framework/Routing/DotvvmRouteTable.cs @@ -16,6 +16,7 @@ public sealed class DotvvmRouteTable : IEnumerable private readonly DotvvmConfiguration configuration; private readonly List> list = new List>(); + private List partialMatchHandlers = new List(); private readonly Dictionary dictionary = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -23,6 +24,9 @@ private readonly Dictionary routeTableGroups = new Dictionary(); private RouteTableGroup? group = null; + + public IReadOnlyList PartialMatchHandlers => partialMatchHandlers; + /// /// Initializes a new instance of the class. /// @@ -113,17 +117,8 @@ public void Add(string routeName, string? url, string virtualPath, object? defau { ThrowIfFrozen(); - url = CombinePath(group?.UrlPrefix, url); virtualPath = CombinePath(group?.VirtualPathPrefix, virtualPath); - presenterFactory ??= GetDefaultPresenter; - routeName = group?.RouteNamePrefix + routeName; - - RouteBase route = localizedUrls == null - ? new DotvvmRoute(url, virtualPath, defaultValues, presenterFactory, configuration) - : new LocalizedDotvvmRoute(url, - localizedUrls.Select(l => new LocalizedRouteUrl(l.CultureIdentifier, CombinePath(group?.UrlPrefix, l.RouteUrl))).ToArray(), - virtualPath, defaultValues, presenterFactory, configuration); - Add(routeName, route); + AddCore(routeName, url, virtualPath, defaultValues, presenterFactory, localizedUrls); } /// @@ -137,10 +132,15 @@ public void Add(string routeName, string? url, Func? presenterFactory, LocalizedRouteUrl[]? localizedUrls) + { url = CombinePath(group?.UrlPrefix, url); presenterFactory ??= GetDefaultPresenter; routeName = group?.RouteNamePrefix + routeName; - var virtualPath = group?.VirtualPathPrefix ?? ""; RouteBase route = localizedUrls == null ? new DotvvmRoute(url, virtualPath, defaultValues, presenterFactory, configuration) @@ -256,6 +256,12 @@ public void Add(string routeName, RouteBase route) dictionary.Add(routeName, route); } + public void AddPartialMatchHandler(IPartialMatchRouteHandler handler) + { + ThrowIfFrozen(); + partialMatchHandlers.Add(handler); + } + public bool Contains(string routeName) { return dictionary.ContainsKey(routeName); diff --git a/src/Framework/Framework/Routing/IPartialMatchRoute.cs b/src/Framework/Framework/Routing/IPartialMatchRoute.cs new file mode 100644 index 0000000000..d1d342f7e1 --- /dev/null +++ b/src/Framework/Framework/Routing/IPartialMatchRoute.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; + +namespace DotVVM.Framework.Routing; + +public interface IPartialMatchRoute +{ + bool IsPartialMatch(string url, [MaybeNullWhen(false)] out RouteBase matchedRoute, [MaybeNullWhen(false)] out IDictionary values); +} diff --git a/src/Framework/Framework/Routing/IPartialMatchRouteHandler.cs b/src/Framework/Framework/Routing/IPartialMatchRouteHandler.cs new file mode 100644 index 0000000000..2166a97923 --- /dev/null +++ b/src/Framework/Framework/Routing/IPartialMatchRouteHandler.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using DotVVM.Framework.Hosting; + +namespace DotVVM.Framework.Routing; + +public interface IPartialMatchRouteHandler +{ + /// + /// Handles the partial route match and returns whether the request was handled to prevent other handlers to take place. + /// + /// If true, the next partial match handlers will not be executed. + Task TryHandlePartialMatch(IDotvvmRequestContext context); +} diff --git a/src/Framework/Framework/Routing/LocalizedDotvvmRoute.cs b/src/Framework/Framework/Routing/LocalizedDotvvmRoute.cs index 00e4adfd9c..2a18088360 100644 --- a/src/Framework/Framework/Routing/LocalizedDotvvmRoute.cs +++ b/src/Framework/Framework/Routing/LocalizedDotvvmRoute.cs @@ -18,7 +18,7 @@ namespace DotVVM.Framework.Routing /// Please note that the extraction of the culture from the URL and setting the culture must be done at the beginning of the request pipeline. /// Therefore, the route only matches the URL for the current culture. /// - public sealed class LocalizedDotvvmRoute : RouteBase + public sealed class LocalizedDotvvmRoute : RouteBase, IPartialMatchRoute { private static readonly HashSet AvailableCultureNames = CultureInfo.GetCultures(CultureTypes.AllCultures) .Where(c => c != CultureInfo.InvariantCulture) @@ -34,6 +34,22 @@ public sealed class LocalizedDotvvmRoute : RouteBase /// public override IEnumerable ParameterNames => GetRouteForCulture(CultureInfo.CurrentUICulture).ParameterNames; + public override string RouteName + { + get + { + return base.RouteName; + } + internal set + { + base.RouteName = value; + foreach (var route in localizedRoutes) + { + route.Value.RouteName = value; + } + } + } + /// /// Initializes a new instance of the class. /// @@ -87,6 +103,46 @@ public static void ValidateCultureName(string cultureIdentifier) ///
public override bool IsMatch(string url, [MaybeNullWhen(false)] out IDictionary values) => GetRouteForCulture(CultureInfo.CurrentCulture).IsMatch(url, out values); + public bool IsPartialMatch(string url, [MaybeNullWhen(false)] out RouteBase matchedRoute, [MaybeNullWhen(false)] out IDictionary values) + { + RouteBase? twoLetterCultureMatch = null; + IDictionary twoLetterCultureMatchValues = null; + + foreach (var route in localizedRoutes) + { + if (route.Value.IsMatch(url, out values)) + { + if (route.Key.Length > 2) + { + // exact culture match - return immediately + matchedRoute = route.Value; + return true; + } + else if (route.Key.Length > 0 && twoLetterCultureMatch == null) + { + // match for two-letter culture - continue searching if there is a better match + twoLetterCultureMatch = route.Value; + twoLetterCultureMatchValues = values; + } + else + { + // ignore exact match - this was done using classic IsMatch + } + } + } + + if (twoLetterCultureMatch != null) + { + matchedRoute = twoLetterCultureMatch; + values = twoLetterCultureMatchValues!; + return true; + } + + matchedRoute = null; + values = null; + return false; + } + protected internal override string BuildUrlCore(Dictionary values) => GetRouteForCulture(CultureInfo.CurrentCulture).BuildUrlCore(values); protected override void Freeze2() diff --git a/src/Framework/Framework/Routing/RouteBase.cs b/src/Framework/Framework/Routing/RouteBase.cs index 9c17498015..76df5c0506 100644 --- a/src/Framework/Framework/Routing/RouteBase.cs +++ b/src/Framework/Framework/Routing/RouteBase.cs @@ -26,7 +26,7 @@ public abstract class RouteBase /// /// Gets key of route. /// - public string RouteName { get; internal set; } + public virtual string RouteName { get; internal set; } /// /// Gets the default values of the optional parameters. diff --git a/src/Samples/ApplicationInsights.Owin/Web.config b/src/Samples/ApplicationInsights.Owin/Web.config index 6063647d33..860a3c2c89 100644 --- a/src/Samples/ApplicationInsights.Owin/Web.config +++ b/src/Samples/ApplicationInsights.Owin/Web.config @@ -48,7 +48,7 @@ - + @@ -105,5 +105,17 @@ + + + + + + + + + + + + diff --git a/src/Samples/AspNetCore/Startup.cs b/src/Samples/AspNetCore/Startup.cs index b1273c033e..05adcc31a9 100644 --- a/src/Samples/AspNetCore/Startup.cs +++ b/src/Samples/AspNetCore/Startup.cs @@ -75,7 +75,8 @@ public void ConfigureServices(IServiceCollection services) .SetDefaultCulture(supportedCultures[0]) .AddSupportedCultures(supportedCultures) .AddSupportedUICultures(supportedCultures) - .AddInitialRequestCultureProvider(new PrefixRequestCultureProvider()); + .AddInitialRequestCultureProvider(new PrefixRequestCultureProvider()) + .AddInitialRequestCultureProvider(new QueryStringRequestCultureProvider() { UIQueryStringKey = "lang", QueryStringKey = "lang" }); ; }); } diff --git a/src/Samples/AspNetCoreLatest/Startup.cs b/src/Samples/AspNetCoreLatest/Startup.cs index 43107a6baa..d614f337ee 100644 --- a/src/Samples/AspNetCoreLatest/Startup.cs +++ b/src/Samples/AspNetCoreLatest/Startup.cs @@ -79,7 +79,8 @@ public void ConfigureServices(IServiceCollection services) .SetDefaultCulture(supportedCultures[0]) .AddSupportedCultures(supportedCultures) .AddSupportedUICultures(supportedCultures) - .AddInitialRequestCultureProvider(new PrefixRequestCultureProvider()); + .AddInitialRequestCultureProvider(new PrefixRequestCultureProvider()) + .AddInitialRequestCultureProvider(new QueryStringRequestCultureProvider() { UIQueryStringKey = "lang", QueryStringKey = "lang" }); }); } diff --git a/src/Samples/Common/DotvvmStartup.cs b/src/Samples/Common/DotvvmStartup.cs index bd3b873553..abc43b9ab0 100644 --- a/src/Samples/Common/DotvvmStartup.cs +++ b/src/Samples/Common/DotvvmStartup.cs @@ -240,6 +240,7 @@ private static void AddRoutes(DotvvmConfiguration config) new("cs-CZ", "cs/FeatureSamples/Localization/lokalizovana-routa"), new("de", "de/FeatureSamples/Localization/lokalisierte-route"), }); + config.RouteTable.AddPartialMatchHandler(new CanonicalRedirectPartialMatchRouteHandler()); config.RouteTable.AutoDiscoverRoutes(new DefaultRouteStrategy(config)); diff --git a/src/Samples/Owin/Startup.cs b/src/Samples/Owin/Startup.cs index bef3c95d16..214311bbf7 100644 --- a/src/Samples/Owin/Startup.cs +++ b/src/Samples/Owin/Startup.cs @@ -28,7 +28,17 @@ public class Startup public void Configuration(IAppBuilder app) { app.Use((context, next) => { - if (context.Request.Path.StartsWithSegments(new PathString("/cs"))) + if (context.Request.Query["lang"] == "cs") + { + CultureInfo.CurrentCulture = CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("cs-CZ"); + context.Set(HostingConstants.OwinDoNotSetRequestCulture, true); + } + else if (context.Request.Query["lang"] == "de") + { + CultureInfo.CurrentCulture = CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("de"); + context.Set(HostingConstants.OwinDoNotSetRequestCulture, true); + } + else if (context.Request.Path.StartsWithSegments(new PathString("/cs"))) { CultureInfo.CurrentCulture = CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("cs-CZ"); context.Set(HostingConstants.OwinDoNotSetRequestCulture, true); diff --git a/src/Samples/Tests/Tests/Feature/LocalizationTests.cs b/src/Samples/Tests/Tests/Feature/LocalizationTests.cs index 5751368528..e3b56c741b 100644 --- a/src/Samples/Tests/Tests/Feature/LocalizationTests.cs +++ b/src/Samples/Tests/Tests/Feature/LocalizationTests.cs @@ -176,6 +176,19 @@ public void Feature_Localization_LocalizableRoute() }); } + [Fact] + public void Feature_Localization_LocalizableRoute_PartialMatchHandlers() + { + RunInAllBrowsers(browser => { + browser.NavigateToUrl("/cs/FeatureSamples/Localization/lokalizovana-routa?lang=de"); + + var culture = browser.Single("span[data-ui=culture]"); + AssertUI.TextEquals(culture, "de"); + + AssertUI.Url(browser, p => p.EndsWith("/de/FeatureSamples/Localization/lokalisierte-route")); + }); + } + public LocalizationTests(ITestOutputHelper output) : base(output) { } diff --git a/src/Tests/Routing/DotvvmRouteTests.cs b/src/Tests/Routing/DotvvmRouteTests.cs index c11403f94e..338acc4aff 100644 --- a/src/Tests/Routing/DotvvmRouteTests.cs +++ b/src/Tests/Routing/DotvvmRouteTests.cs @@ -10,6 +10,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using System.Globalization; +using System.Threading; using DotVVM.Framework.Tests.Binding; namespace DotVVM.Framework.Tests.Routing @@ -262,6 +263,68 @@ public void DotvvmRoute_IsMatch_TwoParameters_OneOptional_Suffix() Assert.AreEqual(1, parameters.Count); } + [TestMethod] + public void LocalizedDotvvmRoute_IsMatch_ExactCultureMatch() + { + CultureUtils.RunWithCulture("cs-CZ", () => + { + var route = new LocalizedDotvvmRoute("cs-CZ", new [] { + new LocalizedRouteUrl("cs", "cs"), + new LocalizedRouteUrl("cs-CZ", "cs-CZ"), + new LocalizedRouteUrl("en", "en") + }, "", null, _ => null, configuration); + + var result = route.IsMatch("cs-CZ", out var parameters); + Assert.IsTrue(result); + }); + } + + [TestMethod] + public void LocalizedDotvvmRoute_IsMatch_TwoLetterCultureMatch() + { + CultureUtils.RunWithCulture("en-US", () => { + var route = new LocalizedDotvvmRoute("en", new[] { + new LocalizedRouteUrl("cs", "cs"), + new LocalizedRouteUrl("cs-CZ", "cs-CZ"), + new LocalizedRouteUrl("en", "en") + }, "", null, _ => null, configuration); + + var result = route.IsMatch("en", out var parameters); + Assert.IsTrue(result); + }); + } + + [TestMethod] + public void LocalizedDotvvmRoute_IsMatch_InvalidCultureMatch() + { + CultureUtils.RunWithCulture("en-US", () => { + var route = new LocalizedDotvvmRoute("", new[] { + new LocalizedRouteUrl("cs", "cs"), + new LocalizedRouteUrl("cs-CZ", "cs-CZ"), + new LocalizedRouteUrl("en", "en") + }, "", null, _ => null, configuration); + + var result = route.IsMatch("cs", out var parameters); + Assert.IsFalse(result); + }); + } + + [TestMethod] + public void LocalizedDotvvmRoute_IsPartialMatch() + { + CultureUtils.RunWithCulture("en-US", () => { + var route = new LocalizedDotvvmRoute("", new[] { + new LocalizedRouteUrl("cs", "cs"), + new LocalizedRouteUrl("cs-CZ", "cs-CZ"), + new LocalizedRouteUrl("en", "en") + }, "", null, _ => null, configuration); + + var result = route.IsPartialMatch("cs", out var matchedRoute, out var parameters); + Assert.IsTrue(result); + Assert.AreEqual("cs", matchedRoute.Url); + }); + } + [TestMethod] public void DotvvmRoute_BuildUrl_UrlTwoParameters() { From 3f6f134bfbcd350c260de6762ab7a18342645608 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Herceg?= Date: Fri, 12 Jul 2024 14:06:13 +0200 Subject: [PATCH 08/40] Fixed binding redirects --- .../ApplicationInsights.Owin/Web.config | 82 +++++++------------ 1 file changed, 30 insertions(+), 52 deletions(-) diff --git a/src/Samples/ApplicationInsights.Owin/Web.config b/src/Samples/ApplicationInsights.Owin/Web.config index 860a3c2c89..0e6e3656cd 100644 --- a/src/Samples/ApplicationInsights.Owin/Web.config +++ b/src/Samples/ApplicationInsights.Owin/Web.config @@ -31,72 +31,50 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + - + + + - + + + - + + + - + + + - - - - - - + + + + - - + + + + - - + + @@ -107,14 +85,14 @@ - - + + - - + + From de399fc031ea837e79a32b6ba0d219dcc4954534 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Herceg?= Date: Fri, 12 Jul 2024 17:19:02 +0200 Subject: [PATCH 09/40] Review comments resolved --- .../Framework/Hosting/HostingConstants.cs | 5 ++ .../Middlewares/DotvvmRoutingMiddleware.cs | 47 ++++++++++--------- .../LocalResourceUrlManager.cs | 4 +- .../Framework/Routing/DotvvmRoute.cs | 4 ++ .../Framework/Routing/DotvvmRouteParser.cs | 13 ++++- .../Framework/Routing/DotvvmRouteTable.cs | 19 ++++++-- .../Framework/Routing/LocalizedDotvvmRoute.cs | 15 +++++- src/Framework/Framework/Routing/RouteBase.cs | 5 ++ .../Routing/RouteTableJsonConverter.cs | 4 +- src/Tests/Routing/DotvvmRouteTests.cs | 15 ++++++ 10 files changed, 100 insertions(+), 31 deletions(-) diff --git a/src/Framework/Framework/Hosting/HostingConstants.cs b/src/Framework/Framework/Hosting/HostingConstants.cs index f2e4886d52..c30a77f526 100644 --- a/src/Framework/Framework/Hosting/HostingConstants.cs +++ b/src/Framework/Framework/Hosting/HostingConstants.cs @@ -29,6 +29,11 @@ public class HostingConstants public const string HostAppModeKey = "host.AppMode"; + /// + /// When this key is set to true in the OWIN environment, the request culture will not be set by DotVVM to config.DefaultCulture. + /// Use this key when the request culture is set by the host or the middleware preceding DotVVM. + /// See https://github.com/riganti/dotvvm/blob/93107dd07127ff2bd29c2934f3aa2a26ec2ca79c/src/Samples/Owin/Startup.cs#L34 + /// public const string OwinDoNotSetRequestCulture = "OwinDoNotSetRequestCulture"; } } diff --git a/src/Framework/Framework/Hosting/Middlewares/DotvvmRoutingMiddleware.cs b/src/Framework/Framework/Hosting/Middlewares/DotvvmRoutingMiddleware.cs index def06012d2..70f94a12d5 100644 --- a/src/Framework/Framework/Hosting/Middlewares/DotvvmRoutingMiddleware.cs +++ b/src/Framework/Framework/Hosting/Middlewares/DotvvmRoutingMiddleware.cs @@ -38,10 +38,9 @@ private static bool TryParseGooglebotHashbangEscapedFragment(string queryString, return false; } - public static RouteBase? FindMatchingRoute(IEnumerable routes, IDotvvmRequestContext context, out IDictionary? parameters, out bool isPartialMatch) + public static string GetRouteMatchUrl(IDotvvmRequestContext context) { - string? url; - if (!TryParseGooglebotHashbangEscapedFragment(context.HttpContext.Request.Url.Query, out url)) + if (!TryParseGooglebotHashbangEscapedFragment(context.HttpContext.Request.Url.Query, out var url)) { url = context.HttpContext.Request.Path.Value; } @@ -52,33 +51,40 @@ private static bool TryParseGooglebotHashbangEscapedFragment(string queryString, { url = url.Substring(HostingConstants.SpaUrlIdentifier.Length).Trim('/'); } + return url; + } - // find the route - RouteBase? partialMatch = null; - IDictionary? partialMatchParameters = null; - + internal static RouteBase? FindExactMatchRoute(IEnumerable routes, string matchUrl, out IDictionary? parameters) + { foreach (var r in routes) { - if (r.IsMatch(url, out parameters)) + if (r.IsMatch(matchUrl, out parameters)) { - isPartialMatch = false; return r; } + } + parameters = null; + return null; + } - if (partialMatch == null - && r is IPartialMatchRoute partialMatchRoute - && partialMatchRoute.IsPartialMatch(url, out var partialMatchResult, out var partialMatchParametersResult)) - { - partialMatch = partialMatchResult; - partialMatchParameters = partialMatchParametersResult; - } + public static RouteBase? FindMatchingRoute(DotvvmRouteTable routes, IDotvvmRequestContext context, out IDictionary? parameters, out bool isPartialMatch) + { + var url = GetRouteMatchUrl(context); + + var route = FindExactMatchRoute(routes, url, out parameters); + if (route is { }) + { + isPartialMatch = false; + return route; } - if (partialMatch != null) + foreach (var r in routes.PartialMatchRoutes) { - isPartialMatch = true; - parameters = partialMatchParameters; - return partialMatch; + if (r.IsPartialMatch(url, out var matchedRoute, out parameters)) + { + isPartialMatch = true; + return matchedRoute; + } } isPartialMatch = false; @@ -86,7 +92,6 @@ private static bool TryParseGooglebotHashbangEscapedFragment(string queryString, return null; } - public async Task Handle(IDotvvmRequestContext context) { var requestTracer = context.Services.GetRequiredService(); diff --git a/src/Framework/Framework/ResourceManagement/LocalResourceUrlManager.cs b/src/Framework/Framework/ResourceManagement/LocalResourceUrlManager.cs index e968eb8e91..c740bbfa96 100644 --- a/src/Framework/Framework/ResourceManagement/LocalResourceUrlManager.cs +++ b/src/Framework/Framework/ResourceManagement/LocalResourceUrlManager.cs @@ -65,7 +65,9 @@ protected virtual string GetVersionHash(ILocalResourceLocation location, IDotvvm public ILocalResourceLocation? FindResource(string url, IDotvvmRequestContext context, out string? mimeType) { mimeType = null; - if (DotvvmRoutingMiddleware.FindMatchingRoute(new[] { resourceRoute }, context, out var parameters, out _) == null) + + var routeMatchUrl = DotvvmRoutingMiddleware.GetRouteMatchUrl(context); + if (DotvvmRoutingMiddleware.FindExactMatchRoute(new[] { resourceRoute }, routeMatchUrl, out var parameters) == null) { return null; } diff --git a/src/Framework/Framework/Routing/DotvvmRoute.cs b/src/Framework/Framework/Routing/DotvvmRoute.cs index 993929b848..fc5102c930 100644 --- a/src/Framework/Framework/Routing/DotvvmRoute.cs +++ b/src/Framework/Framework/Routing/DotvvmRoute.cs @@ -20,12 +20,15 @@ public sealed class DotvvmRoute : RouteBase private List, string>> urlBuilders; private List?>> parameters; private string urlWithoutTypes; + private List> parameterMetadata; /// /// Gets the names of the route parameters in the order in which they appear in the URL. /// public override IEnumerable ParameterNames => parameters.Select(p => p.Key); + public override IEnumerable> ParameterMetadata => parameterMetadata; + public override string UrlWithoutTypes => urlWithoutTypes; @@ -77,6 +80,7 @@ private void ParseRouteUrl(DotvvmConfiguration configuration) routeRegex = result.RouteRegex; urlBuilders = result.UrlBuilders; parameters = result.Parameters; + parameterMetadata = result.ParameterMetadata; urlWithoutTypes = result.UrlWithoutTypes; } diff --git a/src/Framework/Framework/Routing/DotvvmRouteParser.cs b/src/Framework/Framework/Routing/DotvvmRouteParser.cs index 3795668dc9..123ae0caa3 100644 --- a/src/Framework/Framework/Routing/DotvvmRouteParser.cs +++ b/src/Framework/Framework/Routing/DotvvmRouteParser.cs @@ -25,6 +25,7 @@ public UrlParserResult ParseRouteUrl(string url, IDictionary de var regex = new StringBuilder("^"); var parameters = new List?>>(); + var parameterMetadata = new List>(); var urlBuilders = new List, string>>(); urlBuilders.Add(_ => "~"); @@ -32,6 +33,7 @@ void AppendParameterParserResult(UrlParameterParserResult result) { regex.Append(result.ParameterRegexPart); parameters.Add(result.Parameter); + parameterMetadata.Add(new KeyValuePair(result.Parameter.Key, result.Metadata)); urlBuilders.Add(result.UrlBuilder); } @@ -78,6 +80,7 @@ void AppendParameterParserResult(UrlParameterParserResult result) RouteRegex = new Regex(regex.ToString(), RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), UrlBuilders = urlBuilders, Parameters = parameters, + ParameterMetadata = parameterMetadata, UrlWithoutTypes = string.Concat(urlBuilders.Skip(1).Select(b => b(fakeParameters))).TrimStart('/') }; } @@ -109,6 +112,7 @@ private UrlParameterParserResult ParseParameter(string url, string prefix, ref i // determine route parameter constraint IRouteParameterConstraint? type = null; string? parameter = null; + string? typeName = null; if (url[index] == ':') { startIndex = index + 1; @@ -118,7 +122,7 @@ private UrlParameterParserResult ParseParameter(string url, string prefix, ref i throw new ArgumentException($"The route URL '{url}' is not valid! It contains an unclosed parameter."); } - var typeName = url.Substring(startIndex, index - startIndex); + typeName = url.Substring(startIndex, index - startIndex); if (!routeConstraints.ContainsKey(typeName)) { throw new ArgumentException($"The route parameter constraint '{typeName}' is not valid!"); @@ -181,7 +185,8 @@ private UrlParameterParserResult ParseParameter(string url, string prefix, ref i { ParameterRegexPart = result, UrlBuilder = urlBuilder, - Parameter = parameterParser + Parameter = parameterParser, + Metadata = new DotvvmRouteParameterMetadata(isOptional, parameter != null ? $"{typeName}({parameter})" : typeName) }; } @@ -190,14 +195,18 @@ private struct UrlParameterParserResult public string ParameterRegexPart { get; set; } public Func, string> UrlBuilder { get; set; } public KeyValuePair?> Parameter { get; set; } + public DotvvmRouteParameterMetadata Metadata { get; set; } } } + public record DotvvmRouteParameterMetadata(bool IsOptional, string? ConstraintName); + public struct UrlParserResult { public Regex RouteRegex { get; set; } public List, string>> UrlBuilders { get; set; } public List?>> Parameters { get; set; } public string UrlWithoutTypes { get; set; } + public List> ParameterMetadata { get; set; } } } diff --git a/src/Framework/Framework/Routing/DotvvmRouteTable.cs b/src/Framework/Framework/Routing/DotvvmRouteTable.cs index 8b477d6d0e..fd0bd12417 100644 --- a/src/Framework/Framework/Routing/DotvvmRouteTable.cs +++ b/src/Framework/Framework/Routing/DotvvmRouteTable.cs @@ -1,6 +1,7 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using DotVVM.Framework.Configuration; using DotVVM.Framework.Hosting; @@ -14,19 +15,22 @@ namespace DotVVM.Framework.Routing public sealed class DotvvmRouteTable : IEnumerable { private readonly DotvvmConfiguration configuration; - private readonly List> list - = new List>(); - private List partialMatchHandlers = new List(); + private readonly List> list = new(); + private readonly List partialMatchHandlers = new(); + private readonly List partialMatchRoutes = new(); private readonly Dictionary dictionary = new Dictionary(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary routeTableGroups = new Dictionary(); - private RouteTableGroup? group = null; + private RouteTableGroup? group = null; public IReadOnlyList PartialMatchHandlers => partialMatchHandlers; + internal IEnumerable PartialMatchRoutes => partialMatchRoutes; + /// /// Initializes a new instance of the class. /// @@ -254,6 +258,11 @@ public void Add(string routeName, RouteBase route) // The list is used for finding the routes because it keeps the ordering, the dictionary is for checking duplicates list.Add(new KeyValuePair(routeName, route)); dictionary.Add(routeName, route); + + if (route is IPartialMatchRoute partialMatchRoute) + { + partialMatchRoutes.Add(partialMatchRoute); + } } public void AddPartialMatchHandler(IPartialMatchRouteHandler handler) @@ -267,7 +276,7 @@ public bool Contains(string routeName) return dictionary.ContainsKey(routeName); } - public bool TryGetValue(string routeName, out RouteBase? route) + public bool TryGetValue(string routeName, [MaybeNullWhen(false)] out RouteBase route) { return dictionary.TryGetValue(routeName, out route); } diff --git a/src/Framework/Framework/Routing/LocalizedDotvvmRoute.cs b/src/Framework/Framework/Routing/LocalizedDotvvmRoute.cs index 2a18088360..2c7e5dd809 100644 --- a/src/Framework/Framework/Routing/LocalizedDotvvmRoute.cs +++ b/src/Framework/Framework/Routing/LocalizedDotvvmRoute.cs @@ -34,6 +34,8 @@ public sealed class LocalizedDotvvmRoute : RouteBase, IPartialMatchRoute /// public override IEnumerable ParameterNames => GetRouteForCulture(CultureInfo.CurrentUICulture).ParameterNames; + public override IEnumerable> ParameterMetadata => GetRouteForCulture(CultureInfo.CurrentUICulture).ParameterMetadata; + public override string RouteName { get @@ -61,13 +63,24 @@ public LocalizedDotvvmRoute(string defaultLanguageUrl, LocalizedRouteUrl[] local throw new ArgumentException("There must be at least one localized route URL!", nameof(localizedUrls)); } + var defaultRoute = new DotvvmRoute(defaultLanguageUrl, virtualPath, defaultValues, presenterFactory, configuration); + + var sortedParameters = defaultRoute.ParameterMetadata + .OrderBy(n => n.Key) + .ToArray(); + foreach (var localizedUrl in localizedUrls) { var localizedRoute = new DotvvmRoute(localizedUrl.RouteUrl, virtualPath, defaultValues, presenterFactory, configuration); + if (!localizedRoute.ParameterMetadata.OrderBy(n => n.Key) + .SequenceEqual(sortedParameters)) + { + throw new ArgumentException($"Localized route URL '{localizedUrl.RouteUrl}' must contain the same parameters with equal constraints as the default route URL!", nameof(localizedUrls)); + } + localizedRoutes.Add(localizedUrl.CultureIdentifier, localizedRoute); } - var defaultRoute = new DotvvmRoute(defaultLanguageUrl, virtualPath, defaultValues, presenterFactory, configuration); localizedRoutes.Add("", defaultRoute); } diff --git a/src/Framework/Framework/Routing/RouteBase.cs b/src/Framework/Framework/Routing/RouteBase.cs index 76df5c0506..84ddcc3d34 100644 --- a/src/Framework/Framework/Routing/RouteBase.cs +++ b/src/Framework/Framework/Routing/RouteBase.cs @@ -89,6 +89,11 @@ public RouteBase(string url, string virtualPath, IDictionary? d ///
public abstract IEnumerable ParameterNames { get; } + /// + /// Gets the metadata of the route parameters. + /// + public abstract IEnumerable> ParameterMetadata { get; } + /// /// Determines whether the route matches to the specified URL and extracts the parameter values. /// diff --git a/src/Framework/Framework/Routing/RouteTableJsonConverter.cs b/src/Framework/Framework/Routing/RouteTableJsonConverter.cs index c1fa741c9c..ba7c799227 100644 --- a/src/Framework/Framework/Routing/RouteTableJsonConverter.cs +++ b/src/Framework/Framework/Routing/RouteTableJsonConverter.cs @@ -67,7 +67,9 @@ public ErrorRoute(string? url, string? virtualPath, string? name, IDictionary ParameterNames => new string[0]; + public override IEnumerable ParameterNames { get; } = new string[0]; + + public override IEnumerable> ParameterMetadata { get; } = new KeyValuePair[0]; public override string UrlWithoutTypes => base.Url; diff --git a/src/Tests/Routing/DotvvmRouteTests.cs b/src/Tests/Routing/DotvvmRouteTests.cs index 338acc4aff..a6d7e6847a 100644 --- a/src/Tests/Routing/DotvvmRouteTests.cs +++ b/src/Tests/Routing/DotvvmRouteTests.cs @@ -325,6 +325,21 @@ public void LocalizedDotvvmRoute_IsPartialMatch() }); } + [DataTestMethod] + [DataRow("product/{id?}/{name:maxLength(5)}", "en/products/{id?}/{name:maxLength(10)}")] + [DataRow("product/{id?}/{name:maxLength(5)}", "en/products/{id?}/{name}")] + [DataRow("product/{id?}/{name:maxLength(5)}", "en/products/{Id:int?}/{name}")] + [DataRow("product/{id?}/{name:maxLength(5)}", "en/products/{abc}")] + [DataRow("product/{id?}/{name:maxLength(5)}", "en/products/{Id?}/{name:maxLength(5)}")] + public void LocalizedDotvvmRoute_RouteConstraintChecks(string defaultRoute, string localizedRoute) + { + Assert.ThrowsException(() => { + var route = new LocalizedDotvvmRoute(defaultRoute, new[] { + new LocalizedRouteUrl("en", localizedRoute) + }, "", null, _ => null, configuration); + }); + } + [TestMethod] public void DotvvmRoute_BuildUrl_UrlTwoParameters() { From d304c1e04aeed3b4a5f9e613d9a87a2c7bb7f4e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Herceg?= Date: Sun, 14 Jul 2024 09:49:45 +0200 Subject: [PATCH 10/40] Fixed issues in tests --- src/Framework/Framework/Routing/LocalizedDotvvmRoute.cs | 2 +- src/Samples/Common/DotvvmStartup.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Framework/Framework/Routing/LocalizedDotvvmRoute.cs b/src/Framework/Framework/Routing/LocalizedDotvvmRoute.cs index 2c7e5dd809..5c82ae6c78 100644 --- a/src/Framework/Framework/Routing/LocalizedDotvvmRoute.cs +++ b/src/Framework/Framework/Routing/LocalizedDotvvmRoute.cs @@ -119,7 +119,7 @@ public static void ValidateCultureName(string cultureIdentifier) public bool IsPartialMatch(string url, [MaybeNullWhen(false)] out RouteBase matchedRoute, [MaybeNullWhen(false)] out IDictionary values) { RouteBase? twoLetterCultureMatch = null; - IDictionary twoLetterCultureMatchValues = null; + IDictionary? twoLetterCultureMatchValues = null; foreach (var route in localizedRoutes) { diff --git a/src/Samples/Common/DotvvmStartup.cs b/src/Samples/Common/DotvvmStartup.cs index abc43b9ab0..49884aecb4 100644 --- a/src/Samples/Common/DotvvmStartup.cs +++ b/src/Samples/Common/DotvvmStartup.cs @@ -235,7 +235,7 @@ private static void AddRoutes(DotvvmConfiguration config) config.RouteTable.Add("FeatureSamples_Localization_Globalize", "FeatureSamples/Localization/Globalize", "Views/FeatureSamples/Localization/Globalize.dothtml", presenterFactory: LocalizablePresenter.BasedOnQuery("lang")); config.RouteTable.Add("FeatureSamples_CustomPrimitiveTypes_Basic", "FeatureSamples/CustomPrimitiveTypes/Basic/{Id?}", "Views/FeatureSamples/CustomPrimitiveTypes/Basic.dothtml"); - config.RouteTable.Add("FeatureSamples_Localization_LocalizableRoute", "FeatureSamples/Localization/LocalizableRoute/{lang?}", "Views/FeatureSamples/Localization/LocalizableRoute.dothtml", + config.RouteTable.Add("FeatureSamples_Localization_LocalizableRoute", "FeatureSamples/Localization/LocalizableRoute", "Views/FeatureSamples/Localization/LocalizableRoute.dothtml", localizedUrls: new LocalizedRouteUrl[] { new("cs-CZ", "cs/FeatureSamples/Localization/lokalizovana-routa"), new("de", "de/FeatureSamples/Localization/lokalisierte-route"), From 390bb3231d1080064d72aeb639b4ce15d7ae7358 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Herceg?= Date: Sun, 14 Jul 2024 09:57:43 +0200 Subject: [PATCH 11/40] Removed .NET 3.1 from GitHub Actions --- .github/setup/action.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/setup/action.yml b/.github/setup/action.yml index 8b786bb560..360f10360f 100644 --- a/.github/setup/action.yml +++ b/.github/setup/action.yml @@ -37,7 +37,6 @@ runs: dotnet-version: | 8.0.x 6.0.x - 3.1.x - if: ${{ runner.os == 'Windows' }} uses: microsoft/setup-msbuild@v1.1 From f4f2060f716126c4e82c03b101c3d83741f3eae6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Standa=20Luke=C5=A1?= Date: Sat, 17 Aug 2024 23:24:21 +0200 Subject: [PATCH 12/40] enable github actions on main-* branches --- .github/workflows/main.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 054358ef42..65304b74ee 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -4,6 +4,7 @@ on: push: branches: - 'main' + - 'main-*' - 'release/**' pull_request: workflow_dispatch: From 6eaa46d3ebfb3c9d65fc3c4e8b1b103b02e9c239 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Herceg?= Date: Thu, 1 Aug 2024 18:11:15 +0200 Subject: [PATCH 13/40] AlternateCultureLinks control added --- .../Controls/AlternateCultureLinks.cs | 50 +++++++++++++++++++ .../Framework/Routing/LocalizedDotvvmRoute.cs | 2 + .../Localization/LocalizableRoute.dothtml | 2 + .../Tests/Tests/Feature/LocalizationTests.cs | 13 +++++ 4 files changed, 67 insertions(+) create mode 100644 src/Framework/Framework/Controls/AlternateCultureLinks.cs diff --git a/src/Framework/Framework/Controls/AlternateCultureLinks.cs b/src/Framework/Framework/Controls/AlternateCultureLinks.cs new file mode 100644 index 0000000000..aad5f2b79b --- /dev/null +++ b/src/Framework/Framework/Controls/AlternateCultureLinks.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using DotVVM.Framework.Configuration; +using DotVVM.Framework.Hosting; +using DotVVM.Framework.Routing; + +namespace DotVVM.Framework.Controls +{ + public class AlternateCultureLinks : CompositeControl + { + private readonly IDotvvmRequestContext context; + + public AlternateCultureLinks(IDotvvmRequestContext context) + { + this.context = context; + } + + public IEnumerable GetContents(string? routeName = null) + { + var route = routeName != null ? context.Configuration.RouteTable[routeName] : context.Route; + if (route is LocalizedDotvvmRoute localizedRoute) + { + var currentCultureRoute = localizedRoute.GetRouteForCulture(CultureInfo.CurrentUICulture); + + foreach (var alternateCultureRoute in localizedRoute.GetAllCultureRoutes()) + { + if (alternateCultureRoute.Value == currentCultureRoute) continue; + + var languageCode = alternateCultureRoute.Key == "" ? "x-default" : alternateCultureRoute.Key.ToLowerInvariant(); + var alternateUrl = context.TranslateVirtualPath(alternateCultureRoute.Value.BuildUrl(context.Parameters)); + var absoluteAlternateUrl = BuildAbsoluteAlternateUrl(alternateUrl); + + yield return new HtmlGenericControl("link") + .SetAttribute("rel", "alternate") + .SetAttribute("hreflang", languageCode) + .SetAttribute("href", absoluteAlternateUrl); + } + + } + } + + protected virtual string BuildAbsoluteAlternateUrl(string alternateUrl) + { + return new Uri(context.HttpContext.Request.Url, alternateUrl).AbsoluteUri; + } + } +} diff --git a/src/Framework/Framework/Routing/LocalizedDotvvmRoute.cs b/src/Framework/Framework/Routing/LocalizedDotvvmRoute.cs index 5c82ae6c78..be29042772 100644 --- a/src/Framework/Framework/Routing/LocalizedDotvvmRoute.cs +++ b/src/Framework/Framework/Routing/LocalizedDotvvmRoute.cs @@ -98,6 +98,8 @@ public DotvvmRoute GetRouteForCulture(CultureInfo culture) : throw new NotSupportedException("Invalid localized route - no default route found!"); } + public IReadOnlyDictionary GetAllCultureRoutes() => localizedRoutes; + public static void ValidateCultureName(string cultureIdentifier) { if (!AvailableCultureNames.Contains(cultureIdentifier)) diff --git a/src/Samples/Common/Views/FeatureSamples/Localization/LocalizableRoute.dothtml b/src/Samples/Common/Views/FeatureSamples/Localization/LocalizableRoute.dothtml index 23394a4f4c..cce5c3578b 100644 --- a/src/Samples/Common/Views/FeatureSamples/Localization/LocalizableRoute.dothtml +++ b/src/Samples/Common/Views/FeatureSamples/Localization/LocalizableRoute.dothtml @@ -6,6 +6,8 @@ + + diff --git a/src/Samples/Tests/Tests/Feature/LocalizationTests.cs b/src/Samples/Tests/Tests/Feature/LocalizationTests.cs index e3b56c741b..5d26fb5071 100644 --- a/src/Samples/Tests/Tests/Feature/LocalizationTests.cs +++ b/src/Samples/Tests/Tests/Feature/LocalizationTests.cs @@ -146,6 +146,8 @@ public void Feature_Localization_LocalizableRoute() AssertUI.Attribute(links[1], "href", v => v.EndsWith("/de/FeatureSamples/Localization/lokalisierte-route")); AssertUI.Attribute(links[2], "href", v => v.EndsWith("/FeatureSamples/Localization/LocalizableRoute")); AssertUI.Attribute(links[3], "href", links[2].GetAttribute("href")); + AssertAlternateLink("cs-cz", "/cs/FeatureSamples/Localization/lokalizovana-routa"); + AssertAlternateLink("de", "/de/FeatureSamples/Localization/lokalisierte-route"); links[0].Click().Wait(500); culture = browser.Single("span[data-ui=culture]"); @@ -155,6 +157,8 @@ public void Feature_Localization_LocalizableRoute() AssertUI.Attribute(links[1], "href", v => v.EndsWith("/de/FeatureSamples/Localization/lokalisierte-route")); AssertUI.Attribute(links[2], "href", v => v.EndsWith("/FeatureSamples/Localization/LocalizableRoute")); AssertUI.Attribute(links[3], "href", links[0].GetAttribute("href")); + AssertAlternateLink("x-default", "/FeatureSamples/Localization/LocalizableRoute"); + AssertAlternateLink("de", "/de/FeatureSamples/Localization/lokalisierte-route"); links[1].Click().Wait(500); culture = browser.Single("span[data-ui=culture]"); @@ -164,6 +168,8 @@ public void Feature_Localization_LocalizableRoute() AssertUI.Attribute(links[1], "href", v => v.EndsWith("/de/FeatureSamples/Localization/lokalisierte-route")); AssertUI.Attribute(links[2], "href", v => v.EndsWith("/FeatureSamples/Localization/LocalizableRoute")); AssertUI.Attribute(links[3], "href", links[1].GetAttribute("href")); + AssertAlternateLink("x-default", "/FeatureSamples/Localization/LocalizableRoute"); + AssertAlternateLink("cs-cz", "/cs/FeatureSamples/Localization/lokalizovana-routa"); links[2].Click().Wait(500); culture = browser.Single("span[data-ui=culture]"); @@ -173,6 +179,13 @@ public void Feature_Localization_LocalizableRoute() AssertUI.Attribute(links[1], "href", v => v.EndsWith("/de/FeatureSamples/Localization/lokalisierte-route")); AssertUI.Attribute(links[2], "href", v => v.EndsWith("/FeatureSamples/Localization/LocalizableRoute")); AssertUI.Attribute(links[3], "href", links[2].GetAttribute("href")); + AssertAlternateLink("cs-cz", "/cs/FeatureSamples/Localization/lokalizovana-routa"); + AssertAlternateLink("de", "/de/FeatureSamples/Localization/lokalisierte-route"); + + void AssertAlternateLink(string culture, string url) + { + AssertUI.Attribute(browser.Single($"link[rel=alternate][hreflang={culture}]"), "href", this.TestSuiteRunner.Configuration.BaseUrls[0].TrimEnd('/') + url); + } }); } From 9b9669113620ca8cb0e1c493392825f46f629761 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Herceg?= Date: Thu, 1 Aug 2024 18:55:55 +0200 Subject: [PATCH 14/40] Fixed build warnings and test --- .../Framework/Controls/AlternateCultureLinks.cs | 2 +- ...ionSerializationTests.SerializeDefaultConfig.json | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/Framework/Framework/Controls/AlternateCultureLinks.cs b/src/Framework/Framework/Controls/AlternateCultureLinks.cs index aad5f2b79b..5e5048fc55 100644 --- a/src/Framework/Framework/Controls/AlternateCultureLinks.cs +++ b/src/Framework/Framework/Controls/AlternateCultureLinks.cs @@ -30,7 +30,7 @@ public IEnumerable GetContents(string? routeName = null) if (alternateCultureRoute.Value == currentCultureRoute) continue; var languageCode = alternateCultureRoute.Key == "" ? "x-default" : alternateCultureRoute.Key.ToLowerInvariant(); - var alternateUrl = context.TranslateVirtualPath(alternateCultureRoute.Value.BuildUrl(context.Parameters)); + var alternateUrl = context.TranslateVirtualPath(alternateCultureRoute.Value.BuildUrl(context.Parameters!)); var absoluteAlternateUrl = BuildAbsoluteAlternateUrl(alternateUrl); yield return new HtmlGenericControl("link") diff --git a/src/Tests/Runtime/config-tests/ConfigurationSerializationTests.SerializeDefaultConfig.json b/src/Tests/Runtime/config-tests/ConfigurationSerializationTests.SerializeDefaultConfig.json index a1ad38e131..54bdcdc371 100644 --- a/src/Tests/Runtime/config-tests/ConfigurationSerializationTests.SerializeDefaultConfig.json +++ b/src/Tests/Runtime/config-tests/ConfigurationSerializationTests.SerializeDefaultConfig.json @@ -360,6 +360,12 @@ "mappingMode": "InnerElement" } }, + "DotVVM.Framework.Controls.AlternateCultureLinks": { + "RouteName": { + "type": "System.String", + "onlyHardcoded": true + } + }, "DotVVM.Framework.Controls.AuthenticatedView": { "AuthenticatedTemplate": { "type": "DotVVM.Framework.Controls.ITemplate, DotVVM.Framework", @@ -2005,6 +2011,12 @@ "baseType": "DotVVM.Framework.Controls.Decorator, DotVVM.Framework", "withoutContent": true }, + "DotVVM.Framework.Controls.AlternateCultureLinks": { + "assembly": "DotVVM.Framework", + "baseType": "DotVVM.Framework.Controls.CompositeControl, DotVVM.Framework", + "withoutContent": true, + "isComposite": true + }, "DotVVM.Framework.Controls.AuthenticatedView": { "assembly": "DotVVM.Framework", "baseType": "DotVVM.Framework.Controls.ConfigurableHtmlControl, DotVVM.Framework", From 491d24681487da49244e328194949906493542b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Standa=20Luke=C5=A1?= Date: Mon, 12 Aug 2024 15:29:42 +0200 Subject: [PATCH 15/40] view compiler: avoid blocking the ThreadPool in the AfterApplicationStart mode * in all cases, we now limit the paralelism using a semaphore instead of pushing 500 tasks onto the ThreadPool * in AfterApplicationStart mode, paralelism is limited to cpu_count/2 --- .../DotvvmViewCompilationService.cs | 38 +++++++++++++++---- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/src/Framework/Framework/Compilation/DotvvmViewCompilationService.cs b/src/Framework/Framework/Compilation/DotvvmViewCompilationService.cs index 072186838f..f70979677c 100644 --- a/src/Framework/Framework/Compilation/DotvvmViewCompilationService.cs +++ b/src/Framework/Framework/Compilation/DotvvmViewCompilationService.cs @@ -11,6 +11,7 @@ using DotVVM.Framework.Utils; using DotVVM.Framework.Binding.Properties; using DotVVM.Framework.Testing; +using Microsoft.Extensions.Logging; namespace DotVVM.Framework.Compilation { @@ -19,14 +20,16 @@ public class DotvvmViewCompilationService : IDotvvmViewCompilationService private readonly IControlBuilderFactory controlBuilderFactory; private readonly CompilationTracer tracer; private readonly IMarkupFileLoader markupFileLoader; + private readonly ILogger? log; private readonly DotvvmConfiguration dotvvmConfiguration; - public DotvvmViewCompilationService(DotvvmConfiguration dotvvmConfiguration, IControlBuilderFactory controlBuilderFactory, CompilationTracer tracer, IMarkupFileLoader markupFileLoader) + public DotvvmViewCompilationService(DotvvmConfiguration dotvvmConfiguration, IControlBuilderFactory controlBuilderFactory, CompilationTracer tracer, IMarkupFileLoader markupFileLoader, ILogger? log = null) { this.dotvvmConfiguration = dotvvmConfiguration; this.controlBuilderFactory = controlBuilderFactory; this.tracer = tracer; this.markupFileLoader = markupFileLoader; + this.log = log; masterPages = new Lazy>(InitMasterPagesCollection); controls = new Lazy>(InitControls); routes = new Lazy>(InitRoutes); @@ -108,7 +111,13 @@ public async Task CompileAll(bool buildInParallel = true, bool forceRecomp } } var discoveredMasterPages = new ConcurrentDictionary(); - + var maxParallelism = buildInParallel ? Environment.ProcessorCount : 1; + if (!dotvvmConfiguration.Debug && dotvvmConfiguration.Markup.ViewCompilation.Mode != ViewCompilationMode.DuringApplicationStart) + { + // in production when compiling after application start, only use half of the CPUs to leave room for handling requests + maxParallelism = (int)Math.Ceiling(maxParallelism * 0.5); + } + var sw = ValueStopwatch.StartNew(); var compilationTaskFactory = (DotHtmlFileInfo t) => () => { BuildView(t, forceRecompile, out var masterPage); @@ -117,15 +126,19 @@ public async Task CompileAll(bool buildInParallel = true, bool forceRecomp }; var compileTasks = filesToCompile.Select(compilationTaskFactory).ToArray(); - await ExecuteCompileTasks(compileTasks, buildInParallel); + var totalCompiledFiles = compileTasks.Length; + await ExecuteCompileTasks(compileTasks, maxParallelism); while (discoveredMasterPages.Any()) { compileTasks = discoveredMasterPages.Values.Select(compilationTaskFactory).ToArray(); + totalCompiledFiles += compileTasks.Length; discoveredMasterPages = new ConcurrentDictionary(); - await ExecuteCompileTasks(compileTasks, buildInParallel); + await ExecuteCompileTasks(compileTasks, maxParallelism); } + + log.LogInformation("Compiled {0} DotHTML files on {1} threads in {2} s", totalCompiledFiles, maxParallelism, sw.ElapsedSeconds); } finally { @@ -135,11 +148,22 @@ public async Task CompileAll(bool buildInParallel = true, bool forceRecomp return !GetFilesWithFailedCompilation().Any(); } - private async Task ExecuteCompileTasks(Action[] compileTasks, bool buildInParallel) + private static async Task ExecuteCompileTasks(Action[] compileTasks, int maxParallelism) { - if (buildInParallel) + if (maxParallelism > 1) { - await Task.WhenAll(compileTasks.Select(Task.Run)); + var semaphore = new SemaphoreSlim(maxParallelism); + await Task.WhenAll(compileTasks.Select(async t => { + await semaphore.WaitAsync(); + try + { + await Task.Run(t); + } + finally + { + semaphore.Release(); + } + })); } else { From a8c784d9fbe2cd12170e6c739998d7d5337674d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Standa=20Luke=C5=A1?= Date: Mon, 12 Aug 2024 15:54:11 +0200 Subject: [PATCH 16/40] Add some missing doccomments --- src/Framework/Core/Storage/UploadedFile.cs | 5 +++++ src/Framework/Framework/Controls/FormControls.cs | 7 ++++++- src/Framework/Framework/Controls/GridViewCheckBoxColumn.cs | 1 + src/Framework/Framework/Controls/GridViewTextColumn.cs | 1 + .../Framework/Controls/UploadedFilesCollection.cs | 5 +++++ src/Framework/Framework/Controls/Validation.cs | 5 +++++ src/Framework/Framework/Controls/ValidatorPlacement.cs | 3 +++ 7 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/Framework/Core/Storage/UploadedFile.cs b/src/Framework/Core/Storage/UploadedFile.cs index 962dfc4e40..cdf14e02be 100644 --- a/src/Framework/Core/Storage/UploadedFile.cs +++ b/src/Framework/Core/Storage/UploadedFile.cs @@ -4,16 +4,21 @@ namespace DotVVM.Core.Storage { public class UploadedFile { + /// A unique, randomly generate ID of the uploaded file. Use this ID to get the file from public Guid FileId { get; set; } + /// A user-specified name of the file. Use with caution, the user may specify this to be any string (for example ../../Web.config) public string? FileName { get; set; } public FileSize FileSize { get; set; } = new FileSize(); + /// If the file type matched one of type MIME types or extensions in FileUpload.AllowedFileTypes. Use with caution, the user may manipulate with this property. public bool IsFileTypeAllowed { get; set; } = true; + /// If the file size is larger that the limit specified in FileUpload.MaxFileSize. Use with caution, the user may manipulate with this property. public bool IsMaxSizeExceeded { get; set; } = false; + /// If the file satisfies both allowed file types and the size limit. Use with caution, the user may manipulate with this property. public bool IsAllowed => IsFileTypeAllowed && !IsMaxSizeExceeded; } diff --git a/src/Framework/Framework/Controls/FormControls.cs b/src/Framework/Framework/Controls/FormControls.cs index d750b72cd0..3ca7f019a8 100644 --- a/src/Framework/Framework/Controls/FormControls.cs +++ b/src/Framework/Framework/Controls/FormControls.cs @@ -8,8 +8,13 @@ namespace DotVVM.Framework.Controls [ContainsDotvvmProperties] public sealed class FormControls { + /// Enables or disables all child form controls (Buttons, TextBoxes, ...) [AttachedProperty(typeof(bool))] - public static DotvvmProperty EnabledProperty = DotvvmProperty.Register(() => EnabledProperty, true, true); + public static DotvvmProperty EnabledProperty = DotvvmProperty.Register(() => EnabledProperty, true, isValueInherited: true); + + /// Sets `form` attribute on all child input controls (Buttons, TextBoxes, ...), which associates them with a given form element by id. See + // [AttachedProperty(typeof(string))] + // public static DotvvmProperty FormIDProperty = DotvvmProperty.Register(() => FormIDProperty, null, isValueInherited: true); private FormControls() {} // the class can't be static, but no instance should exist } } diff --git a/src/Framework/Framework/Controls/GridViewCheckBoxColumn.cs b/src/Framework/Framework/Controls/GridViewCheckBoxColumn.cs index bedb7c9972..c67a92f6b3 100644 --- a/src/Framework/Framework/Controls/GridViewCheckBoxColumn.cs +++ b/src/Framework/Framework/Controls/GridViewCheckBoxColumn.cs @@ -22,6 +22,7 @@ public bool ValueBinding public static readonly DotvvmProperty ValueBindingProperty = DotvvmProperty.Register(c => c.ValueBinding); + /// Whether to automatically attach Validator.Value onto the TextBox or add a standalone Validator component. public ValidatorPlacement ValidatorPlacement { get { return (ValidatorPlacement)GetValue(ValidatorPlacementProperty)!; } diff --git a/src/Framework/Framework/Controls/GridViewTextColumn.cs b/src/Framework/Framework/Controls/GridViewTextColumn.cs index bc188a529d..476f8e316b 100644 --- a/src/Framework/Framework/Controls/GridViewTextColumn.cs +++ b/src/Framework/Framework/Controls/GridViewTextColumn.cs @@ -51,6 +51,7 @@ public IValueBinding? ValueBinding public static readonly DotvvmProperty ValueBindingProperty = DotvvmProperty.Register(c => c.ValueBinding); + /// Whether to automatically attach Validator.Value onto the TextBox or add a standalone Validator component. [MarkupOptions(AllowBinding = false)] public ValidatorPlacement ValidatorPlacement { diff --git a/src/Framework/Framework/Controls/UploadedFilesCollection.cs b/src/Framework/Framework/Controls/UploadedFilesCollection.cs index 7539528c58..53d2510a1f 100644 --- a/src/Framework/Framework/Controls/UploadedFilesCollection.cs +++ b/src/Framework/Framework/Controls/UploadedFilesCollection.cs @@ -3,6 +3,7 @@ namespace DotVVM.Framework.Controls { + /// A view model for the FileUpload control. public class UploadedFilesCollection { public UploadedFilesCollection() @@ -10,12 +11,16 @@ public UploadedFilesCollection() Files = new List(); } + /// if is true, this property contains the upload progress in percents (0-100). public int Progress { get; set; } + /// Indicates whether something is being uploaded at the moment. public bool IsBusy { get; set; } + /// List of all completely uploaded files. public List Files { get; set; } + /// Contains an error message indicating if there was a problem during the upload. public string? Error { get; set; } public void Clear() diff --git a/src/Framework/Framework/Controls/Validation.cs b/src/Framework/Framework/Controls/Validation.cs index a8971683e2..83400ea6d2 100644 --- a/src/Framework/Framework/Controls/Validation.cs +++ b/src/Framework/Framework/Controls/Validation.cs @@ -14,10 +14,15 @@ namespace DotVVM.Framework.Controls [ContainsDotvvmProperties] public class Validation { + /// Controls whether automatic validation is enabled for command bindings on the control and its subtree. [AttachedProperty(typeof(bool))] [MarkupOptions(AllowBinding = false)] public static DotvvmProperty EnabledProperty = DotvvmProperty.Register(() => EnabledProperty, true, true); + /// + /// The object which is the primary target for the automatic validation based on data annotation attributes. + /// Note that data annotations of the property used in the target binding are not validated, only the rules inside its value. + /// [AttachedProperty(typeof(object))] [MarkupOptions(AllowHardCodedValue = false)] public static DotvvmProperty TargetProperty = DotvvmProperty.Register(() => TargetProperty, null, true); diff --git a/src/Framework/Framework/Controls/ValidatorPlacement.cs b/src/Framework/Framework/Controls/ValidatorPlacement.cs index a3b213404c..f3d0e9a641 100644 --- a/src/Framework/Framework/Controls/ValidatorPlacement.cs +++ b/src/Framework/Framework/Controls/ValidatorPlacement.cs @@ -5,8 +5,11 @@ namespace DotVVM.Framework.Controls [Flags] public enum ValidatorPlacement { + /// No validators are placed (automatically). None = 0, + /// Validator.Value is attached to the primary editor control (i.e. a TextBox in GridViewTextColumn) AttachToControl = 1, + /// A standalone Validator (span) control is placed after the editor control. Standalone = 2 } } From 7a434a05b28f039dfa1cc395b4108074a0092a20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Standa=20Luke=C5=A1?= Date: Sun, 18 Aug 2024 00:41:15 +0200 Subject: [PATCH 17/40] Doccomments for TextBox types --- src/Framework/Core/Storage/UploadedFile.cs | 5 ++-- .../Framework/Controls/FormControls.cs | 5 +--- src/Framework/Framework/Controls/TextBox.cs | 3 ++- .../Framework/Controls/TextBoxType.cs | 23 +++++++++++++++++++ .../Controls/UploadedFilesCollection.cs | 3 ++- 5 files changed, 31 insertions(+), 8 deletions(-) diff --git a/src/Framework/Core/Storage/UploadedFile.cs b/src/Framework/Core/Storage/UploadedFile.cs index cdf14e02be..f28077f37c 100644 --- a/src/Framework/Core/Storage/UploadedFile.cs +++ b/src/Framework/Core/Storage/UploadedFile.cs @@ -4,12 +4,13 @@ namespace DotVVM.Core.Storage { public class UploadedFile { - /// A unique, randomly generate ID of the uploaded file. Use this ID to get the file from + /// A unique, randomly generated ID of the uploaded file. Use this ID to get the file from public Guid FileId { get; set; } - /// A user-specified name of the file. Use with caution, the user may specify this to be any string (for example ../../Web.config) + /// A user-specified name of the file. Use with caution, the user may specify this to be any string (for example ../../Web.config). public string? FileName { get; set; } + /// Length of the file in bytes. Use with caution, the user may manipulate with this property and it might not correspond to the file returned from . public FileSize FileSize { get; set; } = new FileSize(); /// If the file type matched one of type MIME types or extensions in FileUpload.AllowedFileTypes. Use with caution, the user may manipulate with this property. diff --git a/src/Framework/Framework/Controls/FormControls.cs b/src/Framework/Framework/Controls/FormControls.cs index 3ca7f019a8..60b4bcac3e 100644 --- a/src/Framework/Framework/Controls/FormControls.cs +++ b/src/Framework/Framework/Controls/FormControls.cs @@ -8,13 +8,10 @@ namespace DotVVM.Framework.Controls [ContainsDotvvmProperties] public sealed class FormControls { - /// Enables or disables all child form controls (Buttons, TextBoxes, ...) + /// Enables or disables all child form controls with an Enabled property (Buttons, TextBoxes, ...) [AttachedProperty(typeof(bool))] public static DotvvmProperty EnabledProperty = DotvvmProperty.Register(() => EnabledProperty, true, isValueInherited: true); - /// Sets `form` attribute on all child input controls (Buttons, TextBoxes, ...), which associates them with a given form element by id. See - // [AttachedProperty(typeof(string))] - // public static DotvvmProperty FormIDProperty = DotvvmProperty.Register(() => FormIDProperty, null, isValueInherited: true); private FormControls() {} // the class can't be static, but no instance should exist } } diff --git a/src/Framework/Framework/Controls/TextBox.cs b/src/Framework/Framework/Controls/TextBox.cs index ccc373d38b..5d5f9558f2 100644 --- a/src/Framework/Framework/Controls/TextBox.cs +++ b/src/Framework/Framework/Controls/TextBox.cs @@ -94,8 +94,9 @@ public string Text DotvvmProperty.Register(t => t.Text, ""); /// - /// Gets or sets the mode of the text field. + /// Gets or sets the mode of the text field (input/textarea and its type attribute) /// + /// To override the type attribute determined based on this property, you can explicitly specify the attribute value using the `html:type=YOUR_VALUE` syntax [MarkupOptions(AllowBinding = false)] public TextBoxType Type { diff --git a/src/Framework/Framework/Controls/TextBoxType.cs b/src/Framework/Framework/Controls/TextBoxType.cs index 82cf46cb59..acb588e7c1 100644 --- a/src/Framework/Framework/Controls/TextBoxType.cs +++ b/src/Framework/Framework/Controls/TextBoxType.cs @@ -6,18 +6,41 @@ namespace DotVVM.Framework.Controls { public enum TextBoxType { + /// The standard <input type=text text box. Normal, + /// The <input type=password text box which hides the written text. + /// Password, + /// The <textarea> element which allows writing multiple lines. MultiLine, + /// The <input type=tel text box. + /// Telephone, + /// The <input type=url text box which automatically validates whether the user to entered a valid URL. + /// Url, + /// The <input type=email text box which automatically validates whether the user to entered a valid email address. + /// Email, + /// The <input type=datetime element which typicaly shows a date picker (without time). + /// Date, + /// The <input type=datetime-local element which typicaly shows a time-of-day picker (without date). + /// Time, + /// The <input type=number element which typically shows an interactive color picker and stored its 7-character RGB color code in hexadecimal format into the bound view model property. + /// Color, + /// The <input type=range text box. + /// Search, + /// The <input type=range text box which only allows typing digits and typically has up/down arrows. + /// Number, + /// The <input type=range text box which allows the user to specify a year and month combination in the YYYY-MM format. + /// Month, + /// The <input type=range text box which allows the user to specify a date time in their local timezone. DotVVM can automatically convert it into a UTC timestamp using the two-way function. DateTimeLocal } } diff --git a/src/Framework/Framework/Controls/UploadedFilesCollection.cs b/src/Framework/Framework/Controls/UploadedFilesCollection.cs index 53d2510a1f..c2bc011032 100644 --- a/src/Framework/Framework/Controls/UploadedFilesCollection.cs +++ b/src/Framework/Framework/Controls/UploadedFilesCollection.cs @@ -11,7 +11,7 @@ public UploadedFilesCollection() Files = new List(); } - /// if is true, this property contains the upload progress in percents (0-100). + /// If is true, this property contains the upload progress in percents (0-100). public int Progress { get; set; } /// Indicates whether something is being uploaded at the moment. @@ -23,6 +23,7 @@ public UploadedFilesCollection() /// Contains an error message indicating if there was a problem during the upload. public string? Error { get; set; } + /// Resets the viewmodel to the default state (no files, no error). public void Clear() { Progress = 0; From 0370cd4c1c203e0e61024b204cb9dd3f40fa949b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Herceg?= Date: Thu, 1 Aug 2024 17:43:42 +0200 Subject: [PATCH 18/40] Added helper methods to build command binding expressions --- .../Binding/DotvvmBindingCacheHelper.cs | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/Framework/Framework/Binding/DotvvmBindingCacheHelper.cs b/src/Framework/Framework/Binding/DotvvmBindingCacheHelper.cs index cc11591f26..b9e9c2c1eb 100644 --- a/src/Framework/Framework/Binding/DotvvmBindingCacheHelper.cs +++ b/src/Framework/Framework/Binding/DotvvmBindingCacheHelper.cs @@ -60,6 +60,28 @@ public ValueBindingExpression CreateValueBinding(string code, })); } + /// Compiles a new `{command: ...code...}` binding which can be evaluated server-side and also client-side. The result is cached. + public CommandBindingExpression CreateCommand(string code, DataContextStack dataContext, BindingParserOptions? parserOptions = null) + { + return CreateCachedBinding($"Command:{code}", new object?[] { dataContext, parserOptions }, () => + new CommandBindingExpression(compilationService, new object?[] { + dataContext, + new OriginalStringBindingProperty(code), + parserOptions + })); + } + + /// Compiles a new `{command: ...code...}` binding which can be evaluated server-side and also client-side. The result is implicitly converted to . The result is cached. + public CommandBindingExpression CreateCommand(string code, DataContextStack dataContext, BindingParserOptions? parserOptions = null) + { + return CreateCachedBinding($"Command<{typeof(TResult).ToCode()}>:{code}", new object?[] { dataContext, parserOptions }, () => + new CommandBindingExpression(compilationService, new object?[] { + dataContext, + new OriginalStringBindingProperty(code), + parserOptions + })); + } + /// Compiles a new `{staticCommand: ...code...}` binding which can be evaluated server-side and also client-side. The result is cached. public StaticCommandBindingExpression CreateStaticCommand(string code, DataContextStack dataContext, BindingParserOptions? parserOptions = null) { From cf32aeccc352b89777fa194228ea881c4c1dcb5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Herceg?= Date: Thu, 1 Aug 2024 18:44:19 +0200 Subject: [PATCH 19/40] Fixed nullability warning --- .../Framework/Binding/Expressions/CommandBindingExpression.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Framework/Framework/Binding/Expressions/CommandBindingExpression.cs b/src/Framework/Framework/Binding/Expressions/CommandBindingExpression.cs index ee99725ee8..d1b26970f6 100644 --- a/src/Framework/Framework/Binding/Expressions/CommandBindingExpression.cs +++ b/src/Framework/Framework/Binding/Expressions/CommandBindingExpression.cs @@ -30,7 +30,7 @@ namespace DotVVM.Framework.Binding.Expressions [Options] public class CommandBindingExpression : BindingExpression, ICommandBinding { - public CommandBindingExpression(BindingCompilationService service, IEnumerable properties) : base(service, properties) + public CommandBindingExpression(BindingCompilationService service, IEnumerable properties) : base(service, properties) { AddNullResolvers(); } @@ -172,6 +172,6 @@ public CommandBindingExpression(BindingCompilationService service, BindingDelega public class CommandBindingExpression : CommandBindingExpression, ICommandBinding { public new BindingDelegate BindingDelegate => base.BindingDelegate.ToGeneric(); - public CommandBindingExpression(BindingCompilationService service, IEnumerable properties) : base(service, properties) { } + public CommandBindingExpression(BindingCompilationService service, IEnumerable properties) : base(service, properties) { } } } From 89f5abe5b6167cedf88d25553d17ce91b037ab5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Herceg?= Date: Fri, 16 Aug 2024 17:23:00 +0200 Subject: [PATCH 20/40] Added helper methods for resource binding --- .../Binding/DotvvmBindingCacheHelper.cs | 22 +++++++++++++++++++ .../Expressions/ResourceBindingExpression.cs | 4 ++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/Framework/Framework/Binding/DotvvmBindingCacheHelper.cs b/src/Framework/Framework/Binding/DotvvmBindingCacheHelper.cs index b9e9c2c1eb..cda2f8c42b 100644 --- a/src/Framework/Framework/Binding/DotvvmBindingCacheHelper.cs +++ b/src/Framework/Framework/Binding/DotvvmBindingCacheHelper.cs @@ -60,6 +60,28 @@ public ValueBindingExpression CreateValueBinding(string code, })); } + /// Compiles a new `{resource: ...code...}` binding which can be evaluated server-side. The result is cached. + public ResourceBindingExpression CreateResourceBinding(string code, DataContextStack dataContext, BindingParserOptions? parserOptions = null) + { + return CreateCachedBinding("ResourceBinding:" + code, new object?[] { dataContext, parserOptions }, () => + new ResourceBindingExpression(compilationService, new object?[] { + dataContext, + new OriginalStringBindingProperty(code), + parserOptions + })); + } + + /// Compiles a new `{resource: ...code...}` binding which can be evaluated server-side. The result is implicitly converted to . The result is cached. + public ResourceBindingExpression CreateResourceBinding(string code, DataContextStack dataContext, BindingParserOptions? parserOptions = null) + { + return CreateCachedBinding($"ResourceBinding<{typeof(TResult).ToCode()}>:{code}", new object?[] { dataContext, parserOptions }, () => + new ResourceBindingExpression(compilationService, new object?[] { + dataContext, + new OriginalStringBindingProperty(code), + parserOptions + })); + } + /// Compiles a new `{command: ...code...}` binding which can be evaluated server-side and also client-side. The result is cached. public CommandBindingExpression CreateCommand(string code, DataContextStack dataContext, BindingParserOptions? parserOptions = null) { diff --git a/src/Framework/Framework/Binding/Expressions/ResourceBindingExpression.cs b/src/Framework/Framework/Binding/Expressions/ResourceBindingExpression.cs index 8273dffd56..fb5da85b8b 100644 --- a/src/Framework/Framework/Binding/Expressions/ResourceBindingExpression.cs +++ b/src/Framework/Framework/Binding/Expressions/ResourceBindingExpression.cs @@ -18,7 +18,7 @@ namespace DotVVM.Framework.Binding.Expressions [Options] public class ResourceBindingExpression : BindingExpression, IStaticValueBinding { - public ResourceBindingExpression(BindingCompilationService service, IEnumerable properties) : base(service, properties) { } + public ResourceBindingExpression(BindingCompilationService service, IEnumerable properties) : base(service, properties) { } public BindingDelegate BindingDelegate => this.bindingDelegate.GetValueOrThrow(this); @@ -34,7 +34,7 @@ public class OptionsAttribute : BindingCompilationOptionsAttribute public class ResourceBindingExpression : ResourceBindingExpression, IStaticValueBinding { - public ResourceBindingExpression(BindingCompilationService service, IEnumerable properties) : base(service, properties) { } + public ResourceBindingExpression(BindingCompilationService service, IEnumerable properties) : base(service, properties) { } public new BindingDelegate BindingDelegate => base.BindingDelegate.ToGeneric(); } From de43b4af12da3cfe4293f4f32fb149fe575f827b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Standa=20Luke=C5=A1?= Date: Mon, 12 Aug 2024 16:37:43 +0200 Subject: [PATCH 21/40] Add memory barries to writes at the end of a lock There could have been a race condition on systems with weak memory ordering: Another thread can read the result value before we exit the critical section. Without a strong memory ordering, the pointer to the result value might get written before the value itself. The other thread might therefore read zeroed-out memory. --- src/Framework/Framework/Binding/DotvvmProperty.cs | 6 +++++- .../Framework/Binding/Expressions/BindingExpression.cs | 2 +- .../Compilation/ControlTree/DotvvmPropertyGroup.cs | 8 ++++++-- .../Framework/Compilation/ExtensionMethodsCache.cs | 1 - .../Compilation/Styles/ResolvedControlHelper.cs | 2 ++ .../Framework/Runtime/Caching/SimpleLruDictionary.cs | 10 ++++++++-- 6 files changed, 22 insertions(+), 7 deletions(-) diff --git a/src/Framework/Framework/Binding/DotvvmProperty.cs b/src/Framework/Framework/Binding/DotvvmProperty.cs index 8eb7261768..aaa21563e1 100644 --- a/src/Framework/Framework/Binding/DotvvmProperty.cs +++ b/src/Framework/Framework/Binding/DotvvmProperty.cs @@ -115,7 +115,11 @@ internal void AddUsedInCapability(DotvvmCapabilityProperty? p) if (p is object) lock(this) { - UsedInCapabilities = UsedInCapabilities.Add(p); + if (UsedInCapabilities.Contains(p)) return; + + var newArray = UsedInCapabilities.Add(p); + Thread.MemoryBarrier(); // make sure the array is complete before we let other threads use it lock-free + UsedInCapabilities = newArray; } } diff --git a/src/Framework/Framework/Binding/Expressions/BindingExpression.cs b/src/Framework/Framework/Binding/Expressions/BindingExpression.cs index c84e512c8b..3c38a0229c 100644 --- a/src/Framework/Framework/Binding/Expressions/BindingExpression.cs +++ b/src/Framework/Framework/Binding/Expressions/BindingExpression.cs @@ -260,7 +260,7 @@ protected void AddNullResolvers() - string? toStringValue; + volatile string? toStringValue; public override string ToString() { if (toStringValue is null) diff --git a/src/Framework/Framework/Compilation/ControlTree/DotvvmPropertyGroup.cs b/src/Framework/Framework/Compilation/ControlTree/DotvvmPropertyGroup.cs index 2fa03d4137..96c84f10cb 100644 --- a/src/Framework/Framework/Compilation/ControlTree/DotvvmPropertyGroup.cs +++ b/src/Framework/Framework/Compilation/ControlTree/DotvvmPropertyGroup.cs @@ -9,6 +9,7 @@ using DotVVM.Framework.Utils; using System.Runtime.CompilerServices; using System.Collections.Immutable; +using System.Threading; namespace DotVVM.Framework.Compilation.ControlTree { @@ -81,8 +82,11 @@ internal void AddUsedInCapability(DotvvmCapabilityProperty? p) if (p is object) lock(this) { - if (!UsedInCapabilities.Contains(p)) - UsedInCapabilities = UsedInCapabilities.Add(p); + if (UsedInCapabilities.Contains(p)) return; + + var newArray = UsedInCapabilities.Add(p); + Thread.MemoryBarrier(); + UsedInCapabilities = newArray; } } diff --git a/src/Framework/Framework/Compilation/ExtensionMethodsCache.cs b/src/Framework/Framework/Compilation/ExtensionMethodsCache.cs index f40859f1da..282304f212 100644 --- a/src/Framework/Framework/Compilation/ExtensionMethodsCache.cs +++ b/src/Framework/Framework/Compilation/ExtensionMethodsCache.cs @@ -38,7 +38,6 @@ public IEnumerable GetExtensionsForNamespaces(string[] @namespaces) // it's most likely the same namespaces, so it won't help at all - only run into lock contention in System.Reflection lock (methodsCache) { - results = namespaces.Select(x => methodsCache.GetValueOrDefault(x)).ToArray(); var missingNamespaces = namespaces.Where(x => !methodsCache.ContainsKey(x)).ToArray(); var createdNamespaces = CreateExtensionsForNamespaces(missingNamespaces); diff --git a/src/Framework/Framework/Compilation/Styles/ResolvedControlHelper.cs b/src/Framework/Framework/Compilation/Styles/ResolvedControlHelper.cs index 51669e764b..8eb3f9f034 100644 --- a/src/Framework/Framework/Compilation/Styles/ResolvedControlHelper.cs +++ b/src/Framework/Framework/Compilation/Styles/ResolvedControlHelper.cs @@ -19,6 +19,7 @@ using DotVVM.Framework.Compilation.ViewCompiler; using DotVVM.Framework.Runtime; using FastExpressionCompiler; +using System.Threading; namespace DotVVM.Framework.Compilation.Styles { @@ -417,6 +418,7 @@ void InitializeChildren(IDotvvmRequestContext? context) var services = context?.Services ?? this.services; Children.Add((DotvvmControl)ResolvedControl.ToRuntimeControl(services)); + Thread.MemoryBarrier(); // make sure write to Children is done before we let other threads read without lock initialized = true; } } diff --git a/src/Framework/Framework/Runtime/Caching/SimpleLruDictionary.cs b/src/Framework/Framework/Runtime/Caching/SimpleLruDictionary.cs index c227fefac6..5d7f39e5bb 100644 --- a/src/Framework/Framework/Runtime/Caching/SimpleLruDictionary.cs +++ b/src/Framework/Framework/Runtime/Caching/SimpleLruDictionary.cs @@ -20,9 +20,9 @@ public class SimpleLruDictionary { // concurrencyLevel: 1, we don't write in parallel anyway // new generation - private ConcurrentDictionary hot = new ConcurrentDictionary(concurrencyLevel: 1, capacity: 1); + private volatile ConcurrentDictionary hot = new ConcurrentDictionary(concurrencyLevel: 1, capacity: 1); // old generation - private ConcurrentDictionary cold = new ConcurrentDictionary(concurrencyLevel: 1, capacity: 1); + private volatile ConcurrentDictionary cold = new ConcurrentDictionary(concurrencyLevel: 1, capacity: 1); // free to take for GC. however, if the GC does not want to collect, we can still use it private readonly ConcurrentDictionary> dead = new ConcurrentDictionary>(concurrencyLevel: 1, capacity: 1); private TimeSpan lastCleanupTime = TimeSpan.MinValue; @@ -148,6 +148,12 @@ public bool Remove(TKey key, out TValue oldValue) oldValue = deadValue; r = true; } + if (hot.TryRemove(key, out hotValue)) + { + // hot again, it could have been added back in the meantime + oldValue = hotValue; + r = true; + } return r; } } From 54bc1a403f098746b1b258a2aa68a469d31048ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Standa=20Luke=C5=A1?= Date: Mon, 12 Aug 2024 16:56:15 +0200 Subject: [PATCH 22/40] Fix near page indexes crashing on invalid page number ... which can very plausibly occur when rows get removed fixes #1835 --- .../Controls/Options/DistanceNearPageIndexesProvider.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Framework/Core/Controls/Options/DistanceNearPageIndexesProvider.cs b/src/Framework/Core/Controls/Options/DistanceNearPageIndexesProvider.cs index f40ac13687..79cdec24d9 100644 --- a/src/Framework/Core/Controls/Options/DistanceNearPageIndexesProvider.cs +++ b/src/Framework/Core/Controls/Options/DistanceNearPageIndexesProvider.cs @@ -26,8 +26,10 @@ public DistanceNearPageIndexesProvider(int distance) /// The settings for paging. public IList GetIndexes(IPagingOptions pagingOptions) { - var firstIndex = Math.Max(pagingOptions.PageIndex - distance, 0); - var lastIndex = Math.Min(pagingOptions.PageIndex + distance + 1, pagingOptions.PagesCount); + var count = pagingOptions.PagesCount; + var index = Math.Max(0, Math.Min(count - 1, pagingOptions.PageIndex)); // clamp index to be a valid page + var firstIndex = Math.Max(index - distance, 0); + var lastIndex = Math.Min(index + distance + 1, count); return Enumerable .Range(firstIndex, lastIndex - firstIndex) From 795adecfc0fce6a715b80b9a6c4d60ee85859b36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Standa=20Luke=C5=A1?= Date: Sun, 18 Aug 2024 11:49:04 +0200 Subject: [PATCH 23/40] command binding creation - link to CommandBindingExpression constructor --- src/Framework/Framework/Binding/DotvvmBindingCacheHelper.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Framework/Framework/Binding/DotvvmBindingCacheHelper.cs b/src/Framework/Framework/Binding/DotvvmBindingCacheHelper.cs index cda2f8c42b..be11c6556a 100644 --- a/src/Framework/Framework/Binding/DotvvmBindingCacheHelper.cs +++ b/src/Framework/Framework/Binding/DotvvmBindingCacheHelper.cs @@ -60,7 +60,7 @@ public ValueBindingExpression CreateValueBinding(string code, })); } - /// Compiles a new `{resource: ...code...}` binding which can be evaluated server-side. The result is cached. + /// Compiles a new `{resource: ...code...}` binding which can be evaluated server-side. The result is cached. public ResourceBindingExpression CreateResourceBinding(string code, DataContextStack dataContext, BindingParserOptions? parserOptions = null) { return CreateCachedBinding("ResourceBinding:" + code, new object?[] { dataContext, parserOptions }, () => @@ -82,7 +82,7 @@ public ResourceBindingExpression CreateResourceBinding(string })); } - /// Compiles a new `{command: ...code...}` binding which can be evaluated server-side and also client-side. The result is cached. + /// Compiles a new `{command: ...code...}` binding which can be evaluated server-side and also client-side. The result is cached. Note that command bindings might be easier to create using the constructor. public CommandBindingExpression CreateCommand(string code, DataContextStack dataContext, BindingParserOptions? parserOptions = null) { return CreateCachedBinding($"Command:{code}", new object?[] { dataContext, parserOptions }, () => @@ -93,7 +93,7 @@ public CommandBindingExpression CreateCommand(string code, DataContextStack data })); } - /// Compiles a new `{command: ...code...}` binding which can be evaluated server-side and also client-side. The result is implicitly converted to . The result is cached. + /// Compiles a new `{command: ...code...}` binding which can be evaluated server-side and also client-side. The result is implicitly converted to . The result is cached. Note that command bindings might be easier to create using the constructor. public CommandBindingExpression CreateCommand(string code, DataContextStack dataContext, BindingParserOptions? parserOptions = null) { return CreateCachedBinding($"Command<{typeof(TResult).ToCode()}>:{code}", new object?[] { dataContext, parserOptions }, () => From 97e10bcaa50f3e272c5a9ea0b827e46f1ae5cc27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Standa=20Luke=C5=A1?= Date: Sun, 18 Aug 2024 11:57:32 +0200 Subject: [PATCH 24/40] Add binding missing redirects to OWIN samples --- src/Samples/Owin/Web.config | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Samples/Owin/Web.config b/src/Samples/Owin/Web.config index 76fe2ad837..1938b46fdb 100644 --- a/src/Samples/Owin/Web.config +++ b/src/Samples/Owin/Web.config @@ -47,6 +47,12 @@ + + + + + + From 8a148e68b1bd9b56642ad3cb3476408789714053 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Standa=20Luke=C5=A1?= Date: Sun, 18 Aug 2024 00:16:54 +0200 Subject: [PATCH 25/40] AlternateCultureLinks: doccomments --- .../Controls/AlternateCultureLinks.cs | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/Framework/Framework/Controls/AlternateCultureLinks.cs b/src/Framework/Framework/Controls/AlternateCultureLinks.cs index 5e5048fc55..167a78769b 100644 --- a/src/Framework/Framework/Controls/AlternateCultureLinks.cs +++ b/src/Framework/Framework/Controls/AlternateCultureLinks.cs @@ -9,16 +9,18 @@ namespace DotVVM.Framework.Controls { + /// + /// Renders a <link rel=alternate element for each localized route equivalent to the current route. + /// On non-localized routes, it renders nothing (the control is therefore safe to use in a master page). + /// The href must be an absolute URL, so it will only work correctly if Context.Request.Url contains the corrent domain. + /// + /// + /// + /// public class AlternateCultureLinks : CompositeControl { - private readonly IDotvvmRequestContext context; - - public AlternateCultureLinks(IDotvvmRequestContext context) - { - this.context = context; - } - - public IEnumerable GetContents(string? routeName = null) + /// The name of the route to generate alternate links for. If not set, the current route is used. + public IEnumerable GetContents(IDotvvmRequestContext context, string? routeName = null) { var route = routeName != null ? context.Configuration.RouteTable[routeName] : context.Route; if (route is LocalizedDotvvmRoute localizedRoute) @@ -31,7 +33,7 @@ public IEnumerable GetContents(string? routeName = null) var languageCode = alternateCultureRoute.Key == "" ? "x-default" : alternateCultureRoute.Key.ToLowerInvariant(); var alternateUrl = context.TranslateVirtualPath(alternateCultureRoute.Value.BuildUrl(context.Parameters!)); - var absoluteAlternateUrl = BuildAbsoluteAlternateUrl(alternateUrl); + var absoluteAlternateUrl = BuildAbsoluteAlternateUrl(context, alternateUrl); yield return new HtmlGenericControl("link") .SetAttribute("rel", "alternate") @@ -42,7 +44,7 @@ public IEnumerable GetContents(string? routeName = null) } } - protected virtual string BuildAbsoluteAlternateUrl(string alternateUrl) + protected virtual string BuildAbsoluteAlternateUrl(IDotvvmRequestContext context, string alternateUrl) { return new Uri(context.HttpContext.Request.Url, alternateUrl).AbsoluteUri; } From 795fbaf4443f7117de6e086006172f5e118804d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Standa=20Luke=C5=A1?= Date: Fri, 16 Aug 2024 15:44:09 +0200 Subject: [PATCH 26/40] js translations: TimeOnly, DateOnly and DateTime.Now Support for TimeOnly, and DateOnly instance properties (Year, Month, ..., Hour, Minute, ...) in JS bindings. Similar DateTime properties are already supported. Support for DateOnly.FromDateTime and TimeOnly.FromDateTime methods Support for DateTime.Now, DateTime.UtcNow and DateTime.Today static properties. Before this patch, these properties expanded into the compile-time value, which could give the impression of working correctly during development. resolves #1693 --- .../Javascript/Ast/JsAstHelpers.cs | 3 ++ .../JavascriptTranslatableMethodCollection.cs | 45 +++++++++++++++++++ .../Resources/Scripts/dotvvm-root.ts | 4 +- .../DateOnlyTranslations.dothtml | 36 +++++++++++++++ .../DateTimeTranslations.dothtml | 8 ++++ .../TimeOnlyTranslations.dothtml | 35 +++++++++++++++ .../Abstractions/SamplesRouteUrls.designer.cs | 2 + .../Tests/Feature/DateTimeTranslationTests.cs | 37 +++++++++++++++ .../Binding/JavascriptCompilationTests.cs | 32 +++++++++++++ 9 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 src/Samples/Common/Views/FeatureSamples/JavascriptTranslation/DateOnlyTranslations.dothtml create mode 100644 src/Samples/Common/Views/FeatureSamples/JavascriptTranslation/TimeOnlyTranslations.dothtml diff --git a/src/Framework/Framework/Compilation/Javascript/Ast/JsAstHelpers.cs b/src/Framework/Framework/Compilation/Javascript/Ast/JsAstHelpers.cs index 53e710a8a8..a1ba82e0c6 100644 --- a/src/Framework/Framework/Compilation/Javascript/Ast/JsAstHelpers.cs +++ b/src/Framework/Framework/Compilation/Javascript/Ast/JsAstHelpers.cs @@ -19,6 +19,9 @@ public static JsExpression Invoke(this JsExpression target, IEnumerable new JsInvocationExpression(target, arguments); + public static JsExpression CallMethod(this JsExpression target, string methodName, params JsExpression?[] arguments) => + target.Member(methodName).Invoke(arguments); + public static JsExpression Indexer(this JsExpression target, JsExpression argument) { return new JsIndexerExpression(target, argument); diff --git a/src/Framework/Framework/Compilation/Javascript/JavascriptTranslatableMethodCollection.cs b/src/Framework/Framework/Compilation/Javascript/JavascriptTranslatableMethodCollection.cs index fbbf532109..944d2f4036 100644 --- a/src/Framework/Framework/Compilation/Javascript/JavascriptTranslatableMethodCollection.cs +++ b/src/Framework/Framework/Compilation/Javascript/JavascriptTranslatableMethodCollection.cs @@ -243,6 +243,8 @@ JsExpression dictionarySetIndexer(JsExpression[] args, MethodInfo method) => AddDefaultListTranslations(); AddDefaultMathTranslations(); AddDefaultDateTimeTranslations(); + AddDefaultDateOnlyTranslations(); + AddDefaultTimeOnlyTranslations(); AddDefaultConvertTranslations(); } @@ -781,10 +783,53 @@ JsExpression IncrementExpression(JsExpression left, int value) AddPropertyTranslator(() => DateTime.Now.Millisecond, new GenericMethodCompiler(args => new JsInvocationExpression(new JsIdentifierExpression("dotvvm").Member("serialization").Member("parseDate"), args[0]).Member("getMilliseconds").Invoke())); + AddPropertyTranslator(() => DateTime.Now, new GenericMethodCompiler(args => + new JsIdentifierExpression("dotvvm").Member("serialization").CallMethod("serializeDate", new JsNewExpression("Date"), new JsLiteral(false)))); + AddPropertyTranslator(() => DateTime.UtcNow, new GenericMethodCompiler(args => + new JsIdentifierExpression("dotvvm").Member("serialization").CallMethod("serializeDate", new JsNewExpression("Date"), new JsLiteral(true)))); + AddPropertyTranslator(() => DateTime.Today, new GenericMethodCompiler(args => + new JsIdentifierExpression("dotvvm").Member("serialization").CallMethod("serializeDate", new JsNewExpression("Date"), new JsLiteral(false)) + .CallMethod("substring", new JsLiteral(0), new JsLiteral("0000-00-00".Length)) + .Binary(BinaryOperatorType.Plus, new JsLiteral("T00:00:00.000")))); + + AddMethodTranslator(() => DateTime.UtcNow.ToBrowserLocalTime(), new GenericMethodCompiler(args => new JsIdentifierExpression("dotvvm").Member("translations").Member("dateTime").Member("toBrowserLocalTime").Invoke(args[1].WithAnnotation(ShouldBeObservableAnnotation.Instance)).WithAnnotation(ResultIsObservableAnnotation.Instance))); AddMethodTranslator(() => default(Nullable).ToBrowserLocalTime(), new GenericMethodCompiler(args => new JsIdentifierExpression("dotvvm").Member("translations").Member("dateTime").Member("toBrowserLocalTime").Invoke(args[1].WithAnnotation(ShouldBeObservableAnnotation.Instance)).WithAnnotation(ResultIsObservableAnnotation.Instance))); + + } + + private void AddDefaultDateOnlyTranslations() + { + JsExpression parse(JsExpression arg) => + new JsIdentifierExpression("dotvvm").Member("serialization").CallMethod("parseDateOnly", arg); + AddPropertyTranslator(() => DateOnly.MinValue.Year, new GenericMethodCompiler(args => + parse(args[0]).CallMethod("getFullYear"))); + AddPropertyTranslator(() => DateOnly.MinValue.Month, new GenericMethodCompiler(args => + parse(args[0]).CallMethod("getMonth").Binary(BinaryOperatorType.Plus, new JsLiteral(1)))); + AddPropertyTranslator(() => DateOnly.MinValue.Day, new GenericMethodCompiler(args => + parse(args[0]).CallMethod("getDate"))); + + AddMethodTranslator(() => DateOnly.FromDateTime(DateTime.Now), new GenericMethodCompiler(args => + args[1].CallMethod("substring", new JsLiteral(0), new JsLiteral("0000-00-00".Length)))); + } + + private void AddDefaultTimeOnlyTranslations() + { + JsExpression parse(JsExpression arg) => + new JsIdentifierExpression("dotvvm").Member("serialization").CallMethod("parseTimeOnly", arg); + AddPropertyTranslator(() => TimeOnly.MinValue.Hour, new GenericMethodCompiler(args => + parse(args[0]).CallMethod("getHours"))); + AddPropertyTranslator(() => TimeOnly.MinValue.Minute, new GenericMethodCompiler(args => + parse(args[0]).CallMethod("getMinutes"))); + AddPropertyTranslator(() => TimeOnly.MinValue.Second, new GenericMethodCompiler(args => + parse(args[0]).CallMethod("getSeconds"))); + AddPropertyTranslator(() => TimeOnly.MinValue.Millisecond, new GenericMethodCompiler(args => + parse(args[0]).CallMethod("getMilliseconds"))); + + AddMethodTranslator(() => TimeOnly.FromDateTime(DateTime.Now), new GenericMethodCompiler(args => + args[1].CallMethod("substring", new JsLiteral("0000-00-00T".Length)))); } private void AddDefaultConvertTranslations() diff --git a/src/Framework/Framework/Resources/Scripts/dotvvm-root.ts b/src/Framework/Framework/Resources/Scripts/dotvvm-root.ts index 40ca264fc5..8e7cb8e4c0 100644 --- a/src/Framework/Framework/Resources/Scripts/dotvvm-root.ts +++ b/src/Framework/Framework/Resources/Scripts/dotvvm-root.ts @@ -4,7 +4,7 @@ import * as spa from "./spa/spa" import * as validation from './validation/validation' import { postBack } from './postback/postback' import { serialize } from './serialization/serialize' -import { serializeDate, parseDate } from './serialization/date' +import { serializeDate, parseDate, parseDateOnly, parseTimeOnly } from './serialization/date' import { deserialize } from './serialization/deserialize' import registerBindingHandlers from './binding-handlers/register' import * as evaluator from './utils/evaluator' @@ -97,6 +97,8 @@ const dotvvmExports = { serialize, serializeDate, parseDate, + parseDateOnly, + parseTimeOnly, deserialize }, metadata: { diff --git a/src/Samples/Common/Views/FeatureSamples/JavascriptTranslation/DateOnlyTranslations.dothtml b/src/Samples/Common/Views/FeatureSamples/JavascriptTranslation/DateOnlyTranslations.dothtml new file mode 100644 index 0000000000..e35bb76f45 --- /dev/null +++ b/src/Samples/Common/Views/FeatureSamples/JavascriptTranslation/DateOnlyTranslations.dothtml @@ -0,0 +1,36 @@ +@viewModel DotVVM.Samples.Common.ViewModels.FeatureSamples.JavascriptTranslation.DateTimeTranslationsViewModel, DotVVM.Samples.Common +@import System + + + + + + + + + + + +

DateOnly testing

+ + + + +

+ DateOnly.ToString: + +

+

+ DateOnly properties: + + {{value: DateOnly.FromDateTime(NullableDateTimeProp).Day}}. {{value: DateOnly.FromDateTime(NullableDateTimeProp).Month}}. {{value: DateOnly.FromDateTime(NullableDateTimeProp).Year}} + +

+ + + + diff --git a/src/Samples/Common/Views/FeatureSamples/JavascriptTranslation/DateTimeTranslations.dothtml b/src/Samples/Common/Views/FeatureSamples/JavascriptTranslation/DateTimeTranslations.dothtml index d5500c9643..626044f233 100644 --- a/src/Samples/Common/Views/FeatureSamples/JavascriptTranslation/DateTimeTranslations.dothtml +++ b/src/Samples/Common/Views/FeatureSamples/JavascriptTranslation/DateTimeTranslations.dothtml @@ -24,11 +24,19 @@ TimeShift.setTimezoneOffset(-120); }()) +

DateTime testing

+ + +

Year: diff --git a/src/Samples/Common/Views/FeatureSamples/JavascriptTranslation/TimeOnlyTranslations.dothtml b/src/Samples/Common/Views/FeatureSamples/JavascriptTranslation/TimeOnlyTranslations.dothtml new file mode 100644 index 0000000000..3741b97752 --- /dev/null +++ b/src/Samples/Common/Views/FeatureSamples/JavascriptTranslation/TimeOnlyTranslations.dothtml @@ -0,0 +1,35 @@ +@viewModel DotVVM.Samples.Common.ViewModels.FeatureSamples.JavascriptTranslation.DateTimeTranslationsViewModel, DotVVM.Samples.Common +@import System + + + + + + + + + + + +

TimeOnly testing

+ + + +

+ TimeOnly.ToString: + +

+

+ TimeOnly properties: + + {{value: TimeOnly.FromDateTime(NullableDateTimeProp).Hour}} hours {{value: TimeOnly.FromDateTime(NullableDateTimeProp).Minute}} minues {{value: TimeOnly.FromDateTime(NullableDateTimeProp).Second}} seconds and {{value: TimeOnly.FromDateTime(NullableDateTimeProp).Millisecond}} milliseconds + + + + + + diff --git a/src/Samples/Tests/Abstractions/SamplesRouteUrls.designer.cs b/src/Samples/Tests/Abstractions/SamplesRouteUrls.designer.cs index 95f4e37fea..ff8f0ccc1b 100644 --- a/src/Samples/Tests/Abstractions/SamplesRouteUrls.designer.cs +++ b/src/Samples/Tests/Abstractions/SamplesRouteUrls.designer.cs @@ -264,12 +264,14 @@ public partial class SamplesRouteUrls public const string FeatureSamples_IdGeneration_IdGeneration = "FeatureSamples/IdGeneration/IdGeneration"; public const string FeatureSamples_JavascriptEvents_JavascriptEvents = "FeatureSamples/JavascriptEvents/JavascriptEvents"; public const string FeatureSamples_JavascriptTranslation_ArrayTranslation = "FeatureSamples/JavascriptTranslation/ArrayTranslation"; + public const string FeatureSamples_JavascriptTranslation_DateOnlyTranslations = "FeatureSamples/JavascriptTranslation/DateOnlyTranslations"; public const string FeatureSamples_JavascriptTranslation_DateTimeTranslations = "FeatureSamples/JavascriptTranslation/DateTimeTranslations"; public const string FeatureSamples_JavascriptTranslation_DictionaryIndexerTranslation = "FeatureSamples/JavascriptTranslation/DictionaryIndexerTranslation"; public const string FeatureSamples_JavascriptTranslation_GenericMethodTranslation = "FeatureSamples/JavascriptTranslation/GenericMethodTranslation"; public const string FeatureSamples_JavascriptTranslation_ListIndexerTranslation = "FeatureSamples/JavascriptTranslation/ListIndexerTranslation"; public const string FeatureSamples_JavascriptTranslation_ListMethodTranslations = "FeatureSamples/JavascriptTranslation/ListMethodTranslations"; public const string FeatureSamples_JavascriptTranslation_MathMethodTranslation = "FeatureSamples/JavascriptTranslation/MathMethodTranslation"; + public const string FeatureSamples_JavascriptTranslation_TimeOnlyTranslations = "FeatureSamples/JavascriptTranslation/TimeOnlyTranslations"; public const string FeatureSamples_JavascriptTranslation_StringMethodTranslations = "FeatureSamples/JavascriptTranslation/StringMethodTranslations"; public const string FeatureSamples_JavascriptTranslation_WebUtilityTranslations = "FeatureSamples/JavascriptTranslation/WebUtilityTranslations"; public const string FeatureSamples_JsComponentIntegration_ReactComponentIntegration = "FeatureSamples/JsComponentIntegration/ReactComponentIntegration"; diff --git a/src/Samples/Tests/Tests/Feature/DateTimeTranslationTests.cs b/src/Samples/Tests/Tests/Feature/DateTimeTranslationTests.cs index 70ca58aa4a..ae6b2a3dcb 100644 --- a/src/Samples/Tests/Tests/Feature/DateTimeTranslationTests.cs +++ b/src/Samples/Tests/Tests/Feature/DateTimeTranslationTests.cs @@ -68,6 +68,43 @@ public void Feature_DateTime_PropertyTranslations() }); } + [Fact] + public void Feature_DateOnly_PropertyTranslations() + { + RunInAllBrowsers(browser => { + browser.NavigateToUrl(SamplesRouteUrls.FeatureSamples_JavascriptTranslation_DateOnlyTranslations); + + var stringDateTime = "6/28/2021 3:28:31 PM"; + + var textbox = browser.Single("input[data-ui=textbox]"); + textbox.Clear().SendKeys(stringDateTime).SendEnterKey(); + + var str = browser.Single("span[data-ui=dateOnlyToString]"); + AssertUI.TextEquals(str, "Monday, June 28, 2021"); + var props = browser.Single("span[data-ui=dateOnlyProperties]"); + AssertUI.TextEquals(props, "28. 6. 2021"); + }); + } + + [Fact] + public void Feature_TimeOnly_PropertyTranslations() + { + RunInAllBrowsers(browser => { + browser.NavigateToUrl(SamplesRouteUrls.FeatureSamples_JavascriptTranslation_TimeOnlyTranslations); + + var stringDateTime = "6/28/2021 3:28:31 PM"; + + var textbox = browser.Single("input[data-ui=textbox]"); + textbox.Clear().SendKeys(stringDateTime).SendEnterKey(); + + var str = browser.Single("span[data-ui=timeOnlyToString]"); + AssertUI.TextEquals(str, "3:28:31 PM"); + var props = browser.Single("span[data-ui=timeOnlyProperties]"); + AssertUI.TextEquals(props, "15 hours 28 minues 31 seconds and 0 milliseconds"); + }); + } + + public DateTimeTranslationTests(ITestOutputHelper output) : base(output) { } diff --git a/src/Tests/Binding/JavascriptCompilationTests.cs b/src/Tests/Binding/JavascriptCompilationTests.cs index d387558513..7b9aa99c94 100644 --- a/src/Tests/Binding/JavascriptCompilationTests.cs +++ b/src/Tests/Binding/JavascriptCompilationTests.cs @@ -1112,6 +1112,17 @@ public void JsTranslator_DateTime_Property_Getters(string binding, string jsFunc Assert.AreEqual($"dotvvm.serialization.parseDate(DateTime()).{jsFunction}(){(increment ? "+1" : string.Empty)}", result); } + [TestMethod] + [DataRow("DateTime.Now", "dotvvm.serialization.serializeDate(new Date(),false)")] + [DataRow("DateTime.UtcNow", "dotvvm.serialization.serializeDate(new Date(),true)")] + [DataRow("DateTime.Today", "dotvvm.serialization.serializeDate(new Date(),false).substring(0,10)+\"T00:00:00.000\"")] + public void JsTranslator_DateTime_Now(string binding, string expected) + { + var result = CompileBinding(binding); + Assert.AreEqual(expected, result); + } + + [TestMethod] [DataRow("DateOnly.ToString()", "")] [DataRow("DateOnly.ToString('D')", "\"D\"")] @@ -1129,6 +1140,16 @@ public void JsTranslator_NullableDateOnly_ToString(string binding, string args) Assert.AreEqual($"dotvvm.globalize.bindingDateOnlyToString(NullableDateOnly{((args.Length > 0) ? $",{args}" : string.Empty)})", result); } + [DataTestMethod] + [DataRow("DateOnly.Year", "getFullYear()")] + [DataRow("DateOnly.Month", "getMonth()+1")] + [DataRow("DateOnly.Day", "getDate()")] + public void JsTranslator_DateOnly_Property_Getters(string binding, string jsFunction) + { + var result = CompileBinding(binding, new[] { typeof(TestViewModel) }); + Assert.AreEqual($"dotvvm.serialization.parseDateOnly(DateOnly()).{jsFunction}", result); + } + [TestMethod] [DataRow("TimeOnly.ToString()", "")] [DataRow("TimeOnly.ToString('T')", "\"T\"")] @@ -1146,6 +1167,17 @@ public void JsTranslator_NullableTimeOnly_ToString(string binding, string args) Assert.AreEqual($"dotvvm.globalize.bindingTimeOnlyToString(NullableTimeOnly{((args.Length > 0) ? $",{args}" : string.Empty)})", result); } + [DataTestMethod] + [DataRow("TimeOnly.Hour", "getHours()")] + [DataRow("TimeOnly.Minute", "getMinutes()")] + [DataRow("TimeOnly.Second", "getSeconds()")] + [DataRow("TimeOnly.Millisecond", "getMilliseconds()")] + public void JsTranslator_TimeOnly_Property_Getters(string binding, string jsFunction) + { + var result = CompileBinding(binding, new[] { typeof(TestViewModel) }); + Assert.AreEqual($"dotvvm.serialization.parseTimeOnly(TimeOnly()).{jsFunction}", result); + } + [TestMethod] public void JsTranslator_WebUtility_UrlEncode() { From 3a3f38cc87195433a8eebd95c3e946610d70693c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Standa=20Luke=C5=A1?= Date: Sun, 18 Aug 2024 15:22:22 +0200 Subject: [PATCH 27/40] Fix view compilation on OWIN (optional log) --- .../Framework/Compilation/DotvvmViewCompilationService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Framework/Framework/Compilation/DotvvmViewCompilationService.cs b/src/Framework/Framework/Compilation/DotvvmViewCompilationService.cs index f70979677c..6d633f2c8a 100644 --- a/src/Framework/Framework/Compilation/DotvvmViewCompilationService.cs +++ b/src/Framework/Framework/Compilation/DotvvmViewCompilationService.cs @@ -138,7 +138,7 @@ public async Task CompileAll(bool buildInParallel = true, bool forceRecomp await ExecuteCompileTasks(compileTasks, maxParallelism); } - log.LogInformation("Compiled {0} DotHTML files on {1} threads in {2} s", totalCompiledFiles, maxParallelism, sw.ElapsedSeconds); + log?.LogInformation("Compiled {0} DotHTML files on {1} threads in {2} s", totalCompiledFiles, maxParallelism, sw.ElapsedSeconds); } finally { From 75cb7100fe57ef14dca0ac8c58707e90c4920210 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Standa=20Luke=C5=A1?= Date: Wed, 28 Aug 2024 13:01:57 +0200 Subject: [PATCH 28/40] Fix and maybe optimize CopyProperty for inherited properties CopyProperty used the HasBinding method which only checks if the current control has that property set. See https://forum.dotvvm.com/t/bp-gridviewtemplatecolumn-how-to-filter/230/4 for some context --- .../Controls/DotvvmBindableObject.cs | 29 ++- .../Runtime/DotvvmBindableObjectTests.cs | 174 ++++++++++++++++++ 2 files changed, 194 insertions(+), 9 deletions(-) create mode 100644 src/Tests/Runtime/DotvvmBindableObjectTests.cs diff --git a/src/Framework/Framework/Controls/DotvvmBindableObject.cs b/src/Framework/Framework/Controls/DotvvmBindableObject.cs index cb45b1d0a6..751b9095dc 100644 --- a/src/Framework/Framework/Controls/DotvvmBindableObject.cs +++ b/src/Framework/Framework/Controls/DotvvmBindableObject.cs @@ -403,24 +403,35 @@ public virtual IEnumerable GetLogicalChildren() /// The that holds the value of the /// The to which will be copied /// Determines whether to throw an exception if copying fails - protected void CopyProperty(DotvvmProperty sourceProperty, DotvvmBindableObject target, DotvvmProperty targetProperty, bool throwOnFailure = false) + protected internal void CopyProperty(DotvvmProperty sourceProperty, DotvvmBindableObject target, DotvvmProperty targetProperty, bool throwOnFailure = false) { - if (throwOnFailure && !targetProperty.MarkupOptions.AllowBinding && !targetProperty.MarkupOptions.AllowHardCodedValue) + var targetOptions = targetProperty.MarkupOptions; + if (throwOnFailure && !targetOptions.AllowBinding && !targetOptions.AllowHardCodedValue) { throw new DotvvmControlException(this, $"TargetProperty: {targetProperty.FullName} doesn't allow bindings nor hard coded values"); } - if (targetProperty.MarkupOptions.AllowBinding && HasBinding(sourceProperty)) + if (IsPropertySet(sourceProperty)) { - target.SetBinding(targetProperty, GetBinding(sourceProperty)); - } - else if (targetProperty.MarkupOptions.AllowHardCodedValue && IsPropertySet(sourceProperty)) - { - target.SetValue(targetProperty, GetValue(sourceProperty)); + var sourceValue = GetValueRaw(sourceProperty); + if ((targetOptions.AllowBinding || sourceValue is not IBinding) && + (targetOptions.AllowHardCodedValue || sourceValue is IBinding)) + { + target.SetValueRaw(targetProperty, sourceValue); + } + else if (targetOptions.AllowHardCodedValue) + { + target.SetValue(targetProperty, EvalPropertyValue(sourceProperty, sourceValue)); + } + else if (throwOnFailure) + { + throw new DotvvmControlException(this, $"Value of {sourceProperty.FullName} couldn't be copied to targetProperty: {targetProperty.FullName}, because {targetProperty.FullName} does not support hard coded values."); + } } + else if (throwOnFailure) { - throw new DotvvmControlException(this, $"Value of {sourceProperty.FullName} couldn't be copied to targetProperty: {targetProperty.FullName}, because {targetProperty.FullName} is not set."); + throw new DotvvmControlException(this, $"Value of {sourceProperty.FullName} couldn't be copied to targetProperty: {targetProperty.FullName}, because {sourceProperty.FullName} is not set."); } } } diff --git a/src/Tests/Runtime/DotvvmBindableObjectTests.cs b/src/Tests/Runtime/DotvvmBindableObjectTests.cs new file mode 100644 index 0000000000..a3f8b7b23b --- /dev/null +++ b/src/Tests/Runtime/DotvvmBindableObjectTests.cs @@ -0,0 +1,174 @@ +using System.Threading.Tasks; +using DotVVM.Framework.Binding; +using DotVVM.Framework.Compilation.ControlTree; +using DotVVM.Framework.Controls; +using DotVVM.Framework.Testing; +using DotVVM.Framework.Tests.Binding; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace DotVVM.Framework.Tests.Runtime +{ + [TestClass] + public class DotvvmBindableObjectTests + { + readonly BindingCompilationService bindingService = DotvvmTestHelper.DefaultConfig.ServiceProvider.GetRequiredService(); + readonly DataContextStack dataContext = DataContextStack.Create(typeof(TestViewModel)); + + [TestMethod] + public void CopyProperty_Error_NotSet() + { + var source = new HtmlGenericControl("div"); + var target = new HtmlGenericControl("div"); + + var ex = Assert.ThrowsException(() => source.CopyProperty(HtmlGenericControl.VisibleProperty, target, HtmlGenericControl.VisibleProperty, throwOnFailure: true)); + StringAssert.Contains(ex.Message, "Visible is not set"); + } + + [TestMethod] + public void CopyProperty_Nop_NotSet() + { + var source = new HtmlGenericControl("div"); + var target = new HtmlGenericControl("div"); + + source.CopyProperty(HtmlGenericControl.VisibleProperty, target, HtmlGenericControl.VisibleProperty); // throwOnFailure: false is default + Assert.IsFalse(target.IsPropertySet(HtmlGenericControl.VisibleProperty)); + } + + [TestMethod] + public void CopyProperty_Copy_Value() + { + var source = new HtmlGenericControl("div"); + source.SetValue(HtmlGenericControl.VisibleProperty, (object)false); + var target = new HtmlGenericControl("div"); + source.CopyProperty(HtmlGenericControl.VisibleProperty, target, HtmlGenericControl.VisibleProperty); + + Assert.IsFalse(target.GetValue(HtmlGenericControl.VisibleProperty)); + Assert.AreSame(source.GetValue(HtmlGenericControl.VisibleProperty), target.GetValue(HtmlGenericControl.VisibleProperty)); + } + + [TestMethod] + public void CopyProperty_Copy_Binding() + { + var source = new HtmlGenericControl("div"); + source.DataContext = new TestViewModel { IntProp = 0 }; + source.SetValue(Internal.DataContextTypeProperty, dataContext); + source.SetValue(HtmlGenericControl.VisibleProperty, bindingService.Cache.CreateValueBinding("IntProp == 12", dataContext)); + var target = new HtmlGenericControl("div"); + source.CopyProperty(HtmlGenericControl.VisibleProperty, target, HtmlGenericControl.VisibleProperty); + target.DataContext = source.DataContext; + + Assert.IsFalse(source.GetValue(HtmlGenericControl.VisibleProperty)); + Assert.IsFalse(target.GetValue(HtmlGenericControl.VisibleProperty)); + Assert.AreSame(source.GetValue(HtmlGenericControl.VisibleProperty), target.GetValue(HtmlGenericControl.VisibleProperty)); + } + + [TestMethod] + public void CopyProperty_EvalBinding() + { + var source = new HtmlGenericControl("div"); + source.DataContext = new TestViewModel { IntProp = 0 }; + source.SetValue(Internal.DataContextTypeProperty, dataContext); + source.SetValue(HtmlGenericControl.VisibleProperty, bindingService.Cache.CreateValueBinding("IntProp == 12", dataContext)); + + Assert.IsFalse(Button.IsSubmitButtonProperty.MarkupOptions.AllowBinding); + var target = new Button(); + source.CopyProperty(HtmlGenericControl.VisibleProperty, target, Button.IsSubmitButtonProperty); + target.DataContext = source.DataContext; + + Assert.IsFalse(target.IsSubmitButton); + Assert.AreEqual(false, target.GetValueRaw(Button.IsSubmitButtonProperty)); + } + + [TestMethod] + public void CopyProperty_Error_ValueToBinding() + { + var source = new HtmlGenericControl("div"); + source.SetValue(HtmlGenericControl.VisibleProperty, (object)false); + Assert.IsFalse(CheckBox.CheckedProperty.MarkupOptions.AllowHardCodedValue); + var target = new CheckBox(); + + var ex = Assert.ThrowsException(() => + source.CopyProperty(HtmlGenericControl.VisibleProperty, target, CheckBox.CheckedProperty, throwOnFailure: true)); + StringAssert.Contains(ex.Message, "Checked does not support hard coded values"); + } + + [TestMethod] + public void CopyProperty_Nop_ValueToBinding() + { + // TODO: this is a weird behavior, I'd consider changing it in a future major version + var source = new HtmlGenericControl("div"); + source.SetValue(HtmlGenericControl.VisibleProperty, (object)false); + Assert.IsFalse(CheckBox.CheckedProperty.MarkupOptions.AllowHardCodedValue); + var target = new CheckBox(); + + source.CopyProperty(HtmlGenericControl.VisibleProperty, target, CheckBox.CheckedProperty); + Assert.IsFalse(target.IsPropertySet(CheckBox.CheckedProperty)); + } + + [TestMethod] + public void CopyProperty_Copy_InheritedBinding() + { + var sourceParent = new HtmlGenericControl("div"); + sourceParent.DataContext = new TestViewModel { IntProp = 0 }; + sourceParent.SetValue(Internal.DataContextTypeProperty, dataContext); + sourceParent.SetValue(Validation.EnabledProperty, bindingService.Cache.CreateValueBinding("IntProp == 12", dataContext)); + var source = new HtmlGenericControl("div"); + sourceParent.Children.Add(source); + + var target = new HtmlGenericControl("div"); + source.CopyProperty(Validation.EnabledProperty, target, Validation.EnabledProperty); + + Assert.AreSame(sourceParent.GetValue(Validation.EnabledProperty), target.GetValue(Validation.EnabledProperty)); + } + + [TestMethod] + public void CopyProperty_Copy_InheritedValue() + { + var sourceParent = new HtmlGenericControl("div"); + sourceParent.SetValue(Validation.EnabledProperty, (object)false); + var source = new HtmlGenericControl("div"); + sourceParent.Children.Add(source); + + var target = new HtmlGenericControl("div"); + source.CopyProperty(Validation.EnabledProperty, target, Validation.EnabledProperty); + + Assert.AreSame(sourceParent.GetValue(Validation.EnabledProperty), target.GetValue(Validation.EnabledProperty)); + } + + + [TestMethod] + public void CopyProperty_Copy_FormControlsEnabledBinding() + { + var sourceParent = new HtmlGenericControl("div"); + sourceParent.DataContext = new TestViewModel { IntProp = 0 }; + sourceParent.SetValue(Internal.DataContextTypeProperty, dataContext); + sourceParent.SetValue(FormControls.EnabledProperty, bindingService.Cache.CreateValueBinding("IntProp == 12", dataContext)); + var source = new TextBox(); + sourceParent.Children.Add(source); + + var target = new TextBox(); + source.CopyProperty(TextBox.EnabledProperty, target, TextBox.EnabledProperty); + target.DataContext = source.DataContext; + + Assert.AreSame(sourceParent.GetValue(FormControls.EnabledProperty), target.GetValue(TextBox.EnabledProperty)); + Assert.AreSame(source.GetValue(TextBox.EnabledProperty), target.GetValue(TextBox.EnabledProperty)); + } + + [TestMethod] + public void CopyProperty_Copy_FormControlsEnabledValue() + { + var sourceParent = new HtmlGenericControl("div"); + sourceParent.SetValue(FormControls.EnabledProperty, (object)false); + var source = new TextBox(); + sourceParent.Children.Add(source); + + var target = new TextBox(); + source.CopyProperty(TextBox.EnabledProperty, target, TextBox.EnabledProperty); + + Assert.AreSame(sourceParent.GetValue(FormControls.EnabledProperty), target.GetValue(TextBox.EnabledProperty)); + Assert.AreSame(source.GetValue(TextBox.EnabledProperty), target.GetValue(TextBox.EnabledProperty)); + } + + } +} From 1f0605991590103ec13ac4c71905ab1b6338e10f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Standa=20Luke=C5=A1?= Date: Wed, 27 Sep 2023 00:03:01 +0200 Subject: [PATCH 29/40] Optimize html encoding using .NET IndexOf --- .../Framework/Controls/HtmlWriter.cs | 216 ++++++++++++------ 1 file changed, 152 insertions(+), 64 deletions(-) diff --git a/src/Framework/Framework/Controls/HtmlWriter.cs b/src/Framework/Framework/Controls/HtmlWriter.cs index 7c97e590f9..df72417106 100644 --- a/src/Framework/Framework/Controls/HtmlWriter.cs +++ b/src/Framework/Framework/Controls/HtmlWriter.cs @@ -26,7 +26,7 @@ public class HtmlWriter : IHtmlWriter private readonly bool debug; private readonly bool enableWarnings; - private List<(string name, string? val, string? separator, bool allowAppending)> attributes = new List<(string, string?, string? separator, bool allowAppending)>(); + private readonly List<(string name, string? val, string? separator, bool allowAppending)> attributes = new List<(string, string?, string? separator, bool allowAppending)>(); private DotvvmBindableObject? errorContext; private OrderedDictionary dataBindAttributes = new OrderedDictionary(); private Stack openTags = new Stack(); @@ -435,7 +435,6 @@ private int CountQuotesAndApos(string value) return result; } - private void WriteEncodedText(string input, bool escapeQuotes, bool escapeApos) { int index = 0; @@ -449,7 +448,6 @@ private void WriteEncodedText(string input, bool escapeQuotes, bool escapeApos) writer.Write(input); return; } - #if NoSpan writer.Write(input.Substring(startIndex)); #else @@ -464,81 +462,171 @@ private void WriteEncodedText(string input, bool escapeQuotes, bool escapeApos) #else writer.Write(input.AsSpan().Slice(startIndex, index - startIndex)); #endif - switch (input[index]) - { - case '<': - writer.Write("<"); - break; - case '>': - writer.Write(">"); - break; - case '"': - writer.Write("""); - break; - case '\'': - writer.Write("'"); - break; - case '&': - writer.Write("&"); - break; - default: - throw new Exception("Should not happen."); - } + var encoding = EncodingTable[input[index] - 34]; + Debug.Assert(encoding != null); + writer.Write(encoding); + index++; + if (index == input.Length) + return; } } } + static string?[] EncodingTable = new List(Enumerable.Repeat((string?)null, 63 - 34)) { + [34 - 34] = """, // " + [39 - 34] = "'", // ' + [60 - 34] = "<", // < + [62 - 34] = ">", // > + [38 - 34] = "&" // & + }.ToArray(); + + + // private static int IndexOfHtmlEncodingChars(string input, int startIndex, bool escapeQuotes, bool escapeApos) + // { + // for (int i = startIndex; i < input.Length; i++) + // { + // char ch = input[i]; + // if (ch <= '>') + // { + // switch (ch) + // { + // case '<': + // case '>': + // return i; + // case '"': + // if (escapeQuotes) + // return i; + // break; + // case '\'': + // if (escapeApos) + // return i; + // break; + // case '&': + // // HTML spec permits ampersands, if they are not ambiguous: + + // // An ambiguous ampersand is a U+0026 AMPERSAND character (&) that is followed by one or more ASCII alphanumerics, followed by a U+003B SEMICOLON character (;), where these characters do not match any of the names given in the named character references section. + + // // so if the next character is not alphanumeric, we can leave it there + // if (i == input.Length) + // return i; + // var nextChar = input[i + 1]; + // if (IsInRange(nextChar, 'a', 'z') || + // IsInRange(nextChar, 'A', 'Z') || + // IsInRange(nextChar, '0', '9') || + // nextChar == '#') + // return i; + // break; + // } + // } + // else if (char.IsSurrogate(ch)) + // { + // // surrogates are fine, but they must not code for ASCII characters + + // var value = Char.ConvertToUtf32(ch, input[i + 1]); + // if (value < 256) + // throw new InvalidOperationException("Encountered UTF16 surrogate coding for ASCII char, this is not allowed."); + + // i++; + // } + // } + + // return -1; + // } + + private static char[] MinimalEscapeChars = new char[] { '<', '>', '&' }; + private static char[] DoubleQEscapeChars = new char[] { '<', '>', '&', '"' }; + private static char[] SingleQEscapeChars = new char[] { '<', '>', '&', '\'' }; + private static char[] BothQEscapeChars = new char[] { '<', '>', '&', '"', '\'' }; + private static int IndexOfHtmlEncodingChars(string input, int startIndex, bool escapeQuotes, bool escapeApos) { - for (int i = startIndex; i < input.Length; i++) + char[] breakChars; + if (escapeQuotes) { - char ch = input[i]; - if (ch <= '>') + if (escapeApos) + breakChars = BothQEscapeChars; + else + breakChars = DoubleQEscapeChars; + } + else if (escapeApos) + { + breakChars = SingleQEscapeChars; + } + else + { + breakChars = MinimalEscapeChars; + } + // Span breakChars = stackalloc char[5]; + // breakChars[0] = '<'; + // breakChars[1] = '>'; + // breakChars[2] = '&'; + // if (escapeQuotes && escapeApos) + // { + // breakChars[3] = '"'; + // breakChars[4] = '\''; + // } + // else if (escapeQuotes) + // { + // breakChars[3] = '"'; + // } + // else if (escapeApos) + // { + // breakChars[3] = '\''; + // } + // breakChars = breakChars.Slice(0, 3 + (escapeQuotes ? 1 : 0) + (escapeApos ? 1 : 0)); + + int i = startIndex; + while (true) + { + var foundIndex = MemoryExtensions.IndexOfAny(input.AsSpan(start: i), breakChars); + // var stopIndex = foundIndex < 0 ? input.Length : foundIndex + i + 1; + // check for surrogates + // for (int j = i; j < stopIndex; j++) + // { + // if (char.IsSurrogate(input[j])) + // { + // CheckSurrogate(input, j); + // j++; + // } + // } + if (foundIndex < 0) + return -1; + + i += foundIndex; + + if (input[i] == '&' && i + 1 < input.Length) { - switch (ch) - { - case '<': - case '>': - return i; - case '"': - if (escapeQuotes) - return i; - break; - case '\'': - if (escapeApos) - return i; - break; - case '&': - // HTML spec permits ampersands, if they are not ambiguous: - - // An ambiguous ampersand is a U+0026 AMPERSAND character (&) that is followed by one or more ASCII alphanumerics, followed by a U+003B SEMICOLON character (;), where these characters do not match any of the names given in the named character references section. - - // so if the next character is not alphanumeric, we can leave it there - if (i + 1 == input.Length) - return i; - var nextChar = input[i + 1]; - if (IsInRange(nextChar, 'a', 'z') || - IsInRange(nextChar, 'A', 'Z') || - IsInRange(nextChar, '0', '9') || - nextChar == '#') - return i; - break; - } + // HTML spec permits ampersands, if they are not ambiguous: + // (and unnecessarily quoting them makes JS less readable) + + // An ambiguous ampersand is a U+0026 AMPERSAND character (&) that is followed by one or more ASCII alphanumerics, followed by a U+003B SEMICOLON character (;), where these characters do not match any of the names given in the named character references section. + + // so if the next character is not alphanumeric, we can leave it there + var nextChar = input[i + 1]; + if (IsInRange(nextChar, 'a', 'z') | + IsInRange(nextChar, 'A', 'Z') | + IsInRange(nextChar, '0', '9') | + nextChar == '#') + return i; } - else if (char.IsSurrogate(ch)) + else { - // surrogates are fine, but they must not code for ASCII characters + // all other characters are escaped unconditionaly + return i; + } - var value = Char.ConvertToUtf32(ch, input[i + 1]); - if (value < 256) - throw new InvalidOperationException("Encountered UTF16 surrogate coding for ASCII char, this is not allowed."); + i++; + } - i++; - } + [MethodImpl(MethodImplOptions.NoInlining)] // this case is rare, it's better to be separate from the hot path + static void CheckSurrogate(string input, int j) + { + // surrogates are fine, but they must not code for ASCII characters + var value = Char.ConvertToUtf32(input[j], input[j + 1]); + if (value < 256) + throw new InvalidOperationException("Encountered UTF16 surrogate coding for ASCII char, this is not allowed."); } - - return -1; } private void ThrowIfAttributesArePresent([CallerMemberName] string operation = "Write") From 99cbaf14f367135c7ba68be3278d79d1c2d7f8de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Standa=20Luke=C5=A1?= Date: Wed, 27 Sep 2023 17:33:58 +0200 Subject: [PATCH 30/40] Small performance improvements in binding formatting --- .../Framework/Binding/BindingHelper.cs | 6 ++-- .../Javascript/JavascriptTranslator.cs | 28 ++++++++++++------- .../Javascript/ParametrizedCode.cs | 19 ++++++++----- .../Controls/DotvvmBindableObject.cs | 19 +++++++++++-- .../Framework/Controls/KnockoutHelper.cs | 19 ++++++++----- .../InlineScriptResource.cs | 2 +- .../InlineStylesheetResource.cs | 2 +- .../ResourceManagement/TemplateResource.cs | 2 +- 8 files changed, 64 insertions(+), 33 deletions(-) diff --git a/src/Framework/Framework/Binding/BindingHelper.cs b/src/Framework/Framework/Binding/BindingHelper.cs index eb1a4719b0..1d9c6a2b79 100644 --- a/src/Framework/Framework/Binding/BindingHelper.cs +++ b/src/Framework/Framework/Binding/BindingHelper.cs @@ -63,11 +63,11 @@ public static string FormatKnockoutScript(this ParametrizedCode code, DotvvmBind /// Gets Internal.PathFragmentProperty or DataContext.KnockoutExpression. Returns null if none of these is set. /// public static string? GetDataContextPathFragment(this DotvvmBindableObject currentControl) => - (string?)currentControl.GetValue(Internal.PathFragmentProperty, inherit: false) ?? - (currentControl.GetBinding(DotvvmBindableObject.DataContextProperty, inherit: false) is IValueBinding binding ? + currentControl.properties.TryGet(Internal.PathFragmentProperty, out var pathFragment) && pathFragment is string pathFragmentStr ? pathFragmentStr : + currentControl.properties.TryGet(DotvvmBindableObject.DataContextProperty, out var dataContext) && dataContext is IValueBinding binding ? binding.GetProperty() .Code.FormatKnockoutScript(currentControl, binding) : - null); + null; // PERF: maybe safe last GetValue's target/binding to ThreadLocal variable, so the path does not have to be traversed twice diff --git a/src/Framework/Framework/Compilation/Javascript/JavascriptTranslator.cs b/src/Framework/Framework/Compilation/Javascript/JavascriptTranslator.cs index 83957e3fcf..5beb58f288 100644 --- a/src/Framework/Framework/Compilation/Javascript/JavascriptTranslator.cs +++ b/src/Framework/Framework/Compilation/Javascript/JavascriptTranslator.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; @@ -147,7 +147,7 @@ public static ParametrizedCode AdjustKnockoutScriptContext(ParametrizedCode expr { if (dataContextLevel == 0) return expression; return expression.AssignParameters(o => - o is ViewModelSymbolicParameter vm ? GetKnockoutViewModelParameter(vm.ParentIndex + dataContextLevel).ToParametrizedCode() : + o is ViewModelSymbolicParameter vm ? GetKnockoutViewModelParameter(vm.ParentIndex + dataContextLevel, vm.ReturnObservable).ToParametrizedCode() : o is ContextSymbolicParameter context ? GetKnockoutContextParameter(context.ParentIndex + dataContextLevel).ToParametrizedCode() : o == CommandBindingExpression.OptionalKnockoutContextParameter ? GetKnockoutContextParameter(dataContextLevel).ToParametrizedCode() : default @@ -164,14 +164,22 @@ public static string FormatKnockoutScript(JsExpression expression, bool allowDat /// public static string FormatKnockoutScript(ParametrizedCode expression, bool allowDataGlobal = true, int dataContextLevel = 0) { - // TODO(exyi): more symbolic parameters - var adjusted = AdjustKnockoutScriptContext(expression, dataContextLevel); - if (allowDataGlobal) - return adjusted.ToDefaultString(); - else - return adjusted.ToString(o => - o == KnockoutViewModelParameter ? CodeParameterAssignment.FromIdentifier("$data") : - default); + if (dataContextLevel == 0) + { + if (allowDataGlobal) + return expression.ToDefaultString(); + else + return expression.ToString(o => + o == KnockoutViewModelParameter ? CodeParameterAssignment.FromIdentifier("$data") : + default); + + } + return expression.ToString(o => + o is ViewModelSymbolicParameter vm ? GetKnockoutViewModelParameter(vm.ParentIndex + dataContextLevel, vm.ReturnObservable).DefaultAssignment : + o is ContextSymbolicParameter context ? GetKnockoutContextParameter(context.ParentIndex + dataContextLevel).DefaultAssignment : + o == CommandBindingExpression.OptionalKnockoutContextParameter ? GetKnockoutContextParameter(dataContextLevel).DefaultAssignment : + default + ); } ///

diff --git a/src/Framework/Framework/Compilation/Javascript/ParametrizedCode.cs b/src/Framework/Framework/Compilation/Javascript/ParametrizedCode.cs index 95a0941035..8e67eb4639 100644 --- a/src/Framework/Framework/Compilation/Javascript/ParametrizedCode.cs +++ b/src/Framework/Framework/Compilation/Javascript/ParametrizedCode.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Linq; using System.Collections.Generic; using System.Text; @@ -64,12 +64,17 @@ public string ToString(Func para if (allIsDefault && this.evaluatedDefault != null) return evaluatedDefault; - var sb = new StringBuilder(codes.Sum((p) => p.code.Length) + stringParts.Sum(p => p.Length)); + var capacity = 0; + foreach (var c in codes) capacity += c.code.Length; + foreach (var s in stringParts) capacity += s.Length; + var sb = new StringBuilder(capacity); + sb.Append(stringParts[0]); for (int i = 0; i < codes.Length;) { - var isGlobalContext = codes[i].parameter.IsGlobalContext && parameters![i].IsSafeMemberAccess; - var needsParens = codes[i].parameter.Code!.OperatorPrecedence.NeedsParens(parameters![i].OperatorPrecedence); + var code = codes[i]; + var isGlobalContext = code.parameter.IsGlobalContext && parameters![i].IsSafeMemberAccess; + var needsParens = code.parameter.Code!.OperatorPrecedence.NeedsParens(parameters![i].OperatorPrecedence); if (isGlobalContext) sb.Append(stringParts[++i], 1, stringParts[i].Length - 1); // skip `.` @@ -77,9 +82,9 @@ public string ToString(Func para { if (needsParens) sb.Append("("); - else if (JsFormattingVisitor.NeedSpaceBetween(sb, codes[i].code)) + else if (JsFormattingVisitor.NeedSpaceBetween(sb, code.code)) sb.Append(" "); - sb.Append(codes[i].code); + sb.Append(code.code); i++; if (needsParens) sb.Append(")"); else if (JsFormattingVisitor.NeedSpaceBetween(sb, stringParts[i])) @@ -177,7 +182,7 @@ public void CopyTo(Builder builder) builder.Add(stringParts[i]); builder.Add(parameters[i]); } - builder.Add(stringParts.Last()); + builder.Add(stringParts[stringParts.Length - 1]); } } diff --git a/src/Framework/Framework/Controls/DotvvmBindableObject.cs b/src/Framework/Framework/Controls/DotvvmBindableObject.cs index cb45b1d0a6..230a8baf59 100644 --- a/src/Framework/Framework/Controls/DotvvmBindableObject.cs +++ b/src/Framework/Framework/Controls/DotvvmBindableObject.cs @@ -274,14 +274,27 @@ internal IEnumerable GetDataContextHierarchy() /// /// Gets the closest control binding target. Returns null if the control is not found. /// - public DotvvmBindableObject? GetClosestControlBindingTarget() => - GetClosestControlBindingTarget(out int numberOfDataContextChanges); + public DotvvmBindableObject? GetClosestControlBindingTarget() + { + var c = this; + while (c != null) + { + if (c.properties.TryGet(Internal.IsControlBindingTargetProperty, out var x) && (bool)x!) + { + return c; + } + c = c.Parent; + } + return null; + } /// /// Gets the closest control binding target and returns number of DataContext changes since the target. Returns null if the control is not found. /// public DotvvmBindableObject? GetClosestControlBindingTarget(out int numberOfDataContextChanges) => - (Parent ?? this).GetClosestWithPropertyValue(out numberOfDataContextChanges, (control, _) => (bool)control.GetValue(Internal.IsControlBindingTargetProperty)!); + (Parent ?? this).GetClosestWithPropertyValue( + out numberOfDataContextChanges, + (control, _) => control.properties.TryGet(Internal.IsControlBindingTargetProperty, out var x) && (bool)x!); /// /// Gets the closest control binding target and returns number of DataContext changes since the target. Returns null if the control is not found. diff --git a/src/Framework/Framework/Controls/KnockoutHelper.cs b/src/Framework/Framework/Controls/KnockoutHelper.cs index e0438393ea..f767bae9e3 100644 --- a/src/Framework/Framework/Controls/KnockoutHelper.cs +++ b/src/Framework/Framework/Controls/KnockoutHelper.cs @@ -374,10 +374,10 @@ private static JsExpression TransformOptionValueToExpression(DotvvmBindableObjec // determine concurrency queue string? queueName = null; - var queueSettings = obj.GetValueRaw(PostBack.ConcurrencyQueueSettingsProperty) as ConcurrencyQueueSettingsCollection; + var queueSettings = PostBack.ConcurrencyQueueSettingsProperty.GetValue(obj, inherit: false); if (queueSettings != null) { - queueName = queueSettings.FirstOrDefault(q => string.Equals(q.EventName, propertyName, StringComparison.OrdinalIgnoreCase))?.ConcurrencyQueue; + queueName = ((ConcurrencyQueueSettingsCollection)queueSettings).FirstOrDefault(q => string.Equals(q.EventName, propertyName, StringComparison.OrdinalIgnoreCase))?.ConcurrencyQueue; } if (queueName == null) { @@ -385,18 +385,23 @@ private static JsExpression TransformOptionValueToExpression(DotvvmBindableObjec } // return the handler script - if (mode == PostbackConcurrencyMode.Default && "default".Equals(queueName)) + if (mode == PostbackConcurrencyMode.Default && "default".Equals(queueName, StringComparison.Ordinal)) { return null; } - var handlerName = $"concurrency-{mode.ToString().ToLowerInvariant()}"; - if ("default".Equals(queueName)) + var handlerNameJson = mode switch { + PostbackConcurrencyMode.Default => "\"concurrency-default\"", + PostbackConcurrencyMode.Deny => "\"concurrency-deny\"", + PostbackConcurrencyMode.Queue => "\"concurrency-queue\"", + _ => throw new NotSupportedException() + }; + if ("default".Equals(queueName, StringComparison.Ordinal)) { - return JsonConvert.ToString(handlerName); + return handlerNameJson; } else { - return $"[{JsonConvert.ToString(handlerName)},{GenerateHandlerOptions(obj, new Dictionary { ["q"] = queueName })}]"; + return $"[{handlerNameJson},{{q:{JsonConvert.ToString(queueName)}}}]"; } } diff --git a/src/Framework/Framework/ResourceManagement/InlineScriptResource.cs b/src/Framework/Framework/ResourceManagement/InlineScriptResource.cs index 543906baec..b3f798a551 100644 --- a/src/Framework/Framework/ResourceManagement/InlineScriptResource.cs +++ b/src/Framework/Framework/ResourceManagement/InlineScriptResource.cs @@ -12,7 +12,7 @@ namespace DotVVM.Framework.ResourceManagement /// /// Piece of javascript code that is used in the page. /// - public class InlineScriptResource : ResourceBase, IDeferrableResource + public sealed class InlineScriptResource : ResourceBase, IDeferrableResource { [Obsolete("Code parameter is required, please provide it in the constructor.")] public InlineScriptResource(ResourceRenderPosition renderPosition = ResourceRenderPosition.Body) : base(renderPosition) diff --git a/src/Framework/Framework/ResourceManagement/InlineStylesheetResource.cs b/src/Framework/Framework/ResourceManagement/InlineStylesheetResource.cs index 06fcb49acb..05c6dcecdc 100644 --- a/src/Framework/Framework/ResourceManagement/InlineStylesheetResource.cs +++ b/src/Framework/Framework/ResourceManagement/InlineStylesheetResource.cs @@ -10,7 +10,7 @@ namespace DotVVM.Framework.ResourceManagement /// /// CSS in header. It's perfect for small css. For example critical CSS. /// - public class InlineStylesheetResource : ResourceBase + public sealed class InlineStylesheetResource : ResourceBase { private readonly ILocalResourceLocation? resourceLocation; private volatile Lazy? code; diff --git a/src/Framework/Framework/ResourceManagement/TemplateResource.cs b/src/Framework/Framework/ResourceManagement/TemplateResource.cs index c2923ddcaf..92596b9f2c 100644 --- a/src/Framework/Framework/ResourceManagement/TemplateResource.cs +++ b/src/Framework/Framework/ResourceManagement/TemplateResource.cs @@ -7,7 +7,7 @@ namespace DotVVM.Framework.ResourceManagement { - public class TemplateResource : IResource + public sealed class TemplateResource : IResource { public ResourceRenderPosition RenderPosition => ResourceRenderPosition.Body; public string[] Dependencies { get; } = new string[0]; From 7d05167d69b476f1a9898cc9c0204bff6f15d413 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Standa=20Luke=C5=A1?= Date: Wed, 28 Aug 2024 16:11:15 +0200 Subject: [PATCH 31/40] small allocation optimization --- .../Javascript/JavascriptTranslator.cs | 49 ++++++++++++++----- .../Javascript/ParametrizedCode.cs | 10 ++-- 2 files changed, 41 insertions(+), 18 deletions(-) diff --git a/src/Framework/Framework/Compilation/Javascript/JavascriptTranslator.cs b/src/Framework/Framework/Compilation/Javascript/JavascriptTranslator.cs index 5beb58f288..cfbbac5ca8 100644 --- a/src/Framework/Framework/Compilation/Javascript/JavascriptTranslator.cs +++ b/src/Framework/Framework/Compilation/Javascript/JavascriptTranslator.cs @@ -146,12 +146,15 @@ public JsExpression CompileToJavascript(Expression binding, DataContextStack dat public static ParametrizedCode AdjustKnockoutScriptContext(ParametrizedCode expression, int dataContextLevel) { if (dataContextLevel == 0) return expression; - return expression.AssignParameters(o => - o is ViewModelSymbolicParameter vm ? GetKnockoutViewModelParameter(vm.ParentIndex + dataContextLevel, vm.ReturnObservable).ToParametrizedCode() : - o is ContextSymbolicParameter context ? GetKnockoutContextParameter(context.ParentIndex + dataContextLevel).ToParametrizedCode() : - o == CommandBindingExpression.OptionalKnockoutContextParameter ? GetKnockoutContextParameter(dataContextLevel).ToParametrizedCode() : - default - ); + + // separate method to avoid closure allocation if dataContextLevel == 0 + return shift(expression, dataContextLevel); + static ParametrizedCode shift(ParametrizedCode expression, int dataContextLevel) => + expression.AssignParameters(o => + o is ViewModelSymbolicParameter vm ? GetKnockoutViewModelParameter(vm.ParentIndex + dataContextLevel, vm.ReturnObservable).ToParametrizedCode() : + o is ContextSymbolicParameter context ? GetKnockoutContextParameter(context.ParentIndex + dataContextLevel).ToParametrizedCode() : + o == CommandBindingExpression.OptionalKnockoutContextParameter ? GetKnockoutContextParameter(dataContextLevel).ToParametrizedCode() : + default); } /// @@ -169,17 +172,37 @@ public static string FormatKnockoutScript(ParametrizedCode expression, bool allo if (allowDataGlobal) return expression.ToDefaultString(); else - return expression.ToString(o => + return expression.ToString(static o => o == KnockoutViewModelParameter ? CodeParameterAssignment.FromIdentifier("$data") : default); } - return expression.ToString(o => - o is ViewModelSymbolicParameter vm ? GetKnockoutViewModelParameter(vm.ParentIndex + dataContextLevel, vm.ReturnObservable).DefaultAssignment : - o is ContextSymbolicParameter context ? GetKnockoutContextParameter(context.ParentIndex + dataContextLevel).DefaultAssignment : - o == CommandBindingExpression.OptionalKnockoutContextParameter ? GetKnockoutContextParameter(dataContextLevel).DefaultAssignment : - default - ); + + // separate method to avoid closure allocation if dataContextLevel == 0 + return shiftToString(expression, dataContextLevel); + + static string shiftToString(ParametrizedCode expression, int dataContextLevel) => + expression.ToString(o => { + if (o is ViewModelSymbolicParameter vm) + { + var p = GetKnockoutViewModelParameter(vm.ParentIndex + dataContextLevel, vm.ReturnObservable).DefaultAssignment; + return new(p.Code!.ToDefaultString(), p.Code.OperatorPrecedence); + } + else if (o is ContextSymbolicParameter context) + { + var p = GetKnockoutContextParameter(context.ParentIndex + dataContextLevel).DefaultAssignment; + return new(p.Code!.ToDefaultString(), p.Code.OperatorPrecedence); + } + else if (o == CommandBindingExpression.OptionalKnockoutContextParameter) + { + var p = GetKnockoutContextParameter(dataContextLevel).DefaultAssignment; + return new(p.Code!.ToDefaultString(), p.Code.OperatorPrecedence); + } + else + { + return default; + } + }); } /// diff --git a/src/Framework/Framework/Compilation/Javascript/ParametrizedCode.cs b/src/Framework/Framework/Compilation/Javascript/ParametrizedCode.cs index 8e67eb4639..cfc4b51940 100644 --- a/src/Framework/Framework/Compilation/Javascript/ParametrizedCode.cs +++ b/src/Framework/Framework/Compilation/Javascript/ParametrizedCode.cs @@ -77,18 +77,18 @@ public string ToString(Func para var needsParens = code.parameter.Code!.OperatorPrecedence.NeedsParens(parameters![i].OperatorPrecedence); if (isGlobalContext) - sb.Append(stringParts[++i], 1, stringParts[i].Length - 1); // skip `.` + sb.Append(stringParts[++i], startIndex: 1, count: stringParts[i].Length - 1); // skip `.` else { if (needsParens) - sb.Append("("); + sb.Append('('); else if (JsFormattingVisitor.NeedSpaceBetween(sb, code.code)) - sb.Append(" "); + sb.Append(' '); sb.Append(code.code); i++; - if (needsParens) sb.Append(")"); + if (needsParens) sb.Append(')'); else if (JsFormattingVisitor.NeedSpaceBetween(sb, stringParts[i])) - sb.Append(" "); + sb.Append(' '); sb.Append(stringParts[i]); } } From 50a2431205275885474b0f2664dfe344bac90eae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Standa=20Luke=C5=A1?= Date: Wed, 28 Aug 2024 16:20:14 +0200 Subject: [PATCH 32/40] perf: cleanup rejected/unused code --- .../Framework/Controls/HtmlWriter.cs | 91 ------------------- .../InlineScriptResource.cs | 2 +- .../InlineStylesheetResource.cs | 2 +- .../ResourceManagement/TemplateResource.cs | 2 +- 4 files changed, 3 insertions(+), 94 deletions(-) diff --git a/src/Framework/Framework/Controls/HtmlWriter.cs b/src/Framework/Framework/Controls/HtmlWriter.cs index df72417106..80cbce74c2 100644 --- a/src/Framework/Framework/Controls/HtmlWriter.cs +++ b/src/Framework/Framework/Controls/HtmlWriter.cs @@ -480,60 +480,6 @@ private void WriteEncodedText(string input, bool escapeQuotes, bool escapeApos) [62 - 34] = ">", // > [38 - 34] = "&" // & }.ToArray(); - - - // private static int IndexOfHtmlEncodingChars(string input, int startIndex, bool escapeQuotes, bool escapeApos) - // { - // for (int i = startIndex; i < input.Length; i++) - // { - // char ch = input[i]; - // if (ch <= '>') - // { - // switch (ch) - // { - // case '<': - // case '>': - // return i; - // case '"': - // if (escapeQuotes) - // return i; - // break; - // case '\'': - // if (escapeApos) - // return i; - // break; - // case '&': - // // HTML spec permits ampersands, if they are not ambiguous: - - // // An ambiguous ampersand is a U+0026 AMPERSAND character (&) that is followed by one or more ASCII alphanumerics, followed by a U+003B SEMICOLON character (;), where these characters do not match any of the names given in the named character references section. - - // // so if the next character is not alphanumeric, we can leave it there - // if (i == input.Length) - // return i; - // var nextChar = input[i + 1]; - // if (IsInRange(nextChar, 'a', 'z') || - // IsInRange(nextChar, 'A', 'Z') || - // IsInRange(nextChar, '0', '9') || - // nextChar == '#') - // return i; - // break; - // } - // } - // else if (char.IsSurrogate(ch)) - // { - // // surrogates are fine, but they must not code for ASCII characters - - // var value = Char.ConvertToUtf32(ch, input[i + 1]); - // if (value < 256) - // throw new InvalidOperationException("Encountered UTF16 surrogate coding for ASCII char, this is not allowed."); - - // i++; - // } - // } - - // return -1; - // } - private static char[] MinimalEscapeChars = new char[] { '<', '>', '&' }; private static char[] DoubleQEscapeChars = new char[] { '<', '>', '&', '"' }; private static char[] SingleQEscapeChars = new char[] { '<', '>', '&', '\'' }; @@ -557,39 +503,11 @@ private static int IndexOfHtmlEncodingChars(string input, int startIndex, bool e { breakChars = MinimalEscapeChars; } - // Span breakChars = stackalloc char[5]; - // breakChars[0] = '<'; - // breakChars[1] = '>'; - // breakChars[2] = '&'; - // if (escapeQuotes && escapeApos) - // { - // breakChars[3] = '"'; - // breakChars[4] = '\''; - // } - // else if (escapeQuotes) - // { - // breakChars[3] = '"'; - // } - // else if (escapeApos) - // { - // breakChars[3] = '\''; - // } - // breakChars = breakChars.Slice(0, 3 + (escapeQuotes ? 1 : 0) + (escapeApos ? 1 : 0)); int i = startIndex; while (true) { var foundIndex = MemoryExtensions.IndexOfAny(input.AsSpan(start: i), breakChars); - // var stopIndex = foundIndex < 0 ? input.Length : foundIndex + i + 1; - // check for surrogates - // for (int j = i; j < stopIndex; j++) - // { - // if (char.IsSurrogate(input[j])) - // { - // CheckSurrogate(input, j); - // j++; - // } - // } if (foundIndex < 0) return -1; @@ -618,15 +536,6 @@ private static int IndexOfHtmlEncodingChars(string input, int startIndex, bool e i++; } - - [MethodImpl(MethodImplOptions.NoInlining)] // this case is rare, it's better to be separate from the hot path - static void CheckSurrogate(string input, int j) - { - // surrogates are fine, but they must not code for ASCII characters - var value = Char.ConvertToUtf32(input[j], input[j + 1]); - if (value < 256) - throw new InvalidOperationException("Encountered UTF16 surrogate coding for ASCII char, this is not allowed."); - } } private void ThrowIfAttributesArePresent([CallerMemberName] string operation = "Write") diff --git a/src/Framework/Framework/ResourceManagement/InlineScriptResource.cs b/src/Framework/Framework/ResourceManagement/InlineScriptResource.cs index b3f798a551..543906baec 100644 --- a/src/Framework/Framework/ResourceManagement/InlineScriptResource.cs +++ b/src/Framework/Framework/ResourceManagement/InlineScriptResource.cs @@ -12,7 +12,7 @@ namespace DotVVM.Framework.ResourceManagement /// /// Piece of javascript code that is used in the page. /// - public sealed class InlineScriptResource : ResourceBase, IDeferrableResource + public class InlineScriptResource : ResourceBase, IDeferrableResource { [Obsolete("Code parameter is required, please provide it in the constructor.")] public InlineScriptResource(ResourceRenderPosition renderPosition = ResourceRenderPosition.Body) : base(renderPosition) diff --git a/src/Framework/Framework/ResourceManagement/InlineStylesheetResource.cs b/src/Framework/Framework/ResourceManagement/InlineStylesheetResource.cs index 05c6dcecdc..06fcb49acb 100644 --- a/src/Framework/Framework/ResourceManagement/InlineStylesheetResource.cs +++ b/src/Framework/Framework/ResourceManagement/InlineStylesheetResource.cs @@ -10,7 +10,7 @@ namespace DotVVM.Framework.ResourceManagement /// /// CSS in header. It's perfect for small css. For example critical CSS. /// - public sealed class InlineStylesheetResource : ResourceBase + public class InlineStylesheetResource : ResourceBase { private readonly ILocalResourceLocation? resourceLocation; private volatile Lazy? code; diff --git a/src/Framework/Framework/ResourceManagement/TemplateResource.cs b/src/Framework/Framework/ResourceManagement/TemplateResource.cs index 92596b9f2c..c2923ddcaf 100644 --- a/src/Framework/Framework/ResourceManagement/TemplateResource.cs +++ b/src/Framework/Framework/ResourceManagement/TemplateResource.cs @@ -7,7 +7,7 @@ namespace DotVVM.Framework.ResourceManagement { - public sealed class TemplateResource : IResource + public class TemplateResource : IResource { public ResourceRenderPosition RenderPosition => ResourceRenderPosition.Body; public string[] Dependencies { get; } = new string[0]; From 264b8e8f3f9d64b394bef510f1d4e7cba9dfe94e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Standa=20Luke=C5=A1?= Date: Sat, 17 Aug 2024 23:58:46 +0200 Subject: [PATCH 33/40] Preserve other hisory.state properties when storing the viewmodel on page unload --- .../Framework/Resources/Scripts/dotvvm-base.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/Framework/Framework/Resources/Scripts/dotvvm-base.ts b/src/Framework/Framework/Resources/Scripts/dotvvm-base.ts index 6abad6881b..1d42ba76c2 100644 --- a/src/Framework/Framework/Resources/Scripts/dotvvm-base.ts +++ b/src/Framework/Framework/Resources/Scripts/dotvvm-base.ts @@ -69,6 +69,10 @@ export function getStateManager(): StateManager { return getCoreS let initialViewModelWrapper: any; +function isBackForwardNavigation() { + return (performance.getEntriesByType?.("navigation").at(-1) as PerformanceNavigationTiming)?.type == "back_forward"; +} + export function initCore(culture: string): void { if (currentCoreState) { throw new Error("DotVVM is already loaded"); @@ -124,8 +128,10 @@ const getViewModelStorageElement = () => document.getElementById("__dot_viewmodel_root") function persistViewModel() { - const viewModel = getState() - const persistedViewModel = { ...initialViewModelWrapper, viewModel }; - - getViewModelStorageElement().value = JSON.stringify(persistedViewModel); + history.replaceState({ + ...history.state, + viewModel: { ...initialViewModelWrapper, viewModel: getState() } + }, "") + // avoid storing the viewmodel hidden field, as Firefox would also reuse it on page reloads + getViewModelStorageElement()?.remove() } From 5f9f48a4ecb2a3a059b0893bee3e829424d7416c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Standa=20Luke=C5=A1?= Date: Sat, 7 Sep 2024 09:53:49 +0200 Subject: [PATCH 34/40] JS: restore last viewmodel from history API --- src/Framework/Framework/Resources/Scripts/dotvvm-base.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Framework/Framework/Resources/Scripts/dotvvm-base.ts b/src/Framework/Framework/Resources/Scripts/dotvvm-base.ts index 1d42ba76c2..1d1d1a5eba 100644 --- a/src/Framework/Framework/Resources/Scripts/dotvvm-base.ts +++ b/src/Framework/Framework/Resources/Scripts/dotvvm-base.ts @@ -79,7 +79,9 @@ export function initCore(culture: string): void { } // load the viewmodel - const thisViewModel = initialViewModelWrapper = JSON.parse(getViewModelStorageElement().value); + const thisViewModel = initialViewModelWrapper = + (isBackForwardNavigation() ? history.state?.viewModel : null) ?? + JSON.parse(getViewModelStorageElement().value); resourceLoader.registerResources(thisViewModel.renderedResources) From 05b955471f4aa174d9a929434f9522244a59a0f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Standa=20Luke=C5=A1?= Date: Sat, 7 Sep 2024 10:02:06 +0200 Subject: [PATCH 35/40] Add UI test for back navigaton VM restore --- .../Tests/Tests/Complex/TaskListTests.cs | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/Samples/Tests/Tests/Complex/TaskListTests.cs b/src/Samples/Tests/Tests/Complex/TaskListTests.cs index 09fd44c01d..2e1776e050 100644 --- a/src/Samples/Tests/Tests/Complex/TaskListTests.cs +++ b/src/Samples/Tests/Tests/Complex/TaskListTests.cs @@ -62,5 +62,46 @@ public void Complex_TaskList_ServerRenderedTaskList() "Last task is not marked as completed."); }); } + + [Fact] + public void Complex_TaskList_TaskListAsyncCommands_ViewModelRestore() + { + // view model should be restored after back/forward navigation, but not on refresh + RunInAllBrowsers(browser => + { + browser.NavigateToUrl("/"); + browser.NavigateToUrl(SamplesRouteUrls.ComplexSamples_TaskList_TaskListAsyncCommands); + + browser.SendKeys("input[type=text]", "test1"); + browser.Click("input[type=button]"); + + browser.FindElements(".table tr").ThrowIfDifferentCountThan(4); + + browser.NavigateBack(); + browser.WaitUntilDotvvmInited(); + browser.NavigateForward(); + + browser.FindElements(".table tr").ThrowIfDifferentCountThan(4); + + browser.Refresh(); + + browser.FindElements(".table tr").ThrowIfDifferentCountThan(3); + + browser.SendKeys("input[type=text]", "test2"); + browser.Click("input[type=button]"); + browser.FindElements(".table tr").ThrowIfDifferentCountThan(4); + + browser.NavigateToUrl("/"); + browser.NavigateToUrl(SamplesRouteUrls.ComplexSamples_TaskList_TaskListAsyncCommands); + + browser.FindElements(".table tr").ThrowIfDifferentCountThan(3); + + browser.NavigateBack(); + browser.WaitUntilDotvvmInited(); + browser.NavigateBack(); + + browser.FindElements(".table tr").ThrowIfDifferentCountThan(4); + }); + } } } From 5ed77a9bd2a5bfbb873947eea0f5830e13c9d944 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Standa=20Luke=C5=A1?= Date: Sat, 7 Sep 2024 10:27:52 +0200 Subject: [PATCH 36/40] bit less instructions in GenerateConcurrencyModeHandler --- .../Framework/Controls/KnockoutHelper.cs | 35 +++++++++++++++---- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/src/Framework/Framework/Controls/KnockoutHelper.cs b/src/Framework/Framework/Controls/KnockoutHelper.cs index f767bae9e3..c4f10e38a4 100644 --- a/src/Framework/Framework/Controls/KnockoutHelper.cs +++ b/src/Framework/Framework/Controls/KnockoutHelper.cs @@ -370,22 +370,43 @@ private static JsExpression TransformOptionValueToExpression(DotvvmBindableObjec static string? GenerateConcurrencyModeHandler(string propertyName, DotvvmBindableObject obj) { - var mode = (obj.GetValue(PostBack.ConcurrencyProperty) as PostbackConcurrencyMode?) ?? PostbackConcurrencyMode.Default; + if (obj.GetValue(PostBack.ConcurrencyProperty) is not PostbackConcurrencyMode mode) + mode = PostbackConcurrencyMode.Default; // determine concurrency queue string? queueName = null; - var queueSettings = PostBack.ConcurrencyQueueSettingsProperty.GetValue(obj, inherit: false); + var queueSettings = obj.GetValueRaw(PostBack.ConcurrencyQueueSettingsProperty); if (queueSettings != null) { - queueName = ((ConcurrencyQueueSettingsCollection)queueSettings).FirstOrDefault(q => string.Equals(q.EventName, propertyName, StringComparison.OrdinalIgnoreCase))?.ConcurrencyQueue; + foreach (var q in (ConcurrencyQueueSettingsCollection)queueSettings) + { + if (string.Equals(q.EventName, propertyName, StringComparison.OrdinalIgnoreCase)) + { + queueName = q.ConcurrencyQueue; + break; + } + } + } + bool queueDefault; + if (queueName is null) + { + if (obj.GetValue(PostBack.ConcurrencyQueueProperty) is string queueValue) + { + queueName = queueValue; + queueDefault = "default".Equals(queueName, StringComparison.Ordinal); + } + else + { + queueDefault = true; + } } - if (queueName == null) + else { - queueName = obj.GetValue(PostBack.ConcurrencyQueueProperty) as string ?? "default"; + queueDefault = "default".Equals(queueName, StringComparison.Ordinal); } // return the handler script - if (mode == PostbackConcurrencyMode.Default && "default".Equals(queueName, StringComparison.Ordinal)) + if (mode == PostbackConcurrencyMode.Default && queueDefault) { return null; } @@ -395,7 +416,7 @@ private static JsExpression TransformOptionValueToExpression(DotvvmBindableObjec PostbackConcurrencyMode.Queue => "\"concurrency-queue\"", _ => throw new NotSupportedException() }; - if ("default".Equals(queueName, StringComparison.Ordinal)) + if (queueDefault) { return handlerNameJson; } From 780c9d1ab72978304056c4b4e6e3db5c61997d12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Herceg=20=28RIGANTI=20s=2Er=2Eo=2E=29?= Date: Sat, 7 Sep 2024 16:42:40 +0200 Subject: [PATCH 37/40] Fixed failing UI test --- src/Samples/Tests/Tests/Feature/FormControlsEnabledTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Samples/Tests/Tests/Feature/FormControlsEnabledTests.cs b/src/Samples/Tests/Tests/Feature/FormControlsEnabledTests.cs index 79c468a479..7a47b04a0c 100644 --- a/src/Samples/Tests/Tests/Feature/FormControlsEnabledTests.cs +++ b/src/Samples/Tests/Tests/Feature/FormControlsEnabledTests.cs @@ -84,7 +84,7 @@ public void Feature_FormControlsEnabled_FormControlsEnabled() private void TestLinkButton(IBrowserWrapper browser, string id, bool shouldBeEnabled, ref int currentPresses) { - browser.First($"#{id}").Click(); + browser.First($"#{id}").ScrollTo().Wait(500).Click(); if (shouldBeEnabled) { currentPresses++; From 474608544480ec8815fbbb8590e034f3f42fa066 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Standa=20Luke=C5=A1?= Date: Sat, 7 Sep 2024 16:02:29 +0200 Subject: [PATCH 38/40] Block postbacks for 5s after redirects The page does not unload right after we perform a navigation, and allows the user to hit the submit button a second time. In the past, we used to disable all postbacks after a redirect, but that also blocks the page after a file is returned. There probably isn't a 100% reliable way to detect if the location change is a navigation or a file return. With this patch, we will block the page again, but only for a limited time (5 seconds). We also only only block the postback queue, which means that only postbacks with Concurrency=Deny or Queue will be affected. Standard file returns as provided by DotVVM are excluded from this, but it should also work acceptably with custom file returns (some buttons will not work for 5 seconds after the file return) --- .../Hosting/DotvvmRequestContextExtensions.cs | 8 +- .../Framework/Hosting/HttpRedirectService.cs | 7 +- .../Resources/Scripts/postback/redirect.ts | 17 +++- .../Resources/Scripts/tests/eventArgs.test.ts | 6 +- .../Scripts/utils/magic-navigator.ts | 7 +- .../Resources/Scripts/utils/promise.ts | 1 + .../DefaultViewModelSerializer.cs | 3 +- .../RedirectPostbackConcurrencyViewModel.cs | 65 +++++++++++++ .../RedirectPostbackConcurrency.dothtml | 33 +++++++ .../Abstractions/SamplesRouteUrls.designer.cs | 1 + .../Tests/Tests/Feature/RedirectTests.cs | 94 ++++++++++++++++++- 11 files changed, 227 insertions(+), 15 deletions(-) create mode 100644 src/Samples/Common/ViewModels/FeatureSamples/Redirect/RedirectPostbackConcurrencyViewModel.cs create mode 100644 src/Samples/Common/Views/FeatureSamples/Redirect/RedirectPostbackConcurrency.dothtml diff --git a/src/Framework/Framework/Hosting/DotvvmRequestContextExtensions.cs b/src/Framework/Framework/Hosting/DotvvmRequestContextExtensions.cs index 913347ffb7..8bc46a5b7a 100644 --- a/src/Framework/Framework/Hosting/DotvvmRequestContextExtensions.cs +++ b/src/Framework/Framework/Hosting/DotvvmRequestContextExtensions.cs @@ -141,8 +141,8 @@ public static void RedirectToRoutePermanent(this IDotvvmRequestContext context, context.RedirectToUrlPermanent(url, replaceInHistory, allowSpaRedirect); } - public static void SetRedirectResponse(this IDotvvmRequestContext context, string url, int statusCode = (int)HttpStatusCode.Redirect, bool replaceInHistory = false, bool allowSpaRedirect = false) => - context.Configuration.ServiceProvider.GetRequiredService().WriteRedirectResponse(context.HttpContext, url, statusCode, replaceInHistory, allowSpaRedirect); + public static void SetRedirectResponse(this IDotvvmRequestContext context, string url, int statusCode = (int)HttpStatusCode.Redirect, bool replaceInHistory = false, bool allowSpaRedirect = false, string? downloadName = null) => + context.Configuration.ServiceProvider.GetRequiredService().WriteRedirectResponse(context.HttpContext, url, statusCode, replaceInHistory, allowSpaRedirect, downloadName); internal static Task SetCachedViewModelMissingResponse(this IDotvvmRequestContext context) { @@ -262,8 +262,10 @@ public static async Task ReturnFileAsync(this IDotvvmRequestContext context, Str AttachmentDispositionType = attachmentDispositionType ?? "attachment" }; + var downloadAttribute = attachmentDispositionType == "inline" ? null : fileName; + var generatedFileId = await returnedFileStorage.StoreFileAsync(stream, metadata).ConfigureAwait(false); - context.SetRedirectResponse(context.TranslateVirtualPath("~/dotvvmReturnedFile?id=" + generatedFileId)); + context.SetRedirectResponse(context.TranslateVirtualPath("~/dotvvmReturnedFile?id=" + generatedFileId), downloadName: downloadAttribute); throw new DotvvmInterruptRequestExecutionException(InterruptReason.ReturnFile, fileName); } diff --git a/src/Framework/Framework/Hosting/HttpRedirectService.cs b/src/Framework/Framework/Hosting/HttpRedirectService.cs index d1aef7f12f..c29281b6c7 100644 --- a/src/Framework/Framework/Hosting/HttpRedirectService.cs +++ b/src/Framework/Framework/Hosting/HttpRedirectService.cs @@ -25,14 +25,13 @@ namespace DotVVM.Framework.Hosting { public interface IHttpRedirectService { - void WriteRedirectResponse(IHttpContext httpContext, string url, int statusCode = (int)HttpStatusCode.Redirect, bool replaceInHistory = false, bool allowSpaRedirect = false); + void WriteRedirectResponse(IHttpContext httpContext, string url, int statusCode = (int)HttpStatusCode.Redirect, bool replaceInHistory = false, bool allowSpaRedirect = false, string? downloadName = null); } public class DefaultHttpRedirectService: IHttpRedirectService { - public void WriteRedirectResponse(IHttpContext httpContext, string url, int statusCode = (int)HttpStatusCode.Redirect, bool replaceInHistory = false, bool allowSpaRedirect = false) + public void WriteRedirectResponse(IHttpContext httpContext, string url, int statusCode = (int)HttpStatusCode.Redirect, bool replaceInHistory = false, bool allowSpaRedirect = false, string? downloadName = null) { - if (DotvvmRequestContext.DetermineRequestType(httpContext) is DotvvmRequestType.Navigate) { httpContext.Response.Headers["Location"] = url; @@ -43,7 +42,7 @@ public void WriteRedirectResponse(IHttpContext httpContext, string url, int stat httpContext.Response.StatusCode = 200; httpContext.Response.ContentType = "application/json"; httpContext.Response - .WriteAsync(DefaultViewModelSerializer.GenerateRedirectActionResponse(url, replaceInHistory, allowSpaRedirect)) + .WriteAsync(DefaultViewModelSerializer.GenerateRedirectActionResponse(url, replaceInHistory, allowSpaRedirect, downloadName)) .GetAwaiter().GetResult(); // ^ we just wait for this Task. Redirect API never was async and the response size is small enough that we can't quite safely wait for the result // .GetAwaiter().GetResult() preserves stack traces across async calls, thus I like it more that .Wait() diff --git a/src/Framework/Framework/Resources/Scripts/postback/redirect.ts b/src/Framework/Framework/Resources/Scripts/postback/redirect.ts index bad3b21184..fb28132976 100644 --- a/src/Framework/Framework/Resources/Scripts/postback/redirect.ts +++ b/src/Framework/Framework/Resources/Scripts/postback/redirect.ts @@ -1,17 +1,23 @@ import * as events from '../events'; import * as magicNavigator from '../utils/magic-navigator' import { handleSpaNavigationCore } from "../spa/spa"; +import { delay } from '../utils/promise'; + export function performRedirect(url: string, replace: boolean, allowSpa: boolean): Promise { if (replace) { location.replace(url); - return Promise.resolve(); } else if (compileConstants.isSpa && allowSpa) { return handleSpaNavigationCore(url); } else { magicNavigator.navigate(url); - return Promise.resolve(); } + + // When performing redirect, we pretend that the request takes additional X second to avoid + // double submit with Postback.Concurrency=Deny or Queue. + // We do not want to block the page forever, as the redirect might just return a file (or HTTP 204/205), + // and the page will continue to live. + return delay(5_000); } export async function handleRedirect(options: PostbackOptions, resultObject: any, response: Response, replace: boolean = false): Promise { @@ -28,7 +34,12 @@ export async function handleRedirect(options: PostbackOptions, resultObject: any } events.redirect.trigger(redirectArgs); - await performRedirect(url, replace, resultObject.allowSpa); + const downloadFileName = resultObject.download + if (downloadFileName != null) { + magicNavigator.navigate(url, downloadFileName) + } else { + await performRedirect(url, replace, resultObject.allowSpa); + } return redirectArgs; } diff --git a/src/Framework/Framework/Resources/Scripts/tests/eventArgs.test.ts b/src/Framework/Framework/Resources/Scripts/tests/eventArgs.test.ts index 097fe5c478..d6c29aa268 100644 --- a/src/Framework/Framework/Resources/Scripts/tests/eventArgs.test.ts +++ b/src/Framework/Framework/Resources/Scripts/tests/eventArgs.test.ts @@ -174,7 +174,8 @@ const fetchDefinitions = { postbackRedirect: async (url: string, init: RequestInit) => { return { action: "redirect", - url: "/newUrl" + url: "/newUrl", + download: "some-file" // say it's a file, so that DotVVM does not block postback queue after the test } as any; }, @@ -213,7 +214,8 @@ const fetchDefinitions = { return { action: "redirect", url: "/newUrl", - allowSpa: true + allowSpa: true, + download: "some-file" } as any; }, spaNavigateError: async (url: string, init: RequestInit) => { diff --git a/src/Framework/Framework/Resources/Scripts/utils/magic-navigator.ts b/src/Framework/Framework/Resources/Scripts/utils/magic-navigator.ts index 60e3486ab1..b97ca9dc46 100644 --- a/src/Framework/Framework/Resources/Scripts/utils/magic-navigator.ts +++ b/src/Framework/Framework/Resources/Scripts/utils/magic-navigator.ts @@ -1,10 +1,15 @@ let fakeAnchor: HTMLAnchorElement | undefined; -export function navigate(url: string) { +export function navigate(url: string, downloadName: string | null | undefined = null) { if (!fakeAnchor) { fakeAnchor = document.createElement("a"); fakeAnchor.style.display = "none"; document.body.appendChild(fakeAnchor); } + if (downloadName == null) { + fakeAnchor.removeAttribute("download"); + } else { + fakeAnchor.download = downloadName + } fakeAnchor.href = url; fakeAnchor.click(); } diff --git a/src/Framework/Framework/Resources/Scripts/utils/promise.ts b/src/Framework/Framework/Resources/Scripts/utils/promise.ts index a860c54669..c702c2f86e 100644 --- a/src/Framework/Framework/Resources/Scripts/utils/promise.ts +++ b/src/Framework/Framework/Resources/Scripts/utils/promise.ts @@ -1,2 +1,3 @@ /** Runs the callback in the next event loop cycle */ export const defer = (callback: () => T) => Promise.resolve().then(callback) +export const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) diff --git a/src/Framework/Framework/ViewModel/Serialization/DefaultViewModelSerializer.cs b/src/Framework/Framework/ViewModel/Serialization/DefaultViewModelSerializer.cs index eb35dc99f4..db51e3f283 100644 --- a/src/Framework/Framework/ViewModel/Serialization/DefaultViewModelSerializer.cs +++ b/src/Framework/Framework/ViewModel/Serialization/DefaultViewModelSerializer.cs @@ -258,7 +258,7 @@ public JObject BuildResourcesJson(IDotvvmRequestContext context, Func /// Serializes the redirect action. /// - public static string GenerateRedirectActionResponse(string url, bool replace, bool allowSpa) + public static string GenerateRedirectActionResponse(string url, bool replace, bool allowSpa, string? downloadName) { // create result object var result = new JObject(); @@ -266,6 +266,7 @@ public static string GenerateRedirectActionResponse(string url, bool replace, bo result["action"] = "redirect"; if (replace) result["replace"] = true; if (allowSpa) result["allowSpa"] = true; + if (downloadName is object) result["download"] = downloadName; return result.ToString(Formatting.None); } diff --git a/src/Samples/Common/ViewModels/FeatureSamples/Redirect/RedirectPostbackConcurrencyViewModel.cs b/src/Samples/Common/ViewModels/FeatureSamples/Redirect/RedirectPostbackConcurrencyViewModel.cs new file mode 100644 index 0000000000..75f53ba0b1 --- /dev/null +++ b/src/Samples/Common/ViewModels/FeatureSamples/Redirect/RedirectPostbackConcurrencyViewModel.cs @@ -0,0 +1,65 @@ +using System.Diagnostics.Metrics; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using DotVVM.Core.Storage; +using DotVVM.Framework.ViewModel; + +namespace DotVVM.Samples.BasicSamples.ViewModels.FeatureSamples.Redirect +{ + class RedirectPostbackConcurrencyViewModel : DotvvmViewModelBase + { + public static int GlobalCounter = 0; + private readonly IReturnedFileStorage returnedFileStorage; + + [Bind(Direction.ServerToClient)] + public int Counter { get; set; } = GlobalCounter; + + public int MiniCounter { get; set; } = 0; + + [FromQuery("empty")] + public bool IsEmptyPage { get; set; } = false; + [FromQuery("loadDelay")] + public int LoadDelay { get; set; } = 0; + + public RedirectPostbackConcurrencyViewModel(IReturnedFileStorage returnedFileStorage) + { + this.returnedFileStorage = returnedFileStorage; + } + public override async Task Init() + { + await Task.Delay(LoadDelay); // delay to enable user to click DelayIncrement button between it succeeding and loading the next page + await base.Init(); + } + + public async Task DelayIncrement() + { + await Task.Delay(1000); + + Interlocked.Increment(ref GlobalCounter); + + Context.RedirectToRoute(Context.Route.RouteName, query: new { empty = true, loadDelay = 2000 }); + } + + public async Task GetFileStandard() + { + await Context.ReturnFileAsync("test file"u8.ToArray(), "test.txt", "text/plain"); + } + + public async Task GetFileCustom() + { + var metadata = new ReturnedFileMetadata() + { + FileName = "test_custom.txt", + MimeType = "text/plain", + AttachmentDispositionType = "attachment" + }; + + var stream = new MemoryStream("test custom file"u8.ToArray()); + var generatedFileId = await returnedFileStorage.StoreFileAsync(stream, metadata).ConfigureAwait(false); + + var url = Context.TranslateVirtualPath("~/dotvvmReturnedFile?id=" + generatedFileId); + Context.RedirectToUrl(url); + } + } +} diff --git a/src/Samples/Common/Views/FeatureSamples/Redirect/RedirectPostbackConcurrency.dothtml b/src/Samples/Common/Views/FeatureSamples/Redirect/RedirectPostbackConcurrency.dothtml new file mode 100644 index 0000000000..f9838fcdc6 --- /dev/null +++ b/src/Samples/Common/Views/FeatureSamples/Redirect/RedirectPostbackConcurrency.dothtml @@ -0,0 +1,33 @@ +@viewModel DotVVM.Samples.BasicSamples.ViewModels.FeatureSamples.Redirect.RedirectPostbackConcurrencyViewModel, DotVVM.Samples.Common + + + Hello from DotVVM! + + +
+ Back +
+
+

Redirect and postback concurrency test

+

+ Testing Concurrency=Deny / Concurrency=Queue with redirect and file returns. +

+

First, we have a set of buttons incrementing a static variable, each takes about 2sec and redirects to a blank page afterwards

+

GlobalCounter =

+

MiniCounter(Concurrency=Deny) =

+ +

+ Increment (Concurrency=Default) + Increment (Concurrency=Deny) + Increment (Concurrency=Queue) +

+ +

We also test that returning files does not block the page forever,

+ +

+ Standard return file + Custom return file (will have delay before page works) +

+
+ + diff --git a/src/Samples/Tests/Abstractions/SamplesRouteUrls.designer.cs b/src/Samples/Tests/Abstractions/SamplesRouteUrls.designer.cs index ff8f0ccc1b..83323d2379 100644 --- a/src/Samples/Tests/Abstractions/SamplesRouteUrls.designer.cs +++ b/src/Samples/Tests/Abstractions/SamplesRouteUrls.designer.cs @@ -327,6 +327,7 @@ public partial class SamplesRouteUrls public const string FeatureSamples_Redirect_RedirectionHelpers_PageC = "FeatureSamples/Redirect/RedirectionHelpers_PageC"; public const string FeatureSamples_Redirect_RedirectionHelpers_PageD = "FeatureSamples/Redirect/RedirectionHelpers_PageD"; public const string FeatureSamples_Redirect_RedirectionHelpers_PageE = "FeatureSamples/Redirect/RedirectionHelpers_PageE"; + public const string FeatureSamples_Redirect_RedirectPostbackConcurrency = "FeatureSamples/Redirect/RedirectPostbackConcurrency"; public const string FeatureSamples_Redirect_Redirect_StaticCommand = "FeatureSamples/Redirect/Redirect_StaticCommand"; public const string FeatureSamples_RenderSettingsModeServer_RenderSettingModeServerProperty = "FeatureSamples/RenderSettingsModeServer/RenderSettingModeServerProperty"; public const string FeatureSamples_RenderSettingsModeServer_RepeaterCollectionExchange = "FeatureSamples/RenderSettingsModeServer/RepeaterCollectionExchange"; diff --git a/src/Samples/Tests/Tests/Feature/RedirectTests.cs b/src/Samples/Tests/Tests/Feature/RedirectTests.cs index 70af6f5cc2..ef9b12b62e 100644 --- a/src/Samples/Tests/Tests/Feature/RedirectTests.cs +++ b/src/Samples/Tests/Tests/Feature/RedirectTests.cs @@ -1,7 +1,11 @@ using System; using System.Linq; +using System.Threading; using DotVVM.Samples.Tests.Base; using DotVVM.Testing.Abstractions; +using OpenQA.Selenium; +using Riganti.Selenium.Core; +using Riganti.Selenium.Core.Abstractions; using Riganti.Selenium.DotVVM; using Xunit; using Xunit.Abstractions; @@ -69,6 +73,94 @@ public void Feature_Redirect_RedirectionHelpers() Assert.Matches($"{SamplesRouteUrls.FeatureSamples_Redirect_RedirectionHelpers_PageE}/1221\\?x=a", currentUrl.LocalPath + currentUrl.Query); }); } - + + bool TryClick(IElementWrapper element) + { + if (element is null) return false; + try + { + element.Click(); + return true; + } + catch (StaleElementReferenceException) + { + return false; + } + } + + [Fact] + public void Feature_Redirect_RedirectPostbackConcurrency() + { + RunInAllBrowsers(browser => { + browser.NavigateToUrl(SamplesRouteUrls.FeatureSamples_Redirect_RedirectPostbackConcurrency); + + int globalCounter() => int.Parse(browser.First("counter", SelectByDataUi).GetText()); + + var initialCounter = globalCounter(); + for (int i = 0; i < 20; i++) + { + TryClick(browser.FirstOrDefault("inc-default", SelectByDataUi)); + Thread.Sleep(1); + } + browser.WaitFor(() => Assert.Contains("empty=true", browser.CurrentUrl, StringComparison.OrdinalIgnoreCase), 7000, "Redirect did not happen"); + browser.NavigateToUrl(SamplesRouteUrls.FeatureSamples_Redirect_RedirectPostbackConcurrency); + + // must increment at least 20 times, otherwise delays are too short + Assert.Equal(globalCounter(), initialCounter + 20); + + initialCounter = globalCounter(); + var clickCount = 0; + while (TryClick(browser.FirstOrDefault("inc-deny", SelectByDataUi))) + { + clickCount++; + Thread.Sleep(1); + } + Assert.InRange(clickCount, 3, int.MaxValue); + browser.WaitFor(() => Assert.Contains("empty=true", browser.CurrentUrl, StringComparison.OrdinalIgnoreCase), timeout: 500, "Redirect did not happen"); + + browser.NavigateToUrl(SamplesRouteUrls.FeatureSamples_Redirect_RedirectPostbackConcurrency); + Assert.Equal(globalCounter(), initialCounter + 1); // only one click was allowed + + initialCounter = globalCounter(); + clickCount = 0; + while (TryClick(browser.FirstOrDefault("inc-queue", SelectByDataUi))) + { + clickCount++; + Thread.Sleep(1); + } + + Assert.InRange(clickCount, 3, int.MaxValue); + browser.WaitFor(() => Assert.Contains("empty=true", browser.CurrentUrl, StringComparison.OrdinalIgnoreCase), timeout: 500, "Redirect did not happen"); + + browser.NavigateToUrl(SamplesRouteUrls.FeatureSamples_Redirect_RedirectPostbackConcurrency); + Assert.Equal(globalCounter(), initialCounter + 1); // only one click was allowed + }); + } + + [Fact] + public void Feature_Redirect_RedirectPostbackConcurrencyFileReturn() + { + RunInAllBrowsers(browser => { + browser.NavigateToUrl(SamplesRouteUrls.FeatureSamples_Redirect_RedirectPostbackConcurrency); + + void increment(int timeout) + { + browser.WaitFor(() => { + var original = int.Parse(browser.First("minicounter", SelectByDataUi).GetText()); + browser.First("minicounter", SelectByDataUi).Click(); + AssertUI.TextEquals(browser.First("minicounter", SelectByDataUi), (original + 1).ToString()); + }, timeout, "Could not increment minicounter in given timeout (postback queue is blocked)"); + } + + increment(3000); + + browser.First("file-std", SelectByDataUi).Click(); + increment(3000); + + browser.First("file-custom", SelectByDataUi).Click(); + // longer timeout, because DotVVM blocks postback queue for 5s after redirects to debounce any further requests + increment(15000); + }); + } } } From a600e96bd6c9be12c1a84f5aded5af853627f286 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Standa=20Luke=C5=A1?= Date: Sat, 7 Sep 2024 17:45:52 +0200 Subject: [PATCH 39/40] HtmlWriter: remove upper attribute warning, as it is redundant The same warning is emitted compile-time, which makes this one redundant. Moreover, the compile-time warning includes smarter checks, and allows certain cases of uppercase attributes (in prefixed capabilities) --- src/Framework/Framework/Controls/HtmlWriter.cs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/Framework/Framework/Controls/HtmlWriter.cs b/src/Framework/Framework/Controls/HtmlWriter.cs index 80cbce74c2..94e7e1ac68 100644 --- a/src/Framework/Framework/Controls/HtmlWriter.cs +++ b/src/Framework/Framework/Controls/HtmlWriter.cs @@ -322,11 +322,6 @@ private void WriteAttrWithTransformers(string name, string attributeName, string { WriteHtmlAttribute(attributeName, attributeValue); } - - if (this.enableWarnings && char.IsUpper(attributeName[0])) - { - Warn($"{attributeName} is used as an HTML attribute on element {name}, but it starts with an uppercase letter. Did you intent to use a DotVVM property instead? To silence this warning, just use all lowercase letters for standard HTML attributes."); - } } private string ConvertHtmlAttributeValue(object value) From 2f57498dc2aa843150ee98f78b24014a25719658 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Herceg=20=28RIGANTI=20s=2Er=2Eo=2E=29?= Date: Sat, 7 Sep 2024 18:37:31 +0200 Subject: [PATCH 40/40] Fixed warnings in compilation status page --- .../Parser/Dothtml/Tokenizer/DothtmlTokenizer.cs | 9 ++++++++- .../Diagnostics/CompilationDiagnostic.dotcontrol | 2 +- .../Diagnostics/CompilationDiagnosticRows.dotcontrol | 2 +- .../Parser/Dothtml/DothtmlTokenizerElementsTests.cs | 12 ++++++++++++ 4 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/Framework/Framework/Compilation/Parser/Dothtml/Tokenizer/DothtmlTokenizer.cs b/src/Framework/Framework/Compilation/Parser/Dothtml/Tokenizer/DothtmlTokenizer.cs index 1f44e55897..6b3fe1f0e9 100644 --- a/src/Framework/Framework/Compilation/Parser/Dothtml/Tokenizer/DothtmlTokenizer.cs +++ b/src/Framework/Framework/Compilation/Parser/Dothtml/Tokenizer/DothtmlTokenizer.cs @@ -227,7 +227,14 @@ private ReadElementType ReadElement(bool wasOpenBraceRead = false) if (!char.IsLetterOrDigit(firstChar) & firstChar != '/' & firstChar != ':') { - CreateToken(DothtmlTokenType.Text, errorProvider: t => CreateTokenError(t, "'<' char is not allowed in normal text")); + if (char.IsWhiteSpace(firstChar)) + { + CreateToken(DothtmlTokenType.Text); + } + else + { + CreateToken(DothtmlTokenType.Text, errorProvider: t => CreateTokenError(t, "'<' char is not allowed in normal text")); + } return ReadElementType.Error; } diff --git a/src/Framework/Framework/Diagnostics/CompilationDiagnostic.dotcontrol b/src/Framework/Framework/Diagnostics/CompilationDiagnostic.dotcontrol index d76471315c..dfaab7490c 100644 --- a/src/Framework/Framework/Diagnostics/CompilationDiagnostic.dotcontrol +++ b/src/Framework/Framework/Diagnostics/CompilationDiagnostic.dotcontrol @@ -2,7 +2,7 @@
-
+
{{value: LineNumber}}: {{value: SourceLinePrefix}}{{value: SourceLineHighlight}}{{value: SourceLineSuffix}}
diff --git a/src/Framework/Framework/Diagnostics/CompilationDiagnosticRows.dotcontrol b/src/Framework/Framework/Diagnostics/CompilationDiagnosticRows.dotcontrol index 34cd0cdc8a..9a84822eb3 100644 --- a/src/Framework/Framework/Diagnostics/CompilationDiagnosticRows.dotcontrol +++ b/src/Framework/Framework/Diagnostics/CompilationDiagnosticRows.dotcontrol @@ -6,7 +6,7 @@ 0}> - + diff --git a/src/Tests/Parser/Dothtml/DothtmlTokenizerElementsTests.cs b/src/Tests/Parser/Dothtml/DothtmlTokenizerElementsTests.cs index 0fd1c4fc11..697f0b2455 100644 --- a/src/Tests/Parser/Dothtml/DothtmlTokenizerElementsTests.cs +++ b/src/Tests/Parser/Dothtml/DothtmlTokenizerElementsTests.cs @@ -239,6 +239,18 @@ public void DothtmlTokenizer_Invalid_OpenBraceInText() tokenizer.Tokenize(input); Assert.IsTrue(tokenizer.Tokens.All(t => t.Type == DothtmlTokenType.Text)); + Assert.IsTrue(tokenizer.Tokens.All(t => !t.HasError)); + Assert.AreEqual(string.Concat(tokenizer.Tokens.Select(t => t.Text)), input); + } + + [TestMethod] + public void DothtmlTokenizer_Invalid_OpenBraceInTextWithoutSpace() + { + var input = "inline t.HasError)); Assert.AreEqual(string.Concat(tokenizer.Tokens.Select(t => t.Text)), input); }