Skip to content

Commit 4e624bc

Browse files
committed
Allow runtime compilation to be configured via the hosting startup.
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 dotnet#38465
1 parent ba177a2 commit 4e624bc

File tree

10 files changed

+242
-46
lines changed

10 files changed

+242
-46
lines changed

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

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -86,17 +86,16 @@ internal static void AddServices(IServiceCollection services)
8686

8787
if (actionDescriptorProvider != null)
8888
{
89+
// RuntimeCompilation registers an instance of PageActionDescriptorProvider(PageADP). CompiledPageADP and runtime compilation
90+
// cannot co-exist since CompiledPageADP will attempt to resolve action descriptors for lazily compiled views (such as for
91+
// ones from non-physical file providers). We'll instead remove CompiledPageActionDescriptors from the DI container if present.
8992
services.Remove(actionDescriptorProvider);
90-
91-
// Add PageActionDescriptorProvider and the matcher policy that supports runtime compilation.
92-
// 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
93-
// will be absent. We'll use the presence of the CompiledPageActionDescriptorProvider service as a poor way to test this.
94-
services.TryAddEnumerable(
95-
ServiceDescriptor.Singleton<IActionDescriptorProvider, PageActionDescriptorProvider>());
96-
97-
services.TryAddEnumerable(ServiceDescriptor.Singleton<MatcherPolicy, PageLoaderMatcherPolicy>());
9893
}
9994

95+
services.TryAddEnumerable(
96+
ServiceDescriptor.Singleton<IActionDescriptorProvider, PageActionDescriptorProvider>());
97+
services.TryAddEnumerable(ServiceDescriptor.Singleton<MatcherPolicy, PageLoaderMatcherPolicy>());
98+
10099
services.TryAddSingleton<RuntimeCompilationFileProvider>();
101100
services.TryAddSingleton<RazorReferenceManager>();
102101
services.TryAddSingleton<CSharpCompiler>();

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

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,27 @@
44
using Microsoft.AspNetCore.Http;
55
using Microsoft.AspNetCore.Routing;
66
using Microsoft.AspNetCore.Routing.Matching;
7+
using Microsoft.Extensions.DependencyInjection;
78

89
namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure;
910

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

26+
public PageLoaderMatcherPolicy(PageLoader? loader)
27+
{
2128
_loader = loader;
2229
}
2330

@@ -73,6 +80,8 @@ public Task ApplyAsync(HttpContext httpContext, CandidateSet candidates)
7380
var page = endpoint.Metadata.GetMetadata<PageActionDescriptor>();
7481
if (page != null)
7582
{
83+
_loader ??= httpContext.RequestServices.GetRequiredService<PageLoader>();
84+
7685
// We found an endpoint instance that has a PageActionDescriptor, but not a
7786
// CompiledPageActionDescriptor. Update the CandidateSet.
7887
var compiled = _loader.LoadAsync(page, endpoint.Metadata);
@@ -85,15 +94,15 @@ public Task ApplyAsync(HttpContext httpContext, CandidateSet candidates)
8594
{
8695
// In the most common case, GetOrAddAsync will return a synchronous result.
8796
// Avoid going async since this is a fairly hot path.
88-
return ApplyAsyncAwaited(candidates, compiled, i);
97+
return ApplyAsyncAwaited(_loader, candidates, compiled, i);
8998
}
9099
}
91100
}
92101

93102
return Task.CompletedTask;
94103
}
95104

96-
private async Task ApplyAsyncAwaited(CandidateSet candidates, Task<CompiledPageActionDescriptor> actionDescriptorTask, int index)
105+
private static async Task ApplyAsyncAwaited(PageLoader pageLoader, CandidateSet candidates, Task<CompiledPageActionDescriptor> actionDescriptorTask, int index)
97106
{
98107
var compiled = await actionDescriptorTask;
99108

@@ -112,7 +121,7 @@ private async Task ApplyAsyncAwaited(CandidateSet candidates, Task<CompiledPageA
112121
var page = endpoint.Metadata.GetMetadata<PageActionDescriptor>();
113122
if (page != null)
114123
{
115-
compiled = await _loader.LoadAsync(page, endpoint.Metadata);
124+
compiled = await pageLoader.LoadAsync(page, endpoint.Metadata);
116125

117126
candidates.ReplaceEndpoint(i, compiled.Endpoint, candidates[i].Values);
118127
}

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

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -43,19 +43,4 @@ public void AddServices_ReplacesActionDescriptorProvider()
4343
serviceDescriptor = Assert.Single(services, service => service.ServiceType == typeof(MatcherPolicy));
4444
Assert.Equal(typeof(PageLoaderMatcherPolicy), serviceDescriptor.ImplementationType);
4545
}
46-
47-
[Fact]
48-
public void AddServices_DoesNotPageActionDescriptor_IfItWasNotPreviouslyFound()
49-
{
50-
// we want to make sure Page specific featurees are only added if AddRazorPages was called by the user.
51-
// Arrange
52-
var services = new ServiceCollection();
53-
54-
// Act
55-
RazorRuntimeCompilationMvcCoreBuilderExtensions.AddServices(services);
56-
57-
// Assert
58-
Assert.Empty(services.Where(service => service.ServiceType == typeof(IActionDescriptorProvider)));
59-
Assert.Empty(services.Where(service => service.ServiceType == typeof(MatcherPolicy)));
60-
}
6146
}

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using Microsoft.AspNetCore.Mvc.Abstractions;
66
using Microsoft.AspNetCore.Routing;
77
using Microsoft.AspNetCore.Routing.Matching;
8+
using Microsoft.Extensions.DependencyInjection;
89
using Moq;
910

1011
namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure;
@@ -30,6 +31,32 @@ public async Task ApplyAsync_UpdatesCandidateSet()
3031
Assert.Same(compiled.Endpoint, candidateSet[0].Endpoint);
3132
}
3233

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

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

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using System.Linq;
45
using Microsoft.AspNetCore.Mvc.Abstractions;
56
using Microsoft.AspNetCore.Mvc.ApplicationModels;
67
using Microsoft.AspNetCore.Mvc.Filters;
@@ -106,8 +107,19 @@ internal static void AddRazorPagesServices(IServiceCollection services)
106107
services.TryAddSingleton<PageActionEndpointDataSourceIdProvider>();
107108

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

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

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

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

src/Mvc/Mvc.slnf

Lines changed: 2 additions & 1 deletion
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",
Lines changed: 117 additions & 0 deletions
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

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,6 @@ public void Configure(IApplicationBuilder app)
2525
});
2626
}
2727

28-
public static void Main(string[] args)
29-
{
30-
var host = CreateWebHostBuilder(args)
31-
.Build();
32-
33-
host.Run();
34-
}
35-
3628
public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
3729
new WebHostBuilder()
3830
.UseContentRoot(Directory.GetCurrentDirectory())

0 commit comments

Comments
 (0)