Skip to content

Commit

Permalink
Support Meteor.defer in inactive iOS tabs.
Browse files Browse the repository at this point in the history
In iOS Safari, `setTimeout` and `setInterval` events are not delivered
to inactive tabs (unless and until they become active again).  This
means that using `setTimeout(fn, 0)` to run `fn` in the next event
loop can in fact delay `fn` indefinitely.

This implementation uses the native `setImmediate` (when available) or
`postMessage` (all other modern browsers); falling back to
`setTimeout` if the first two aren't available.

The `qa` subdirectory includes a manual test to check that `defer` is
working in inactive tabs.  (Sadly the test can't run automatically
because scripts aren't allowed to open child windows except in
response to user events).

Factors out some common code in `timers.js`.
  • Loading branch information
awwx authored and n1mmy committed May 23, 2013
1 parent 4e05cc6 commit 4a99d1b
Show file tree
Hide file tree
Showing 10 changed files with 319 additions and 30 deletions.
6 changes: 6 additions & 0 deletions LICENSE.txt
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,12 @@ node-kexec: https://github.com/jprichardson/node-kexec
Copyright (c) 2011-2012 JP Richardson


----------
setImmediate: https://github.com/NobleJS/setImmediate
----------

Copyright (c) 2012 Barnesandnoble.com, llc, Donavon West, and Domenic Denicola


