2012/04/14

Script registration labyrinth – startup scripts and $find

You are an ASP.NET developer working for surrealistic corporation. You have an ASP.NET timer on your ASPX page and it should be disabled when a particular condition is met. This condition can be evaluated only on the server. There is Enabled property in Timer class that can be easily used to fulfill customer’s requirement but it is against corporate identity. You are expected to use client side API instead.

OK. You have a timer:
<asp:Timer runat="server" ID="BretonTimer" OnTick="BretonTimer_Tick"
    Interval="2000" />

To use client side API of ASP.NET AJAX controls $find method has to be utilized to find the control instance - it is an client-side control instance not DOM element. Timer’s client-side API is not well documented but there are some clues on the Internet. So let’s choose _stopTimer function for this purpose:
string script = string.Format("$find('{0}')._stopTimer();", BretonTimer.ClientID);

Create a button just to test this undocumented function:
<asp:Button runat="server" ID="StopBreton" Text="Stop!"
    OnClientClick="$find('BretonTimer')._stopTimer(); return false;" />

Click on the button to test that API works well.

Synchronous postback
So as the last step this script should be registered as a startup script and today’s work will be done:
string script = string.Format("$find('{0}')._stopTimer();", BretonTimer.ClientID);
ScriptManager.RegisterStartupScript(this, this.GetType(), "key1", script, true);

The timer is really off after page reload. There is a last annoyance, a javascript error: $find("BretonTimer") is null

Why $find returns an instance when the script is executed within click event handler and fails when the same script is run during page load? Let’s see how page source looks like.

Startup script is near the page end as expected:
$find('BretonTimer')._stopTimer();Sys.Application.initialize();
Sys.Application.add_init(function() {
    $create(Sys.UI._Timer, {"enabled":true,"interval":2000,"uniqueID":"BretonTimer"}, null, null, $get("BretonTimer"));

But what is on the next line? It seems like something what is intended to create client-side instance of the timer control. ASP.NET AJAX design does not follow idiosyncratic philosophy of your corporation, so it is not possible to find an instance of control that was not created yet.

Add_init attaches event handler to the client-side init event. It reminds server-side page lifecycle. And really there is the client-side load event as well. Load event handlers are executed after init handlers and it is exactly what you need (see Ajax Client Life-Cycle Events for more information):
string script = string.Format("Sys.Application.add_load(function() {{ $find('{0}')._stopTimer(); }});", BretonTimer.ClientID);
ScriptManager.RegisterStartupScript(this, this.GetType(), "key1", script, true);

It works! Timer is not running anymore and there is no javascript error.
But your sixth sense warns you that there is something wrong with this solution... What about partial postback?

Partial postback
To avoid reloads of whole page you are forced to use an UpdatePanel. You modify the markup in that way:
<asp:UpdatePanel runat="server" ID="UpdatePanel1">
    <ContentTemplate>
        <asp:Timer runat="server" ID="BretonTimer" OnTick="BretonTimer_Tick" Interval="2000" />
        <asp:Button runat="server" ID="StopBretonButton" Text="Stop!" OnClick="StopBretonButton_Click" />
        <asp:Button runat="server" ID="JustAnotherPostbackButton" Text="Just another postback" />
    </ContentTemplate>
    <Triggers>
        <asp:AsyncPostBackTrigger ControlID="BretonTimer" />
        <asp:AsyncPostBackTrigger ControlID="StopBretonButton" />
    </Triggers>
</asp:UpdatePanel>

and code behind:
protected void StopBretonButton_Click(object sender, EventArgs e)
{
    string script = string.Format("Sys.Application.add_load(function() {{ $find('{0}')._stopTimer(); }});", BretonTimer.ClientID);
    ScriptManager.RegisterStartupScript(this, this.GetType(), "key1", script, true);
}

When “Stop!” button is clicked, timer is stopped properly. But there is a strange feeling in your guts. Something is wrong.
Add an alert message to make sure what is really happing:
string script = string.Format("Sys.Application.add_load(function() {{ alert('load event handler');$find('{0}')._stopTimer(); }});", BretonTimer.ClientID);

Alert message “load event handler” is displayed after pushing “Stop!” button. But when it is pushed for second time, two message boxes are displayed. Push “Just another postback” button and you will get two message boxes as well. It means load event handler remains attached to the event after partial postback.

For init events it would be a catastrophic behavior. They are used by ASP.NET AJAX framework heavily to run $create functions. When a new event handler would be attached to init event after every partial postback then the page would be unusable after a while because browser would spend a lot of time executing many init identical event handlers. It can be hardly true.

Let’s do an experiment:
string script = string.Format("Sys.Application.add_init(function() {{ alert('init event handler'); }});", BretonTimer.ClientID);

Only one message box is shown regardless how many times the button is clicked. So there is a fundamental difference between load and init events. Why?

It is difficult to find an answer (MSDN keep silent). If you find one, please let me know, I would love to hear any reason. It is pretty surreal and well suited to the corporate identity.

Load event solution is well crafted by AJAX Control Toolkit authors. The handler is unregistered after it is executed for the first time:
(function () {
    var fn = function () {
        $find('BretonTimer')._stopTimer();
        Sys.Application.remove_load(fn);
    };

    Sys.Application.add_load(fn);
})();

The beauty of this solution is in the fact that you don’t need to generate a unique function name for each event handler.

Here is the final markup and code behind.

Conclusion
The example with Timer control is quite absurd and may seem useless. But frameworks like AJAX Control Toolkit or Telerik are based on $create and $find functions and you have to register some startup scripts using $find function on and off. I chose the Timer control as something that is known to all users of those ASP.NET AJAX frameworks.
Registering startup script that uses $find function is not as straightforward as it seems for the first time. AJAX Control Toolkit solution is safe for synchronous and partial postbacks:
(function () {
    var fn = function () {
        var ajaxControl = $find('AJAX control');
        // do something usefull
    };

    Sys.Application.add_load(fn);
})();
It is based on client side page life cycle which is not very commonly known. It handles a little bit shocking load event behavior that is not documented on MSDN. It is good to know about these ASP.NET AJAX parts.

1 comment: