Skip to content

Commit 7768593

Browse files
Merged PR 34696: [8.0] Prevent delivery of events to disposed components (second attempt) (#52057)
Prevents delivery of events to disposed components. Co-authored-by: Steve Sanderson <stevesa@microsoft.com>
1 parent c28cf86 commit 7768593

File tree

6 files changed

+151
-31
lines changed

6 files changed

+151
-31
lines changed

src/Components/Components/src/RenderTree/RenderTreeDiffBuilder.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -978,7 +978,7 @@ private static void InitializeNewAttributeFrame(ref DiffContext diffContext, ref
978978
newFrame.AttributeNameField.Length >= 3 &&
979979
newFrame.AttributeNameField.StartsWith("on", StringComparison.Ordinal))
980980
{
981-
diffContext.Renderer.AssignEventHandlerId(ref newFrame);
981+
diffContext.Renderer.AssignEventHandlerId(diffContext.ComponentId, ref newFrame);
982982
}
983983
}
984984

src/Components/Components/src/RenderTree/Renderer.Log.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,5 +63,16 @@ public static void HandlingEvent(ILogger logger, ulong eventHandlerId, EventArgs
6363
HandlingEvent(logger, eventHandlerId, eventArgs?.GetType().Name ?? "null");
6464
}
6565
}
66+
67+
[LoggerMessage(6, LogLevel.Debug, "Skipping attempt to raise event {EventId} of type '{EventType}' because the component ID {ComponentId} was already disposed", EventName = "SkippingEventOnDisposedComponent", SkipEnabledCheck = true)]
68+
public static partial void SkippingEventOnDisposedComponent(ILogger logger, int componentId, ulong eventId, string eventType);
69+
70+
public static void SkippingEventOnDisposedComponent(ILogger logger, int componentId, ulong eventHandlerId, EventArgs? eventArgs)
71+
{
72+
if (logger.IsEnabled(LogLevel.Debug)) // This is almost always false, so skip the evaluations
73+
{
74+
SkippingEventOnDisposedComponent(logger, componentId, eventHandlerId, eventArgs?.GetType().Name ?? "null");
75+
}
76+
}
6677
}
6778
}

src/Components/Components/src/RenderTree/Renderer.cs

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ public abstract partial class Renderer : IDisposable, IAsyncDisposable
2828
private readonly Dictionary<int, ComponentState> _componentStateById = new Dictionary<int, ComponentState>();
2929
private readonly Dictionary<IComponent, ComponentState> _componentStateByComponent = new Dictionary<IComponent, ComponentState>();
3030
private readonly RenderBatchBuilder _batchBuilder = new RenderBatchBuilder();
31-
private readonly Dictionary<ulong, EventCallback> _eventBindings = new Dictionary<ulong, EventCallback>();
31+
private readonly Dictionary<ulong, (int RenderedByComponentId, EventCallback Callback)> _eventBindings = new();
3232
private readonly Dictionary<ulong, ulong> _eventHandlerIdReplacements = new Dictionary<ulong, ulong>();
3333
private readonly ILogger _logger;
3434
private readonly ComponentFactory _componentFactory;
@@ -416,7 +416,22 @@ public virtual Task DispatchEventAsync(ulong eventHandlerId, EventFieldInfo? fie
416416
_pendingTasks ??= new();
417417
}
418418

