Halcyon
Halcyon

Reputation: 57719

closing a window breaks event loop assumption

I've have encountered a small annoyance that blows up to be a huge problem.

Problem 1: In Internet Explorer when you close a window (that you opened via window.open) the ownerDocument will disappear along with it.

The implication of this is that any call to the DOM, such as appendChild or createElement, will fail with a SCRIPT70: Permission Denied or SCRIPT1717: The interface is unknown.

I've looked at the behaviour of other browsers such as Chrome. In Chrome ownerDocument still references the #document but ownerDocument.defaultView will eventually be undefined. This makes sense to me. Calls to appendChild and createElement will pass. I think everything is fine so long as you don't try to reference the defaultView directly.

Problem 2: In Internet Explorer when you click on the close button of the spawned window it doesn't seem to respect the Event loop. I attached an unload event to the spawned window and it fires immediately instead of queuing it at the end of the Event loop. This does not make sense to me. It becomes quite impossible to deal with this rather trivial problem.

If we just had problem 1 there would be a -still painful but- straightforward solution: check if the ownerDocument exists and skip if it doesn't. As it is ownerDocument disappears in the middle of synchronous JavaScript code.

Expected behaviour: a DOM node should not disappear if you've referenced it - garbage collection sanity.

Expected behaviour 2: a DOM node should not disappear in synchronous code. (unless you delete it of course).

Known workaround: move all the code that interacts with the DOM into the window, so that when the window is closed so is the JavaScript runtime environment. This is not a trivial solution and may require significant changes in your architecture.

Crappy solution: wrap any function that does interaction with the DOM in a function that will consume errors if it detects the window of the element has been closed. This is quite invasive and has a significant impact on performance, and IE is already so slow.

Is there a better solution?

What I want, at the very least, is a way to ignore any Errors that are thrown because the user closed a window. problem 1 and problem 2 break basic assumptions you make about JavaScript code: garbage collection and event loop.


Demo script

<script type="text/javascript">
function go() {
    var popup = window.open('', 'open', 'width=500,height=300,scrollbars=yes,resizable=yes');
    popup.document.open();
    popup.document.write('<html><head></head><body></body></html>');
    popup.document.close();
    for (var i = 0; i < 10000; i += 1) {
        var node = popup.document.createTextNode(i + " ");
        popup.document.body.appendChild(node);
    }
}
</script>
<input type="button" onclick="go();" value="Open popup" />

(save as .html file)

Instructions:

Here it is a JSFiddle: http://jsfiddle.net/C9p2R/1/

Upvotes: 16

Views: 2303

Answers (4)

Nenad
Nenad

Reputation: 26647

OK, let me try to simplify. To gain responsiveness of windows and avoid breaking of application when child is closed, several things have to be combined.

  1. Parent should not modify child DOM directly. Functions should be in child itself. However, this alone would not solve anything, if functions are triggered from parent and executed synchronously. Hanged windows and exceptions are still there.
  2. Main function that parent calls on child has to use setTimeout or setInterval, to schedule DOM-modifying functions, delegate execution to the child and return immediately. This step makes auto-cleanup possible!
  3. Long lasting DOM operations in child should be split to further faster chunks using setTimeout or setInterval, so that events-loop is not locked for a long time. This gives responsiveness to both windows.

Simplest example of child javascript. Function called from parent is LongLastingOperation and it has been split in 4 chunks:

<script>
    function wasteTime(message) {
        // TODO: waste time
    }
    function FirstPartOfLongOperation(){
        wasteTime('long... part 1');
        setTimeout(SecondPartOfLongOperation, 0);
    }
    function SecondPartOfLongOperation() {
        wasteTime('long... part 2');
        setTimeout(ThirdPartOfLongOperation, 0);
    }
    function ThirdPartOfLongOperation() {
        wasteTime('long... part 3');
        setTimeout(FourthPartOfLongOperation, 0);
    }
    function FourthPartOfLongOperation() {
        wasteTime('long... part 4');
        alert('Done!');
    }

    // Main entry, called from parent.
    // Ex: popup.LongLastingOperation({ data: ....})
    function LongLastingOperation(parametersPassedFromParentWindow) {
        // decompose long operation
        setTimeout(FirstPartOfLongOperation, 0);
    }
</script>

Upvotes: 0

Nenad
Nenad

Reputation: 26647

After trying couple things (postMessage, Worker,... ) always having problems with IE9, I found nice solution. I made Parallel.For loop using setInterval function. Catch is here:

The setInterval() method will continue calling the function until clearInterval() is called, or the window is closed.

Logic for creating nodes is in the child window, but it's triggered from parent window. Loop is executed in chunks and, because setInterval is used, closing of child window at any point doesn't produce errors. More, browser do not hang (neither parent or child while this is running). And it looks like this:

enter image description here

We have 3 components: parent-ie.html, child-ie.html and small parallel.js file. One quirk was, for all browser to work setInterval(function, -1) was used. Positive values were braking IE, 2nd omitted parameter confuses Opera so it makes only 1st chunk of function. Anyhow, code is here:

