From f0e961ba2ca402c521266bfc10af4b556ba355c8 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 19 Jan 2016 20:26:44 -0800 Subject: [PATCH] Instrument and throttle timers when embeds are not in viewport. --- 3p/environment.js | 263 +++++++++++++++++++++++++ 3p/integration.js | 36 ++-- 3p/messaging.js | 54 +++++ builtins/amp-ad.js | 21 +- examples/ads.amp.html | 8 + src/iframe-helper.js | 3 + test/functional/test-3p-environment.js | 251 +++++++++++++++++++++++ test/functional/test-amp-ad.js | 24 ++- 8 files changed, 624 insertions(+), 36 deletions(-) create mode 100644 3p/environment.js create mode 100644 3p/messaging.js create mode 100644 test/functional/test-3p-environment.js diff --git a/3p/environment.js b/3p/environment.js new file mode 100644 index 000000000000..0302dd46a997 --- /dev/null +++ b/3p/environment.js @@ -0,0 +1,263 @@ +/** + * Copyright 2016 The AMP HTML Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {listenParent} from './messaging'; + +/** + * Info about the current document/iframe. + * @type {boolean} + */ +let inViewport = true; + +/** + * @param {boolean} inV + */ +export function setInViewportForTesting(inV) { + inViewport = inV; +} + +let rafId = 0; +let rafQueue = {}; + +/** + * Add instrumentation to a window and all child iframes. + * @param {!Window} win + */ +export function manageWin(win) { + try { + manageWin_(win); + } catch (e) { + // We use a try block, because the ad integrations often swallow errors. + console./*OK*/error(e.message, e.stack); + } +} + +/** + * @param {!Window} win + */ +function manageWin_(win) { + if (win.ampSeen) { + return; + } + win.ampSeen = true; + // Instrument window. + instrumentEntryPoints(win); + + // Watch for new iframes. + installObserver(win); + // Existing iframes. + maybeInstrumentsNodes(win, win.document.querySelectorAll('iframe')); +} + + +/** + * Add instrumentation code to doc.write. + * @param {!Window} parent + * @param {!Window} win + */ +function instrumentDocWrite(parent, win) { + const doc = win.document; + const close = doc.close; + doc.close = function() { + parent.ampManageWin = function(win) { + manageWin(win); + }; + doc.write(''); + // .call does not work in Safari with document.write. + doc._close = close; + return doc._close(); + }; +} + +/** + * Add instrumentation code to iframe's srcdoc. + * @param {!Window} parent + * @param {!Element} iframe + */ +function instrumentSrcdoc(parent, iframe) { + let srcdoc = iframe.getAttribute('srcdoc'); + parent.ampManageWin = function(win) { + manageWin(win); + }; + srcdoc += ''; + iframe.setAttribute('srcdoc', srcdoc); +} + +/** + * Instrument added nodes if they are instrumentable iframes. + * @param {!Window} win + * @param {!Array} addedNodes + */ +function maybeInstrumentsNodes(win, addedNodes) { + for (let n = 0; n < addedNodes.length; n++) { + const node = addedNodes[n]; + try { + if (node.tagName != 'IFRAME') { + continue; + } + const src = node.getAttribute('src'); + const srcdoc = node.getAttribute('srcdoc'); + if (src == null || /^(about:|javascript:)/i.test(src.trim()) || + srcdoc) { + if (node.contentWindow) { + instrumentIframeWindow(node, win, node.contentWindow); + node.addEventListener('load', () => { + try { + instrumentIframeWindow(node, win, node.contentWindow); + } catch (e) { + console./*OK*/error(e.message, e.stack); + } + }); + } else if (srcdoc) { + instrumentSrcdoc(parent, node); + } + } + } catch (e) { + console./*OK*/error(e.message, e.stack); + } + } +} + +/** + * Installs a mutation observer in a window to look for iframes. + * @param {!Element} node + * @param {!Window} parent + * @param {!Window} win + */ +function instrumentIframeWindow(node, parent, win) { + if (win.ampSeen) { + return; + } + const doc = win.document; + instrumentDocWrite(parent, win); + if (doc.body && doc.body.childNodes.length) { + manageWin(win); + } +} + +/** + * Installs a mutation observer in a window to look for iframes. + * @param {!Window} win + */ +function installObserver(win) { + if (!window.MutationObserver) { + return; + } + const observer = new MutationObserver(function(mutations) { + for (let i = 0; i < mutations.length; i++) { + maybeInstrumentsNodes(win, mutations[i].addedNodes); + } + }); + observer.observe(win.document.documentElement, { + subtree: true, + childList: true, + }); +} + +/** + * Replace timers with variants that can be throttled. + * @param {!Window} win + */ +function instrumentEntryPoints(win) { + // Change setTimeout to respect a minimum timeout. + const setTimeout = win.setTimeout; + win.setTimeout = function(fn, time) { + time = minTime(time); + return setTimeout(fn, time); + }; + // Implement setInterval in terms of setTimeout to make + // it respect the same rules + const intervals = {}; + let intervalId = 0; + win.setInterval = function(fn, time) { + const id = intervalId++; + function next() { + intervals[id] = win.setTimeout(function() { + next(); + return fn.apply(this, arguments); + }, time); + } + next(); + return id; + }; + win.clearInterval = function(id) { + win.clearTimeout(intervals[id]); + delete intervals[id]; + }; + // Throttle requestAnimationFrame. + const requestAnimationFrame = win.requestAnimationFrame || + win.webkitRequestAnimationFrame; + win.requestAnimationFrame = function(cb) { + if (!inViewport) { + // If the doc is not visible, queue up the frames until we become + // visible again. + const id = rafId++; + rafQueue[id] = [win, cb]; + // Only queue 20 frame requests to avoid mem leaks. + delete rafQueue[id - 20]; + return id; + } + return requestAnimationFrame.call(this, cb); + }; + const cancelAnimationFrame = win.cancelAnimationFrame; + win.cancelAnimationFrame = function(id) { + cancelAnimationFrame.call(this, id); + delete rafQueue[id]; + }; + if (win.webkitRequestAnimationFrame) { + win.webkitRequestAnimationFrame = win.requestAnimationFrame; + win.webkitCancelAnimationFrame = win.webkitCancelRequestAnimationFrame = + win.cancelAnimationFrame; + } +} + +/** + * Run when we just became visible again. Runs all the queued up rafs. + * @visibleForTesting + */ +export function becomeVisible() { + for (const id in rafQueue) { + if (rafQueue.hasOwnProperty(id)) { + const f = rafQueue[id]; + f[0].requestAnimationFrame(f[1]); + } + } + rafQueue = {}; +} + +/** + * Calculates the minimum time that a timeout should have right now. + * @param {number} time + * @return {number} + */ +function minTime(time) { + if (!inViewport) { + time += 1000; + } + // Eventually this should throttle like this: + // - for timeouts in the order of a frame use requestAnimationFrame + // instead. + // - only allow about 2-4 short timeouts (< 16ms) in a 16ms time frame. + // Throttle further timeouts to requestAnimationFrame. + return time; +} + +listenParent('embed-state', function(data) { + inViewport = data.inViewport; + if (inViewport) { + becomeVisible(); + } +}); diff --git a/3p/integration.js b/3p/integration.js index 05ba12da5283..1d706db726c8 100644 --- a/3p/integration.js +++ b/3p/integration.js @@ -29,6 +29,8 @@ import {adsense} from '../ads/adsense'; import {adtech} from '../ads/adtech'; import {doubleclick} from '../ads/doubleclick'; import {facebook} from './facebook'; +import {manageWin} from './environment'; +import {nonSensitiveDataPostMessage, listenParent} from './messaging'; import {twitter} from './twitter'; import {register, run} from '../src/3p'; import {parseUrl} from '../src/url'; @@ -127,7 +129,13 @@ window.draw3p = function(opt_configCallback) { window.context.reportRenderedEntityIdentifier = reportRenderedEntityIdentifier; delete data._context; + // Run this only in canary and local dev for the time being. + if (location.pathname.indexOf('-canary') || + location.pathname.indexOf('current')) { + manageWin(window); + } draw3p(window, data, opt_configCallback); + nonSensitiveDataPostMessage('render-start'); }; function triggerNoContentAvailable() { @@ -148,17 +156,6 @@ function triggerResizeRequest(width, height) { }); } -function nonSensitiveDataPostMessage(type, opt_object) { - if (window.parent == window) { - return; // Nothing to do. - } - const object = opt_object || {}; - object.type = type; - object.sentinel = 'amp-3p'; - window.parent./*OK*/postMessage(object, - window.context.location.origin); -} - /** * Registers a callback for intersections of this iframe with the current * viewport. @@ -170,22 +167,11 @@ function nonSensitiveDataPostMessage(type, opt_object) { * observes for intersection messages. */ function observeIntersection(observerCallback) { - function listener(event) { - if (event.source != window.parent || - event.origin != window.context.location.origin || - !event.data || - event.data.sentinel != 'amp-3p' || - event.data.type != 'intersection') { - return; - } - observerCallback(event.data.changes); - } // Send request to received records. nonSensitiveDataPostMessage('send-intersections'); - window.addEventListener('message', listener); - return function() { - window.removeEventListener('message', listener); - }; + return listenParent('intersection', data => { + observerCallback(data.changes); + }); } /** diff --git a/3p/messaging.js b/3p/messaging.js new file mode 100644 index 000000000000..5995d365513e --- /dev/null +++ b/3p/messaging.js @@ -0,0 +1,54 @@ +/** + * Copyright 2016 The AMP HTML Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Send messages to parent frame. These should not contain user data. + * @param {string} type Type of messages + * @param {*=} opt_object Data for the message. + */ +export function nonSensitiveDataPostMessage(type, opt_object) { + if (window.parent == window) { + return; // Nothing to do. + } + const object = opt_object || {}; + object.type = type; + object.sentinel = 'amp-3p'; + window.parent./*OK*/postMessage(object, + window.context.location.origin); +} + +/** + * Listen to message events from document frame. + * @param {string} type Type of messages + * @param {function(*)} callback Called with data payload of message. + * @return {function()} function to unlisten for messages. + */ +export function listenParent(type, callback) { + const listener = function(event) { + if (event.source != window.parent || + event.origin != window.context.location.origin || + !event.data || + event.data.sentinel != 'amp-3p' || + event.data.type != type) { + return; + } + callback(event.data); + }; + window.addEventListener('message', listener); + return function() { + window.removeEventListener('message', listener); + }; +} diff --git a/builtins/amp-ad.js b/builtins/amp-ad.js index 612114144d50..67cfe3da5ca6 100644 --- a/builtins/amp-ad.js +++ b/builtins/amp-ad.js @@ -20,9 +20,10 @@ import {assert} from '../src/asserts'; import {getIframe, prefetchBootstrap} from '../src/3p-frame'; import {IntersectionObserver} from '../src/intersection-observer'; import {isLayoutSizeDefined} from '../src/layout'; -import {listen, listenOnce} from '../src/iframe-helper'; +import {listen, listenOnce, postMessage} from '../src/iframe-helper'; import {loadPromise} from '../src/event-helper'; import {log} from '../src/log'; +import {parseUrl} from '../src/url'; import {registerElement} from '../src/custom-element'; import {timer} from '../src/timer'; @@ -258,6 +259,9 @@ export function installAd(win) { this.updateHeight_(newHeight); } }, /* opt_is3P */ true); + listenOnce(this.iframe_, 'render-start', () => { + this.sendEmbedInfo_(this.isInViewport()); + }, /* opt_is3P */ true); } return loadPromise(this.iframe_); } @@ -267,6 +271,21 @@ export function installAd(win) { if (this.intersectionObserver_) { this.intersectionObserver_.onViewportCallback(inViewport); } + this.sendEmbedInfo_(inViewport); + } + + /** + * @param {boolean} inViewport + * @private + */ + sendEmbedInfo_(inViewport) { + if (this.iframe_) { + const targetOrigin = + this.iframe_.src ? parseUrl(this.iframe_.src).origin : '*'; + postMessage(this.iframe_, 'embed-state', { + inViewport: inViewport + }, targetOrigin, /* opt_is3P */ true); + } } /** diff --git a/examples/ads.amp.html b/examples/ads.amp.html index e5ddb98e82c8..e09f2a3decd9 100644 --- a/examples/ads.amp.html +++ b/examples/ads.amp.html @@ -112,5 +112,13 @@

Doubleclick with overriden size

class="red" > +

Challenging ad.

+ + diff --git a/src/iframe-helper.js b/src/iframe-helper.js index 0778502c91a3..3250bb39406a 100644 --- a/src/iframe-helper.js +++ b/src/iframe-helper.js @@ -86,6 +86,9 @@ export function listenOnce(iframe, typeOfMessage, callback, opt_is3P) { * @param {boolean=} opt_is3P set to true if the iframe is 3p. */ export function postMessage(iframe, type, object, targetOrigin, opt_is3P) { + if (!iframe.contentWindow) { + return; + } object.type = type; object.sentinel = getSentinel_(opt_is3P); iframe.contentWindow./*OK*/postMessage(object, targetOrigin); diff --git a/test/functional/test-3p-environment.js b/test/functional/test-3p-environment.js new file mode 100644 index 000000000000..989d808173c2 --- /dev/null +++ b/test/functional/test-3p-environment.js @@ -0,0 +1,251 @@ +/** + * Copyright 2015 The AMP HTML Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {becomeVisible, manageWin, setInViewportForTesting} from + '../../3p/environment'; +import {createIframePromise} from '../../testing/iframe'; +import {timer} from '../../src/timer'; +import {loadPromise} from '../../src/event-helper'; +import * as lolex from 'lolex'; + +describe('3p environment', () => { + + beforeEach(() => { + iframeCount = 0; + return createIframePromise(true).then(iframe => { + testWin = iframe.win; + }); + }); + + it('should instrument a window', () => { + expect(testWin.setTimeout).to.match(/native/); + manageWin(testWin); + testWindow(testWin); + }); + + it('should instrument dynamically created child iframes: srcdoc', () => { + expect(testWin.setTimeout).to.match(/native/); + manageWin(testWin); + const iframe = makeChildIframeSrcdoc(testWin); + testWindow(testWin); + return waitForMutationObserver(iframe).then(() => { + testWindow(iframe.contentWindow); + }); + }); + + it('should instrument dynamically created child iframes: doc.write', () => { + expect(testWin.setTimeout).to.match(/native/); + manageWin(testWin); + const iframe = makeChildIframeDocWrite(testWin); + testWindow(testWin); + return waitForMutationObserver(iframe).then(() => { + testWindow(iframe.contentWindow); + }); + }); + + it('should instrument nested child iframes: doc.write', () => { + expect(testWin.setTimeout).to.match(/native/); + manageWin(testWin); + const iframe = makeChildIframeDocWrite(testWin); + testWindow(testWin); + return waitForMutationObserver(iframe).then(() => { + testWindow(iframe.contentWindow); + const i0 = makeChildIframeDocWrite(iframe.contentWindow); + const i1 = makeChildIframeDocWrite(iframe.contentWindow); + return waitForMutationObserver(i1).then(() => { + testWindow(i0.contentWindow); + testWindow(i1.contentWindow); + const i2 = makeChildIframeDocWrite(iframe.contentWindow); + return waitForMutationObserver(i2).then(() => { + testWindow(i2.contentWindow); + }); + }); + }); + }); + + it('should instrument nested child iframes: mixed', () => { + expect(testWin.setTimeout).to.match(/native/); + manageWin(testWin); + const iframe = makeChildIframeSrcdoc(testWin); + testWindow(testWin); + return waitForMutationObserver(iframe).then(() => { + testWindow(iframe.contentWindow); + const i0 = makeChildIframeDocWrite(iframe.contentWindow); + const i1 = makeChildIframeDocWrite(iframe.contentWindow); + return waitForMutationObserver(i1).then(() => { + testWindow(i0.contentWindow); + testWindow(i1.contentWindow); + const i2 = makeChildIframeDocWrite(iframe.contentWindow); + return waitForMutationObserver(i2).then(() => { + testWindow(i2.contentWindow); + }); + }); + }); + }); + + describe('timers', function() { + let clock; + let progress; + + function installTimer(win) { + progress = ''; + clock = lolex.install(win); + // The clock does not override this. + win.requestAnimationFrame = function(fn) { + return win.setTimeout(fn, 16); + }; + win.cancelAnimationFrame = function(id) { + win.clearTimeout(id); + }; + } + + function add(p) { + return function() { + progress += p; + }; + } + + afterEach(() => { + if (clock) { + clock.tick(10000); + clock.uninstall(); + } + }); + + + it('throttle setTimeout', () => { + installTimer(testWin); + manageWin(testWin); + testWin.setTimeout(add('a'), 50); + testWin.setTimeout(add('b'), 60); + testWin.setTimeout(add('c'), 100); + clock.tick(99); + expect(progress).to.equal('ab'); + clock.tick(1); + expect(progress).to.equal('abc'); + setInViewportForTesting(false); + testWin.setTimeout(add('d'), 100); + const t0 = testWin.setTimeout(add('canceled'), 100); + testWin.clearTimeout(t0); + clock.tick(100); + expect(progress).to.equal('abc'); + clock.tick(999); + expect(progress).to.equal('abc'); + clock.tick(1); + expect(progress).to.equal('abcd'); + setInViewportForTesting(true); + testWin.setTimeout(add('e'), 100); + const t1 = testWin.setTimeout(add('canceled'), 100); + testWin.clearTimeout(t1); + clock.tick(100); + expect(progress).to.equal('abcde'); + }); + + it('throttle setInterval', () => { + installTimer(testWin); + manageWin(testWin); + const ia = testWin.setInterval(add('a'), 1); + testWin.setInterval(add('b'), 10); + const ic = testWin.setInterval(add('c'), 20); + clock.tick(20); + expect(progress).to.equal('aaaaaaaaabaaaaaaaaaacba'); + setInViewportForTesting(false); + clock.tick(20); + expect(progress).to.equal('aaaaaaaaabaaaaaaaaaacbaabc'); + clock.tick(980); + expect(progress).to.equal('aaaaaaaaabaaaaaaaaaacbaabc'); + setInViewportForTesting(true); + clock.tick(20); + expect(progress).to.equal( + 'aaaaaaaaabaaaaaaaaaacbaabcaaaaaaaaaaaaaaaaaaba'); + testWin.clearInterval(ia); + testWin.clearInterval(ic); + clock.tick(20); + expect(progress).to.equal( + 'aaaaaaaaabaaaaaaaaaacbaabcaaaaaaaaaaaaaaaaaababb'); + }); + + it('throttle requestAnimationFrame', () => { + installTimer(testWin); + manageWin(testWin); + testWin.requestAnimationFrame(add('a')); + testWin.requestAnimationFrame(add('b')); + clock.tick(16); + clock.tick(16); + expect(progress).to.equal('ab'); + setInViewportForTesting(false); + testWin.requestAnimationFrame(add('a')); + testWin.requestAnimationFrame(add('b')); + const f0 = testWin.requestAnimationFrame(add('CANCEL0')); + testWin.cancelAnimationFrame(f0); + clock.tick(5000); + expect(progress).to.equal('ab'); + setInViewportForTesting(true); + becomeVisible(); + clock.tick(16); + expect(progress).to.equal('abab'); + testWin.requestAnimationFrame(add('a')); + testWin.requestAnimationFrame(add('b')); + const f1 = testWin.requestAnimationFrame(add('CANCEL1')); + testWin.cancelAnimationFrame(f1); + clock.tick(16); + expect(progress).to.equal('ababab'); + }); + }); + + function testWindow(win) { + expect(win.ampSeen).to.be.true; + expect(win.setTimeout).to.not.match(/native/); + expect(win.setInterval).to.not.match(/native/); + expect(win.requestAnimationFrame).to.not.match(/native/); + if (win.webkitRequestAnimationFrame) { + expect(win.webkitRequestAnimationFrame).to.not.match(/native/); + } + } + + function waitForMutationObserver(iframe) { + if (iframe.contentWindow && iframe.contentWindow.document && + iframe.contentWindow.document.body.childNodes.length) { + return timer.promise(10); + } + return loadPromise(iframe).then(() => { + return timer.promise(10); + }); + } + + function makeChildIframeSrcdoc(win) { + const doc = win.document; + const iframe = doc.createElement('iframe'); + iframe.name = 'testChild' + (iframeCount++); + iframe.setAttribute('srcdoc', 'hello: ' + iframe.name); + doc.body.appendChild(iframe); + doc.body.appendChild(doc.createElement('hr')); + return iframe; + } + + function makeChildIframeDocWrite(win) { + const doc = win.document; + const iframe = doc.createElement('iframe'); + iframe.name = 'testChild' + (iframeCount++); + iframe.src = 'about:blank'; + doc.body.appendChild(iframe); + iframe.contentWindow.document.open(); + iframe.contentWindow.document.write('write: ' + iframe.name); + iframe.contentWindow.document.close(); + doc.body.appendChild(doc.createElement('hr')); + return iframe; + } +}); diff --git a/test/functional/test-amp-ad.js b/test/functional/test-amp-ad.js index a611ee77dcf6..df5c3c5665cc 100644 --- a/test/functional/test-amp-ad.js +++ b/test/functional/test-amp-ad.js @@ -302,34 +302,38 @@ describe('amp-ad', () => { const viewport = viewportFor(win); expect(posts).to.have.length(1); ampAd.viewportCallback(true); - expect(posts).to.have.length(2); - viewport.scroll_(); expect(posts).to.have.length(3); - viewport.resize_(); + expect(posts[2].data.type).to.equal('embed-state'); + expect(posts[2].data.inViewport).to.be.true; + viewport.scroll_(); expect(posts).to.have.length(4); - ampAd.viewportCallback(false); + viewport.resize_(); expect(posts).to.have.length(5); + ampAd.viewportCallback(false); + expect(posts).to.have.length(7); + expect(posts[6].data.type).to.equal('embed-state'); + expect(posts[6].data.inViewport).to.be.false; // No longer listening. viewport.scroll_(); - expect(posts).to.have.length(5); + expect(posts).to.have.length(7); viewport.resize_(); - expect(posts).to.have.length(5); + expect(posts).to.have.length(7); }); it('report changes upon remeasure', () => { expect(posts).to.have.length(1); ampAd.viewportCallback(true); - expect(posts).to.have.length(2); - ampAd.onLayoutMeasure(); expect(posts).to.have.length(3); ampAd.onLayoutMeasure(); expect(posts).to.have.length(4); - ampAd.viewportCallback(false); + ampAd.onLayoutMeasure(); expect(posts).to.have.length(5); + ampAd.viewportCallback(false); + expect(posts).to.have.length(7); // We also send a new record when we are currently not in the // viewport, because that might have just changed. ampAd.onLayoutMeasure(); - expect(posts).to.have.length(6); + expect(posts).to.have.length(8); }); });