Skip to content

Commit 586e91b

Browse files
committed
Rewrite diff and patch operations
1 parent 9317115 commit 586e91b

File tree

3 files changed

+212
-171
lines changed

3 files changed

+212
-171
lines changed

Abies/DOM/Operations.cs

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
using System.Collections.Generic;
2+
using System.Diagnostics.CodeAnalysis;
3+
4+
namespace Abies.DOM
5+
{
6+
/// <summary>
7+
/// Provides diffing and patching utilities for the virtual DOM.
8+
/// The implementation is inspired by Elm's VirtualDom diff algorithm
9+
/// and is written with performance in mind.
10+
/// </summary>
11+
public static class Operations
12+
{
13+
/// <summary>
14+
/// Apply a patch to the real DOM by invoking JavaScript interop.
15+
/// </summary>
16+
public static async Task Apply(Patch patch)
17+
{
18+
switch (patch)
19+
{
20+
case AddRoot addRoot:
21+
await Interop.SetAppContent(Render.Html(addRoot.Element));
22+
break;
23+
case ReplaceChild replaceChild:
24+
await Interop.ReplaceChildHtml(replaceChild.OldElement.Id, Render.Html(replaceChild.NewElement));
25+
break;
26+
case AddChild addChild:
27+
await Interop.AddChildHtml(addChild.Parent.Id, Render.Html(addChild.Child));
28+
break;
29+
case RemoveChild removeChild:
30+
await Interop.RemoveChild(removeChild.Parent.Id, removeChild.Child.Id);
31+
break;
32+
case UpdateAttribute updateAttribute:
33+
await Interop.UpdateAttribute(updateAttribute.Element.Id, updateAttribute.Attribute.Name, updateAttribute.Value);
34+
break;
35+
case AddAttribute addAttribute:
36+
await Interop.AddAttribute(addAttribute.Element.Id, addAttribute.Attribute.Name, addAttribute.Attribute.Value);
37+
break;
38+
case RemoveAttribute removeAttribute:
39+
await Interop.RemoveAttribute(removeAttribute.Element.Id, removeAttribute.Attribute.Name);
40+
break;
41+
case AddHandler addHandler:
42+
await Interop.AddAttribute(addHandler.Element.Id, addHandler.Handler.Name, addHandler.Handler.Value);
43+
break;
44+
case RemoveHandler removeHandler:
45+
await Interop.RemoveAttribute(removeHandler.Element.Id, removeHandler.Handler.Name);
46+
break;
47+
case UpdateText updateText:
48+
await Interop.UpdateTextContent(updateText.Node.Id, updateText.Text);
49+
break;
50+
default:
51+
throw new InvalidOperationException("Unknown patch type");
52+
}
53+
}
54+
55+
/// <summary>
56+
/// Compute the list of patches that transform <paramref name="oldNode"/> into <paramref name="newNode"/>.
57+
/// </summary>
58+
/// <param name="oldNode">The previous virtual DOM node. Can be <c>null</c> when rendering for the first time.</param>
59+
/// <param name="newNode">The new virtual DOM node.</param>
60+
public static List<Patch> Diff(Node? oldNode, Node newNode)
61+
{
62+
var patches = new List<Patch>();
63+
if (oldNode is null)
64+
{
65+
patches.Add(new AddRoot((Element)newNode));
66+
return patches;
67+
}
68+
69+
DiffInternal(oldNode, newNode, null, patches);
70+
return patches;
71+
}
72+
73+
private static void DiffInternal(Node oldNode, Node newNode, Element? parent, List<Patch> patches)
74+
{
75+
// Text nodes only need an update when the value changes
76+
if (oldNode is Text oldText && newNode is Text newText)
77+
{
78+
if (!string.Equals(oldText.Value, newText.Value, StringComparison.Ordinal))
79+
patches.Add(new UpdateText(oldText, newText.Value));
80+
return;
81+
}
82+
83+
// Elements may need to be replaced when the tag differs or the node type changed
84+
if (oldNode is Element oldElement && newNode is Element newElement)
85+
{
86+
if (!string.Equals(oldElement.Tag, newElement.Tag, StringComparison.Ordinal))
87+
{
88+
if (parent == null)
89+
patches.Add(new AddRoot(newElement));
90+
else
91+
patches.Add(new ReplaceChild(oldElement, newElement));
92+
return;
93+
}
94+
95+
DiffAttributes(oldElement, newElement, patches);
96+
DiffChildren(oldElement, newElement, patches);
97+
return;
98+
}
99+
100+
// Fallback for node type mismatch
101+
if (oldNode is Element oe && newNode is Element ne && parent != null)
102+
{
103+
patches.Add(new ReplaceChild(oe, ne));
104+
}
105+
}
106+
107+
// Diff attribute collections using dictionaries for O(n) lookup
108+
private static void DiffAttributes(Element oldElement, Element newElement, List<Patch> patches)
109+
{
110+
var oldMap = new Dictionary<string, Attribute>(oldElement.Attributes.Length);
111+
foreach (var attr in oldElement.Attributes)
112+
oldMap[attr.Id] = attr;
113+
114+
foreach (var newAttr in newElement.Attributes)
115+
{
116+
if (oldMap.TryGetValue(newAttr.Id, out var oldAttr))
117+
{
118+
oldMap.Remove(newAttr.Id);
119+
if (!newAttr.Equals(oldAttr))
120+
{
121+
if (oldAttr is Handler oldHandler)
122+
patches.Add(new RemoveHandler(oldElement, oldHandler));
123+
else if (newAttr is Handler)
124+
patches.Add(new RemoveAttribute(oldElement, oldAttr));
125+
126+
if (newAttr is Handler newHandler)
127+
patches.Add(new AddHandler(newElement, newHandler));
128+
else
129+
patches.Add(new UpdateAttribute(oldElement, newAttr, newAttr.Value));
130+
}
131+
}
132+
else
133+
{
134+
if (newAttr is Handler handler)
135+
patches.Add(new AddHandler(newElement, handler));
136+
else
137+
patches.Add(new AddAttribute(newElement, newAttr));
138+
}
139+
}
140+
141+
// Any remaining old attributes must be removed
142+
foreach (var remaining in oldMap.Values)
143+
{
144+
if (remaining is Handler handler)
145+
patches.Add(new RemoveHandler(oldElement, handler));
146+
else
147+
patches.Add(new RemoveAttribute(oldElement, remaining));
148+
}
149+
}
150+
151+
private static void DiffChildren(Element oldParent, Element newParent, List<Patch> patches)
152+
{
153+
var oldChildren = oldParent.Children;
154+
var newChildren = newParent.Children;
155+
int shared = Math.Min(oldChildren.Length, newChildren.Length);
156+
157+
// Diff children that exist in both trees
158+
for (int i = 0; i < shared; i++)
159+
DiffInternal(oldChildren[i], newChildren[i], oldParent, patches);
160+
161+
// Remove extra old children
162+
for (int i = shared; i < oldChildren.Length; i++)
163+
{
164+
if (oldChildren[i] is Element oldChild)
165+
patches.Add(new RemoveChild(oldParent, oldChild));
166+
}
167+
168+
// Add additional new children
169+
for (int i = shared; i < newChildren.Length; i++)
170+
{
171+
if (newChildren[i] is Element newChild)
172+
patches.Add(new AddChild(newParent, newChild));
173+
}
174+
}
175+
}
176+
}

