-
Notifications
You must be signed in to change notification settings - Fork 10.3k
Forms behavior clarifications #49340
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
Forms behavior clarifications #49340
Conversation
1974dd1
to
1fbcb22
Compare
d89d848
to
d3122c7
Compare
d11e239
to
a0224b1
Compare
…Still needs more test updates.
d087641
to
24af917
Compare
One further usability issue I've realised. We're not providing any mechanism to handle the case where a submitted form has an unmatched handler name due to changes in underlying data. We just return a 400. While designing this feature, we've been saying that people rendering lists would be advised to create a unique form or mapping scope name for each item in the list, derived from an ID for the item. But then this happens:
This will lead to a 400 error, but the app developer is not at fault here. They really need some way to provide a nice UX around "sorry, this item was already deleted" or similar. We don't provide a good way to do that if the error comes from "no matching handler". The one way I can think of doing this well is having a single large form surrounding the entire list, and then for each item, have a different submit button, e.g.: <form method="post" @formname="list-editor" @onsubmit="HandleAction">
<AntiforgeryToken />
<button name="deleteitem" value="1" type="submit">Delete item 1</button>
<button name="deleteitem" value="2" type="submit">Delete item 2</button>
<button name="deleteitem" value="3" type="submit">Delete item 3</button>
</form>
@code {
[SupplyParameterFromForm] public int? DeleteItem { get; set; }
void HandleAction()
{
if (DeleteItem.HasValue)
{
MyDb.DeleteById(DeleteItem.Value); // Or if it was already deleted, show some UI for that, etc.
}
}
} AFAICT this works fine and is arguably much more useful since you can also round-trip other state in the single big form, and you only have to render AntiforgeryToken once. My point with raising this is that we probably should not be advising people to generate form names programmatically based on IDs from data, unless they can truly guarantee the underlying data never changes in some way that would disrupt it. |
_shouldGenerateFieldNames = EditContext.ShouldUseFieldIdentifiers; | ||
} | ||
else | ||
{ | ||
// Ideally we'd know if we were in an SSR context but we don't | ||
_shouldGenerateFieldNames = !OperatingSystem.IsBrowser(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should do this always or have a way to configure it in webassembly (as it was possible with EditContext). Otherwise people will start on server, write CSS rules using name, etc. and then suddenly those things will start to fail in webassembly.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should do this always or have a way to configure it in webassembly (as it was possible with EditContext)
It is still configurable in WebAssembly, as the EditContext approach still works. People who need the field names in WebAssembly for some reason can still use this to configure it on a per-area basis.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok, so this is then just moving it from EditContext to here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The !OperatingSystem.IsBrowser()
default is still in EditContext
as a default, but there also needs to be a default in InputBase
for the case where there is no EditContext
.
throw new InvalidOperationException($"The mapping scope name '{Name}' starts with a disallowed character."); | ||
} | ||
|
||
_cascadingValueSupplier = new SupplyParameterFromFormValueProvider(FormValueModelBinder, Name); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
naming: ModelBinder (replace for something else)
/// <param name="valueType">The <see cref="Type"/> of the value to map.</param> | ||
/// <param name="parameterName">The name of the parameter to map data to.</param> | ||
public FormValueMappingContext(string formName, Type valueType, string parameterName) | ||
public FormValueMappingContext(string mappingScopeName, string? restrictToFormName, Type valueType, string parameterName) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Second parameter "sounds like a boolean" but it's not. why not just "formName"?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
While working on this code I found many parts of it hard to make sense of because they just had parameters called things like "name" or "handler" without indicating what it's a name of, whether it's an incoming value we're checking against vs an outgoing value we're emitting, etc. I had to keep tracing backwards through multiple layers of reference to work out what a value really represented.
In this case I was trying to clarify that this is not a name of the incoming form, but rather is a filter criteria that we're applying. It makes it clearer that when you read this value, you're not getting the name of the posted form (as it would sound if it was FormName
), but rather are getting the name of a restriction that's in effect within this FormValueMappingContext
.
Reviewing it now I see that's inconsistent with the name MappingScopeName
so perhaps I should rename these two to AllowMappingScopeName
and AllowFormName
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Reviewing it now I see that's inconsistent with the name MappingScopeName so perhaps I should rename these two to AllowMappingScopeName and AllowFormName.
Making this change in my follow-up PR.
Overall looks similar to the existing implementation. There are a few things that are hard to understand:
Form as hidden input:
Concrete suggestions:
|
It lets you send a POST request to the server which can receive data in the form body. Whether or not the developer wants to trigger an
It doesn't benefit anyone to find based on the scope alone, since the form name is always provided in the request by default.
The scope name is an empty string by default. And that means we will bind to any
It's not really viable to expect people to write out the handler name by hand, as it involves combining with a potential scope name too. I find it makes a very significant difference in usability. It would be pretty much impractical to use this if people had to remember the markup for a hidden field with a magic name every time they wanted a form. Having a component that emits this would still require baking special integration into the framework, as it would have no way of discovering the form name without magically looking at the rendertree of its parent.
These endpoints are specifically Razor Component endpoints, not arbitrary server-side endpoints, so I don't find it problematic that we require a single specific system-defined field to indicate which form is being submitted. There are dozens of other places we control aspects of the data sent to the server when they tie in with framework features. For example,
The routing decision is that it goes to a Razor Component endpoint, isn't it? The behavior after that is internal to Razor Component endpoints. |
This does not answer the questions I raised, or why do we actually need to support this as opposed to binding the handler.
Why then not a component that handles it for you, similar to how Antiforgery works?
EditForm is an optional component, you can choose to use it or use something different. This is a system primitive that users can't change.
Using the term routing might have caused confusion. What I mean is that we are forcing a concrete way of selecting what properties get to bind + what form gets to dispatch an action without any way for the developer to override that choice. It's still not clear to me, what the implications of putting the value inside a hidden field are security wise, specially since the limits for the form are much bigger than for the request headers/URL. Specifically, some of the scenarios that I raised above. |
Perhaps the issue is that I'm not seeing what problem you're citing.
If people want to put in logic that applies on an event handler, then they use an event handler. If they want to put logic in
As I mentioned, it doesn't save us from having to put special handling in the framework to make that work. So at that point, we might as well just make it even easier for people by covering the requirement automatically.
This only affects forms that submit to a Razor Component endpoint, not arbitrary forms or arbitrary endpoints. We've decided Razor Component endpoint form posts only work when a handler is specified, so it's helpful to do that.
There are many kinds of choice the developer can make but I'm not sure which specific kinds of choices you think are required and missing here, or why it would be substantially different if we made them type out the handler field manually.
We put antiforgery data in there too, and it's less sensitive than that. |
We put an opinion in, but any developer that build on top can override it and apply their own idiom. They aren't boxed into the handler being something that gets delivered within the form. A service can retrieve the value from the request and produce the required string. Anyone can choose how that value gets generated and in what form it gets delivered from the browser.
The choices are where those parameters come from (query, header, url or form body) as opposed to having a fixed way in the framework that can be changed.
It's different because by the time you check antiforgery you are already sure you want to do something with the body. In this case, you are trying to decide whether you want/need to do something with it, and that has a fix cost that can be controlled from a third-party. With this in mind, I think at this point we have different views on the subject, so let's get this in. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I left some comments in case you want to consider them
Thanks for the further feedback. We can certainly add more options in the future if demand arises. Until then, hopefully the approach here will be meet needs in the vast majority of cases, while being pretty easy to discover. |
I tried implementing a similar approach to this, with some data being loaded via an external API and ran into some unexpected behaviour. Here's a minimal repro: In this example there's one form which enables the user to search some arbitrary data. Then the results load and they are rendered inside another form, for the purposes of enabling interaction with each result (but still using forms and static SSR). At the moment, when you click a button in the search results, the form is submitted, but I was hoping the search results would remain present on the screen. As it is, they disappear as soon as you try to interact with any of the items. (this is with data-enhance enabled). dotnet: 8.0.100-rtm.23503.6 |
Hi @jonhilt. It looks like you just commented on a closed PR. The team will most probably miss it. If you'd like to bring something important up to their attention, consider filing a new issue and add enough details to build context. |
This is just about all the remaining usability work for SSR form handling.
In this PR:
<form>
more usable<form>
<form>
are reduced to havingmethod=post
and some@formname
(also,<AntiForgeryToken>
if that's enabled, which it is by default). Having an@onsubmit
itself is an independent choice, and you don't have to supply ahandler
field manually.<FormMappingScope>
, its name simply overrides any parent name, like other cascaded values. It's simpler and still covers all the scenarios (and in most cases people won't use this anyway)..value
prop is different from the value attribute)@onsubmit:form
to@formname