Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions Abies.Tests/DomBehaviorTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
using Xunit;
using Abies.DOM;
using System.Linq;
using System.Collections.Generic;
using DOMAttribute = Abies.DOM.Attribute;

namespace Abies.Tests;

public class DomBehaviorTests
{
private record DummyMessage() : Message;

[Fact]
public void AddRoot_ShouldRenderCorrectly()
{
var newDom = new Element("1", "div", System.Array.Empty<DOMAttribute>(),
new Text("2", "Hello"));

var patches = Operations.Diff(null, newDom);
var result = ApplyPatches(null, patches, null);

Assert.Equal(Render.Html(newDom), Render.Html(result!));
}

[Fact]
public void ReplaceChild_ShouldUpdateTree()
{
var oldDom = new Element("1", "div", System.Array.Empty<DOMAttribute>(),
new Element("2", "span", System.Array.Empty<DOMAttribute>(), new Text("3", "Old")));

var newDom = new Element("1", "div", System.Array.Empty<DOMAttribute>(),
new Element("4", "p", System.Array.Empty<DOMAttribute>(), new Text("5", "New")));

var patches = Operations.Diff(oldDom, newDom);
var result = ApplyPatches(oldDom, patches, oldDom);

Assert.Equal(Render.Html(newDom), Render.Html(result));
}

[Fact]
public void AttributeChanges_ShouldReflectInResult()
{
var oldDom = new Element("1", "button",
new DOMAttribute[] { new DOMAttribute("a1", "class", "btn") },
System.Array.Empty<Node>());

var newDom = new Element("1", "button",
new DOMAttribute[]
{
new DOMAttribute("a1", "class", "btn-primary"),
new Handler("click", "cmd1", new DummyMessage(), "h1")
},
System.Array.Empty<Node>());

var patches = Operations.Diff(oldDom, newDom);
var result = ApplyPatches(oldDom, patches, oldDom);

Assert.Equal(Render.Html(newDom), Render.Html(result));
}


private static Node? ApplyPatches(Node? root, IEnumerable<Patch> patches, Node? initialRoot)
{
var current = root;
foreach (var patch in patches)
{
current = ApplyPatch(current, patch, initialRoot);
}
return current;
}

private static Node? ApplyPatch(Node? root, Patch patch, Node? initialRoot)
{
return patch switch
{
AddRoot ar => ar.Element,
ReplaceChild rc => ReplaceNode(root!, rc.OldElement, rc.NewElement),
AddChild ac => UpdateElement(root!, ac.Parent.Id, e => e with { Children = e.Children.Append(ac.Child).ToArray() }),
RemoveChild rc => UpdateElement(root!, rc.Parent.Id, e => e with { Children = e.Children.Where(c => c.Id != rc.Child.Id).ToArray() }),
UpdateAttribute ua => UpdateElement(root!, ua.Element.Id, e => e with { Attributes = e.Attributes.Select(a => a.Id == ua.Attribute.Id ? ua.Attribute with { Value = ua.Value } : a).ToArray() }),
AddAttribute aa => UpdateElement(root!, aa.Element.Id, e => e with { Attributes = e.Attributes.Append(aa.Attribute).ToArray() }),
RemoveAttribute ra => UpdateElement(root!, ra.Element.Id, e => e with { Attributes = e.Attributes.Where(a => a.Id != ra.Attribute.Id).ToArray() }),
AddHandler ah => UpdateElement(root!, ah.Element.Id, e => e with { Attributes = e.Attributes.Append(ah.Handler).ToArray() }),
RemoveHandler rh => UpdateElement(root!, rh.Element.Id, e => e with { Attributes = e.Attributes.Where(a => a.Id != rh.Handler.Id).ToArray() }),
UpdateText ut => ReplaceNode(root!, ut.Node, new Text(ut.Node.Id, ut.Text)),
_ => root
};
}

private static Node ReplaceNode(Node node, Node target, Node newNode)
{
if (ReferenceEquals(node, target) || node.Id == target.Id)
return newNode;

if (node is Element el)
{
var newChildren = el.Children.Select(c => ReplaceNode(c, target, newNode)).ToArray();
return el with { Children = newChildren };
}
return node;
}

private static Node UpdateElement(Node node, string targetId, System.Func<Element, Element> update)
{
if (node is Element el)
{
if (el.Id == targetId)
{
var updated = update(el);
return updated;
}

var newChildren = el.Children.Select(c => UpdateElement(c, targetId, update)).ToArray();
return el with { Children = newChildren };
}
return node;
}
}

176 changes: 176 additions & 0 deletions Abies/DOM/Operations.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;

