User:Catrope/Bizarre browser bugs

From MediaWiki.org
Jump to: navigation, search

Opera[edit]

Opera does a couple of annoying brain-damaged things that makes web developers go crazy. If someone ever starts serial-killing Opera developers, here's why:

Newline handling[edit]

The representation of newlines varies over browsers. For instance, Firefox represents newlines as \n (even on Windows), while IE represents them as \r\n. Both browsers seem to be tolerant when trying to insert text in the 'wrong' newline format, and automatically convert it. This means you can code JS quite nicely without having to worry about one browser saying a newline is one character while the other says it's two.

Since Opera is this schizophrenic browser torn between wanting to be a good browser and wanting to be like IE, it came up with the following compromise: when you grab the contents of a textarea using $('#mytextbox').val() or document.getElementById('mytextbox').value , newlines are represented as \n , i.e. as one character. However, when manipulating selections using e.selectionStart or selection.moveStart() (Opera, in its schizophrenia, supports both the Gecko way and the IE way), newlines are treated as two characters (presumably \r\n). This means that the following snippet doesn't do what you expect in Opera:

var searchStr = "foo";
var e = document.getElementById('mytextbox');
var pos = e.value.indexOf(searchStr);
if(pos != -1)
{
	e.selectionStart = pos;
	e.selectionEnd = pos + searchStr.length;
}

In Firefox, this would search for the string "foo" in the textarea and select it if found. It'll also work in IE if you replace the e.selectionStart assignment with something using a TextRange object. However, it won't work in Opera, because of the different newline representations: the selection will be off to the left by the number of newlines preceding the found occurrence of "foo".

To hack around this, you'd have to do var text = e.value.replace( /\n/g, "\r\n" ); and then call text.indexOf(). Of course, you only want to do this when you're sure you're on a broken version of Opera (to my knowledge, all current versions are broken, but they might fix it some day), so you need a function that tests for this brokenness, then conditionally replaces \n with \r\n.

The code for this function can be found in this file in a function called fixOperaBrokenness.

Moving selections[edit]

Some older versions of Opera (I only tested this on 9.0, not sure about 9.4 or 10) are very picky about how selections are moved. A typical code snipper to select the 4th through the 8th character would be:

e.selectionStart = 4; // Zero-based
e.selectionEnd = 7;

This looks very intuitive and works fine... initially. However, changing the selection to (9, 13) afterwards using the same template doesn't do what you want: you end up selecting (4, 13).

The reason for this is that in between the selectionStart and the selectionEnd assignments, the selection would be (9, 4); that is, selectionStart would be moved past selectionEnd. All sane browsers that use selectionStart allow this and treat negative-length selections as selections of length zero; that is, if selectionEnd <= selectionStart the cursor is placed at selectionStart and nothing is selected. However, Opera is not flexible like that: it stubbornly refuses to set selectionStart past selectionEnd, and simply ignores the assignment. To work around that, you have to set selectionEnd first so selectionStart can safely be moved. However, assuming that this bug is symmetrical, moving selectionStart first is actually right when moving the selection back, so as to prevent moving selectionEnd back past selectionStart. This results in the following code:

function setSelection(start, end)
{
	if(start > this.selectionEnd)
	{
		this.selectionEnd = end;
		this.selectionStart = start;
	}
	else
	{
		this.selectionStart = start;
		this.selectionEnd = end;
	}
}

However, even this doesn't fully address Opera 9.0's bugginess. In addition to refusing to move selections 'the wrong way around', it also refuses to create a zero-length selection before the current cursor position, that is, move the cursor back without selecting anything. In detail, it refuses to change selectionEnd to be equal to selectionStart. The reverse seems to work, but that's only an option when moving forward.

Internet Explorer[edit]

Some of the bugs and workarounds in this section were found by or in cooperation with Trevor Parscal.

Content-editable iframe manipulation[edit]

IE has some weird bugs related to content-editable iframes. They're detailed below in increasing order of weirdness.

A simple attempt at getting a content-editable iframe to work:

var $iframe = $('<iframe />').insertAfter(something);

