diff --git a/Abies.Tests/DomBehaviorTests.cs b/Abies.Tests/DomBehaviorTests.cs new file mode 100644 index 00000000..44df241f --- /dev/null +++ b/Abies.Tests/DomBehaviorTests.cs @@ -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(), + 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(), + new Element("2", "span", System.Array.Empty(), new Text("3", "Old"))); + + var newDom = new Element("1", "div", System.Array.Empty(), + new Element("4", "p", System.Array.Empty(), 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()); + + var newDom = new Element("1", "button", + new DOMAttribute[] + { + new DOMAttribute("a1", "class", "btn-primary"), + new Handler("click", "cmd1", new DummyMessage(), "h1") + }, + System.Array.Empty()); + + 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 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 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; + } +} + diff --git a/Abies/DOM/Operations.cs b/Abies/DOM/Operations.cs new file mode 100644 index 00000000..023db577 --- /dev/null +++ b/Abies/DOM/Operations.cs @@ -0,0 +1,176 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; + +namespace Abies.DOM +{ + /// + /// 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. + /// + public static class Operations + { + /// + /// Apply a patch to the real DOM by invoking JavaScript interop. + /// + 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"); + } + } + + /// + /// Compute the list of patches that transform into . + /// + /// The previous virtual DOM node. Can be null when rendering for the first time. + /// The new virtual DOM node. + public static List Diff(Node? oldNode, Node newNode) + { + var patches = new List(); + 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 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 patches) + { + var oldMap = new Dictionary(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 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)); + } + } + } +} diff --git a/Abies/Types.cs b/Abies/Types.cs index 27cd9eba..9431df3b 100644 --- a/Abies/Types.cs +++ b/Abies/Types.cs @@ -459,175 +459,5 @@ private static void RenderNode(Node node, System.Text.StringBuilder sb) } } - public static class Operations - { - 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 Exception("Unknown patch type"); - } - } - - public static List Diff(Node oldNode, Node newNode) - { - if (oldNode is null) - { - // Generate patches to create the newNode from scratch - return [new AddRoot((Element)newNode)]; - } - - List patches; - if (oldNode is Text oldText && newNode is Text newText) - { - patches = oldText.Value == newText.Value - ? [] - : [new UpdateText(oldText, newText.Value)]; - } - else if (oldNode is Element oldElement && newNode is Element newElement && oldElement.Tag != newElement.Tag) - { - patches = [new ReplaceChild(oldElement, newElement)]; - } - else if (oldNode is Element o && newNode is Element n) - { - patches = DiffElements(o, n); - } - else - { - throw new Exception("Unknown node type"); - } - - return patches; - } - - private static List DiffElements(Element oldElement, Element newElement) - { - var patches = new List(); - - if (oldElement.Tag != newElement.Tag) - { - return new List { new ReplaceChild(oldElement, newElement) }; - } - - // Compare Attributes using Span - var oldAttributes = oldElement.Attributes.AsSpan(); - var newAttributes = newElement.Attributes.AsSpan(); - - for (int i = 0; i < newAttributes.Length; i++) - { - ref var newAttr = ref newAttributes[i]; - Attribute? oldAttr = null; - - for (int j = 0; j < oldAttributes.Length; j++) - { - if (oldAttributes[j].Id == newAttr.Id) - { - oldAttr = oldAttributes[j]; - break; - } - } - - if (oldAttr != null) - { - if (!newAttr.Equals(oldAttr)) - { - if (newAttr is Handler handler) - { - patches.Add(new RemoveHandler(oldElement, (Handler)oldAttr)); - patches.Add(new AddHandler(newElement, handler)); - } - else - { - patches.Add(new UpdateAttribute(oldElement, newAttr, newAttr.Value)); - } - } - } - else - { - patches.Add(new AddAttribute(oldElement, newAttr)); - } - } - - for (int i = 0; i < oldAttributes.Length; i++) - { - var oldAttr = oldAttributes[i]; - bool existsInNew = false; - - for (int j = 0; j < newAttributes.Length; j++) - { - if (newAttributes[j].Id == oldAttr.Id) - { - existsInNew = true; - break; - } - } - - if (!existsInNew) - { - patches.Add(new RemoveAttribute(oldElement, oldAttr)); - } - } - - // Compare Children - var childPatches = DiffChildren(oldElement.Children, newElement.Children); - patches.AddRange(childPatches); - - return patches; - } - - private static List DiffChildren(ReadOnlySpan children1, ReadOnlySpan children2) - { - var patches = new List(); - - var length = Math.Max(children1.Length, children2.Length); - for (int i = 0; i < length; i++) - { - if (i < children1.Length && i < children2.Length) - { - patches.AddRange(Diff(children1[i], children2[i])); - } - else if (i < children1.Length) - { - patches.Add(new RemoveChild((Element)children1[i - 1], (Element)children1[i])); - } - else if (i < children2.Length) - { - patches.Add(new AddChild((Element)children2[i - 1], (Element)children2[i])); - } - } - - return patches; - } - } + // Operations class moved to a dedicated file for clarity } \ No newline at end of file diff --git a/docs/virtual_dom_algorithm.md b/docs/virtual_dom_algorithm.md new file mode 100644 index 00000000..eb2204fc --- /dev/null +++ b/docs/virtual_dom_algorithm.md @@ -0,0 +1,35 @@ +# Virtual DOM Diff and Patch Algorithm + +This document outlines the algorithm used by **Abies** to compute updates to the +DOM. The implementation is heavily inspired by the Elm `VirtualDom` package but +adapted for C#. + +## Overview + +1. **Diffing** – The `Operations.Diff` method walks two virtual DOM trees and + produces a list of `Patch` objects. These describe the minimal changes needed +to transform the old tree into the new tree. +2. **Patching** – Each `Patch` is executed by `Operations.Apply` through + JavaScript interop calls which update the real DOM. + +## Diffing Strategy + +The diff uses a stack based traversal to avoid recursion overhead. Attributes +are compared using dictionaries so lookups are O(1). Children are processed in +order, removing extra nodes and appending new ones when necessary. + +Patches are generated for: + +- Replacing nodes when their tag changes. +- Adding or removing child elements. +- Updating, adding or removing attributes and event handlers. +- Updating text node content. + +This approach mirrors Elm's efficient diff while remaining idiomatic C#. + +## Patching + +`Operations.Apply` interprets a patch and forwards the change to the browser via +JavaScript functions. Because patches are small and interop calls are batched, +the runtime stays responsive. +