419-
var callback = GetRequiredEventCallback(eventHandlerId);
419+
var (renderedByComponentId, callback) = GetRequiredEventBindingEntry(eventHandlerId);
420+
421+
// If this event attribute was rendered by a component that's since been disposed, don't dispatch the event at all.
422+
// This can occur because event handler disposal is deferred, so event handler IDs can outlive their components.
423+
// The reason the following check is based on "which component rendered this frame" and not on "which component
424+
// receives the callback" (i.e., callback.Receiver) is that if parent A passes a RenderFragment with events to child B,
425+
// and then child B is disposed, we don't want to dispatch the events (because the developer considers them removed
426+
// from the UI) even though the receiver A is still alive.
427+
if (!_componentStateById.ContainsKey(renderedByComponentId))
428+
{
429+
// This is not an error since it can happen legitimately (in Blazor Server, the user might click a button at the same
430+
// moment that the component is disposed remotely, and then the click event will arrive after disposal).
431+
Log.SkippingEventOnDisposedComponent(_logger, renderedByComponentId, eventHandlerId, eventArgs);
432+
return Task.CompletedTask;
433+
}
434+
420435
Log.HandlingEvent(_logger, eventHandlerId, eventArgs);
421436

422437
// Try to match it up with a receiver so that, if the event handler later throws, we can route the error to the
@@ -480,7 +495,7 @@ public virtual Task DispatchEventAsync(ulong eventHandlerId, EventFieldInfo? fie
480495
/// <returns>The parameter type expected by the event handler. Normally this is a subclass of <see cref="EventArgs"/>.</returns>
481496
public Type GetEventArgsType(ulong eventHandlerId)
482497
{
483-
var methodInfo = GetRequiredEventCallback(eventHandlerId).Delegate?.Method;
498+
var methodInfo = GetRequiredEventBindingEntry(eventHandlerId).Callback.Delegate?.Method;
484499

485500
// The DispatchEventAsync code paths allow for the case where Delegate or its method
486501
// is null, and in this case the event receiver just receives null. This won't happen
@@ -581,7 +596,7 @@ protected virtual void AddPendingTask(ComponentState? componentState, Task task)
581596
_pendingTasks?.Add(task);
582597
}
583598

584-
internal void AssignEventHandlerId(ref RenderTreeFrame frame)
599+
internal void AssignEventHandlerId(int renderedByComponentId, ref RenderTreeFrame frame)
585600
{
586601
var id = ++_lastEventHandlerId;
587602

@@ -593,15 +608,15 @@ internal void AssignEventHandlerId(ref RenderTreeFrame frame)
593608
//
594609
// When that happens we intentionally box the EventCallback because we need to hold on to
595610
// the receiver.
596-
_eventBindings.Add(id, callback);
611+
_eventBindings.Add(id, (renderedByComponentId, callback));
597612
}
598613
else if (frame.AttributeValueField is MulticastDelegate @delegate)
599614
{
600615
// This is the common case for a delegate, where the receiver of the event
601616
// is the same as delegate.Target. In this case since the receiver is implicit we can
602617
// avoid boxing the EventCallback object and just re-hydrate it on the other side of the
603618
// render tree.
604-
_eventBindings.Add(id, new EventCallback(@delegate.Target as IHandleEvent, @delegate));
619+
_eventBindings.Add(id, (renderedByComponentId, new EventCallback(@delegate.Target as IHandleEvent, @delegate)));
605620
}
606621

607622
// NOTE: we do not to handle EventCallback<T> here. EventCallback<T> is only used when passing
@@ -645,14 +660,14 @@ internal void TrackReplacedEventHandlerId(ulong oldEventHandlerId, ulong newEven
645660
_eventHandlerIdReplacements.Add(oldEventHandlerId, newEventHandlerId);
646661
}
647662

648-
private EventCallback GetRequiredEventCallback(ulong eventHandlerId)
663+
private (int RenderedByComponentId, EventCallback Callback) GetRequiredEventBindingEntry(ulong eventHandlerId)
649664
{
650-
if (!_eventBindings.TryGetValue(eventHandlerId, out var callback))
665+
if (!_eventBindings.TryGetValue(eventHandlerId, out var entry))
651666
{
652667
throw new ArgumentException($"There is no event handler associated with this event. EventId: '{eventHandlerId}'.", nameof(eventHandlerId));
653668
}
654669

655-
return callback;
670+
return entry;
656671
}
657672

658673
private ulong FindLatestEventHandlerIdInChain(ulong eventHandlerId)

src/Components/Components/test/RendererTest.cs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2619,6 +2619,56 @@ public void RenderBatch_DoesNotDisposeComponentMultipleTimes()
26192619
Assert.True(component.Disposed);
26202620
}
26212621

