From 12ff75011dc57ade9a9793023b87361504490c2d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Apr 2026 13:58:16 +0000 Subject: [PATCH 01/17] Initial plan From a913919373c507c349e605cd75e969c0ac8d683f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Apr 2026 14:25:38 +0000 Subject: [PATCH 02/17] Support ContentPlaceHolder in CompositeControl templates (deferred master page composition) Agent-Logs-Url: https://github.com/riganti/dotvvm/sessions/ca479579-7532-44bd-96e4-d8638c2d5130 Co-authored-by: tomasherceg <5599524+tomasherceg@users.noreply.github.com> --- .../Framework/Controls/ContentPlaceHolder.cs | 47 ++++++++++++++++++- src/Framework/Framework/Controls/Internal.cs | 28 +++++++++++ .../Framework/Hosting/DotvvmPresenter.cs | 25 ++++++++++ .../Runtime/DefaultDotvvmViewBuilder.cs | 30 ++++++++++-- .../LateContentPlaceHolderViewModel.cs | 8 ++++ .../LateContentPlaceHolders/Content.dothtml | 6 +++ .../MismatchedContent.dothtml | 9 ++++ .../LateContentPlaceHolders/Nested.dotmaster | 7 +++ .../LateContentPlaceHolders/Root.dotmaster | 19 ++++++++ .../TemplateContainerControl.cs | 23 +++++++++ .../Abstractions/SamplesRouteUrls.designer.cs | 2 + .../Tests/Tests/Feature/MasterPageTests.cs | 26 ++++++++++ 12 files changed, 226 insertions(+), 4 deletions(-) create mode 100644 src/Samples/Common/ViewModels/FeatureSamples/LateContentPlaceHolders/LateContentPlaceHolderViewModel.cs create mode 100644 src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/Content.dothtml create mode 100644 src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/MismatchedContent.dothtml create mode 100644 src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/Nested.dotmaster create mode 100644 src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/Root.dotmaster create mode 100644 src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/TemplateContainerControl.cs diff --git a/src/Framework/Framework/Controls/ContentPlaceHolder.cs b/src/Framework/Framework/Controls/ContentPlaceHolder.cs index 19d223bcb2..dfa9145a8b 100644 --- a/src/Framework/Framework/Controls/ContentPlaceHolder.cs +++ b/src/Framework/Framework/Controls/ContentPlaceHolder.cs @@ -19,7 +19,52 @@ public ContentPlaceHolder() { SetValue(Internal.IsNamingContainerProperty, true); } - + + protected internal override void OnInit(IDotvvmRequestContext context) + { + // Check if there are any pending Content controls waiting for this ContentPlaceHolder. + // This handles the case where ContentPlaceHolder is inside a CompositeControl template + // and is instantiated in the Load phase (after the initial master page composition). + ResolvePendingComposition(); + + base.OnInit(context); + } + + /// + /// Looks for a pending master page composition matching this ContentPlaceHolder's ID + /// and performs the composition if found. + /// + internal void ResolvePendingComposition() + { + if (ID == null) return; + + // Traverse ancestors to find the pending compositions list stored on the root page + foreach (var ancestor in this.GetAllAncestors()) + { + if (ancestor.GetValue(Internal.PendingMasterPageCompositionsProperty) is List pendingList) + { + var pendingIndex = pendingList.FindIndex(p => p.Content.ContentPlaceHolderID == ID); + if (pendingIndex >= 0) + { + var pending = pendingList[pendingIndex]; + pendingList.RemoveAt(pendingIndex); + + // Perform the deferred composition: wrap Content in a PlaceHolder and add it as our child + var wrapper = new PlaceHolder(); + wrapper.SetDataContextType(pending.DataContextType); + + this.Children.Clear(); + this.Children.Add(wrapper); + + wrapper.Children.Add(pending.Content); + pending.Content.SetValue(Internal.IsMasterPageCompositionFinishedProperty, true); + } + // Found the list (even if no match) - no need to search further ancestors + break; + } + } + } + protected override void AddAttributesToRender(IHtmlWriter writer, IDotvvmRequestContext context) { // The ID is used only at runtime to find the PlaceHolder-Content pair. diff --git a/src/Framework/Framework/Controls/Internal.cs b/src/Framework/Framework/Controls/Internal.cs index 4d9a5c4547..dd8933b873 100644 --- a/src/Framework/Framework/Controls/Internal.cs +++ b/src/Framework/Framework/Controls/Internal.cs @@ -69,6 +69,13 @@ public class Internal public static DotvvmProperty UsedPropertiesInfoProperty = DotvvmProperty.Register(() => UsedPropertiesInfoProperty); + /// + /// Stores a list of Content controls that have not yet been matched to their corresponding ContentPlaceHolder. + /// This is used to support ContentPlaceHolder controls inside CompositeControl templates (Load phase). + /// + public static readonly DotvvmProperty PendingMasterPageCompositionsProperty = + DotvvmProperty.Register?, Internal>(() => PendingMasterPageCompositionsProperty, defaultValue: null, isValueInherited: false); + public static bool IsViewCompilerProperty(DotvvmProperty property) { return property.DeclaringType == typeof(Internal); @@ -105,4 +112,25 @@ public static TControl SetDataContextType(this TControl control, DataC return control; } } + + /// + /// 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). + /// + internal sealed class PendingMasterPageComposition + { + /// The Content control waiting to be placed in a ContentPlaceHolder. + public readonly Content Content; + /// The DataContextStack of the Content's original parent (the child page). + public readonly DataContextStack? DataContextType; + /// The master page file name, used for error messages. + public readonly string? MasterPageFile; + + public PendingMasterPageComposition(Content content, DataContextStack? dataContextType, string? masterPageFile) + { + Content = content; + DataContextType = dataContextType; + MasterPageFile = masterPageFile; + } + } } diff --git a/src/Framework/Framework/Hosting/DotvvmPresenter.cs b/src/Framework/Framework/Hosting/DotvvmPresenter.cs index 6282f79d0d..e73e234799 100644 --- a/src/Framework/Framework/Hosting/DotvvmPresenter.cs +++ b/src/Framework/Framework/Hosting/DotvvmPresenter.cs @@ -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; @@ -196,6 +197,9 @@ public async Task ProcessRequestCore(IDotvvmRequestContext context) // run the load phase in the page DotvvmControlCollection.InvokePageLifeCycleEventRecursive(page, LifeCycleEventType.Load, context); await requestTracer.TraceEvent(RequestTracingConstants.LoadCompleted, context); + + // After Load, ensure all Content controls have been matched to their ContentPlaceHolders + ValidateMasterPageComposition(page); } else { @@ -233,6 +237,9 @@ 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 + ValidateMasterPageComposition(page); + // invoke the postback command var actionInfo = ViewModelSerializer.ResolveCommand(context, page); @@ -598,5 +605,23 @@ public static bool DeterminePartialRendering(IHttpContext context) => { return context.Request.Headers[HostingConstants.SpaContentPlaceHolderHeaderName]; } + + /// + /// Validates that all Content controls have been matched to their corresponding ContentPlaceHolder controls + /// after the Load phase. If any Content controls remain unmatched (e.g. because the ContentPlaceHolder + /// with the specified ID does not exist in the master page), an exception is thrown. + /// + private static void ValidateMasterPageComposition(DotvvmView page) + { + var pendingList = page.GetValue(Internal.PendingMasterPageCompositionsProperty) as List; + if (pendingList is { Count: > 0 }) + { + var pending = pendingList[0]; + var masterPageFile = pending.MasterPageFile is { } f ? $" '{f}'" : ""; + throw new DotvvmControlException(pending.Content, + $"The ContentPlaceHolder with ID '{pending.Content.ContentPlaceHolderID}' was not found in the master page{masterPageFile}. " + + $"Make sure that each Content element has a corresponding ContentPlaceHolder in the master page."); + } + } } } diff --git a/src/Framework/Framework/Runtime/DefaultDotvvmViewBuilder.cs b/src/Framework/Framework/Runtime/DefaultDotvvmViewBuilder.cs index f9814573b9..7b6d3a0f67 100644 --- a/src/Framework/Framework/Runtime/DefaultDotvvmViewBuilder.cs +++ b/src/Framework/Framework/Runtime/DefaultDotvvmViewBuilder.cs @@ -43,6 +43,10 @@ public DotvvmView BuildView(IDotvvmRequestContext context) FillsDefaultDirectives(contentPage); + // shared list for Content controls that couldn't be matched during static composition + // (e.g. because their ContentPlaceHolder is inside a CompositeControl template) + var pendingCompositions = new List(); + // check for master page and perform composition recursively while (pageDescriptor.MasterPage is object) { @@ -51,12 +55,16 @@ public DotvvmView BuildView(IDotvvmRequestContext context) var masterPage = (DotvvmView)pageBuilder.Value.BuildControl(controlBuilderFactory, context.Services); FillsDefaultDirectives(masterPage); - PerformMasterPageComposition(contentPage, masterPage); + PerformMasterPageComposition(contentPage, masterPage, pendingCompositions); masterPage.ViewModelType = contentPage.ViewModelType; contentPage = masterPage; } + // Store the pending compositions on the final page so ContentPlaceHolder controls + // can find them during their OnInit (which runs in Load phase for template-instantiated controls) + contentPage.SetValue(Internal.PendingMasterPageCompositionsProperty, pendingCompositions); + // verifies the SPA request VerifySpaRequest(context, contentPage); @@ -99,7 +107,7 @@ private void FillsDefaultDirectives(DotvvmView page) /// /// Performs the master page nesting. /// - private void PerformMasterPageComposition(DotvvmView childPage, DotvvmView masterPage) + private void PerformMasterPageComposition(DotvvmView childPage, DotvvmView masterPage, List pendingCompositions) { 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}'."); @@ -117,7 +125,23 @@ private void PerformMasterPageComposition(DotvvmView childPage, DotvvmView maste 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)}'!"); + // ContentPlaceHolder not found yet - it might be inside a CompositeControl template + // that will be instantiated in the Load phase. Defer the composition. + + // Set up properties that we'll need during deferred composition + content.SetValue(DotvvmView.DirectivesProperty, childPage.Directives); + content.SetValue(Internal.MarkupFileNameProperty, childPage.GetValue(Internal.MarkupFileNameProperty)); + content.SetValue(Internal.ReferencedViewModuleInfoProperty, childPage.GetValue(Internal.ReferencedViewModuleInfoProperty)); + + // Capture the DataContextType before removing the content from its parent + var dataContextType = content.Parent?.GetDataContextType(); + + // Remove the content from the child page (which will be discarded) + (content.Parent as DotvvmControl)?.Children.Remove(content); + + // Add to the shared pending list - will be resolved by ContentPlaceHolder.OnInit + pendingCompositions.Add(new PendingMasterPageComposition(content, dataContextType, masterPage.GetValue(Internal.MarkupFileNameProperty)?.ToString())); + continue; } // replace the contents diff --git a/src/Samples/Common/ViewModels/FeatureSamples/LateContentPlaceHolders/LateContentPlaceHolderViewModel.cs b/src/Samples/Common/ViewModels/FeatureSamples/LateContentPlaceHolders/LateContentPlaceHolderViewModel.cs new file mode 100644 index 0000000000..1f088d981b --- /dev/null +++ b/src/Samples/Common/ViewModels/FeatureSamples/LateContentPlaceHolders/LateContentPlaceHolderViewModel.cs @@ -0,0 +1,8 @@ +using DotVVM.Framework.ViewModel; + +namespace DotVVM.Samples.Common.ViewModels.FeatureSamples.LateContentPlaceHolders +{ + public class LateContentPlaceHolderViewModel : DotvvmViewModelBase + { + } +} diff --git a/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/Content.dothtml b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/Content.dothtml new file mode 100644 index 0000000000..c1f7baddbc --- /dev/null +++ b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/Content.dothtml @@ -0,0 +1,6 @@ +@viewModel DotVVM.Samples.Common.ViewModels.FeatureSamples.LateContentPlaceHolders.LateContentPlaceHolderViewModel, DotVVM.Samples.Common +@masterPage Views/FeatureSamples/LateContentPlaceHolders/Nested.dotmaster + + +

Page Content - Late ContentPlaceHolder works!

+
diff --git a/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/MismatchedContent.dothtml b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/MismatchedContent.dothtml new file mode 100644 index 0000000000..1ed24283d1 --- /dev/null +++ b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/MismatchedContent.dothtml @@ -0,0 +1,9 @@ +@viewModel DotVVM.Samples.Common.ViewModels.FeatureSamples.LateContentPlaceHolders.LateContentPlaceHolderViewModel, DotVVM.Samples.Common +@masterPage Views/FeatureSamples/LateContentPlaceHolders/Nested.dotmaster + + +

This content matches correctly

+
+ +

This content does NOT have a matching ContentPlaceHolder - should throw!

+
diff --git a/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/Nested.dotmaster b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/Nested.dotmaster new file mode 100644 index 0000000000..5ac76523a2 --- /dev/null +++ b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/Nested.dotmaster @@ -0,0 +1,7 @@ +@viewModel DotVVM.Samples.Common.ViewModels.FeatureSamples.LateContentPlaceHolders.LateContentPlaceHolderViewModel, DotVVM.Samples.Common +@masterPage Views/FeatureSamples/LateContentPlaceHolders/Root.dotmaster + + +

Nested Master Page

