Saturday 12 May 2007

Messing with UpdatePanel to speed up and extend Ajax

I was looking for an answer to the problem of a grid inside an update panel. You see, since the rows and cells of a DataGrid or a GridView are special controls that can't be put inside panels, only in specific parent controls like tables and rows, there is no way to update only a row or a cell of a grid. If the grid is big, it takes a long time to render it entirely, it takes the CPU to 100%, it even blocks the animation of gifs. That results in ugly Ajax.

So, my first thought was: is there a way to update only what has changed? As I was saying in a previous post, a Page is rendered as its HTML string the first time it is loaded and then each Ajax postback makes it render like a list of tokens. The token format is this:

length|type|id|content|


For example 100|updatePanel|UpdatePanel1|<inner HTML of panel of 100 bytes>|

What if I would to insert my own tokens, then? Could I, let's say, change the innerHTML of a control outside of the UpdatePanel? And the answer is YES!

There are 20 token types:
  • updatePanel
  • hiddenField
  • arrayDeclaration
  • scriptBlock
  • expando
  • onSubmit
  • asyncPostBackControlIDs
  • postBackControlIDs
  • updatePanelIDs
  • asyncPostBackTimeout
  • childUpdatePanelIDs
  • panelsToRefreshIDs
  • formAction
  • dataItem
  • dataItemJson
  • scriptDispose
  • pageRedirect
  • error
  • pageTitle
  • focus


Most are not interesting, but 4 of them are!

type:updatePanel
If you add a token to the rendered page string that has the type updatePanel and the id is the UniqueID or ClientID of a control, the content will replace the innerHTML of that control, even if the control is not in an UpdatePanel.

type:hiddenField
If you add a token to the rendered page string that has the type hiddenField and the id is the UniqueID or ClientID of a control, the content will replace the value html property of that control. You can use it on hidden fields, but also on any type of input or html element that has a value. If the control does not exist, a hidden input will be created with that id and then the value will be set. You could read that value after a normal PostBack, let's say.

type:expando
If you add a token to the rendered page string that has the type expando a script will be executed in Javascript that looks like this:
id=content

Example: 5|expando|document.getElementById('TextBox1').style.backgroundColor|'red'|
This will result in the change of the background color of the control with id TextBox1 to red.

type:focus
If you add a token to the rendered page string that has the type focus and the content is a ClientID, then the focus will be set to that control provided that the focus.js script has been loaded. This script is loaded when you use Page.SetFocus. So, in order to set the focus to a control using this method, you must use SetFocus in PageLoad on any control you would like.

Why not use SetFocus, then, and be done with it? Well, because this, as all the methods above work on ANY control in the page, not just the ones in the update panel.

And now the code
using System;
using System.IO;
using System.Web.UI;
 
public partial class _Default : Page
{
    protected void Page_Load(object sender, EventArgs e)
    {
        // Use SetFocus so that focus.js is loaded
        SetFocus(TextBox1);
    }
 
    // get the token for a javascript property change
    private string TokenizeProperty(string value, string property)
    {
        return string.Format("{0}|expando|{1}|{2}|", value.Length, property, value);
    }
 
    // get the token for a javascript value change
    private string TokenizeValue(string content, string controlID)
    {
        return string.Format("{0}|hiddenField|{1}|{2}|", content.Length, controlID, content);
    }
 
    // get the token for setting focus to a control through javascript
    private string TokenizeFocus(string controlID)
    {
        return string.Format("{0}|focus||{1}|", controlID.Length, controlID);
    }
 
    // get the token to replace the innerHTML through javascript
    private string TokenizeInnerHtml(string content, string controlID)
    {
        return string.Format("{0}|updatePanel|{1}|{2}|", content.Length, controlID, content);
    }
 
    protected override void Render(HtmlTextWriter writer)
    {
        // we only do this in the case of an Async Postback
 
        ScriptManager sm = ScriptManager.GetCurrent(this);
        if ((sm == null) || !sm.IsInAsyncPostBack)
        {
            base.Render(writer);
            return;
        }
 
        // Get the rendered page string 
        // (which should be a list of Ajax tokens)
 
        HtmlTextWriter tw = new HtmlTextWriter(new StringWriter());
        base.Render(tw);
        string content = tw.InnerWriter.ToString();
 
        // Get some meaningless text that changes over time
        string insert = DateTime.Now.ToLongTimeString();
 
        //Change the inner html of Panel2 and some 
        // table cell with the id 'testTD' with the string
        content += TokenizeInnerHtml(insert, Panel2.UniqueID);
        content += TokenizeInnerHtml(insert, "testTD");
 
        // Set value of TextBox2 to the string
        content += TokenizeValue(insert, TextBox2.UniqueID);
 
        // change the background color of TextBox2 to red
        string property = string.Format(
            "document.getElementById('{0}').style.backgroundColor",
            TextBox2.ClientID);
        string value = "'red'";
        content += TokenizeProperty(value, property);
 
        // Set focus to TextBox2
        content += TokenizeFocus(TextBox2.ClientID);
 
        // write the content with the extra tokens
        writer.Write(content);
    }
     
}



Of course, that doesn't solve my initial problem, of speeding up the Ajax rendering of large grids. That's because, even if I would solve the ViewState issues and the quirks that are bound to appear, I still can't change the innerHTML property of tables or table rows, as it is a readonly property.

So where am I to use this? It's easy: first of all, put a button (and only a button) inside an UpdatePanel. Any click on that button will trigger an Ajax postback, but will send a minimal amount of data. Then, put outside the UpdatePanel a Panel. Now you can override the Render of the page and on every Ajax postback, add whatever HTML you want to that panel. If you want to do it from javascript, put the button in a div with style="display:none" and then trigger the button click whenever you want to cause the postback. I am certain that for large readonly grids, that is a way faster method than the putting the grid inside the updatepanel.

To do this the traditional Atlas way you would have had to declare a web service, and then to set a javascript onclick event on the button, that would have executed a WebService method that returned a string, and manually change the innerHTML of the panel.

4 comments:

  1. Hi there, very interesting approach. I'd like to find out if you made if finally to work. Any problems?

    ReplyDelete
  2. It did work in the test scenarios, but I haven't used it yet in a real life situation. If you do, don't forget to let me know ;)

    ReplyDelete
  3. Couldn't you give each table row (or cell for that matter) a unique ID, and use the expando token technique to document.getElementById(id).innerHtml = xxx?

    ReplyDelete
  4. As I said, a row cannot be changed by changing its innerHTML. That would have been cool, but it doesn't work.

    Changing cells one by one doesn't really help either, I believe, due to the amount of work necessary to make it function flawlessly, but since I never tried it, I can't say for sure.

    ReplyDelete