2622+
[Fact]
2623+
public async Task DoesNotDispatchEventsAfterOwnerComponentIsDisposed()
2624+
{
2625+
// Arrange
2626+
var renderer = new TestRenderer();
2627+
var eventCount = 0;
2628+
Action<EventArgs> origEventHandler = args => { eventCount++; };
2629+
var component = new ConditionalParentComponent<EventComponent>
2630+
{
2631+
IncludeChild = true,
2632+
ChildParameters = new Dictionary<string, object>
2633+
{
2634+
{ nameof(EventComponent.OnTest), origEventHandler }
2635+
}
2636+
};
2637+
var rootComponentId = renderer.AssignRootComponentId(component);
2638+
component.TriggerRender();
2639+
var batch = renderer.Batches.Single();
2640+
var rootComponentDiff = batch.DiffsByComponentId[rootComponentId].Single();
2641+
var rootComponentFrame = batch.ReferenceFrames[0];
2642+
var childComponentFrame = rootComponentDiff.Edits
2643+
.Select(e => batch.ReferenceFrames[e.ReferenceFrameIndex])
2644+
.Where(f => f.FrameType == RenderTreeFrameType.Component)
2645+
.Single();
2646+
var childComponentId = childComponentFrame.ComponentId;
2647+
var childComponentDiff = batch.DiffsByComponentId[childComponentFrame.ComponentId].Single();
2648+
var eventHandlerId = batch.ReferenceFrames
2649+
.Skip(childComponentDiff.Edits[0].ReferenceFrameIndex) // Search from where the child component frames start
2650+
.Where(f => f.FrameType == RenderTreeFrameType.Attribute)
2651+
.Single(f => f.AttributeEventHandlerId != 0)
2652+
.AttributeEventHandlerId;
2653+
2654+
// Act/Assert 1: Event handler fires when we trigger it
2655+
Assert.Equal(0, eventCount);
2656+
var renderTask = renderer.DispatchEventAsync(eventHandlerId, args: null);
2657+
Assert.True(renderTask.IsCompletedSuccessfully);
2658+
Assert.Equal(1, eventCount);
2659+
await renderTask;
2660+
2661+
// Now remove the EventComponent, but without ever acknowledging the renderbatch, so the event handler doesn't get disposed
2662+
var disposalBatchAcknowledgementTcs = new TaskCompletionSource();
2663+
component.IncludeChild = false;
2664+
renderer.NextRenderResultTask = disposalBatchAcknowledgementTcs.Task;
2665+
component.TriggerRender();
2666+
2667+
// Act/Assert 2: Can no longer fire the original event. It's not an error but the delegate was not invoked.
2668+
await renderer.DispatchEventAsync(eventHandlerId, args: null);
2669+
Assert.Equal(1, eventCount);
2670+
}
2671+
26222672
[Fact]
26232673
public async Task DisposesEventHandlersWhenAttributeValueChanged()
26242674
{

src/Components/test/E2ETest/Tests/FormsTest.cs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -990,6 +990,39 @@ public void CanHaveModelLevelValidationErrors()
990990
Browser.Collection(logEntries, x => Assert.Equal("OnValidSubmit", x));
991991
}
992992