namespace Abies.DOM
{
/// <summary>
/// Provides diffing and patching utilities for the virtual DOM.
/// The implementation is inspired by Elm's VirtualDom diff algorithm
/// and is written with performance in mind.
/// </summary>
public static class Operations
{
/// <summary>
/// Apply a patch to the real DOM by invoking JavaScript interop.
/// </summary>
public static async Task Apply(Patch patch)
{
switch (patch)
{
case AddRoot addRoot:
await Interop.SetAppContent(Render.Html(addRoot.Element));
break;
case ReplaceChild replaceChild:
await Interop.ReplaceChildHtml(replaceChild.OldElement.Id, Render.Html(replaceChild.NewElement));
break;
case AddChild addChild:
await Interop.AddChildHtml(addChild.Parent.Id, Render.Html(addChild.Child));
break;
case RemoveChild removeChild:
await Interop.RemoveChild(removeChild.Parent.Id, removeChild.Child.Id);
break;
case UpdateAttribute updateAttribute:
await Interop.UpdateAttribute(updateAttribute.Element.Id, updateAttribute.Attribute.Name, updateAttribute.Value);
break;
case AddAttribute addAttribute:
await Interop.AddAttribute(addAttribute.Element.Id, addAttribute.Attribute.Name, addAttribute.Attribute.Value);
break;
case RemoveAttribute removeAttribute:
await Interop.RemoveAttribute(removeAttribute.Element.Id, removeAttribute.Attribute.Name);
break;
case AddHandler addHandler:
await Interop.AddAttribute(addHandler.Element.Id, addHandler.Handler.Name, addHandler.Handler.Value);
break;
case RemoveHandler removeHandler:
await Interop.RemoveAttribute(removeHandler.Element.Id, removeHandler.Handler.Name);
break;
case UpdateText updateText:
await Interop.UpdateTextContent(updateText.Node.Id, updateText.Text);
break;
default:
throw new InvalidOperationException("Unknown patch type");
}
}

/// <summary>
/// Compute the list of patches that transform <paramref name="oldNode"/> into <paramref name="newNode"/>.
/// </summary>
/// <param name="oldNode">The previous virtual DOM node. Can be <c>null</c> when rendering for the first time.</param>
/// <param name="newNode">The new virtual DOM node.</param>
public static List<Patch> Diff(Node? oldNode, Node newNode)
{
var patches = new List<Patch>();
if (oldNode is null)
{
patches.Add(new AddRoot((Element)newNode));
return patches;
}

DiffInternal(oldNode, newNode, null, patches);
return patches;
}

private static void DiffInternal(Node oldNode, Node newNode, Element? parent, List<Patch> patches)
{
// Text nodes only need an update when the value changes
if (oldNode is Text oldText && newNode is Text newText)
{
if (!string.Equals(oldText.Value, newText.Value, StringComparison.Ordinal))
patches.Add(new UpdateText(oldText, newText.Value));
return;
}

// Elements may need to be replaced when the tag differs or the node type changed
if (oldNode is Element oldElement && newNode is Element newElement)
{
if (!string.Equals(oldElement.Tag, newElement.Tag, StringComparison.Ordinal))
{
if (parent == null)
patches.Add(new AddRoot(newElement));
else
patches.Add(new ReplaceChild(oldElement, newElement));
return;
}

DiffAttributes(oldElement, newElement, patches);
DiffChildren(oldElement, newElement, patches);
return;
}

// Fallback for node type mismatch
if (oldNode is Element oe && newNode is Element ne && parent != null)
{
patches.Add(new ReplaceChild(oe, ne));
}
}

// Diff attribute collections using dictionaries for O(n) lookup
private static void DiffAttributes(Element oldElement, Element newElement, List<Patch> patches)
{
var oldMap = new Dictionary<string, Attribute>(oldElement.Attributes.Length);
foreach (var attr in oldElement.Attributes)
oldMap[attr.Id] = attr;

foreach (var newAttr in newElement.Attributes)
{
if (oldMap.TryGetValue(newAttr.Id, out var oldAttr))
{
oldMap.Remove(newAttr.Id);
if (!newAttr.Equals(oldAttr))
{
if (oldAttr is Handler oldHandler)
patches.Add(new RemoveHandler(oldElement, oldHandler));
else if (newAttr is Handler)
patches.Add(new RemoveAttribute(oldElement, oldAttr));

if (newAttr is Handler newHandler)
patches.Add(new AddHandler(newElement, newHandler));
else
patches.Add(new UpdateAttribute(oldElement, newAttr, newAttr.Value));
}
}
else
{
if (newAttr is Handler handler)
patches.Add(new AddHandler(newElement, handler));
else
patches.Add(new AddAttribute(newElement, newAttr));
}
}

// Any remaining old attributes must be removed
foreach (var remaining in oldMap.Values)
{
if (remaining is Handler handler)
patches.Add(new RemoveHandler(oldElement, handler));
else
patches.Add(new RemoveAttribute(oldElement, remaining));
}
}

private static void DiffChildren(Element oldParent, Element newParent, List<Patch> patches)
{
var oldChildren = oldParent.Children;
var newChildren = newParent.Children;
int shared = Math.Min(oldChildren.Length, newChildren.Length);

// Diff children that exist in both trees
for (int i = 0; i < shared; i++)
DiffInternal(oldChildren[i], newChildren[i], oldParent, patches);

// Remove extra old children
for (int i = shared; i < oldChildren.Length; i++)
{
if (oldChildren[i] is Element oldChild)
patches.Add(new RemoveChild(oldParent, oldChild));
}

// Add additional new children
for (int i = shared; i < newChildren.Length; i++)
{
if (newChildren[i] is Element newChild)
patches.Add(new AddChild(newParent, newChild));
}
}
}
}
Loading
Loading