$iframe[0].contentWindow.document.open();
$iframe[0].contentWindow.document.write('<html><head><title>My cute litte iframe</title></head><body></body></html>');
$iframe[0].contentWindow.document.close();
$iframe[0].contentWindow.document.designMode = 'on';

var $body = $($iframe[0].contentWindow.document.body);
$body.html("some dynamic stuff here"); // jQuery all you want here

This works nicely in Firefox, but in IE the document.open() fails, because the iframe hasn't been properly initialized yet. It's been added to the DOM, but it hasn't recevied its own DOM yet. This seems to happen semi-asynchronously: either the iframe is being constructed in parallel, or IE merely schedules the iframe to be constructed after the current event has been processed (I think the latter is more likely).

To work around this, we put the rest of the code in a setTimeout(). Timeouts also fire after the current event has been processed completely, and in the case of IE it also seems to fire after the async construction of the iframe. This even happens for timeout intervals of 0 ms. Of course all of this is balancing on implementation details only barely coming together, but it works:

var $iframe = $('<iframe />').insertAfter(something);

// Defer this till after iframe construction
setTimeout(function() {
	$iframe[0].contentWindow.document.open();
	$iframe[0].contentWindow.document.write('<html><head><title>My cute litte iframe</title></head><body></body></html>');
	$iframe[0].contentWindow.document.close();
	$iframe[0].contentWindow.document.designMode = 'on';

	var $body = $($iframe[0].contentWindow.document.body);
	$body.html("some dynamic stuff here"); // jQuery all you want here
}, 0);

With this workaround, the document.*() function calls don't fail any more. Instead, the iframe shows up for a short while, and then the entire page becomes blank. What's happening here is the most bizarre, confusing and scary phenomenon I've ever encountered. For some reason, IE doesn't like the fact that we're messing with the body element inside the iframe (the bug doesn't appear when messing with a pre inside the body, and also appears when using .text() instead of .html()), and just totally freaks out. It runs through the room in circles, screaming, with its hands against its head. And when it's calmed down, it finds out that it's decapitated the DOM.

