Possible bug in ASP.NET MVC with form values being replaced

后端 未结 12 628
抹茶落季
抹茶落季 2020-11-29 00:41

I appear to be having a problem with ASP.NET MVC in that, if I have more than one form on a page which uses the same name in each one, but as different types (radio/hidden/e

相关标签:
12条回答
  • 2020-11-29 00:58

    This may be 'by design' but it's not what is documented:

    Public Shared Function Hidden(  
    
      ByVal htmlHelper As System.Web.Mvc.HtmlHelper,  
      ByVal name As String, ByVal value As Object)  
    As String  
    

    Member of System.Web.Mvc.Html.InputExtensions

    Summary: Returns a hidden input tag.

    Parameters:
    htmlHelper: The HTML helper.
    name: The form field name and System.Web.Mvc.ViewDataDictionary key used to look up the value.
    value: The value of the hidden input. If null, looks at the System.Web.Mvc.ViewDataDictionary and then System.Web.Mvc.ModelStateDictionary for the value.

    This would seem to suggest that ONLY when the value parameter is null (or not specified) would the HtmlHelper look elsewhere for a value.

    In my app, I've got a form where: html.Hidden("remote", True) is rendering as <input id="remote" name="remote" type="hidden" value="False" />

    Note the value is getting over-ridden by what is in the ViewData.ModelState dictionary.

    Or am I missing something?

    0 讨论(0)
  • 2020-11-29 00:59

    Example to reproduce the "design problem", and a possible workaroud. There is no workaround for the 3 hours lost trying to find the "bug" though ... Note that this "design" is still in ASP.NET MVC 2.0 RTM.

        [HttpPost]
        public ActionResult ProductEditSave(ProductModel product)
        {
            //Change product name from what was submitted by the form
            product.Name += " (user set)";
    
            //MVC Helpers are using, to find the value to render, these dictionnaries in this order: 
            //1) ModelState 2) ViewData 3) Value
            //This means MVC won't render values modified by this code, but the original values posted to this controller.
            //Here we simply don't want to render ModelState values.
            ModelState.Clear(); //Possible workaround which works. You loose binding errors information though...  => Instead you could replace HtmlHelpers by HTML input for the specific inputs you are modifying in this method.
            return View("ProductEditForm", product);
        }
    

    If your form originally contains this: <%= Html.HiddenFor( m => m.ProductId ) %>

    If the original value of "Name" (when the form was rendered) is "dummy", after the form is submitted you expect to see "dummy (user set)" rendered. Without ModelState.Clear() you'll still see "dummy" !!!!!!

    Correct workaround:

    <input type="hidden" name="Name" value="<%= Html.AttributeEncode(Model.Name) %>" />
    

    I feel this is not a good design at all, as every mvc form developer needs to keep that in mind.

    0 讨论(0)
  • 2020-11-29 01:00

    Same as others I would have expected the ModelState to be used to fill the Model and as we explicitly use the Model in expressions in the view, it should use the Model and not ModelState.

    It's a design choice and I do get why: if validations fail, the input value might not be parseable to the datatype in the model and you still want to render whatever wrong value the user typed, so it's easy to correct it.

    The only thing I don't understand is: why isn't it by design that the Model is used, which is set explicitly by the developer and if a validation error occurred, the ModelState is used.

    I have seen many people using workarounds like

    • ModelState.Clear(): Clears all ModelState values, but basically disables usage of default validation in MVC
    • ModelState.Remove("SomeKey"): Same as ModelState.Clear() but needs micromanagement of ModelState keys, which is too much work and it doesn't feel right with the auto binding feature from MVC. Feels like 20 years back when we were also managing Form and QueryString keys.
    • Rendering HTMLthemselves: too much work, detail and throws away the HTML Helper methods with the additional features. An example: Replace @Html.HiddenFor by <input type="hidden" name="@NameFor(m => m.Name)" id="@Html.IdFor(m=>m.Name)" value="@Html.AttributeEncode(Model.Name)">. Or replace @Html.DropDownListFor by ...
    • Create custom HTML Helpers to replace default MVC HTML Helpers to avoid the by-design issue. This is a more generic approach then rendering your HTML, but still requires more HTML+MVC knowledge or decompiling System.Web.MVC to still keep all other features but disable ModelState precedence over Model.
    • Apply the POST-REDIRECT-GET Pattern: this is easy in some environments, but harder in the ones with more interaction/complexity. This pattern has it's pros and cons and you shouldn't be forced to apply this pattern because of a by-design choice of ModelState over Model.

    Issue

    So the issue is that the Model is filled from ModelState and in the view, we set explicitly to use the Model. Everybody expects the Model value (in case it changed) to be used unless there's a validation error; then the ModelState can be used.

    Currently, in the MVC Helper extensions, the ModelState value gets precedence over the Model value.

    Solution

    So the actual fix for this issue should be: for each expression to pull the Model value the ModelState value should be removed if there is no validation error for that value. If there's a validation error for that input control the ModelState value shouldn't be removed and it will be used like normal. I think this solves the issue exactly, which is better than most workarounds.

    The code is here:

        /// <summary>
        /// Removes the ModelState entry corresponding to the specified property on the model if no validation errors exist. 
        /// Call this when changing Model values on the server after a postback, 
        /// to prevent ModelState entries from taking precedence.
        /// </summary>
        public static void RemoveStateFor<TModel, TProperty>(this HtmlHelper helper,  
            Expression<Func<TModel, TProperty>> expression)
        {
            //First get the expected name value. This is equivalent to helper.NameFor(expression)
            string name = ExpressionHelper.GetExpressionText(expression);
            string fullHtmlFieldName = helper.ViewContext.ViewData.TemplateInfo.GetFullHtmlFieldName(name);
    
            //Now check whether modelstate errors exist for this input control
            ModelState modelState;
            if (!helper.ViewData.ModelState.TryGetValue(fullHtmlFieldName, out modelState) ||
                modelState.Errors.Count == 0)
            {
                //Only remove ModelState value if no modelstate error exists,
                //so the ModelState will not be used over the Model
                helper.ViewData.ModelState.Remove(name);
            }
        }
    

    And then we create our own HTML Helper extensions todo this before calling the MVC extensions:

        public static MvcHtmlString TextBoxForModel<TModel, TProperty>(this HtmlHelper<TModel> htmlHelper,
            Expression<Func<TModel, TProperty>> expression,
            string format = "",
            Dictionary<string, object> htmlAttributes = null)
        {
            RemoveStateFor(htmlHelper, expression);
            return htmlHelper.TextBoxFor(expression, format, htmlAttributes);
        }
    
        public static IHtmlString HiddenForModel<TModel, TProperty>(this HtmlHelper<TModel> htmlHelper,
            Expression<Func<TModel, TProperty>> expression)
        {
            RemoveStateFor(htmlHelper, expression);
            return htmlHelper.HiddenFor(expression);
        }
    

    This solution removes the issue but doesn't require you to decompile, analyze, and rebuild whatever MVC is offering you normally (don't forget also managing changes over-time, browser differences, etc.).

    I think the logic of "Model value unless validation error then ModelState" should have been by-design. If it was, it wouldn't have bitten so many people, but still covered what MVC was intended todo.

    0 讨论(0)
  • 2020-11-29 01:01

    This would be the expected behavoir - MVC doesn't use a viewstate or other behind your back tricks to pass extra information in the form, so it has no idea which form you submitted (the form name is not part of the data submitted, only a list of name/value pairs).

    When MVC renders the form back, it is simply checking to see if a submitted value with the same name exists - again, it has no way of knowing which form a named value came from, or even what type of control it was (whether you use a radio, text or hidden, it's all just name=value when its submitted through HTTP).

    0 讨论(0)
  • 2020-11-29 01:09

    I just ran into same issue. Html helpers like TextBox() precedence for passed values appear to behave exactly opposite what I inferred from the Documentation where it says:

    The value of the text input element. If this value is null reference (Nothing in Visual Basic), the value of the element is retrieved from the ViewDataDictionary object. If no value exists there, the value is retrieved from the ModelStateDictionary object.

    To me, I read that the value, if passed is used. But reading TextBox() source:

    string attemptedValue = (string)htmlHelper.GetModelStateValue(name, typeof(string));
    tagBuilder.MergeAttribute("value", attemptedValue ?? ((useViewData) ? htmlHelper.EvalString(name) : valueParameter), isExplicitValue);
    

    seems to indicate that the actual order is the exact opposite of what is documented. Actual order seems to be:

    1. ModelState
    2. ViewData
    3. Value (passed into TextBox() by caller)
    0 讨论(0)
  • 2020-11-29 01:11

    This issue still exists in MVC 5, and clearly it's not considered a bug which is fine.

    We're finding that, although by design, this is not the expected behavior for us. Rather we always want the value of the hidden field to operate similarly to other types of fields and not be treated special, or pull its value from some obscure collection (which reminds us of ViewState!).

    A few findings (correct value for us is the model value, incorrect is the ModelState value):

    • Html.DisplayFor() displays the correct value (it pulls from Model)
    • Html.ValueFor does not (it pulls from ModelState)
    • ModelMetadata.FromLambdaExpression(expression, htmlHelper.ViewData).Model pulls the correct value

    Our solution is to simply implement our own Extension:

            /// <summary>
            /// Custom HiddenFor that addresses the issues noted here:
            /// http://stackoverflow.com/questions/594600/possible-bug-in-asp-net-mvc-with-form-values-being-replaced
            /// We will only ever want values pulled from the model passed to the page instead of 
            /// pulling from modelstate.  
            /// Note, do not use 'ValueFor' in this method for these reasons.
            /// </summary>
            public static IHtmlString HiddenTheWayWeWantItFor<TModel, TProperty>(this HtmlHelper<TModel> htmlHelper,
                                                        Expression<Func<TModel, TProperty>> expression,
                                                        object value = null,
                                                        bool withValidation = false)
            {
                if (value == null)
                {
                    value = ModelMetadata.FromLambdaExpression(expression, htmlHelper.ViewData).Model;
                }
    
                return new HtmlString(String.Format("<input type='hidden' id='{0}' name='{1}' value='{2}' />",
                                        htmlHelper.IdFor(expression),
                                        htmlHelper.NameFor(expression),
                                        value));
            }
    
    0 讨论(0)
提交回复
热议问题