bUnit-dev / bUnit

bUnit is a testing library for Blazor components that make tests look, feel, and runs like regular unit tests. bUnit makes it easy to render and control a component under test’s life-cycle, pass parameter and inject services into it, trigger event handlers, and verify the rendered markup from the component using a built-in semantic HTML comparer.
https://bunit.dev
MIT License
1.13k stars 105 forks source link

Clicking a submit <button> or <submit> input element doesn't trigger parent <form>'s onsubmit event handler #563

Closed egil closed 2 years ago

egil commented 2 years ago

Describe the bug

<input type="submit"> or <button type="submit"> onclick events do not trigger onsubmit events a parent <form> element.

Example: Testing this component:

<form @onsubmit="() => formSubmitted = true">
    <button type="submit">Submit</button>
    <input type="submit" value="Submit" />
</form>
<p>@formSubmitted</p>

@code {
    private bool formSubmitted;
}

With this test:

[Fact]
public void SubmitButton_SubmitsForm()
{
    using var ctx = new TestContext();
    var cut = ctx.RenderComponent<Index>();
    cut.Find("button").Click();
    cut.Find("p").MarkupMatches("true");
}

[Fact]
public void SubmitInput_SubmitsForm()
{
    using var ctx = new TestContext();
    var cut = ctx.RenderComponent<Index>();
    cut.Find("button").Click();
    cut.Find("p").MarkupMatches("true");
}

Results in this output:

Test fails because there is no onclick handler on the button/input element. If there is, then the test fails because the form's onsubmit is not bubbled to.

Expected behavior:

Version info:

Additional context:

linkdotnet commented 2 years ago

Hey @egil I had a short look at the discussion and the bug itself. We could extend TriggerEventDispatchExtensions to check if we have an onclick in a form. If so we add the event.

An idea (out of my brain, this is most likely not compilable) to extend TriggerBubblingEventAsync:

if (eventName == "click" && element is IHtmlInputElement { Type: "submit" } && candidate is IHtmlFormElement form)
{
    try
    {
        var info = new EventFieldInfo() { FieldValue = "onsubmit" };
        form.TryGetEventId(Htmlizer.ToBlazorAttribute("onsubmit"), out var formSubmitEventId)
        eventTasks.Add(renderer.DispatchEventAsync(formSubmitEventId, info, eventArgs));
    }
    catch (UnknownEventHandlerIdException) when (eventTasks.Count > 0)
    {
        // Capture and ignore NoEventHandlerException for bubbling events
        // if at least one event handler has been triggered without throwing.
    }
}

Of course without doubling all the logic et cetera... Any input on that?

egil commented 2 years ago

Hi, thanks for taking the time.

You are on the right track. The challenge here is to make sure that we do exactly as the browser does, and bubble the right types of events to the correct <form> (or all forms)?

In general, this is a specialization of the bubbling implementation in TriggerBubblingEvent you found, we just need to figure out all corner cases/scenarios.

This issue is definitely up for grabs if you want to take a crack at it.

linkdotnet commented 2 years ago

Hey @egil thanks for the quick response. I can try to have a look at the end of the week / weekend, but can't promise anything.

note to myself: We can leverage overriding the TriggerBubble event for IHtmlInputElement

linkdotnet commented 2 years ago

I try to keep this comment up to date with all the findings / requirements I found.

egil commented 2 years ago

Sounds good. If you find any other issues while investing this, we can expand this issue.

I'm very hung up with a work thing the next two days, but hopefully get back to this on Friday.

linkdotnet commented 2 years ago
<button form="my-form">Click to submit</button>
<form id="my-form" onsubmit="(() => console.log('submitted'))()">
</form>

Would print "Submitted" on the console and submit afterwards the form.

And here a very super special edge-case:

<form onsubmit="(() => console.log('submitted'))()">
    <input type="text" name="name" />
    <input type="submit" id="submit-form" class="hidden" />
</form>

<label for="submit-form" tabindex="0">Submit</label>

clicking on the label would raise the submit

linkdotnet commented 2 years ago

I made a small proposal PR. It does not fix the last issue (button / label outside the form which can raise the onsubmit event). But I guess it can be a foundation for a discussion.

Right now 1 test is failing which invokes onsubmit on an input element which is not valid according to the specification.

I purposely throw an exception even though the browser would just silently swallow any onsubmit on any non-form element. As a developer, especially in a test, I want to have it as explicit as possible. We can also remove the check but this would give a false impression that somehow "onsubmit" gets invoked when you click on a button.

egil commented 2 years ago

Great. I will take a look tonight and give you my feedback.

egil commented 2 years ago

I try to keep this comment up to date with all the findings / requirements I found.

  • [ ] Forms inside forms must not contain other forms (see B. Element Prohibitions). Therefore we don't have to care about another <form> element in the hierarchy which would receive the submit-event as well.