Abies/Types.cs

Lines changed: 1 addition & 171 deletions
Original file line numberDiff line numberDiff line change
@@ -459,175 +459,5 @@ private static void RenderNode(Node node, System.Text.StringBuilder sb)
459459
}
460460
}
461461

462-
public static class Operations
463-
{
464-
public static async Task Apply(Patch patch)
465-
{
466-
switch (patch)
467-
{
468-
case AddRoot addRoot:
469-
await Interop.SetAppContent(Render.Html(addRoot.Element));
470-
break;
471-
case ReplaceChild replaceChild:
472-
await Interop.ReplaceChildHtml(replaceChild.OldElement.Id, Render.Html(replaceChild.NewElement));
473-
break;
474-
case AddChild addChild:
475-
await Interop.AddChildHtml(addChild.Parent.Id, Render.Html(addChild.Child));
476-
break;
477-
case RemoveChild removeChild:
478-
await Interop.RemoveChild(removeChild.Parent.Id, removeChild.Child.Id);
479-
break;
480-
case UpdateAttribute updateAttribute:
481-
await Interop.UpdateAttribute(updateAttribute.Element.Id, updateAttribute.Attribute.Name, updateAttribute.Value);
482-
break;
483-
case AddAttribute addAttribute:
484-
await Interop.AddAttribute(addAttribute.Element.Id, addAttribute.Attribute.Name, addAttribute.Attribute.Value);
485-
break;
486-
case RemoveAttribute removeAttribute:
487-
await Interop.RemoveAttribute(removeAttribute.Element.Id, removeAttribute.Attribute.Name);
488-
break;
489-
case AddHandler addHandler:
490-
await Interop.AddAttribute(addHandler.Element.Id, addHandler.Handler.Name, addHandler.Handler.Value);
491-
break;
492-
case RemoveHandler removeHandler:
493-
await Interop.RemoveAttribute(removeHandler.Element.Id, removeHandler.Handler.Name);
494-
break;
495-
case UpdateText updateText:
496-
await Interop.UpdateTextContent(updateText.Node.Id, updateText.Text);
497-
break;
498-
default:
499-
throw new Exception("Unknown patch type");
500-
}
501-
}
502-
503-
public static List<Patch> Diff(Node oldNode, Node newNode)
504-
{
505-
if (oldNode is null)
506-
{
507-
// Generate patches to create the newNode from scratch
508-
return [new AddRoot((Element)newNode)];
509-
}
510-
511-
List<Patch> patches;
512-
if (oldNode is Text oldText && newNode is Text newText)
513-
{
514-
patches = oldText.Value == newText.Value
515-
? []
516-
: [new UpdateText(oldText, newText.Value)];
517-
}
518-
else if (oldNode is Element oldElement && newNode is Element newElement && oldElement.Tag != newElement.Tag)
519-
{
520-
patches = [new ReplaceChild(oldElement, newElement)];
521-
}
522-
else if (oldNode is Element o && newNode is Element n)
523-
{
524-
patches = DiffElements(o, n);
525-
}
526-
else
527-
{
528-
throw new Exception("Unknown node type");
529-
}
530-
531-
return patches;
532-
}
533-
534-
private static List<Patch> DiffElements(Element oldElement, Element newElement)
535-
{
536-
var patches = new List<Patch>();
537-
538-
if (oldElement.Tag != newElement.Tag)
539-
{
540-
return new List<Patch> { new ReplaceChild(oldElement, newElement) };
541-
}
542-
543-
// Compare Attributes using Span<T>
544-
var oldAttributes = oldElement.Attributes.AsSpan();
545-
var newAttributes = newElement.Attributes.AsSpan();
546-
547-
for (int i = 0; i < newAttributes.Length; i++)
548-
{
549-
ref var newAttr = ref newAttributes[i];
550-
Attribute? oldAttr = null;
551-
552-
for (int j = 0; j < oldAttributes.Length; j++)
553-
{
554-
if (oldAttributes[j].Id == newAttr.Id)
555-
{
556-
oldAttr = oldAttributes[j];
557-
break;
558-
}
559-
}
560-
561-
if (oldAttr != null)
562-
{
563-
if (!newAttr.Equals(oldAttr))
564-
{
565-
if (newAttr is Handler handler)
566-
{
567-
patches.Add(new RemoveHandler(oldElement, (Handler)oldAttr));
568-
patches.Add(new AddHandler(newElement, handler));
569-
}
570-
else
571-
{
572-
patches.Add(new UpdateAttribute(oldElement, newAttr, newAttr.Value));
573-
}
574-
}
575-
}
576-
else
577-
{
578-
patches.Add(new AddAttribute(oldElement, newAttr));
579-
}
580-
}
581-
582-
for (int i = 0; i < oldAttributes.Length; i++)
583-
{
584-
var oldAttr = oldAttributes[i];
585-
bool existsInNew = false;
586-
587-
for (int j = 0; j < newAttributes.Length; j++)
588-
{
589-
if (newAttributes[j].Id == oldAttr.Id)
590-
{
591-
existsInNew = true;
592-
break;
593-
}
594-
}
595-
596-
if (!existsInNew)
597-
{
598-
patches.Add(new RemoveAttribute(oldElement, oldAttr));
599-
}
600-
}
601-
602-
// Compare Children
603-
var childPatches = DiffChildren(oldElement.Children, newElement.Children);
604-
patches.AddRange(childPatches);
605-
606-
return patches;
607-
}
608-
609-
private static List<Patch> DiffChildren(ReadOnlySpan<Node> children1, ReadOnlySpan<Node> children2)
610-
{
611-
var patches = new List<Patch>();
612-
613-
var length = Math.Max(children1.Length, children2.Length);
614-
for (int i = 0; i < length; i++)
615-
{
616-
if (i < children1.Length && i < children2.Length)
617-
{
618-
patches.AddRange(Diff(children1[i], children2[i]));
619-
}
620-
else if (i < children1.Length)
621-
{
622-
patches.Add(new RemoveChild((Element)children1[i - 1], (Element)children1[i]));
623-
}
624-
else if (i < children2.Length)
625-
{
626-
patches.Add(new AddChild((Element)children2[i - 1], (Element)children2[i]));
627-
}
628-
}
629-
630-
return patches;
631-
}
632-
}
462+
// Operations class moved to a dedicated file for clarity
633463
}

