-
Notifications
You must be signed in to change notification settings - Fork 0
feat: Add Memo<T> node type for skipping diff on unchanged data #51
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -508,4 +508,114 @@ private static Node UpdateElement(Node node, string targetId, Func<Element, Elem | |
| } | ||
| return node; | ||
| } | ||
|
|
||
| #region Memo Tests | ||
|
|
||
| /// <summary> | ||
| /// Test data record for memoization tests. | ||
| /// </summary> | ||
| private record TestRow(int Id, string Label); | ||
|
|
||
| [Fact] | ||
| public void Memo_WithSameData_SkipsDiffing() | ||
| { | ||
| // Create two memo nodes with the same data | ||
| var row = new TestRow(1, "Test"); | ||
| var element = new Element("row-1", "tr", [], new Text("t1", "Test")); | ||
|
|
||
| var oldNode = new Memo<TestRow>(row, element); | ||
| var newNode = new Memo<TestRow>(row, element); | ||
|
|
||
| var patches = Operations.Diff(oldNode, newNode); | ||
|
|
||
| // Should produce no patches because data is equal | ||
| Assert.Empty(patches); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void Memo_WithDifferentData_ProducesDiff() | ||
| { | ||
| // Create two memo nodes with different data | ||
| var oldRow = new TestRow(1, "Old"); | ||
| var newRow = new TestRow(1, "New"); | ||
|
|
||
| var oldElement = new Element("row-1", "tr", [], new Text("t1", "Old")); | ||
| var newElement = new Element("row-1", "tr", [], new Text("t1", "New")); | ||
|
|
||
| var oldNode = new Memo<TestRow>(oldRow, oldElement); | ||
| var newNode = new Memo<TestRow>(newRow, newElement); | ||
|
|
||
| var patches = Operations.Diff(oldNode, newNode); | ||
|
|
||
| // Should produce patches because data changed | ||
| Assert.NotEmpty(patches); | ||
| Assert.Contains(patches, p => p is UpdateText); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void Memo_RendersCorrectHtml() | ||
| { | ||
| var row = new TestRow(1, "Test"); | ||
| var element = new Element("row-1", "tr", [], new Text("t1", "Test")); | ||
| var memoNode = new Memo<TestRow>(row, element); | ||
|
|
||
| var html = Render.Html(memoNode); | ||
|
|
||
| // Memo should render the inner element | ||
| Assert.Contains("row-1", html); | ||
| Assert.Contains("<tr", html); | ||
| Assert.Contains("Test", html); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void Memo_GetKey_ReturnsInnerElementKey() | ||
| { | ||
| var row = new TestRow(1, "Test"); | ||
| var element = new Element("row-1", "tr", | ||
| [new DOMAttribute("k1", "data-key", "custom-key")], | ||
| new Text("t1", "Test")); | ||
| var memoNode = new Memo<TestRow>(row, element); | ||
|
|
||
| // The key should be extracted from the inner element | ||
| // We can't test GetKey directly since it's private, but we can test | ||
| // that diffing works correctly with keyed memo nodes | ||
| var parent = new Element("p1", "tbody", [], memoNode); | ||
| var html = Render.Html(parent); | ||
|
|
||
| Assert.Contains("custom-key", html); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void Memo_ListReorder_SkipsDiffingUnchangedItems() | ||
| { | ||
| // Simulate a list reorder where items don't change, just move | ||
| var rows = new[] | ||
| { | ||
| new TestRow(1, "First"), | ||
| new TestRow(2, "Second"), | ||
| new TestRow(3, "Third") | ||
| }; | ||
|
|
||
| // Create memo-wrapped elements for old tree | ||
| var oldChildren = rows.Select((row, i) => | ||
| (Node)new Memo<TestRow>(row, new Element($"row-{row.Id}", "tr", [], new Text($"t{row.Id}", row.Label))) | ||
| ).ToArray(); | ||
| var oldTree = new Element("tbody", "tbody", [], oldChildren); | ||
|
|
||
| // Reorder: swap first and last | ||
| var reorderedRows = new[] { rows[2], rows[1], rows[0] }; | ||
| var newChildren = reorderedRows.Select((row, i) => | ||
| (Node)new Memo<TestRow>(row, new Element($"row-{row.Id}", "tr", [], new Text($"t{row.Id}", row.Label))) | ||
| ).ToArray(); | ||
| var newTree = new Element("tbody", "tbody", [], newChildren); | ||
|
|
||
| var patches = Operations.Diff(oldTree, newTree); | ||
|
|
||
| // The memo optimization means we should NOT see UpdateText patches | ||
| // because the data is equal and diffing was skipped for the inner content. | ||
| // We may see structural patches (AddChild, RemoveChild) for reordering. | ||
| Assert.DoesNotContain(patches, p => p is UpdateText); | ||
| } | ||
|
Comment on lines
+588
to
+618
|
||
|
|
||
| #endregion | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -280,6 +280,50 @@ public record Text(string Id, string Value) : Node(Id) | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// </summary> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| public record Empty() : Node(""); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// <summary> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// Interface for memoized nodes to enable non-generic data comparison. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// </summary> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| public interface IMemoNode | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// <summary> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// The underlying node that will be rendered. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// </summary> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Node InnerNode { get; } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// <summary> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// Checks if this memo node has the same data as another memo node. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// Returns false if the other node is not a compatible IMemoNode. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// </summary> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| bool HasSameData(IMemoNode other); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+286
to
+297
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// <summary> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// Represents a memoized node in the Abies DOM. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// The node is only re-rendered when the underlying data changes. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// This is similar to React.memo or Elm's lazy. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// </summary> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// <typeparam name="T">The type of the data used to create the node.</typeparam> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// <param name="Data">The data that was used to create the wrapped node.</param> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// <param name="InnerNode">The actual DOM node (will be rendered lazily or reused).</param> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// <remarks> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// When diffing, if two Memo nodes have equal Data (via Equals), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// the inner nodes are assumed to be identical and no diff is performed. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// This dramatically reduces diffing overhead for large lists of stable items. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+302
to
+311
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// The node is only re-rendered when the underlying data changes. | |
| /// This is similar to React.memo or Elm's lazy. | |
| /// </summary> | |
| /// <typeparam name="T">The type of the data used to create the node.</typeparam> | |
| /// <param name="Data">The data that was used to create the wrapped node.</param> | |
| /// <param name="InnerNode">The actual DOM node (will be rendered lazily or reused).</param> | |
| /// <remarks> | |
| /// When diffing, if two Memo nodes have equal Data (via Equals), | |
| /// the inner nodes are assumed to be identical and no diff is performed. | |
| /// This dramatically reduces diffing overhead for large lists of stable items. | |
| /// Memo nodes allow the diff algorithm to skip comparing inner nodes when the underlying data is unchanged. | |
| /// This is conceptually similar to React.memo or Elm's lazy diffing semantics. | |
| /// </summary> | |
| /// <typeparam name="T">The type of the data used to create the node.</typeparam> | |
| /// <param name="Data">The data that was used to create the wrapped node.</param> | |
| /// <param name="InnerNode">The actual DOM node associated with this memoized value.</param> | |
| /// <remarks> | |
| /// When diffing, if two Memo nodes have equal <paramref name="Data"/> (via <see cref="object.Equals(object?, object?)"/>), | |
| /// the inner nodes are assumed to be equivalent and no diff is performed between them. | |
| /// This reduces diffing overhead for large lists of stable items, but does not by itself control when or how | |
| /// <paramref name="InnerNode"/> instances are created. |
Copilot
AI
Feb 9, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
GetKey unwraps a memo node, but DiffChildrenCore still treats Node values as Element/RawHtml/Text when generating Add/Remove patches. If list children are Memo<T> (the expected usage), the reorder/membership paths will generate no structural patches because memo nodes won't match those type checks, leaving the real DOM out of sync. Consider unwrapping memo nodes (recursively) before all child-type switches used for patch creation (remove/add/diff), e.g. via a shared UnwrapMemo(Node) helper.
Copilot
AI
Feb 9, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Memo diff-skipping currently depends only on HasSameData(). If Data is equal but the InnerNode's type/tag/id differs (or for Text/RawHtml, if the id changes), skipping the diff can leave the real DOM inconsistent because no Replace/Update patches will be emitted. To keep correctness, require at least InnerNode.Id equality and the same node shape/type before returning skipDiff=true (otherwise unwrap and continue diffing).
| // Data is equal - skip diffing entirely | |
| return (oldMemo.InnerNode, newMemo.InnerNode, true); | |
| } | |
| // Data is equal - only skip diffing if the inner node identity and shape are also stable. | |
| // This ensures the real DOM remains consistent even when memo data stays the same | |
| // but the underlying node type or Id would otherwise require a Replace/Update patch. | |
| var oldInner = oldMemo.InnerNode; | |
| var newInner = newMemo.InnerNode; | |
| // Id must be equal for safe diff skipping. | |
| if (!string.Equals(oldInner.Id, newInner.Id, StringComparison.Ordinal)) | |
| { | |
| return (oldInner, newInner, false); | |
| } | |
| // Node shape/type must be the same to avoid mismatched DOM structures. | |
| if (oldInner.GetType() != newInner.GetType()) | |
| { | |
| return (oldInner, newInner, false); | |
| } | |
| // Data, Id, and node shape are all equal - safe to skip diffing entirely. | |
| return (oldInner, newInner, true); | |
| } |
Copilot
AI
Feb 9, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
UnwrapMemoNodes only unwraps a single memo layer. If a memo node wraps another memo node (easy to do via the memo(data, Node node) overload), DiffInternal will continue with oldNode/newNode still being memo nodes and then fall through without producing correct patches. Please unwrap memo nodes recursively/iteratively until the underlying non-memo Node is reached.
| // Fast path: neither is a Memo node | |
| if (oldNode is not IMemoNode && newNode is not IMemoNode) | |
| { | |
| return (oldNode, newNode, false); | |
| } | |
| // Both are Memo nodes - check if data is equal | |
| if (oldNode is IMemoNode oldMemo && newNode is IMemoNode newMemo) | |
| { | |
| if (oldMemo.HasSameData(newMemo)) | |
| { | |
| // Data is equal - skip diffing entirely | |
| return (oldMemo.InnerNode, newMemo.InnerNode, true); | |
| } | |
| // Data changed - need to diff the inner nodes | |
| return (oldMemo.InnerNode, newMemo.InnerNode, false); | |
| } | |
| // Only one is Memo - unwrap it and continue diffing | |
| if (oldNode is IMemoNode oldMemoOnly) | |
| { | |
| return (oldMemoOnly.InnerNode, newNode, false); | |
| } | |
| if (newNode is IMemoNode newMemoOnly) | |
| { | |
| return (oldNode, newMemoOnly.InnerNode, false); | |
| } | |
| // Shouldn't reach here, but return unchanged | |
| return (oldNode, newNode, false); | |
| // Iteratively unwrap memo nodes on both sides until we reach the underlying non-memo nodes. | |
| // While unwrapping, preserve the optimization of skipping the diff when both memo nodes | |
| // report equal data via HasSameData. | |
| var currentOld = oldNode; | |
| var currentNew = newNode; | |
| while (true) | |
| { | |
| // Both are Memo nodes - check if data is equal | |
| if (currentOld is IMemoNode oldMemo && currentNew is IMemoNode newMemo) | |
| { | |
| if (oldMemo.HasSameData(newMemo)) | |
| { | |
| // Data is equal - skip diffing entirely, but still return the inner nodes | |
| return (oldMemo.InnerNode, newMemo.InnerNode, true); | |
| } | |
| // Data changed - unwrap one level on both sides and continue | |
| currentOld = oldMemo.InnerNode; | |
| currentNew = newMemo.InnerNode; | |
| continue; | |
| } | |
| // Only the old node is a Memo - unwrap and continue | |
| if (currentOld is IMemoNode oldMemoOnly) | |
| { | |
| currentOld = oldMemoOnly.InnerNode; | |
| continue; | |
| } | |
| // Only the new node is a Memo - unwrap and continue | |
| if (currentNew is IMemoNode newMemoOnly) | |
| { | |
| currentNew = newMemoOnly.InnerNode; | |
| continue; | |
| } | |
| // Neither side is a Memo node anymore - we're fully unwrapped | |
| return (currentOld, currentNew, false); | |
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -517,4 +517,33 @@ public static Node rect(DOM.Attribute[] attributes, [UniqueId(UniqueIdFormat.Htm | |||||||||||||||||||||||||||||
| public static Node raw(string html, [UniqueId(UniqueIdFormat.HtmlId)] string? id = null) | ||||||||||||||||||||||||||||||
| => new RawHtml(id!, html); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| /// <summary> | ||||||||||||||||||||||||||||||
| /// Creates a memoized node that skips diffing when the data hasn't changed. | ||||||||||||||||||||||||||||||
| /// Use this for list items or components that don't change frequently. | ||||||||||||||||||||||||||||||
| /// </summary> | ||||||||||||||||||||||||||||||
| /// <typeparam name="T">The type of the data (must implement proper Equals).</typeparam> | ||||||||||||||||||||||||||||||
| /// <param name="data">The data that determines when to re-render.</param> | ||||||||||||||||||||||||||||||
| /// <param name="render">A function that renders the data to a Node.</param> | ||||||||||||||||||||||||||||||
| /// <returns>A memoized node that only diffs when data changes.</returns> | ||||||||||||||||||||||||||||||
| /// <example> | ||||||||||||||||||||||||||||||
| /// // Without memo: diffs every row on every render | ||||||||||||||||||||||||||||||
| /// model.Rows.Select(row => TableRow(row)) | ||||||||||||||||||||||||||||||
| /// | ||||||||||||||||||||||||||||||
| /// // With memo: only diffs rows whose data changed | ||||||||||||||||||||||||||||||
| /// model.Rows.Select(row => memo(row, r => TableRow(r))) | ||||||||||||||||||||||||||||||
|
Comment on lines
+528
to
+533
|
||||||||||||||||||||||||||||||
| /// <example> | |
| /// // Without memo: diffs every row on every render | |
| /// model.Rows.Select(row => TableRow(row)) | |
| /// | |
| /// // With memo: only diffs rows whose data changed | |
| /// model.Rows.Select(row => memo(row, r => TableRow(r))) | |
| /// <example> | |
| /// <code> | |
| /// // Without memo: diffs every row on every render | |
| /// model.Rows.Select(row => TableRow(row)) | |
| /// | |
| /// // With memo: only diffs rows whose data changed | |
| /// model.Rows.Select(row => memo(row, r => TableRow(r))) | |
| /// </code> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Memo_WithSameData_SkipsDiffingis not a strong regression test for memo diff-skipping: it diffs twoMemoroots and reuses the sameElementinstance, which could yield no patches even without a correct memo implementation. Consider embedding memo nodes as children of a real rootElement(matching production usage) and using distinct but equivalent inner node instances so the test fails if memo unwrapping/skipping is removed or broken.