We recently decided to add support for MasterPages to the NetQuarry Enterprise Application Platform. The platform provides a set of .aspx “templates†that are used to host content in various configurations. The standard templates include Wizard, Console, and several flavors of Subform templates as well as support for custom templates. The basic idea is to support hosting templates in .master pages. Note that NetQuarry is currently using .Net 2.0.
Everything started out well enough. I created a master page called minimal.master and added it to my project. The .master file was very simple:
<%@ Master Language=”C#” AutoEventWireup=”true” CodeBehind=”minimal.master.cs” Inherits=”NetQuarry.minimal” %>
<%@ Register TagPrefix=”eap” Namespace=”NetQuarry.WebControls” Assembly=”EAP.WebControls” %>
<!DOCTYPE HTML PUBLIC “-//W3C//DTD HTML 4.0 Transitional//EN” >
<html xmlns=”http://www.w3.org/1999/xhtml” >
<head runat=”server”>
<title runat=”server” id=”pageTitle”>Untitled Page</title>
<asp:ContentPlaceHolder id=”header” runat=”server”>
</asp:ContentPlaceHolder>
</head>
<body runat=”server” id=”body” class=”background”>
<esp:ContentPlaceHolder id=”content” runat=”server”/>
</body>
</html>
I made a few quick modifications to my templates wrapping my original header and body contents in appropriate asp:Content controls:
<asp:Content ID=”header” ContentPlaceHolderID=”header” runat=”Server”>
</asp:Content>
<asp:Content ID=”content” ContentPlaceHolderID=”content” runat=”Server”>
</asp:Content>
A bit more tweaking, the basics were there, but I still had problems. The big problem was that the NetQuarry platform is used to create web applications, not web sites. Our applications include a fair amount of javascript and AJAX and using master pages had broken lots of script. It didn’t take long for me to figure out that the main problem was that all my client-side elements now had mangled names. For example, instead of a textbox being named something like “first_nameâ€, it was being rendered into the HTML as “ctl00_content_first_nameâ€. This mangling broke our script, and badly.
The cause of the mangling is the .Net NamingContainer idea. NamingContainers allow a page to host multiple instances of an object by automatically generating unique client-side names that represent the control hierarchy on the server. In this case, the MasterPage class itself was a NamingContainer as well as the ContentPlaceHolder control. Since I hadn’t provided an ID for my MasterPage .Net named it ctl00. I had named the ContentPlaceHolder for the main page content “contentâ€. So, since my existing page content was all contained in the “content†ContentPlaceHolder and it was in turn contained in the “ctl00†MasterPage, .Net composed a unique client-side name for the any control in the “content†prefixed with “ctl00_content_†and, voila, broken script.
Now NamingContainers are a great feature of .Net and they allow a lot of things to work seamlessly. However, in this case I didn’t want any stinking NamingContainers. Unfortunately, there’s no way to turn off the NamingContainer feature of any control marked as a NamingContainer (a control is marked as a NamingContainer by deriving from the INamingContainer marker interface). Hint to Microsoft, this would be a good feature.
I actually spent about two days fighting with this and scouring the internet to see if anyone else had come up with a solution. I did find several partial solutions that helped me move in the right direction. First there was a good article about MasterPages in general on www.OdeToCode.com – ASP.Net 2.0 – Master Pages: Tips, Tricks, and Traps. A bit more digging and I found a partial solution on Rick Strahl’s Web Log – Overriding ClientID and UniqueID on ASP.NET controls. I’ve found Rick’s blog to be very helpful in the past. And, that article pointed me to one other helpful blog on glJakal blog – Getting rid of the Naming Container in asp.net 2.0.
Together these articles showed how to defeat the NamingContainer for client-side naming. The full code of the final solution is below, but defeating the NamingContainer for client-side id mangling involved overriding the ClientID, UniqueID, and ID properties on the MasterPage and ContentPlaceHolder classes to return null, and the NamingContainer property on the ContentPlaceHolder class to return null. With that my pages rendered well and my javascript worked!
Alas, what didn’t work was any postbacks. The problem was that the id’s of the client-side controls no longer matched the control hierarchy on the server! It didn’t take me long to figure out that what I needed to do was figure out a way to make the server-side control hierarchy match my client-side naming. Essentially this meant making the server-side hierarchy look like my pre-MasterPage hierarchy, as far as NamingContainers go that is.
After a day of failed attempts, I was able to work out how to accomplish this. The secret was to rearrange the control tree during the page PreInit event. The PreInit event is available only to Page objects and is the only time that you can rearrange controls in the way I needed to. Eventually I figured out that two things needed to be done: 1) move the controls out of the MasterPage and ContentPlaceHolder controls so that .Net can match up the server-side controls with the unmangled client-side id’s, and 2) leave the controls in the MasterPage and ContentPlaceHolder controls for rendering purposes so that the contents are rendered correctly with respect to other elements in the MasterPage.
May 20, 2009 update: Thanks to Ritchie Annand for discovering a problem in the original code when run with .Net tracing enabled (via the <trace> tag) and pointing the way to a fix. The code now includes a fix to ensure that all hosted controls get an ID which is required when tracing (see the call to AssignIDs() in TemplateMaster::OnPreRender()). Note that there is still a glitch that affects our platform under certain circumstances when .Net tracing is enabled and pageOutput is turned on.
To use the code below you need to do just a few things:
- Include the code below in your project,
- Derive your master page from TemplateMaster instead of MasterPage,
- Use the <eap:ContentPlaceHolder> tag in place of <asp:ContentPlaceHolder>,
- In your master page .designer file, use the new ContentPlaceHolder instead of the .Net ContentPlaceHolder, and
- In your page’s PreInit event, call the TemplateMaster.Rehost() method, something like this:
protected override void OnPreInit(EventArgs e) { //--- Rehost controls from MasterPage/Content/ContentPlaceHolder hierarchy onto the //--- Page itself. This has the effect of cancelling the NamingContainer effect of //--- these controls on the server. if (this.Master is TemplateMaster) { (this.Master as TemplateMaster).Rehost(); } base.OnPreInit(e); }
And finally, here’s the code:
//--------------------------------------------------------------------------------------- // Copyright (c) 2005-2009 by NetQuarry, Inc. // All rights reserved. // // Licensed under the Eiffel Forum License, version 2: // // 1. Permission is hereby granted to use, copy, modify and/or // distribute this package, provided that: // * copyright notices are retained unchanged, // * any distribution of this package, whether modified or not, // includes this license text. // 2. Permission is hereby also granted to distribute binary programs // which depend on this package. If the binary program depends on a // modified version of this package, you are encouraged to publicly // release the modified version of this package. // // THIS PACKAGE IS PROVIDED "AS IS" AND WITHOUT WARRANTY. ANY EXPRESS OR // IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED // WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE // DISCLAIMED. IN NO EVENT SHALL THE AUTHORS BE LIABLE TO ANY PARTY FOR ANY // DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL // DAMAGES ARISING IN ANY WAY OUT OF THE USE OF THIS PACKAGE. // // www.netquarry.com //--------------------------------------------------------------------------------------- using System; using System.Web; using System.Web.UI; using System.Web.UI.WebControls; using System.Web.UI.HtmlControls; using System.Text; using System.Collections.Generic; [assembly: TagPrefix("NetQuarry.WebControls", "eap")] namespace NetQuarry.WebControls { /// <summary> /// Base class that must be used for any master page hosting platform templates. /// </summary> public abstract class TemplateMaster : System.Web.UI.MasterPage { private class ContentContainer : Control { protected override void Render(HtmlTextWriter writer) { //base.Render(writer); // Ignore the normal render. } public void ExternalRender(HtmlTextWriter writer) { base.Render(writer); // Now render! } protected override void OnPreRender(EventArgs e) { base.OnPreRender(e); int nIDIndex = 0; AssignIDs(this, this.ID, ref nIDIndex); } /// <summary> /// In .Net TRACE enabled every control in the hierarchy MUST have and ID and our juggling rehosting /// controls messes up .Net's id assignment. Here we recursively search through the specified control /// and provide unique ID's to any controls so lacking. /// </summary> /// <param name="ctrl">The control whose hierarchy should be scanned and assigned.</param> /// <param name="baseName">The base name for any subcontrols.</param> /// <param name="idIndex">The ID index to use when assigning a unique ID.</param> private void AssignIDs(Control ctrl, string baseName, ref int idIndex) { if (string.IsNullOrEmpty(ctrl.ID)) { ctrl.ID = baseName + idIndex.ToString().PadLeft(3, '0'); idIndex++; } if (ctrl.Controls != null) { foreach (Control child in ctrl.Controls) { AssignIDs(child, baseName, ref idIndex); } } } } private class ContentRenderer : Control { ContentContainer _container = null; public ContentRenderer(ContentContainer container) { _container = container; } protected override void Render(HtmlTextWriter writer) { if (_container != null) _container.ExternalRender(writer); // Ask associated container to render. } } /// <summary> /// Defeat the effect of the .Net MasterPage being a NamingContainer with regard /// to client-side NAME and ID attributes rendering by always returning a null ClientID. /// </summary> public override string ClientID { get { return null; } } /// <summary> /// Defeat the effect of the .Net MasterPage being a NamingContainer with regard /// to client-side NAME and ID attributes rendering by always returning a null UniqueID. /// </summary> public override string UniqueID { get { return null; } } /// <summary> /// Defeat the effect of the .Net MasterPage being a NamingContainer with regard /// to client-side NAME and ID attributes rendering by always returning a null ID. /// </summary> public override string ID { get { return null; } set { base.ID = value; } } /// <summary> /// Rehost controls from MasterPage/Content/ContentPlaceHolder hierarchy onto the /// Page itself. This has the effect of cancelling the NamingContainer effect of /// these controls on the server. /// </summary> /// <param name="parent">The parent control whose children should be rehosted.</param> /// <param name="idIndex">The ID index to use when providing ID's to controls so lacking.</param> private void MoveControlsToPage(Control parent, ref int idIndex) { if (base.Page != null && this.Controls.Count > 0) { List<Control> lst = new List<Control>(this.Controls.Count); ContentContainer container = new ContentContainer(); // Container will host the controls w/out a NamingContainer. ContentRenderer renderer = new ContentRenderer(container); // Renderer will render contents of container. //--- In .Net TRACE enabled every control in the hierarchy MUST have //--- and ID and our juggling rehosting controls messes up .Net's id assignment. container.ID = "ctlcont" + idIndex.ToString().PadLeft(3, '0'); renderer.ID = "ctlrend" + idIndex.ToString().PadLeft(3, '0'); idIndex++; //--— Create collection of all controls to move. for (int ii = 0; ii < parent.Controls.Count; ii++) { lst.Add(parent.Controls[ii]); } //--— Determine index of this container. Control root = parent; while (root != null && root.Parent != null && !(root.Parent is Page)) { root = root.Parent; } int nIndex = (root == null) ? -1 : root.Parent.Controls.IndexOf(root); //--— Remove controls from original parent and add them to new container. for (int ii = 0; ii < lst.Count; ii++) { Control ctrl = lst[ii]; parent.Controls.Remove(ctrl); container.Controls.AddAt(ii, ctrl); } //--— Add new renderer to replace original controls and new container to the page itself. //—-- The container is used to extablish the control hierarchy and in this way collapsing //—-- the MasterPage so that it doesn’t act as a NamingContainer for the purposes of the //—-- control hierarchy. The container will ignore the control’s normal Render() method. //--— The renderer has no controls, just a reference to the container. When the renderer’s //--— Render() method is called it will have the container render in its place. parent.Controls.Add(renderer); // Render controls as if contained in master. Page.Controls.AddAt(nIndex + 1, container); // But host them flat on the page instead of nested in NamingContainers. } } /// <summary> /// Rehost controls from MasterPage/Content/ContentPlaceHolder hierarchy onto the /// Page itself. This has the effect of cancelling the NamingContainer effect of /// these controls on the server. /// </summary> /// <param name="parent">The tree of controls from which to rehost.</param> /// <param name="idIndex">The ID index to use when providing ID's to controls so lacking.</param> private void RehostMasterControls(Control parent, ref int idIndex) { //--— Find all ContentPlaceHolder controls and rehost the children to the page itself. foreach (Control ctrl in parent.Controls) { if (ctrl is NetQuarry.WebControls.ContentPlaceHolder) { MoveControlsToPage(ctrl, ref idIndex); // Rehost children to page. } else { RehostMasterControls(ctrl, ref idIndex); // Recurse. } } } /// <summary> /// Rehost controls from MasterPage/Content/ContentPlaceHolder hierarchy onto the /// Page itself. This has the effect of cancelling the NamingContainer effect of /// these controls on the server. /// </summary> public void Rehost() { int nIDIndex = 0; // Need to provide unique ID's for controls so lacking. RehostMasterControls(this.Page.Master, ref nIDIndex); } } /// <summary> /// Replacement control for System.Web.UI.WebControls.ContentPlaceHolder for use when it is desired /// to NOT have the MasterPage, Content, and ContentPlaceHolder controls behave as naming /// containers. There are two aspects to this: 1) the effect a NamingContainer has on the /// client-side control NAME and ID mangling, and 2) the control hierarchy on the server. /// </summary> public class ContentPlaceHolder : System.Web.UI.WebControls.ContentPlaceHolder { /// <summary> /// Defeat the effect of the .Net ContentPlaceHolder being a NamingContainer with regard /// to client-side NAME and ID attributes rendering by always returning a null NamingContainer. /// </summary> public override Control NamingContainer { get { return null; } } /// <summary> /// Defeat the effect of the .Net ContentPlaceHolder being a NamingContainer with regard /// to client-side NAME and ID attributes rendering by always returning a null ClientID. /// </summary> public override string ClientID { get { return null; } } /// <summary> /// Defeat the effect of the .Net ContentPlaceHolder being a NamingContainer with regard /// to client-side NAME and ID attributes rendering by always returning a null UniqueID. /// </summary> public override string UniqueID { get { return null; } } /// <summary> /// Defeat the effect of the .Net ContentPlaceHolder being a NamingContainer with regard /// to client-side NAME and ID attributes rendering by always returning a null ID. /// </summary> public override string ID { get { return null; } set { base.ID = value; } } } }
I’m having some difficulties getting this to work. More often than not, it pops up with a “Trace requires that controls have unique IDs.” error.
(Now, this may be a function of running in Debug mode; I’m not sure – there’s a lot of code in Reflector to go through!)
It seems to be the id-less controls that are at issue. Under the Page, everything is named uniquely. Once it gets to the ContentPlaceHolder, the automatic “ctlxx” IDs start getting reused again.
There’s one avenue I think I might pursue to fix this: the UniqueID code gives some clue as to how regular Pages seem to get by with no such issues.
get_UniqueID is coded like this:
public virtual string get_UniqueID()
{
if (this._cachedUniqueID == null)
{
Control namingContainer = this.NamingContainer;
if (namingContainer == null)
{
return this._id;
}
if (this._id == null)
{
this.GenerateAutomaticID();
}
if (this.Page == namingContainer)
{
this._cachedUniqueID = this._id;
}
else
{
string uniqueIDPrefix = namingContainer.GetUniqueIDPrefix();
if (uniqueIDPrefix.Length == 0)
{
return this._id;
}
this._cachedUniqueID = uniqueIDPrefix + this._id;
}
}
return this._cachedUniqueID;
}
The addition of the underscore happens in master pages, but note that if the naming container is somehow equal to the *Page*, that the ID is left alone, and it looks like it might continue its unique naming numbers, so the auto-generated ctlxx sequence continues just fine.
I’m going to see what happens if I make the ContentPlaceHolder return the Page instead of null, and I’ll report back on the results.
Are you using .Net 3.0? I haven’t seen this problem, but we’re still on 2.0. I realized when I implemented this scheme that it might be sensitive to .Net version. I’m looking forward to your results.
Actually, that’s a good point. I am on the VS2008/3.5 track, and so far, no luck. Regardless of what I do, the _occasionalFields.NamedControlsID in the Page gets reset to zero.
What seems to be blowing up the process is that there is a Control.BuildProfileTree method that gets called by ASP.NET, and it seems to be *extremely* sensitive to duplicate generated IDs, even if nothing else could possibly care. Page.Trace.AddNewControl is what freaks out.
Now this is totally trace-related, as far as I can tell; it might not be 3.5-specific. We have a line like this under our section in Web.Config:
I don’t know if you’re in any position to check whether 2.0 freaks out when tracing is turned on, but it might be informative.
I’m beginning to wonder whether I should maybe alter RehostMasterControls and MoveControlsToPage to somehow generate IDs for any ID-less rehosted controls themselves.
*laugh* Okay, it’s taking < and > as HTML, is it? 🙂
Substituting HTML entities, let’s see how this turns out…
…line like this under our <system.web> section in Web.Config:
<trace enabled=”true” pageOutput=”false” requestLimit=”50″ mostRecent=”true”/>
Okay, I managed to get things working by generating IDs. It turns out that either the container or renderer need to have IDs – I had added IDs to both of them for earlier testing, and when I tried removing them just now, the duplicate error came back.
My apologies for the amount of source code pasted, but these are the changes I made. I added a new recursive function “AssignIDs”, and modified MoveControlsToPage and Rehost() to suit:
private void AssignIDs(Control current, string baseName, ref int index)
{
if (string.IsNullOrEmpty(current.ID))
{
current.ID = baseName + index.ToString().PadLeft(3, ‘0’);
index++;
}
if (current.Controls != null)
foreach (Control child in current.Controls)
AssignIDs(child, baseName, ref index);
}
private void MoveControlsToPage(Control parent, ref int index)
{
if (base.Page != null && this.Controls.Count > 0)
{
List<Control> lst = new List<Control>(this.Controls.Count);
ContentContainer container = new ContentContainer(); // Container will host the controls w/out a NamingContainer.
container.ID = “ctlcontainer” + index.ToString().PadLeft(3, ‘0’);
ContentRenderer renderer = new ContentRenderer(container); // Renderer will render contents of container.
renderer.ID = “ctlrenderer” + index.ToString().PadLeft(3, ‘0’);
index++;
//— Create collection of all controls to move.
for (int ii = 0; ii < parent.Controls.Count; ii++)
{
lst.Add(parent.Controls[ii]);
}
//— Determine index of this container.
Control root = parent;
while (root != null && root.Parent != null && !(root.Parent is Page))
{
root = root.Parent;
}
int nIndex = (root == null) ? -1 : root.Parent.Controls.IndexOf(root);
//— Add new renderer to replace original controls and new container to the page itself.
//— The container is used to extablish the control hierarchy and in this way collapsing
//— the MasterPage so that it doesn’t act as a NamingContainer for the purposes of the
//— control hierarchy. The container will ignore the control’s normal Render() method.
//— The renderer has no controls, just a reference to the container. When the renderer’s
//— Render() method is called it will have the container render in its place.
parent.Controls.Add(renderer); // Render controls as if contained in master.
Page.Controls.AddAt(nIndex + 1, container); // But host them flat on the page instead of nested in NamingContainers.
//— Remove controls from original parent and add them to new container.
for (int ii = 0; ii < lst.Count; ii++)
{
Control ctrl = lst[ii];
parent.Controls.Remove(ctrl);
container.Controls.AddAt(ii, ctrl);
}
AssignIDs(container, “rehost”, ref index);
}
}
public void Rehost()
{
int index = 0;
RehostMasterControls(this.Page.Master, ref index);
}
I turned on trace and get “Multiple controls with the same ID ‘ctl00’ were found. Trace requires that controls have unique IDs.” That’s on .Net 2.0.
I’m going to try adding your changes to my implementation and verify that the problem is resolved and things function properly. If so, I’ll update the source in the blog.
Glad to hear it could apply to a .NET 2.0 issue as well.
Let me know if the changes work, or if you find an even better way to accomplish it than my stubbly hack.
Thanks, Cam, and thanks for the trick in the first place 🙂
Sorry for the delay. I’ve got it working in production now on a couple of sites. Your solution was definitely in the right direction, but I found that for our platform I needed to modify it so that the id’s are assigned later in the page life-cycle. Our platform does a lot of dynamic generation of controls so that’s probably why I found I needed to do that and you didn’t.
I’ll try to post the updated source today or tomorrow.
We do generate some controls dynamically, but definitely early on in the process. If it still behaves well assigning the IDs later in the process, then props to you 🙂
Thanks Ritchie, I finally got the code updated using this great code formatter: http://www.manoli.net/csharpformat/
The code now works with very late dynamic rendering with .Net tracing enabled. However, under certain circumstances I still get tracing errors when pageOutput is enabled. Again, I suspect that this will only happen with dynamic rendering very late in the page life-cycle.
Cam
Do you know why I get “is not allowed here because it does not extend class ‘System.Web.UI.MasterPage’.” when I try to use the derived MasterPage? I am using VS2008+sp1.
Thanks,
Lewis
Are you using TemplateMaster directly without deriving your own class from it? Also a typo could easily cause that problem.
Somehow VS messed up everything. I recreate the file and everything is fine now. However I ran into two new problems. One is that button event won’t be fired if the button is on the master page. The second one is that a dropdownlist with 50 items will always return selectedindex=1 somehow in my projects. I have 3 other dropdownlist without any problem. I have to change the type to htmlselect in order for it to work right. It is a great control. Thanks.
Good work. I have a lot of pages from old project copy pasted without master pages. When I have tried to extract common part of course all failed. Then I hae added you solution. Mostly works good. But some heavy control (based on datagrid) that have dynamic check box colums have problems. Generated Client Ids in browser differs from values that check boxes have on server side. So on post back checked values set incorrectly (because check boxes try to extract it from view state). May be thes check boxes are created in incorrect place (I have no time to investigate because as I asiad this control is very heavy with a lot of code). I have found that removing AssignIDs call fix this problem..
You might consider explicitly setting the ID’s on those checkboxes. If you did that I think things would work as desired even with the AssignIDs call. However, removing the AssignIDs call should be fine as long as you don’t turn on Trace.
In the following scenario with your TemplateMaster “fix” the “BtnLogin_Click” event does NOT get fired and EmailAddress.Text always equals String.Empty on PostBack.
MasterPage:
ContentPage:
I got this to work but I needed to change the following line – otherwise it was crashing
while (root != null && root.Parent != null && !(root.Parent is Page))
to
while (root != null && root.Parent != null && root.Parent.Parent != null)
Crash was evident in very simple test cases using VS2010 targeting 3.0
Cheers!