Skip to content

Commit 1d3434a

Browse files
authored
Allow runtime compilation to be configured via the hosting startup. (#39381) (#39639)
As part of refactoring how Razor Pages are loaded, the ability to use runtime compilation via the hosting startup / env variable was broken. The refactoring assumed AddRuntimeCompilation was always invoked after a call to AddRazorPages / AddControllersWithViews etc which is not the case for registration via the HostingStartup. This PR address this defect. Fixes #38465
1 parent a8fe0e9 commit 1d3434a

File tree

10 files changed

+241
-44
lines changed

10 files changed

+241
-44
lines changed

src/Mvc/Mvc.Razor.RuntimeCompilation/src/DependencyInjection/RazorRuntimeCompilationMvcCoreBuilderExtensions.cs

+7-7
Original file line numberDiff line numberDiff line change
@@ -87,16 +87,16 @@ internal static void AddServices(IServiceCollection services)
8787

8888
if (actionDescriptorProvider != null)
8989
{
90+
// RuntimeCompilation registers an instance of PageActionDescriptorProvider(PageADP). CompiledPageADP and runtime compilation
91+
// cannot co-exist since CompiledPageADP will attempt to resolve action descriptors for lazily compiled views (such as for
92+
// ones from non-physical file providers). We'll instead remove CompiledPageActionDescriptors from the DI container if present.
9093
services.Remove(actionDescriptorProvider);
94+
}
9195

92-
// Add PageActionDescriptorProvider and the matcher policy that supports runtime compilation.
93-
// 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
94-
// will be absent. We'll use the presence of the CompiledPageActionDescriptorProvider service as a poor way to test this.
95-
services.TryAddEnumerable(
96-
ServiceDescriptor.Singleton<IActionDescriptorProvider, PageActionDescriptorProvider>());
96+
services.TryAddEnumerable(
97+
ServiceDescriptor.Singleton<IActionDescriptorProvider, PageActionDescriptorProvider>());
9798

98-
services.TryAddEnumerable(ServiceDescriptor.Singleton<MatcherPolicy, PageLoaderMatcherPolicy>());
99-
}
99+
services.TryAddEnumerable(ServiceDescriptor.Singleton<MatcherPolicy, PageLoaderMatcherPolicy>());
100100

101101
services.TryAddSingleton<RuntimeCompilationFileProvider>();
102102
services.TryAddSingleton<RazorReferenceManager>();

src/Mvc/Mvc.Razor.RuntimeCompilation/src/PageLoaderMatcherPolicy.cs

+19-10
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,27 @@
77
using Microsoft.AspNetCore.Http;
88
using Microsoft.AspNetCore.Routing;
99
using Microsoft.AspNetCore.Routing.Matching;
10+
using Microsoft.Extensions.DependencyInjection;
1011

1112
namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure
1213
{
1314
internal class PageLoaderMatcherPolicy : MatcherPolicy, IEndpointSelectorPolicy
1415
{
15-
private readonly PageLoader _loader;
16-
17-
public PageLoaderMatcherPolicy(PageLoader loader)
16+
private PageLoader? _loader;
17+
18+
/// <remarks>
19+
/// The <see cref="PageLoader"/> service is configured by <c>app.AddRazorPages()</c>.
20+
/// If the app is configured as <c>app.AddControllersWithViews().AddRazorRuntimeCompilation()</c>, the <see cref="PageLoader"/>
21+
/// service will not be registered. Since Razor Pages is not a pre-req for runtime compilation, we'll defer reading the service
22+
/// until we need to load a page in the body of <see cref="ApplyAsync(HttpContext, CandidateSet)"/>.
23+
/// </remarks>
24+
public PageLoaderMatcherPolicy()
25+
: this(loader: null)
1826
{
19-
if (loader == null)
20-
{
21-
throw new ArgumentNullException(nameof(loader));
22-
}
27+
}
2328

29+
public PageLoaderMatcherPolicy(PageLoader? loader)
30+
{
2431
_loader = loader;
2532
}
2633

@@ -76,6 +83,8 @@ public Task ApplyAsync(HttpContext httpContext, CandidateSet candidates)
7683
var page = endpoint.Metadata.GetMetadata<PageActionDescriptor>();
7784
if (page != null)
7885
{
86+
_loader ??= httpContext.RequestServices.GetRequiredService<PageLoader>();
87+
7988
// We found an endpoint instance that has a PageActionDescriptor, but not a
8089
// CompiledPageActionDescriptor. Update the CandidateSet.
8190
var compiled = _loader.LoadAsync(page, endpoint.Metadata);
@@ -88,15 +97,15 @@ public Task ApplyAsync(HttpContext httpContext, CandidateSet candidates)
8897
{
8998
// In the most common case, GetOrAddAsync will return a synchronous result.
9099
// Avoid going async since this is a fairly hot path.
91-
return ApplyAsyncAwaited(candidates, compiled, i);
100+
return ApplyAsyncAwaited(_loader, candidates, compiled, i);
92101
}
93102
}
94103
}
95104

96105
return Task.CompletedTask;
97106
}
98107

