Skip to content

Find() racing a timer's render caches the old DOM until the next render #1938

Open

Description

@xfunc-tim-schneider

Describe the bug

After a timer re-renders my component, Find can keep returning the old DOM. A later cut.InvokeAsync(() => cut.Find("input").Change(...)) then fails with UnknownEventHandlerIdException, though that call is the pattern the exception's own message recommends. Meanwhile cut.Markup already shows the new render.

It happens when the timer's render runs while the test's first Find is still parsing the markup. bUnit can then cache nodes parsed from the old markup, and every later Find and FindAll returns them, inside InvokeAsync or not, until the component's markup is next updated (normally by its next render).

The component below is based on Microsoft Learn's CounterState2 timer sample. I kept its timer code, replaced its page markup with an input bound to a new Name property and a Ticks line, and set the timer to fire once, after 10 ms. The test calls Find from the test method, as the samples on bUnit's trigger event handlers page do.

I hit this in the test suite of Formidable, but only about once in a hundred full-suite runs. There, a form runs its validation on a timer about 300 ms after its first render, and that renders the form again. The test failed when its thread was delayed long enough to overlap that render. That's why the repro shortens the timer to 10 ms.

To reproduce:

  1. Run dotnet new xunit -o BunitRepro -f net10.0, then cd BunitRepro.
  2. In BunitRepro.csproj, change Sdk="Microsoft.NET.Sdk" to Sdk="Microsoft.NET.Sdk.Razor", and delete UnitTest1.cs.
  3. Run dotnet add package bunit --version 2.11.3.
  4. Add _Imports.razor containing the one line @using Microsoft.AspNetCore.Components.Web, then TimerTicking.razor and StaleDomCacheTimerTest.cs from below.
  5. Run dotnet test.

Example:
Testing this component (TimerTicking.razor):

@using System.Timers
@implements IDisposable

<input @bind="Name" />
<p>Ticks: @currentCount</p>

@code {
    private int currentCount = 0;
    private Timer timer = new(10) { AutoReset = false };

    public string? Name { get; set; }

    protected override void OnInitialized()
    {
        timer.Elapsed += (sender, eventArgs) => OnTimerCallback();
        timer.Start();
    }

    private void OnTimerCallback()
    {
        _ = InvokeAsync(() =>
        {
            currentCount++;
            StateHasChanged();
        });
    }

    public void Dispose() => timer.Dispose();
}

With this test (StaleDomCacheTimerTest.cs):

using Bunit;

namespace BunitRepro;

public class StaleDomCacheTimerTest : BunitContext
{
    [Fact]
    public async Task Find_made_while_a_timer_renders_keeps_the_old_DOM()
    {
        var cut = Render<TimerTicking>();

        cut.Find("input"); // the first lookup, made while the timer's render runs
        cut.WaitForAssertion(() => Assert.Contains("Ticks: 1", cut.Markup)); // passes

        await cut.InvokeAsync(() => cut.Find("input").Change("Tim")); // throws
        Assert.Equal("Tim", cut.Instance.Name);
    }
}

