Skip to content
Open
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
23 changes: 11 additions & 12 deletions src/Components/Web.JS/src/Rendering/DomMerging/DomSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <option> 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 <option> child or a min/max attribute).
if (editableElementValue !== null) {
ensureEditableValueSynchronized(destination as Element, editableElementValue);
}
}
break;
}
Expand Down
38 changes: 38 additions & 0 deletions src/Components/Web.JS/test/DomSync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(`<div id="myelem" class="expand" data-permanent>preserved</div>`);
const newContent = makeNewContent(`<div id="myelem" data-permanent>other content</div>`);
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(`<div id="myelem" data-permanent></div>`);
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(`<div id="myelem" data-permanent></div>`);

// 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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

<div id="preserved-content" data-permanent></div>
<div id="non-preserved-content"></div>
<div id="preserved-attributes" data-permanent></div>

<InteractiveNavigationComponent @rendermode="@_interactiveNavigationRenderMode" />

Expand All @@ -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++;
Expand Down
Loading