Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ public class ResolvedTreeRoot : ResolvedControl, IAbstractTreeRoot
(from ds in Directives
from d in ds.Value
select (ds.Key, d.Value)).ToImmutableArray(),
GetViewModuleInfo()
GetViewModuleInfo(),
CollectContentPlaceHolderIds()
);

private ViewModuleReferenceInfo? GetViewModuleInfo()
Expand All @@ -41,6 +42,33 @@ from d in ds.Value
return null;
}

/// <summary>
/// Traverses the entire resolved tree (including controls inside templates) to collect
/// all ContentPlaceHolder IDs declared in this page/master page file.
/// </summary>
private ImmutableArray<string> CollectContentPlaceHolderIds()
{
var collector = new ContentPlaceHolderIdCollector();
this.AcceptChildren(collector);
return collector.Ids.ToImmutableArray();
}

private sealed class ContentPlaceHolderIdCollector : ResolvedControlTreeVisitor
{
public readonly List<string> Ids = new List<string>();

public override void VisitControl(ResolvedControl control)
{
if (control.Metadata.Type == typeof(ContentPlaceHolder)
&& control.Properties.TryGetValue(DotvvmControl.IDProperty, out var idSetter)
&& idSetter is ResolvedPropertyValue { Value: string id })
{
Ids.Add(id);
}
DefaultVisit(control);
}
}

public ResolvedTreeRoot(ControlResolverMetadata metadata, DothtmlNode node, DataContextStack dataContext, ImmutableDictionary<string, ImmutableList<IAbstractDirective>> directives, ControlBuilderDescriptor? masterPage)
: base(metadata, node, null, dataContext)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ public class ControlBuilderDescriptor: IAbstractControlBuilderDescriptor

public ViewModuleReferenceInfo? ViewModuleReference { get; }

/// <summary>
/// All ContentPlaceHolder IDs declared in this page/master page, including those inside CompositeControl templates.
/// Used to validate Content controls when performing master page composition.
/// </summary>
public ImmutableArray<string> ContentPlaceHolderIds { get; }

ITypeDescriptor IAbstractControlBuilderDescriptor.DataContextType => new ResolvedTypeDescriptor(this.DataContextType);

ITypeDescriptor IAbstractControlBuilderDescriptor.ControlType => new ResolvedTypeDescriptor(this.ControlType);
Expand All @@ -39,7 +45,8 @@ public ControlBuilderDescriptor(
string? fileName,
ControlBuilderDescriptor? masterPage,
ImmutableArray<(string name, string value)> directives,
ViewModuleReferenceInfo? viewModuleReference
ViewModuleReferenceInfo? viewModuleReference,
ImmutableArray<string> contentPlaceHolderIds
)
{
this.DataContextType = dataContextType;
Expand All @@ -48,6 +55,7 @@ public ControlBuilderDescriptor(
this.MasterPage = masterPage;
this.Directives = directives;
this.ViewModuleReference = viewModuleReference;
this.ContentPlaceHolderIds = contentPlaceHolderIds;
}
}
}
45 changes: 44 additions & 1 deletion src/Framework/Framework/Controls/ContentPlaceHolder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using DotVVM.Framework.Binding;
using DotVVM.Framework.Configuration;
using DotVVM.Framework.Hosting;
using DotVVM.Framework.Runtime;