Check.

  • [ ] Clicking a submit button (either via <input type="submit"> or <button>) will raise and bubble the onclick event first and afterwards raise the submit event of the single parent <form> element. We have currently the "limitation" that we use Task.WhenAll(IEnumerable<Task>) which does not guarantee order (see stackoverflow). To be honest I would leave this edge case out of scope for now (we currently even don't guarantee that the child raises its event before the parent). @egil Maybe something for the future to come closer to the "real" browser behavior ?

I have no objection to switching to a mode where we invoke and then await each event handler in the proper order. Then, if users need, they can use the async versions of the trigger methods and await them.

  • [ ] Only the <form> element is allowed to "raise" the submit event not the input/button which was clicked. I checked how it is currently done: We are raising the onsubmit event even on a button. Technically this wouldn't be possible / or better it would just not be invoked at all. Should we consider this a bug? @egil (see here)

So what you are saying is that if we have an @onsubmit event on a button it will be triggered? That seems right. If we change this, and we could definitely do that, then we should probably have this case throw an exception letting users know they are doing something that is not supported. However, we should only do so if that is also how Blazor works in the browser.

Another point of view would be that this would not work in the browser, assuming Blazor does as the spec specifies, and then its very unlikely that users would get into a situation where they write a test in bUnit for something that will never work i Blazor.

  • [ ] Forms can be submitted from outside the form itself (see code in comment).

Gah, well, we can work around that by using the DOM querying capabilities to find the related <form> element.

linkdotnet commented 2 years ago

So what you are saying is that if we have an @onsubmit event on a button it will be triggered? That seems right. If we change this, and we could definitely do that, then we should probably have this case throw an exception letting users know they are doing something that is not supported. However, we should only do so if that is also how Blazor works in the browser.

Another point of view would be that this would not work in the browser, assuming Blazor does as the spec specifies, and then its very unlikely that users would get into a situation where they write a test in bUnit for something that will never work i Blazor.

Sorry I wrote that a bit confusing. The browser as well as blazor behaves here the same: Only the onsubmit event of a form is invoked. You can declare it on any other element, but your browser will never execute the onsubmit event. Small example:

<EditForm OnValidSubmit="OnSubmit" Model="model">
    <button @onsubmit="OnButtonSubmit"> Submit</button>
</EditForm>

@code {
    private MyModel model = new();
    private void OnSubmit()
    {
        Console.WriteLine("Valid Form Submit");
    }

    private void OnButtonSubmit()
    {
        Console.WriteLine("I will be not triggered");
    }
}

Clicking on the submit button will only Display: Valid Form Submit but will not show I will be not triggered. Therefore I like to throw an exception when someone is declaring an onsubmit event on a non-form element.

  • [ ] Forms can be submitted from outside the form itself (see code in comment).

Gah, well, we can work around that by using the DOM querying capabilities to find the related <form> element. Yeah we could go through the attributes. I'd suggest to make a extra PR or even issue for that matter. To be honest: I never saw that in the wild :D and I feel that makes the code way more complex for that 0.1% use case.

egil commented 2 years ago

Yeah we could go through the attributes. I'd suggest to make a extra PR or even issue for that matter. To be honest: I never saw that in the wild :D and I feel that makes the code way more complex for that 0.1% use case

Yep, let's just leave that as it is. It took almost 2 years before somebody tripped over this one 😂

linkdotnet commented 2 years ago

I tried to create a valid test case for the Task.WhenAll ordering:

<div id="outer-div" @onclick="OuterDivClick">
    <div id="inner-div" @onclick="InnerDivClick">
        <button id="button" @onclick="ButtonClick"></button>
    </div>
</div>
@code {

    public Stack<string> ComponentCallStack = new();

    private void OuterDivClick()
    {
        ComponentCallStack.Push("outer div");
    }

    private void InnerDivClick()
    {
        var b = CountPartitions(20, 9);
        Console.WriteLine(b);
        ComponentCallStack.Push("inner div");
    }

    private void ButtonClick()
    {
        ComponentCallStack.Push("button");
    }

    private static int CountPartitions(int elements, int subsets)
    {
        if (elements == 0 || subsets == 0 || subsets > elements)
            return 0;
        if (subsets == 1 || subsets == elements)
            return 1;

        return subsets * CountPartitions(elements - 1, subsets)
               + CountPartitions(elements - 1, subsets - 1);
    }
}

Practically speaking CountPartitions is O(n^2). But still the order was always correct. So I digged into MSDN Task.WhenAll again. Sure it states that the result is in the same order as the input for Task.WhenAll(Task<TResult>[]) but we don't have any result array anyway in our case. We don't have a return value. So I dig a bit deeper and come back with my findings.

linkdotnet commented 2 years ago

So having a look at this:

await Task.WhenAll(DoSomething(1000, "1"), DoSomething(10, "2"));

static async Task DoSomething(int timeout, string output)
{
    await Task.Delay(timeout);
    Console.WriteLine(output);
}

This will output:

2 1

We clearly see that it's not in order. But I don't get it reproduced with the events :D

EDIT: Another finding:

await Task.WhenAll(DoSomething(1000, "1"), DoSomething(10, "2"));

static Task DoSomething(int timeout, string output)
{
    var t = Task.Delay(timeout);
    Console.WriteLine(output);
    return t;
}

Output:

1 2

Without directly awaiting we get in order again... and that makes sense. Because we don't await on our own we leave it to Task.WhenAll which seems to do the right thing.

egil commented 2 years ago

So blazor will run as much it can synchronously, an entire event handler if possible ø

So, actually, now that I think of it, Task.WhenAll is not a problem. All event handlers are invoked in the right order. If they do not complete immediately due to async code, they complete out of order. However, that is also how Blazor does it in production, so this works correctly as far as I can tell.