Generating unique placeholders in Sitecore Headless
For a while now I have been working with Sitecore Headless, to be precise the .NET Rendering Host version of it. It has been a great experience so far, but every now and then I do come across a feature which I would like existed in the Headless approach but is not supported. One of those things is generated placeholders.
I know dynamic placeholders is supported; placeholders will get a unique ID based on the rendering they are rendered in. But this is not enough if I want to display a placeholder with the same key multiple times in a rendering, which I would want to do when building a tab component for example.
In this post I will share some background on why this is not supported right now and how you can make it more flexible.
The problem
When you open the Experience Editor, the page is displayed including different frames and chromes to make the Experience Editor function.
To understand the problem, we need to understand the difference in data flow on how the Experience Editor page is being rendered. Let’s first start by looking at the original MVC approach.
In the MVC flow, it is one and the same application generating the page structure and has access to the HtmlHelpers which generate the placeholders. I tried to put this flow in a simple diagram:
While Sitecore is generating the page and including the chromes, it knows everything there is to know about the context of the to be rendered page, rendering and placeholder. This makes it possible to generate any placeholder key, add it to the HTML and still know what kind of chromes to add around it.
If we compare this to the Headless variant, then the diagram looks something like this:
In this scenario, there are 2 applications involved. Sitecore knows only the content structure, based on that it generates the JSON for the Layout Service including the placeholders it knows should exist in each component.
That JSON is then send to the Rendering Host to convert it into an HTML page, but at the time of generating the HTML it only knows what to render in specific placeholders based on what the JSON told it to. So, the Rendering Host is missing context which it would need to be able to generate dynamic and always unique placeholders.
Improving the solution
I don’t think it is (right now) possible to build truly dynamic placeholders in the Headless development approach, but we can make it a little more flexible.
In my case I had to build a tabs component. A content editor should be able to add new tabs to the component and then be able to add any other component to that specific tab. So, for every tab created a different placeholder should be rendered. In this scenario we know which tabs should be displayed based on the content and data source selected on the component, so we can use that to generate extra placeholders in Layout Service response.
On the Layout Service side, we need to make 2 changes.
The first one is in the Sitecore.LayoutService.Placeholders.DynamicPlaceholdersResolver. This is the place where the out of the box functionalities generate unique IDs for the dynamic placeholders. This will also be the place where we can add logic to generate extra placeholders. My version of the override for the GetPlaceholderDefinitions method looks like this:
protected override IList<PlaceholderDefinition> GetPlaceholderDefinitions(Rendering ownerRendering, PlaceholderItem placeholderItem)
{
Assert.ArgumentNotNull(ownerRendering, nameof(ownerRendering));
Assert.ArgumentNotNull(placeholderItem, nameof(placeholderItem));
if (string.IsNullOrEmpty(ownerRendering?.Placeholder))
{
var emptyResult = new PlaceholderDefinition[1]
{
new PlaceholderDefinition()
{
OwnerRendering = ownerRendering,
PlaceholderItem = placeholderItem,
Path = placeholderItem.Key
}
};
return emptyResult;
}
int placeholderCount = this.GetPlaceholderCount(ownerRendering, placeholderItem);
// Store the original DynamicPlaceholder because we can use it as a basis for our generated placeholders.
var dynamicResult = new DynamicPlaceholderKeysResolver().GetDynamicKeys(placeholderItem.Key, ownerRendering, placeholderCount, 0).Select(key =>
{
string str = StringUtil.EnsurePrefix('/', FileUtil.MakePath(ownerRendering.Placeholder, key, '/'));
return new PlaceholderDefinition()
{
OwnerRendering = ownerRendering,
PlaceholderItem = placeholderItem,
Path = str
};
}).ToList();
// If "GenerateChildPlaceholders" is set, we want to generate a placeholder for each Child item of the rendering Data Source
var generateChildPlaceholders = ownerRendering.RenderingItem?.Parameters?.Contains("GenerateChildPlaceholders=1") ?? false;
if (generateChildPlaceholders && dynamicResult.Any() && (ownerRendering.Items?.FirstOrDefault()?.Children.Any() ?? false))
{
var basePlaceholderDefinition = dynamicResult.FirstOrDefault();
if (basePlaceholderDefinition != null)
{
var basePath = basePlaceholderDefinition.Path.Replace(basePlaceholderDefinition.Path.Split('/').Last(), "");
var childList = new List<PlaceholderDefinition>();
// For each child item of the datasource we will add an extra placeholder. These placeholders are based on the original dynamic placeholder, with the only difference being the Path property of the PlaceholderDefinition.
foreach (var item in ownerRendering.Items.FirstOrDefault().Children.ToList())
{
childList.Add(new PlaceholderDefinition
{
OwnerRendering = ownerRendering,
PlaceholderItem = placeholderItem,
Path = $"{basePath}{placeholderItem.Key.TrimEnd('*').ToLower()}-{item.ID.ToString().ToLower()}"
});
}
return childList;
}
}
return dynamicResult;
}
In above additional code I check for a setting “GenerateChildPlaceholders”. This is set on the parameters field of the rendering item. An improvement would be to make this into an actual checkbox instead of freely adding it to some text field.
The second part is making sure the placeholder path is being used in the JSON result instead of the name, as otherwise the placeholders keep overwriting each other in the array.
These placeholders are added in the Sitecore.JavaScriptServices.ViewEngine.LayoutService.Serialization.PlaceholderTransformer.
The piece of code we need to change is in the TransformPlaceholders, where the placeholders are added to an array:
dictionary[placeholder.Name] = ...
But we only want to do this for placeholders which were added by our earlier customization, so we also need to add a check in the TransformPlaceholderElement method.
The result looks something like this:
public IDictionary<string, object> CustomTransformPlaceholders(IList<RenderedPlaceholder> placeholders)
{
if (placeholders == null)
return new Dictionary<string, object>();
IDictionary<string, object> dictionary = new ExpandoObject();
foreach (RenderedPlaceholder placeholder in placeholders)
// Using the placeholder.Path instead of placeholder.Name to be able to use multiple placeholders of the same type in a single component.
dictionary[placeholder.Path.Split('/').Last()] = placeholder.Elements.Select(this.TransformPlaceholderElement);
return dictionary;
}
public override object TransformPlaceholderElement(RenderedPlaceholderElement element)
{
RenderedJsonRendering renderedJsonRendering = element as RenderedJsonRendering;
if (renderedJsonRendering == null)
{
return element;
}
dynamic val = new ExpandoObject();
val.uid = renderedJsonRendering.Uid;
val.componentName = renderedJsonRendering.ComponentName;
val.dataSource = renderedJsonRendering.DataSource;
val.@params = renderedJsonRendering.RenderingParams;
if (renderedJsonRendering.Contents != null)
{
val.fields = renderedJsonRendering.Contents;
}
if (renderedJsonRendering.Placeholders != null && renderedJsonRendering.Placeholders.Count > 0)
{
// If the Rendering Parameters field contains "GenerateChildPlaceholders" we want to apply custom logic
// This can be improved by making it a specific (checkbox) field on the Rendering Template or something similar
if (renderedJsonRendering.RenderingParams.TryGetValue("GenerateChildPlaceholders", out string paramVal)
&& !string.IsNullOrEmpty(paramVal) && paramVal.Equals("1"))
val.placeholders = CustomTransformPlaceholders(renderedJsonRendering.Placeholders);
else
val.placeholders = TransformPlaceholders(renderedJsonRendering.Placeholders);
}
return val;
}
Within this TransformPlaceholderElement method we also want to do a check on the existence of a “GenerateChildPlaceholders” to see if we need to trigger our customized code. However, this is a different source of the setting compared to the GetPlaceholderDefinitions customization because the context is different.
Within the PlaceholderTransformer we only have knowledge about the rendering, some basic fields about the placeholder and the elements in it. We do not have access or even know which rendering is being rendered here. So, to fix this we need to set this “GenerateChildPlaceholders” as a field on the rendering parameters of the rendering, which can easily be done with a base template including this field as a checkbox which is set to be enabled by default.
These changes to the Layout Service result in the following JSON for my Tabs component (each placeholder contains multiple different components):
{
"uid": "06487de2-8c65-49d8-a77b-a553d1b40caa",
"componentName": "Tabs",
"dataSource": "{3C357E6A-F6B1-4679-BCD0-0ECC7423EF46}",
"params": {
"GenerateChildPlaceholders": "1"
},
"fields": {
"items": [
{
"id": "6717e695-25ab-4081-ac44-0feebed3df60",
"url": "/Shared-Content/Tabs/Tabs-Example/Tab-1",
"name": "Tab 1",
"displayName": "Tab 1",
"fields": {
"Title": {
"value": "Tab 1"
}
}
},
{
"id": "a36af741-c233-4787-ad45-757821bfb2cc",
"url": "/Shared-Content/Tabs/Tabs-Example/Tab-2",
"name": "Tab 2",
"displayName": "Tab 2",
"fields": {
"Title": {
"value": "Tab 2"
}
}
}
]
},
"placeholders": {
"dynamic-tab-{6717e695-25ab-4081-ac44-0feebed3df60}": [...],
"dynamic-tab-{a36af741-c233-4787-ad45-757821bfb2cc}": [...]
}
}
On the Rendering Engine side, we need to make one change as well, which is actually more of a bug fix.
The problem is that the sc-placeholder tag helper at some point, while rendering placeholders, changes its context component to the component it renders inside the placeholder. This is not a problem in the out of the box functionality, but in our case, it is because we want to render more than just one placeholder in a component.
Due to methods being private in the original Placeholder Tag Helper, we need to copy all of it instead of being able to just override a single method. The result looks like:
[HtmlTargetElement("custom-placeholder")]
public class PlaceholderTagHelper : TagHelper
{
private readonly IComponentRendererFactory _componentRendererFactory;
private readonly IEditableChromeRenderer _chromeRenderer;
[HtmlAttributeNotBound]
[ViewContext]
public ViewContext ViewContext { get; set; }
public string Name { get; set; }
public PlaceholderTagHelper(IComponentRendererFactory componentFactory, IEditableChromeRenderer chromeRenderer)
{
_componentRendererFactory = componentFactory;
_chromeRenderer = chromeRenderer;
}
public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
{
string placeholderName = Name;
if (string.IsNullOrEmpty(placeholderName))
{
output.Content.SetHtmlContent("<!-- " + Resources.Warning_PlaceholderNameWasNotDefined + " -->");
return;
}
output.TagName = string.Empty;
if (ViewContext == null)
{
throw new NullReferenceException(Resources.Exception_ViewContextCannotBeNull);
}
ISitecoreRenderingContext renderingContext = ViewContext?.HttpContext.GetSitecoreRenderingContext();
if (renderingContext == null)
{
throw new NullReferenceException(Resources.Exception_SitecoreLayoutCannotBeNull);
}
Placeholder placeholderFeatures = GetPlaceholderFeatures(placeholderName, renderingContext);
if (placeholderFeatures == null)
{
output.Content.SetHtmlContent("<!-- " + Resources.Warning_PlaceholderWasNotDefined + " -->");
return;
}
bool foundPlaceholderFeatures = false;
foreach (IPlaceholderFeature item in placeholderFeatures)
{
if (item != null)
{
foundPlaceholderFeatures = true;
Component component = item as Component;
IHtmlContent htmlContent;
if (component == null)
{
EditableChrome editableChrome = item as EditableChrome;
htmlContent = editableChrome == null ? HtmlString.Empty : _chromeRenderer.Render(editableChrome);
}
else
{
// BUGFIX
// Take a copy of the Component and re-set it on the renderingContext after rendering the component.
// This fixes a bug where the next placeholder in the outer component would have a wrong Component context.
var outerComponent = renderingContext.Component;
renderingContext.Component = component;
htmlContent = await RenderComponent(renderingContext, ViewContext).ConfigureAwait(continueOnCapturedContext: false);
renderingContext.Component = outerComponent;
}
output.Content.AppendHtml(htmlContent);
}
}
if (!foundPlaceholderFeatures)
{
output.Content.SetHtmlContent("<!-- " + Resources.Warning_PlaceholderWasEmpty(placeholderName) + " -->");
}
}
private Task<IHtmlContent> RenderComponent(ISitecoreRenderingContext renderingContext, \[AllowNull] ViewContext viewContext)
{
if (renderingContext == null || viewContext == null)
{
throw new NullReferenceException(Resources.Exception_RenderingContextCannotBeNull);
}
Component component = renderingContext.Component;
if (component == null)
{
throw new NullReferenceException(Resources.Exception_RenderingContextComponentCannotBeNull);
}
return _componentRendererFactory.GetRenderer(component).Render(renderingContext, viewContext);
}
private static Placeholder GetPlaceholderFeatures(string placeholderName, ISitecoreRenderingContext renderingContext)
{
Placeholder value = null;
if (renderingContext.Component != null)
{
renderingContext.Component!.Placeholders.TryGetValue(placeholderName, out value);
}
if (value == null || !value.Any())
{
(renderingContext.Response?.Content?.Sitecore?.Route)?.Placeholders.TryGetValue(placeholderName, out value);
}
return value;
}
}
In your components you can then use the
Using this in my Tabs component results in the following ViewComponent:
@model Tabs
<div class="container tabs">
@foreach(var tab in Model.Children)
{
<div class="tab-item">
<div class="tab-header">
<h3 asp-for="@tab.Target.Title"></h3>
<button></button>
</div>
<div class="tab-body" style="@bodyStyle">
<custom-placeholder name="dynamic-tab-@tab.Id.ToString("B")"></custom-placeholder>
</div>
</div>
}
</div>
As you can see, you then need to set the placeholder’s name to the same value as was generated on the Layout Service side. This follows the following structure: [placeholder key]-[sitecore item ID]