docs/virtual_dom_algorithm.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Virtual DOM Diff and Patch Algorithm
2+
3+
This document outlines the algorithm used by **Abies** to compute updates to the
4+
DOM. The implementation is heavily inspired by the Elm `VirtualDom` package but
5+
adapted for C#.
6+
7+
## Overview
8+
9+
1. **Diffing** – The `Operations.Diff` method walks two virtual DOM trees and
10+
produces a list of `Patch` objects. These describe the minimal changes needed
11+
to transform the old tree into the new tree.
12+
2. **Patching** – Each `Patch` is executed by `Operations.Apply` through
13+
JavaScript interop calls which update the real DOM.
14+
15+
## Diffing Strategy
16+
17+
The diff uses a stack based traversal to avoid recursion overhead. Attributes
18+
are compared using dictionaries so lookups are O(1). Children are processed in
19+
order, removing extra nodes and appending new ones when necessary.
20+
21+
Patches are generated for:
22+
23+
- Replacing nodes when their tag changes.
24+
- Adding or removing child elements.
25+
- Updating, adding or removing attributes and event handlers.
26+
- Updating text node content.
27+
28+
This approach mirrors Elm's efficient diff while remaining idiomatic C#.
29+
30+
## Patching
31+
32+
`Operations.Apply` interprets a patch and forwards the change to the browser via
33+
JavaScript functions. Because patches are small and interop calls are batched,
34+
the runtime stays responsive.
35+

0 commit comments

Comments
 (0)