en
Choose your language

[TechSpeak] Removing Orphaned Renderings in Sitecore

Let’s take a break from AI news — today, I want to share a real-world challenge I encountered while working with Sitecore 10: orphaned renderings.

Hire SaM Solutions’ Sitecore team to perform a tailored audit of your Sitecore implementation and highlight points for improvement.

Problem

When working with Sitecore’s dynamic placeholders, child components are often assigned within parent renderings. However, if a parent rendering is deleted, Sitecore does not automatically clean up the child renderings that were nested inside its placeholder. 

These orphaned components:

  • Remain stored in the layout field (__Renderings).
  • Are not visible on the page, even though they still exist in the data structure.
  • Can cause broken layouts, unused placeholders, and excess rendering data.

Solution

So, how do we clean up these orphaned renderings? Let’s dive into the fix.

As part of the solution, we implemented an event handler for item:saved to validate whether a rendering with a nested placeholder was removed, potentially leaving orphaned renderings. If orphaned renderings are detected, they are removed, and the layout field is reserialized.

using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using Sitecore;
using Sitecore.Data.Fields;
using Sitecore.Data.Items;
using Sitecore.Events;
using Sitecore.Layouts;

namespace YourNamespace.Sitecore.Events
{
    public class DeleteOrphanRenderingsHandler
    {
        private const string DefaultDeviceId = "{FECA918C-40C4-4F99-8CF6-BE64DAF8FC32}"; // Default Sitecore device ID
        private const string PlaceholderFormat = "-{0}-"; // Format for placeholders in Sitecore
        private const string LocalDataSourcePrefix = "local:"; // Prefix for local data sources

        public void OnItemSaved(object sender, EventArgs args)
        {
            if (!(Event.ExtractParameter(args, 0) is Item savedItem) ||
                !(Event.ExtractParameter(args, 1) is ItemChanges itemChanges) ||
                itemChanges.IsEmpty)
            {
                return;
            }

            foreach (var fieldId in new[] { Sitecore.FieldIDs.LayoutField, Sitecore.FieldIDs.FinalLayoutField })
            {
                if (!itemChanges.FieldChanges.Contains(fieldId))
                    continue;

                // Important check: If Context.Item is null, local data sources may break (render empty)
                if (Context.Item == null)
                {
                    Context.Item = savedItem;
                }

                var updatedField = itemChanges.FieldChanges[fieldId];
                var updatedFieldValue = updatedField.Value;
                var originalFieldValue = updatedField.OriginalValue;

                if (string.IsNullOrEmpty(originalFieldValue) || string.IsNullOrEmpty(updatedFieldValue))
                    continue;

                var originalLayout = LayoutDefinition.Parse(originalFieldValue);
                var updatedLayout = LayoutDefinition.Parse(updatedFieldValue);

                var deviceId = Context.Device?.ID.ToString() ?? DefaultDeviceId;
                var originalDevice = originalLayout.GetDevice(deviceId);
                var updatedDevice = updatedLayout.Devices.Cast<DeviceDefinition>()
                    .FirstOrDefault(x => x.ID == deviceId);

                if (updatedDevice == null || originalDevice == null)
                {
                    continue;
                }

                var updateLayoutField = (LayoutField)savedItem.Fields[fieldId];
                
                var originalRenderings = originalDevice.Renderings.Cast<RenderingDefinition>().ToArray();
                var updatedRenderingsFromLayout = updateLayoutField.GetReferences(Context.Device).ToList();

                var removeResult = RemoveOrphanedRenderings(originalRenderings, updatedRenderingsFromLayout);
                if (!removeResult.wasUpdated) continue;

                var mappedRenderings = removeResult.updatedRenderings.Select(x =>
                {
                    var dataSourcePath = x.Settings.DataSource;
                    return new RenderingDefinition
                    {
                        UniqueId = x.UniqueId,
                        Placeholder = x.Placeholder,
                        ItemID = x.RenderingID.ToString(),
                        Parameters = x.Settings.Parameters,
                        Datasource = ConvertToLocalDataSource(savedItem.Paths.FullPath, dataSourcePath)
                    };
                }).ToList();

                updatedDevice.Renderings = new ArrayList(mappedRenderings);
                
                savedItem.Editing.BeginEdit();
                // If we use LayoutField.SetFieldValue(), the layout will be missing for the item.
                savedItem.Fields[fieldId].Value = updatedLayout.ToXml();
                
                // Prevent second item:save event
                savedItem.Editing.EndEdit(true);
            }
        }