namespace DotVVM.Framework.Controls
{
Expand All @@ -19,7 +20,49 @@ public ContentPlaceHolder()
{
SetValue(Internal.IsNamingContainerProperty, true);
}


protected internal override void OnInit(IDotvvmRequestContext context)
{
ResolvePendingComposition();

base.OnInit(context);
}

internal void ResolvePendingComposition()
{
if (ID == null) return;

// find the nearest master page
var masterPage = GetAllAncestors()
.FirstOrDefault(a => a.IsPropertySet(Internal.PendingMasterPageCompositionsProperty, inherit: false));
if (masterPage == null)
{
throw new DotvvmControlException(this, "The ContentPlaceHolder or SpaContentPlaceHolder control can be used only in a master page. The current page doesn't have the @masterPage directive.");
}

// check there are not multiple content placeholders with the same ID in the same master page (e.g. due to being inside a template that is instantiated multiple times)
var resolvedIds = (HashSet<string>)masterPage.GetValue(Internal.ResolvedMasterPageCompositionIdsProperty)!;
if (resolvedIds.Contains(ID))
{
throw new DotvvmControlException(this,
$"The ContentPlaceHolder with ID '{ID}' has already been resolved. " +
$"ContentPlaceHolder controls used for master page composition cannot be placed inside templates that are instantiated multiple times (e.g. Repeater, foreach).");
}

// find the pending composition
var pendingList = (List<PendingMasterPageComposition>)masterPage.GetValue(Internal.PendingMasterPageCompositionsProperty)!;
var pending = pendingList.SingleOrDefault(p => p.Content.ContentPlaceHolderID == ID);
if (pending != null)
{
// remove it from the master page and from the root
pendingList.Remove(pending);
resolvedIds.Add(ID);

// perform the deferred composition: wrap Content in a PlaceHolder and add it as our child
DefaultDotvvmViewBuilder.PlaceContentInContentPlaceHolder(pending.DataContextType, this, pending.Content);
}
}

protected override void AddAttributesToRender(IHtmlWriter writer, IDotvvmRequestContext context)
{
// The ID is used only at runtime to find the PlaceHolder-Content pair.
Expand Down
21 changes: 21 additions & 0 deletions src/Framework/Framework/Controls/Internal.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using DotVVM.Framework.Compilation;
using DotVVM.Framework.Compilation.ControlTree;
using DotVVM.Framework.Compilation.ControlTree.Resolved;
using DotVVM.Framework.Controls.Infrastructure;
using DotVVM.Framework.Hosting;

namespace DotVVM.Framework.Controls
Expand Down Expand Up @@ -69,6 +70,16 @@ public class Internal
public static DotvvmProperty UsedPropertiesInfoProperty =
DotvvmProperty.Register<ControlUsedPropertiesInfo, Internal>(() => UsedPropertiesInfoProperty);

/// <summary>
/// This property is set on the master page root element and points to the root element of the child page.
/// </summary>
public static readonly DotvvmProperty MasterPageChildPageProperty =
DotvvmProperty.Register<DotvvmControl?, Internal>(() => MasterPageChildPageProperty, defaultValue: null, isValueInherited: true);
public static readonly DotvvmProperty PendingMasterPageCompositionsProperty =
DotvvmProperty.Register<List<PendingMasterPageComposition>?, Internal>(() => PendingMasterPageCompositionsProperty, defaultValue: null, isValueInherited: true);
public static readonly DotvvmProperty ResolvedMasterPageCompositionIdsProperty =
DotvvmProperty.Register<HashSet<string>?, Internal>(() => ResolvedMasterPageCompositionIdsProperty, defaultValue: null, isValueInherited: true);

public static bool IsViewCompilerProperty(DotvvmProperty property)
{
return property.DeclaringType == typeof(Internal);
Expand Down Expand Up @@ -105,4 +116,14 @@ public static TControl SetDataContextType<TControl>(this TControl control, DataC
return control;
}
}

/// <summary>
/// Represents a Content control that has not yet been matched to a ContentPlaceHolder during master page composition.
/// The match is deferred until the ContentPlaceHolder is added to the control tree (e.g. when a CompositeControl builds its contents).
/// The list of <see cref="PendingMasterPageComposition"/> instances is maintained on the root of the master page, and must be copied down to all Content controls because the root is discarded during composition.
/// </summary>
internal sealed record PendingMasterPageComposition(Content Content, DotvvmView ContentPage, DotvvmView MasterPage, DataContextStack? DataContextType)
{
public string? MasterPageFile => MasterPage.GetValue(Internal.MarkupFileNameProperty)?.ToString();
}
}
34 changes: 34 additions & 0 deletions src/Framework/Framework/Hosting/DotvvmPresenter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
using DotVVM.Framework.Compilation.Binding;
using DotVVM.Framework.Configuration;
using DotVVM.Framework.Controls;
using DotVVM.Framework.Controls.Infrastructure;
using DotVVM.Framework.Runtime;
using DotVVM.Framework.Runtime.Filters;
using DotVVM.Framework.Security;
Expand Down Expand Up @@ -233,6 +234,10 @@ public async Task ProcessRequestCore(IDotvvmRequestContext context)
DotvvmControlCollection.InvokePageLifeCycleEventRecursive(page, LifeCycleEventType.Load, context);
await requestTracer.TraceEvent(RequestTracingConstants.LoadCompleted, context);

// After Load, ensure all Content controls have been matched to their ContentPlaceHolders.
// For postback requests, the Repeater creates children in Load (for Commands), so we check here.
ValidateMasterPageComposition(page);

// invoke the postback command
var actionInfo = ViewModelSerializer.ResolveCommand(context, page);

Expand Down Expand Up @@ -267,6 +272,11 @@ public async Task ProcessRequestCore(IDotvvmRequestContext context)
// run the prerender phase in the page
DotvvmControlCollection.InvokePageLifeCycleEventRecursive(page, LifeCycleEventType.PreRender, context);

// After PreRender, ensure all Content controls have been matched to their ContentPlaceHolders.
// For GET requests, the Repeater creates children in PreRender, so this is the final check point.
// For postbacks, the check above after Load already caught most issues; this is a safety net.
ValidateMasterPageComposition(page);

// run the prerender complete phase in the page
DotvvmControlCollection.InvokePageLifeCycleEventRecursive(page, LifeCycleEventType.PreRenderComplete, context);
await requestTracer.TraceEvent(RequestTracingConstants.PreRenderCompleted, context);
Expand Down Expand Up @@ -598,5 +608,29 @@ public static bool DeterminePartialRendering(IHttpContext context) =>
{
return context.Request.Headers[HostingConstants.SpaContentPlaceHolderHeaderName];
}

/// <summary>
/// Validates that all Content controls have been matched to their corresponding ContentPlaceHolder controls
/// after the Load phase. If any Content controls remain unmatched, it means the ContentPlaceHolder
/// was declared in the master page but never instantiated (e.g. a CompositeControl's GetContents was not called).
/// </summary>
private static void ValidateMasterPageComposition(DotvvmControl page)
{
var childPage = (DotvvmControl?)page;
while (childPage != null)
{
var pendingList = (List<PendingMasterPageComposition>)childPage.GetValue(Internal.PendingMasterPageCompositionsProperty)!;
if (pendingList is { Count: > 0 })
{
var pending = pendingList[0];
var masterPageInfo = pending.MasterPageFile is { } masterPageFile ? $" '{masterPageFile}'" : "";
throw new DotvvmControlException(pending.Content,
$"The ContentPlaceHolder with ID '{pending.Content.ContentPlaceHolderID}' was declared in the master page{masterPageInfo} but was never instantiated. " +
$"Make sure the ContentPlaceHolder is always added to the control tree (e.g. it is not inside a conditional template).");
}

childPage = (DotvvmControl?)childPage.GetValue(Internal.MasterPageChildPageProperty);
}
}
}
}
70 changes: 54 additions & 16 deletions src/Framework/Framework/Runtime/DefaultDotvvmViewBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
using System.Threading.Tasks;
using DotVVM.Framework.Binding;
using DotVVM.Framework.Compilation;
using DotVVM.Framework.Compilation.ControlTree;
using DotVVM.Framework.Compilation.Parser;
using DotVVM.Framework.Compilation.ViewCompiler;
using DotVVM.Framework.Configuration;
using DotVVM.Framework.Controls;
using DotVVM.Framework.Controls.Infrastructure;
Expand Down Expand Up @@ -51,7 +53,7 @@ public DotvvmView BuildView(IDotvvmRequestContext context)
var masterPage = (DotvvmView)pageBuilder.Value.BuildControl(controlBuilderFactory, context.Services);

