Clean final layout on save

sitecoreperformance

August 16, 2022

With an active page in Sitecore with lots of changes to it's layout the final rendering will eventually be polluted. This often happens when renderings are added to a page in a rendering containing dynamic placeholder(s) which is later removed without the renderings inside are removed first. The result is that the final renderings is full of renderings which are actually never rendered on the page.

Even worse is when the leftover renderings are pointing their datasource to an item which is later removed. Then you will get the irritating popup of invalid datasource on each save of the page.

To remedy this I've created a small event handler residing in the OnItemSaving event. When an item with a final layout is saved leftover renderings which is not part of the shared renderings will be removed as well as renderings which contains a datasource ID which has been removed.

This event handler has been confirmed to work on Sitecore 9.3 and might need some modification to work on later (or earlier) versions of Sitecore.

namespace Tom.Stevens.Se.Events
{
    using Sitecore;
    using Sitecore.Data;
    using Sitecore.Data.Fields;
    using Sitecore.Data.Items;
    using Sitecore.Diagnostics;
    using Sitecore.Events;
    using Sitecore.Layouts;
    using System;
    using System.Collections;
    using System.Collections.Generic;
    using System.Linq;

    public class CleanRenderings
    {
        public void OnItemSaving(object sender, EventArgs args)
        {
            var item = Event.ExtractParameter(args, 0) as Item;
            // Make sure item is existing in content path, contains a final layout and that the database is master
            if (item == null || !item.Paths.IsContentItem || string.IsNullOrEmpty(item[FieldIDs.FinalLayoutField]) || !item.Database.Name.Equals("master")) return;

            try
            {
                var deviceItemId = item.Database.Resources.Devices.GetAll().First(d => d.Name.ToLower().Equals("default")).ID.ToString();
                
                var sharedRenderingIDs = LayoutDefinition.Parse(LayoutField.GetFieldValue(item.Fields[FieldIDs.LayoutField]))
                    .GetDevice(deviceItemId).Renderings.Cast<RenderingDefinition>().Select(r => r.UniqueId).ToList();
                
                var finalLayout = LayoutDefinition.Parse(LayoutField.GetFieldValue(item.Fields[FieldIDs.FinalLayoutField]));
                var finalDevice = finalLayout.GetDevice(deviceItemId);
                var finalRenderings = finalDevice.Renderings.Cast<RenderingDefinition>().ToList();

                var remove = new List<string>();

                // Find renderings where DataSource is pointing to non existing item
                remove.AddRange(
                    finalRenderings.Where(r =>
                        !sharedRenderingIDs.Contains(r.UniqueId) && // Not in shared layout
                        ID.TryParse(r.Datasource, out var id) && // Valid item id
                        item.Database.GetItem(id) == null) // Item is missing
                    .Select(renderingDefinition => renderingDefinition.UniqueId));

                // Find renderings which are located in invalid placeholders
                remove.AddRange(finalRenderings.Where(r => !IsRenderingShown(r, finalRenderings)).Select(r => r.UniqueId));
                if (remove.Count == 0) return;

                // Remove from layout
                finalDevice.Renderings = new ArrayList(finalRenderings.Where(r => !remove.Contains(r.UniqueId)).ToList());
                var removedRenderings = finalRenderings.Count - finalDevice.Renderings.Count;
                if (removedRenderings <= 0) return;
                
                using (new Sitecore.Data.Events.EventDisabler()) 
                {
                    // Save item
                    var layoutCode = finalLayout.ToXml();
                    item.Editing.BeginEdit();
                    LayoutField.SetFieldValue(item.Fields[FieldIDs.FinalLayoutField], layoutCode);
                    item.Editing.EndEdit();
                    Log.Info($"Removed {removedRenderings} rendering on item: {item.Paths.ContentPath}, language: {item.Language.Name}, version: {item.Version}, id: {item.ID}", this);
                }

            }
            catch (Exception ex)
            {
                Log.Error("Error while running event CleanRenderings.OnItemSaving",ex, this);
            }
        }
        
        private static bool IsRenderingShown(RenderingDefinition rendering, IReadOnlyCollection<RenderingDefinition> renderings)
        {
            if (string.IsNullOrEmpty(rendering?.Placeholder)) return false;

            var partPlaceholders = rendering.Placeholder.Split('/').Where(p => p.IndexOf('{') != -1); // Placeholders with a dynamic id
            foreach (var p in partPlaceholders)
            {
                // Check if any renderings has an id which matches the one in the placeholder path
                if (Guid.TryParse(p.Substring(p.IndexOf('{'), 38), out var uniqueId) && !renderings.Any(r => Guid.TryParse(r.UniqueId, out var u) && u.Equals(uniqueId)))
                { 
                    return false;
                }
            }

            return true;
        }
    }
}

After that a small config file is needed to load the event handler.

<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/" xmlns:role="http://www.sitecore.net/xmlconfig/role/" xmlns:localenv="http://www.sitecore.net/xmlconfig/localenv/">
  <sitecore>
    <events>
      <event name="item:saving">
        <handler patch:before="handler[@type='Sitecore.Tasks.ItemEventHandler, Sitecore.Kernel']" type="Tom.Stevens.Se.Events.CleanRenderings, Tom.Stevens.Se" method="OnItemSaving" role:require="Standalone or ContentManagement"></handler>
      </event>
    </events>
  </sitecore>
</configuration>