That's right, the DOM has become headless. For some reason, document.documentElement and document.body (point to the html and body elements of the document respectively) have both become null. The DOM elements themselves can find see each other with .parentNode and jQuery's .parent() and what not, and you can still access DOM elements if you already had references to them, but traversing the DOM tree from the root down is no longer possible. This means that IE is unable to render anything (that's why the screen goes blank), and that every reference to a property of document.documentElement or document.body results in a JS error. Even simple jQuery methods (at least all selector-based ones, it seems) use these, as well as jQuery's event handling system. In short: you thought you were having some iframe fun, and all of a sudden you get a mysterious blank page and even more mysterious JS errors all over the place in jQuery's gore internals, all just because you removed a pre tag.

One workaround for this is to avoid manipulating the <body> directly:

var $iframe = $('<iframe />').insertAfter(something);

// Defer this till after iframe construction
setTimeout(function() {
	var content = "dynamic stuff here";
	$iframe[0].contentWindow.document.open();
	$iframe[0].contentWindow.document.write('<html><head><title>My cute litte iframe</title></head><body>' + content + '</body></html>');
	$iframe[0].contentWindow.document.close();
	$iframe[0].contentWindow.document.designMode = 'on';
	
	var $body = $($iframe[0].contentWindow.document.body);
	// Don't manipulate $body directly. Read-only methods like .find() are OK,
	// and manipulating descendants is safe too.
}, 0);

Another one is to load the iframe contents from a .html file and use the load event on the iframe. However, once .designMode = 'on' is set, IE reloads the iframe DOM. This causes a number of issues:

  • Pretty much any action in the rest of the load handler will fail
  • The load event will fire a second time when IE is done reloading the DOM
  • Any events bound from inline <script>s in the iframe will not fire because the elements they've been bound to have been destroyed

The workaround involves short-circuiting the first load event in IE and waiting for the second one before doing any manipulation other than enabling designMode. The event binding issue can be worked around by binding events from the load handler (on the second call of course).

var $iframe = $('<iframe />').attr('src', 'somefile.html').insertAfter(something);

// Defer this till after iframe construction
var isSecondRun = false;
$iframe.load(function() {
	if (!isSecondRun)
	{
		$iframe[0].contentWindow.document.designMode = 'on';
		if($.browser.msie)
		{
			isSecondRun = true;
			return;
		}
	}
	var content = "dynamic stuff here";

	var $body = $($iframe[0].contentWindow.document.body);
	$body.html(content); // this is OK now

	// Bind events here rather than from a <script> in the iframe
	$body.find('.foo').click(function() { whatever(); });
}, 0);

Loss of selection in content-editable iframe[edit]

When a content-editable iframe loses focus to an element in the main document, IE forgets the selection and cursor position (which is really a selection of length 0) in the iframe. When focus is restored to the iframe, the cursor will be at the beginning of the iframe. This makes implementing a toolbar that inserts text into the iframe challenging: if you're not careful, clicking a toolbar button will cause said button to gain focus, causing IE to forget where the cursor was and to insert text at the beginning of the iframe contents. To make matters fun, certain natural choices for toolbar buttons cause the aforementioned breakage on IE, while others don't.

The most natural choices for toolbar buttons are:

  • an <img> if your button is an image
  • an <a> if it's text
  • a <div> if you need more flexibility

Each of these would have an onclick handler that manipulates the selected text in the iframe. Now let's see which of these leave the iframe selection alone:

  • an <img> works just fine
  • an <a href="#"> works too (remember to return false from the onclick handler)
  • an <a> without an href attribute does not work
  • a <div> also doesn't work

If you find yourself designing a toolbar button/widget that needs more flexibility than a plain <img> or <a> can provide, you can still use a <div> as long as you take care to put the actual clickable part in an <img> or <a>.

Whitespace collapsing[edit]

Whitespace collapsing is very serious business in IE. Both IE7 and IE8 are very aggressive about collapsing sequences of whitespace characters into single spaces. This whitespace collapsing will never remove HTML elements like <br> or entities like &nbsp;, but it will collapse other kinds of whitespace into them in certain cases.

Whitespace handling inside dynamically generated <pre> tags[edit]

When inserting arbitrary HTML into whitespace-sensitive tags (most notably <pre>), you have to be very careful: IE doesn't always realize the tag you're inserting into is whitespace-sensitive, in which case it'll wrongly collapse all sequences of whitespace characters into single spaces. Interestingly, there seems to be a difference between injecting HTML with .html() and injecting it into the string fed to the jQuery constructor; I guess the latter makes the browser parse the injected HTML 'in context', realize it's inside a <pre> and handle whitespace accordingly.

// .html() is used for illustration here, but the same whitespace collapsing happens in .text()
$('<div />').html('Foo\nBar  Baz\nWhee').html(); // Returns "Foo Bar Baz Whee"; <div> is not whitespace-sensitive so this is fine
$('<div>Foo\nBar  Baz\nWhee</div>').html(); // Returns "Foo Bar Baz Whee"
$('<pre />').html('Foo\nBar  Baz\nWhee').html(); // Returns "Foo Bar Baz Whee"; this is WRONG because <pre> is whitespace-sensitive
$('<pre>Foo\nBar  Baz\nWhee</pre>').html(); // Returns "Foo\nBar  Baz\nWhee"; this is the only reliable way

Leading whitespace collapsing in HTML[edit]

Having collapsed sequences of whitespace characters into single spaces, IE turns to <br>s followed by spaces. It's not possible to collapse such a sequence into a single space without removing the <br> element, so IE will happily collapse it into the <br> instead, removing the spaces. This means that leading spaces on all lines but the first are removed.

To work around this, we need to replace leading spaces with &nbsp;. Fortunately, IE8 is happy when we replace the first leading space with &nbsp;; this produces sequences like "<br>&nbsp; ", which will be left alone (in our circumstances that is; it seems inserting into a content-editable element triggers special behavior, in more standard circumstances IE8 doesn't seem to be leaving these spaces alone). However, IE7 is more zealous and will still collapse the spaces following the &nbsp; into a single space no matter what; in fact, it'll collapse all whitespace everywhere, so in IE7 we need to replace all spaces with &nbsp;:

if($.browser.msie)
{
	if($.browser.versionNumber <= 7)
	{
		html = html.replace(/ /g, "&nbsp;");
	}
	else
	{
		// IE8 is happy if we just convert the first leading space to &nbsp;
		html = html.replace(/(^|\n) /g, "$1&nbsp;");
	}
}

Tab collapsing in HTML[edit]

IE really doesn't like tab characters. In addition to the aforementioned whitespace collapsing affecting tabs, IE will convert any tab character in your HTML to a space; the exceptions IE8 seems to make for content-editable elements don't apply here. The only workaround we found to be able to display tab characters and retrieve them from the DOM is by replacing them with <span>s that are then styled to look like tabs and converted back to tab characters when translating the DOM content back to text.

// When converting text to HTML:
if($.browser.msie)
{
	html = html.replace(/\t/g, '<span class="tab"></span>');
}
/* CSS: */
.tab { padding-left: 4em; }

Newline insertion in normalized HTML in IE 7[edit]

After inserting stuff into a content-editable iframe and looking at the resulting HTML, different browsers produce different results:

<!-- Firefox: -->
First line<br>Second line<br><div class="foo">Stuff wrapped in a div</div><br>An escaped &lt;tag&gt;
<!-- IE 8: -->
First line<BR>Second line<BR><DIV class="foo">Stuff wrapped in a div</DIV><BR>An escaped &lt;tag&gt;
<!-- IE 7: -->
First line<BR>Second line<BR>
<DIV class="foo">Stuff wrapped in a div</DIV><BR>An escaped &lt;tag&gt;

The difference between Firefox and IE 8 is that IE 8 capitalizes tag names (fair enough), but IE 7 additionally inserts newlines here and there. Closer investigation seems to indicate this happens before the start of each block-level element.

Of course each of these three normalized formats is equally valid, but most code developed on Firefox or IE 8 will be confused by IE 7 making up newlines out of nowhere, especially if said code is something like html.replace( /\<br\>/gi, "\n" );.

Multiline regexes are broken: dot matches \r[edit]

"foo\nbar".match(/^f.*r$/m) correctly returns null in both IE and Firefox: the dot doesn't match \n because of the multiline modifier. However "foo\rbar".match(/^f.*r$/m) returns null on Firefox (correct) but ["foo\rbar"] in IE: apparently the dot doesn't match \n but does match \r in IE, even in multiline mode. Not only is this wrong (\r is a line break character, so it shouldn't be matched by the dot in multiline mode), it's also particularly annoying because $('<pre>Foo\nBar\nBaz</pre>').text() returns "Foo\rBar\rBaz" in IE.

TextRange .text property strips newlines in IE8[edit]

Suppose the DOM contains Foo Bar<br>Baz Whee or <p>Foo Bar</p><p>Baz Whee</p> and the user selects Bar and Baz (note that there's a line boundary between them). Getting the selection text in Firefox with document.getSelection().toString() returns "Bar\nBaz" just fine. In IE, the way to go is document.selection.createRange().text. This returns "Bar\r\nBaz" in IE7, but "BarBaz" in IE8; the newlines get stripped. The only way to reliably determine the selected text including newlines in IE8 is by using document.selection.createRange().htmlText (which is accurate in both IE7 and IE8) and processing that to replace <br> and </p><p> by newlines and unescape entities.

All the magic needed to convert content-editable HTML back to text[edit]

function htmlToText(html)
{
	var $pre = $('<pre>' + // See "Whitespace handling inside dynamically generated <pre> tags"
		html
			.replace(/\r?\n/g, "") // see "Newline insertion in normalized HTML in IE 7"
			.replace( /\<br[^\>]*\>/gi, "\n" )
			.replace( /&nbsp;/g, " " ) // see "Leading whitespace collapsing in HTML"
			.replace( /\<p[^\>]*\>/gi, "\n" ) // IE uses </p><p> for user-inserted line breaks
			.replace( /\<\/p[^\>]*\>/gi, "" )
		+ '</pre>');
	$pre.find('.tab').each(function() { $(this).text("\t"); }); // see "Tab collapsing in HTML"
	return $pre.text();
}