diff --git a/src/Components/Web.JS/src/Rendering/DomMerging/DomSync.ts b/src/Components/Web.JS/src/Rendering/DomMerging/DomSync.ts
index b86ff3bd8d03..20d318d770b8 100644
--- a/src/Components/Web.JS/src/Rendering/DomMerging/DomSync.ts
+++ b/src/Components/Web.JS/src/Rendering/DomMerging/DomSync.ts
@@ -185,22 +185,21 @@ function treatAsMatch(destination: Node, source: Node) {
break;
}
case Node.ELEMENT_NODE: {
- const editableElementValue = getEditableElementValue(source as Element);
- synchronizeAttributes(destination as Element, source as Element);
- applyAnyDeferredValue(destination as Element);
-
if (isDataPermanentElement(destination as Element)) {
- // The destination element's content should be retained, so we avoid recursing into it.
+ // The destination element's content and attributes should be retained.
} else {
+ const editableElementValue = getEditableElementValue(source as Element);
+ synchronizeAttributes(destination as Element, source as Element);
+ applyAnyDeferredValue(destination as Element);
synchronizeDomContentCore(destination as Element, source as Element);
- }
- // This is a much simpler alternative to the deferred-value-assignment logic we use in interactive rendering.
- // Because this sync algorithm goes depth-first, we know all the attributes and descendants are fully in sync
- // by now, so setting any "special value" property is just a matter of assigning it right now (we don't have
- // to be concerned that it's invalid because it doesn't correspond to an child or a min/max attribute).
- if (editableElementValue !== null) {
- ensureEditableValueSynchronized(destination as Element, editableElementValue);
+ // This is a much simpler alternative to the deferred-value-assignment logic we use in interactive rendering.
+ // Because this sync algorithm goes depth-first, we know all the attributes and descendants are fully in sync
+ // by now, so setting any "special value" property is just a matter of assigning it right now (we don't have
+ // to be concerned that it's invalid because it doesn't correspond to an child or a min/max attribute).
+ if (editableElementValue !== null) {
+ ensureEditableValueSynchronized(destination as Element, editableElementValue);
+ }
}
break;
}
diff --git a/src/Components/Web.JS/test/DomSync.test.ts b/src/Components/Web.JS/test/DomSync.test.ts
index 8d7ca6dbc7af..53e7c4ff89d1 100644
--- a/src/Components/Web.JS/test/DomSync.test.ts
+++ b/src/Components/Web.JS/test/DomSync.test.ts
@@ -581,6 +581,44 @@ describe('DomSync', () => {
expect(newNodes[0].textContent).toBe('');
expect(newNodes[1].textContent).toBe('new content');
});
+
+ test('should preserve attributes on elements marked as data permanent', () => {
+ // Arrange: An element with data-permanent has additional attributes that differ from the new content
+ const destination = makeExistingContent(`preserved
`);
+ const newContent = makeNewContent(`other content
`);
+ const oldNode = toNodeArray(destination)[0] as Element;
+
+ // Act
+ synchronizeDomContent(destination, newContent);
+ const newNode = toNodeArray(destination)[0] as Element;
+
+ // Assert: The element is the same, content is preserved, and attributes are preserved
+ expect(newNode).toBe(oldNode);
+ expect(newNode.textContent).toBe('preserved');
+ expect(newNode.getAttribute('class')).toBe('expand');
+ expect(newNode.getAttribute('id')).toBe('myelem');
+ });
+
+ test('should preserve dynamically added attributes on elements marked as data permanent', () => {
+ // Arrange: Simulates the scenario from the issue where JS mutates an element with data-permanent
+ const destination = makeExistingContent(`
`);
+ const oldNode = toNodeArray(destination)[0] as Element;
+
+ // User adds a class via JS
+ oldNode.classList.add('expand');
+ expect(oldNode.classList.contains('expand')).toBe(true);
+
+ // Enhanced nav returns equivalent content
+ const newContent = makeNewContent(`
`);
+
+ // Act
+ synchronizeDomContent(destination, newContent);
+ const newNode = toNodeArray(destination)[0] as Element;
+
+ // Assert: The expand class should be retained
+ expect(newNode).toBe(oldNode);
+ expect(newNode.classList.contains('expand')).toBe(true);
+ });
});
test('should remove value if neither source nor destination has one', () => {
diff --git a/src/Components/test/E2ETest/ServerRenderingTests/EnhancedNavigationTest.cs b/src/Components/test/E2ETest/ServerRenderingTests/EnhancedNavigationTest.cs
index 5b28c9d299ac..6ee4c45be07d 100644
--- a/src/Components/test/E2ETest/ServerRenderingTests/EnhancedNavigationTest.cs
+++ b/src/Components/test/E2ETest/ServerRenderingTests/EnhancedNavigationTest.cs
@@ -499,6 +499,39 @@ public void ElementsWithoutDataPermanentAttribute_DoNotHavePreservedContent()
Browser.Equal("", () => Browser.Exists(By.Id("non-preserved-content")).Text);
}
+ [Fact]
+ public void ElementsWithDataPermanentAttribute_HavePreservedAttributes()
+ {
+ Navigate($"{ServerPathBase}/nav");
+ Browser.Equal("Hello", () => Browser.Exists(By.TagName("h1")).Text);
+
+ Browser.Exists(By.TagName("nav")).FindElement(By.LinkText("Preserve content")).Click();
+ Browser.Equal("Page that preserves content", () => Browser.Exists(By.TagName("h1")).Text);
+
+ // Required until https://github.com/dotnet/aspnetcore/issues/50424 is fixed
+ Browser.Navigate().Refresh();
+
+ Browser.Exists(By.Id("refresh-with-refresh"));
+
+ Browser.Click(By.Id("start-listening"));
+
+ // Verify the dynamically added class exists before enhanced nav
+ var preservedAttributesElement = Browser.Exists(By.Id("preserved-attributes"));
+ Browser.True(() => preservedAttributesElement.GetAttribute("class")?.Contains("dynamically-added-class") == true);
+
+ Browser.Click(By.Id("refresh-with-refresh"));
+ AssertEnhancedUpdateCountEquals(1);
+
+ // Verify the dynamically added class is preserved after enhanced nav
+ Browser.True(() => preservedAttributesElement.GetAttribute("class")?.Contains("dynamically-added-class") == true);
+
+ Browser.Click(By.Id("refresh-with-refresh"));
+ AssertEnhancedUpdateCountEquals(2);
+
+ // Verify the dynamically added class is still preserved after another enhanced nav
+ Browser.True(() => preservedAttributesElement.GetAttribute("class")?.Contains("dynamically-added-class") == true);
+ }
+
[Fact]
public void EnhancedNavNotUsedForNonBlazorDestinations()
{
diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/EnhancedNav/PageThatPreservesContent.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/EnhancedNav/PageThatPreservesContent.razor
index c32b4746ef2f..569123dabdef 100644
--- a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/EnhancedNav/PageThatPreservesContent.razor
+++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/EnhancedNav/PageThatPreservesContent.razor
@@ -5,6 +5,7 @@
+
@@ -18,9 +19,11 @@
//# sourceURL=preserve-content.js
const preservedContent = document.getElementById('preserved-content');
const nonPreservedContent = document.getElementById('non-preserved-content');
+ const preservedAttributes = document.getElementById('preserved-attributes');
preservedContent.textContent = 'Preserved content';
nonPreservedContent.textContent = 'Non preserved content';
+ preservedAttributes.classList.add('dynamically-added-class');
const onEnhancedLoad = (ev) => {
window.enhancedPageUpdateCount++;