Skip to content

Commit 5e25797

Browse files
authored
Better handling of disposed components - fixes #175 (#184)
* Updated changelog with pharry22's docs contributions * Fixes #175 * updated changelog
1 parent 87d61a0 commit 5e25797

File tree

9 files changed

+140
-32
lines changed

9 files changed

+140
-32
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ All notable changes to **bUnit** will be documented in this file. The project ad
66

77
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
88

9+
Thanks to [pharry22](https://github.com/pharry22) for submitting fixes and improvements to the documentation.
10+
911
### Added
1012
List of new features.
1113

@@ -16,6 +18,8 @@ List of new features.
1618
List of changes in existing functionality.
1719

1820
- Moved `InvokeAsync()`, `Render()` and `SetParametersAndRender()` methods out of `IRenderedComponentBase<TComponent>` into extension methods. By [@JeroenBos](https://github.com/JeroenBos) in [#151](https://github.com/egil/bUnit/pull/177).
21+
- Accessing `Markup`, `Nodes` and related methods on a rendered fragment whose underlying component has been removed from the render tree (disposed) now throws a `ComponentDisposedException`. By [@egil](https://github.com/egil) in [#184](https://github.com/egil/bUnit/pull/184).
22+
1923

2024
### Deprecated
2125
List of soon-to-be removed features.
@@ -26,6 +30,8 @@ List of now removed features.
2630
### Fixed
2731
List of any bug fixes.
2832

33+
- Fixes [#175](https://github.com/egil/bUnit/issues/175): When a component referenced in a test, e.g. through the `FindComponent()` method was removed from the render tree, accessing the reference could caused bUnit to look for updates to it in the renderer, causing a exception to be thrown. By [@egil](https://github.com/egil) in [#184](https://github.com/egil/bUnit/pull/184).
34+
2935
### Security
3036
List of fixed security vulnerabilities.
3137

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Text;
4+
5+
namespace Bunit.Rendering
6+
{
7+
/// <summary>
8+
/// Represents an exception that is thrown when a <see cref="IRenderedFragmentBase"/>'s
9+
/// properties is accessed after the underlying component has been dispsoed by the renderer.
10+
/// </summary>
11+
public class ComponentDisposedException : Exception
12+
{
13+
/// <summary>
14+
/// Creates an instance of the <see cref="ComponentDisposedException"/>.
15+
/// </summary>
16+
/// <param name="componentId">Id of the disposed component.</param>
17+
public ComponentDisposedException(int componentId)
18+
: base($"The component has been removed from the render tree by the renderer and is no longer available for inspection. ComponentId = {componentId}.")
19+
{
20+
21+
}
22+
}
23+
}

src/bunit.core/Rendering/RenderEvent.cs

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System;
12
using Microsoft.AspNetCore.Components.RenderTree;
23

34
namespace Bunit.Rendering
@@ -67,8 +68,8 @@ bool HasChangedToChildren(ArrayRange<RenderTreeFrame> componentRenderTreeFrames)
6768
/// <returns>True if component was disposed, false otherwise.</returns>
6869
public bool DidComponentDispose(int componentId)
6970
{
70-
for (var i = 0; i < _renderBatch.DisposedEventHandlerIDs.Count; i++)
71-
if (_renderBatch.DisposedEventHandlerIDs.Array[i].Equals(componentId))
71+
for (var i = 0; i < _renderBatch.DisposedComponentIDs.Count; i++)
72+
if (_renderBatch.DisposedComponentIDs.Array[i].Equals(componentId))
7273
return true;
7374

7475
return false;
@@ -93,18 +94,21 @@ bool DidComponentRenderRoot(int componentId)
9394
return true;
9495
}
9596

96-
return DidChildComponentRender(_renderer.GetCurrentRenderTreeFrames(componentId));
97+
return DidChildComponentRender(componentId);
9798
}
9899

99-
bool DidChildComponentRender(ArrayRange<RenderTreeFrame> componentRenderTreeFrames)
100+
bool DidChildComponentRender(int componentId)
100101
{
101-
for (var i = 0; i < componentRenderTreeFrames.Count; i++)
102+
var frames = _renderer.GetCurrentRenderTreeFrames(componentId);
103+
104+
for (var i = 0; i < frames.Count; i++)
102105
{
103-
ref var frame = ref componentRenderTreeFrames.Array[i];
106+
ref var frame = ref frames.Array[i];
104107
if (frame.FrameType == RenderTreeFrameType.Component)
105108
if (DidComponentRenderRoot(frame.ComponentId))
106109
return true;
107110
}
111+
108112
return false;
109113
}
110114
}

src/bunit.core/Rendering/TestRenderer.cs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,10 +148,21 @@ private Task PublishRenderEvent(in RenderBatch renderBatch)
148148
{
149149
0 => Task.CompletedTask,
150150
1 => _renderEventHandlers[0].Handle(renderEvent),
151-
_ => Task.WhenAll(_renderEventHandlers.Select(x => x.Handle(renderEvent)))
151+
_ => NotifyEventHandlers(renderEvent)
152152
};
153153
}
154154

155+
private Task NotifyEventHandlers(RenderEvent renderEvent)
156+
{
157+
// copy to new array since _renderEventHandlers might be modified by the
158+
// Handle method on event handlers if component is disposed.
159+
var handleTasks = _renderEventHandlers
160+
.ToArray()
161+
.Select(x => x.Handle(renderEvent));
162+
163+
return Task.WhenAll(handleTasks);
164+
}
165+
155166
private void AssertNoUnhandledExceptions()
156167
{
157168
if (_unhandledException is { } unhandled)
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
@if (ShowChild)
2+
{
3+
<Simple1 Header="Helle World" />
4+
}
5+
@code {
6+
[Parameter]
7+
public bool ShowChild { get; set; }
8+
}

src/bunit.web.tests/Rendering/RenderedComponentTest.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System;
2+
using Bunit.Rendering;
23
using Bunit.TestAssets.SampleComponents;
34
using Bunit.TestDoubles.JSInterop;
45
using Shouldly;
@@ -53,5 +54,17 @@ public void Test003()
5354
Should.Throw<InvalidOperationException>(() => cut.SetParametersAndRender(ps => ps.Add(p => p.UnnamedCascadingValue, 42)));
5455
Should.Throw<InvalidOperationException>(() => cut.SetParametersAndRender(ps => ps.Add(p => p.NamedCascadingValue, 1337)));
5556
}
57+
58+
[Fact(DisplayName = "Getting Instance from a RenderedComponent based on a disposed component throws")]
59+
public void Test020()
60+
{
61+
var cut = RenderComponent<ToggleChildComponent>(ps => ps.Add(p => p.ShowChild, true));
62+
var target = cut.FindComponent<Simple1>();
63+
64+
// Disposes of <Simple1 />
65+
cut.SetParametersAndRender(ps => ps.Add(p => p.ShowChild, false));
66+
67+
Should.Throw<ComponentDisposedException>(() => target.Instance);
68+
}
5669
}
5770
}