        private static (bool wasUpdated, List<RenderingReference> updatedRenderings) RemoveOrphanedRenderings(
            RenderingDefinition[] originalRenderings,
            List<RenderingReference> updatedRenderings
        )
        {
            var wasUpdated = false;
            var removedRenderings = originalRenderings
                .Where(orig => updatedRenderings.All(updated => updated.UniqueId != orig.UniqueId))
                .ToList();

            if (!removedRenderings.Any()) return (false, updatedRenderings);

            foreach (var removedRendering in removedRenderings)
            {
                var updatedRenderingsTemp = updatedRenderings
                    .RemoveWhere(x => x.Placeholder.Contains(string.Format(PlaceholderFormat, removedRendering.UniqueId))).ToList();

                if (updatedRenderingsTemp.Count != updatedRenderings.Count)
                {
                    wasUpdated = true;
                }

                updatedRenderings = updatedRenderingsTemp.ToList();
            }

            return (wasUpdated, updatedRenderings);
        }
        
        private static string ConvertToLocalDataSource(string savedItemFullPath, string absoluteDataSourcePath)
        {
            if (string.IsNullOrEmpty(savedItemFullPath) || string.IsNullOrEmpty(absoluteDataSourcePath))
                return absoluteDataSourcePath; 
        
            if (absoluteDataSourcePath.StartsWith(savedItemFullPath, StringComparison.OrdinalIgnoreCase))
            {
                var localPath = absoluteDataSourcePath.Substring(savedItemFullPath.Length).TrimStart('/');
                return $"{LocalDataSourcePrefix}{localPath}";
            }
            return absoluteDataSourcePath;
        }
    }
}

The configuration file should be placed under the App_Config folder, following your project’s folder structure. If you are using the Helix architecture, I recommend placing it under App_Config/Include/Foundation

<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
  <sitecore>
    <events>
      <event name="item:saved">
        <handler type="YourNamespace.Sitecore.Events.DeleteOrphanRenderingsHandler, YourNamespace.Sitecore"
                 method="OnItemSaved" resolve="true"/>  
      </event>
    </events>
  </sitecore>
</configuration>

Some Interesting Moments from the Code Above

Set Context.Item to the current saved item manually

When handling Sitecore’s item:saved event, Context.Item is not automatically set to the saved item, even if resolve="true" is specified in the configuration file. This leads to issues when resolving local data sources (e.g., local:/Data/RichTextDatasource) because Sitecore relies on Context.Item for resolution.

Without explicitly setting Context.Item, the following problem occurs:

  • updateLayoutField.GetReferences(Context.Device); loads local data sources as empty strings (“”).
  • All renderings that had local data sources will lose their assigned values.
  • This causes unexpected behavior and potential errors on the website.

Fix: manually set Context.Item

To ensure correct resolution, manually set Context.Item before processing:

    if (Context.Item == null)
    {
		    Context.Item = savedItem;
    }

Using direct field set instead of LayoutField.SetFieldValue()

Using LayoutField.SetFieldValue() was found to remove the assigned layout, preventing the page from rendering properly.

savedItem.Editing.BeginEdit();
savedItem.Fields[fieldId].Value = updatedLayout.ToXml();

// Prevent second item:save event
savedItem.Editing.EndEdit(true);

ConvertToLocalDataSource function

Datasource = ConvertToLocalDataSource(savedItem.Paths.FullPath, dataSourcePath)

The ConvertToLocalDataSource function was introduced to convert absolute data source paths to local relative paths when possible. This helps simplify the maintenance of data source items, especially when the parent item is renamed, ensuring that data sources remain correctly linked.

Purpose of this function

  • Converts absolute paths (e.g., /sitecore/content/Home/Data/Item) to local paths (local:Data/Item) when applicable.
  • Ensures that data sources remain correctly associated with their parent item.
  • Prevents issues where renaming a parent item breaks hardcoded absolute paths.

This approach helps keep data sources flexible and reduces the risk of broken references when items are moved or renamed in Sitecore.

P. S.

Thank you for reading! I’m planning to share more about the challenges I encounter in Sitecore development. Stay tuned for more insights!

Please wait...
Leave a Comment

Your email address will not be published. Required fields are marked *

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>