==============
Apache License
Expand Down
3 changes: 3 additions & 0 deletions packages/meteor/package.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ Package.on_use(function (api, where) {
api.add_files('client_environment.js', 'client');
api.add_files('server_environment.js', 'server');
api.add_files('helpers.js', ['client', 'server']);
api.add_files('setimmediate.js', ['client', 'server']);
api.add_files('timers.js', ['client', 'server']);
api.add_files('errors.js', ['client', 'server']);
api.add_files('fiber_helpers.js', 'server');
Expand Down Expand Up @@ -63,4 +64,6 @@ Package.on_test(function (api) {
api.add_files('fiber_helpers_test.js', ['server']);

api.add_files('url_tests.js', ['client', 'server']);

api.add_files('timers_tests.js', ['client', 'server']);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
local
5 changes: 5 additions & 0 deletions packages/meteor/qa/defer-in-inactive-tab/.meteor/packages
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Meteor packages used by this project, one per line.
#
# 'meteor add' and 'meteor remove' will edit this file for you,
# but you can also edit it by hand.

13 changes: 13 additions & 0 deletions packages/meteor/qa/defer-in-inactive-tab/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Defer in Inactive Tab

Tests that `Meteor.defer` works in an inactive tab in iOS Safari.

(`setTimeout` and `setInterval` events aren't delivered to inactive
tabs in iOS Safari until they become active again).

Sadly we have to run the test manually because scripts aren't allowed
to open windows themselves except in response to user events.

This test will not run on Chrome for iOS because the storage event is
not implemented in that browser. Also doesn't attempt to run on
versions of IE that don't support `window.addEventListener`.
52 changes: 52 additions & 0 deletions packages/meteor/qa/defer-in-inactive-tab/test.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<head>
<title>defer in inactive tab</title>
<meta name="viewport" content="width=device-width">
</head>

<body>
{{> route}}
</body>

<template name="route">
{{#if isParent}}
{{> parent}}
{{else}}
{{> child}}
{{/if}}
</template>

<template name="parent">
<h1>Test Defer in Inactive Tab</h1>

<p>
Step one: open second tab:
<button id="openTab">Open Tab</button>
</p>

<p>
Step two: run test:
<button id="runTest">Run Test</button>
</p>

<p>
In a successful test the test status will immediately change to
"test successful". (If you switch to the child tab yourself and
that makes the test claim to be successful, that's actually an
invalid test because you're letting the child tab become the
active tab).
</p>

<p style="padding: 1em; outline: 1px solid gray">
Test status: <b>{{testStatus}}</b>
</p>

<p>
After the test has run successfully you can close the child tab.
</p>

</template>

<template name="child">
<p>This is the child.</p>
<p>Switch back to the first tab and run the test.</p>
</template>
57 changes: 57 additions & 0 deletions packages/meteor/qa/defer-in-inactive-tab/test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
if (Meteor.isClient) {

var isParent = (window.location.pathname === '/');
var isChild = ! isParent;

Template.route.isParent = function () {
return isParent;
};

Template.parent.testStatus = function () {
return Session.get('testStatus');
};

Template.parent.events({
'click #openTab': function () {
window.open('/child');
},

'click #runTest': function () {
if (localStorage.getItem('ping') === '!' ||
localStorage.getItem('pong') === '!') {
Session.set('testStatus', 'Test already run. Close the second tab (if open), refresh this page, and run again.');
}
else {
localStorage.setItem('ping', '!');
}
}
});

if (isParent) {
Session.set('testStatus', '');

Meteor.startup(function () {
localStorage.setItem('ping', null);
localStorage.setItem('pong', null);
});
window.addEventListener('storage', function (event) {
if (event.key === 'pong' && event.newValue === '!') {
Session.set('testStatus', 'test successful');
}
});
}

if (isChild) {
window.addEventListener('storage', function (event) {
if (event.key === 'ping' && event.newValue === '!') {
// If we used setTimeout here in iOS Safari it wouldn't
// work (unless we switched tabs) because setTimeout and
// setInterval events don't fire in inactive tabs.
Meteor.defer(function () {
localStorage.setItem('pong', '!');
});
}
});
}

}
141 changes: 141 additions & 0 deletions packages/meteor/setimmediate.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
// Chooses one of three setImmediate implementations:
//
// * Native setImmediate (IE 10, Node 0.9+)
//
// * postMessage (many browsers)
//
// * setTimeout (fallback)
//
// The postMessage implementation is based on
// https://github.com/NobleJS/setImmediate/tree/1.0.1
//
// Don't use `nextTick` for Node since it runs its callbacks before
// I/O, which is stricter than we're looking for.
//
// Not installed as a polyfill, as our public API is `Meteor.defer`.
// Since we're not trying to be a polyfill, we have some
// simplifications:
//
// If one invocation of a setImmediate callback pauses itself by a
// call to alert/prompt/showModelDialog, the NobleJS polyfill
// implementation ensured that no setImmedate callback would run until
// the first invocation completed. While correct per the spec, what it
// would mean for us in practice is that any reactive updates relying
// on Meteor.defer would be hung in the main window until the modal
// dialog was dismissed. Thus we only ensure that a setImmediate
// function is called in a later event loop.
//
// We don't need to support using a string to be eval'ed for the
// callback, arguments to the function, or clearImmediate.

"use strict";

var global = this;


// IE 10, Node >= 9.1

function useSetImmediate() {
if (! global.setImmediate)
return null;
else {
var setImmediate = function (fn) {
global.setImmediate(fn);
};
setImmediate.implementation = 'setImmediate';
return setImmediate;
}
}


// Android 2.3.6, Chrome 26, Firefox 20, IE 8-9, iOS 5.1.1 Safari

function usePostMessage() {
// The test against `importScripts` prevents this implementation
// from being installed inside a web worker, where
// `global.postMessage` means something completely different and
// can't be used for this purpose.

if (!global.postMessage || global.importScripts) {
return null;
}

// Avoid synchronous post message implementations.

var postMessageIsAsynchronous = true;
var oldOnMessage = global.onmessage;
global.onmessage = function () {
postMessageIsAsynchronous = false;
};
global.postMessage("", "*");
global.onmessage = oldOnMessage;

if (! postMessageIsAsynchronous)
return null;

var funcIndex = 0;
var funcs = {};

// Installs an event handler on `global` for the `message` event: see
// * https://developer.mozilla.org/en/DOM/window.postMessage
// * http://www.whatwg.org/specs/web-apps/current-work/multipage/comms.html#crossDocumentMessages

// XXX use Random.id() here?
var MESSAGE_PREFIX = "Meteor._setImmediate." + Math.random() + '.';

function isStringAndStartsWith(string, putativeStart) {
return (typeof string === "string" &&
string.substring(0, putativeStart.length) === putativeStart);
}

function onGlobalMessage(event) {
// This will catch all incoming messages (even from other
// windows!), so we need to try reasonably hard to avoid letting
// anyone else trick us into firing off. We test the origin is
// still this window, and that a (randomly generated)
// unpredictable identifying prefix is present.
if (event.source === global &&
isStringAndStartsWith(event.data, MESSAGE_PREFIX)) {
var index = event.data.substring(MESSAGE_PREFIX.length);
try {
if (funcs[index])
funcs[index]();
}
finally {
delete funcs[index];
}
}
}

if (global.addEventListener) {
global.addEventListener("message", onGlobalMessage, false);
} else {
global.attachEvent("onmessage", onGlobalMessage);
}

var setImmediate = function (fn) {
// Make `global` post a message to itself with the handle and
// identifying prefix, thus asynchronously invoking our
// onGlobalMessage listener above.
++funcIndex;
funcs[funcIndex] = fn;
global.postMessage(MESSAGE_PREFIX + funcIndex, "*");
};
setImmediate.implementation = 'postMessage';
return setImmediate;
}


function useTimeout() {
var setImmediate = function (fn) {
global.setTimeout(fn, 0);
};
setImmediate.implementation = 'setTimeout';
return setImmediate;
}


Meteor._setImmediate =
useSetImmediate() ||
usePostMessage() ||
useTimeout();
50 changes: 20 additions & 30 deletions packages/meteor/timers.js
Original file line number Diff line number Diff line change
@@ -1,36 +1,31 @@
var withCurrentInvocation = function (f) {
if (Meteor._CurrentInvocation) {
if (Meteor._CurrentInvocation.get() && Meteor._CurrentInvocation.get().isSimulation)
throw new Error("Can't set timers inside simulations");
return function () { Meteor._CurrentInvocation.withValue(null, f); };
}
else
return f;
};

var bindAndCatch = function (context, f) {
return Meteor.bindEnvironment(withCurrentInvocation(f), function (e) {
// XXX report nicely (or, should we catch it at all?)
Meteor._debug("Exception from " + context + ":", e);
});
};

_.extend(Meteor, {
// Meteor.setTimeout and Meteor.setInterval callbacks scheduled
// inside a server method are not part of the method invocation and
// should clear out the CurrentInvocation environment variable.

setTimeout: function (f, duration) {
if (Meteor._CurrentInvocation) {
if (Meteor._CurrentInvocation.get() && Meteor._CurrentInvocation.get().isSimulation)
throw new Error("Can't set timers inside simulations");

var f_with_ci = f;
f = function () { Meteor._CurrentInvocation.withValue(null, f_with_ci); };
}

return setTimeout(Meteor.bindEnvironment(f, function (e) {
// XXX report nicely (or, should we catch it at all?)
Meteor._debug("Exception from setTimeout callback:", e.stack);
}), duration);
return setTimeout(bindAndCatch("setTimeout callback", f), duration);
},

setInterval: function (f, duration) {
if (Meteor._CurrentInvocation) {
if (Meteor._CurrentInvocation.get() && Meteor._CurrentInvocation.get().isSimulation)
throw new Error("Can't set timers inside simulations");

var f_with_ci = f;
f = function () { Meteor._CurrentInvocation.withValue(null, f_with_ci); };
}

return setInterval(Meteor.bindEnvironment(f, function (e) {
// XXX report nicely (or, should we catch it at all?)
Meteor._debug("Exception from setInterval callback:", e);
}), duration);
return setInterval(bindAndCatch("setInterval callback", f), duration);
},

clearInterval: function(x) {
Expand All @@ -41,16 +36,11 @@ _.extend(Meteor, {
return clearTimeout(x);
},

// won't be necessary once we clobber the global setTimeout
//
// XXX consider making this guarantee ordering of defer'd callbacks, like
// Deps.afterFlush or Node's nextTick (in practice). Then tests can do:
// callSomethingThatDefersSomeWork();
// Meteor.defer(expect(somethingThatValidatesThatTheWorkHappened));
defer: function (f) {
// Older Firefox will pass an argument to the setTimeout callback
// function, indicating the "actual lateness." It's non-standard,
// so for defer, standardize on not having it.
Meteor.setTimeout(function () {f();}, 0);
Meteor._setImmediate(bindAndCatch("defer callback", f));
}
});
21 changes: 21 additions & 0 deletions packages/meteor/timers_tests.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
Tinytest.addAsync('timers - defer', function (test, onComplete) {
var x = 'a';
Meteor.defer(function () {
test.equal(x, 'b');
onComplete();
});
x = 'b';
});

Tinytest.addAsync('timers - nested defer', function (test, onComplete) {
var x = 'a';
Meteor.defer(function () {
test.equal(x, 'b');
Meteor.defer(function () {
test.equal(x, 'c');
onComplete();
});
x = 'c';
});
x = 'b';
});

0 comments on commit 4a99d1b

Please sign in to comment.