993+
[Fact]
994+
public async Task CannotSubmitEditFormSynchronouslyAfterItWasRemoved()
995+
{
996+
var appElement = MountSimpleValidationComponent();
997+
998+
var submitButtonFinder = By.CssSelector("button[type=submit]");
999+
Browser.Exists(submitButtonFinder);
1000+
1001+
// Remove the form then immediately also submit it, so the server receives both
1002+
// the 'remove' and 'submit' commands (in that order) before it updates the UI
1003+
appElement.FindElement(By.Id("remove-form")).Click();
1004+
1005+
try
1006+
{
1007+
appElement.FindElement(submitButtonFinder).Click();
1008+
}
1009+
catch (NoSuchElementException)
1010+
{
1011+
// This should happen on WebAssembly because the form will be removed synchronously
1012+
// That means the test has passed
1013+
return;
1014+
}
1015+
1016+
// Wait for the removal to complete, which is intentionally delayed to ensure
1017+
// this test can submit a second instruction before the first is processed.
1018+
Browser.DoesNotExist(submitButtonFinder);
1019+
1020+
// Verify that the form submit event was not processed, even if we wait a while
1021+
// to be really sure the second instruction was processed.
1022+
await Task.Delay(1000);
1023+
Browser.DoesNotExist(By.Id("last-callback"));
1024+
}
1025+
9931026
private Func<string[]> CreateValidationMessagesAccessor(IWebElement appElement, string messageSelector = ".validation-message")
9941027
{
9951028
return () => appElement.FindElements(By.CssSelector(messageSelector))
Lines changed: 32 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,42 @@
11
@using System.ComponentModel.DataAnnotations
22
@using Microsoft.AspNetCore.Components.Forms
33

4-
<EditForm Model="@this" OnValidSubmit="@HandleValidSubmit" OnInvalidSubmit="@HandleInvalidSubmit" autocomplete="off">
5-
<DataAnnotationsValidator />
6-
7-
<p class="user-name">
8-
User name: <input @bind="UserName" class="@context.FieldCssClass(() => UserName)" />
9-
</p>
10-
<p class="accepts-terms">
11-
Accept terms: <input type="checkbox" @bind="AcceptsTerms" class="@context.FieldCssClass(() => AcceptsTerms)" />
12-
</p>
13-
14-
<button type="submit">Submit</button>
15-
16-
@* Could use <ValidationSummary /> instead, but this shows it can be done manually *@
17-
<ul class="validation-errors">
18-
@foreach (var message in context.GetValidationMessages())
19-
{
20-
<li class="validation-message">@message</li>
21-
}
22-
</ul>
23-
24-
</EditForm>
4+
@if (!removeForm)
5+
{
6+
<EditForm Model="@this" OnValidSubmit="@HandleValidSubmit" OnInvalidSubmit="@HandleInvalidSubmit" autocomplete="off">
7+
<DataAnnotationsValidator />
8+
9+
<p class="user-name">
10+
User name: <input @bind="UserName" class="@context.FieldCssClass(() => UserName)" />
11+
</p>
12+
<p class="accepts-terms">
13+
Accept terms: <input type="checkbox" @bind="AcceptsTerms" class="@context.FieldCssClass(() => AcceptsTerms)" />
14+
</p>
15+
16+
<button type="submit">Submit</button>
17+
18+
@* Could use <ValidationSummary /> instead, but this shows it can be done manually *@
19+
<ul class="validation-errors">
20+
@foreach (var message in context.GetValidationMessages())
21+
{
22+
<li class="validation-message">@message</li>
23+
}
24+
</ul>
25+
</EditForm>
26+
}
2527

2628
@if (lastCallback != null)
2729
{
2830
<span id="last-callback">@lastCallback</span>
2931
}
3032

33+
<p><button id="remove-form" @onclick="RemoveForm">Remove form</button></p>
34+
3135
@code {
3236
protected virtual bool UseExperimentalValidator => false;
3337

3438
string lastCallback;
39+
bool removeForm;
3540

3641
[Required(ErrorMessage = "Please choose a username")]
3742
public string UserName { get; set; }
@@ -49,4 +54,10 @@
4954
{
5055
lastCallback = "OnInvalidSubmit";
5156
}
57+
58+
void RemoveForm()
59+
{
60+
removeForm = true;
61+
Thread.Sleep(1000); // To ensure we can dispatch another event before this completes
62+
}
5263
}

0 commit comments

Comments
 (0)