parent-id.html

<!DOCTYPE html>
<html>
<head>
    <title>Parent</title>
</head>
<body>
    <script type="text/javascript">
        'use strict';
        (function(){
            var popup;
            var pathname = 'child-ie.html';

            window.openPopup = function _open() {
                popup = window.open(pathname, 'open', 'width=500,height=300,scrollbars=yes,resizable=yes');
            }

            window.createElements = function _createElements() {
                if (popup == null || popup.closed == true) {
                    alert("Open new popup window first.");
                    return;
                }
                var numberOfElements = parseInt(document.getElementById('numberOfElements').value);
                popup.writeElements(numberOfElements);
            }
        })();
    </script>
    <button onclick="openPopup()">Open popup</button><br />
    <button onclick="createElements()">Create elements</button>
    <input id="numberOfElements" type="number" value="10000" />
</body>
</html>

child-ie.html

<!doctype html>
<html>
<head>
    <title>Child window</title>
</head>
<body>
<script src="/Scripts/Parallel.js"></script>
<script>
    (function(){
        function _iterator(index) {
            var node = document.createTextNode(index + " ");
            document.body.appendChild(node);
        }

        window.writeElements = function (numberOfElements) {
            document.body.innerHTML = '';
            Parallel.For(0, numberOfElements, 100, _iterator);
        }
    })();
</script>
</body>
</html>

/Scripts/Parallel.js

'use strict';
var Parallel;
(function (Parallel) {
    var Iterator = (function () {
        function Iterator(from, to, step, expression) {
            this._from = from;
            this._to = to;
            this._step = step;
            this._expression = expression;
        }
        Object.defineProperty(Iterator.prototype, "intervalHandle", {
            set: function (value) {
                this._intervalHandle = value;
            },
            enumerable: true,
            configurable: true
        });
        Iterator.prototype.next = function () {
            var max = this._to > this._step + this._from ? this._step + this._from : this._to;
            for(var i = this._from; i < max; i += 1) {
                this._expression(i);
            }
            if(max === this._to) {
                clearInterval(this._intervalHandle);
            } else {
                this._from = max;
            }
        };
        return Iterator;
    })();    
    function For(from, to, step, expression) {
        var _iterator = new Iterator(from, to, step, expression);
        _iterator.intervalHandle = setInterval(function () {
            _iterator.next();
        }, -1);
    }
    Parallel.For = For;
})(Parallel || (Parallel = {}));

Javascript was generated from typescript file (maybe bit more clear then javascript):

'use strict';
module Parallel {
    class Iterator {
        private _from: number;
        private _to: number;
        private _step: number;
        private _expression: (index: number) => {};
        private _intervalHandle: number;

        public set intervalHandle(value: number) {
            this._intervalHandle = value;
        }

        constructor(from: number, to: number, step: number, expression: (index: number) => {}) {
            this._from = from;
            this._to = to;
            this._step = step;
            this._expression = expression;
        }

        next() {
            var max: number = this._to > this._step + this._from ? this._step + this._from : this._to;
            for (var i = this._from; i < max; i += 1) {
                this._expression(i);
            }
            if (max === this._to) {
                clearInterval(this._intervalHandle);
            }
            else {
                this._from = max;
            }
        }
    }
    export function For(from: number, to: number, step: number, expression: (index: number) => {}) {
        var _iterator = new Iterator(from, to, step, expression);
        _iterator.intervalHandle = setInterval(function () {
            _iterator.next();
        }, -1);
    }
}

That's all.

Upvotes: 0

Halcyon
Halcyon

Reputation: 57719

Unless anyone has a better solution, I'll go with the crappy solution. Here is my code:

function apply_window_close_fix(dom_element, wrapped_element) {
    var ignore_errors = false;
    dom_element.ownerDocument.defaultView.addEventListener("unload", function () {
        ignore_errors = true;
    });
    return map(wrapped_element, function (key, func) {
        return function () {
            try {
                return func.apply(this, arguments);
            } catch (e) {
                if (ignore_errors === false) {
                    throw e;
                }
            }
        };
    });
}

wrapped_element is the API that I return for modifying the DOM. I've wrapped all functions in a try-catch that will ignore errors if it sees the window has been closed. I call this function only for browsers that behave like Internet Explorer does.

There seems to be only a very minor performance hit. Of course this depends on how intensively you call this API.

One minor downside is that currently rethrowing some Errors is broken in some browsers. Rethrowing a DOMException resets the stack in Internet Explorer and Chrome (and possibly others). I've also found no way to fetch a filename and linenumber from a DOMException in Internet Explorer. Once again, a gross oversight that just ends up wasting time for everybody.

Upvotes: 1

Alex Williams
Alex Williams

Reputation: 355

You could modify your "Crappy Solution". Instead of wrapping the functions that interact with DOM, you can redefine them when the unload event is fired in such a way that they don't interact with the DOM, e.g. myOldFunction = function (){};.

Upvotes: 0

Related Questions