All clients who pay on time are great clients, and getting
a call from them is a joy. Not all of their calls result in new gigs, however,
and they don't always offer a technical challenge. Sometimes these calls simply
mean boring questions and complaints. One such question I've been asked
frequently sinceASP.NET
's beta 2 regards the unpleasant side effect that sometimes
originates from hitting the Refresh button of the browser. When the user
refreshes the current page, the
ASP.NET runtime
- under some circumstances -
repeats the last action and reposts the data sent with the last round trip. As
a result, the last server-side operation is repeated, which isn't necessarily a
neutral event to the application. This also can violate - again, sometimes -
the consistency of the application's data and trigger subtle errors and strange
exceptions.
In this article, I'll try to clarify what really happens
when the user clicks on the Refresh button and how this affects ASP.NET pages.
I'll use a sample application to demonstrate that special measures are needed
to discern a regular button click from a browser's refresh action. For a Web
page, it's essential to know the call's reason so its code is executed safely
and doesn't violate the application's integrity and consistency. Normally, the browser stores recently visited pages in
special folders on the user's hard disk: the temporary Internet files. This
saves time when a user revisits a site because the pages are loaded from the
local disk rather than downloaded again from the remote HTTP server. In
contrast, when the user clicks on the browser's Refresh button, the page
currently displayed is reloaded from the source - which bypasses any local
cache. The refresh repeats the last HTTP verb, be it GET or POST. If the action
was a POST, the dialog box in Figure 1 is displayed. If you choose to cancel the
request, nothing happens; the page currently displayed remains displayed.
Otherwise, the browser sends a POST from the HTML form of the page last
invoked. For an ASP.NET page, this behavior has a side effect. The
contents of the input fields in the unique form are reposted to the server and
processed as a postback. As a result, the last server-side action is repeated.
Worse yet, there's no way for the ASP.NET code to detect whether it's been
invoked - was it because the page was refreshed, or because of a deliberate and
conscious user action? Nothing in the headers of the HTTP packet informs the
Web server that the incoming request is actually a refresh request and not a
command request. The ASP.NET runtime can detect, however, whether the page is
requested for the first time or as the result of a postback event. The
IsPostBack Boolean property on the Page class simply returns this type of
information. Unfortunately, a similar mechanism isn't implemented to detect
whether the page is being refreshed or requested. Before I explore this aspect further, let's review a sample
application that illustrates why the refresh action can be a serious problem
for ASP.NET applications. This sample application contains a button that
increases a counter by one whenever you click on it. The current value of the
counter is displayed in the body of the page. But if you refresh the page after
clicking on the button, the counter will be increased by one at each step.
Figure 2 shows the source code of the sample page; Figure 3 shows the page in
action and demonstrates the misbehavior. Figure 2. This code shows a sample ASP.NET page that
misbehaves guiltlessly when refreshed through the browser. In the sample page the side effect is minimal - it's simply
an incremented counter - and it doesn't jeopardize the application's stability.
But the pattern behind it counts more than the effect. If users refresh an
ASP.NET page, the last action could be repeated over and over again, seriously
impacting the integrity of the application. For example, what if the last
action consists of inserting a new record in a database? You could run into
database-key violation errors or fill the database with cloned rows. If you're
working with disconnected and cached data through a DataSet or DataTable
object, the delete operation also is dangerous if you identify the row to
delete by position. But what's the reason that induces ASP.NET to repeat the
last operation in case of refresh? From the ASP.NET perspective, a refresh
action is a postback - no more, no less. The HTTP request doesn't contain
anything that indicates the origin of the packet and the ASP.NET runtime
doesn't provide any built-in mechanism to detect the situation. When the user
hits the Refresh button, the browser prepares a new POST request and stuffs in
it the posted input values to obtain the current page (these values are cached
internally). Typical values are the name of the Submit button that was clicked
on to post the page and the contents of checkboxes and textboxes. For ASP.NET,
the refresh request is like any other request and is processed as usual. If the
previous call contained a postback event that, for instance, added a new
record, it'll be executed again whether intentional or not. When I first tackled this problem, I was fairly sure that
a hidden field would've done the trick. My idea was to force the Submit button
to write a flag into a custom hidden field when you clicked on it. If the page
is refreshed - I thought - this code won't run, which gives the server page a
way to discern between submit and refresh actions. Unfortunately, I was wrong.
For this trick to work, in fact, the browser must reread the input fields upon
refreshing the page. As you probably experienced on your own, this isn't what
really happens. When users hit the Refresh button, the browser blindly repeats
the last HTTP verb, which it has cached. In doing so, the flag written in the
hidden field the last time the Submit button was clicked on is sent over again
and thus cancels any difference for the server page between refresh and submit.
Another approach is needed. Given the browser's behavior, trapping the Refresh button
click isn't a task you can accomplish on the client. When a page is refreshed,
the ASP.NET page receives a block of posted values identical to that of a
previous request. According to this pattern, if you assign a unique,
progressive value - sort of a queue ticket - to each request in the session,
you should be able to catch whether or not a request is new or a "rerun." If
the ticket of the request being processed is older than the last ticket served,
the particular request has been served already - hence, it's simply a page
refresh. Try this solution. To implement it, you need a couple of
session-state slots - I named them Ticket and LastTicketServed - and initialize
both to 0. The former contains the next ticket available for the session; the
latter references the last ticket served within the session. Each page sent to
the browser contains a unique ticket generated progressively from the current
value of the Ticket slot. The ticket for the request is stored in a hidden
field named __TICKET. This code snippet
shows you how to define it: Each new request for the page generates a new ticket
greater than any previous ticket. The content of the __TICKET field is posted
with other form fields when the page originates a postback event or is
refreshed. (Note that hidden fields are treated as visible text fields and that
a similar mechanism lies behind the management of the page's view state.) On
the server, the ticket associated with the request is analyzed and compared
with the last served ticket. If the request's ticket is greater than the last
served, the request is processed for the first time - hence, a regular submit. You need a layer of code running on top of the page to
track the last served ticket and compare it against the request's ticket. This
code also would be responsible for letting the page know whether the request is
a submit or a refresh. Such a code block has some affinity to the code buried
in the folds of the Page class and determines the page's postback mode. One possible way to implement a component that traps the
page refresh is through an HTTP module (see Figure 4). Figure 4. Use this code for the HTTP module that
checks the request ticket and determines whether the request is a regular
submit or a page refresh. The HTTP module intercepts the AcquireRequestState event
and checks the session state associated with the request. The ASP.NET HTTP
runtime fires the AcquireRequestState event immediately after the session state
is bound to the request being processed. The HTTP module manages the
LastTicketServed slot and - if the ticket is greater than 0 and greater than the
last ticket served - sets it to the value of the request ticket. The
comparison's outcome is translated in a Boolean value (true if it's a page
refresh, false if it's a submit operation) and stored in the context of the
request. The HttpContext object represents the call context for the
request and is common to all the modules and handlers that work over the
request. The Items collection on the HttpContext object is a cargo collection
where you can store shared data. Note that the HttpContext has the same
lifetime as the request. The HTTP module sets the IsRefresh item in the cargo
and the page can retrieve it later using the HttpContext.Current property
(Figure 5 shows the page in action): You easily can integrate this article's downloadable
sample code into a new page class, and it will hide much of the complexity of
trapping redundant page refreshes. The sample code in this
article is available for download.Refresh the ASP.NET Page
Figure 1. Take a look at the dialog box that Internet Explorer displays
when you attempt to refresh a page. This dialog box is displayed only if the
page was obtained through a POST command.<%@ Page Language="C#" %>
Click the button and Refresh
Figure 3. To reproduce the problem, click on the Click button to
increase the counter by one and refresh the page. The browser asks for
confirmation, then posts the input values back to the Web server. The page is
processed and the last action is repeated. As a result, the counter is
incremented again.Find a Workaround
void TrackRefreshButton()
{
int ticket = Convert.ToInt32(Session["Ticket"]) + 1;
Session["Ticket"] = ticket;
RegisterHiddenField("__TICKET", ticket.ToString());
}
using System;
using System.Web;
using System.Web.SessionState;
namespace AspNetPro
{
public class RefreshTrapperModule : IHttpModule {
public void Init(HttpApplication app)
{
// Register for pipeline events
app.AcquireRequestState +=
new EventHandler(OnAcquireRequestState);
}
public void Dispose()
{
}
void OnAcquireRequestState(object sender, EventArgs e)
{
// Get access to the HTTP context
HttpApplication app = (HttpApplication) sender;
HttpContext ctx = app.Context;
// Init the session slots for the page (Ticket)
// and the module (LastTicketServed)
if (ctx.Session["LastTicketServed"] == null)
ctx.Session["LastTicketServed"] = 0;
// Set the default result
ctx.Items["IsRefresh"] = false;
// Read the last ticket served and the ticket
// of the current request
object o1 = ctx.Session["LastTicketServed"];
object o2 = ctx.Request["__Ticket"];
int lastTicket = Convert.ToInt32(o1);
int thisTicket = Convert.ToInt32(o2);
// Compare tickets
if (thisTicket > lastTicket ||
(thisTicket==lastTicket && thisTicket==0))
{
ctx.Session["LastTicketServed"] = thisTicket;
ctx.Items["IsRefresh"] = false;
}
else
ctx.Items["IsRefresh"] = true;
}
}
}
public bool IsRefresh;
IsRefresh = (bool) HttpContext.Current.Items["IsRefresh"];
Figure 5. The page uses the custom IsRefresh element in Context.Items to
determine whether the request comes as a new form submission or through a page
refresh.
Trap the Browser Refresh
Distinguish a button click from a browser refresh.
0 comments
Hide comments