FillsDefaultDirectives(masterPage);
PerformMasterPageComposition(contentPage, masterPage);
PerformMasterPageComposition(contentPage, masterPage, pageDescriptor);

masterPage.ViewModelType = contentPage.ViewModelType;
contentPage = masterPage;
Expand Down Expand Up @@ -99,40 +101,61 @@ private void FillsDefaultDirectives(DotvvmView page)
/// <summary>
/// Performs the master page nesting.
/// </summary>
private void PerformMasterPageComposition(DotvvmView childPage, DotvvmView masterPage)
private void PerformMasterPageComposition(DotvvmView childPage, DotvvmView masterPage, ControlBuilderDescriptor masterPageDescriptor)
{
if (!masterPage.ViewModelType.IsAssignableFrom(childPage.ViewModelType))
throw new DotvvmControlException(childPage, $"Master page requires viewModel of type '{masterPage.ViewModelType}' and it is not assignable from '{childPage.ViewModelType}'.");

// find content place holders
var placeHolders = GetMasterPageContentPlaceHolders(masterPage);

// find contents
var (contents, auxControls) = GetChildPageContents(childPage, placeHolders);

// prepare the pending compositions list
// NB: this list is stored in the master page, so ContentPlaceHolders can access it easily thanks to DotVVM property inheritance - it will look up
var pendingCompositions = new List<PendingMasterPageComposition>();
masterPage.SetValue(Internal.PendingMasterPageCompositionsProperty, pendingCompositions);
masterPage.SetValue(Internal.ResolvedMasterPageCompositionIdsProperty, new HashSet<string>(StringComparer.Ordinal));

// save the reference to the child page
// NB: after the Load phase, we need to check the root master page and all nested master pages that all contents are resolved.
// This allows to jump from the root master page to the next nested one.
masterPage.SetValue(Internal.MasterPageChildPageProperty, childPage);

// perform the composition
foreach (var content in contents)
{
// NB: when Contents are placed inside ContentPlaceHolders, the childPage is not part of the control tree anymore - it gets abandoned.
// Therefore, we need to copy all the important properties from the child page to the content control, so they are not lost during the composition.
// This will let potential ContentPlaceHolders inside the Content finding the list of pending compositions and the reference to the child page.
content.SetValue(DotvvmView.DirectivesProperty, childPage.Directives);
content.SetValue(Internal.MarkupFileNameProperty, childPage.GetValue(Internal.MarkupFileNameProperty));
content.SetValue(Internal.ReferencedViewModuleInfoProperty, childPage.GetValue(Internal.ReferencedViewModuleInfoProperty));
content.SetValue(Internal.MasterPageChildPageProperty, childPage.GetValue(Internal.MasterPageChildPageProperty));
content.SetValue(Internal.PendingMasterPageCompositionsProperty, childPage.GetValue(Internal.PendingMasterPageCompositionsProperty));
content.SetValue(Internal.ResolvedMasterPageCompositionIdsProperty, childPage.GetValue(Internal.ResolvedMasterPageCompositionIdsProperty));

// find the corresponding placeholder
var dataContextType = content.Parent!.GetDataContextType();

var placeHolder = placeHolders.SingleOrDefault(p => p.ID == content.ContentPlaceHolderID);
if (placeHolder == null)
{
throw new DotvvmControlException(content, $"The placeholder with ID '{content.ContentPlaceHolderID}' was not found in the master page '{masterPage.GetValue(Internal.MarkupFileNameProperty)}'!");
if (!masterPageDescriptor.ContentPlaceHolderIds.Contains(content.ContentPlaceHolderID!))
{
var masterPageInfo = masterPageDescriptor.FileName is { } masterPageFile ? $" '{masterPageFile}'" : "";
throw new DotvvmControlException(content,
$"The ContentPlaceHolder with ID '{content.ContentPlaceHolderID}' was not found in the master page{masterPageInfo}. " +
$"Make sure that each Content element has a corresponding ContentPlaceHolder in the master page.");
}

pendingCompositions.Add(new PendingMasterPageComposition(content, childPage, masterPage, dataContextType));
continue;
}

// replace the contents
var contentPlaceHolder = new PlaceHolder();
contentPlaceHolder.SetDataContextType(content.Parent!.GetDataContextType());
(content.Parent as DotvvmControl)?.Children.Remove(content);

placeHolder.Children.Clear();
placeHolder.Children.Add(contentPlaceHolder);

contentPlaceHolder.Children.Add(content);
content.SetValue(Internal.IsMasterPageCompositionFinishedProperty, true);
content.SetValue(DotvvmView.DirectivesProperty, childPage.Directives);
content.SetValue(Internal.MarkupFileNameProperty, childPage.GetValue(Internal.MarkupFileNameProperty));
content.SetValue(Internal.ReferencedViewModuleInfoProperty, childPage.GetValue(Internal.ReferencedViewModuleInfoProperty));
PlaceContentInContentPlaceHolder(dataContextType, placeHolder, content);
}

foreach (var control in auxControls)
Expand All @@ -142,6 +165,21 @@ private void PerformMasterPageComposition(DotvvmView childPage, DotvvmView maste
masterPage.ViewModelType = childPage.ViewModelType;
}

internal static void PlaceContentInContentPlaceHolder(DataContextStack? dataContextType, ContentPlaceHolder placeHolder, Content content)
{
var contentPlaceHolder = new PlaceHolder();
contentPlaceHolder.SetDataContextType(dataContextType);

((DotvvmControl)content.Parent!).Children.Remove(content);

placeHolder.Children.Clear();
placeHolder.Children.Add(contentPlaceHolder);

contentPlaceHolder.Children.Add(content);

content.SetValue(Internal.IsMasterPageCompositionFinishedProperty, true);
}

/// <summary>
/// Gets the content place holders.
/// </summary>
Expand Down
Loading
Loading