Thursday, February 07, 2008

IE Misbehaving? Fix It With Prototype!

Disclaimer: Since I specifically make reference to where I work in this post, I thought I should also specifically make reference to the fact that on this blog in general, and in this post in particular, I do not speak for my company in any way shape or form. My opinions are mine. Mine, mine, mine! If my company wants to express its opinions, it can get its own damn blog!

So at my day job we're converting the front end of our product from a Java thick client to a Struts 2 / JSP / several-other-buzzwords webapp. We officially support three browsers (in no particular order other than my blatant preference): Firefox, Safari, and Internet Explorer. We're using a fair amount of Ajax via Prototype and Scriptaculous. Again, no big surprise.

One of the problems we've encountered with our Ajax stuff is that anything we return from a normal Ajax call is subject to caching by the browser. Now I know the standard tricks to defeat caching are to a) submit the request as a POST; and 2) submit as a GET, but append a random string (e.g., a timestamp) to the request string to force the browser to fail on the cache lookup.

Unfortunately, being the curmudgeonly programmer and language maven that I am, I object to both of these approaches. Sometimes (most times) GET is the proper verb to use for my request; it's not changing state on the server, just retrieving it. POST should be used for changing state. The random string hack is just that -- a hack. If the browser is going to cache the results of a single request, it's probably going to cache the result of each "unique" request we create by tacking on this otherwise-irrelevant snippet of "data". I don't want all that unnecessary cruft in my customers' caches if I can help it.

There must be a better way.

Fortunately, there is a better way as of HTTP 1.1, in the form of the Cache-Control response header. See Section 14.9 of the HTTP 1.1 spec for all the gory details. Suffice it to say, including a response header of Cache-Control: no-cache with all of our Ajax responses worked to defeat caching of our Ajax-GET snippets.

Except on IE.

Are you surprised? I was, but only because Cache-Control: no-cache actually works on IE, to a point. So it wasn't obvious at first that IE was screwing up. Fortunately, we have the world's best quality engineering team, so they caught the problem when we lowly developers weren't seeing it.

Long story short: it turns out that (in our application at least; YMMV) the Cache-Control: no-cache response header actually works to prohibit caching in IE. Until the response is > ~8K in size. At which point IE caches it anyway. Which leads to all kinds of fun "but I saved my edits, I know I did" debates between QE and development when the browser returns the cached results instead of the latest and greatest.

This is where Prototype comes in. You see, Prototype already has a set of headers it adds to every Ajax request submitted through it. For example, it sets the header X-Requested-With to XMLHttpRequest. We use this in our app to distinguish between Ajax GETs and non-Ajax (NAjax?) GETs. Comes in handy sometimes. (Note: See Prototype's source file prototype.js, specifically the Ajax.Request.setRequestHeaders() function for the details on this; ~ line 1241 in Prototype

So the trick is to get Prototype to include another header with its Ajax requests: If-Modified-Since. Set If-Modified-Since to some datetime in the past, and the browser should always go to the server instead of the (expired) cache. I could do this by hacking my prototype.js file to include this new header in Ajax.Request.setRequestHeaders(), but then I'd have to remember to re-hack every time I upgrade Prototype. Not fun. I could also submit it as a patch, I suppose, but I don't know that everyone needs this behavior by default.

Enter The Next Best Thing: Functional Programming! Prototype has a wonderful FP-ish function called wrap(), which is defined on Element.Methods (prototype.js, ~ line 1652 in wrap() lets me extend the existing setRequestHeaders() function from the outside thusly:
Ajax.Request.prototype.setRequestHeaders =
function(original) {
// do my stuff first; e.g., set 'If-Modified-Since'
original() // then call the original version
In case this isn't clear, I'm replacing the original definition of the setRequestHeaders() function with a new (anonymous) function that does my stuff first, then does whatever the original was defined to do. To Java programmers circa 2004, this looks kind of like "before advice" in Aspect-Oriented Programming. Without the new-language-compiler-tools-runtime-stack baggage. To functional programming types, it looks like function composition. Without the lambdas and general my-thesis-is-bigger-than-yours snootiness.

To me it looks like a pretty nifty hack, one that plays by everyone's rules.

Well. Except IE's.

But I'm okay with that.


Jose said...

nice. using the wrap() on the prototype call is the slickest solution i've seen. one question - does setting the "If-Modified-Since" work in IE? I think you are saying no in your last line, but not sure. If it does work, could you post the code? Right now I'm just appending a timestamp in the wrap() method, and that seems to work. thanks, J.

David Rupp said...

Hmm. I see where the confusion could come in; sorry about that. Setting "If-Modified-Since" absolutely does defeat IE caching, at least so far as we've seen. The bit about "playing by everyone's rules except IE's" was meant to be facetious: IE apparently doesn't want you to override its cache, even using the perfectly-legal standard no-cache header on the response. So this is the only workaround I've found so far that doesn't involve one of the other hacks I mentioned.

Thanks for your comment. :-)

Jose said...

ok thanks. I put this line in the wrap'ed call before the call to original():

this.transport.setRequestHeader("If-Modified-Since", "Thu, 1 Jan 1970 00:00:00 GMT");

IE seems to be happy. Thanks for the tip. Its a very clean way of modifying prototype behavior w/ out having to touch prototype.js.