Results in this output (with bUnit's two paragraphs of advice and some frames removed):

Bunit.Rendering.UnknownEventHandlerIdException : There is no event handler with ID '1' associated with the 'onchange' event in the current render tree.
---- System.ArgumentException : There is no event handler associated with this event. EventId: '1'. (Parameter 'eventHandlerId')
  Stack Trace:
     at Bunit.Rendering.BunitRenderer.AssertNoUnhandledExceptions() in /_/src/bunit/Rendering/BunitRenderer.cs:line 751
   at Bunit.Rendering.BunitRenderer.DispatchEventAsync(UInt64 eventHandlerId, EventFieldInfo fieldInfo, EventArgs eventArgs, Boolean ignoreUnknownEventHandlers) in /_/src/bunit/Rendering/BunitRenderer.cs:line 249
   at Bunit.Rendering.BunitRenderer.DispatchEventAsync(UInt64 eventHandlerId, EventFieldInfo fieldInfo, EventArgs eventArgs) in /_/src/bunit/Rendering/BunitRenderer.cs:line 197
   at Bunit.TriggerEventDispatchExtensions.TriggerNonBubblingEventAsync(BunitRenderer renderer, IElement element, String eventName, EventArgs eventArgs) in /_/src/bunit/EventDispatchExtensions/TriggerEventDispatchExtensions.cs:line 102
   ...
   at Bunit.EventHandlerDispatchExtensions.Change[T](IElement element, T value) in /_/src/bunit/EventDispatchExtensions/InputEventDispatchExtensions.cs:line 18
   at BunitRepro.StaleDomCacheTimerTest.<>c__DisplayClass0_0.<Find_made_while_a_timer_renders_keeps_the_old_DOM>b__1() in StaleDomCacheTimerTest.cs:line 15

Just before the Change, cut.Find("p").TextContent returns "Ticks: 0" while cut.Markup has "Ticks: 1". Waiting with WaitForAssertion, as bUnit's async assertion page shows, doesn't help either. With everything after the first lookup replaced by cut.WaitForAssertion(() => Assert.Equal("Ticks: 1", cut.Find("p").TextContent));, the test timed out in 17 of 20 runs on my machine. No further render happens, so the assertion is never checked again.

It depends on timing. The test as written failed in 49 of 50 runs on my machine, each in a fresh dotnet test process. The timer's render has to happen while the first Find is still parsing the markup. In 30 fresh processes, that first Find took 32 to 38 ms on my machine. Later lookups in the same process are much faster, so each run needs its own process, which running dotnet test again gives you.

The 10 ms suits my machine, and yours may need a different value. On my machine, 5 ms and 10 ms failed almost every run, and shorter or longer intervals failed less often. If it passes for you, run dotnet test a few more times, or change the new(10) in TimerTicking.razor and try again.

Expected behavior:

Once a render has finished, Find returns the DOM from that render, matching Markup, and the Change inside InvokeAsync reaches the input's current handler.

Version info:

  • bUnit version: 2.11.3 (the code quoted below is unchanged on main at 0b9d00c)
  • .NET Runtime and Blazor version: .NET 10.0.13 (SDK 10.0.402); Microsoft.AspNetCore.Components 10.0.10, the version bUnit 2.11.3 brings in
  • OS type and version: Windows 11 Pro 25H2 (10.0.26200), x64
  • Test framework: xUnit 2.9.3

Additional context:

Here is why I think it happens, from reading src/bunit/Rendering/RenderedComponent.cs at v2.11.3. The Nodes getter is:

return latestRenderNodes ??= htmlParser.Parse(Markup);

A render runs UpdateMarkup() on the dispatcher:

latestRenderNodes = null;
var newMarkup = Htmlizer.GetHtml(ComponentId, renderer);
Volatile.Write(ref markup, newMarkup);

Nothing makes the lookup and the render wait for each other. If the render runs while the lookup is parsing the old Markup, the render clears latestRenderNodes first. The parse then stores nodes built from the old markup, and they stay until the next UpdateMarkup. The cached input still carries handler ID 1 from the old @bind, which the renderer has already removed.

To check, I set only latestRenderNodes to null by reflection, after Markup showed "Ticks: 1" and a lookup still returned "Ticks: 0". The next lookup then returned "Ticks: 1" and the Change succeeded, in all 10 runs.

A fix might store the markup string with the nodes parsed from it, and parse again when that string no longer matches Markup. I haven't built or tested that. The open #1910 caches nodes per markup snapshot. From reading it, I think it would fix this for v3.

As long as a timer can render the component, the workaround is to make every lookup inside InvokeAsync, even ones that don't trigger an event. With the first lookup changed to await cut.InvokeAsync(() => cut.Find("input"));, the test passed in 50 of 50 runs.

Issue #1937 is a similar race in WrapperBase<TElement>.WrappedElement, where a found element can become null during a concurrent render. The symptom differs, but the same workaround covers both.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions