Friday, June 24, 2011

Delay Loading of Data in SharePoint 2010 Web Parts

Sometimes your web-parts may take a long time to load their data, e.g. when connecting to external data through BCS, doing SPSiteDataQuery across a large number of sites, or when iterating over a user's site memberships to read some items from lists in different site-collections. Put a few of such web-parts on a dashboard page and wait for the combined load time of all those web-parts to complete before the page is shown. Not a nice user experience. These days users expect something as shown in this short screencast:


If you've used ASP.NET Ajax UpdatePanels, you might wish to utilize the asynchronous partial page update experience seen on postbacks also during page load. The simple thing seems to be calling __doPostBack for each UpdatePanel from the page load JavaScript event to trigger the Ajax async partial postback. That won't work, as only one concurrent postback is allowed by ASP.NET Ajax, so only one of your web-parts will work as expected, the other __doPostBack calls will get canceled by the ScriptManager.

A simple solution to this problem, is to put an asp:timer control inside the UpdatePanel and let it trigger a postback to your web-part code. Then load the data and update the content of the UpdatePanel during this async Ajax postback.

Here are the code to two base classes that implements this delayed load approach:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Web.UI;
using System.Web.UI.WebControls;
 
namespace Puzzlepart.SharePoint.WebParts
{
    public class AjaxPanelWebPart : System.Web.UI.WebControls.WebParts.WebPart
    {
        protected UpdatePanel AjaxPanel;
 
        protected override void OnLoad(EventArgs e)
        {
            base.OnLoad(e);
            this.EnsureChildControls();
        }
 
        protected override void CreateChildControls()
        {
            AjaxPanel = new UpdatePanel()
            {
                ID = this.ID + "UpdatePanel1",
                UpdateMode = UpdatePanelUpdateMode.Conditional
            };
            Controls.Add(AjaxPanel);
            UpdatePanelConfigurator.AddUpdatePanelProgress(AjaxPanel);
        }
 
        protected virtual void ApplyUserActions()
        {
            //to be overridden in derived classes
        }
 
        protected void RebindControlsWhenNoViewState()
        {
            if (Page.IsPostBack == true &&
            System.Web.UI.ScriptManager.GetCurrent(Page).IsInAsyncPostBack == false)
            {
                ApplyUserActions();
            }
        }
    }
 
 
    public class AjaxPanelDelayedLoadWebPart : AjaxPanelWebPart
    {
        protected Timer LoadTimer;
 
        protected override void OnLoad(EventArgs e)
        {
            base.OnLoad(e);
            this.EnsureChildControls();
        }
 
        protected override void CreateChildControls()
        {
            base.CreateChildControls();
            CreateLoadTimer();
        }
 
        private void CreateLoadTimer()
        {
            LoadTimer = new Timer()
            {
                ID = this.ID + "LoadTimer1",
                Interval = 1 //millisecond
            };
            LoadTimer.Tick += new EventHandler<EventArgs>(LoadTimer_Tick);
            AjaxPanel.ContentTemplateContainer.Controls.Add(LoadTimer);
        }
 
        protected void LoadTimer_Tick(object sender, EventArgs e)
        {
            LoadTimer.Enabled = false;
            try
            {
                ApplyUserActions();
            }
            catch (Exception ex)
            {
                Label msg = new Label()
                {
                    Text = "An error occurred in delayed load: " + ex.Message,
                    ToolTip = ex.ToString()
                };
                Controls.Add(msg);
            }
        }
    }
}

The ApplyUserActions method is where you should fetch your data and update the content of the AjaxPanel member control. All the controls of your web-part should be created as usual, remember to call base.CreateChildControls in your derived web-parts to ensure that the Ajax controls get created.

Note that no slow data must be fetched and bound to your web-part controls during page load, e.g. in the CreateChildControls or OnPreRender methods, as this defeats the purpose of delay loading the data in the ApplyUserActions async postback method. A typical scenario is creating and configuring an SPGridView control in CreateChildControls and then fetch the data and set the grid's DataSource and call the grid's DataBind method in the overridden ApplyUserActions method in your derived web-part.

Note that all page event code get executed on partial postbacks for all web-parts on the page. This can cause problems that are unrelated to the web-part that triggers the postback, manifested as ScriptResource.axd JavaScript errors. Some problems are related to viewstate handling, such as the "Error=Value cannot be null. Parameter name: container" SPGridView exception. The simple solution is to turn off viewstate, and then call the RebindControlsWhenNoViewState method to load and bind the data when the postback is not an async Ajax postback. This must also be done for all controls that do not use viewstate, otherwise they will end up empty after e.g. modal dialogs that reload the page on close.

This ASP.NET Timer approach allows the page to load quickly, then each web-part will in turn get the timer tick postback and update itself using ASP.NET Ajax partial page updates. Note that this code won’t work as a sandboxed web-part. The UpdatePanel control requires the ScriptManager, which isn’t accessible from the sandboxed worker process.

The more professional way of getting real asynchronous loading for web-part content is to use PageAsyncTask as shown in Chapter 9 in Wictor Wilen's excellent SharePoint 2010 Web Parts in Action book. It does require a bit more code, but will allow parallell data fetching and thus faster page load time. It also works without using any UpdatePanels as all is done server-side.