src/bunit.web.tests/Rendering/RenderedFragmentTest.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System;
12
using Bunit.Rendering;
23
using Bunit.TestAssets.SampleComponents;
34

@@ -173,5 +174,17 @@ public void Test010()
173174
first.RenderCount.ShouldBe(2);
174175
second.RenderCount.ShouldBe(2);
175176
}
177+
178+
[Fact(DisplayName = "Getting Markup from a RenderedFragment based on a disposed component throws")]
179+
public void Test020()
180+
{
181+
var cut = RenderComponent<ToggleChildComponent>(ps => ps.Add(p => p.ShowChild, true));
182+
var target = cut.FindComponent<Simple1>();
183+
184+
// Disposes of <Simple1 />
185+
cut.SetParametersAndRender(ps => ps.Add(p => p.ShowChild, false));
186+
187+
Should.Throw<ComponentDisposedException>(() => target.Markup);
188+
}
176189
}
177190
}

src/bunit.web/Rendering/RenderedComponent.cs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,21 @@ namespace Bunit.Rendering
99
/// <inheritdoc/>
1010
internal class RenderedComponent<TComponent> : RenderedFragment, IRenderedComponent<TComponent> where TComponent : IComponent
1111
{
12+
private readonly TComponent _instance;
13+
1214
/// <inheritdoc/>
13-
public TComponent Instance { get; }
15+
public TComponent Instance
16+
{
17+
get
18+
{
19+
EnsureComponentNotDisposed();
20+
return _instance;
21+
}
22+
}
1423

1524
public RenderedComponent(IServiceProvider services, int componentId, TComponent component) : base(services, componentId)
1625
{
17-
Instance = component;
18-
}
26+
_instance = component;
27+
}
1928
}
2029
}

src/bunit.web/Rendering/RenderedFragment.cs

