Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Boolean field rendered with different cases

Bit of a weird one for anyone with thoughts on it…I’m rendering a hidden Boolean field on a particular page. However, I get two slightly different markups for the same field depending on whether a particular event happened prior in the process. The two fields being generated are;

<input id="HasPreviouslyIssuedCard" name="HasPreviouslyIssuedCard" type="hidden" value="false" />

and

<input id="HasPreviouslyIssuedCard" name="HasPreviouslyIssuedCard" type="hidden" value="False" />

The issue is the case of the text in the “value” attribute which you’ll notice is different and later affects JS conditions. The razor markup generating this is;

@Html.Hidden("HasPreviouslyIssuedCard", Model.HasPreviouslyIssuedCard?.ToString(), new { id = nameof(Model.HasPreviouslyIssuedCard) })

However, I’ve also tried a variant using the following with the same difference in rendering the hidden field;

@Html.HiddenFor(m => m.HasPreviouslyIssuedCard)

What event do I do to get this difference? To get the uppercase variant, I hit the browser Back button before getting to the relevant page. Both methods load the data the same way and pass the same value into the renderer in the same way. Two different outputs.

Bear in mind that this is a boolean, value-type field being rendered. There shouldn’t be much scope to tinker with. There are a variety of ways to work around this but as we have a couple of items on the backlog relating to boolean fields and the back button, I’d like to explain this rather than work around it.

My best guess is either that hitting the back button is somehow changing the state of the renderer or that some other flag in the model is different (there are 70+ fields as it's a wizard) is changing how the renderer interprets how to case the boolean value. The value is the same, the page is the same, the data is read in the same way.

Based on this page (Why does Boolean.ToString output "True" and not "true"), we should hypothetically be getting the uppercase variant all the time but this isn't the result.

Any takers/ideas/thoughts?

EDIT 1

Digging through MVC's rendering logic in HiddenFor() method, eventually Convert.ToString(value, CultureInfo.CurrentCulture) is called. I cannot get this to produce a lower-case boolean when called directly, yet it is clearly doing so. My current culture code is set to en-IE but I'm seeing the uppercase boolean values when calling it directly.

EDIT 2

I've done a bit more tinkering and tracing through my application and can provide a bit more detail on what's going on though I haven't yet been able to reproduce this in a simpler application.

MVC 5 Application: It has;

  1. Initial landing page with it's URL at the root domain / retrieved via HTTP GET. Boolean input tags render as True/False
  2. First page in a wizard at URL /Apply retrieved via HTTP GET. Boolean input tags render as True/False
  3. Second page in wizard at same URL after user submitted page on step 2. Retrieved via HTTP POST. Case of input tags now render as true/false.
  4. Hit browser's Back button and hit trap page (we set the browser history to always hit a trap page on Back as it plays merry hell with the wizard).
  5. User hits button on trap page to bring them back into the application where they left off. input tags now back rendering in uppercase (the original reported issue).

I've been delving into the MVC library using ILSpy to try and scan through and MVC (if I'm reading the code correctly) actually uses an implementation of IConverter to write the boolean value, not Convert.ToString(value, CultureInfo.CurrentCulture) as I originally thought.

