Skip to content
Merged
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
6 changes: 5 additions & 1 deletion src/Components/Web.JS/src/Rendering/DomMerging/DomSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ export function attachComponentDescriptorHandler(handler: DescriptorHandler) {
}

export function registerAllComponentDescriptors(root: Node) {
const descriptors = upgradeComponentCommentsToLogicalRootComments(root);
const webAssemblyOptions = discoverWebAssemblyOptions(root);
if (webAssemblyOptions) { descriptorHandler?.setWebAssemblyOptions(webAssemblyOptions); }
const descriptors = upgradeComponentCommentsToLogicalRootComments(root);

for (const descriptor of descriptors) {
descriptorHandler?.registerComponent(descriptor);
Expand All @@ -33,6 +33,10 @@ export function registerAllComponentDescriptors(root: Node) {
export { preprocessAndSynchronizeDomContent as synchronizeDomContent };

function preprocessAndSynchronizeDomContent(destination: CommentBoundedRange | Node, newContent: Node) {
// Strip any WebAssembly metadata comments from the new content before building
// the logical tree, so they don't end up as orphaned nodes in the logical children array
Comment thread
maraf marked this conversation as resolved.
discoverWebAssemblyOptions(newContent);

// Start by recursively identifying component markers in the new content
// and converting them into logical elements so they correctly participate
// in logical element synchronization
Expand Down
16 changes: 15 additions & 1 deletion src/Components/Web.JS/src/Rendering/LogicalElements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,8 +180,22 @@ export function insertLogicalChild(child: Node, parent: LogicalElement, childInd
}

const newSiblings = getLogicalChildrenArray(parent);

// Remove any orphaned nodes (disconnected from DOM) from the siblings array
// and adjust the insertion index accordingly. These can occur when metadata
// comments are stripped from the DOM but remain in the logical children array.
for (let i = newSiblings.length - 1; i >= 0; i--) {
const sibling = newSiblings[i] as any as Node;
if (!sibling.parentNode) {
newSiblings.splice(i, 1);
if (i < childIndex) {
childIndex--;
}
}
}

if (childIndex < newSiblings.length) {
// Insert
// Insert - nextSibling is guaranteed to be connected after orphan cleanup
const nextSibling = newSiblings[childIndex] as any as Node;
nextSibling.parentNode!.insertBefore(nodeToInsert, nextSibling);
newSiblings.splice(childIndex, 0, childAsLogicalElement);
Expand Down
168 changes: 168 additions & 0 deletions src/Components/Web.JS/test/LogicalElements.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import { expect, test, describe } from '@jest/globals';
import {
toLogicalElement,
insertLogicalChild,
getLogicalChildrenArray,
getLogicalChild,
LogicalElement,
} from '../src/Rendering/LogicalElements';

describe('insertLogicalChild', () => {
test('should insert child at the correct position when no orphans exist', () => {
const parent = document.createElement('div');
const existingChild = document.createElement('span');
parent.appendChild(existingChild);
const logicalParent = toLogicalElement(parent, true);

const newChild = document.createElement('p');
insertLogicalChild(newChild, logicalParent, 0);

// New child should be inserted before the existing span in the DOM
expect(parent.childNodes[0]).toBe(newChild);
expect(parent.childNodes[1]).toBe(existingChild);

// Logical children should reflect the correct order
const children = getLogicalChildrenArray(logicalParent);
expect(children.length).toBe(2);
expect(children[0] as any as Node).toBe(newChild);
expect(children[1] as any as Node).toBe(existingChild);
});

test('should remove orphaned sibling at the reference position and insert in correct DOM order', () => {
// Set up: parent with [orphanComment, connectedSpan] in logical children
const parent = document.createElement('div');
const orphanComment = document.createComment('orphan');
const connectedSpan = document.createElement('span');

// Add both to the DOM initially so toLogicalElement picks them up
parent.appendChild(orphanComment);
parent.appendChild(connectedSpan);
const logicalParent = toLogicalElement(parent, true);

// Now orphan the comment by removing it from the DOM (simulates discoverWebAssemblyOptions)
parent.removeChild(orphanComment);

// Insert a new element at index 0 — the orphan is at position 0
const newChild = document.createElement('p');
insertLogicalChild(newChild, logicalParent, 0);

// The orphan should have been cleaned up from logical children
const children = getLogicalChildrenArray(logicalParent);
expect(children).not.toContain(orphanComment as any);

// New child should be before the connected span in the DOM
expect(parent.childNodes[0]).toBe(newChild);
expect(parent.childNodes[1]).toBe(connectedSpan);

// Logical children: [newChild, connectedSpan]
expect(children.length).toBe(2);
expect(children[0] as any as Node).toBe(newChild);
expect(children[1] as any as Node).toBe(connectedSpan);
});

test('should adjust insertion index when orphans precede the target position', () => {
// Set up: parent with [orphan1, orphan2, spanA, spanB] in logical children
const parent = document.createElement('div');
const orphan1 = document.createComment('orphan1');
const orphan2 = document.createComment('orphan2');
const spanA = document.createElement('span');
const spanB = document.createElement('em');

parent.appendChild(orphan1);
parent.appendChild(orphan2);
parent.appendChild(spanA);
parent.appendChild(spanB);
const logicalParent = toLogicalElement(parent, true);

// Orphan the first two comments
parent.removeChild(orphan1);
parent.removeChild(orphan2);

// Insert at logical index 2 (which, after cleanup of 2 orphans, becomes index 0)
// This should insert BEFORE spanA
const newChild = document.createElement('p');
insertLogicalChild(newChild, logicalParent, 2);

const children = getLogicalChildrenArray(logicalParent);

// Orphans should be removed
expect(children).not.toContain(orphan1 as any);
expect(children).not.toContain(orphan2 as any);

// Logical children: [newChild, spanA, spanB]
expect(children.length).toBe(3);
expect(children[0] as any as Node).toBe(newChild);
expect(children[1] as any as Node).toBe(spanA);
expect(children[2] as any as Node).toBe(spanB);

// DOM order should match
expect(parent.childNodes[0]).toBe(newChild);
expect(parent.childNodes[1]).toBe(spanA);
expect(parent.childNodes[2]).toBe(spanB);
});

test('should append when all siblings after orphan cleanup are before insertion point', () => {
// Set up: parent with [connectedSpan, orphanComment] in logical children
const parent = document.createElement('div');
const connectedSpan = document.createElement('span');
const orphanComment = document.createComment('orphan');

parent.appendChild(connectedSpan);
parent.appendChild(orphanComment);
const logicalParent = toLogicalElement(parent, true);

// Orphan the comment
parent.removeChild(orphanComment);

// Insert at index 1 — after cleanup, there's only 1 sibling so index 1 means append
const newChild = document.createElement('p');
insertLogicalChild(newChild, logicalParent, 1);

const children = getLogicalChildrenArray(logicalParent);
expect(children).not.toContain(orphanComment as any);

// Logical children: [connectedSpan, newChild]
expect(children.length).toBe(2);
expect(children[0] as any as Node).toBe(connectedSpan);
expect(children[1] as any as Node).toBe(newChild);

// DOM order
expect(parent.childNodes[0]).toBe(connectedSpan);
expect(parent.childNodes[1]).toBe(newChild);
});

test('should handle multiple orphans interspersed with connected nodes', () => {
const parent = document.createElement('div');
const orphan1 = document.createComment('orphan1');
const spanA = document.createElement('span');
const orphan2 = document.createComment('orphan2');
const spanB = document.createElement('em');

parent.appendChild(orphan1);
parent.appendChild(spanA);
parent.appendChild(orphan2);
parent.appendChild(spanB);
const logicalParent = toLogicalElement(parent, true);

// Orphan both comments
parent.removeChild(orphan1);
parent.removeChild(orphan2);

// Insert at index 1 (between orphan1 and spanA originally).
// After cleanup of orphan1 (index 0, before target) → index becomes 0.
// orphan2 at original index 2 is also removed but doesn't affect adjusted index.
// So we insert at index 0 before spanA.
const newChild = document.createElement('p');
insertLogicalChild(newChild, logicalParent, 1);

const children = getLogicalChildrenArray(logicalParent);
expect(children.length).toBe(3);
expect(children[0] as any as Node).toBe(newChild);
expect(children[1] as any as Node).toBe(spanA);
expect(children[2] as any as Node).toBe(spanB);

expect(parent.childNodes[0]).toBe(newChild);
expect(parent.childNodes[1]).toBe(spanA);
expect(parent.childNodes[2]).toBe(spanB);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -917,6 +917,44 @@ public void EnhancedNavigationScrollBehavesSameAsBrowserOnBackwardsForwardsActio
AssertScrollPositionCorrect(useEnhancedNavigation, landingPagePos2);
}

[Fact]
public void CanNavigateBetweenPagesWhenLayoutContainsInteractiveComponent()
{
// Regression test for https://github.com/dotnet/aspnetcore/issues/64722
// When an interactive component (InteractiveAuto) is placed in the layout,
// navigating between pages should not cause a JS error from orphaned
// metadata comments in the logical children array.
Navigate($"{ServerPathBase}/nav/interactive-in-layout");

var h1Elem = Browser.Exists(By.Id("interactive-layout-home"));
Browser.Equal("Hello from interactive layout", () => h1Elem.Text);

// Use a layout element for staleness checks since it persists across pages
var layoutCounter = Browser.Exists(By.Id("layout-counter"));

// Navigate to the other page and verify enhanced navigation was used (DOM preserved)
Browser.Exists(By.TagName("nav")).FindElement(By.LinkText("Other (interactive layout)")).Click();
Browser.Equal("Other page with interactive layout", () => Browser.Exists(By.Id("interactive-layout-other")).Text);
Assert.False(layoutCounter.IsStale(), "Enhanced navigation should have been used for first navigation");

// Navigate back to home - this is the step that triggered the crash
Browser.Exists(By.TagName("nav")).FindElement(By.LinkText("Home (interactive layout)")).Click();
Browser.Equal("Hello from interactive layout", () => Browser.Exists(By.Id("interactive-layout-home")).Text);
Assert.False(layoutCounter.IsStale(), "Enhanced navigation should have been used for second navigation");

// Verify the interactive layout component is still functional by clicking the counter.
// Wait for interactivity to be established first (InteractiveAuto may start as SSR/Server
// and become interactive only after the circuit or WebAssembly runtime has connected).
Browser.Equal("True", () => Browser.Exists(By.Id("layout-counter-interactive")).Text);
Browser.Equal("0", () => Browser.Exists(By.Id("layout-counter-value")).Text);
Browser.Exists(By.Id("layout-counter-button")).Click();
Browser.Equal("1", () => Browser.Exists(By.Id("layout-counter-value")).Text);

// Verify no JavaScript errors occurred during enhanced navigation
var logs = Browser.GetBrowserLogs(LogLevel.Warning);
Assert.DoesNotContain(logs, log => log.Message.Contains("Error"));
}

private void AssertScrollPositionCorrect(bool useEnhancedNavigation, long previousScrollPosition)
{
// from some reason, scroll position sometimes differs by 1 pixel between enhanced and browser's navigation
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
@page "/nav/interactive-in-layout"
@layout Components.TestServer.RazorComponents.Shared.EnhancedNavWithInteractiveInLayout

<PageTitle>Interactive In Layout Home</PageTitle>

<h1 id="interactive-layout-home">Hello from interactive layout</h1>
<p>This page tests that enhanced navigation works when the layout contains an interactive component.</p>
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
@page "/nav/interactive-in-layout/other"
@layout Components.TestServer.RazorComponents.Shared.EnhancedNavWithInteractiveInLayout

<PageTitle>Interactive In Layout Other</PageTitle>

<h1 id="interactive-layout-other">Other page with interactive layout</h1>
<p>Navigating back from here to the home page should not cause a JavaScript error.</p>
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
@inherits LayoutComponentBase
@using TestContentPackage

<nav>
<NavLink href="nav/interactive-in-layout">Home (interactive layout)</NavLink> |
<NavLink href="nav/interactive-in-layout/other">Other (interactive layout)</NavLink>
</nav>
<hr />
<main>
@Body
<InteractiveCounterInLayout />
</main>
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
@rendermode RenderMode.InteractiveAuto

<p id="layout-counter">
Layout counter: <span id="layout-counter-value">@currentCount</span>
</p>
<p>Interactive: <span id="layout-counter-interactive">@_isInteractive</span></p>
<button id="layout-counter-button" class="btn btn-primary" @onclick="IncrementCount">Increment layout counter</button>

@code {
private int currentCount = 0;
private bool _isInteractive;

private void IncrementCount()
{
currentCount++;
}

protected override void OnAfterRender(bool firstRender)
{
if (firstRender)
{
_isInteractive = true;
StateHasChanged();
}
}
}
Loading