Lines changed: 43 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,14 @@ namespace Bunit.Rendering
1919
/// </summary>
2020
public class RenderedFragment : IRenderedFragment, IRenderEventHandler
2121
{
22-
private readonly object _lock = new object();
22+
private readonly object _markupAccessLock = new object();
2323
private readonly ILogger<RenderedFragment> _logger;
2424
private string? _snapshotMarkup;
2525
private INodeList? _firstRenderNodes;
2626
private INodeList? _latestRenderNodes;
2727
private INodeList? _snapshotNodes;
2828
private string _markup;
29+
private bool _componentDisposed = false;
2930

3031
private HtmlParser HtmlParser { get; }
3132

@@ -36,7 +37,7 @@ public class RenderedFragment : IRenderedFragment, IRenderEventHandler
3637

3738
/// <inheritdoc/>
3839
public ITestRenderer Renderer { get; }
39-
40+
4041
/// <inheritdoc/>
4142
public IServiceProvider Services { get; }
4243

@@ -48,9 +49,11 @@ public string Markup
4849
{
4950
get
5051
{
52+
EnsureComponentNotDisposed();
53+
5154
// The lock ensures that we cannot read the _markup and _latestRenderNodes
5255
// field while it is being updated
53-
lock (_lock)
56+
lock (_markupAccessLock)
5457
{
5558
return Volatile.Read(ref _markup);
5659
}
@@ -62,8 +65,10 @@ public INodeList Nodes
6265
{
6366
get
6467
{
68+
EnsureComponentNotDisposed();
69+
6570
// The lock ensures that latest nodes is always based on the latest rendered markup.
66-
lock (_lock)
71+
lock (_markupAccessLock)
6772
{
6873
if (_latestRenderNodes is null)
6974
_latestRenderNodes = HtmlParser.Parse(Markup);
@@ -131,26 +136,30 @@ public IReadOnlyList<IDiff> GetChangesSinceFirstRender()
131136

132137
Task IRenderEventHandler.Handle(RenderEvent renderEvent)
133138
{
134-
HandleComponentRender(renderEvent);
139+
if (renderEvent.DidComponentDispose(ComponentId))
140+
{
141+
HandleComponentDisposed();
142+
}
143+
else if (renderEvent.DidComponentRender(ComponentId))
144+
{
145+
HandleComponentRender(renderEvent);
146+
}
135147
return Task.CompletedTask;
136148
}
137149

138150
private void HandleComponentRender(RenderEvent renderEvent)
139151
{
140-
if (renderEvent.DidComponentRender(ComponentId))
141-
{
142-
_logger.LogDebug(new EventId(1, nameof(HandleComponentRender)), $"Received a new render where component {ComponentId} did render.");
152+
_logger.LogDebug(new EventId(1, nameof(HandleComponentRender)), $"Received a new render where component {ComponentId} did render.");
143153

144-
RenderCount++;
154+
RenderCount++;
145155

146-
// First notify derived types, e.g. queried AngleSharp collections or elements
147-
// that the markup has changed and they should rerun their queries.
148-
HandleChangesToMarkup(renderEvent);
156+
// First notify derived types, e.g. queried AngleSharp collections or elements
157+
// that the markup has changed and they should rerun their queries.
158+
HandleChangesToMarkup(renderEvent);
149159

150-
// Then it is safe to tell anybody waiting on updates or changes to the rendered fragment
151-
// that they can redo their assertions or continue processing.
152-
OnAfterRender?.Invoke();
153-
}
160+
// Then it is safe to tell anybody waiting on updates or changes to the rendered fragment
161+
// that they can redo their assertions or continue processing.
162+
OnAfterRender?.Invoke();
154163
}
155164

156165
private void HandleChangesToMarkup(RenderEvent renderEvent)
@@ -160,19 +169,31 @@ private void HandleChangesToMarkup(RenderEvent renderEvent)
160169
_logger.LogDebug(new EventId(1, nameof(HandleChangesToMarkup)), $"Received a new render where the markup of component {ComponentId} changed.");
161170

162171
// The lock ensures that latest nodes is always based on the latest rendered markup.
163-
lock (_lock)
172+
lock (_markupAccessLock)
164173
{
165174
_latestRenderNodes = null;
166175
_markup = RetrieveLatestMarkupFromRenderer();
167176
}
168177

169178
OnMarkupUpdated?.Invoke();
170179
}
171-
else if (renderEvent.DidComponentDispose(ComponentId))
172-
{
173-
_logger.LogDebug(new EventId(1, nameof(HandleChangesToMarkup)), $"Received a new render where the component {ComponentId} was disposed.");
174-
Renderer.RemoveRenderEventHandler(this);
175-
}
180+
}
181+
182+
private void HandleComponentDisposed()
183+
{
184+
_logger.LogDebug(new EventId(1, nameof(HandleChangesToMarkup)), $"Received a new render where the component {ComponentId} was disposed.");
185+
_componentDisposed = true;
186+
Renderer.RemoveRenderEventHandler(this);
187+
}
188+
189+
/// <summary>
190+
/// Ensures that the underlying component behind the
191+
/// fragment has not been removed from the render tree.
192+
/// </summary>
193+
protected void EnsureComponentNotDisposed()
194+
{
195+
if (_componentDisposed)
196+
throw new ComponentDisposedException(ComponentId);
176197
}
177198
}
178199
}

0 commit comments

Comments
 (0)