diff --git a/src/Mvc/Mvc.Razor.RuntimeCompilation/src/DependencyInjection/RazorRuntimeCompilationMvcCoreBuilderExtensions.cs b/src/Mvc/Mvc.Razor.RuntimeCompilation/src/DependencyInjection/RazorRuntimeCompilationMvcCoreBuilderExtensions.cs index c8706b41ac80..5c2206378f67 100644 --- a/src/Mvc/Mvc.Razor.RuntimeCompilation/src/DependencyInjection/RazorRuntimeCompilationMvcCoreBuilderExtensions.cs +++ b/src/Mvc/Mvc.Razor.RuntimeCompilation/src/DependencyInjection/RazorRuntimeCompilationMvcCoreBuilderExtensions.cs @@ -87,16 +87,16 @@ internal static void AddServices(IServiceCollection services) if (actionDescriptorProvider != null) { + // RuntimeCompilation registers an instance of PageActionDescriptorProvider(PageADP). CompiledPageADP and runtime compilation + // cannot co-exist since CompiledPageADP will attempt to resolve action descriptors for lazily compiled views (such as for + // ones from non-physical file providers). We'll instead remove CompiledPageActionDescriptors from the DI container if present. services.Remove(actionDescriptorProvider); + } - // Add PageActionDescriptorProvider and the matcher policy that supports runtime compilation. - // We only want to add support for this if we know AddRazorPages was called. In the absence of this, several services registered by Razor Pages - // will be absent. We'll use the presence of the CompiledPageActionDescriptorProvider service as a poor way to test this. - services.TryAddEnumerable( - ServiceDescriptor.Singleton()); + services.TryAddEnumerable( + ServiceDescriptor.Singleton()); - services.TryAddEnumerable(ServiceDescriptor.Singleton()); - } + services.TryAddEnumerable(ServiceDescriptor.Singleton()); services.TryAddSingleton(); services.TryAddSingleton(); diff --git a/src/Mvc/Mvc.Razor.RuntimeCompilation/src/PageLoaderMatcherPolicy.cs b/src/Mvc/Mvc.Razor.RuntimeCompilation/src/PageLoaderMatcherPolicy.cs index 49288286b3eb..6707470f52f4 100644 --- a/src/Mvc/Mvc.Razor.RuntimeCompilation/src/PageLoaderMatcherPolicy.cs +++ b/src/Mvc/Mvc.Razor.RuntimeCompilation/src/PageLoaderMatcherPolicy.cs @@ -7,20 +7,27 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing.Matching; +using Microsoft.Extensions.DependencyInjection; namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure { internal class PageLoaderMatcherPolicy : MatcherPolicy, IEndpointSelectorPolicy { - private readonly PageLoader _loader; - - public PageLoaderMatcherPolicy(PageLoader loader) + private PageLoader? _loader; + + /// + /// The service is configured by app.AddRazorPages(). + /// If the app is configured as app.AddControllersWithViews().AddRazorRuntimeCompilation(), the + /// service will not be registered. Since Razor Pages is not a pre-req for runtime compilation, we'll defer reading the service + /// until we need to load a page in the body of . + /// + public PageLoaderMatcherPolicy() + : this(loader: null) { - if (loader == null) - { - throw new ArgumentNullException(nameof(loader)); - } + } + public PageLoaderMatcherPolicy(PageLoader? loader) + { _loader = loader; } @@ -76,6 +83,8 @@ public Task ApplyAsync(HttpContext httpContext, CandidateSet candidates) var page = endpoint.Metadata.GetMetadata(); if (page != null) { + _loader ??= httpContext.RequestServices.GetRequiredService(); + // We found an endpoint instance that has a PageActionDescriptor, but not a // CompiledPageActionDescriptor. Update the CandidateSet. var compiled = _loader.LoadAsync(page, endpoint.Metadata); @@ -88,7 +97,7 @@ public Task ApplyAsync(HttpContext httpContext, CandidateSet candidates) { // In the most common case, GetOrAddAsync will return a synchronous result. // Avoid going async since this is a fairly hot path. - return ApplyAsyncAwaited(candidates, compiled, i); + return ApplyAsyncAwaited(_loader, candidates, compiled, i); } } } @@ -96,7 +105,7 @@ public Task ApplyAsync(HttpContext httpContext, CandidateSet candidates) return Task.CompletedTask; } - private async Task ApplyAsyncAwaited(CandidateSet candidates, Task actionDescriptorTask, int index) + private static async Task ApplyAsyncAwaited(PageLoader pageLoader, CandidateSet candidates, Task actionDescriptorTask, int index) { var compiled = await actionDescriptorTask; @@ -115,7 +124,7 @@ private async Task ApplyAsyncAwaited(CandidateSet candidates, Task(); if (page != null) { - compiled = await _loader.LoadAsync(page, endpoint.Metadata); + compiled = await pageLoader.LoadAsync(page, endpoint.Metadata); candidates.ReplaceEndpoint(i, compiled.Endpoint, candidates[i].Values); } diff --git a/src/Mvc/Mvc.Razor.RuntimeCompilation/test/DependencyInjection/RazorRuntimeCompilationMvcCoreBuilderExtensionsTest.cs b/src/Mvc/Mvc.Razor.RuntimeCompilation/test/DependencyInjection/RazorRuntimeCompilationMvcCoreBuilderExtensionsTest.cs index 893346bf093d..eaf29f1ec678 100644 --- a/src/Mvc/Mvc.Razor.RuntimeCompilation/test/DependencyInjection/RazorRuntimeCompilationMvcCoreBuilderExtensionsTest.cs +++ b/src/Mvc/Mvc.Razor.RuntimeCompilation/test/DependencyInjection/RazorRuntimeCompilationMvcCoreBuilderExtensionsTest.cs @@ -45,20 +45,5 @@ public void AddServices_ReplacesActionDescriptorProvider() serviceDescriptor = Assert.Single(services, service => service.ServiceType == typeof(MatcherPolicy)); Assert.Equal(typeof(PageLoaderMatcherPolicy), serviceDescriptor.ImplementationType); } - - [Fact] - public void AddServices_DoesNotPageActionDescriptor_IfItWasNotPreviouslyFound() - { - // we want to make sure Page specific featurees are only added if AddRazorPages was called by the user. - // Arrange - var services = new ServiceCollection(); - - // Act - RazorRuntimeCompilationMvcCoreBuilderExtensions.AddServices(services); - - // Assert - Assert.Empty(services.Where(service => service.ServiceType == typeof(IActionDescriptorProvider))); - Assert.Empty(services.Where(service => service.ServiceType == typeof(MatcherPolicy))); - } } } diff --git a/src/Mvc/Mvc.Razor.RuntimeCompilation/test/PageLoaderMatcherPolicyTest.cs b/src/Mvc/Mvc.Razor.RuntimeCompilation/test/PageLoaderMatcherPolicyTest.cs index d530917ddc49..ed6bd2b82993 100644 --- a/src/Mvc/Mvc.Razor.RuntimeCompilation/test/PageLoaderMatcherPolicyTest.cs +++ b/src/Mvc/Mvc.Razor.RuntimeCompilation/test/PageLoaderMatcherPolicyTest.cs @@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing.Matching; +using Microsoft.Extensions.DependencyInjection; using Moq; using Xunit; @@ -34,6 +35,32 @@ public async Task ApplyAsync_UpdatesCandidateSet() Assert.Same(compiled.Endpoint, candidateSet[0].Endpoint); } + [Fact] + public async Task ApplyAsync_ReadsLoaderFromRequestServices() + { + // Arrange + var compiled = new CompiledPageActionDescriptor(); + compiled.Endpoint = CreateEndpoint(new PageActionDescriptor()); + + var candidateSet = CreateCandidateSet(compiled); + var loader = new Mock(); + loader.Setup(l => l.LoadAsync(It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(compiled)) + .Verifiable(); + var policy = new PageLoaderMatcherPolicy(); + var httpContext = new DefaultHttpContext + { + RequestServices = new ServiceCollection().AddSingleton(loader.Object).BuildServiceProvider(), + }; + + // Act + await policy.ApplyAsync(httpContext, candidateSet); + + // Assert + Assert.Same(compiled.Endpoint, candidateSet[0].Endpoint); + loader.Verify(); + } + [Fact] public async Task ApplyAsync_UpdatesCandidateSet_IfLoaderReturnsAsynchronously() { diff --git a/src/Mvc/Mvc.RazorPages/src/DependencyInjection/MvcRazorPagesMvcCoreBuilderExtensions.cs b/src/Mvc/Mvc.RazorPages/src/DependencyInjection/MvcRazorPagesMvcCoreBuilderExtensions.cs index 547d6fe2ebb9..2d55932a52f5 100644 --- a/src/Mvc/Mvc.RazorPages/src/DependencyInjection/MvcRazorPagesMvcCoreBuilderExtensions.cs +++ b/src/Mvc/Mvc.RazorPages/src/DependencyInjection/MvcRazorPagesMvcCoreBuilderExtensions.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Linq; using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.ApplicationModels; using Microsoft.AspNetCore.Mvc.Filters; @@ -107,8 +108,19 @@ internal static void AddRazorPagesServices(IServiceCollection services) services.TryAddSingleton(); // Action description and invocation - services.TryAddEnumerable( - ServiceDescriptor.Singleton()); + var actionDescriptorProvider = services.FirstOrDefault(f => + f.ServiceType == typeof(IActionDescriptorProvider) && + f.ImplementationType == typeof(PageActionDescriptorProvider)); + + if (actionDescriptorProvider is null) + { + // RuntimeCompilation registers an instance of PageActionDescriptorProvider (PageADP). CompiledPageADP and runtime compilation + // cannot co-exist since CompiledPageADP will attempt to resolve action descriptors for lazily compiled views (such as for + // ones from non-physical file providers). We'll instead avoid adding it if PageADP is already registered. Similarly, + // AddRazorRuntimeCompilation will remove CompiledPageADP if it is registered. + services.TryAddEnumerable( + ServiceDescriptor.Singleton()); + } services.TryAddEnumerable( ServiceDescriptor.Singleton()); services.TryAddSingleton(); diff --git a/src/Mvc/Mvc.RazorPages/src/RazorPagesOptions.cs b/src/Mvc/Mvc.RazorPages/src/RazorPagesOptions.cs index d5be840aa0fe..85c88291a77d 100644 --- a/src/Mvc/Mvc.RazorPages/src/RazorPagesOptions.cs +++ b/src/Mvc/Mvc.RazorPages/src/RazorPagesOptions.cs @@ -21,7 +21,7 @@ public class RazorPagesOptions : IEnumerable /// Gets a collection of instances that are applied during /// route and page model construction. /// - public PageConventionCollection Conventions { get; internal set; } = default!; + public PageConventionCollection Conventions { get; internal set; } = new(); /// /// Application relative path used as the root of discovery for Razor Page files. diff --git a/src/Mvc/Mvc.slnf b/src/Mvc/Mvc.slnf index b7b414489896..1dee0bc428eb 100644 --- a/src/Mvc/Mvc.slnf +++ b/src/Mvc/Mvc.slnf @@ -13,6 +13,7 @@ "src\\DataProtection\\DataProtection\\src\\Microsoft.AspNetCore.DataProtection.csproj", "src\\DataProtection\\Extensions\\src\\Microsoft.AspNetCore.DataProtection.Extensions.csproj", "src\\DefaultBuilder\\src\\Microsoft.AspNetCore.csproj", + "src\\Extensions\\Features\\src\\Microsoft.Extensions.Features.csproj", "src\\Features\\JsonPatch\\src\\Microsoft.AspNetCore.JsonPatch.csproj", "src\\FileProviders\\Embedded\\src\\Microsoft.Extensions.FileProviders.Embedded.csproj", "src\\FileProviders\\Manifest.MSBuildTask\\src\\Microsoft.Extensions.FileProviders.Embedded.Manifest.Task.csproj", @@ -24,7 +25,6 @@ "src\\Html.Abstractions\\src\\Microsoft.AspNetCore.Html.Abstractions.csproj", "src\\Http\\Authentication.Abstractions\\src\\Microsoft.AspNetCore.Authentication.Abstractions.csproj", "src\\Http\\Authentication.Core\\src\\Microsoft.AspNetCore.Authentication.Core.csproj", - "src\\Extensions\\Features\\src\\Microsoft.Extensions.Features.csproj", "src\\Http\\Headers\\src\\Microsoft.Net.Http.Headers.csproj", "src\\Http\\Http.Abstractions\\src\\Microsoft.AspNetCore.Http.Abstractions.csproj", "src\\Http\\Http.Extensions\\src\\Microsoft.AspNetCore.Http.Extensions.csproj", @@ -133,6 +133,7 @@ "src\\Servers\\IIS\\IIS\\src\\Microsoft.AspNetCore.Server.IIS.csproj", "src\\Servers\\Kestrel\\Core\\src\\Microsoft.AspNetCore.Server.Kestrel.Core.csproj", "src\\Servers\\Kestrel\\Kestrel\\src\\Microsoft.AspNetCore.Server.Kestrel.csproj", + "src\\Servers\\Kestrel\\Transport.Quic\\src\\Microsoft.AspNetCore.Server.Kestrel.Transport.Quic.csproj", "src\\Servers\\Kestrel\\Transport.Sockets\\src\\Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.csproj", "src\\SignalR\\common\\Http.Connections.Common\\src\\Microsoft.AspNetCore.Http.Connections.Common.csproj", "src\\SignalR\\common\\Http.Connections\\src\\Microsoft.AspNetCore.Http.Connections.csproj", diff --git a/src/Mvc/test/Mvc.FunctionalTests/RazorRuntimeCompilationHostingStartupTest.cs b/src/Mvc/test/Mvc.FunctionalTests/RazorRuntimeCompilationHostingStartupTest.cs new file mode 100644 index 000000000000..3961eea10b53 --- /dev/null +++ b/src/Mvc/test/Mvc.FunctionalTests/RazorRuntimeCompilationHostingStartupTest.cs @@ -0,0 +1,117 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net.Http; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.AspNetCore.Mvc.FunctionalTests; + +public class RazorRuntimeCompilationHostingStartupTest : IClassFixture> +{ + public RazorRuntimeCompilationHostingStartupTest(MvcTestFixture fixture) + { + var factory = fixture.Factories.FirstOrDefault() ?? fixture.WithWebHostBuilder(b => b.UseStartup()); + factory = factory.WithWebHostBuilder(b => b.ConfigureTestServices(serviceCollection => serviceCollection.Configure(ConfigureRuntimeCompilationOptions))); + + Client = factory.CreateDefaultClient(); + + static void ConfigureRuntimeCompilationOptions(MvcRazorRuntimeCompilationOptions options) + { + // Workaround for incorrectly generated deps file. The build output has all of the binaries required to compile. We'll grab these and + // add it to the list of assemblies runtime compilation uses. + foreach (var path in Directory.EnumerateFiles(AppContext.BaseDirectory, "*.dll")) + { + options.AdditionalReferencePaths.Add(path); + } + } + } + + public HttpClient Client { get; } + + [Fact] + public async Task RazorViews_CanBeServedAndUpdatedViaRuntimeCompilation() + { + // Arrange + var expected1 = "Original content"; + var path = "/Views/UpdateableViews/Index.cshtml"; + + // Act - 1 + var body = await Client.GetStringAsync("/UpdateableViews"); + + // Assert - 1 + Assert.Equal(expected1, body.Trim(), ignoreLineEndingDifferences: true); + + // Act - 2 + await UpdateFile(path, "@GetType().Assembly"); + body = await Client.GetStringAsync("/UpdateableViews"); + + // Assert - 2 + var actual2 = body.Trim(); + Assert.NotEqual(expected1, actual2); + + // Act - 3 + // With all things being the same, expect a cached compilation + body = await Client.GetStringAsync("/UpdateableViews"); + + // Assert - 3 + Assert.Equal(actual2, body.Trim(), ignoreLineEndingDifferences: true); + + // Act - 4 + // Trigger a change in ViewImports + await UpdateFile("/Views/UpdateableViews/_ViewImports.cshtml", "new content"); + body = await Client.GetStringAsync("/UpdateableViews"); + + // Assert - 4 + Assert.NotEqual(actual2, body.Trim()); + } + + [Fact] + public async Task RazorPages_CanBeServedAndUpdatedViaRuntimeCompilation() + { + // Arrange + var expected1 = "Original content"; + + // Act - 1 + var body = await Client.GetStringAsync("/UpdateablePage"); + + // Assert - 1 + Assert.Equal(expected1, body.Trim(), ignoreLineEndingDifferences: true); + + // Act - 2 + await UpdateRazorPages(); + await UpdateFile("/Pages/UpdateablePage.cshtml", "@page" + Environment.NewLine + "@GetType().Assembly"); + body = await Client.GetStringAsync("/UpdateablePage"); + + // Assert - 2 + var actual2 = body.Trim(); + Assert.NotEqual(expected1, actual2); + + // Act - 3 + // With all things being unchanged, we should get the cached page. + body = await Client.GetStringAsync("/UpdateablePage"); + + // Assert - 3 + Assert.Equal(actual2, body.Trim(), ignoreLineEndingDifferences: true); + } + + private async Task UpdateFile(string path, string content) + { + var updateContent = new FormUrlEncodedContent(new Dictionary + { + { "path", path }, + { "content", content }, + }); + + var response = await Client.PostAsync($"/UpdateableViews/Update", updateContent); + response.EnsureSuccessStatusCode(); + } + + private async Task UpdateRazorPages() + { + var response = await Client.PostAsync($"/UpdateableViews/UpdateRazorPages", new StringContent(string.Empty)); + response.EnsureSuccessStatusCode(); + } +} diff --git a/src/Mvc/test/WebSites/RazorBuildWebSite/Startup.cs b/src/Mvc/test/WebSites/RazorBuildWebSite/Startup.cs index 20e005248396..0d7a9eb63141 100644 --- a/src/Mvc/test/WebSites/RazorBuildWebSite/Startup.cs +++ b/src/Mvc/test/WebSites/RazorBuildWebSite/Startup.cs @@ -31,14 +31,6 @@ public void Configure(IApplicationBuilder app) }); } - public static void Main(string[] args) - { - var host = CreateWebHostBuilder(args) - .Build(); - - host.Run(); - } - public static IWebHostBuilder CreateWebHostBuilder(string[] args) => new WebHostBuilder() .UseContentRoot(Directory.GetCurrentDirectory()) diff --git a/src/Mvc/test/WebSites/RazorBuildWebSite/StartupWithHostingStartup.cs b/src/Mvc/test/WebSites/RazorBuildWebSite/StartupWithHostingStartup.cs new file mode 100644 index 000000000000..78c56e00ed5a --- /dev/null +++ b/src/Mvc/test/WebSites/RazorBuildWebSite/StartupWithHostingStartup.cs @@ -0,0 +1,54 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Mvc.ApplicationParts; + +namespace RazorBuildWebSite; + +public class StartupWithHostingStartup +{ + public void ConfigureServices(IServiceCollection services) + { + var fileProvider = new UpdateableFileProvider(); + + // RuntimeCompilation supports a hosting startup that adds services before AddRazorPagesServices is invoked. This startup simulates + // this configuration by simply putting the call to AddRazorRuntimeCompilation ahead of AddControllersWithViews / AddRazorPages. + var mvcBuilder = new MockMvcBuilder { Services = services, }; + mvcBuilder.AddRazorRuntimeCompilation(options => options.FileProviders.Add(fileProvider)); + + services.AddSingleton(fileProvider); + services.AddControllersWithViews(); + services.AddRazorPages(); + } + + public void Configure(IApplicationBuilder app) + { + app.UseRouting(); + app.UseEndpoints(endpoints => + { + endpoints.MapDefaultControllerRoute(); + endpoints.MapRazorPages(); + endpoints.MapFallbackToPage("/Fallback"); + }); + } + + public static void Main(string[] args) + { + var host = CreateWebHostBuilder(args) + .Build(); + + host.Run(); + } + + public static IWebHostBuilder CreateWebHostBuilder(string[] args) => + new WebHostBuilder() + .UseContentRoot(Directory.GetCurrentDirectory()) + .UseStartup() + .UseKestrel(); + + private class MockMvcBuilder : IMvcBuilder + { + public IServiceCollection Services { get; set; } + public ApplicationPartManager PartManager { get; } + } +}