Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Closed
egil opened this issue Nov 12, 2021 · 14 comments · Fixed by #580
Labels
bug Something isn't working

Comments

@egil
Copy link
Member

egil commented Nov 12, 2021

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:

  • bUnit version: 1.3.x

Additional context:

@egil egil added the bug Something isn't working label Nov 12, 2021
@linkdotnet
Copy link
Sponsor Collaborator

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
Copy link
Member Author

egil commented Dec 13, 2021

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
Copy link
Sponsor Collaborator

linkdotnet commented Dec 13, 2021

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
Copy link
Sponsor Collaborator

linkdotnet commented Dec 14, 2021

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.
  • 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 ?
  • 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)
  • Forms can be submitted from outside the form itself (see code in comment).

@egil
Copy link
Member Author

egil commented Dec 14, 2021

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
Copy link
Sponsor Collaborator

linkdotnet commented Dec 14, 2021

<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
Copy link
Sponsor Collaborator

linkdotnet commented Dec 16, 2021

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
Copy link
Member Author

egil commented Dec 17, 2021

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

@egil
Copy link
Member Author

egil commented Dec 17, 2021

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
Copy link
Sponsor Collaborator

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
Copy link
Member Author

egil commented Dec 18, 2021

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
Copy link
Sponsor Collaborator

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
Copy link
Sponsor Collaborator

linkdotnet commented Dec 18, 2021

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
Copy link
Member Author

egil commented Dec 18, 2021

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.

@egil egil closed this as completed in #580 Dec 18, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working
Projects
None yet
Development

Successfully merging a pull request may close this issue.

2 participants