diff --git a/src/Microsoft.AspNetCore.Mvc.Razor/IModelTypeProvider.cs b/src/Microsoft.AspNetCore.Mvc.Razor/IModelTypeProvider.cs new file mode 100644 index 0000000000..7485a15694 --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.Razor/IModelTypeProvider.cs @@ -0,0 +1,9 @@ +using System; + +namespace Microsoft.AspNetCore.Mvc.Razor +{ + internal interface IModelTypeProvider + { + Type GetModelType(); + } +} diff --git a/src/Microsoft.AspNetCore.Mvc.Razor/RazorPageActivator.cs b/src/Microsoft.AspNetCore.Mvc.Razor/RazorPageActivator.cs index 758255f494..cc9a20c8cb 100644 --- a/src/Microsoft.AspNetCore.Mvc.Razor/RazorPageActivator.cs +++ b/src/Microsoft.AspNetCore.Mvc.Razor/RazorPageActivator.cs @@ -11,6 +11,7 @@ using Microsoft.AspNetCore.Mvc.Razor.Internal; using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.Routing; using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.Extensions.Internal; namespace Microsoft.AspNetCore.Mvc.Razor { @@ -19,7 +20,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor { // Name of the "public TModel Model" property on RazorPage private const string ModelPropertyName = "Model"; - private readonly ConcurrentDictionary _activationInfo; + private readonly ConcurrentDictionary _activationInfo; private readonly IModelMetadataProvider _metadataProvider; // Value accessors for common singleton properties activated in a RazorPage. @@ -36,7 +37,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor HtmlEncoder htmlEncoder, IModelExpressionProvider modelExpressionProvider) { - _activationInfo = new ConcurrentDictionary(); + _activationInfo = new ConcurrentDictionary(); _metadataProvider = metadataProvider; _propertyAccessors = new RazorPagePropertyActivator.PropertyValueAccessors @@ -62,26 +63,76 @@ namespace Microsoft.AspNetCore.Mvc.Razor throw new ArgumentNullException(nameof(context)); } + var propertyActivator = GetOrAddCacheEntry(page); + propertyActivator.Activate(page, context); + } + + internal RazorPagePropertyActivator GetOrAddCacheEntry(IRazorPage page) + { var pageType = page.GetType(); - RazorPagePropertyActivator propertyActivator; - if (!_activationInfo.TryGetValue(pageType, out propertyActivator)) + Type providedModelType = null; + if (page is IModelTypeProvider modelTypeProvider) + { + providedModelType = modelTypeProvider.GetModelType(); + } + + // We only need to vary by providedModelType since it varies at runtime. Defined model type + // is synonymous with the pageType and consequently does not need to be accounted for in the cache key. + var cacheKey = new CacheKey(pageType, providedModelType); + if (!_activationInfo.TryGetValue(cacheKey, out var propertyActivator)) { // Look for a property named "Model". If it is non-null, we'll assume this is // the equivalent of TModel Model property on RazorPage. // // Otherwise if we don't have a model property the activator will just skip setting // the view data. - var modelType = pageType.GetRuntimeProperty(ModelPropertyName)?.PropertyType; + var modelType = providedModelType; + if (modelType == null) + { + modelType = pageType.GetRuntimeProperty(ModelPropertyName)?.PropertyType; + } + propertyActivator = new RazorPagePropertyActivator( pageType, modelType, _metadataProvider, _propertyAccessors); - propertyActivator = _activationInfo.GetOrAdd(pageType, propertyActivator); + propertyActivator = _activationInfo.GetOrAdd(cacheKey, propertyActivator); } - propertyActivator.Activate(page, context); + return propertyActivator; + } + + private readonly struct CacheKey : IEquatable + { + public CacheKey(Type pageType, Type providedModelType) + { + PageType = pageType; + ProvidedModelType = providedModelType; + } + + public Type PageType { get; } + + public Type ProvidedModelType { get; } + + public bool Equals(CacheKey other) + { + return PageType == other.PageType && + ProvidedModelType == other.ProvidedModelType; + } + + public override int GetHashCode() + { + var hashCodeCombiner = HashCodeCombiner.Start(); + hashCodeCombiner.Add(PageType); + if (ProvidedModelType != null) + { + hashCodeCombiner.Add(ProvidedModelType); + } + + return hashCodeCombiner.CombinedHash; + } } } } \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Mvc.Razor/RazorView.cs b/src/Microsoft.AspNetCore.Mvc.Razor/RazorView.cs index 240c80681c..10ef8139da 100644 --- a/src/Microsoft.AspNetCore.Mvc.Razor/RazorView.cs +++ b/src/Microsoft.AspNetCore.Mvc.Razor/RazorView.cs @@ -96,6 +96,8 @@ namespace Microsoft.AspNetCore.Mvc.Razor /// public IReadOnlyList ViewStartPages { get; } + internal Action OnAfterPageActivated { get; set; } + /// public virtual async Task RenderAsync(ViewContext context) { @@ -167,6 +169,8 @@ namespace Microsoft.AspNetCore.Mvc.Razor page.ViewContext = context; _pageActivator.Activate(page, context); + OnAfterPageActivated?.Invoke(page, context); + _diagnosticSource.BeforeViewPage(page, context); try diff --git a/src/Microsoft.AspNetCore.Mvc.RazorPages/Infrastructure/PageResultExecutor.cs b/src/Microsoft.AspNetCore.Mvc.RazorPages/Infrastructure/PageResultExecutor.cs index 10539c0b2a..967c6fa07d 100644 --- a/src/Microsoft.AspNetCore.Mvc.RazorPages/Infrastructure/PageResultExecutor.cs +++ b/src/Microsoft.AspNetCore.Mvc.RazorPages/Infrastructure/PageResultExecutor.cs @@ -76,13 +76,29 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure } var viewContext = result.Page.ViewContext; + var pageAdapter = new RazorPageAdapter(result.Page, pageContext.ActionDescriptor.DeclaredModelTypeInfo); + viewContext.View = new RazorView( _razorViewEngine, _razorPageActivator, viewStarts, - new RazorPageAdapter(result.Page), + pageAdapter, _htmlEncoder, - _diagnosticSource); + _diagnosticSource) + { + OnAfterPageActivated = (page, currentViewContext) => + { + if (page != pageAdapter) + { + return; + } + + // ViewContext is always activated with the "right" ViewData type. + // Copy that over to the PageContext since PageContext.ViewData is exposed + // as the ViewData property on the Page that the user works with. + pageContext.ViewData = currentViewContext.ViewData; + }, + }; return ExecuteAsync(viewContext, result.ContentType, result.StatusCode); } diff --git a/src/Microsoft.AspNetCore.Mvc.RazorPages/Infrastructure/RazorPageAdapter.cs b/src/Microsoft.AspNetCore.Mvc.RazorPages/Infrastructure/RazorPageAdapter.cs index 452161083b..52c319f22a 100644 --- a/src/Microsoft.AspNetCore.Mvc.RazorPages/Infrastructure/RazorPageAdapter.cs +++ b/src/Microsoft.AspNetCore.Mvc.RazorPages/Infrastructure/RazorPageAdapter.cs @@ -14,10 +14,12 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure // // The page gets activated before handler methods run, but the RazorView will also activate // each page. - public class RazorPageAdapter : IRazorPage + public class RazorPageAdapter : IRazorPage, IModelTypeProvider { private readonly RazorPageBase _page; + private readonly Type _modelType; + [Obsolete("This constructor is obsolete and will be removed in a future version.")] public RazorPageAdapter(RazorPageBase page) { if (page == null) @@ -28,6 +30,12 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure _page = page; } + public RazorPageAdapter(RazorPageBase page, Type modelType) + { + _page = page ?? throw new ArgumentNullException(nameof(page)); + _modelType = modelType ?? throw new ArgumentNullException(nameof(modelType)); + } + public ViewContext ViewContext { get { return _page.ViewContext; } @@ -75,5 +83,7 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure { return _page.ExecuteAsync(); } + + Type IModelTypeProvider.GetModelType() => _modelType; } } diff --git a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RazorPagesWithBasePathTest.cs b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RazorPagesWithBasePathTest.cs index 6d43c53c97..0a645b9e03 100644 --- a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RazorPagesWithBasePathTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RazorPagesWithBasePathTest.cs @@ -607,6 +607,22 @@ Hello from /Pages/Shared/"; Assert.Equal(HttpStatusCode.OK, response.StatusCode); } + [Fact] + public async Task ViewDataSetInViewStart_IsAvailableToPage() + { + // Arrange & Act + var document = await Client.GetHtmlDocumentAsync("/ViewData/ViewDataSetInViewStart"); + + // Assert + var valueSetInViewStart = document.RequiredQuerySelector("#valuefromviewstart").TextContent; + var valueSetInPageModel = document.RequiredQuerySelector("#valuefrompagemodel").TextContent; + var valueSetInPage = document.RequiredQuerySelector("#valuefrompage").TextContent; + + Assert.Equal("Value from _ViewStart", valueSetInViewStart); + Assert.Equal("Value from Page Model", valueSetInPageModel); + Assert.Equal("Value from Page", valueSetInPage); + } + private async Task AddAntiforgeryHeadersAsync(HttpRequestMessage request) { var response = await Client.GetAsync(request.RequestUri); diff --git a/test/Microsoft.AspNetCore.Mvc.Razor.Test/RazorPageActivatorTest.cs b/test/Microsoft.AspNetCore.Mvc.Razor.Test/RazorPageActivatorTest.cs index 17b0999b58..34a8b0926b 100644 --- a/test/Microsoft.AspNetCore.Mvc.Razor.Test/RazorPageActivatorTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Razor.Test/RazorPageActivatorTest.cs @@ -165,6 +165,69 @@ namespace Microsoft.AspNetCore.Mvc.Razor Assert.Throws(() => activator.Activate(instance, viewContext)); } + [Fact] + public void Activate_UsesModelFromModelTypeProvider() + { + // Arrange + var activator = CreateActivator(); + + var viewData = new ViewDataDictionary(MetadataProvider, new ModelStateDictionary()) + { + { "key", "value" }, + }; + var viewContext = CreateViewContext(viewData); + var page = new ModelTypeProviderRazorPage(); + + // Act + activator.Activate(page, viewContext); + + // Assert + Assert.Same(viewContext.ViewData, page.ViewData); + Assert.NotSame(viewData, viewContext.ViewData); + + Assert.IsType>(viewContext.ViewData); + Assert.Equal("value", viewContext.ViewData["key"]); + } + + [Fact] + public void GetOrAddCacheEntry_CachesPages() + { + // Arrange + var activator = CreateActivator(); + var page = new TestRazorPage(); + + // Act + var result1 = activator.GetOrAddCacheEntry(page); + var result2 = activator.GetOrAddCacheEntry(page); + + // Assert + Assert.Same(result1, result2); + } + + [Fact] + public void GetOrAddCacheEntry_VariesByModelType_IfPageIsModelTypeProvider() + { + // Arrange + var activator = CreateActivator(); + var page = new ModelTypeProviderRazorPage(); + + // Act - 1 + var result1 = activator.GetOrAddCacheEntry(page); + var result2 = activator.GetOrAddCacheEntry(page); + + // Assert - 1 + Assert.Same(result1, result2); + + // Act - 2 + page.ModelType = typeof(string); + var result3 = activator.GetOrAddCacheEntry(page); + var result4 = activator.GetOrAddCacheEntry(page); + + // Assert - 2 + Assert.Same(result3, result4); + Assert.NotSame(result1, result3); + } + private RazorPageActivator CreateActivator() { return new RazorPageActivator(MetadataProvider, UrlHelperFactory, JsonHelper, DiagnosticSource, HtmlEncoder, ModelExpressionProvider); @@ -225,6 +288,21 @@ namespace Microsoft.AspNetCore.Mvc.Razor } } + private class ModelTypeProviderRazorPage : RazorPage, IModelTypeProvider + { + [RazorInject] + public ViewDataDictionary ViewData { get; set; } + + public Type ModelType { get; set; } = typeof(Guid); + + public override Task ExecuteAsync() + { + throw new NotImplementedException(); + } + + public Type GetModelType() => ModelType; + } + private abstract class NoModelPropertyBase : RazorPage { [RazorInject] diff --git a/test/Microsoft.AspNetCore.Mvc.Razor.Test/RazorViewTest.cs b/test/Microsoft.AspNetCore.Mvc.Razor.Test/RazorViewTest.cs index cd5e8835a2..71fa07b67e 100644 --- a/test/Microsoft.AspNetCore.Mvc.Razor.Test/RazorViewTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Razor.Test/RazorViewTest.cs @@ -1747,6 +1747,49 @@ namespace Microsoft.AspNetCore.Mvc.Razor Assert.Equal(expected, viewContext.Writer.ToString()); } + [Fact] + public async Task RenderAsync_InvokesOnAfterPageActivated() + { + // Arrange + var viewStart = new TestableRazorPage(_ => { }); + var page = new TestableRazorPage(p => { p.Layout = LayoutPath; }); + var layout = new TestableRazorPage(p => { p.RenderBodyPublic(); }); + var expected = new HashSet(); + var onAfterPageActivatedCalled = 0; + + var activated = new HashSet(); + var pageActivator = new Mock(); + pageActivator.Setup(p => p.Activate(It.IsAny(), It.IsAny())) + .Callback((IRazorPage p, ViewContext v) => activated.Add(p)); + + var viewEngine = new Mock(); + viewEngine.Setup(v => v.FindPage(It.IsAny(), LayoutPath)) + .Returns(new RazorPageResult(LayoutPath, layout)); + + var view = new RazorView( + viewEngine.Object, + pageActivator.Object, + new[] { viewStart }, + page, + new HtmlTestEncoder(), + new DiagnosticListener("Microsoft.AspNetCore.Mvc.Razor")) + { + OnAfterPageActivated = AssertActivated, + }; + var viewContext = CreateViewContext(view); + + // Act + await view.RenderAsync(viewContext); + Assert.Equal(3, onAfterPageActivatedCalled); + + void AssertActivated(IRazorPage p, ViewContext v) + { + onAfterPageActivatedCalled++; + expected.Add(p); + Assert.Equal(expected, activated); + } + } + private static ViewContext CreateViewContext(RazorView view) { var httpContext = new DefaultHttpContext(); diff --git a/test/WebSites/RazorPagesWebSite/Pages/ViewData/ViewDataSetInViewStart/Index.cs b/test/WebSites/RazorPagesWebSite/Pages/ViewData/ViewDataSetInViewStart/Index.cs new file mode 100644 index 0000000000..f911dbfe5e --- /dev/null +++ b/test/WebSites/RazorPagesWebSite/Pages/ViewData/ViewDataSetInViewStart/Index.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace RazorPagesWebSite.ViewDataSetInViewStart +{ + public class Index : PageModel + { + [ViewData] + public string ValueFromPageModel => "Value from Page Model"; + } +} diff --git a/test/WebSites/RazorPagesWebSite/Pages/ViewData/ViewDataSetInViewStart/Index.cshtml b/test/WebSites/RazorPagesWebSite/Pages/ViewData/ViewDataSetInViewStart/Index.cshtml new file mode 100644 index 0000000000..683a1631e8 --- /dev/null +++ b/test/WebSites/RazorPagesWebSite/Pages/ViewData/ViewDataSetInViewStart/Index.cshtml @@ -0,0 +1,7 @@ +@page +@namespace RazorPagesWebSite.ViewDataSetInViewStart +@model Index +@{ + ViewData["ValueFromPage"] = "Value from Page"; +} +Sample that shows ViewData attributes being set in a PageModel. diff --git a/test/WebSites/RazorPagesWebSite/Pages/ViewData/ViewDataSetInViewStart/_Layout.cshtml b/test/WebSites/RazorPagesWebSite/Pages/ViewData/ViewDataSetInViewStart/_Layout.cshtml new file mode 100644 index 0000000000..6b3bb02e51 --- /dev/null +++ b/test/WebSites/RazorPagesWebSite/Pages/ViewData/ViewDataSetInViewStart/_Layout.cshtml @@ -0,0 +1,4 @@ +@RenderBody() +@ViewData["ValueFromViewStart"] +@ViewData["ValueFromPage"] +@ViewData["ValueFromPageModel"] diff --git a/test/WebSites/RazorPagesWebSite/Pages/ViewData/ViewDataSetInViewStart/_ViewStart.cshtml b/test/WebSites/RazorPagesWebSite/Pages/ViewData/ViewDataSetInViewStart/_ViewStart.cshtml new file mode 100644 index 0000000000..51245c5fa0 --- /dev/null +++ b/test/WebSites/RazorPagesWebSite/Pages/ViewData/ViewDataSetInViewStart/_ViewStart.cshtml @@ -0,0 +1,4 @@ +@{ + Layout = "_Layout"; + ViewData["ValueFromViewStart"] = "Value from _ViewStart"; +} \ No newline at end of file