99-
private async Task ApplyAsyncAwaited(CandidateSet candidates, Task<CompiledPageActionDescriptor> actionDescriptorTask, int index)
108+
private static async Task ApplyAsyncAwaited(PageLoader pageLoader, CandidateSet candidates, Task<CompiledPageActionDescriptor> actionDescriptorTask, int index)
100109
{
101110
var compiled = await actionDescriptorTask;
102111

@@ -115,7 +124,7 @@ private async Task ApplyAsyncAwaited(CandidateSet candidates, Task<CompiledPageA
115124
var page = endpoint.Metadata.GetMetadata<PageActionDescriptor>();
116125
if (page != null)
117126
{
118-
compiled = await _loader.LoadAsync(page, endpoint.Metadata);
127+
compiled = await pageLoader.LoadAsync(page, endpoint.Metadata);
119128

120129
candidates.ReplaceEndpoint(i, compiled.Endpoint, candidates[i].Values);
121130
}

src/Mvc/Mvc.Razor.RuntimeCompilation/test/DependencyInjection/RazorRuntimeCompilationMvcCoreBuilderExtensionsTest.cs

-15
Original file line numberDiff line numberDiff line change
@@ -45,20 +45,5 @@ public void AddServices_ReplacesActionDescriptorProvider()
4545
serviceDescriptor = Assert.Single(services, service => service.ServiceType == typeof(MatcherPolicy));
4646
Assert.Equal(typeof(PageLoaderMatcherPolicy), serviceDescriptor.ImplementationType);
4747
}
48-
49-
[Fact]
50-
public void AddServices_DoesNotPageActionDescriptor_IfItWasNotPreviouslyFound()
51-
{
52-
// we want to make sure Page specific featurees are only added if AddRazorPages was called by the user.
53-
// Arrange
54-
var services = new ServiceCollection();
55-
56-
// Act
57-
RazorRuntimeCompilationMvcCoreBuilderExtensions.AddServices(services);
58-
59-
// Assert
60-
Assert.Empty(services.Where(service => service.ServiceType == typeof(IActionDescriptorProvider)));
61-
Assert.Empty(services.Where(service => service.ServiceType == typeof(MatcherPolicy)));
62-
}
6348
}
6449
}

src/Mvc/Mvc.Razor.RuntimeCompilation/test/PageLoaderMatcherPolicyTest.cs

+27
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
using Microsoft.AspNetCore.Mvc.Abstractions;
99
using Microsoft.AspNetCore.Routing;
1010
using Microsoft.AspNetCore.Routing.Matching;
11+
using Microsoft.Extensions.DependencyInjection;
1112
using Moq;
1213
using Xunit;
1314

@@ -34,6 +35,32 @@ public async Task ApplyAsync_UpdatesCandidateSet()
3435
Assert.Same(compiled.Endpoint, candidateSet[0].Endpoint);
3536
}
3637

38+
[Fact]
39+
public async Task ApplyAsync_ReadsLoaderFromRequestServices()
40+
{
41+
// Arrange
42+
var compiled = new CompiledPageActionDescriptor();
43+
compiled.Endpoint = CreateEndpoint(new PageActionDescriptor());
44+
45+
var candidateSet = CreateCandidateSet(compiled);
46+
var loader = new Mock<PageLoader>();
47+
loader.Setup(l => l.LoadAsync(It.IsAny<PageActionDescriptor>(), It.IsAny<EndpointMetadataCollection>()))
48+
.Returns(Task.FromResult(compiled))
49+
.Verifiable();
50+
var policy = new PageLoaderMatcherPolicy();
51+
var httpContext = new DefaultHttpContext
52+
{
53+
RequestServices = new ServiceCollection().AddSingleton(loader.Object).BuildServiceProvider(),
54+
};
55+
56+
// Act
57+
await policy.ApplyAsync(httpContext, candidateSet);
58+
59+
// Assert
60+
Assert.Same(compiled.Endpoint, candidateSet[0].Endpoint);
61+
loader.Verify();
62+
}
63+
3764
[Fact]
3865
public async Task ApplyAsync_UpdatesCandidateSet_IfLoaderReturnsAsynchronously()
3966
{

src/Mvc/Mvc.RazorPages/src/DependencyInjection/MvcRazorPagesMvcCoreBuilderExtensions.cs

+14-2
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33

44
using System;
5+
using System.Linq;
56
using Microsoft.AspNetCore.Mvc.Abstractions;
67
using Microsoft.AspNetCore.Mvc.ApplicationModels;
78
using Microsoft.AspNetCore.Mvc.Filters;
@@ -107,8 +108,19 @@ internal static void AddRazorPagesServices(IServiceCollection services)
107108
services.TryAddSingleton<PageActionEndpointDataSourceIdProvider>();
108109

109110
// Action description and invocation
110-
services.TryAddEnumerable(
111-
ServiceDescriptor.Singleton<IActionDescriptorProvider, CompiledPageActionDescriptorProvider>());
111+
var actionDescriptorProvider = services.FirstOrDefault(f =>
112+
f.ServiceType == typeof(IActionDescriptorProvider) &&
113+
f.ImplementationType == typeof(PageActionDescriptorProvider));
114+
115+
if (actionDescriptorProvider is null)
116+
{
117+
// RuntimeCompilation registers an instance of PageActionDescriptorProvider (PageADP). CompiledPageADP and runtime compilation
118+
// cannot co-exist since CompiledPageADP will attempt to resolve action descriptors for lazily compiled views (such as for
119+
// ones from non-physical file providers). We'll instead avoid adding it if PageADP is already registered. Similarly,
120+
// AddRazorRuntimeCompilation will remove CompiledPageADP if it is registered.
121+
services.TryAddEnumerable(
122+
ServiceDescriptor.Singleton<IActionDescriptorProvider, CompiledPageActionDescriptorProvider>());
123+
}
112124
services.TryAddEnumerable(
113125
ServiceDescriptor.Singleton<IPageRouteModelProvider, CompiledPageRouteModelProvider>());
114126
services.TryAddSingleton<PageActionEndpointDataSourceFactory>();

src/Mvc/Mvc.RazorPages/src/RazorPagesOptions.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ public class RazorPagesOptions : IEnumerable<ICompatibilitySwitch>
2121
/// Gets a collection of <see cref="IPageConvention"/> instances that are applied during
2222
/// route and page model construction.
2323
/// </summary>
24-
public PageConventionCollection Conventions { get; internal set; } = default!;
24+
public PageConventionCollection Conventions { get; internal set; } = new();
2525

2626
/// <summary>
2727
/// Application relative path used as the root of discovery for Razor Page files.

src/Mvc/Mvc.slnf

+2-1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"src\\DataProtection\\DataProtection\\src\\Microsoft.AspNetCore.DataProtection.csproj",
1414
"src\\DataProtection\\Extensions\\src\\Microsoft.AspNetCore.DataProtection.Extensions.csproj",
1515
"src\\DefaultBuilder\\src\\Microsoft.AspNetCore.csproj",
16+
"src\\Extensions\\Features\\src\\Microsoft.Extensions.Features.csproj",
1617
"src\\Features\\JsonPatch\\src\\Microsoft.AspNetCore.JsonPatch.csproj",
1718
"src\\FileProviders\\Embedded\\src\\Microsoft.Extensions.FileProviders.Embedded.csproj",
1819
"src\\FileProviders\\Manifest.MSBuildTask\\src\\Microsoft.Extensions.FileProviders.Embedded.Manifest.Task.csproj",
@@ -24,7 +25,6 @@
2425
"src\\Html.Abstractions\\src\\Microsoft.AspNetCore.Html.Abstractions.csproj",
2526
"src\\Http\\Authentication.Abstractions\\src\\Microsoft.AspNetCore.Authentication.Abstractions.csproj",
2627
"src\\Http\\Authentication.Core\\src\\Microsoft.AspNetCore.Authentication.Core.csproj",
27-
"src\\Extensions\\Features\\src\\Microsoft.Extensions.Features.csproj",
2828
"src\\Http\\Headers\\src\\Microsoft.Net.Http.Headers.csproj",
2929
"src\\Http\\Http.Abstractions\\src\\Microsoft.AspNetCore.Http.Abstractions.csproj",
3030
"src\\Http\\Http.Extensions\\src\\Microsoft.AspNetCore.Http.Extensions.csproj",
@@ -133,6 +133,7 @@
133133
"src\\Servers\\IIS\\IIS\\src\\Microsoft.AspNetCore.Server.IIS.csproj",
134134
"src\\Servers\\Kestrel\\Core\\src\\Microsoft.AspNetCore.Server.Kestrel.Core.csproj",
135135
"src\\Servers\\Kestrel\\Kestrel\\src\\Microsoft.AspNetCore.Server.Kestrel.csproj",
136+
"src\\Servers\\Kestrel\\Transport.Quic\\src\\Microsoft.AspNetCore.Server.Kestrel.Transport.Quic.csproj",
136137
"src\\Servers\\Kestrel\\Transport.Sockets\\src\\Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.csproj",
137138
"src\\SignalR\\common\\Http.Connections.Common\\src\\Microsoft.AspNetCore.Http.Connections.Common.csproj",
138139
"src\\SignalR\\common\\Http.Connections\\src\\Microsoft.AspNetCore.Http.Connections.csproj",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Net.Http;
5+
using Microsoft.AspNetCore.Hosting;
6+
using Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation;
7+
using Microsoft.AspNetCore.TestHost;
8+
using Microsoft.Extensions.DependencyInjection;
9+
10+
namespace Microsoft.AspNetCore.Mvc.FunctionalTests;
11+
12+
public class RazorRuntimeCompilationHostingStartupTest : IClassFixture<MvcTestFixture<RazorBuildWebSite.StartupWithHostingStartup>>
13+
{
14+
public RazorRuntimeCompilationHostingStartupTest(MvcTestFixture<RazorBuildWebSite.StartupWithHostingStartup> fixture)
15+
{
16+
var factory = fixture.Factories.FirstOrDefault() ?? fixture.WithWebHostBuilder(b => b.UseStartup<RazorBuildWebSite.StartupWithHostingStartup>());
17+
factory = factory.WithWebHostBuilder(b => b.ConfigureTestServices(serviceCollection => serviceCollection.Configure<MvcRazorRuntimeCompilationOptions>(ConfigureRuntimeCompilationOptions)));
18+
19+
Client = factory.CreateDefaultClient();
20+
21+
static void ConfigureRuntimeCompilationOptions(MvcRazorRuntimeCompilationOptions options)
22+
{
23+
// Workaround for incorrectly generated deps file. The build output has all of the binaries required to compile. We'll grab these and
24+
// add it to the list of assemblies runtime compilation uses.
25+
foreach (var path in Directory.EnumerateFiles(AppContext.BaseDirectory, "*.dll"))
26+
{
27+
options.AdditionalReferencePaths.Add(path);
28+
}
29+
}
30+
}
31+
32+
public HttpClient Client { get; }
33+
34+
[Fact]
35+
public async Task RazorViews_CanBeServedAndUpdatedViaRuntimeCompilation()
36+
{
37+
// Arrange
38+
var expected1 = "Original content";
39+
var path = "/Views/UpdateableViews/Index.cshtml";
40+
41+
// Act - 1
42+
var body = await Client.GetStringAsync("/UpdateableViews");
43+
44+
// Assert - 1
45+
Assert.Equal(expected1, body.Trim(), ignoreLineEndingDifferences: true);
46+
47+
// Act - 2
48+
await UpdateFile(path, "@GetType().Assembly");
49+
body = await Client.GetStringAsync("/UpdateableViews");
50+
51+
// Assert - 2
52+
var actual2 = body.Trim();
53+
Assert.NotEqual(expected1, actual2);
54+
55+
// Act - 3
56+
// With all things being the same, expect a cached compilation
57+
body = await Client.GetStringAsync("/UpdateableViews");
58+
59+
// Assert - 3
60+
Assert.Equal(actual2, body.Trim(), ignoreLineEndingDifferences: true);
61+
62+
// Act - 4
63+
// Trigger a change in ViewImports
64+
await UpdateFile("/Views/UpdateableViews/_ViewImports.cshtml", "new content");
65+
body = await Client.GetStringAsync("/UpdateableViews");
66+
67+
// Assert - 4
68+
Assert.NotEqual(actual2, body.Trim());
69+
}
70+
71+
[Fact]
72+
public async Task RazorPages_CanBeServedAndUpdatedViaRuntimeCompilation()
73+
{
74+
// Arrange
75+
var expected1 = "Original content";
76+
77+
// Act - 1
78+
var body = await Client.GetStringAsync("/UpdateablePage");
79+
80+
// Assert - 1
81+
Assert.Equal(expected1, body.Trim(), ignoreLineEndingDifferences: true);
82+
83+
// Act - 2
84+
await UpdateRazorPages();
85+
await UpdateFile("/Pages/UpdateablePage.cshtml", "@page" + Environment.NewLine + "@GetType().Assembly");
86+
body = await Client.GetStringAsync("/UpdateablePage");
87+
88+
// Assert - 2
89+
var actual2 = body.Trim();
90+
Assert.NotEqual(expected1, actual2);
91+
92+
// Act - 3
93+
// With all things being unchanged, we should get the cached page.
94+
body = await Client.GetStringAsync("/UpdateablePage");
95+
96+
// Assert - 3
97+
Assert.Equal(actual2, body.Trim(), ignoreLineEndingDifferences: true);
98+
}
99+
100+
private async Task UpdateFile(string path, string content)
101+
{
102+
var updateContent = new FormUrlEncodedContent(new Dictionary<string, string>
103+
{
104+
{ "path", path },
105+
{ "content", content },
106+
});
107+
108+
var response = await Client.PostAsync($"/UpdateableViews/Update", updateContent);
109+
response.EnsureSuccessStatusCode();
110+
}
111+
112+
private async Task UpdateRazorPages()
113+
{
114+
var response = await Client.PostAsync($"/UpdateableViews/UpdateRazorPages", new StringContent(string.Empty));
115+
response.EnsureSuccessStatusCode();
116+
}
117+
}

src/Mvc/test/WebSites/RazorBuildWebSite/Startup.cs

-8
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,6 @@ public void Configure(IApplicationBuilder app)
3131
});
3232
}
3333

34-
public static void Main(string[] args)
35-
{
36-
var host = CreateWebHostBuilder(args)
37-
.Build();
38-
39-
host.Run();
40-
}
41-
4234
public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
4335
new WebHostBuilder()
4436
.UseContentRoot(Directory.GetCurrentDirectory())

0 commit comments

Comments
 (0)