Skip to content

Commit 6389e27

Browse files
committed
perf: add ClearChildren optimization for bulk child removal
When clearing all children from an element, generate a single ClearChildren patch instead of N individual RemoveChild patches. This uses the native parent.replaceChildren() API which is much faster than N remove() calls. Benchmark improvement (09_clear1k): - Before: 159.6ms - After: 91.2ms - Improvement: 1.75x faster (43% reduction) Changes: - Add ClearChildren patch type in Operations.cs - Add ClearChildren JSImport in Interop.cs - Add clearChildren function and batch handler in abies.js - Add handler cleanup for ClearChildren in ApplyBatch - Add optimization in diff membership change path - Add 4 unit tests for ClearChildren behavior
1 parent b930bd4 commit 6389e27

File tree

6 files changed

+220
-16
lines changed

6 files changed

+220
-16
lines changed

Abies.Presentation/wwwroot/abies.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -969,6 +969,21 @@ setModuleImports('abies.js', {
969969
}
970970
}),
971971

972+
/**
973+
* Clears all children from a parent element.
974+
* This is more efficient than multiple removeChild calls when clearing all children.
975+
* @param {string} parentId - The ID of the parent element to clear.
976+
*/
977+
clearChildren: withSpan('clearChildren', async (parentId) => {
978+
const parent = document.getElementById(parentId);
979+
if (parent) {
980+
// replaceChildren() with no args removes all children efficiently
981+
parent.replaceChildren();
982+
} else {
983+
console.error(`Cannot clear children: parent with ID ${parentId} not found.`);
984+
}
985+
}),
986+
972987
/**
973988
* Replaces an existing node with new HTML content.
974989
* @param {number} oldNodeId - The ID of the node to replace.
@@ -1160,6 +1175,14 @@ setModuleImports('abies.js', {
11601175
}
11611176
break;
11621177
}
1178+
case 'ClearChildren': {
1179+
const parent = document.getElementById(patch.ParentId);
1180+
if (parent) {
1181+
// replaceChildren() with no args efficiently removes all children
1182+
parent.replaceChildren();
1183+
}
1184+
break;
1185+
}
11631186
case 'ReplaceChild': {
11641187
const oldNode = document.getElementById(patch.TargetId);
11651188
if (oldNode && oldNode.parentNode) {

Abies.SubscriptionsDemo/wwwroot/abies.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -969,6 +969,21 @@ setModuleImports('abies.js', {
969969
}
970970
}),
971971

972+
/**
973+
* Clears all children from a parent element.
974+
* This is more efficient than multiple removeChild calls when clearing all children.
975+
* @param {string} parentId - The ID of the parent element to clear.
976+
*/
977+
clearChildren: withSpan('clearChildren', async (parentId) => {
978+
const parent = document.getElementById(parentId);
979+
if (parent) {
980+
// replaceChildren() with no args removes all children efficiently
981+
parent.replaceChildren();
982+
} else {
983+
console.error(`Cannot clear children: parent with ID ${parentId} not found.`);
984+
}
985+
}),
986+
972987
/**
973988
* Replaces an existing node with new HTML content.
974989
* @param {number} oldNodeId - The ID of the node to replace.
@@ -1160,6 +1175,14 @@ setModuleImports('abies.js', {
11601175
}
11611176
break;
11621177
}
1178+
case 'ClearChildren': {
1179+
const parent = document.getElementById(patch.ParentId);
1180+
if (parent) {
1181+
// replaceChildren() with no args efficiently removes all children
1182+
parent.replaceChildren();
1183+
}
1184+
break;
1185+
}
11631186
case 'ReplaceChild': {
11641187
const oldNode = document.getElementById(patch.TargetId);
11651188
if (oldNode && oldNode.parentNode) {

Abies.Tests/DomBehaviorTests.cs

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,7 @@ public void LegacyDataKey_ShouldStillWork()
380380
ReplaceChild rc => ReplaceNode(root!, rc.OldElement, rc.NewElement),
381381
AddChild ac => UpdateElement(root!, ac.Parent.Id, e => e with { Children = e.Children.Append(ac.Child).ToArray() }),
382382
RemoveChild rc => UpdateElement(root!, rc.Parent.Id, e => e with { Children = e.Children.Where(c => c.Id != rc.Child.Id).ToArray() }),
383+
ClearChildren cc => UpdateElement(root!, cc.Parent.Id, e => e with { Children = [] }),
383384
MoveChild mc => UpdateElement(root!, mc.Parent.Id, e =>
384385
{
385386
// Remove child from current position
@@ -922,4 +923,96 @@ public void KeyedChildren_ReverseOrder_ShouldMinimizeMoves()
922923
}
923924

924925
#endregion
926+
927+
#region ClearChildren Optimization Tests
928+
929+
[Fact]
930+
public void ClearAllChildren_ShouldUseSingleClearChildrenPatch()
931+
{
932+
// When removing ALL children, the diff should generate a single ClearChildren patch
933+
// instead of N individual RemoveChild patches
934+
935+
var oldDom = new Element("tbody", "tbody", [],
936+
new Element("row-0", "tr", [], new Text("t0", "Row 0")),
937+
new Element("row-1", "tr", [], new Text("t1", "Row 1")),
938+
new Element("row-2", "tr", [], new Text("t2", "Row 2")),
939+
new Element("row-3", "tr", [], new Text("t3", "Row 3")),
940+
new Element("row-4", "tr", [], new Text("t4", "Row 4")));
941+
942+
var newDom = new Element("tbody", "tbody", []); // Empty children
943+
944+
var patches = Operations.Diff(oldDom, newDom);
945+
946+
// Should have exactly 1 ClearChildren patch, no RemoveChild patches
947+
Assert.Single(patches);
948+
Assert.IsType<ClearChildren>(patches.First());
949+
Assert.DoesNotContain(patches, p => p is RemoveChild);
950+
951+
// Verify the patch contains the old children for handler cleanup
952+
var clearPatch = (ClearChildren)patches.First();
953+
Assert.Equal(5, clearPatch.OldChildren.Length);
954+
}
955+
956+
[Fact]
957+
public void ClearManyChildren_ShouldUseSingleClearChildrenPatch()
958+
{
959+
// Test with larger list to verify performance benefit
960+
const int listSize = 100;
961+
962+
var oldChildren = new Node[listSize];
963+
for (int i = 0; i < listSize; i++)
964+
{
965+
oldChildren[i] = new Element($"row-{i}", "tr", [], new Text($"text-{i}", $"Row {i}"));
966+
}
967+
var oldDom = new Element("tbody", "tbody", [], oldChildren);
968+
var newDom = new Element("tbody", "tbody", []); // Empty
969+
970+
var patches = Operations.Diff(oldDom, newDom);
971+
972+
// Should be 1 ClearChildren instead of 100 RemoveChild patches
973+
Assert.Single(patches);
974+
Assert.IsType<ClearChildren>(patches.First());
975+
}
976+
977+
[Fact]
978+
public void RemoveSomeChildren_ShouldNotUseClearChildren()
979+
{
980+
// When removing SOME children but not all, should use individual RemoveChild patches
981+
982+
var oldDom = new Element("div", "div", [],
983+
new Element("a", "span", []),
984+
new Element("b", "span", []),
985+
new Element("c", "span", []));
986+
987+
var newDom = new Element("div", "div", [],
988+
new Element("a", "span", [])); // Keep first, remove b and c
989+
990+
var patches = Operations.Diff(oldDom, newDom);
991+
992+
// Should have 2 RemoveChild patches, not ClearChildren
993+
Assert.DoesNotContain(patches, p => p is ClearChildren);
994+
Assert.Equal(2, patches.Count(p => p is RemoveChild));
995+
}
996+
997+
[Fact]
998+
public void ClearChildren_ShouldApplyCorrectly()
999+
{
1000+
// Verify that ClearChildren patch applies correctly
1001+
var oldDom = new Element("div", "div", [],
1002+
new Element("a", "span", []),
1003+
new Element("b", "span", []),
1004+
new Element("c", "span", []));
1005+
1006+
var newDom = new Element("div", "div", []);
1007+
1008+
var patches = Operations.Diff(oldDom, newDom);
1009+
var result = ApplyPatches(oldDom, patches, oldDom);
1010+
1011+
Assert.NotNull(result);
1012+
Assert.IsType<Element>(result);
1013+
var resultElement = (Element)result;
1014+
Assert.Empty(resultElement.Children);
1015+
}
1016+
1017+
#endregion
9251018
}

Abies/DOM/Operations.cs

Lines changed: 55 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,18 @@ public readonly struct RemoveChild(Element parent, Element child) : Patch
405405
public readonly Element Child = child;
406406
}
407407

408+
/// <summary>
409+
/// Represents a patch operation to clear all children from an element in the Abies DOM.
410+
/// This is more efficient than multiple RemoveChild operations when removing all children.
411+
/// </summary>
412+
/// <param name="parent">The parent element to clear.</param>
413+
/// <param name="oldChildren">The children being removed (needed for handler unregistration).</param>
414+
public readonly struct ClearChildren(Element parent, Node[] oldChildren) : Patch
415+
{
416+
public readonly Element Parent = parent;
417+
public readonly Node[] OldChildren = oldChildren;
418+
}
419+
408420
/// <summary>
409421
/// Represents a patch operation to update an attribute in the Abies DOM.
410422
/// </summary>
@@ -890,6 +902,10 @@ public static async Task Apply(Patch patch)
890902
Runtime.UnregisterHandlers(removeChild.Child);
891903
await Interop.RemoveChild(removeChild.Parent.Id, removeChild.Child.Id);
892904
break;
905+
case ClearChildren clearChildren:
906+
// Single DOM operation to clear all children - much faster than N RemoveChild operations
907+
await Interop.ClearChildren(clearChildren.Parent.Id);
908+
break;
893909
case UpdateAttribute updateAttribute:
894910
await Interop.UpdateAttribute(updateAttribute.Element.Id, updateAttribute.Attribute.Name, updateAttribute.Value);
895911
break;
@@ -986,6 +1002,16 @@ public static async Task ApplyBatch(List<Patch> patches)
9861002
case RemoveChild removeChild:
9871003
Runtime.UnregisterHandlers(removeChild.Child);
9881004
break;
1005+
case ClearChildren clearChildren:
1006+
// Unregister handlers for all children being cleared
1007+
foreach (var child in clearChildren.OldChildren)
1008+
{
1009+
if (child is Element element)
1010+
{
1011+
Runtime.UnregisterHandlers(element);
1012+
}
1013+
}
1014+
break;
9891015
case AddHandler addHandler:
9901016
Runtime.RegisterHandler(addHandler.Handler);
9911017
break;
@@ -1062,6 +1088,11 @@ private static PatchData ConvertToPatchData(Patch patch)
10621088
ParentId = removeChild.Parent.Id,
10631089
ChildId = removeChild.Child.Id
10641090
},
1091+
ClearChildren clearChildren => new PatchData
1092+
{
1093+
Type = "ClearChildren",
1094+
ParentId = clearChildren.Parent.Id
1095+
},
10651096
UpdateAttribute updateAttribute => new PatchData
10661097
{
10671098
Type = "UpdateAttribute",
@@ -1672,24 +1703,32 @@ private static void DiffChildrenCore(
16721703
}
16731704
}
16741705

1675-
// Remove old children that don't exist in new (iterate backwards to maintain order)
1676-
for (int i = keysToRemove.Count - 1; i >= 0; i--)
1706+
// Optimization: if removing ALL children and adding none, use ClearChildren
1707+
if (keysToRemove.Count == oldLength && keysToAdd.Count == 0 && keysToDiff.Count == 0)
16771708
{
1678-
var idx = keysToRemove[i];
1679-
// Unwrap memo nodes to get the actual content for patch creation
1680-
var effectiveOld = UnwrapMemoNode(oldChildren[idx]);
1681-
1682-
if (effectiveOld is Element oldChild)
1683-
{
1684-
patches.Add(new RemoveChild(oldParent, oldChild));
1685-
}
1686-
else if (effectiveOld is RawHtml oldRaw)
1687-
{
1688-
patches.Add(new RemoveRaw(oldParent, oldRaw));
1689-
}
1690-
else if (effectiveOld is Text oldText)
1709+
patches.Add(new ClearChildren(oldParent, oldChildren));
1710+
}
1711+
else
1712+
{
1713+
// Remove old children that don't exist in new (iterate backwards to maintain order)
1714+
for (int i = keysToRemove.Count - 1; i >= 0; i--)
16911715
{
1692-
patches.Add(new RemoveText(oldParent, oldText));
1716+
var idx = keysToRemove[i];
1717+
// Unwrap memo nodes to get the actual content for patch creation
1718+
var effectiveOld = UnwrapMemoNode(oldChildren[idx]);
1719+
1720+
if (effectiveOld is Element oldChild)
1721+
{
1722+
patches.Add(new RemoveChild(oldParent, oldChild));
1723+
}
1724+
else if (effectiveOld is RawHtml oldRaw)
1725+
{
1726+
patches.Add(new RemoveRaw(oldParent, oldRaw));
1727+
}
1728+
else if (effectiveOld is Text oldText)
1729+
{
1730+
patches.Add(new RemoveText(oldParent, oldText));
1731+
}
16931732
}
16941733
}
16951734

Abies/Interop.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ public static partial class Interop
5656
[JSImport("removeChild", "abies.js")]
5757
public static partial Task RemoveChild(string parentId, string childId);
5858

59+
[JSImport("clearChildren", "abies.js")]
60+
public static partial Task ClearChildren(string parentId);
61+
5962
[JSImport("replaceChildHtml", "abies.js")]
6063
public static partial Task ReplaceChildHtml(string oldNodeId, string newHtml);
6164

Abies/wwwroot/abies.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -969,6 +969,21 @@ setModuleImports('abies.js', {
969969
}
970970
}),
971971

972+
/**
973+
* Clears all children from a parent element.
974+
* This is more efficient than multiple removeChild calls when clearing all children.
975+
* @param {string} parentId - The ID of the parent element to clear.
976+
*/
977+
clearChildren: withSpan('clearChildren', async (parentId) => {
978+
const parent = document.getElementById(parentId);
979+
if (parent) {
980+
// replaceChildren() with no args removes all children efficiently
981+
parent.replaceChildren();
982+
} else {
983+
console.error(`Cannot clear children: parent with ID ${parentId} not found.`);
984+
}
985+
}),
986+
972987
/**
973988
* Replaces an existing node with new HTML content.
974989
* @param {number} oldNodeId - The ID of the node to replace.
@@ -1160,6 +1175,14 @@ setModuleImports('abies.js', {
11601175
}
11611176
break;
11621177
}
1178+
case 'ClearChildren': {
1179+
const parent = document.getElementById(patch.ParentId);
1180+
if (parent) {
1181+
// replaceChildren() with no args efficiently removes all children
1182+
parent.replaceChildren();
1183+
}
1184+
break;
1185+
}
11631186
case 'ReplaceChild': {
11641187
const oldNode = document.getElementById(patch.TargetId);
11651188
if (oldNode && oldNode.parentNode) {

0 commit comments

Comments
 (0)