Friday, January 6, 2012

How to load content in your IFRAME

When we started the TextView we used an IFRAME as the top most element. Over the time we changed the way we load the content of the IFRAME a few times, in this blog I will go over what we learnt in the process.
In our first version we made the entire initialization of the text view synchronous. That was done using this strategy:

var iframe = document.createElement("IFRAME");
parent.addChild(iframe);
var frameWindow = iframe.contentWindow;
var frameDocument = frameWindow.document;
var html = "<!DOCTYPE html><HTML><HEAD>";
for (var i = 0; i < stylesheets.length; i++) {
 var objXml = new XMLHttpRequest();
 objXml.open("GET", stylesheets[i], false);
 objXml.send(null);
 html += "<STYLE>" + objXml.responseText + '</STYLE>';
}
html += "</HEAD><BODY></BODY></HTML>";
frameDocument.open();
frameDocument.write(html);
frameDocument.close(); 
// call method to create all other elements 
createElements(frameDocument.body); 
This method works on all browsers but is not without limitations.
First, it is possible that iframe.contentWindow is undefined. This happens when the parent is not connected to the DOM. Another similar problem is that the parent (or an ancestor of it) can be hidden, in which case the browser can choose to not apply any styling to it.

The second problem is using STYLE instead of LINK to include the css files. In order to use STYLE we need to download all the files synchronously one after the other. Another problem using STYLE is that all the URIs in the CSS are relative to the page base URI, which causes problems during deployment.

The solution for these problems is to wait for the load event of the iframe to write the html and to use LINK to include the css files:

var iframe = document.createElement("IFRAME");
var iframeLoaded = false;
iframe.addEventListener("load", function() {
 if (iframeLoaded) return;
 iframeLoaded = true;
 var frameWindow = iframe.contentWindow;
 var frameDocument = frameWindow.document;
 var html = "<!DOCTYPE html><HTML><HEAD>";
 for (var i = 0; i < stylesheets.length; i++) {
  html += "<LINK rel='stylesheets' type='text/css' href='" + stylesheets[i] + "'></LINK>";
 }
 html += "</HEAD><BODY></BODY></HTML>";
 frameDocument.open();
 frameDocument.write(html);
 frameDocument.close(); 
 createElements(frameDocument.body); // BAD CSS is not done loading
}, false);
parent.addChild(iframe);
This code is step forward, it solves the problem when iframe.contentWindow is undefined. Using LINK also means that all files are downloaded in parallel and there is no problems with relatives URI inside the CSS.

The main problem with the code above is that the CSS are not loaded at the time body is being accessed. The solve this problem our initial solution was to use the load event for the frameWindow:

var iframe = document.createElement("IFRAME");
var iframeLoaded = false;
iframe.addEventListener("load", function() {
 if (iframeLoaded) return;
 iframeLoaded = true;
 var frameWindow = iframe.contentWindow;
 var frameDocument = frameWindow.document;
 var html = "<!DOCTYPE html><HTML><HEAD>";
 for (var i = 0; i < stylesheets.length; i++) {
  html += "<LINK rel='stylesheets' type='text/css' href='" + stylesheets[i] + "'></LINK>";
 }
 html += "</HEAD><BODY></BODY></HTML>";
 frameDocument.open();
 frameDocument.write(html);
 frameDocument.close(); 
 var windowLoaded = false;
 frameWindow.addEventListener("load", function() {
  if (windowLoaded) return;
  windowLoaded = true;
  createElements(frameDocument.body);
 }, false);
}, false);
parent.addChild(iframe);
Here is where things get ugly, there are number of problems:

  • Firefox does not send any load events when document.write() is called from the iframe load handler.
  • calling document.write() not from the iframe load handler causes Firefox to change the navigation history.
  • Chrome some times does not fire load events for the iframe window (when navigating back or forward).
  • Safari sends the load event for the iframe before the CSS is loaded.
  • Webkit sends the load event for the iframe before the CSS is loaded, adding a SCRIPT element after the last LINK element fixes it for Webkit.