+ +
diff --git a/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/Root.dotmaster b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/Root.dotmaster new file mode 100644 index 0000000000..34683aa83b --- /dev/null +++ b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/Root.dotmaster @@ -0,0 +1,19 @@ +@viewModel DotVVM.Samples.Common.ViewModels.FeatureSamples.LateContentPlaceHolders.LateContentPlaceHolderViewModel, DotVVM.Samples.Common + + + + + + Late ContentPlaceHolder - Root + + +

Root Master Page

+ + + + + + + + + diff --git a/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/TemplateContainerControl.cs b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/TemplateContainerControl.cs new file mode 100644 index 0000000000..b9fbee8b65 --- /dev/null +++ b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/TemplateContainerControl.cs @@ -0,0 +1,23 @@ +using DotVVM.Framework.Binding; +using DotVVM.Framework.Controls; + +namespace DotVVM.Samples.Common.Views.FeatureSamples.LateContentPlaceHolders +{ + /// + /// A simple CompositeControl with an ITemplate property. + /// The template is instantiated in GetContents (Load phase), allowing ContentPlaceHolder + /// controls defined inside the template to be created after the initial master page composition. + /// + public class TemplateContainerControl : CompositeControl + { + public static DotvvmControl GetContents( + ITemplate contentTemplate + ) + { + return new HtmlGenericControl("div") + .AppendChildren( + new TemplateHost() { Template = contentTemplate } + ); + } + } +} diff --git a/src/Samples/Tests/Abstractions/SamplesRouteUrls.designer.cs b/src/Samples/Tests/Abstractions/SamplesRouteUrls.designer.cs index d962e32e67..79b1214198 100644 --- a/src/Samples/Tests/Abstractions/SamplesRouteUrls.designer.cs +++ b/src/Samples/Tests/Abstractions/SamplesRouteUrls.designer.cs @@ -316,6 +316,8 @@ public partial class SamplesRouteUrls public const string FeatureSamples_MarkupControl_StaticCommandInMarkupControlCallingRegularCommand = "FeatureSamples/MarkupControl/StaticCommandInMarkupControlCallingRegularCommand"; public const string FeatureSamples_ModalDialog_ModalDialog = "FeatureSamples/ModalDialog/ModalDialog"; public const string FeatureSamples_NestedMasterPages_Content = "FeatureSamples/NestedMasterPages/Content"; + public const string FeatureSamples_LateContentPlaceHolders_Content = "FeatureSamples/LateContentPlaceHolders/Content"; + public const string FeatureSamples_LateContentPlaceHolders_MismatchedContent = "FeatureSamples/LateContentPlaceHolders/MismatchedContent"; public const string FeatureSamples_NoJsForm_NoJsForm = "FeatureSamples/NoJsForm/NoJsForm"; public const string FeatureSamples_NullHandling_Button_Enabled = "FeatureSamples/NullHandling/Button_Enabled"; public const string FeatureSamples_ParameterBinding_OptionalParameterBinding = "FeatureSamples/ParameterBinding/OptionalParameterBinding"; diff --git a/src/Samples/Tests/Tests/Feature/MasterPageTests.cs b/src/Samples/Tests/Tests/Feature/MasterPageTests.cs index d1e9bf014b..c190dba091 100644 --- a/src/Samples/Tests/Tests/Feature/MasterPageTests.cs +++ b/src/Samples/Tests/Tests/Feature/MasterPageTests.cs @@ -19,6 +19,32 @@ public void Feature_NestedMasterPages_Content_TwoNestedMasterPages() }); } + [Fact] + [SampleReference(nameof(SamplesRouteUrls.FeatureSamples_LateContentPlaceHolders_Content))] + public void Feature_LateContentPlaceHolders_ContentPlaceHolderInTemplate() + { + RunInAllBrowsers(browser => { + browser.NavigateToUrl(SamplesRouteUrls.FeatureSamples_LateContentPlaceHolders_Content); + + browser.First("h1"); // root master page + browser.First("h2"); // nested master page + var pageContent = browser.First("[data-ui='page-content']"); + AssertUI.InnerTextEquals(pageContent, "Page Content - Late ContentPlaceHolder works!"); + }); + } + + [Fact] + [SampleReference(nameof(SamplesRouteUrls.FeatureSamples_LateContentPlaceHolders_MismatchedContent))] + [Trait("Category", "dev-only")] // tests the error page behavior + public void Feature_LateContentPlaceHolders_MismatchedContent_ThrowsError() + { + RunInAllBrowsers(browser => { + browser.NavigateToUrl(SamplesRouteUrls.FeatureSamples_LateContentPlaceHolders_MismatchedContent); + // The page should show an error because Content(NonExistentPlaceHolder) has no matching ContentPlaceHolder + AssertUI.InnerText(browser.First(".exceptionMessage"), s => s.Contains("NonExistentPlaceHolder")); + }); + } + public MasterPageTests(ITestOutputHelper output) : base(output) { } From 03179a7fabf26cadfa1e49fadd654e33eb0f7b9f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Apr 2026 14:30:18 +0000 Subject: [PATCH 03/17] Fix variable naming in error message per code review Agent-Logs-Url: https://github.com/riganti/dotvvm/sessions/ca479579-7532-44bd-96e4-d8638c2d5130 Co-authored-by: tomasherceg <5599524+tomasherceg@users.noreply.github.com> --- src/Framework/Framework/Hosting/DotvvmPresenter.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Framework/Framework/Hosting/DotvvmPresenter.cs b/src/Framework/Framework/Hosting/DotvvmPresenter.cs index e73e234799..bf78c7625c 100644 --- a/src/Framework/Framework/Hosting/DotvvmPresenter.cs +++ b/src/Framework/Framework/Hosting/DotvvmPresenter.cs @@ -617,9 +617,9 @@ private static void ValidateMasterPageComposition(DotvvmView page) if (pendingList is { Count: > 0 }) { var pending = pendingList[0]; - var masterPageFile = pending.MasterPageFile is { } f ? $" '{f}'" : ""; + var masterPageInfo = pending.MasterPageFile is { } masterPageFile ? $" '{masterPageFile}'" : ""; throw new DotvvmControlException(pending.Content, - $"The ContentPlaceHolder with ID '{pending.Content.ContentPlaceHolderID}' was not found in the master page{masterPageFile}. " + + $"The ContentPlaceHolder with ID '{pending.Content.ContentPlaceHolderID}' was not found in the master page{masterPageInfo}. " + $"Make sure that each Content element has a corresponding ContentPlaceHolder in the master page."); } } From 973957a4a6ef4fb147b6bf07d84558e0dbd1c925 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Herceg?= Date: Sun, 12 Apr 2026 17:03:14 +0200 Subject: [PATCH 04/17] Potential fix for pull request finding 'Missed opportunity to use Where' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- .../Framework/Controls/ContentPlaceHolder.cs | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/Framework/Framework/Controls/ContentPlaceHolder.cs b/src/Framework/Framework/Controls/ContentPlaceHolder.cs index dfa9145a8b..b396e75da5 100644 --- a/src/Framework/Framework/Controls/ContentPlaceHolder.cs +++ b/src/Framework/Framework/Controls/ContentPlaceHolder.cs @@ -39,28 +39,28 @@ internal void ResolvePendingComposition() if (ID == null) return; // Traverse ancestors to find the pending compositions list stored on the root page - foreach (var ancestor in this.GetAllAncestors()) + var pendingList = this.GetAllAncestors() + .Select(ancestor => ancestor.GetValue(Internal.PendingMasterPageCompositionsProperty) as List) + .Where(list => list != null) + .FirstOrDefault(); + + if (pendingList != null) { - if (ancestor.GetValue(Internal.PendingMasterPageCompositionsProperty) is List pendingList) + var pendingIndex = pendingList.FindIndex(p => p.Content.ContentPlaceHolderID == ID); + if (pendingIndex >= 0) { - var pendingIndex = pendingList.FindIndex(p => p.Content.ContentPlaceHolderID == ID); - if (pendingIndex >= 0) - { - var pending = pendingList[pendingIndex]; - pendingList.RemoveAt(pendingIndex); + var pending = pendingList[pendingIndex]; + pendingList.RemoveAt(pendingIndex); - // Perform the deferred composition: wrap Content in a PlaceHolder and add it as our child - var wrapper = new PlaceHolder(); - wrapper.SetDataContextType(pending.DataContextType); + // Perform the deferred composition: wrap Content in a PlaceHolder and add it as our child + var wrapper = new PlaceHolder(); + wrapper.SetDataContextType(pending.DataContextType); - this.Children.Clear(); - this.Children.Add(wrapper); + this.Children.Clear(); + this.Children.Add(wrapper); - wrapper.Children.Add(pending.Content); - pending.Content.SetValue(Internal.IsMasterPageCompositionFinishedProperty, true); - } - // Found the list (even if no match) - no need to search further ancestors - break; + wrapper.Children.Add(pending.Content); + pending.Content.SetValue(Internal.IsMasterPageCompositionFinishedProperty, true); } } } From 82a518aa566e1f82153c492b677c7dde503212a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Herceg?= Date: Sun, 12 Apr 2026 17:43:47 +0200 Subject: [PATCH 05/17] Fixed control registration --- src/Samples/Common/DotvvmStartup.cs | 2 ++ .../FeatureSamples/LateContentPlaceHolders/Nested.dotmaster | 6 +++++- src/Samples/Tests/Tests/Feature/MasterPageTests.cs | 1 + 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/Samples/Common/DotvvmStartup.cs b/src/Samples/Common/DotvvmStartup.cs index d263487d3e..3add781bba 100644 --- a/src/Samples/Common/DotvvmStartup.cs +++ b/src/Samples/Common/DotvvmStartup.cs @@ -33,6 +33,7 @@ using DotVVM.Framework.ResourceManagement; using DotVVM.Samples.Common.Presenters; using DotVVM.Samples.Common.ViewModels.FeatureSamples.CustomPrimitiveTypes; +using DotVVM.Samples.Common.Views.FeatureSamples.LateContentPlaceHolders; namespace DotVVM.Samples.BasicSamples { @@ -269,6 +270,7 @@ private static void AddControls(DotvvmConfiguration config) config.Markup.AddCodeControls("cc", typeof(Controls.TextRepeater)); config.Markup.AddCodeControls("cc", typeof(DerivedControlUsageValidationTestControl)); config.Markup.AddCodeControls("PropertyUpdate", typeof(Controls.ServerRenderedLabel)); + config.Markup.AddCodeControls("sample", typeof(TemplateContainerControl)); config.Markup.AddMarkupControl("IdGeneration", "Control", "Views/FeatureSamples/IdGeneration/IdGeneration_control.dotcontrol"); config.Markup.AddMarkupControl("FileUploadInRepeater", "FileUploadWrapper", "Views/ComplexSamples/FileUploadInRepeater/FileUploadWrapper.dotcontrol"); config.Markup.AddMarkupControl("cc", "RecursiveTextRepeater", "Views/FeatureSamples/PostBack/RecursiveTextRepeater.dotcontrol"); diff --git a/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/Nested.dotmaster b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/Nested.dotmaster index 5ac76523a2..e03093eb3f 100644 --- a/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/Nested.dotmaster +++ b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/Nested.dotmaster @@ -3,5 +3,9 @@

Nested Master Page

- + + + + +
diff --git a/src/Samples/Tests/Tests/Feature/MasterPageTests.cs b/src/Samples/Tests/Tests/Feature/MasterPageTests.cs index c190dba091..bac47fecfb 100644 --- a/src/Samples/Tests/Tests/Feature/MasterPageTests.cs +++ b/src/Samples/Tests/Tests/Feature/MasterPageTests.cs @@ -1,5 +1,6 @@ using DotVVM.Samples.Tests.Base; using DotVVM.Testing.Abstractions; +using Riganti.Selenium.Core; using Xunit; using Xunit.Abstractions; From f0a56331dcda76b7b110a0e92ff19d4967d6d797 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Apr 2026 15:54:20 +0000 Subject: [PATCH 06/17] Add same-ID and default-content scenarios for LateContentPlaceHolders Agent-Logs-Url: https://github.com/riganti/dotvvm/sessions/7069fa2e-7910-464f-85d5-bac0a16e9d74 Co-authored-by: tomasherceg <5599524+tomasherceg@users.noreply.github.com> --- .../Framework/Controls/ContentPlaceHolder.cs | 7 ++++- .../ContentWithDefault.dothtml | 2 ++ .../LateContentPlaceHolders/Nested.dotmaster | 4 ++- .../NestedSameId.dotmaster | 14 +++++++++ .../SharedIdContent.dothtml | 6 ++++ .../Abstractions/SamplesRouteUrls.designer.cs | 2 ++ .../Tests/Tests/Feature/MasterPageTests.cs | 31 +++++++++++++++++++ 7 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/ContentWithDefault.dothtml create mode 100644 src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/NestedSameId.dotmaster create mode 100644 src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/SharedIdContent.dothtml diff --git a/src/Framework/Framework/Controls/ContentPlaceHolder.cs b/src/Framework/Framework/Controls/ContentPlaceHolder.cs index b396e75da5..d27c8a0dcc 100644 --- a/src/Framework/Framework/Controls/ContentPlaceHolder.cs +++ b/src/Framework/Framework/Controls/ContentPlaceHolder.cs @@ -46,7 +46,12 @@ internal void ResolvePendingComposition() if (pendingList != null) { - var pendingIndex = pendingList.FindIndex(p => p.Content.ContentPlaceHolderID == ID); + // When the same ID is used at multiple master page levels, the pending list contains + // multiple entries with the same ID. Items are added from innermost to outermost (because + // BuildView processes master pages from inner to outer). We must match the LAST entry + // so that the outermost ContentPlaceHolder gets the outermost Content, and inner + // ContentPlaceHolders (nested inside that content) get the inner Content entries. + var pendingIndex = pendingList.FindLastIndex(p => p.Content.ContentPlaceHolderID == ID); if (pendingIndex >= 0) { var pending = pendingList[pendingIndex]; diff --git a/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/ContentWithDefault.dothtml b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/ContentWithDefault.dothtml new file mode 100644 index 0000000000..cadf393170 --- /dev/null +++ b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/ContentWithDefault.dothtml @@ -0,0 +1,2 @@ +@viewModel DotVVM.Samples.Common.ViewModels.FeatureSamples.LateContentPlaceHolders.LateContentPlaceHolderViewModel, DotVVM.Samples.Common +@masterPage Views/FeatureSamples/LateContentPlaceHolders/Nested.dotmaster diff --git a/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/Nested.dotmaster b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/Nested.dotmaster index e03093eb3f..823771db4a 100644 --- a/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/Nested.dotmaster +++ b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/Nested.dotmaster @@ -5,7 +5,9 @@

Nested Master Page

- + +

Default nested content

+
diff --git a/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/NestedSameId.dotmaster b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/NestedSameId.dotmaster new file mode 100644 index 0000000000..aea58987ed --- /dev/null +++ b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/NestedSameId.dotmaster @@ -0,0 +1,14 @@ +@viewModel DotVVM.Samples.Common.ViewModels.FeatureSamples.LateContentPlaceHolders.LateContentPlaceHolderViewModel, DotVVM.Samples.Common +@masterPage Views/FeatureSamples/LateContentPlaceHolders/Root.dotmaster + + +

Nested Master Page (shared ID)

+ + + <%-- Same ID as the root ContentPlaceHolder to test same-ID nesting --%> + +

Default shared-ID content

+
+
+
+
diff --git a/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/SharedIdContent.dothtml b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/SharedIdContent.dothtml new file mode 100644 index 0000000000..afcce0b900 --- /dev/null +++ b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/SharedIdContent.dothtml @@ -0,0 +1,6 @@ +@viewModel DotVVM.Samples.Common.ViewModels.FeatureSamples.LateContentPlaceHolders.LateContentPlaceHolderViewModel, DotVVM.Samples.Common +@masterPage Views/FeatureSamples/LateContentPlaceHolders/NestedSameId.dotmaster + + +

Shared ID Page Content

+
diff --git a/src/Samples/Tests/Abstractions/SamplesRouteUrls.designer.cs b/src/Samples/Tests/Abstractions/SamplesRouteUrls.designer.cs index 79b1214198..46eb9f3153 100644 --- a/src/Samples/Tests/Abstractions/SamplesRouteUrls.designer.cs +++ b/src/Samples/Tests/Abstractions/SamplesRouteUrls.designer.cs @@ -318,6 +318,8 @@ public partial class SamplesRouteUrls public const string FeatureSamples_NestedMasterPages_Content = "FeatureSamples/NestedMasterPages/Content"; public const string FeatureSamples_LateContentPlaceHolders_Content = "FeatureSamples/LateContentPlaceHolders/Content"; public const string FeatureSamples_LateContentPlaceHolders_MismatchedContent = "FeatureSamples/LateContentPlaceHolders/MismatchedContent"; + public const string FeatureSamples_LateContentPlaceHolders_SharedIdContent = "FeatureSamples/LateContentPlaceHolders/SharedIdContent"; + public const string FeatureSamples_LateContentPlaceHolders_ContentWithDefault = "FeatureSamples/LateContentPlaceHolders/ContentWithDefault"; public const string FeatureSamples_NoJsForm_NoJsForm = "FeatureSamples/NoJsForm/NoJsForm"; public const string FeatureSamples_NullHandling_Button_Enabled = "FeatureSamples/NullHandling/Button_Enabled"; public const string FeatureSamples_ParameterBinding_OptionalParameterBinding = "FeatureSamples/ParameterBinding/OptionalParameterBinding"; diff --git a/src/Samples/Tests/Tests/Feature/MasterPageTests.cs b/src/Samples/Tests/Tests/Feature/MasterPageTests.cs index bac47fecfb..4d36c2bfed 100644 --- a/src/Samples/Tests/Tests/Feature/MasterPageTests.cs +++ b/src/Samples/Tests/Tests/Feature/MasterPageTests.cs @@ -34,6 +34,37 @@ public void Feature_LateContentPlaceHolders_ContentPlaceHolderInTemplate() }); } + [Fact] + [SampleReference(nameof(SamplesRouteUrls.FeatureSamples_LateContentPlaceHolders_SharedIdContent))] + public void Feature_LateContentPlaceHolders_SameContentPlaceHolderIdInRootAndNested() + { + RunInAllBrowsers(browser => { + browser.NavigateToUrl(SamplesRouteUrls.FeatureSamples_LateContentPlaceHolders_SharedIdContent); + + browser.First("h1"); // root master page + browser.First("h2"); // nested master page (shared ID) + var pageContent = browser.First("[data-ui='shared-id-page-content']"); + AssertUI.InnerTextEquals(pageContent, "Shared ID Page Content"); + // Default content from the shared ID placeholder should NOT be shown + AssertUI.IsNotDisplayed(browser.Single("[data-ui='default-shared-content']")); + }); + } + + [Fact] + [SampleReference(nameof(SamplesRouteUrls.FeatureSamples_LateContentPlaceHolders_ContentWithDefault))] + public void Feature_LateContentPlaceHolders_DefaultContentUsedWhenNoContentProvided() + { + RunInAllBrowsers(browser => { + browser.NavigateToUrl(SamplesRouteUrls.FeatureSamples_LateContentPlaceHolders_ContentWithDefault); + + browser.First("h1"); // root master page + browser.First("h2"); // nested master page + // Default content from the NestedContent placeholder should be shown + var defaultContent = browser.First("[data-ui='default-nested-content']"); + AssertUI.InnerTextEquals(defaultContent, "Default nested content"); + }); + } + [Fact] [SampleReference(nameof(SamplesRouteUrls.FeatureSamples_LateContentPlaceHolders_MismatchedContent))] [Trait("Category", "dev-only")] // tests the error page behavior From b1a21476014703f928985dee1ee6203f834235f0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Apr 2026 16:38:40 +0000 Subject: [PATCH 07/17] Move LateContentPlaceHolders tests to dedicated LateContentPlaceHoldersTests class Agent-Logs-Url: https://github.com/riganti/dotvvm/sessions/d304bd6d-2bdb-4b99-97c5-89777d4241b4 Co-authored-by: tomasherceg <5599524+tomasherceg@users.noreply.github.com> --- .../Feature/LateContentPlaceHoldersTests.cs | 72 +++++++++++++++++++ .../Tests/Tests/Feature/MasterPageTests.cs | 57 --------------- 2 files changed, 72 insertions(+), 57 deletions(-) create mode 100644 src/Samples/Tests/Tests/Feature/LateContentPlaceHoldersTests.cs diff --git a/src/Samples/Tests/Tests/Feature/LateContentPlaceHoldersTests.cs b/src/Samples/Tests/Tests/Feature/LateContentPlaceHoldersTests.cs new file mode 100644 index 0000000000..48fc4e6dda --- /dev/null +++ b/src/Samples/Tests/Tests/Feature/LateContentPlaceHoldersTests.cs @@ -0,0 +1,72 @@ +using DotVVM.Samples.Tests.Base; +using DotVVM.Testing.Abstractions; +using Riganti.Selenium.Core; +using Xunit; +using Xunit.Abstractions; + +namespace DotVVM.Samples.Tests.Feature +{ + public class LateContentPlaceHoldersTests : AppSeleniumTest + { + [Fact] + [SampleReference(nameof(SamplesRouteUrls.FeatureSamples_LateContentPlaceHolders_Content))] + public void Feature_LateContentPlaceHolders_ContentPlaceHolderInTemplate() + { + RunInAllBrowsers(browser => { + browser.NavigateToUrl(SamplesRouteUrls.FeatureSamples_LateContentPlaceHolders_Content); + + browser.First("h1"); // root master page + browser.First("h2"); // nested master page + var pageContent = browser.First("[data-ui='page-content']"); + AssertUI.InnerTextEquals(pageContent, "Page Content - Late ContentPlaceHolder works!"); + }); + } + + [Fact] + [SampleReference(nameof(SamplesRouteUrls.FeatureSamples_LateContentPlaceHolders_SharedIdContent))] + public void Feature_LateContentPlaceHolders_SameContentPlaceHolderIdInRootAndNested() + { + RunInAllBrowsers(browser => { + browser.NavigateToUrl(SamplesRouteUrls.FeatureSamples_LateContentPlaceHolders_SharedIdContent); + + browser.First("h1"); // root master page + browser.First("h2"); // nested master page (shared ID) + var pageContent = browser.First("[data-ui='shared-id-page-content']"); + AssertUI.InnerTextEquals(pageContent, "Shared ID Page Content"); + // Default content from the shared ID placeholder should NOT be shown + AssertUI.IsNotDisplayed(browser.Single("[data-ui='default-shared-content']")); + }); + } + + [Fact] + [SampleReference(nameof(SamplesRouteUrls.FeatureSamples_LateContentPlaceHolders_ContentWithDefault))] + public void Feature_LateContentPlaceHolders_DefaultContentUsedWhenNoContentProvided() + { + RunInAllBrowsers(browser => { + browser.NavigateToUrl(SamplesRouteUrls.FeatureSamples_LateContentPlaceHolders_ContentWithDefault); + + browser.First("h1"); // root master page + browser.First("h2"); // nested master page + // Default content from the NestedContent placeholder should be shown + var defaultContent = browser.First("[data-ui='default-nested-content']"); + AssertUI.InnerTextEquals(defaultContent, "Default nested content"); + }); + } + + [Fact] + [SampleReference(nameof(SamplesRouteUrls.FeatureSamples_LateContentPlaceHolders_MismatchedContent))] + [Trait("Category", "dev-only")] // tests the error page behavior + public void Feature_LateContentPlaceHolders_MismatchedContent_ThrowsError() + { + RunInAllBrowsers(browser => { + browser.NavigateToUrl(SamplesRouteUrls.FeatureSamples_LateContentPlaceHolders_MismatchedContent); + // The page should show an error because Content(NonExistentPlaceHolder) has no matching ContentPlaceHolder + AssertUI.InnerText(browser.First(".exceptionMessage"), s => s.Contains("NonExistentPlaceHolder")); + }); + } + + public LateContentPlaceHoldersTests(ITestOutputHelper output) : base(output) + { + } + } +} diff --git a/src/Samples/Tests/Tests/Feature/MasterPageTests.cs b/src/Samples/Tests/Tests/Feature/MasterPageTests.cs index 4d36c2bfed..6159fb5204 100644 --- a/src/Samples/Tests/Tests/Feature/MasterPageTests.cs +++ b/src/Samples/Tests/Tests/Feature/MasterPageTests.cs @@ -20,63 +20,6 @@ public void Feature_NestedMasterPages_Content_TwoNestedMasterPages() }); } - [Fact] - [SampleReference(nameof(SamplesRouteUrls.FeatureSamples_LateContentPlaceHolders_Content))] - public void Feature_LateContentPlaceHolders_ContentPlaceHolderInTemplate() - { - RunInAllBrowsers(browser => { - browser.NavigateToUrl(SamplesRouteUrls.FeatureSamples_LateContentPlaceHolders_Content); - - browser.First("h1"); // root master page - browser.First("h2"); // nested master page - var pageContent = browser.First("[data-ui='page-content']"); - AssertUI.InnerTextEquals(pageContent, "Page Content - Late ContentPlaceHolder works!"); - }); - } - - [Fact] - [SampleReference(nameof(SamplesRouteUrls.FeatureSamples_LateContentPlaceHolders_SharedIdContent))] - public void Feature_LateContentPlaceHolders_SameContentPlaceHolderIdInRootAndNested() - { - RunInAllBrowsers(browser => { - browser.NavigateToUrl(SamplesRouteUrls.FeatureSamples_LateContentPlaceHolders_SharedIdContent); - - browser.First("h1"); // root master page - browser.First("h2"); // nested master page (shared ID) - var pageContent = browser.First("[data-ui='shared-id-page-content']"); - AssertUI.InnerTextEquals(pageContent, "Shared ID Page Content"); - // Default content from the shared ID placeholder should NOT be shown - AssertUI.IsNotDisplayed(browser.Single("[data-ui='default-shared-content']")); - }); - } - - [Fact] - [SampleReference(nameof(SamplesRouteUrls.FeatureSamples_LateContentPlaceHolders_ContentWithDefault))] - public void Feature_LateContentPlaceHolders_DefaultContentUsedWhenNoContentProvided() - { - RunInAllBrowsers(browser => { - browser.NavigateToUrl(SamplesRouteUrls.FeatureSamples_LateContentPlaceHolders_ContentWithDefault); - - browser.First("h1"); // root master page - browser.First("h2"); // nested master page - // Default content from the NestedContent placeholder should be shown - var defaultContent = browser.First("[data-ui='default-nested-content']"); - AssertUI.InnerTextEquals(defaultContent, "Default nested content"); - }); - } - - [Fact] - [SampleReference(nameof(SamplesRouteUrls.FeatureSamples_LateContentPlaceHolders_MismatchedContent))] - [Trait("Category", "dev-only")] // tests the error page behavior - public void Feature_LateContentPlaceHolders_MismatchedContent_ThrowsError() - { - RunInAllBrowsers(browser => { - browser.NavigateToUrl(SamplesRouteUrls.FeatureSamples_LateContentPlaceHolders_MismatchedContent); - // The page should show an error because Content(NonExistentPlaceHolder) has no matching ContentPlaceHolder - AssertUI.InnerText(browser.First(".exceptionMessage"), s => s.Contains("NonExistentPlaceHolder")); - }); - } - public MasterPageTests(ITestOutputHelper output) : base(output) { } From 77b06797f7580226972c84db3d4ca7bc925fbaa5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 22 May 2026 12:40:34 +0000 Subject: [PATCH 08/17] Add ContentPlaceHolderIds validation and duplicate-composition guard Agent-Logs-Url: https://github.com/riganti/dotvvm/sessions/c846671f-e8f5-4f1d-b690-76b7f1ef8655 Co-authored-by: tomasherceg <5599524+tomasherceg@users.noreply.github.com> --- .../ControlTree/Resolved/ResolvedTreeRoot.cs | 32 ++++++++- .../ViewCompiler/ControlBuilderDescriptor.cs | 10 ++- .../Framework/Controls/ContentPlaceHolder.cs | 66 ++++++++++++------- src/Framework/Framework/Controls/Internal.cs | 8 +++ .../Framework/Hosting/DotvvmPresenter.cs | 8 +-- .../Runtime/DefaultDotvvmViewBuilder.cs | 17 +++-- 6 files changed, 108 insertions(+), 33 deletions(-) diff --git a/src/Framework/Framework/Compilation/ControlTree/Resolved/ResolvedTreeRoot.cs b/src/Framework/Framework/Compilation/ControlTree/Resolved/ResolvedTreeRoot.cs index 12aa8911cb..f0bc968701 100644 --- a/src/Framework/Framework/Compilation/ControlTree/Resolved/ResolvedTreeRoot.cs +++ b/src/Framework/Framework/Compilation/ControlTree/Resolved/ResolvedTreeRoot.cs @@ -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() @@ -41,6 +42,35 @@ from d in ds.Value return null; } + /// + /// Traverses the entire resolved tree (including controls inside templates) to collect + /// all ContentPlaceHolder IDs declared in this page/master page file. + /// + private ImmutableArray CollectContentPlaceHolderIds() + { + var collector = new ContentPlaceHolderIdCollector(); + this.AcceptChildren(collector); + return collector.Ids.Count == 0 + ? ImmutableArray.Empty + : collector.Ids.ToImmutableArray(); + } + + private sealed class ContentPlaceHolderIdCollector : ResolvedControlTreeVisitor + { + public readonly List Ids = new List(); + + 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> directives, ControlBuilderDescriptor? masterPage) : base(metadata, node, null, dataContext) { diff --git a/src/Framework/Framework/Compilation/ViewCompiler/ControlBuilderDescriptor.cs b/src/Framework/Framework/Compilation/ViewCompiler/ControlBuilderDescriptor.cs index 83654e8c32..ea3f84847c 100644 --- a/src/Framework/Framework/Compilation/ViewCompiler/ControlBuilderDescriptor.cs +++ b/src/Framework/Framework/Compilation/ViewCompiler/ControlBuilderDescriptor.cs @@ -27,6 +27,12 @@ public class ControlBuilderDescriptor: IAbstractControlBuilderDescriptor public ViewModuleReferenceInfo? ViewModuleReference { get; } + /// + /// All ContentPlaceHolder IDs declared in this page/master page, including those inside CompositeControl templates. + /// Used to validate Content controls when performing master page composition. + /// + public ImmutableArray ContentPlaceHolderIds { get; } + ITypeDescriptor IAbstractControlBuilderDescriptor.DataContextType => new ResolvedTypeDescriptor(this.DataContextType); ITypeDescriptor IAbstractControlBuilderDescriptor.ControlType => new ResolvedTypeDescriptor(this.ControlType); @@ -39,7 +45,8 @@ public ControlBuilderDescriptor( string? fileName, ControlBuilderDescriptor? masterPage, ImmutableArray<(string name, string value)> directives, - ViewModuleReferenceInfo? viewModuleReference + ViewModuleReferenceInfo? viewModuleReference, + ImmutableArray contentPlaceHolderIds = default ) { this.DataContextType = dataContextType; @@ -48,6 +55,7 @@ public ControlBuilderDescriptor( this.MasterPage = masterPage; this.Directives = directives; this.ViewModuleReference = viewModuleReference; + this.ContentPlaceHolderIds = contentPlaceHolderIds.IsDefault ? ImmutableArray.Empty : contentPlaceHolderIds; } } } diff --git a/src/Framework/Framework/Controls/ContentPlaceHolder.cs b/src/Framework/Framework/Controls/ContentPlaceHolder.cs index d27c8a0dcc..48782ed009 100644 --- a/src/Framework/Framework/Controls/ContentPlaceHolder.cs +++ b/src/Framework/Framework/Controls/ContentPlaceHolder.cs @@ -32,41 +32,61 @@ protected internal override void OnInit(IDotvvmRequestContext context) /// /// Looks for a pending master page composition matching this ContentPlaceHolder's ID - /// and performs the composition if found. + /// and performs the composition if found. Throws if the same ContentPlaceHolder ID + /// is being resolved for a second time (e.g. ContentPlaceHolder inside a Repeater template). /// internal void ResolvePendingComposition() { if (ID == null) return; // Traverse ancestors to find the pending compositions list stored on the root page - var pendingList = this.GetAllAncestors() - .Select(ancestor => ancestor.GetValue(Internal.PendingMasterPageCompositionsProperty) as List) - .Where(list => list != null) - .FirstOrDefault(); + var rootPage = this.GetAllAncestors() + .FirstOrDefault(ancestor => ancestor.GetValue(Internal.PendingMasterPageCompositionsProperty) != null); - if (pendingList != null) + if (rootPage == null) return; + + var pendingList = rootPage.GetValue(Internal.PendingMasterPageCompositionsProperty) as List; + if (pendingList == null) return; + + // Check for duplicate: if this ID was already resolved via deferred composition, a second + // instantiation (e.g. ContentPlaceHolder inside a Repeater) would silently render with the + // wrong content. Throw instead to surface the problem early. + var resolvedIds = rootPage.GetValue(Internal.ResolvedMasterPageCompositionIdsProperty) as HashSet; + if (resolvedIds != null && 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)."); + } + + // When the same ID is used at multiple master page levels, the pending list contains + // multiple entries with the same ID. Items are added from innermost to outermost (because + // BuildView processes master pages from inner to outer). We must match the LAST entry + // so that the outermost ContentPlaceHolder gets the outermost Content, and inner + // ContentPlaceHolders (nested inside that content) get the inner Content entries. + var pendingIndex = pendingList.FindLastIndex(p => p.Content.ContentPlaceHolderID == ID); + if (pendingIndex >= 0) { - // When the same ID is used at multiple master page levels, the pending list contains - // multiple entries with the same ID. Items are added from innermost to outermost (because - // BuildView processes master pages from inner to outer). We must match the LAST entry - // so that the outermost ContentPlaceHolder gets the outermost Content, and inner - // ContentPlaceHolders (nested inside that content) get the inner Content entries. - var pendingIndex = pendingList.FindLastIndex(p => p.Content.ContentPlaceHolderID == ID); - if (pendingIndex >= 0) + var pending = pendingList[pendingIndex]; + pendingList.RemoveAt(pendingIndex); + + // Track that this ID has been resolved so a second instantiation can be detected. + if (resolvedIds == null) { - var pending = pendingList[pendingIndex]; - pendingList.RemoveAt(pendingIndex); + resolvedIds = new HashSet(StringComparer.Ordinal); + rootPage.SetValue(Internal.ResolvedMasterPageCompositionIdsProperty, resolvedIds); + } + resolvedIds.Add(ID); - // Perform the deferred composition: wrap Content in a PlaceHolder and add it as our child - var wrapper = new PlaceHolder(); - wrapper.SetDataContextType(pending.DataContextType); + // Perform the deferred composition: wrap Content in a PlaceHolder and add it as our child + var wrapper = new PlaceHolder(); + wrapper.SetDataContextType(pending.DataContextType); - this.Children.Clear(); - this.Children.Add(wrapper); + this.Children.Clear(); + this.Children.Add(wrapper); - wrapper.Children.Add(pending.Content); - pending.Content.SetValue(Internal.IsMasterPageCompositionFinishedProperty, true); - } + wrapper.Children.Add(pending.Content); + pending.Content.SetValue(Internal.IsMasterPageCompositionFinishedProperty, true); } } diff --git a/src/Framework/Framework/Controls/Internal.cs b/src/Framework/Framework/Controls/Internal.cs index dd8933b873..ad19241c1d 100644 --- a/src/Framework/Framework/Controls/Internal.cs +++ b/src/Framework/Framework/Controls/Internal.cs @@ -76,6 +76,14 @@ public class Internal public static readonly DotvvmProperty PendingMasterPageCompositionsProperty = DotvvmProperty.Register?, Internal>(() => PendingMasterPageCompositionsProperty, defaultValue: null, isValueInherited: false); + /// + /// Tracks ContentPlaceHolder IDs that have already been resolved via deferred master page composition. + /// Used to detect when a ContentPlaceHolder is instantiated more than once (e.g. inside a Repeater template), + /// which is not supported and would result in only the first instance being filled with Content. + /// + public static readonly DotvvmProperty ResolvedMasterPageCompositionIdsProperty = + DotvvmProperty.Register?, Internal>(() => ResolvedMasterPageCompositionIdsProperty, defaultValue: null, isValueInherited: false); + public static bool IsViewCompilerProperty(DotvvmProperty property) { return property.DeclaringType == typeof(Internal); diff --git a/src/Framework/Framework/Hosting/DotvvmPresenter.cs b/src/Framework/Framework/Hosting/DotvvmPresenter.cs index bf78c7625c..7c22d02603 100644 --- a/src/Framework/Framework/Hosting/DotvvmPresenter.cs +++ b/src/Framework/Framework/Hosting/DotvvmPresenter.cs @@ -608,8 +608,8 @@ public static bool DeterminePartialRendering(IHttpContext context) => /// /// Validates that all Content controls have been matched to their corresponding ContentPlaceHolder controls - /// after the Load phase. If any Content controls remain unmatched (e.g. because the ContentPlaceHolder - /// with the specified ID does not exist in the master page), an exception is thrown. + /// 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). /// private static void ValidateMasterPageComposition(DotvvmView page) { @@ -619,8 +619,8 @@ private static void ValidateMasterPageComposition(DotvvmView page) var pending = pendingList[0]; var masterPageInfo = pending.MasterPageFile is { } masterPageFile ? $" '{masterPageFile}'" : ""; throw new DotvvmControlException(pending.Content, - $"The ContentPlaceHolder with ID '{pending.Content.ContentPlaceHolderID}' was not found in the master page{masterPageInfo}. " + - $"Make sure that each Content element has a corresponding ContentPlaceHolder in the master page."); + $"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)."); } } } diff --git a/src/Framework/Framework/Runtime/DefaultDotvvmViewBuilder.cs b/src/Framework/Framework/Runtime/DefaultDotvvmViewBuilder.cs index 7b6d3a0f67..a979dc9943 100644 --- a/src/Framework/Framework/Runtime/DefaultDotvvmViewBuilder.cs +++ b/src/Framework/Framework/Runtime/DefaultDotvvmViewBuilder.cs @@ -8,6 +8,7 @@ using DotVVM.Framework.Binding; using DotVVM.Framework.Compilation; using DotVVM.Framework.Compilation.Parser; +using DotVVM.Framework.Compilation.ViewCompiler; using DotVVM.Framework.Configuration; using DotVVM.Framework.Controls; using DotVVM.Framework.Controls.Infrastructure; @@ -55,7 +56,7 @@ public DotvvmView BuildView(IDotvvmRequestContext context) var masterPage = (DotvvmView)pageBuilder.Value.BuildControl(controlBuilderFactory, context.Services); FillsDefaultDirectives(masterPage); - PerformMasterPageComposition(contentPage, masterPage, pendingCompositions); + PerformMasterPageComposition(contentPage, masterPage, pageDescriptor, pendingCompositions); masterPage.ViewModelType = contentPage.ViewModelType; contentPage = masterPage; @@ -107,7 +108,7 @@ private void FillsDefaultDirectives(DotvvmView page) /// /// Performs the master page nesting. /// - private void PerformMasterPageComposition(DotvvmView childPage, DotvvmView masterPage, List pendingCompositions) + private void PerformMasterPageComposition(DotvvmView childPage, DotvvmView masterPage, ControlBuilderDescriptor masterPageDescriptor, List pendingCompositions) { 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}'."); @@ -125,8 +126,16 @@ private void PerformMasterPageComposition(DotvvmView childPage, DotvvmView maste var placeHolder = placeHolders.SingleOrDefault(p => p.ID == content.ContentPlaceHolderID); if (placeHolder == null) { - // ContentPlaceHolder not found yet - it might be inside a CompositeControl template - // that will be instantiated in the Load phase. Defer the composition. + // ContentPlaceHolder not found in the statically-built tree. + // Before deferring, verify the ID is at least declared somewhere in the master page + // (including inside CompositeControl templates that are instantiated in Load phase). + 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."); + } // Set up properties that we'll need during deferred composition content.SetValue(DotvvmView.DirectivesProperty, childPage.Directives); From 796fba02b575ee7d7ea4b75631392449b1235808 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 22 May 2026 13:04:24 +0000 Subject: [PATCH 09/17] Add Repeater and AuthenticatedView ContentPlaceHolder test scenarios Agent-Logs-Url: https://github.com/riganti/dotvvm/sessions/2b48d2fd-fe36-480d-bdc3-1a9c5b7c5012 Co-authored-by: tomasherceg <5599524+tomasherceg@users.noreply.github.com> --- .../Framework/Hosting/DotvvmPresenter.cs | 11 ++-- .../Runtime/DefaultDotvvmViewBuilder.cs | 2 +- .../LateContentPlaceHolderViewModel.cs | 22 ++++++++ .../AuthViewContent.dothtml | 6 +++ .../AuthViewMaster.dotmaster | 27 ++++++++++ .../RepeaterMaster.dotmaster | 17 ++++++ .../RepeaterMultipleItems.dothtml | 6 +++ .../RepeaterOneItem.dothtml | 6 +++ .../RepeaterZeroItems.dothtml | 6 +++ .../Abstractions/SamplesRouteUrls.designer.cs | 4 ++ .../Feature/LateContentPlaceHoldersTests.cs | 53 +++++++++++++++++++ 11 files changed, 155 insertions(+), 5 deletions(-) create mode 100644 src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/AuthViewContent.dothtml create mode 100644 src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/AuthViewMaster.dotmaster create mode 100644 src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/RepeaterMaster.dotmaster create mode 100644 src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/RepeaterMultipleItems.dothtml create mode 100644 src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/RepeaterOneItem.dothtml create mode 100644 src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/RepeaterZeroItems.dothtml diff --git a/src/Framework/Framework/Hosting/DotvvmPresenter.cs b/src/Framework/Framework/Hosting/DotvvmPresenter.cs index 7c22d02603..6a15851970 100644 --- a/src/Framework/Framework/Hosting/DotvvmPresenter.cs +++ b/src/Framework/Framework/Hosting/DotvvmPresenter.cs @@ -197,9 +197,6 @@ public async Task ProcessRequestCore(IDotvvmRequestContext context) // run the load phase in the page DotvvmControlCollection.InvokePageLifeCycleEventRecursive(page, LifeCycleEventType.Load, context); await requestTracer.TraceEvent(RequestTracingConstants.LoadCompleted, context); - - // After Load, ensure all Content controls have been matched to their ContentPlaceHolders - ValidateMasterPageComposition(page); } else { @@ -237,7 +234,8 @@ 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 + // 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 @@ -274,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); diff --git a/src/Framework/Framework/Runtime/DefaultDotvvmViewBuilder.cs b/src/Framework/Framework/Runtime/DefaultDotvvmViewBuilder.cs index a979dc9943..87138715c5 100644 --- a/src/Framework/Framework/Runtime/DefaultDotvvmViewBuilder.cs +++ b/src/Framework/Framework/Runtime/DefaultDotvvmViewBuilder.cs @@ -129,7 +129,7 @@ private void PerformMasterPageComposition(DotvvmView childPage, DotvvmView maste // ContentPlaceHolder not found in the statically-built tree. // Before deferring, verify the ID is at least declared somewhere in the master page // (including inside CompositeControl templates that are instantiated in Load phase). - if (!masterPageDescriptor.ContentPlaceHolderIds.Contains(content.ContentPlaceHolderID)) + if (!masterPageDescriptor.ContentPlaceHolderIds.Contains(content.ContentPlaceHolderID!)) { var masterPageInfo = masterPageDescriptor.FileName is { } masterPageFile ? $" '{masterPageFile}'" : ""; throw new DotvvmControlException(content, diff --git a/src/Samples/Common/ViewModels/FeatureSamples/LateContentPlaceHolders/LateContentPlaceHolderViewModel.cs b/src/Samples/Common/ViewModels/FeatureSamples/LateContentPlaceHolders/LateContentPlaceHolderViewModel.cs index 1f088d981b..7665fa6899 100644 --- a/src/Samples/Common/ViewModels/FeatureSamples/LateContentPlaceHolders/LateContentPlaceHolderViewModel.cs +++ b/src/Samples/Common/ViewModels/FeatureSamples/LateContentPlaceHolders/LateContentPlaceHolderViewModel.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using DotVVM.Framework.ViewModel; namespace DotVVM.Samples.Common.ViewModels.FeatureSamples.LateContentPlaceHolders @@ -5,4 +6,25 @@ namespace DotVVM.Samples.Common.ViewModels.FeatureSamples.LateContentPlaceHolder public class LateContentPlaceHolderViewModel : DotvvmViewModelBase { } + + /// Viewmodel for Repeater tests - items list is set per subclass. + public abstract class RepeaterContentPlaceHolderViewModel : DotvvmViewModelBase + { + public List Items { get; set; } = new List(); + } + + public class RepeaterOneItemViewModel : RepeaterContentPlaceHolderViewModel + { + public RepeaterOneItemViewModel() { Items = new List { "Item 1" }; } + } + + public class RepeaterZeroItemsViewModel : RepeaterContentPlaceHolderViewModel + { + public RepeaterZeroItemsViewModel() { Items = new List(); } + } + + public class RepeaterMultipleItemsViewModel : RepeaterContentPlaceHolderViewModel + { + public RepeaterMultipleItemsViewModel() { Items = new List { "Item 1", "Item 2" }; } + } } diff --git a/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/AuthViewContent.dothtml b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/AuthViewContent.dothtml new file mode 100644 index 0000000000..9ab53d0e84 --- /dev/null +++ b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/AuthViewContent.dothtml @@ -0,0 +1,6 @@ +@viewModel DotVVM.Samples.Common.ViewModels.FeatureSamples.LateContentPlaceHolders.LateContentPlaceHolderViewModel, DotVVM.Samples.Common +@masterPage Views/FeatureSamples/LateContentPlaceHolders/AuthViewMaster.dotmaster + + +

Content from ContentPlaceHolder inside AuthenticatedView template

+
diff --git a/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/AuthViewMaster.dotmaster b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/AuthViewMaster.dotmaster new file mode 100644 index 0000000000..a238c1bfa4 --- /dev/null +++ b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/AuthViewMaster.dotmaster @@ -0,0 +1,27 @@ +@viewModel DotVVM.Samples.Common.ViewModels.FeatureSamples.LateContentPlaceHolders.LateContentPlaceHolderViewModel, DotVVM.Samples.Common + + + + + + Late ContentPlaceHolder - AuthenticatedView Master + + +

AuthenticatedView Master Page

+ + <%-- Same ContentPlaceHolder ID in both templates - only one will be instantiated --%> + + +
+ +
+
+ +
+ +
+
+
+ + + diff --git a/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/RepeaterMaster.dotmaster b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/RepeaterMaster.dotmaster new file mode 100644 index 0000000000..5b755206d4 --- /dev/null +++ b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/RepeaterMaster.dotmaster @@ -0,0 +1,17 @@ +@viewModel DotVVM.Samples.Common.ViewModels.FeatureSamples.LateContentPlaceHolders.RepeaterContentPlaceHolderViewModel, DotVVM.Samples.Common + + + + + + Late ContentPlaceHolder - Repeater Master + + +

Repeater Master Page

+ + + + + + + diff --git a/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/RepeaterMultipleItems.dothtml b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/RepeaterMultipleItems.dothtml new file mode 100644 index 0000000000..bcd373a2b5 --- /dev/null +++ b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/RepeaterMultipleItems.dothtml @@ -0,0 +1,6 @@ +@viewModel DotVVM.Samples.Common.ViewModels.FeatureSamples.LateContentPlaceHolders.RepeaterMultipleItemsViewModel, DotVVM.Samples.Common +@masterPage Views/FeatureSamples/LateContentPlaceHolders/RepeaterMaster.dotmaster + + +

{{value: _this}}

+
diff --git a/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/RepeaterOneItem.dothtml b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/RepeaterOneItem.dothtml new file mode 100644 index 0000000000..16f54c5860 --- /dev/null +++ b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/RepeaterOneItem.dothtml @@ -0,0 +1,6 @@ +@viewModel DotVVM.Samples.Common.ViewModels.FeatureSamples.LateContentPlaceHolders.RepeaterOneItemViewModel, DotVVM.Samples.Common +@masterPage Views/FeatureSamples/LateContentPlaceHolders/RepeaterMaster.dotmaster + + +

{{value: _this}}

+
diff --git a/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/RepeaterZeroItems.dothtml b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/RepeaterZeroItems.dothtml new file mode 100644 index 0000000000..49b259208f --- /dev/null +++ b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/RepeaterZeroItems.dothtml @@ -0,0 +1,6 @@ +@viewModel DotVVM.Samples.Common.ViewModels.FeatureSamples.LateContentPlaceHolders.RepeaterZeroItemsViewModel, DotVVM.Samples.Common +@masterPage Views/FeatureSamples/LateContentPlaceHolders/RepeaterMaster.dotmaster + + +

{{value: _this}}

+
diff --git a/src/Samples/Tests/Abstractions/SamplesRouteUrls.designer.cs b/src/Samples/Tests/Abstractions/SamplesRouteUrls.designer.cs index 46eb9f3153..89b8e38d88 100644 --- a/src/Samples/Tests/Abstractions/SamplesRouteUrls.designer.cs +++ b/src/Samples/Tests/Abstractions/SamplesRouteUrls.designer.cs @@ -320,6 +320,10 @@ public partial class SamplesRouteUrls public const string FeatureSamples_LateContentPlaceHolders_MismatchedContent = "FeatureSamples/LateContentPlaceHolders/MismatchedContent"; public const string FeatureSamples_LateContentPlaceHolders_SharedIdContent = "FeatureSamples/LateContentPlaceHolders/SharedIdContent"; public const string FeatureSamples_LateContentPlaceHolders_ContentWithDefault = "FeatureSamples/LateContentPlaceHolders/ContentWithDefault"; + public const string FeatureSamples_LateContentPlaceHolders_RepeaterOneItem = "FeatureSamples/LateContentPlaceHolders/RepeaterOneItem"; + public const string FeatureSamples_LateContentPlaceHolders_RepeaterZeroItems = "FeatureSamples/LateContentPlaceHolders/RepeaterZeroItems"; + public const string FeatureSamples_LateContentPlaceHolders_RepeaterMultipleItems = "FeatureSamples/LateContentPlaceHolders/RepeaterMultipleItems"; + public const string FeatureSamples_LateContentPlaceHolders_AuthViewContent = "FeatureSamples/LateContentPlaceHolders/AuthViewContent"; public const string FeatureSamples_NoJsForm_NoJsForm = "FeatureSamples/NoJsForm/NoJsForm"; public const string FeatureSamples_NullHandling_Button_Enabled = "FeatureSamples/NullHandling/Button_Enabled"; public const string FeatureSamples_ParameterBinding_OptionalParameterBinding = "FeatureSamples/ParameterBinding/OptionalParameterBinding"; diff --git a/src/Samples/Tests/Tests/Feature/LateContentPlaceHoldersTests.cs b/src/Samples/Tests/Tests/Feature/LateContentPlaceHoldersTests.cs index 48fc4e6dda..48ad3a92b9 100644 --- a/src/Samples/Tests/Tests/Feature/LateContentPlaceHoldersTests.cs +++ b/src/Samples/Tests/Tests/Feature/LateContentPlaceHoldersTests.cs @@ -65,6 +65,59 @@ public void Feature_LateContentPlaceHolders_MismatchedContent_ThrowsError() }); } + [Fact] + [SampleReference(nameof(SamplesRouteUrls.FeatureSamples_LateContentPlaceHolders_RepeaterOneItem))] + public void Feature_LateContentPlaceHolders_ContentPlaceHolderInRepeater_OneItem_Works() + { + RunInAllBrowsers(browser => { + browser.NavigateToUrl(SamplesRouteUrls.FeatureSamples_LateContentPlaceHolders_RepeaterOneItem); + + browser.First("[data-ui='master-heading']"); // master page rendered + var items = browser.FindElements("[data-ui='repeater-item']"); + Assert.Equal(1, items.Count); + AssertUI.InnerTextEquals(items[0], "Item 1"); + }); + } + + [Fact] + [SampleReference(nameof(SamplesRouteUrls.FeatureSamples_LateContentPlaceHolders_RepeaterZeroItems))] + [Trait("Category", "dev-only")] // tests the error page behavior + public void Feature_LateContentPlaceHolders_ContentPlaceHolderInRepeater_ZeroItems_ThrowsError() + { + RunInAllBrowsers(browser => { + browser.NavigateToUrl(SamplesRouteUrls.FeatureSamples_LateContentPlaceHolders_RepeaterZeroItems); + // Should throw because ContentPlaceHolder is never instantiated (Repeater has no items) + AssertUI.InnerText(browser.First(".exceptionMessage"), s => s.Contains("RepeaterContent")); + }); + } + + [Fact] + [SampleReference(nameof(SamplesRouteUrls.FeatureSamples_LateContentPlaceHolders_RepeaterMultipleItems))] + [Trait("Category", "dev-only")] // tests the error page behavior + public void Feature_LateContentPlaceHolders_ContentPlaceHolderInRepeater_MultipleItems_ThrowsError() + { + RunInAllBrowsers(browser => { + browser.NavigateToUrl(SamplesRouteUrls.FeatureSamples_LateContentPlaceHolders_RepeaterMultipleItems); + // Should throw because ContentPlaceHolder is instantiated more than once (Repeater has 2+ items) + AssertUI.InnerText(browser.First(".exceptionMessage"), s => s.Contains("RepeaterContent") && s.Contains("already been resolved")); + }); + } + + [Fact] + [SampleReference(nameof(SamplesRouteUrls.FeatureSamples_LateContentPlaceHolders_AuthViewContent))] + public void Feature_LateContentPlaceHolders_SameContentPlaceHolderIdInAuthenticatedViewTemplates_Works() + { + RunInAllBrowsers(browser => { + browser.NavigateToUrl(SamplesRouteUrls.FeatureSamples_LateContentPlaceHolders_AuthViewContent); + + browser.First("[data-ui='master-heading']"); // master page rendered + // In unauthenticated state, the NotAuthenticatedTemplate is instantiated + var section = browser.First("[data-ui='not-authenticated-section']"); + var content = section.First("[data-ui='auth-content']"); + AssertUI.InnerText(content, s => s.Contains("Content from ContentPlaceHolder inside AuthenticatedView template")); + }); + } + public LateContentPlaceHoldersTests(ITestOutputHelper output) : base(output) { } From cb3f5adfef11f34c7b90b7c7f61d96cb9e2ca519 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Herceg?= Date: Fri, 12 Jun 2026 15:00:33 +0200 Subject: [PATCH 10/17] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Standa Lukeš --- src/Framework/Framework/Controls/ContentPlaceHolder.cs | 4 ++-- src/Framework/Framework/Hosting/DotvvmPresenter.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Framework/Framework/Controls/ContentPlaceHolder.cs b/src/Framework/Framework/Controls/ContentPlaceHolder.cs index 48782ed009..7228acb823 100644 --- a/src/Framework/Framework/Controls/ContentPlaceHolder.cs +++ b/src/Framework/Framework/Controls/ContentPlaceHolder.cs @@ -45,13 +45,13 @@ internal void ResolvePendingComposition() if (rootPage == null) return; - var pendingList = rootPage.GetValue(Internal.PendingMasterPageCompositionsProperty) as List; + var pendingList = (List?)rootPage.GetValue(Internal.PendingMasterPageCompositionsProperty); if (pendingList == null) return; // Check for duplicate: if this ID was already resolved via deferred composition, a second // instantiation (e.g. ContentPlaceHolder inside a Repeater) would silently render with the // wrong content. Throw instead to surface the problem early. - var resolvedIds = rootPage.GetValue(Internal.ResolvedMasterPageCompositionIdsProperty) as HashSet; + var resolvedIds = (HashSet?)rootPage.GetValue(Internal.ResolvedMasterPageCompositionIdsProperty); if (resolvedIds != null && resolvedIds.Contains(ID)) { throw new DotvvmControlException(this, diff --git a/src/Framework/Framework/Hosting/DotvvmPresenter.cs b/src/Framework/Framework/Hosting/DotvvmPresenter.cs index 6a15851970..2b1924b93f 100644 --- a/src/Framework/Framework/Hosting/DotvvmPresenter.cs +++ b/src/Framework/Framework/Hosting/DotvvmPresenter.cs @@ -616,7 +616,7 @@ public static bool DeterminePartialRendering(IHttpContext context) => /// private static void ValidateMasterPageComposition(DotvvmView page) { - var pendingList = page.GetValue(Internal.PendingMasterPageCompositionsProperty) as List; + var pendingList = (List?)page.GetValue(Internal.PendingMasterPageCompositionsProperty); if (pendingList is { Count: > 0 }) { var pending = pendingList[0]; From ef6bbd038dbfa47e4293ee1489bd07ecd198b94d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Herceg?= Date: Fri, 12 Jun 2026 15:06:01 +0200 Subject: [PATCH 11/17] Code review comment applied --- .../Runtime/DefaultDotvvmViewBuilder.cs | 26 +++++-------------- 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/src/Framework/Framework/Runtime/DefaultDotvvmViewBuilder.cs b/src/Framework/Framework/Runtime/DefaultDotvvmViewBuilder.cs index 87138715c5..e359e9c8e8 100644 --- a/src/Framework/Framework/Runtime/DefaultDotvvmViewBuilder.cs +++ b/src/Framework/Framework/Runtime/DefaultDotvvmViewBuilder.cs @@ -122,13 +122,14 @@ private void PerformMasterPageComposition(DotvvmView childPage, DotvvmView maste // perform the composition foreach (var content in contents) { + content.SetValue(DotvvmView.DirectivesProperty, childPage.Directives); + content.SetValue(Internal.MarkupFileNameProperty, childPage.GetValue(Internal.MarkupFileNameProperty)); + content.SetValue(Internal.ReferencedViewModuleInfoProperty, childPage.GetValue(Internal.ReferencedViewModuleInfoProperty)); + // find the corresponding placeholder var placeHolder = placeHolders.SingleOrDefault(p => p.ID == content.ContentPlaceHolderID); if (placeHolder == null) { - // ContentPlaceHolder not found in the statically-built tree. - // Before deferring, verify the ID is at least declared somewhere in the master page - // (including inside CompositeControl templates that are instantiated in Load phase). if (!masterPageDescriptor.ContentPlaceHolderIds.Contains(content.ContentPlaceHolderID!)) { var masterPageInfo = masterPageDescriptor.FileName is { } masterPageFile ? $" '{masterPageFile}'" : ""; @@ -137,18 +138,8 @@ private void PerformMasterPageComposition(DotvvmView childPage, DotvvmView maste $"Make sure that each Content element has a corresponding ContentPlaceHolder in the master page."); } - // Set up properties that we'll need during deferred composition - content.SetValue(DotvvmView.DirectivesProperty, childPage.Directives); - content.SetValue(Internal.MarkupFileNameProperty, childPage.GetValue(Internal.MarkupFileNameProperty)); - content.SetValue(Internal.ReferencedViewModuleInfoProperty, childPage.GetValue(Internal.ReferencedViewModuleInfoProperty)); - - // Capture the DataContextType before removing the content from its parent - var dataContextType = content.Parent?.GetDataContextType(); - - // Remove the content from the child page (which will be discarded) - (content.Parent as DotvvmControl)?.Children.Remove(content); - - // Add to the shared pending list - will be resolved by ContentPlaceHolder.OnInit + var dataContextType = content.Parent!.GetDataContextType(); + ((DotvvmControl)content.Parent!).Children.Remove(content); pendingCompositions.Add(new PendingMasterPageComposition(content, dataContextType, masterPage.GetValue(Internal.MarkupFileNameProperty)?.ToString())); continue; } @@ -156,16 +147,13 @@ private void PerformMasterPageComposition(DotvvmView childPage, DotvvmView maste // replace the contents var contentPlaceHolder = new PlaceHolder(); contentPlaceHolder.SetDataContextType(content.Parent!.GetDataContextType()); - (content.Parent as DotvvmControl)?.Children.Remove(content); + ((DotvvmControl)content.Parent!).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)); } foreach (var control in auxControls) From 13bb747dedf91e26500db11ef94a08ff141dfe75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Herceg?= Date: Fri, 12 Jun 2026 15:42:27 +0200 Subject: [PATCH 12/17] Fixed serialized config --- ...figurationSerializationTests.SerializeDefaultConfig.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Tests/Runtime/config-tests/ConfigurationSerializationTests.SerializeDefaultConfig.json b/src/Tests/Runtime/config-tests/ConfigurationSerializationTests.SerializeDefaultConfig.json index 5d893cd5ce..d135300721 100644 --- a/src/Tests/Runtime/config-tests/ConfigurationSerializationTests.SerializeDefaultConfig.json +++ b/src/Tests/Runtime/config-tests/ConfigurationSerializationTests.SerializeDefaultConfig.json @@ -1195,6 +1195,9 @@ "PathFragment": { "type": "System.String" }, + "PendingMasterPageCompositions": { + "type": "System.Collections.Generic.List`1[[DotVVM.Framework.Controls.PendingMasterPageComposition, DotVVM.Framework, Version=***, Culture=neutral, PublicKeyToken=23f3607db32275da]]" + }, "ReferencedViewModuleInfo": { "type": "DotVVM.Framework.Binding.ViewModuleReferenceInfo, DotVVM.Framework" }, @@ -1202,6 +1205,9 @@ "type": "DotVVM.Framework.Hosting.IDotvvmRequestContext, DotVVM.Framework", "isValueInherited": true }, + "ResolvedMasterPageCompositionIds": { + "type": "System.Collections.Generic.HashSet`1[[System.String, CoreLibrary]]" + }, "UniqueID": { "type": "System.String" }, From 69784f858bccdab6315a3328da53417effa5a11a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Herceg?= Date: Fri, 12 Jun 2026 15:59:27 +0200 Subject: [PATCH 13/17] Another serialization config test fix --- src/Tests/Runtime/ConfigurationSerializationTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Tests/Runtime/ConfigurationSerializationTests.cs b/src/Tests/Runtime/ConfigurationSerializationTests.cs index 8433a2ea33..9408e8248f 100644 --- a/src/Tests/Runtime/ConfigurationSerializationTests.cs +++ b/src/Tests/Runtime/ConfigurationSerializationTests.cs @@ -39,6 +39,7 @@ void checkConfig(DotvvmConfiguration config, bool includeProperties = false, str serialized = serialized.Replace("System.Private.CoreLib, Version=***, Culture=neutral, PublicKeyToken=7cec85d7bea7798e", "CoreLibrary"); serialized = serialized.Replace("mscorlib", "CoreLibrary"); serialized = serialized.Replace("System.Private.CoreLib", "CoreLibrary"); + serialized = serialized.Replace(", System.Core", ""); // Special case - unify IServiceProvider serialized = serialized.Replace("System.IServiceProvider, CoreLibrary", "System.IServiceProvider, ComponentLibrary"); serialized = serialized.Replace("System.IServiceProvider, System.ComponentModel, Version=***, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a", "System.IServiceProvider, ComponentLibrary"); From 6ec33fecd36c6d65fd58e966291006ec3bb94fbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Herceg?= Date: Sat, 13 Jun 2026 09:42:35 +0200 Subject: [PATCH 14/17] Fix of nested master pages - unfinished --- .../Framework/Controls/ContentPlaceHolder.cs | 48 +++++++------------ src/Framework/Framework/Controls/Internal.cs | 26 ++++------ .../Framework/Hosting/DotvvmPresenter.cs | 22 +++++---- .../Runtime/DefaultDotvvmViewBuilder.cs | 24 +++++----- .../RepeaterMaster.dotmaster | 2 +- .../RepeaterMultipleItems.dothtml | 2 +- .../RepeaterOneItem.dothtml | 2 +- .../RepeaterZeroItems.dothtml | 2 +- 8 files changed, 57 insertions(+), 71 deletions(-) diff --git a/src/Framework/Framework/Controls/ContentPlaceHolder.cs b/src/Framework/Framework/Controls/ContentPlaceHolder.cs index 7228acb823..80a8ccf521 100644 --- a/src/Framework/Framework/Controls/ContentPlaceHolder.cs +++ b/src/Framework/Framework/Controls/ContentPlaceHolder.cs @@ -22,36 +22,27 @@ public ContentPlaceHolder() protected internal override void OnInit(IDotvvmRequestContext context) { - // Check if there are any pending Content controls waiting for this ContentPlaceHolder. - // This handles the case where ContentPlaceHolder is inside a CompositeControl template - // and is instantiated in the Load phase (after the initial master page composition). ResolvePendingComposition(); base.OnInit(context); } - /// - /// Looks for a pending master page composition matching this ContentPlaceHolder's ID - /// and performs the composition if found. Throws if the same ContentPlaceHolder ID - /// is being resolved for a second time (e.g. ContentPlaceHolder inside a Repeater template). - /// internal void ResolvePendingComposition() { if (ID == null) return; - // Traverse ancestors to find the pending compositions list stored on the root page - var rootPage = this.GetAllAncestors() - .FirstOrDefault(ancestor => ancestor.GetValue(Internal.PendingMasterPageCompositionsProperty) != null); - - if (rootPage == null) return; + // find the nearest master page and pending compositions + var childPage = (DotvvmControl)GetValue(Internal.MasterPageChildPageProperty)!; + var pendingList = (List)childPage.GetValue(Internal.PendingMasterPageCompositionsProperty)!; + if (pendingList.Count == 0) + { + return; + } - var pendingList = (List?)rootPage.GetValue(Internal.PendingMasterPageCompositionsProperty); - if (pendingList == null) return; + var masterPage = pendingList[0].MasterPage; - // Check for duplicate: if this ID was already resolved via deferred composition, a second - // instantiation (e.g. ContentPlaceHolder inside a Repeater) would silently render with the - // wrong content. Throw instead to surface the problem early. - var resolvedIds = (HashSet?)rootPage.GetValue(Internal.ResolvedMasterPageCompositionIdsProperty); + // 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?)masterPage.GetValue(Internal.ResolvedMasterPageCompositionIdsProperty); if (resolvedIds != null && resolvedIds.Contains(ID)) { throw new DotvvmControlException(this, @@ -59,26 +50,21 @@ internal void ResolvePendingComposition() $"ContentPlaceHolder controls used for master page composition cannot be placed inside templates that are instantiated multiple times (e.g. Repeater, foreach)."); } - // When the same ID is used at multiple master page levels, the pending list contains - // multiple entries with the same ID. Items are added from innermost to outermost (because - // BuildView processes master pages from inner to outer). We must match the LAST entry - // so that the outermost ContentPlaceHolder gets the outermost Content, and inner - // ContentPlaceHolders (nested inside that content) get the inner Content entries. - var pendingIndex = pendingList.FindLastIndex(p => p.Content.ContentPlaceHolderID == ID); - if (pendingIndex >= 0) + // find the pending composition + var pending = pendingList.SingleOrDefault(p => p.Content.ContentPlaceHolderID == ID); + if (pending != null) { - var pending = pendingList[pendingIndex]; - pendingList.RemoveAt(pendingIndex); + // remove it from the master page and from the root + pendingList.Remove(pending); - // Track that this ID has been resolved so a second instantiation can be detected. if (resolvedIds == null) { resolvedIds = new HashSet(StringComparer.Ordinal); - rootPage.SetValue(Internal.ResolvedMasterPageCompositionIdsProperty, resolvedIds); + masterPage.SetValue(Internal.ResolvedMasterPageCompositionIdsProperty, resolvedIds); } resolvedIds.Add(ID); - // Perform the deferred composition: wrap Content in a PlaceHolder and add it as our child + // perform the deferred composition: wrap Content in a PlaceHolder and add it as our child var wrapper = new PlaceHolder(); wrapper.SetDataContextType(pending.DataContextType); diff --git a/src/Framework/Framework/Controls/Internal.cs b/src/Framework/Framework/Controls/Internal.cs index ad19241c1d..6b1cb7170e 100644 --- a/src/Framework/Framework/Controls/Internal.cs +++ b/src/Framework/Framework/Controls/Internal.cs @@ -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 @@ -70,19 +71,14 @@ public class Internal DotvvmProperty.Register(() => UsedPropertiesInfoProperty); /// - /// Stores a list of Content controls that have not yet been matched to their corresponding ContentPlaceHolder. - /// This is used to support ContentPlaceHolder controls inside CompositeControl templates (Load phase). + /// This property is set on the master page root element and points to the root element of the child page. /// + public static readonly DotvvmProperty MasterPageChildPageProperty = + DotvvmProperty.Register(() => MasterPageChildPageProperty, defaultValue: null, isValueInherited: true); public static readonly DotvvmProperty PendingMasterPageCompositionsProperty = - DotvvmProperty.Register?, Internal>(() => PendingMasterPageCompositionsProperty, defaultValue: null, isValueInherited: false); - - /// - /// Tracks ContentPlaceHolder IDs that have already been resolved via deferred master page composition. - /// Used to detect when a ContentPlaceHolder is instantiated more than once (e.g. inside a Repeater template), - /// which is not supported and would result in only the first instance being filled with Content. - /// + DotvvmProperty.Register?, Internal>(() => PendingMasterPageCompositionsProperty, defaultValue: null, isValueInherited: true); public static readonly DotvvmProperty ResolvedMasterPageCompositionIdsProperty = - DotvvmProperty.Register?, Internal>(() => ResolvedMasterPageCompositionIdsProperty, defaultValue: null, isValueInherited: false); + DotvvmProperty.Register?, Internal>(() => ResolvedMasterPageCompositionIdsProperty, defaultValue: null, isValueInherited: true); public static bool IsViewCompilerProperty(DotvvmProperty property) { @@ -127,18 +123,16 @@ public static TControl SetDataContextType(this TControl control, DataC /// internal sealed class PendingMasterPageComposition { - /// The Content control waiting to be placed in a ContentPlaceHolder. public readonly Content Content; - /// The DataContextStack of the Content's original parent (the child page). + public readonly DotvvmView MasterPage; public readonly DataContextStack? DataContextType; - /// The master page file name, used for error messages. - public readonly string? MasterPageFile; + public string? MasterPageFile => MasterPage.GetValue(Internal.MarkupFileNameProperty)?.ToString(); - public PendingMasterPageComposition(Content content, DataContextStack? dataContextType, string? masterPageFile) + public PendingMasterPageComposition(Content content, DotvvmView masterPage, DataContextStack? dataContextType) { Content = content; + MasterPage = masterPage; DataContextType = dataContextType; - MasterPageFile = masterPageFile; } } } diff --git a/src/Framework/Framework/Hosting/DotvvmPresenter.cs b/src/Framework/Framework/Hosting/DotvvmPresenter.cs index 2b1924b93f..be2143a680 100644 --- a/src/Framework/Framework/Hosting/DotvvmPresenter.cs +++ b/src/Framework/Framework/Hosting/DotvvmPresenter.cs @@ -614,16 +614,22 @@ public static bool DeterminePartialRendering(IHttpContext context) => /// 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). /// - private static void ValidateMasterPageComposition(DotvvmView page) + private static void ValidateMasterPageComposition(DotvvmControl page) { - var pendingList = (List?)page.GetValue(Internal.PendingMasterPageCompositionsProperty); - if (pendingList is { Count: > 0 }) + var childPage = (DotvvmControl?)page.GetValue(Internal.MasterPageChildPageProperty); + while (childPage != null) { - 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)."); + var pendingList = (List)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); } } } diff --git a/src/Framework/Framework/Runtime/DefaultDotvvmViewBuilder.cs b/src/Framework/Framework/Runtime/DefaultDotvvmViewBuilder.cs index e359e9c8e8..99a987af76 100644 --- a/src/Framework/Framework/Runtime/DefaultDotvvmViewBuilder.cs +++ b/src/Framework/Framework/Runtime/DefaultDotvvmViewBuilder.cs @@ -44,10 +44,6 @@ public DotvvmView BuildView(IDotvvmRequestContext context) FillsDefaultDirectives(contentPage); - // shared list for Content controls that couldn't be matched during static composition - // (e.g. because their ContentPlaceHolder is inside a CompositeControl template) - var pendingCompositions = new List(); - // check for master page and perform composition recursively while (pageDescriptor.MasterPage is object) { @@ -56,16 +52,12 @@ public DotvvmView BuildView(IDotvvmRequestContext context) var masterPage = (DotvvmView)pageBuilder.Value.BuildControl(controlBuilderFactory, context.Services); FillsDefaultDirectives(masterPage); - PerformMasterPageComposition(contentPage, masterPage, pageDescriptor, pendingCompositions); + PerformMasterPageComposition(contentPage, masterPage, pageDescriptor); masterPage.ViewModelType = contentPage.ViewModelType; contentPage = masterPage; } - // Store the pending compositions on the final page so ContentPlaceHolder controls - // can find them during their OnInit (which runs in Load phase for template-instantiated controls) - contentPage.SetValue(Internal.PendingMasterPageCompositionsProperty, pendingCompositions); - // verifies the SPA request VerifySpaRequest(context, contentPage); @@ -108,23 +100,31 @@ private void FillsDefaultDirectives(DotvvmView page) /// /// Performs the master page nesting. /// - private void PerformMasterPageComposition(DotvvmView childPage, DotvvmView masterPage, ControlBuilderDescriptor masterPageDescriptor, List pendingCompositions) + 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); + // set the reference to the child page + masterPage.SetValue(Internal.MasterPageChildPageProperty, childPage); + + // prepare the pending compositions list + var pendingCompositions = new List(); + childPage.SetValue(Internal.PendingMasterPageCompositionsProperty, pendingCompositions); + // perform the composition foreach (var content in contents) { 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.PendingMasterPageCompositionsProperty, pendingCompositions); // find the corresponding placeholder var placeHolder = placeHolders.SingleOrDefault(p => p.ID == content.ContentPlaceHolderID); @@ -140,7 +140,7 @@ private void PerformMasterPageComposition(DotvvmView childPage, DotvvmView maste var dataContextType = content.Parent!.GetDataContextType(); ((DotvvmControl)content.Parent!).Children.Remove(content); - pendingCompositions.Add(new PendingMasterPageComposition(content, dataContextType, masterPage.GetValue(Internal.MarkupFileNameProperty)?.ToString())); + pendingCompositions.Add(new PendingMasterPageComposition(content, masterPage, dataContextType)); continue; } diff --git a/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/RepeaterMaster.dotmaster b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/RepeaterMaster.dotmaster index 5b755206d4..77403a0ead 100644 --- a/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/RepeaterMaster.dotmaster +++ b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/RepeaterMaster.dotmaster @@ -10,7 +10,7 @@

Repeater Master Page

- + diff --git a/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/RepeaterMultipleItems.dothtml b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/RepeaterMultipleItems.dothtml index bcd373a2b5..ace24a3580 100644 --- a/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/RepeaterMultipleItems.dothtml +++ b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/RepeaterMultipleItems.dothtml @@ -2,5 +2,5 @@ @masterPage Views/FeatureSamples/LateContentPlaceHolders/RepeaterMaster.dotmaster -

{{value: _this}}

+

{{value: Items.Count}}

diff --git a/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/RepeaterOneItem.dothtml b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/RepeaterOneItem.dothtml index 16f54c5860..6366bbe407 100644 --- a/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/RepeaterOneItem.dothtml +++ b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/RepeaterOneItem.dothtml @@ -2,5 +2,5 @@ @masterPage Views/FeatureSamples/LateContentPlaceHolders/RepeaterMaster.dotmaster -

{{value: _this}}

+

{{value: Items[0]}}

diff --git a/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/RepeaterZeroItems.dothtml b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/RepeaterZeroItems.dothtml index 49b259208f..57da701002 100644 --- a/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/RepeaterZeroItems.dothtml +++ b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/RepeaterZeroItems.dothtml @@ -2,5 +2,5 @@ @masterPage Views/FeatureSamples/LateContentPlaceHolders/RepeaterMaster.dotmaster -

{{value: _this}}

+

{{value: Items.Count}}

From 2041b13d3092aa827afa21bd918f5d3352c23aed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Herceg?= Date: Sat, 13 Jun 2026 10:39:39 +0200 Subject: [PATCH 15/17] Final bug fixes & explanatory comments --- .../Framework/Controls/ContentPlaceHolder.cs | 35 ++++-------- src/Framework/Framework/Controls/Internal.cs | 13 +---- .../Framework/Hosting/DotvvmPresenter.cs | 2 +- .../Runtime/DefaultDotvvmViewBuilder.cs | 53 ++++++++++++------- .../LateContentPlaceHolderViewModel.cs | 2 +- .../RepeaterMaster.dotmaster | 14 ++--- .../Feature/LateContentPlaceHoldersTests.cs | 2 +- 7 files changed, 53 insertions(+), 68 deletions(-) diff --git a/src/Framework/Framework/Controls/ContentPlaceHolder.cs b/src/Framework/Framework/Controls/ContentPlaceHolder.cs index 80a8ccf521..ec6b5c57dd 100644 --- a/src/Framework/Framework/Controls/ContentPlaceHolder.cs +++ b/src/Framework/Framework/Controls/ContentPlaceHolder.cs @@ -6,6 +6,7 @@ using DotVVM.Framework.Binding; using DotVVM.Framework.Configuration; using DotVVM.Framework.Hosting; +using DotVVM.Framework.Runtime; namespace DotVVM.Framework.Controls { @@ -31,19 +32,13 @@ internal void ResolvePendingComposition() { if (ID == null) return; - // find the nearest master page and pending compositions - var childPage = (DotvvmControl)GetValue(Internal.MasterPageChildPageProperty)!; - var pendingList = (List)childPage.GetValue(Internal.PendingMasterPageCompositionsProperty)!; - if (pendingList.Count == 0) - { - return; - } - - var masterPage = pendingList[0].MasterPage; - + // find the nearest master page + var masterPage = GetAllAncestors() + .First(a => a.IsPropertySet(Internal.PendingMasterPageCompositionsProperty, inherit: false)); + // 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?)masterPage.GetValue(Internal.ResolvedMasterPageCompositionIdsProperty); - if (resolvedIds != null && resolvedIds.Contains(ID)) + var resolvedIds = (HashSet)masterPage.GetValue(Internal.ResolvedMasterPageCompositionIdsProperty)!; + if (resolvedIds.Contains(ID)) { throw new DotvvmControlException(this, $"The ContentPlaceHolder with ID '{ID}' has already been resolved. " + @@ -51,28 +46,16 @@ internal void ResolvePendingComposition() } // find the pending composition + var pendingList = (List)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); - - if (resolvedIds == null) - { - resolvedIds = new HashSet(StringComparer.Ordinal); - masterPage.SetValue(Internal.ResolvedMasterPageCompositionIdsProperty, resolvedIds); - } resolvedIds.Add(ID); // perform the deferred composition: wrap Content in a PlaceHolder and add it as our child - var wrapper = new PlaceHolder(); - wrapper.SetDataContextType(pending.DataContextType); - - this.Children.Clear(); - this.Children.Add(wrapper); - - wrapper.Children.Add(pending.Content); - pending.Content.SetValue(Internal.IsMasterPageCompositionFinishedProperty, true); + DefaultDotvvmViewBuilder.PlaceContentInContentPlaceHolder(pending.DataContextType, this, pending.Content); } } diff --git a/src/Framework/Framework/Controls/Internal.cs b/src/Framework/Framework/Controls/Internal.cs index 6b1cb7170e..df5f5281ba 100644 --- a/src/Framework/Framework/Controls/Internal.cs +++ b/src/Framework/Framework/Controls/Internal.cs @@ -120,19 +120,10 @@ public static TControl SetDataContextType(this TControl control, DataC /// /// 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 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. /// - internal sealed class PendingMasterPageComposition + internal sealed record PendingMasterPageComposition(Content Content, DotvvmView ContentPage, DotvvmView MasterPage, DataContextStack? DataContextType) { - public readonly Content Content; - public readonly DotvvmView MasterPage; - public readonly DataContextStack? DataContextType; public string? MasterPageFile => MasterPage.GetValue(Internal.MarkupFileNameProperty)?.ToString(); - - public PendingMasterPageComposition(Content content, DotvvmView masterPage, DataContextStack? dataContextType) - { - Content = content; - MasterPage = masterPage; - DataContextType = dataContextType; - } } } diff --git a/src/Framework/Framework/Hosting/DotvvmPresenter.cs b/src/Framework/Framework/Hosting/DotvvmPresenter.cs index be2143a680..172002ec82 100644 --- a/src/Framework/Framework/Hosting/DotvvmPresenter.cs +++ b/src/Framework/Framework/Hosting/DotvvmPresenter.cs @@ -616,7 +616,7 @@ public static bool DeterminePartialRendering(IHttpContext context) => /// private static void ValidateMasterPageComposition(DotvvmControl page) { - var childPage = (DotvvmControl?)page.GetValue(Internal.MasterPageChildPageProperty); + var childPage = (DotvvmControl?)page; while (childPage != null) { var pendingList = (List)childPage.GetValue(Internal.PendingMasterPageCompositionsProperty)!; diff --git a/src/Framework/Framework/Runtime/DefaultDotvvmViewBuilder.cs b/src/Framework/Framework/Runtime/DefaultDotvvmViewBuilder.cs index 99a987af76..960bc3704e 100644 --- a/src/Framework/Framework/Runtime/DefaultDotvvmViewBuilder.cs +++ b/src/Framework/Framework/Runtime/DefaultDotvvmViewBuilder.cs @@ -7,6 +7,7 @@ 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; @@ -110,23 +111,34 @@ private void PerformMasterPageComposition(DotvvmView childPage, DotvvmView maste // find contents var (contents, auxControls) = GetChildPageContents(childPage, placeHolders); - - // set the reference to the child page - masterPage.SetValue(Internal.MasterPageChildPageProperty, childPage); - + // 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(); - childPage.SetValue(Internal.PendingMasterPageCompositionsProperty, pendingCompositions); + masterPage.SetValue(Internal.PendingMasterPageCompositionsProperty, pendingCompositions); + masterPage.SetValue(Internal.ResolvedMasterPageCompositionIdsProperty, new HashSet(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.PendingMasterPageCompositionsProperty, pendingCompositions); + 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) { @@ -138,22 +150,12 @@ private void PerformMasterPageComposition(DotvvmView childPage, DotvvmView maste $"Make sure that each Content element has a corresponding ContentPlaceHolder in the master page."); } - var dataContextType = content.Parent!.GetDataContextType(); - ((DotvvmControl)content.Parent!).Children.Remove(content); - pendingCompositions.Add(new PendingMasterPageComposition(content, masterPage, dataContextType)); + pendingCompositions.Add(new PendingMasterPageComposition(content, childPage, masterPage, dataContextType)); continue; } // replace the contents - var contentPlaceHolder = new PlaceHolder(); - contentPlaceHolder.SetDataContextType(content.Parent!.GetDataContextType()); - ((DotvvmControl)content.Parent!).Children.Remove(content); - - placeHolder.Children.Clear(); - placeHolder.Children.Add(contentPlaceHolder); - - contentPlaceHolder.Children.Add(content); - content.SetValue(Internal.IsMasterPageCompositionFinishedProperty, true); + PlaceContentInContentPlaceHolder(dataContextType, placeHolder, content); } foreach (var control in auxControls) @@ -163,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); + } + /// /// Gets the content place holders. /// diff --git a/src/Samples/Common/ViewModels/FeatureSamples/LateContentPlaceHolders/LateContentPlaceHolderViewModel.cs b/src/Samples/Common/ViewModels/FeatureSamples/LateContentPlaceHolders/LateContentPlaceHolderViewModel.cs index 7665fa6899..27385c76e6 100644 --- a/src/Samples/Common/ViewModels/FeatureSamples/LateContentPlaceHolders/LateContentPlaceHolderViewModel.cs +++ b/src/Samples/Common/ViewModels/FeatureSamples/LateContentPlaceHolders/LateContentPlaceHolderViewModel.cs @@ -8,7 +8,7 @@ public class LateContentPlaceHolderViewModel : DotvvmViewModelBase } /// Viewmodel for Repeater tests - items list is set per subclass. - public abstract class RepeaterContentPlaceHolderViewModel : DotvvmViewModelBase + public abstract class RepeaterContentPlaceHolderViewModel : LateContentPlaceHolderViewModel { public List Items { get; set; } = new List(); } diff --git a/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/RepeaterMaster.dotmaster b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/RepeaterMaster.dotmaster index 77403a0ead..b9d1a1663d 100644 --- a/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/RepeaterMaster.dotmaster +++ b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/RepeaterMaster.dotmaster @@ -1,17 +1,11 @@ @viewModel DotVVM.Samples.Common.ViewModels.FeatureSamples.LateContentPlaceHolders.RepeaterContentPlaceHolderViewModel, DotVVM.Samples.Common - +@masterPage Views/FeatureSamples/LateContentPlaceHolders/Root.dotmaster - - - - Late ContentPlaceHolder - Repeater Master - - -

Repeater Master Page

+ +

Repeater Master Page

- - +
diff --git a/src/Samples/Tests/Tests/Feature/LateContentPlaceHoldersTests.cs b/src/Samples/Tests/Tests/Feature/LateContentPlaceHoldersTests.cs index 48ad3a92b9..4fe3124575 100644 --- a/src/Samples/Tests/Tests/Feature/LateContentPlaceHoldersTests.cs +++ b/src/Samples/Tests/Tests/Feature/LateContentPlaceHoldersTests.cs @@ -34,7 +34,7 @@ public void Feature_LateContentPlaceHolders_SameContentPlaceHolderIdInRootAndNes var pageContent = browser.First("[data-ui='shared-id-page-content']"); AssertUI.InnerTextEquals(pageContent, "Shared ID Page Content"); // Default content from the shared ID placeholder should NOT be shown - AssertUI.IsNotDisplayed(browser.Single("[data-ui='default-shared-content']")); + browser.FindElements("[data-ui='default-shared-content']").ThrowIfDifferentCountThan(0); }); } From f8ce33057c6757688515b524070e039f10502653 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Herceg?= Date: Sat, 13 Jun 2026 10:41:57 +0200 Subject: [PATCH 16/17] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Standa Lukeš --- .../Compilation/ControlTree/Resolved/ResolvedTreeRoot.cs | 4 +--- .../Compilation/ViewCompiler/ControlBuilderDescriptor.cs | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/Framework/Framework/Compilation/ControlTree/Resolved/ResolvedTreeRoot.cs b/src/Framework/Framework/Compilation/ControlTree/Resolved/ResolvedTreeRoot.cs index f0bc968701..1255fb66b1 100644 --- a/src/Framework/Framework/Compilation/ControlTree/Resolved/ResolvedTreeRoot.cs +++ b/src/Framework/Framework/Compilation/ControlTree/Resolved/ResolvedTreeRoot.cs @@ -50,9 +50,7 @@ private ImmutableArray CollectContentPlaceHolderIds() { var collector = new ContentPlaceHolderIdCollector(); this.AcceptChildren(collector); - return collector.Ids.Count == 0 - ? ImmutableArray.Empty - : collector.Ids.ToImmutableArray(); + return collector.Ids.ToImmutableArray(); } private sealed class ContentPlaceHolderIdCollector : ResolvedControlTreeVisitor diff --git a/src/Framework/Framework/Compilation/ViewCompiler/ControlBuilderDescriptor.cs b/src/Framework/Framework/Compilation/ViewCompiler/ControlBuilderDescriptor.cs index ea3f84847c..47b0427ee1 100644 --- a/src/Framework/Framework/Compilation/ViewCompiler/ControlBuilderDescriptor.cs +++ b/src/Framework/Framework/Compilation/ViewCompiler/ControlBuilderDescriptor.cs @@ -46,7 +46,7 @@ public ControlBuilderDescriptor( ControlBuilderDescriptor? masterPage, ImmutableArray<(string name, string value)> directives, ViewModuleReferenceInfo? viewModuleReference, - ImmutableArray contentPlaceHolderIds = default + ImmutableArray contentPlaceHolderIds ) { this.DataContextType = dataContextType; @@ -55,7 +55,7 @@ public ControlBuilderDescriptor( this.MasterPage = masterPage; this.Directives = directives; this.ViewModuleReference = viewModuleReference; - this.ContentPlaceHolderIds = contentPlaceHolderIds.IsDefault ? ImmutableArray.Empty : contentPlaceHolderIds; + this.ContentPlaceHolderIds = contentPlaceHolderIds; } } } From ceeac7a463ff3d637d4f8c6ef029436631bff3bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Herceg?= Date: Sat, 13 Jun 2026 11:30:15 +0200 Subject: [PATCH 17/17] Fixed tests --- .../Framework/Controls/ContentPlaceHolder.cs | 6 +++++- .../RouteLink/RouteLinkSpaUrlGen.dothtml | 20 +++---------------- .../RouteLink/RouteLinkSpaUrlGen.dotmaster | 17 ++++++++++++++++ ...alizationTests.SerializeDefaultConfig.json | 10 ++++++++-- 4 files changed, 33 insertions(+), 20 deletions(-) create mode 100644 src/Samples/Common/Views/ControlSamples/RouteLink/RouteLinkSpaUrlGen.dotmaster diff --git a/src/Framework/Framework/Controls/ContentPlaceHolder.cs b/src/Framework/Framework/Controls/ContentPlaceHolder.cs index ec6b5c57dd..d816825cc7 100644 --- a/src/Framework/Framework/Controls/ContentPlaceHolder.cs +++ b/src/Framework/Framework/Controls/ContentPlaceHolder.cs @@ -34,7 +34,11 @@ internal void ResolvePendingComposition() // find the nearest master page var masterPage = GetAllAncestors() - .First(a => a.IsPropertySet(Internal.PendingMasterPageCompositionsProperty, inherit: false)); + .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)masterPage.GetValue(Internal.ResolvedMasterPageCompositionIdsProperty)!; diff --git a/src/Samples/Common/Views/ControlSamples/RouteLink/RouteLinkSpaUrlGen.dothtml b/src/Samples/Common/Views/ControlSamples/RouteLink/RouteLinkSpaUrlGen.dothtml index 43f6751433..44b16e8661 100644 --- a/src/Samples/Common/Views/ControlSamples/RouteLink/RouteLinkSpaUrlGen.dothtml +++ b/src/Samples/Common/Views/ControlSamples/RouteLink/RouteLinkSpaUrlGen.dothtml @@ -1,17 +1,6 @@ @viewModel DotVVM.Samples.BasicSamples.ViewModels.ControlSamples.RouteLink.RouteLinkUrlGenViewModel - - - - - Hello from DotVVM! - - - - +@masterPage Views/ControlSamples/RouteLink/RouteLinkSpaUrlGen.dotmaster +

RouteLink SPA url generation demo

@@ -61,7 +50,4 @@ Text="Server rendered: Optional prefixed parameter (at start)" data-ui="optional-prefix-parameter-at-start-server" />

- - - - +
diff --git a/src/Samples/Common/Views/ControlSamples/RouteLink/RouteLinkSpaUrlGen.dotmaster b/src/Samples/Common/Views/ControlSamples/RouteLink/RouteLinkSpaUrlGen.dotmaster new file mode 100644 index 0000000000..a43d5d5599 --- /dev/null +++ b/src/Samples/Common/Views/ControlSamples/RouteLink/RouteLinkSpaUrlGen.dotmaster @@ -0,0 +1,17 @@ +@viewModel DotVVM.Samples.BasicSamples.ViewModels.ControlSamples.RouteLink.RouteLinkUrlGenViewModel + + + + + Hello from DotVVM! + + + + + + + diff --git a/src/Tests/Runtime/config-tests/ConfigurationSerializationTests.SerializeDefaultConfig.json b/src/Tests/Runtime/config-tests/ConfigurationSerializationTests.SerializeDefaultConfig.json index d135300721..8463a4f3f9 100644 --- a/src/Tests/Runtime/config-tests/ConfigurationSerializationTests.SerializeDefaultConfig.json +++ b/src/Tests/Runtime/config-tests/ConfigurationSerializationTests.SerializeDefaultConfig.json @@ -1192,11 +1192,16 @@ "type": "System.Int32", "defaultValue": -1 }, + "MasterPageChildPage": { + "type": "DotVVM.Framework.Controls.DotvvmControl, DotVVM.Framework", + "isValueInherited": true + }, "PathFragment": { "type": "System.String" }, "PendingMasterPageCompositions": { - "type": "System.Collections.Generic.List`1[[DotVVM.Framework.Controls.PendingMasterPageComposition, DotVVM.Framework, Version=***, Culture=neutral, PublicKeyToken=23f3607db32275da]]" + "type": "System.Collections.Generic.List`1[[DotVVM.Framework.Controls.PendingMasterPageComposition, DotVVM.Framework, Version=***, Culture=neutral, PublicKeyToken=23f3607db32275da]]", + "isValueInherited": true }, "ReferencedViewModuleInfo": { "type": "DotVVM.Framework.Binding.ViewModuleReferenceInfo, DotVVM.Framework" @@ -1206,7 +1211,8 @@ "isValueInherited": true }, "ResolvedMasterPageCompositionIds": { - "type": "System.Collections.Generic.HashSet`1[[System.String, CoreLibrary]]" + "type": "System.Collections.Generic.HashSet`1[[System.String, CoreLibrary]]", + "isValueInherited": true }, "UniqueID": { "type": "System.String"