navigation
 Tuesday, November 06, 2007

Have you ever seen one of those web pages that's about to charge your credit card and practically begs you not to click the "submit" button more than once? Even assuming that every user of the page actually reads the text (and many don't), many users have a habit of double-clicking everything, because that's how it works on their desktop. The only reliable way to prevent submitting a web form multiple times is to make it impossible. I recently came upon a situation where I needed to prevent this form happening in one of my web applications, so I decided to create a custom button that would encapsulate this behavior, so that it could be easily shared and re-used amongst the team.

First Steps

The basic idea is simple enough: when the button is clicked, disable it, and then let ASP.NET post the page. I created a subclass of Button and began hacking. My first attempt looked very much like this:

protected override void OnPreRender( EventArgs e )
{
    OnClientClick = "this.disabled = true;" + OnClientClick;
    base.OnPreRender( e );
}

This accomplished disabling the button, but now the button didn't cause a postback! A Google search indicated that I should set UseSubmitBehavior to false. When UseSubmitBehavior is true (the default), ASP.NET uses the browser's built-in submission behavior, and when the property is false, ASP.NET renders client script to perform the postback instead. I guess that this means that Internet Explorer won't submit a form if the source button is disabled. At any rate, this means that my button will need to have a default of false for UseSubmitBehavior. I also decided to disallow changing the value, since the button just won't work otherwise. My code now looked something like this:

public class SubmitButton : Button
{
    public SubmitButton()
        : base()
    {
        base.UseSubmitBehavior = false;
    }

    protected override void OnPreRender( EventArgs e )
    {
        OnClientClick = "this.disabled = true;" + OnClientClick;
        base.OnPreRender( e );
    }

    [DefaultValue( false )]
    public override bool UseSubmitBehavior
    {
        set { }
    }
}

This is all well and good, but what if there is validation on the page, and the validation fails? Now you're stuck with a disabled submit button and no way to post the page!

Supporting Validation

My next attempt was to create a script for OnClientClick that would duplicate everything that ASP.NET does when it performs a postback. Fortunately, this is not too difficult thanks to methods of ClientScriptManager:

protected override void OnPreRender( EventArgs e )
{
    OnClientClick = CreateClientClick();
    base.OnPreRender( e );
}

private string CreateClientClick()
{
    if ( Page == null )
        throw new Exception( "Page is null. SubmitButton can't create scripts." );

    string postback = Page.ClientScript.GetPostBackEventReference( GetPostBackOptions() );
    return "this.disabled = true;" + OnClientClick + postback + "this.disabled = Page_IsValid;";
}

This works pretty well, except that it doesn't prevent ASP.NET from rendering yet another postback call and appending it to the end of all of this. That means validation runs twice, and depending on the browser's script engine, the postback might occur twice!

What we really need is to disable the button, let ASP.NET do all of its stuff including validation, and then re-enable the button if validation failed. This means that we need to be able to have client script that runs before and after whatever client script ASP.NET renders, and that's where things begin to get hairy, because ASP.NET assumes that its postback will be the last thing to get rendered, and it doesn't make it easy at all to change that. Here is a quick summary of the things I tried to get ASP.NET to let me have control over how it renders the onclick attribute:

  1. Override AddAttributesToRender. This is where Button adds the onclick attribute to the HtmlTextWriter that will ultimately write the control to the output stream. Unfortunately, no amount of tweaking the Button's properties before invoking the base method will allow script to be appended to the end of the onclick handler after the ASP.NET postback script, and after the base method has been called, the script has been input into the HtmlTextWriter, where it may not be modified or even retrieved.
  2. Replace AddAttributesToRender. This approach could work; I could copy the disassembled code from the Button class and paste it into my subclass with the modifications I want, but this offends my delicate sensibilities. :) It's inelegant, the resulting code won't be very human-readable, and it won't inherit any changes in ASP.NET that might occur as the result of a patch or service pack.
  3. Next, I considered writing my own HtmlTextWriter that would detect when an onclick attribute was added, and inject the code I wanted there. ASP.NET does allow this kind of custom rendering, but unfortunately, it's intended as a way to adapt ASP.NET output to different devices and browsers, and it affects all content, rather than one specific control, so this approach would be incompatible with adaptive rendering for alternative devices.
  4. Lastly, I looked at creating a WebControlAdapter, which is specific to a particular control, but unfortunately, it did not offer control that was fine-grained enough. I needed to touch code smack dab in the middle of a 60-line method that rendered other attributes as well, but WebControlAdapters only offer control at the method level.

At this point, I had to give up on trying to control the way that ASP.NET renders the button. ASP.NET just wasn't designed to give that kind of control. Finally, I turned to my last option: solving the problem using JavaScript.

The Solution

I'm no expert at JavaScript, I know that it's a very flexible and powerful language, and it can execute without interference from ASP.NET's assumptions about what I might want to do in a custom control's lifecycle, so I have high hopes. First I wrote the function that I wanted to execute when the button was clicked:

function SubmitButton_Click()
{
    var src = event.srcElement;
    src.disabled = true;
    src.aspnet_onclick();
    src.disabled = Page_IsValid;
}

Whoops! If there are no validators on the page, Page_IsValid will be undefined, so that last line really needs to go like this:

src.disabled = typeof( Page_IsValid ) != "undefined" ? Page_IsValid : true;

I know that you can add new properties to an element simply by assigning them, so my plan was to use JavaScript to capture the onclick handler that ASP.NET rendered and assign it to a new property called aspnet_onclick, then surround the call to that with my own code. Now I needed to create the function that would accomplish the swap:

function SubmitButton_InitOnClick( id )
{
    var sb = document.getElementById( id );
    sb.aspnet_onclick = sb.onclick;
    sb.onclick = SubmitButton_Click;
}

I saved these scripts in a separate .js file. Now all I need to do is to get my custom button to use the scripts:

protected override void OnPreRender( EventArgs e )
{
    RegisterClientScript();
    RegisterStartupScript();
    base.OnPreRender( e );
}

private void RegisterClientScript()
{
    if ( Page == null )
        throw new Exception( "Page is null. SubmitButton can't register scripts." );

    string key = "SubmitButton";
    string url = ResolveClientUrl( "SubmitButton.js" );
    if ( !Page.ClientScript.IsClientScriptIncludeRegistered( key ) )
        Page.ClientScript.RegisterClientScriptInclude( key, url );
}

private void RegisterStartupScript()
{
    if ( Page == null )
        throw new Exception( "Page is null. SubmitButton can't register scripts." );

    string key = UniqueID + "_Startup";
    string script = String.Format( "SubmitButton_InitOnClick( '{0}' );", UniqueID );
    if ( !Page.ClientScript.IsStartupScriptRegistered( key ) )
        Page.ClientScript.RegisterStartupScript( typeof( Page ), key, script, true );
}

The resulting output does exactly what we want: disables the button, performs whatever action the button would have done if it was an ordinary ASP.NET button, including whether the button causes validation, and using the button's validation group, and then finally conditionally re-enables the button.

I've attached the complete source code to this blog post. As far as I know, this is the best single-click button available on the web. Enjoy, and never write "Please only click the button once" ever again!

SubmitButton12.zip (2 KB)