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
10 changes: 10 additions & 0 deletions src/Components/Components/src/Reflection/ComponentProperties.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, object>? unmatched = null;
foreach (var parameter in parameters)
{
if (!parameter.Cascading)
{
parentSuppliedDirectParameters = true;
}

var parameterName = parameter.Name;
if (string.Equals(parameterName, writers.CaptureUnmatchedValuesPropertyName, StringComparison.OrdinalIgnoreCase))
{
Expand Down Expand Up @@ -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)
Expand Down
42 changes: 42 additions & 0 deletions src/Components/Components/test/ParameterViewTest.Assignment.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, object> { { "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<string, object> { { "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()
{
Expand Down
294 changes: 294 additions & 0 deletions src/Components/Components/test/RendererTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<MyStrongComponent>(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<MyStrongComponent>(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<MyStrongComponent>(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<MyStrongComponent>(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<MyStrongComponent>(1);
builder.AddComponentParameter(2, nameof(MyStrongComponent.Text), "first");
if (includeAttrOnFirst)
{
builder.AddAttribute(10, "data-x", "first-x");
}
builder.CloseComponent();

builder.OpenComponent<MyStrongComponent>(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<MyStrongComponent>(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<MyStrongContainerComponent>(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()
{
Expand Down Expand Up @@ -5488,6 +5762,26 @@ protected override void BuildRenderTree(RenderTreeBuilder builder)
}
}

private class MyStrongContainerComponent : AutoRenderComponent
{
[Parameter(CaptureUnmatchedValues = true)] public IDictionary<string, object> Attributes { get; set; }

[Parameter] public string ChildText { get; set; }

[Parameter] public IDictionary<string, object> ChildAttributes { get; set; }

protected override void BuildRenderTree(RenderTreeBuilder builder)
{
builder.OpenElement(0, "div");
builder.AddMultipleAttributes(1, Attributes);
builder.OpenComponent<MyStrongComponent>(10);
builder.AddComponentParameter(11, nameof(MyStrongComponent.Text), ChildText);
builder.AddMultipleAttributes(12, ChildAttributes);
builder.CloseComponent();
builder.CloseElement();
}
}

private class FakeComponent : IComponent
{
[Parameter]
Expand Down
Loading