To workaround the problems listed above the next version includes a timer:

var iframe = document.createElement("IFRAME");
var iframeLoaded = false;
iframe.addEventListener("load", function() {
 if (iframeLoaded) return;
 iframeLoaded = true;
 var frameWindow = iframe.contentWindow;
 var frameDocument = frameWindow.document;
 var html = "<!DOCTYPE html><HTML><HEAD>";
 for (var i = 0; i < stylesheets.length; i++) {
  html += "<LINK rel='stylesheets' type='text/css' href='" + stylesheets[i] + "'></LINK>";
 }
 html += "<SCRIPT>var waitForStyleSheets = true;</SCRIPT>";
 html += "</HEAD><BODY></BODY></HTML>";
 frameDocument.open();
 frameDocument.write(html);
 frameDocument.close(); 

 var done = false;
 frameWindow.addEventListener("load", function() {
  if (done) return;
  if (frameDocument.readyState === "complete") {
   done = true;
   createElements(frameDocument.body);
  }
 }, false);
 var createTimer = function() {
  if (done) return;
  if (frameDocument.readyState === "complete") {
   done = true;
   createElements(frameDocument.body);
  } else {
   setTimeout(createTimer, 10);
  }
 }
 setTimeout(createTimer, 10);
}, false);
parent.addChild(iframe);
The version above is almost our current solution. Except that it does not work on Firefox. For some reason, on Firefox the frameDocument.readyState stays permanently set to "interactive" when document.write() is called from the iframe load handler. The only way we found to detected that all CSS are loaded was checking the cssRules of each stylesheet (thank you Mihai).

var iframe = document.createElement("IFRAME");
var iframeLoaded = false;
iframe.addEventListener("load", function() {
 if (iframeLoaded) return;
 iframeLoaded = true;
 var frameWindow = iframe.contentWindow;
 var frameDocument = frameWindow.document;
 var html = "<!DOCTYPE html><HTML><HEAD>";
 for (var i = 0; i < stylesheets.length; i++) {
  html += "<LINK rel='stylesheets' type='text/css' href='" + stylesheets[i] + "'></LINK>";
 }
 html += "<SCRIPT>var waitForStyleSheets = true;</SCRIPT>";
 html += "</HEAD><BODY></BODY></HTML>";
 frameDocument.open();
 frameDocument.write(html);
 frameDocument.close(); 

 var done = false;
 frameWindow.addEventListener("load", function() {
  if (done) return;
  if (frameDocument.readyState === "complete") {
   done = true;
   createElements(frameDocument.body);
  }
 }, false);
 var createTimer = function() {
  if (done) return;
  var ready = false;
  if (frameDocument.readyState === "complete") {
   ready = true;
  } else if (frameDocument.readyState === "interactive" && isFirefox) {
   var sheets = frameDocument.styleSheets;
   if (sheets.length === stylesheets.length) {
    var index = 0;
    while (index < sheets.length) {
     var count = 0;
     try {
      count = styleSheets.item(index).cssRules.length;
     } catch (ex) {
      //invalid access error means the css is not loaded
      if (ex.code !== DOMException.INVALID_ACCESS_ERR) {
       //other errors, like network security, assume the css is loaded
       count = 1;
      }
     }
     if (count === 0) { break; }
     index++;
    }
    ready = index === sheets.length;
   }
  }
  if (ready) {
   done = true;
   createElements(frameDocument.body);
  } else {
   setTimeout(createTimer, 10);
  }
 }
 setTimeout(createTimer, 10);
}, false);
parent.addChild(iframe);

This is all the code that was needed to load the content into the iframe - somewhat extreme if you'd ask me. Probably the most pertinent question is why we are using an iframe, the answer to that is definitely another post...

2 comments:

  1. Hi. Reads interesting. But can you demo or provide an explanation how this might be useful please?

    ReplyDelete