The stack of code traced from the call to HiddenFor() is (I think);

  1. System.Web.Mvc.InputExtentions.HiddenFor() (public function)
  2. System.Web.Mvc.InputExtentions.HiddenHelper() (private function, some logic here for arrays but doesn't apply in our situation)
  3. System.Web.Mvc.InputExtentions.InputHelper() (private function, magic happens here)

Decompiled code for System.Web.Mvc.InputExtentions.InputHelper();

private static MvcHtmlString InputHelper(HtmlHelper htmlHelper, InputType inputType, ModelMetadata metadata, string name, object value, bool useViewData, bool isChecked, bool setId, bool isExplicitValue, string format, IDictionary<string, object> htmlAttributes)
{
    string fullHtmlFieldName = htmlHelper.ViewContext.ViewData.TemplateInfo.GetFullHtmlFieldName(name);
    if (string.IsNullOrEmpty(fullHtmlFieldName))
    {
        throw new ArgumentException(MvcResources.Common_NullOrEmpty, "name");
    }
    TagBuilder tagBuilder = new TagBuilder("input");
    tagBuilder.MergeAttributes<string, object>(htmlAttributes);
    tagBuilder.MergeAttribute("type", HtmlHelper.GetInputTypeString(inputType));
    tagBuilder.MergeAttribute("name", fullHtmlFieldName, true);
    string text = htmlHelper.FormatValue(value, format);
    bool flag = false;
    switch (inputType)
    {
    case InputType.CheckBox:
    {
        bool? flag2 = htmlHelper.GetModelStateValue(fullHtmlFieldName, typeof(bool)) as bool?;
        if (flag2.HasValue)
        {
            isChecked = flag2.Value;
            flag = true;
        }
        break;
    }
    case InputType.Hidden:
        goto IL_131;
    case InputType.Password:
        if (value != null)
        {
            tagBuilder.MergeAttribute("value", text, isExplicitValue);
            goto IL_16C;
        }
        goto IL_16C;
    case InputType.Radio:
        break;
    default:
        goto IL_131;
    }
    if (!flag)
    {
        string text2 = htmlHelper.GetModelStateValue(fullHtmlFieldName, typeof(string)) as string;
        if (text2 != null)
        {
            isChecked = string.Equals(text2, text, StringComparison.Ordinal);
            flag = true;
        }
    }
    if (!flag && useViewData)
    {
        isChecked = htmlHelper.EvalBoolean(fullHtmlFieldName);
    }
    if (isChecked)
    {
        tagBuilder.MergeAttribute("checked", "checked");
    }
    tagBuilder.MergeAttribute("value", text, isExplicitValue);
    goto IL_16C;
    IL_131:
    string text3 = (string)htmlHelper.GetModelStateValue(fullHtmlFieldName, typeof(string));
    tagBuilder.MergeAttribute("value", text3 ?? (useViewData ? htmlHelper.EvalString(fullHtmlFieldName, format) : text), isExplicitValue);
    IL_16C:
    if (setId)
    {
        tagBuilder.GenerateId(fullHtmlFieldName);
    }
    ModelState modelState;
    if (htmlHelper.ViewData.ModelState.TryGetValue(fullHtmlFieldName, out modelState) && modelState.Errors.Count > 0)
    {
        tagBuilder.AddCssClass(HtmlHelper.ValidationInputCssClassName);
    }
    tagBuilder.MergeAttributes<string, object>(htmlHelper.GetUnobtrusiveValidationAttributes(name, metadata));
    if (inputType == InputType.CheckBox)
    {
        StringBuilder stringBuilder = new StringBuilder();
        stringBuilder.Append(tagBuilder.ToString(TagRenderMode.SelfClosing));
        TagBuilder tagBuilder2 = new TagBuilder("input");
        tagBuilder2.MergeAttribute("type", HtmlHelper.GetInputTypeString(InputType.Hidden));
        tagBuilder2.MergeAttribute("name", fullHtmlFieldName);
        tagBuilder2.MergeAttribute("value", "false");
        stringBuilder.Append(tagBuilder2.ToString(TagRenderMode.SelfClosing));
        return MvcHtmlString.Create(stringBuilder.ToString());
    }
    return tagBuilder.ToMvcHtmlString(TagRenderMode.SelfClosing);
}

EDIT 3

Just to restate as there are a few comments on the javascript side of things. I had considered this early on in trying to diagnose the issue. To rule out any potential JS interference/manipulation, I used Fiddler to capture the HTML in transmission. The HTML that MVC is generating is changing case - and I can see this in Fiddler, at a point before JS would even load, never mind run. It's not a JS issue.

like image 223
DiskJunky Avatar asked Jun 23 '17 11:06

DiskJunky


1 Answers

It seems I was wrong, it was JS after all, though not at the point where I was trying to rule it out. Sequence of events to re-produce this;

  1. Create HTML form with boolean hidden field + non-submit button
  2. In the onclick event for the button, have a piece of JS that sets the boolean field to true (lowercase)
  3. In your POST action in the controller, render the same view

After the POST the boolean field will have a lowercase value rather than uppercase.

What's going on? Well, HiddenFor() (and its variants) will render the boolean value from ViewData.ModelState rather than the model property's ToString() value if the key is present in the ModelState collection. Seems intuitive but what can (and did for me) throw you is that all other data types, the model binder is quite specific - the ModelState value and your model's property values will be identical. Except in the case for a boolean - the model binder is smart enough to treat True and true as the same when converting a POST but this leaves the ModelState value and your stringified property value out of whack if it was set via a JS boolean. JS will have set the ModelState value as true while your model property's ToString() value comes out as True. And in JS, "true" !== "True".

As for why this reset for me after hitting the browser's Back button - the trap page that we have doesn't have the form values and links back into the application via HTTP GET which causes HiddenFor to call the model property's .ToString() rather than pulling it from the ModelState as it's not in there at that point in time. Likewise, in my tests, the user would be at a point in the wizard long after it would be set via JS, so it would stay in uppercase as they continued through the wizard.

My assumption had been that JS was kicking in after the page loaded. It was actually due to JS setting a value before kicking off the POST and that lower case value persisted through the page life cycle via ModelState. Go figure.

EDIT

Code to reproduce;

Model;

public class Test
{
    public bool Sample { get; set; }
}

Razor markup;

@model TestModelValueCase.Models.Test
@{
    ViewBag.Title = "Test Page";
}

@using (Html.BeginForm())
{
    @Html.HiddenFor(m => m.Sample)
    <div>
        <label>Hidden Value:</label>
        <span id="_uiValue"></span>
    </div>
    <button type="submit">Try Post</button>
}
@section scripts
{
    <script type="text/javascript" language="javascript">
        $(document).ready(function() {
            var source = $('#@nameof(Model.Sample)');
            $('#_uiValue').html(source.val());
            source.val(true);
        });
    </script>
}

Controller;

    public ActionResult Index()
    {
        Test model = new Test();
        return View(model);
    }

    [HttpPost]
    public ActionResult Index(Test model)
    {
        return View(model);
    }
like image 101
DiskJunky Avatar answered Nov 15 '22 06:11

DiskJunky