diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/DefaultActionDescriptorCollectionProvider.cs b/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/DefaultActionDescriptorCollectionProvider.cs index 9dce3d6f5a..aef4292b6c 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/DefaultActionDescriptorCollectionProvider.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/DefaultActionDescriptorCollectionProvider.cs @@ -1,7 +1,6 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. -using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Diagnostics; diff --git a/src/Microsoft.AspNetCore.Mvc.Razor/DependencyInjection/MvcRazorMvcCoreBuilderExtensions.cs b/src/Microsoft.AspNetCore.Mvc.Razor/DependencyInjection/MvcRazorMvcCoreBuilderExtensions.cs index 4960f18b41..7ede8232f4 100644 --- a/src/Microsoft.AspNetCore.Mvc.Razor/DependencyInjection/MvcRazorMvcCoreBuilderExtensions.cs +++ b/src/Microsoft.AspNetCore.Mvc.Razor/DependencyInjection/MvcRazorMvcCoreBuilderExtensions.cs @@ -159,6 +159,9 @@ namespace Microsoft.Extensions.DependencyInjection services.TryAddEnumerable( ServiceDescriptor.Transient, RazorViewEngineOptionsSetup>()); + services.TryAddEnumerable( + ServiceDescriptor.Transient, RazorViewEngineOptionsSetup>()); + services.TryAddSingleton< IRazorViewEngineFileProviderAccessor, DefaultRazorViewEngineFileProviderAccessor>(); diff --git a/src/Microsoft.AspNetCore.Mvc.Razor/Internal/RazorViewCompiler.cs b/src/Microsoft.AspNetCore.Mvc.Razor/Internal/RazorViewCompiler.cs index 3d107018c2..9bbce19d88 100644 --- a/src/Microsoft.AspNetCore.Mvc.Razor/Internal/RazorViewCompiler.cs +++ b/src/Microsoft.AspNetCore.Mvc.Razor/Internal/RazorViewCompiler.cs @@ -116,6 +116,8 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal } } + public bool AllowRecompilingViewsOnFileChange { get; set; } + /// public Task CompileAsync(string relativePath) { @@ -254,16 +256,9 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal // Used to validate and recompile NormalizedPath = normalizedPath, - ExpirationTokens = new List(), - }; - var checksums = precompiledView.Item.GetChecksumMetadata(); - for (var i = 0; i < checksums.Count; i++) - { - // We rely on Razor to provide the right set of checksums. Trust the compiler, it has to do a good job, - // so it probably will. - item.ExpirationTokens.Add(_fileProvider.Watch(checksums[i].Identifier)); - } + ExpirationTokens = GetExpirationTokens(precompiledView), + }; // We also need to create a new descriptor, because the original one doesn't have expiration tokens on // it. These will be used by the view location cache, which is like an L1 cache for views (this class is @@ -282,10 +277,13 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal private ViewCompilerWorkItem CreateRuntimeCompilationWorkItem(string normalizedPath) { - var expirationTokens = new List() + IList expirationTokens = Array.Empty(); + + if (AllowRecompilingViewsOnFileChange) { - _fileProvider.Watch(normalizedPath), - }; + var changeToken = _fileProvider.Watch(normalizedPath); + expirationTokens = new List { changeToken }; + } var projectItem = _projectEngine.FileSystem.GetItem(normalizedPath); if (!projectItem.Exists) @@ -313,9 +311,46 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal _logger.ViewCompilerFoundFileToCompile(normalizedPath); + GetChangeTokensFromImports(expirationTokens, projectItem); + + return new ViewCompilerWorkItem() + { + SupportsCompilation = true, + + NormalizedPath = normalizedPath, + ExpirationTokens = expirationTokens, + }; + } + + private IList GetExpirationTokens(CompiledViewDescriptor precompiledView) + { + if (!AllowRecompilingViewsOnFileChange) + { + return Array.Empty(); + } + + var checksums = precompiledView.Item.GetChecksumMetadata(); + var expirationTokens = new List(checksums.Count); + + for (var i = 0; i < checksums.Count; i++) + { + // We rely on Razor to provide the right set of checksums. Trust the compiler, it has to do a good job, + // so it probably will. + expirationTokens.Add(_fileProvider.Watch(checksums[i].Identifier)); + } + + return expirationTokens; + } + + private void GetChangeTokensFromImports(IList expirationTokens, RazorProjectItem projectItem) + { + if (!AllowRecompilingViewsOnFileChange) + { + return; + } + // OK this means we can do compilation. For now let's just identify the other files we need to watch // so we can create the cache entry. Compilation will happen after we release the lock. - var importFeature = _projectEngine.ProjectFeatures.OfType().FirstOrDefault(); // There should always be an import feature unless someone has misconfigured their RazorProjectEngine. @@ -330,14 +365,6 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal { expirationTokens.Add(_fileProvider.Watch(physicalImport.FilePath)); } - - return new ViewCompilerWorkItem() - { - SupportsCompilation = true, - - NormalizedPath = normalizedPath, - ExpirationTokens = expirationTokens, - }; } protected virtual CompiledViewDescriptor CompileAndEmit(string relativePath) diff --git a/src/Microsoft.AspNetCore.Mvc.Razor/Internal/RazorViewCompilerProvider.cs b/src/Microsoft.AspNetCore.Mvc.Razor/Internal/RazorViewCompilerProvider.cs index 560b1493d6..3776bc845f 100644 --- a/src/Microsoft.AspNetCore.Mvc.Razor/Internal/RazorViewCompilerProvider.cs +++ b/src/Microsoft.AspNetCore.Mvc.Razor/Internal/RazorViewCompilerProvider.cs @@ -80,7 +80,10 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal #pragma warning restore CS0618 // Type or member is obsolete feature.ViewDescriptors, _compilationMemoryCacheProvider.CompilationMemoryCache, - _logger); + _logger) + { + AllowRecompilingViewsOnFileChange = _viewEngineOptions.AllowRecompilingViewsOnFileChange, + }; } } } diff --git a/src/Microsoft.AspNetCore.Mvc.Razor/Properties/AssemblyInfo.cs b/src/Microsoft.AspNetCore.Mvc.Razor/Properties/AssemblyInfo.cs index 5bd324d5d6..73c4c5a356 100644 --- a/src/Microsoft.AspNetCore.Mvc.Razor/Properties/AssemblyInfo.cs +++ b/src/Microsoft.AspNetCore.Mvc.Razor/Properties/AssemblyInfo.cs @@ -7,3 +7,4 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] [assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.Razor.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.RazorPages.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] diff --git a/src/Microsoft.AspNetCore.Mvc.Razor/RazorViewEngineOptions.cs b/src/Microsoft.AspNetCore.Mvc.Razor/RazorViewEngineOptions.cs index 8ae0120211..6c6148cb24 100644 --- a/src/Microsoft.AspNetCore.Mvc.Razor/RazorViewEngineOptions.cs +++ b/src/Microsoft.AspNetCore.Mvc.Razor/RazorViewEngineOptions.cs @@ -2,8 +2,10 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Collections; using System.Collections.Generic; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Mvc.Razor.Compilation; using Microsoft.CodeAnalysis; using Microsoft.Extensions.FileProviders; @@ -13,10 +15,21 @@ namespace Microsoft.AspNetCore.Mvc.Razor /// /// Provides programmatic configuration for the . /// - public class RazorViewEngineOptions + public class RazorViewEngineOptions : IEnumerable { + private readonly ICompatibilitySwitch[] _switches; + private readonly CompatibilitySwitch _allowRecompilingViewsOnFileChange; private Action _compilationCallback = c => { }; + public RazorViewEngineOptions() + { + _allowRecompilingViewsOnFileChange = new CompatibilitySwitch(nameof(AllowRecompilingViewsOnFileChange)); + _switches = new[] + { + _allowRecompilingViewsOnFileChange, + }; + } + /// /// Gets a used by the . /// @@ -181,5 +194,52 @@ namespace Microsoft.AspNetCore.Mvc.Razor _compilationCallback = value; } } + + /// + /// Gets or sets a value that determines if Razor files (Razor Views and Razor Pages) are recompiled and updated + /// if files change on disk. + /// + /// When , MVC will use to watch for changes to + /// Razor files in configured instances. + /// + /// + /// + /// The default value is if the version is + /// or earlier. If the version is later and is Development, + /// the default value is . Otherwise, the default value is . + /// + /// + /// + /// This property is associated with a compatibility switch and can provide a different behavior depending on + /// the configured compatibility version for the application. See for + /// guidance and examples of setting the application's compatibility version. + /// + /// + /// Configuring the desired value of the compatibility switch by calling this property's setter will take + /// precedence over the value implied by the application's . + /// + /// + /// If the application's compatibility version is set to or + /// lower then this setting will have the value unless explicitly configured. + /// + /// + /// If the application's compatibility version is set to or + /// higher then this setting will have the value unless + /// is Development or the value is explicitly configured. + /// + /// + public bool AllowRecompilingViewsOnFileChange + { + // Note: When compatibility switches are removed in 3.0, this property should be retained as a regular boolean property. + get => _allowRecompilingViewsOnFileChange.Value; + set => _allowRecompilingViewsOnFileChange.Value = value; + } + + IEnumerator IEnumerable.GetEnumerator() + { + return ((IEnumerable)_switches).GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() => _switches.GetEnumerator(); } } diff --git a/src/Microsoft.AspNetCore.Mvc.Razor/Internal/RazorViewEngineOptionsSetup.cs b/src/Microsoft.AspNetCore.Mvc.Razor/RazorViewEngineOptionsSetup.cs similarity index 50% rename from src/Microsoft.AspNetCore.Mvc.Razor/Internal/RazorViewEngineOptionsSetup.cs rename to src/Microsoft.AspNetCore.Mvc.Razor/RazorViewEngineOptionsSetup.cs index e8c49113ba..6747d87726 100644 --- a/src/Microsoft.AspNetCore.Mvc.Razor/Internal/RazorViewEngineOptionsSetup.cs +++ b/src/Microsoft.AspNetCore.Mvc.Razor/RazorViewEngineOptionsSetup.cs @@ -2,27 +2,45 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Collections.Generic; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -namespace Microsoft.AspNetCore.Mvc.Razor.Internal +namespace Microsoft.AspNetCore.Mvc.Razor { - /// - /// Sets up default options for . - /// - public class RazorViewEngineOptionsSetup : IConfigureOptions + internal class RazorViewEngineOptionsSetup : + ConfigureCompatibilityOptions, + IConfigureOptions { private readonly IHostingEnvironment _hostingEnvironment; - /// - /// Initializes a new instance of . - /// - /// for the application. - public RazorViewEngineOptionsSetup(IHostingEnvironment hostingEnvironment) + public RazorViewEngineOptionsSetup( + IHostingEnvironment hostingEnvironment, + ILoggerFactory loggerFactory, + IOptions compatibilityOptions) + : base(loggerFactory, compatibilityOptions) { _hostingEnvironment = hostingEnvironment ?? throw new ArgumentNullException(nameof(hostingEnvironment)); } + protected override IReadOnlyDictionary DefaultValues + { + get + { + var values = new Dictionary(); + if (Version < CompatibilityVersion.Version_2_2) + { + // Default to true in 2.1 or earlier. In 2.2, we have to conditionally enable this + // and consequently this switch has no default value. + values[nameof(RazorViewEngineOptions.AllowRecompilingViewsOnFileChange)] = true; + } + + return values; + } + } + public void Configure(RazorViewEngineOptions options) { if (options == null) @@ -41,6 +59,11 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal options.AreaViewLocationFormats.Add("/Areas/{2}/Views/{1}/{0}" + RazorViewEngine.ViewExtension); options.AreaViewLocationFormats.Add("/Areas/{2}/Views/Shared/{0}" + RazorViewEngine.ViewExtension); options.AreaViewLocationFormats.Add("/Views/Shared/{0}" + RazorViewEngine.ViewExtension); + + if (_hostingEnvironment.IsDevelopment()) + { + options.AllowRecompilingViewsOnFileChange = true; + } } } } \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/PageActionDescriptorChangeProvider.cs b/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/PageActionDescriptorChangeProvider.cs index c68ee3ed65..fadb391b90 100644 --- a/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/PageActionDescriptorChangeProvider.cs +++ b/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/PageActionDescriptorChangeProvider.cs @@ -5,6 +5,7 @@ using System; using System.Diagnostics; using System.Linq; using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.AspNetCore.Mvc.Razor; using Microsoft.AspNetCore.Mvc.Razor.Internal; using Microsoft.AspNetCore.Razor.Language; using Microsoft.Extensions.FileProviders; @@ -18,11 +19,13 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal private readonly IFileProvider _fileProvider; private readonly string[] _searchPatterns; private readonly string[] _additionalFilesToTrack; + private readonly bool _watchForChanges; public PageActionDescriptorChangeProvider( RazorTemplateEngine templateEngine, IRazorViewEngineFileProviderAccessor fileProviderAccessor, - IOptions razorPagesOptions) + IOptions razorPagesOptions, + IOptions razorViewEngineOptions) { if (templateEngine == null) { @@ -39,6 +42,13 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal throw new ArgumentNullException(nameof(razorPagesOptions)); } + _watchForChanges = razorViewEngineOptions.Value.AllowRecompilingViewsOnFileChange; + if (!_watchForChanges) + { + // No need to do any additional work if we aren't going to be watching for file changes. + return; + } + _fileProvider = fileProviderAccessor.FileProvider; var rootDirectory = razorPagesOptions.Value.RootDirectory; @@ -84,6 +94,11 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal public IChangeToken GetChangeToken() { + if (!_watchForChanges) + { + return NullChangeToken.Singleton; + } + var changeTokens = new IChangeToken[_additionalFilesToTrack.Length + _searchPatterns.Length]; for (var i = 0; i < _additionalFilesToTrack.Length; i++) { diff --git a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RazorFileUpdateTests.cs b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RazorFileUpdateTests.cs new file mode 100644 index 0000000000..b4aee9ad9c --- /dev/null +++ b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RazorFileUpdateTests.cs @@ -0,0 +1,131 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Razor; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.FunctionalTests +{ + // Verifies that updating Razor files (views and pages) with AllowRecompilingViewsOnFileChange=true works + public class RazorFileUpdateTests : IClassFixture> + { + public RazorFileUpdateTests(MvcTestFixture fixture) + { + var factory = fixture.WithWebHostBuilder(builder => + { + builder.UseStartup(); + builder.ConfigureTestServices(services => + { + services.Configure(options => options.AllowRecompilingViewsOnFileChange = true); + }); + }); + Client = factory.CreateDefaultClient(); + } + + public HttpClient Client { get; } + + [Fact] + public async Task RazorViews_AreUpdatedOnChange() + { + // Arrange + var expected1 = "Original content"; + var expected2 = "New content"; + var path = "/Views/UpdateableShared/_Partial.cshtml"; + + // Act - 1 + var body = await Client.GetStringAsync("/UpdateableFileProvider"); + + // Assert - 1 + Assert.Equal(expected1, body.Trim(), ignoreLineEndingDifferences: true); + + // Act - 2 + await UpdateFile(path, expected2); + body = await Client.GetStringAsync("/UpdateableFileProvider"); + + // Assert - 2 + Assert.Equal(expected2, body.Trim(), ignoreLineEndingDifferences: true); + } + + [Fact] + public async Task RazorViews_AreUpdatedWhenViewImportsChange() + { + // Arrange + var content = "@GetType().Assembly.FullName"; + await UpdateFile("/Views/UpdateableIndex/Index.cshtml", content); + var initial = await Client.GetStringAsync("/UpdateableFileProvider"); + + // Act + // Trigger a change in ViewImports + await UpdateFile("/Views/UpdateableIndex/_ViewImports.cshtml", string.Empty); + var updated = await Client.GetStringAsync("/UpdateableFileProvider"); + + // Assert + Assert.NotEqual(initial, updated); + } + + [Fact] + public async Task RazorPages_AreUpdatedOnChange() + { + // Arrange + var expected1 = "Original content"; + var expected2 = "New 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 + expected2); + body = await Client.GetStringAsync("/UpdateablePage"); + + // Assert - 2 + Assert.Equal(expected2, body.Trim(), ignoreLineEndingDifferences: true); + } + + [Fact] + public async Task RazorPages_AreUpdatedWhenViewImportsChange() + { + // Arrange + var content = "@GetType().Assembly.FullName"; + await UpdateFile("/Pages/UpdateablePage.cshtml", "@page" + Environment.NewLine + content); + var initial = await Client.GetStringAsync("/UpdateablePage"); + + // Act + // Trigger a change in ViewImports + await UpdateRazorPages(); + await UpdateFile("/Pages/UpdateablePage.cshtml", "@page" + Environment.NewLine + content); + var updated = await Client.GetStringAsync("/UpdateablePage"); + + // Assert + Assert.NotEqual(initial, updated); + } + + private async Task UpdateFile(string path, string content) + { + var updateContent = new FormUrlEncodedContent(new Dictionary + { + { "path", path }, + { "content", content }, + }); + + var response = await Client.PostAsync($"/UpdateableFileProvider/Update", updateContent); + response.EnsureSuccessStatusCode(); + } + + private async Task UpdateRazorPages() + { + var response = await Client.PostAsync($"/UpdateableFileProvider/UpdateRazorPages", new StringContent(string.Empty)); + response.EnsureSuccessStatusCode(); + } + } +} diff --git a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ViewEngineTests.cs b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ViewEngineTests.cs index 363f534469..7c23e4c377 100644 --- a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ViewEngineTests.cs +++ b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ViewEngineTests.cs @@ -270,28 +270,6 @@ Hello from Shared/_EmbeddedPartial Assert.Equal(expected, body.Trim(), ignoreLineEndingDifferences: true); } - [Fact] - public async Task RazorViewEngine_UpdatesViewsReferencedViaRelativePathsOnChange() - { - // Arrange - var expected1 = "Original content"; - var expected2 = "New content"; - - // Act - 1 - var body = await Client.GetStringAsync("/UpdateableFileProvider"); - - // Assert - 1 - Assert.Equal(expected1, body.Trim(), ignoreLineEndingDifferences: true); - - // Act - 2 - var response = await Client.PostAsync("/UpdateableFileProvider/Update", new StringContent(string.Empty)); - response.EnsureSuccessStatusCode(); - body = await Client.GetStringAsync("/UpdateableFileProvider"); - - // Assert - 1 - Assert.Equal(expected2, body.Trim(), ignoreLineEndingDifferences: true); - } - [Fact] public async Task LayoutValueIsPassedBetweenNestedViewStarts() { diff --git a/test/Microsoft.AspNetCore.Mvc.Razor.Test/Internal/RazorViewCompilerTest.cs b/test/Microsoft.AspNetCore.Mvc.Razor.Test/Internal/RazorViewCompilerTest.cs index d92c4ef4a1..ce99154a00 100644 --- a/test/Microsoft.AspNetCore.Mvc.Razor.Test/Internal/RazorViewCompilerTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Razor.Test/Internal/RazorViewCompilerTest.cs @@ -37,6 +37,25 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal var result1 = await viewCompiler.CompileAsync(path); var result2 = await viewCompiler.CompileAsync(path); + // Assert + Assert.Same(result1, result2); + Assert.Null(result1.ViewAttribute); + Assert.Empty(result1.ExpirationTokens); + } + + [Fact] + public async Task CompileAsync_ReturnsResultWithExpirationToken_WhenWatchingForFileChanges() + { + // Arrange + var path = "/file/does-not-exist"; + var fileProvider = new TestFileProvider(); + var viewCompiler = GetViewCompiler(fileProvider); + viewCompiler.AllowRecompilingViewsOnFileChange = true; + + // Act + var result1 = await viewCompiler.CompileAsync(path); + var result2 = await viewCompiler.CompileAsync(path); + // Assert Assert.Same(result1, result2); Assert.Null(result1.ViewAttribute); @@ -57,6 +76,24 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal // Act var result = await viewCompiler.CompileAsync(path); + // Assert + Assert.NotNull(result.ViewAttribute); + Assert.Empty(result.ExpirationTokens); + } + + [Fact] + public async Task CompileAsync_AddsChangeTokensForViewStartsIfFileExists_WhenWatchingForFileChanges() + { + // Arrange + var path = "/file/exists/FilePath.cshtml"; + var fileProvider = new TestFileProvider(); + fileProvider.AddFile(path, "Content"); + var viewCompiler = GetViewCompiler(fileProvider); + viewCompiler.AllowRecompilingViewsOnFileChange = true; + + // Act + var result = await viewCompiler.CompileAsync(path); + // Assert Assert.NotNull(result.ViewAttribute); Assert.Collection( @@ -92,13 +129,40 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal } [Fact] - public async Task CompileAsync_InvalidatesCache_IfChangeTokenExpires() + public async Task CompileAsync_DoesNotInvalidCache_IfChangeTokenChanges() { // Arrange var path = "/Views/Home/Index.cshtml"; var fileProvider = new TestFileProvider(); var fileInfo = fileProvider.AddFile(path, "some content"); var viewCompiler = GetViewCompiler(fileProvider); + var changeToken = fileProvider.Watch(path); + + // Act 1 + var result1 = await viewCompiler.CompileAsync(path); + + // Assert 1 + Assert.NotNull(result1.ViewAttribute); + + // Act 2 + fileProvider.DeleteFile(path); + fileProvider.GetChangeToken(path).HasChanged = true; + viewCompiler.Compile = _ => throw new Exception("Can't call me"); + var result2 = await viewCompiler.CompileAsync(path); + + // Assert 2 + Assert.Same(result1, result2); + } + + [Fact] + public async Task CompileAsync_InvalidatesCache_IfChangeTokenExpires_WhenWatchingForFileChanges() + { + // Arrange + var path = "/Views/Home/Index.cshtml"; + var fileProvider = new TestFileProvider(); + var fileInfo = fileProvider.AddFile(path, "some content"); + var viewCompiler = GetViewCompiler(fileProvider); + viewCompiler.AllowRecompilingViewsOnFileChange = true; // Act 1 var result1 = await viewCompiler.CompileAsync(path); @@ -125,6 +189,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal var fileProvider = new TestFileProvider(); var fileInfo = fileProvider.AddFile(path, "some content"); var viewCompiler = GetViewCompiler(fileProvider); + viewCompiler.AllowRecompilingViewsOnFileChange = true; var expected2 = new CompiledViewDescriptor(); // Act 1 @@ -151,6 +216,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal var fileProvider = new TestFileProvider(); var fileInfo = fileProvider.AddFile(path, "some content"); var viewCompiler = GetViewCompiler(fileProvider); + viewCompiler.AllowRecompilingViewsOnFileChange = true; var expected2 = new CompiledViewDescriptor(); // Act 1 @@ -327,6 +393,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal }; var viewCompiler = GetViewCompiler(fileProvider, precompiledViews: new[] { precompiledView }); + viewCompiler.AllowRecompilingViewsOnFileChange = true; // Act var result = await viewCompiler.CompileAsync(path); @@ -371,7 +438,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal } [Fact] - public async Task CompileAsync_PrecompiledViewWithChecksum_CanRecompile() + public async Task CompileAsync_PrecompiledViewWithChecksum_DoesNotAddExpirationTokens() { // Arrange var path = "/Views/Home/Index.cshtml"; @@ -392,11 +459,43 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal var viewCompiler = GetViewCompiler(fileProvider, precompiledViews: new[] { precompiledView }); + // Act + var result = await viewCompiler.CompileAsync(path); + + // Assert + Assert.Same(precompiledView.Item, result.Item); + Assert.Empty(result.ExpirationTokens); + } + + [Fact] + public async Task CompileAsync_PrecompiledViewWithChecksum_CanRecompile() + { + // Arrange + var path = "/Views/Home/Index.cshtml"; + + var fileProvider = new TestFileProvider(); + var fileInfo = fileProvider.AddFile(path, "some content"); + + var expected2 = new CompiledViewDescriptor(); + + var precompiledView = new CompiledViewDescriptor + { + RelativePath = path, + Item = new TestRazorCompiledItem(typeof(string), "mvc.1.0.view", path, new object[] + { + new RazorSourceChecksumAttribute("SHA1", GetChecksum("some content"), path), + }), + }; + + var viewCompiler = GetViewCompiler(fileProvider, precompiledViews: new[] { precompiledView }); + viewCompiler.AllowRecompilingViewsOnFileChange = true; + // Act - 1 var result = await viewCompiler.CompileAsync(path); // Assert - 1 Assert.Same(precompiledView.Item, result.Item); + Assert.NotEmpty(result.ExpirationTokens); // Act - 2 fileInfo.Content = "some other content"; @@ -427,6 +526,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal }; var viewCompiler = GetViewCompiler(fileProvider, precompiledViews: new[] { precompiledView }); + viewCompiler.AllowRecompilingViewsOnFileChange = true; // Act - 1 var result = await viewCompiler.CompileAsync(path); @@ -463,6 +563,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal }; var viewCompiler = GetViewCompiler(fileProvider, precompiledViews: new[] { precompiledView }); + viewCompiler.AllowRecompilingViewsOnFileChange = true; viewCompiler.Compile = _ => expected1; // Act - 1 @@ -504,6 +605,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal }; var viewCompiler = GetViewCompiler(fileProvider, precompiledViews: new[] { precompiledView }); + viewCompiler.AllowRecompilingViewsOnFileChange = true; // Act - 1 var result = await viewCompiler.CompileAsync(path); diff --git a/test/Microsoft.AspNetCore.Mvc.Razor.Test/Internal/RazorViewEngineOptionsSetupTest.cs b/test/Microsoft.AspNetCore.Mvc.Razor.Test/Internal/RazorViewEngineOptionsSetupTest.cs index 1351b64fa5..fc28afe325 100644 --- a/test/Microsoft.AspNetCore.Mvc.Razor.Test/Internal/RazorViewEngineOptionsSetupTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Razor.Test/Internal/RazorViewEngineOptionsSetupTest.cs @@ -2,7 +2,11 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; using Moq; using Xunit; @@ -19,7 +23,8 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal var hostingEnv = new Mock(); hostingEnv.SetupGet(e => e.ContentRootFileProvider) .Returns(expected); - var optionsSetup = new RazorViewEngineOptionsSetup(hostingEnv.Object); + + var optionsSetup = GetSetup(hostingEnvironment: hostingEnv.Object); // Act optionsSetup.Configure(options); @@ -28,5 +33,128 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal var fileProvider = Assert.Single(options.FileProviders); Assert.Same(expected, fileProvider); } + + [Fact] + public void PostConfigure_SetsAllowRecompilingViewsOnFileChange_For21() + { + // Arrange + var options = new RazorViewEngineOptions(); + var optionsSetup = GetSetup(CompatibilityVersion.Version_2_1); + + // Act + optionsSetup.Configure(options); + optionsSetup.PostConfigure(string.Empty, options); + + // Assert + Assert.True(options.AllowRecompilingViewsOnFileChange); + } + + [Theory] + [InlineData(CompatibilityVersion.Version_2_2)] + [InlineData(CompatibilityVersion.Latest)] + public void PostConfigure_SetsAllowRecompilingViewsOnFileChange_InDevelopmentMode(CompatibilityVersion compatibilityVersion) + { + // Arrange + var options = new RazorViewEngineOptions(); + var hostingEnv = Mock.Of(env => env.EnvironmentName == EnvironmentName.Development); + var optionsSetup = GetSetup(compatibilityVersion, hostingEnv); + + // Act + optionsSetup.Configure(options); + optionsSetup.PostConfigure(string.Empty, options); + + // Assert + Assert.True(options.AllowRecompilingViewsOnFileChange); + } + + [Theory] + [InlineData(CompatibilityVersion.Version_2_2)] + [InlineData(CompatibilityVersion.Latest)] + public void PostConfigure_DoesNotSetAllowRecompilingViewsOnFileChange_WhenNotInDevelopment(CompatibilityVersion compatibilityVersion) + { + // Arrange + var options = new RazorViewEngineOptions(); + var hostingEnv = Mock.Of(env => env.EnvironmentName == EnvironmentName.Staging); + var optionsSetup = GetSetup(compatibilityVersion, hostingEnv); + + // Act + optionsSetup.Configure(options); + optionsSetup.PostConfigure(string.Empty, options); + + // Assert + Assert.False(options.AllowRecompilingViewsOnFileChange); + } + + [Fact] + public void RazorViewEngineOptionsSetup_DoesNotOverwriteAllowRecompilingViewsOnFileChange_In21CompatMode() + { + // Arrange + var hostingEnv = Mock.Of(env => env.EnvironmentName == EnvironmentName.Staging); + var compatibilityVersion = new MvcCompatibilityOptions { CompatibilityVersion = CompatibilityVersion.Version_2_1 }; + var optionsSetup = GetSetup(CompatibilityVersion.Version_2_1, hostingEnv); + var serviceProvider = new ServiceCollection() + .AddOptions() + .AddSingleton>(optionsSetup) + .Configure(o => o.AllowRecompilingViewsOnFileChange = false) + .BuildServiceProvider(); + + // Act + var options = serviceProvider.GetRequiredService>(); + + // Assert + Assert.False(options.Value.AllowRecompilingViewsOnFileChange); + } + + [Fact] + public void RazorViewEngineOptionsSetup_ConfiguresAllowRecompilingViewsOnFileChange() + { + // Arrange + var hostingEnv = Mock.Of(env => env.EnvironmentName == EnvironmentName.Production); + var compatibilityVersion = new MvcCompatibilityOptions { CompatibilityVersion = CompatibilityVersion.Version_2_2 }; + var optionsSetup = GetSetup(CompatibilityVersion.Version_2_2, hostingEnv); + var serviceProvider = new ServiceCollection() + .AddOptions() + .AddSingleton>(optionsSetup) + .BuildServiceProvider(); + + // Act + var options = serviceProvider.GetRequiredService>(); + + // Assert + Assert.False(options.Value.AllowRecompilingViewsOnFileChange); + } + + [Fact] + public void RazorViewEngineOptionsSetup_DoesNotOverwriteAllowRecompilingViewsOnFileChange() + { + // Arrange + var hostingEnv = Mock.Of(env => env.EnvironmentName == EnvironmentName.Production); + var optionsSetup = GetSetup(CompatibilityVersion.Version_2_2, hostingEnv); + var serviceProvider = new ServiceCollection() + .AddOptions() + .AddSingleton>(optionsSetup) + .Configure(o => o.AllowRecompilingViewsOnFileChange = true) + .BuildServiceProvider(); + + // Act + var options = serviceProvider.GetRequiredService>(); + + // Assert + Assert.True(options.Value.AllowRecompilingViewsOnFileChange); + } + + private static RazorViewEngineOptionsSetup GetSetup( + CompatibilityVersion compatibilityVersion = CompatibilityVersion.Latest, + IHostingEnvironment hostingEnvironment = null) + { + hostingEnvironment = hostingEnvironment ?? Mock.Of(); + var compatibilityOptions = new MvcCompatibilityOptions { CompatibilityVersion = compatibilityVersion }; + + return new RazorViewEngineOptionsSetup( + hostingEnvironment, + NullLoggerFactory.Instance, + Options.Create(compatibilityOptions)); + } + } } diff --git a/test/Microsoft.AspNetCore.Mvc.Razor.Test/RazorViewEngineTest.cs b/test/Microsoft.AspNetCore.Mvc.Razor.Test/RazorViewEngineTest.cs index ba35ac33d2..f522b54a49 100644 --- a/test/Microsoft.AspNetCore.Mvc.Razor.Test/RazorViewEngineTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Razor.Test/RazorViewEngineTest.cs @@ -8,6 +8,7 @@ using System.Threading; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Mvc.Razor.Compilation; using Microsoft.AspNetCore.Mvc.Razor.Internal; using Microsoft.AspNetCore.Mvc.Routing; @@ -1979,7 +1980,10 @@ namespace Microsoft.AspNetCore.Mvc.Razor IEnumerable areaViewLocationFormats = null, IEnumerable pageViewLocationFormats = null) { - var optionsSetup = new RazorViewEngineOptionsSetup(Mock.Of()); + var optionsSetup = new RazorViewEngineOptionsSetup( + Mock.Of(), + NullLoggerFactory.Instance, + Options.Create(new MvcCompatibilityOptions())); var options = new RazorViewEngineOptions(); optionsSetup.Configure(options); diff --git a/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/PageActionDescriptorChangeProviderTest.cs b/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/PageActionDescriptorChangeProviderTest.cs index 21f9db7215..48c7cb3454 100644 --- a/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/PageActionDescriptorChangeProviderTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/PageActionDescriptorChangeProviderTest.cs @@ -30,8 +30,9 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal var templateEngine = new RazorTemplateEngine( RazorProjectEngine.Create(RazorConfiguration.Default, fileSystem).Engine, fileSystem); - var options = Options.Create(new RazorPagesOptions()); - var changeProvider = new PageActionDescriptorChangeProvider(templateEngine, accessor, options); + var razorPageOptions = Options.Create(new RazorPagesOptions()); + var razorViewEngineOptions = Options.Create(new RazorViewEngineOptions { AllowRecompilingViewsOnFileChange = true }); + var changeProvider = new PageActionDescriptorChangeProvider(templateEngine, accessor, razorPageOptions, razorViewEngineOptions); // Act var changeToken = changeProvider.GetChangeToken(); @@ -57,8 +58,9 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal fileSystem); var options = Options.Create(new RazorPagesOptions()); options.Value.RootDirectory = rootDirectory; + var razorViewEngineOptions = Options.Create(new RazorViewEngineOptions { AllowRecompilingViewsOnFileChange = true }); - var changeProvider = new PageActionDescriptorChangeProvider(templateEngine, accessor, options); + var changeProvider = new PageActionDescriptorChangeProvider(templateEngine, accessor, options, razorViewEngineOptions); // Act var changeToken = changeProvider.GetChangeToken(); @@ -81,7 +83,8 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal RazorProjectEngine.Create(RazorConfiguration.Default, fileSystem).Engine, fileSystem); var options = Options.Create(new RazorPagesOptions { AllowAreas = true }); - var changeProvider = new PageActionDescriptorChangeProvider(templateEngine, accessor, options); + var razorViewEngineOptions = Options.Create(new RazorViewEngineOptions { AllowRecompilingViewsOnFileChange = true }); + var changeProvider = new PageActionDescriptorChangeProvider(templateEngine, accessor, options, razorViewEngineOptions); // Act var changeToken = changeProvider.GetChangeToken(); @@ -104,8 +107,9 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal templateEngine.Options.ImportsFileName = "_ViewImports.cshtml"; var options = Options.Create(new RazorPagesOptions()); options.Value.RootDirectory = "/dir1/dir2"; + var razorViewEngineOptions = Options.Create(new RazorViewEngineOptions { AllowRecompilingViewsOnFileChange = true }); - var changeProvider = new PageActionDescriptorChangeProvider(templateEngine, accessor, options); + var changeProvider = new PageActionDescriptorChangeProvider(templateEngine, accessor, options, razorViewEngineOptions); // Act & Assert var compositeChangeToken = Assert.IsType(changeProvider.GetChangeToken()); @@ -131,7 +135,8 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal options.Value.RootDirectory = "/dir1/dir2"; options.Value.AllowAreas = true; - var changeProvider = new PageActionDescriptorChangeProvider(templateEngine, accessor, options); + var razorViewEngineOptions = Options.Create(new RazorViewEngineOptions { AllowRecompilingViewsOnFileChange = true }); + var changeProvider = new PageActionDescriptorChangeProvider(templateEngine, accessor, options, razorViewEngineOptions); // Act & Assert var compositeChangeToken = Assert.IsType(changeProvider.GetChangeToken()); @@ -155,8 +160,9 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal fileSystem); templateEngine.Options.ImportsFileName = "_ViewImports.cshtml"; var options = Options.Create(new RazorPagesOptions { AllowAreas = false }); + var razorViewEngineOptions = Options.Create(new RazorViewEngineOptions { AllowRecompilingViewsOnFileChange = true }); - var changeProvider = new PageActionDescriptorChangeProvider(templateEngine, accessor, options); + var changeProvider = new PageActionDescriptorChangeProvider(templateEngine, accessor, options, razorViewEngineOptions); // Act & Assert var compositeChangeToken = Assert.IsType(changeProvider.GetChangeToken()); @@ -164,5 +170,27 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal changeToken => Assert.Same(fileProvider.GetChangeToken("/_ViewImports.cshtml"), changeToken), changeToken => Assert.Same(fileProvider.GetChangeToken("/Pages/**/*.cshtml"), changeToken)); } + + [Fact] + public void GetChangeToken_DoesNotWatch_WhenOptionIsReset() + { + // Arrange + var fileProvider = new Mock(MockBehavior.Strict); + var accessor = Mock.Of(a => a.FileProvider == fileProvider.Object); + + var fileSystem = new FileProviderRazorProjectFileSystem(accessor, _hostingEnvironment); + var templateEngine = new RazorTemplateEngine( + RazorProjectEngine.Create(RazorConfiguration.Default, fileSystem).Engine, + fileSystem); + templateEngine.Options.ImportsFileName = "_ViewImports.cshtml"; + var options = Options.Create(new RazorPagesOptions()); + var razorViewEngineOptions = Options.Create(new RazorViewEngineOptions()); + + var changeProvider = new PageActionDescriptorChangeProvider(templateEngine, accessor, options, razorViewEngineOptions); + + // Act & Assert + var compositeChangeToken = Assert.IsType(changeProvider.GetChangeToken()); + fileProvider.Verify(f => f.Watch(It.IsAny()), Times.Never()); + } } } diff --git a/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/RazorPagesRazorViewEngineOptionsSetupTest.cs b/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/RazorPagesRazorViewEngineOptionsSetupTest.cs index e1571e451f..b0690ea559 100644 --- a/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/RazorPagesRazorViewEngineOptionsSetupTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/RazorPagesRazorViewEngineOptionsSetupTest.cs @@ -2,9 +2,10 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Mvc.Razor; -using Microsoft.AspNetCore.Mvc.Razor.Internal; using Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Moq; using Xunit; @@ -186,7 +187,10 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal private static RazorViewEngineOptions GetViewEngineOptions() { - var defaultSetup = new RazorViewEngineOptionsSetup(Mock.Of()); + var defaultSetup = new RazorViewEngineOptionsSetup( + Mock.Of(), + NullLoggerFactory.Instance, + Options.Create(new MvcCompatibilityOptions())); var options = new RazorViewEngineOptions(); defaultSetup.Configure(options); diff --git a/test/Microsoft.AspNetCore.Mvc.Test/IntegrationTest/CompatibilitySwitchIntegrationTest.cs b/test/Microsoft.AspNetCore.Mvc.Test/IntegrationTest/CompatibilitySwitchIntegrationTest.cs index 2413eb34bf..2281f4d602 100644 --- a/test/Microsoft.AspNetCore.Mvc.Test/IntegrationTest/CompatibilitySwitchIntegrationTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Test/IntegrationTest/CompatibilitySwitchIntegrationTest.cs @@ -1,11 +1,14 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Formatters; +using Microsoft.AspNetCore.Mvc.Razor; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.ObjectPool; using Microsoft.Extensions.Options; +using Moq; using Xunit; namespace Microsoft.AspNetCore.Mvc.IntegrationTest @@ -32,6 +35,7 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTest var jsonOptions = services.GetRequiredService>().Value; var razorPagesOptions = services.GetRequiredService>().Value; var apiBehaviorOptions = services.GetRequiredService>().Value; + var razorViewEngineOptions = services.GetRequiredService>().Value; // Assert Assert.False(mvcOptions.AllowCombiningAuthorizeFilters); @@ -44,6 +48,7 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTest Assert.Null(mvcOptions.MaxValidationDepth); Assert.True(apiBehaviorOptions.SuppressUseValidationProblemDetailsForInvalidModelStateResponses); Assert.True(apiBehaviorOptions.SuppressMapClientErrors); + Assert.True(razorViewEngineOptions.AllowRecompilingViewsOnFileChange); } [Fact] @@ -61,6 +66,7 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTest var jsonOptions = services.GetRequiredService>().Value; var razorPagesOptions = services.GetRequiredService>().Value; var apiBehaviorOptions = services.GetRequiredService>().Value; + var razorViewEngineOptions = services.GetRequiredService>().Value; // Assert Assert.True(mvcOptions.AllowCombiningAuthorizeFilters); @@ -73,6 +79,7 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTest Assert.Null(mvcOptions.MaxValidationDepth); Assert.True(apiBehaviorOptions.SuppressUseValidationProblemDetailsForInvalidModelStateResponses); Assert.True(apiBehaviorOptions.SuppressMapClientErrors); + Assert.True(razorViewEngineOptions.AllowRecompilingViewsOnFileChange); } [Fact] @@ -90,6 +97,7 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTest var jsonOptions = services.GetRequiredService>().Value; var razorPagesOptions = services.GetRequiredService>().Value; var apiBehaviorOptions = services.GetRequiredService>().Value; + var razorViewEngineOptions = services.GetRequiredService>().Value; // Assert Assert.True(mvcOptions.AllowCombiningAuthorizeFilters); @@ -102,6 +110,7 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTest Assert.Equal(32, mvcOptions.MaxValidationDepth); Assert.False(apiBehaviorOptions.SuppressUseValidationProblemDetailsForInvalidModelStateResponses); Assert.False(apiBehaviorOptions.SuppressMapClientErrors); + Assert.False(razorViewEngineOptions.AllowRecompilingViewsOnFileChange); } [Fact] @@ -119,6 +128,7 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTest var jsonOptions = services.GetRequiredService>().Value; var razorPagesOptions = services.GetRequiredService>().Value; var apiBehaviorOptions = services.GetRequiredService>().Value; + var razorViewEngineOptions = services.GetRequiredService>().Value; // Assert Assert.True(mvcOptions.AllowCombiningAuthorizeFilters); @@ -131,11 +141,13 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTest Assert.Equal(32, mvcOptions.MaxValidationDepth); Assert.False(apiBehaviorOptions.SuppressUseValidationProblemDetailsForInvalidModelStateResponses); Assert.False(apiBehaviorOptions.SuppressMapClientErrors); + Assert.False(razorViewEngineOptions.AllowRecompilingViewsOnFileChange); } // This just does the minimum needed to be able to resolve these options. private static void AddHostingServices(IServiceCollection serviceCollection) { + serviceCollection.AddSingleton(Mock.Of()); serviceCollection.AddLogging(); serviceCollection.AddSingleton(); } diff --git a/test/WebSites/RazorPagesWebSite/NonWatchingPhysicalFileProvider.cs b/test/WebSites/RazorPagesWebSite/NonWatchingPhysicalFileProvider.cs new file mode 100644 index 0000000000..c93dba01e3 --- /dev/null +++ b/test/WebSites/RazorPagesWebSite/NonWatchingPhysicalFileProvider.cs @@ -0,0 +1,18 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Primitives; + +namespace RazorPagesWebSite +{ + public class NonWatchingPhysicalFileProvider : PhysicalFileProvider, IFileProvider + { + public NonWatchingPhysicalFileProvider(string root) : base(root) + { + } + + IChangeToken IFileProvider.Watch(string filter) => throw new ArgumentException("This method should not be called."); + } +} diff --git a/test/WebSites/RazorPagesWebSite/StartupWithBasePath.cs b/test/WebSites/RazorPagesWebSite/StartupWithBasePath.cs index 0ded866ccf..93d36cea31 100644 --- a/test/WebSites/RazorPagesWebSite/StartupWithBasePath.cs +++ b/test/WebSites/RazorPagesWebSite/StartupWithBasePath.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; using RazorPagesWebSite.Conventions; @@ -11,11 +12,18 @@ namespace RazorPagesWebSite { public class StartupWithBasePath { + private readonly IHostingEnvironment _hostingEnvironment; + + public StartupWithBasePath(IHostingEnvironment hostingEnvironment) + { + _hostingEnvironment = hostingEnvironment; + } + public void ConfigureServices(IServiceCollection services) { services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) .AddCookie(options => options.LoginPath = "/Login"); - services.AddMvc() + var builder = services.AddMvc() .AddCookieTempDataProvider() .AddRazorPagesOptions(options => { @@ -27,6 +35,14 @@ namespace RazorPagesWebSite options.Conventions.Add(new CustomModelTypeConvention()); }) .SetCompatibilityVersion(CompatibilityVersion.Latest); + + // Ensure we don't have code paths that call IFileProvider.Watch in the default code path. + // Comment this code block if you happen to run this site in Development. + builder.AddRazorOptions(options => + { + options.FileProviders.Clear(); + options.FileProviders.Add(new NonWatchingPhysicalFileProvider(_hostingEnvironment.ContentRootPath)); + }); } public void Configure(IApplicationBuilder app) diff --git a/test/WebSites/RazorWebSite/Controllers/UpdateableFileProviderController.cs b/test/WebSites/RazorWebSite/Controllers/UpdateableFileProviderController.cs index a504b11cb6..ed5a12a6f2 100644 --- a/test/WebSites/RazorWebSite/Controllers/UpdateableFileProviderController.cs +++ b/test/WebSites/RazorWebSite/Controllers/UpdateableFileProviderController.cs @@ -10,9 +10,16 @@ namespace RazorWebSite public IActionResult Index() => View("/Views/UpdateableIndex/Index.cshtml"); [HttpPost] - public IActionResult Update([FromServices] UpdateableFileProvider fileProvider) + public IActionResult Update([FromServices] UpdateableFileProvider fileProvider, string path, string content) { - fileProvider.UpdateContent("/Views/UpdateableShared/_Partial.cshtml", "New content"); + fileProvider.UpdateContent(path, content); + return Ok(); + } + + [HttpPost] + public IActionResult UpdateRazorPages([FromServices] UpdateableFileProvider fileProvider) + { + fileProvider.CancelRazorPages(); return Ok(); } } diff --git a/test/WebSites/RazorWebSite/Services/UpdateableFileProvider.cs b/test/WebSites/RazorWebSite/Services/UpdateableFileProvider.cs index 0e3ef8aaac..5613ffda9a 100644 --- a/test/WebSites/RazorWebSite/Services/UpdateableFileProvider.cs +++ b/test/WebSites/RazorWebSite/Services/UpdateableFileProvider.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Collections; using System.Collections.Generic; using System.IO; using System.Text; @@ -13,8 +14,14 @@ namespace RazorWebSite { public class UpdateableFileProvider : IFileProvider { + public CancellationTokenSource _pagesTokenSource = new CancellationTokenSource(); + private readonly Dictionary _content = new Dictionary() { + { + "/Views/UpdateableIndex/_ViewImports.cshtml", + new TestFileInfo(string.Empty) + }, { "/Views/UpdateableIndex/Index.cshtml", new TestFileInfo(@"@Html.Partial(""../UpdateableShared/_Partial.cshtml"")") @@ -23,9 +30,21 @@ namespace RazorWebSite "/Views/UpdateableShared/_Partial.cshtml", new TestFileInfo("Original content") }, + { + "/Pages/UpdateablePage.cshtml", + new TestFileInfo("@page" + Environment.NewLine + "Original content") + }, }; - public IDirectoryContents GetDirectoryContents(string subpath) => new NotFoundDirectoryContents(); + public IDirectoryContents GetDirectoryContents(string subpath) + { + if (subpath == "/Pages") + { + return new PagesDirectoryContents(); + } + + return new NotFoundDirectoryContents(); + } public void UpdateContent(string subpath, string content) { @@ -34,10 +53,16 @@ namespace RazorWebSite _content[subpath] = new TestFileInfo(content); } + public void CancelRazorPages() + { + var oldToken = _pagesTokenSource; + _pagesTokenSource = new CancellationTokenSource(); + oldToken.Cancel(); + } + public IFileInfo GetFileInfo(string subpath) { - TestFileInfo fileInfo; - if (!_content.TryGetValue(subpath, out fileInfo)) + if (!_content.TryGetValue(subpath, out var fileInfo)) { fileInfo = new TestFileInfo(null); } @@ -47,8 +72,12 @@ namespace RazorWebSite public IChangeToken Watch(string filter) { - TestFileInfo fileInfo; - if (_content.TryGetValue(filter, out fileInfo)) + if (filter == "/Pages/**/*.cshtml") + { + return new CancellationChangeToken(_pagesTokenSource.Token); + } + + if (_content.TryGetValue(filter, out var fileInfo)) { return fileInfo.ChangeToken; } @@ -71,7 +100,7 @@ namespace RazorWebSite public bool IsDirectory => false; public DateTimeOffset LastModified => DateTimeOffset.MinValue; public long Length => -1; - public string Name => null; + public string Name { get; set; } public string PhysicalPath => null; public CancellationTokenSource TokenSource { get; } = new CancellationTokenSource(); public CancellationChangeToken ChangeToken { get; } @@ -81,5 +110,23 @@ namespace RazorWebSite return new MemoryStream(Encoding.UTF8.GetBytes(_content)); } } + + private class PagesDirectoryContents : IDirectoryContents + { + public bool Exists => true; + + public IEnumerator GetEnumerator() + { + var file = new TestFileInfo("@page" + Environment.NewLine + "Original content") + { + Name = "UpdateablePage.cshtml" + }; + + var files = new List { file }; + return files.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } } }