diff --git a/src/Components/Components/src/Reflection/ComponentProperties.cs b/src/Components/Components/src/Reflection/ComponentProperties.cs index c5a49dfa3f80..31419db9d843 100644 --- a/src/Components/Components/src/Reflection/ComponentProperties.cs +++ b/src/Components/Components/src/Reflection/ComponentProperties.cs @@ -98,9 +98,15 @@ public static void SetProperties(in ParameterView parameters, object target) { // Logic with components with a CaptureUnmatchedValues parameter var isCaptureUnmatchedValuesParameterSetExplicitly = false; + var parentSuppliedDirectParameters = false; Dictionary? unmatched = null; foreach (var parameter in parameters) { + if (!parameter.Cascading) + { + parentSuppliedDirectParameters = true; + } + var parameterName = parameter.Name; if (string.Equals(parameterName, writers.CaptureUnmatchedValuesPropertyName, StringComparison.OrdinalIgnoreCase)) { @@ -167,6 +173,10 @@ public static void SetProperties(in ParameterView parameters, object target) // We had some unmatched values, set the CaptureUnmatchedValues property SetProperty(target, writers.CaptureUnmatchedValuesWriter, writers.CaptureUnmatchedValuesPropertyName!, unmatched); } + else if (parentSuppliedDirectParameters && !isCaptureUnmatchedValuesParameterSetExplicitly) + { + SetProperty(target, writers.CaptureUnmatchedValuesWriter, writers.CaptureUnmatchedValuesPropertyName!, (object)null!); + } } static void SetProperty(object target, PropertySetter writer, string parameterName, object value) diff --git a/src/Components/Components/test/ParameterViewTest.Assignment.cs b/src/Components/Components/test/ParameterViewTest.Assignment.cs index 827f0e4694ae..a5015d1abe21 100644 --- a/src/Components/Components/test/ParameterViewTest.Assignment.cs +++ b/src/Components/Components/test/ParameterViewTest.Assignment.cs @@ -365,6 +365,48 @@ public void SettingCaptureUnmatchedValuesParameterWithUnmatchedValuesWorks() }); } + [Fact] + public void CaptureUnmatchedValues_IsResetToNull_WhenNoUnmatchedValuesAreSupplied() + { + // Regression for issue #56463: a render that supplies only direct parameters (no unmatched) + // must reset the previously-captured CaptureUnmatchedValues to null so the renderer emits + // RemoveAttribute edits for the omitted attributes. + var target = new HasCaptureUnmatchedValuesProperty + { + CaptureUnmatchedValues = new Dictionary { { "old", "value" } } + }; + var parameters = new ParameterViewBuilder + { + { nameof(HasCaptureUnmatchedValuesProperty.StringProp), "hi" }, + }.Build(); + + parameters.SetParameterProperties(target); + + Assert.Equal("hi", target.StringProp); + Assert.Null(target.CaptureUnmatchedValues); + } + + [Fact] + public void CaptureUnmatchedValues_IsPreserved_WhenOnlyCascadingParametersAreSupplied() + { + // Regression for: when a re-render supplies only cascading values (e.g. a parent cascading + // value that did not change) and no direct parameters, we must not clear previously-captured + // unmatched attributes, because the parent did not actually stop supplying them. + var target = new HasCaptureUnmatchedValuesPropertyAndCascadingParameter + { + CaptureUnmatchedValues = new Dictionary { { "class", "kept" } } + }; + var builder = new ParameterViewBuilder(); + builder.Add(nameof(HasCaptureUnmatchedValuesPropertyAndCascadingParameter.Cascading), "hi", cascading: true); + var parameters = builder.Build(); + + parameters.SetParameterProperties(target); + + Assert.Equal("hi", target.Cascading); + Assert.NotNull(target.CaptureUnmatchedValues); + Assert.Equal("kept", target.CaptureUnmatchedValues["class"]); + } + [Fact] public void SettingCaptureUnmatchedValuesParameterExplicitlyAndImplicitly_Throws() { diff --git a/src/Components/Components/test/RendererTest.cs b/src/Components/Components/test/RendererTest.cs index f180013fa5a9..8e5c177a2a3f 100644 --- a/src/Components/Components/test/RendererTest.cs +++ b/src/Components/Components/test/RendererTest.cs @@ -2213,6 +2213,280 @@ public void ReRendersDoesNotReRenderChildComponentWhenUnmatchedValuesDoNotChange Assert.False(renderer.Batches[1].DiffsByComponentId.ContainsKey(childComponentId)); } + [Fact] + public void RemovesSplatAttributeFromChildElementWhenOmittedOnSubsequentRender() + { + var renderer = new TestRenderer(); + var includeAttribute = true; + var component = new TestComponent(builder => + { + builder.OpenComponent(1); + builder.AddComponentParameter(2, nameof(MyStrongComponent.Text), "hi there."); + if (includeAttribute) + { + builder.AddAttribute(10, "class", "example-class"); + } + builder.CloseComponent(); + }); + + renderer.AssignRootComponentId(component); + component.TriggerRender(); + + var childComponentId = renderer.Batches.Single() + .ReferenceFrames + .Single(frame => frame.FrameType == RenderTreeFrameType.Component) + .ComponentId; + + includeAttribute = false; + component.TriggerRender(); + + var childDiff = renderer.Batches[1].DiffsByComponentId[childComponentId].Single(); + Assert.Contains(childDiff.Edits, edit => + edit.Type == RenderTreeEditType.RemoveAttribute && edit.RemovedAttributeName == "class"); + } + + [Fact] + public void RemovesOnlyOmittedUnmatchedAttributesFromChildElement() + { + var renderer = new TestRenderer(); + var includeSecondAttribute = true; + var component = new TestComponent(builder => + { + builder.OpenComponent(1); + builder.AddComponentParameter(2, nameof(MyStrongComponent.Text), "hi there."); + builder.AddAttribute(10, "data-test-1", "value1"); + if (includeSecondAttribute) + { + builder.AddAttribute(11, "data-test-2", "value2"); + } + builder.CloseComponent(); + }); + + renderer.AssignRootComponentId(component); + component.TriggerRender(); + + var childComponentId = renderer.Batches.Single() + .ReferenceFrames + .Single(frame => frame.FrameType == RenderTreeFrameType.Component) + .ComponentId; + + includeSecondAttribute = false; + component.TriggerRender(); + + var childDiff = renderer.Batches[1].DiffsByComponentId[childComponentId].Single(); + Assert.Contains(childDiff.Edits, edit => + edit.Type == RenderTreeEditType.RemoveAttribute && edit.RemovedAttributeName == "data-test-2"); + Assert.DoesNotContain(childDiff.Edits, edit => + edit.Type == RenderTreeEditType.RemoveAttribute && edit.RemovedAttributeName == "data-test-1"); + } + + [Fact] + public void RemovesBooleanUnmatchedAttributeFromChildElementWhenOmittedOnSubsequentRender() + { + var renderer = new TestRenderer(); + var includeDisabled = true; + var component = new TestComponent(builder => + { + builder.OpenComponent(1); + builder.AddComponentParameter(2, nameof(MyStrongComponent.Text), "hi there."); + if (includeDisabled) + { + builder.AddAttribute(10, "disabled", ""); + } + builder.CloseComponent(); + }); + + renderer.AssignRootComponentId(component); + component.TriggerRender(); + + var childComponentId = renderer.Batches.Single() + .ReferenceFrames + .Single(frame => frame.FrameType == RenderTreeFrameType.Component) + .ComponentId; + + includeDisabled = false; + component.TriggerRender(); + + var childDiff = renderer.Batches[1].DiffsByComponentId[childComponentId].Single(); + Assert.Contains(childDiff.Edits, edit => + edit.Type == RenderTreeEditType.RemoveAttribute && edit.RemovedAttributeName == "disabled"); + } + + [Fact] + public void RemovesMultipleUnmatchedAttributesFromChildElementWhenAllOmittedOnSubsequentRender() + { + // Tests removing more than one splat attribute at the same time, e.g. when a Razor + // block is gated behind a debug/feature flag and produces two or more attributes. + var renderer = new TestRenderer(); + var includeBlock = true; + var component = new TestComponent(builder => + { + builder.OpenComponent(1); + builder.AddComponentParameter(2, nameof(MyStrongComponent.Text), "hi there."); + if (includeBlock) + { + builder.AddAttribute(10, "data-test-1", "value1"); + builder.AddAttribute(11, "data-test-2", "value2"); + builder.AddAttribute(12, "data-test-3", "value3"); + } + builder.CloseComponent(); + }); + + renderer.AssignRootComponentId(component); + component.TriggerRender(); + + var childComponentId = renderer.Batches.Single() + .ReferenceFrames + .Single(frame => frame.FrameType == RenderTreeFrameType.Component) + .ComponentId; + + includeBlock = false; + component.TriggerRender(); + + var childDiff = renderer.Batches[1].DiffsByComponentId[childComponentId].Single(); + Assert.Contains(childDiff.Edits, edit => + edit.Type == RenderTreeEditType.RemoveAttribute && edit.RemovedAttributeName == "data-test-1"); + Assert.Contains(childDiff.Edits, edit => + edit.Type == RenderTreeEditType.RemoveAttribute && edit.RemovedAttributeName == "data-test-2"); + Assert.Contains(childDiff.Edits, edit => + edit.Type == RenderTreeEditType.RemoveAttribute && edit.RemovedAttributeName == "data-test-3"); + } + + [Fact] + public void RemovesUnmatchedAttributesAcrossMultipleChildComponentsInTheSameRender() + { + var renderer = new TestRenderer(); + var includeAttrOnFirst = true; + var includeAttrOnSecond = true; + var component = new TestComponent(builder => + { + builder.OpenComponent(1); + builder.AddComponentParameter(2, nameof(MyStrongComponent.Text), "first"); + if (includeAttrOnFirst) + { + builder.AddAttribute(10, "data-x", "first-x"); + } + builder.CloseComponent(); + + builder.OpenComponent(20); + builder.AddComponentParameter(21, nameof(MyStrongComponent.Text), "second"); + if (includeAttrOnSecond) + { + builder.AddAttribute(30, "data-y", "second-y"); + } + builder.CloseComponent(); + }); + + renderer.AssignRootComponentId(component); + component.TriggerRender(); + + var childComponentIds = renderer.Batches.Single() + .ReferenceFrames + .Where(frame => frame.FrameType == RenderTreeFrameType.Component) + .Select(frame => frame.ComponentId) + .ToList(); + Assert.Equal(2, childComponentIds.Count); + + includeAttrOnFirst = false; + includeAttrOnSecond = false; + component.TriggerRender(); + + var firstChildDiff = renderer.Batches[1].DiffsByComponentId[childComponentIds[0]].Single(); + var secondChildDiff = renderer.Batches[1].DiffsByComponentId[childComponentIds[1]].Single(); + + Assert.Contains(firstChildDiff.Edits, edit => + edit.Type == RenderTreeEditType.RemoveAttribute && edit.RemovedAttributeName == "data-x"); + Assert.Contains(secondChildDiff.Edits, edit => + edit.Type == RenderTreeEditType.RemoveAttribute && edit.RemovedAttributeName == "data-y"); + } + + [Fact] + public void RemovesUnmatchedAttribute_AcrossRapidAddRemoveCycles() + { + var renderer = new TestRenderer(); + var includeAttribute = true; + var renderCount = 0; + var component = new TestComponent(builder => + { + renderCount++; + builder.OpenComponent(1); + builder.AddComponentParameter(2, nameof(MyStrongComponent.Text), "hi there."); + if (includeAttribute) + { + builder.AddAttribute(10, "data-toggle", "present"); + } + builder.CloseComponent(); + }); + + renderer.AssignRootComponentId(component); + component.TriggerRender(); + + var childComponentId = renderer.Batches.Single() + .ReferenceFrames + .Single(frame => frame.FrameType == RenderTreeFrameType.Component) + .ComponentId; + + includeAttribute = false; + component.TriggerRender(); + + includeAttribute = true; + component.TriggerRender(); + + includeAttribute = false; + component.TriggerRender(); + + var lastBatchIndex = renderer.Batches.Count - 1; + var lastChildDiff = renderer.Batches[lastBatchIndex].DiffsByComponentId[childComponentId].Single(); + Assert.Contains(lastChildDiff.Edits, edit => + edit.Type == RenderTreeEditType.RemoveAttribute && edit.RemovedAttributeName == "data-toggle"); + } + + [Fact] + public void RemovesUnmatchedAttributeFromParentAndChildIndependentlyInNestedHierarchy() + { + + var renderer = new TestRenderer(); + var includeParentAttr = true; + var includeChildAttr = true; + var component = new TestComponent(builder => + { + builder.OpenComponent(1); + builder.AddComponentParameter(2, nameof(MyStrongContainerComponent.ChildText), "hi"); + if (includeParentAttr) + { + builder.AddAttribute(3, "data-parent", "parent-value"); + } + if (includeChildAttr) + { + builder.AddAttribute(4, "data-child", "child-value"); + } + builder.CloseComponent(); + }); + + renderer.AssignRootComponentId(component); + component.TriggerRender(); + + // Sanity: two component instances were created in the first batch. + var componentIds = renderer.Batches.Single() + .ReferenceFrames + .Where(frame => frame.FrameType == RenderTreeFrameType.Component) + .Select(frame => frame.ComponentId) + .Distinct() + .ToList(); + Assert.Equal(2, componentIds.Count); + + includeParentAttr = false; + includeChildAttr = false; + component.TriggerRender(); + var allEdits = renderer.Batches[1].DiffsInOrder.SelectMany(d => d.Edits).ToList(); + + Assert.Contains(allEdits, edit => + edit.Type == RenderTreeEditType.RemoveAttribute && edit.RemovedAttributeName == "data-parent"); + + Assert.Contains(allEdits, edit => + edit.Type == RenderTreeEditType.RemoveAttribute && edit.RemovedAttributeName == "data-child"); + } + [Fact] public void RenderBatchIncludesListOfDisposedComponents() { @@ -5488,6 +5762,26 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) } } + private class MyStrongContainerComponent : AutoRenderComponent + { + [Parameter(CaptureUnmatchedValues = true)] public IDictionary Attributes { get; set; } + + [Parameter] public string ChildText { get; set; } + + [Parameter] public IDictionary ChildAttributes { get; set; } + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + builder.OpenElement(0, "div"); + builder.AddMultipleAttributes(1, Attributes); + builder.OpenComponent(10); + builder.AddComponentParameter(11, nameof(MyStrongComponent.Text), ChildText); + builder.AddMultipleAttributes(12, ChildAttributes); + builder.CloseComponent(); + builder.CloseElement(); + } + } + private class FakeComponent : IComponent { [Parameter] diff --git a/src/Components/Web/test/Forms/InputTextTest.cs b/src/Components/Web/test/Forms/InputTextTest.cs index 695ab5da3180..9af09bee5843 100644 --- a/src/Components/Web/test/Forms/InputTextTest.cs +++ b/src/Components/Web/test/Forms/InputTextTest.cs @@ -82,6 +82,46 @@ public async Task RendersIdAttribute_WhenShouldUseFieldIdentifiersIsFalse_Intera var idAttribute = frames.Array.Single(f => f.FrameType == RenderTreeFrameType.Attribute && f.AttributeName == "id"); Assert.Equal("model_StringProperty", idAttribute.AttributeValue); } + [Fact] + public async Task InputText_RemovesAttributeFromChildren_WhenOmittedOnSubsequentRender() + { + // Regression for issue #56463: when the real InputText component receives a splat attribute + // (e.g. via AdditionalAttributes) on one render and the parent omits it on the next, the + // diff for the InputText's element must contain a RemoveAttribute edit. Without the + // fix in ComponentProperties.SetProperties, no RemoveAttribute edit is generated, leaving + // the stale attribute on the DOM. + var model = new TestModel(); + var hostComponent = new TestInputConditionalAttributeHostComponent + { + EditContext = new EditContext(model), + ValueExpression = () => model.StringProperty, + IncludeAttribute = true, + AttributeName = "data-test-id", + AttributeValue = "example-id", + }; + + var hostComponentId = _testRenderer.AssignRootComponentId(hostComponent); + await _testRenderer.RenderRootComponentAsync(hostComponentId); + + var inputTextComponentId = _testRenderer.Batches.Single() + .GetComponentFrames().Single().ComponentId; + + var firstRenderFrames = _testRenderer.GetCurrentRenderTreeFrames(inputTextComponentId); + Assert.Contains(firstRenderFrames.Array, f => + f.FrameType == RenderTreeFrameType.Attribute && + f.AttributeName == "data-test-id" && + (string)f.AttributeValue == "example-id"); + + hostComponent.IncludeAttribute = false; + await _testRenderer.RenderRootComponentAsync(hostComponentId); + + var inputTextDiff = _testRenderer.Batches[1] + .DiffsByComponentId[inputTextComponentId] + .Single(); + Assert.Contains( + inputTextDiff.Edits, + edit => edit.Type == RenderTreeEditType.RemoveAttribute && edit.RemovedAttributeName == "data-test-id"); + } private async Task RenderAndGetInputTextComponentIdAsync(TestInputHostComponent hostComponent) { diff --git a/src/Components/Web/test/Forms/TestInputConditionalAttributeHostComponent.cs b/src/Components/Web/test/Forms/TestInputConditionalAttributeHostComponent.cs new file mode 100644 index 000000000000..f30d33bddefd --- /dev/null +++ b/src/Components/Web/test/Forms/TestInputConditionalAttributeHostComponent.cs @@ -0,0 +1,64 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Linq.Expressions; +using Microsoft.AspNetCore.Components.Rendering; +using Microsoft.AspNetCore.Components.Test.Helpers; + +namespace Microsoft.AspNetCore.Components.Forms; + +/// +/// A host component used to render an descendant +/// (e.g. ) inside an , where the set of +/// "extra" HTML attributes supplied is driven by the value of . +/// This mirrors the user code pattern from issue #56463 where conditional Razor +/// (builder.AddAttribute(10, "class", value)) calls may be omitted on a +/// subsequent render and the omitted attribute must be removed from the DOM. +/// +internal class TestInputConditionalAttributeHostComponent : AutoRenderComponent where TComponent : InputBase +{ + public EditContext EditContext { get; set; } + + public TValue Value { get; set; } + + public Action ValueChanged { get; set; } + + public Expression> ValueExpression { get; set; } + + /// + /// When (the default), the named attribute is added to the + /// inner component's AdditionalAttributes dictionary. When + /// the attribute is omitted, allowing tests to verify that the omitted attribute is + /// actually removed from the rendered DOM on the subsequent render. + /// + public bool IncludeAttribute { get; set; } = true; + + /// + /// The attribute name (and value) supplied to the inner component when + /// is . Defaults to + /// "class" to mirror the bug report in #56463. + /// + public string AttributeName { get; set; } = "class"; + + public object AttributeValue { get; set; } = "example-class"; + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + builder.OpenComponent>(0); + builder.AddComponentParameter(1, "Value", EditContext); + builder.AddComponentParameter(2, "ChildContent", new RenderFragment(childBuilder => + { + childBuilder.OpenComponent(0); + childBuilder.AddComponentParameter(0, "Value", Value); + childBuilder.AddComponentParameter(1, "ValueChanged", + EventCallback.Factory.Create(this, ValueChanged)); + childBuilder.AddComponentParameter(2, "ValueExpression", ValueExpression); + if (IncludeAttribute) + { + childBuilder.AddAttribute(3, AttributeName, AttributeValue); + } + childBuilder.CloseComponent(); + })); + builder.CloseComponent(); + } +}