diff --git a/src/document-ready.js b/src/document-ready.js index d224058ae459..3e2f0da04967 100644 --- a/src/document-ready.js +++ b/src/document-ready.js @@ -21,7 +21,7 @@ * @return {boolean} */ export function isDocumentReady(doc) { - return doc.readyState != 'loading'; + return doc.readyState != 'loading' && doc.readyState != 'uninitialized'; } /** diff --git a/src/friendly-iframe-embed.js b/src/friendly-iframe-embed.js index acf713f928de..44c0f5134e57 100644 --- a/src/friendly-iframe-embed.js +++ b/src/friendly-iframe-embed.js @@ -17,8 +17,10 @@ import {escapeHtml} from './dom'; import {extensionsFor} from './extensions'; import {getTopWindow} from './service'; +import {isDocumentReady} from './document-ready'; import {loadPromise} from './event-helper'; import {resourcesForDoc} from './resources'; +import {rethrowAsync} from './log'; /** @@ -96,13 +98,10 @@ export function installFriendlyIframeEmbed(iframe, container, spec) { // Chrome does not reflect the iframe readystate. iframe.readyState = 'complete'; }; - let readyPromise; + let loadedPromise; if (isSrcdocSupported()) { iframe.srcdoc = html; - // TODO(dvoytenko): Look for a way to get a faster call from here. - // Experiments show that the iframe's "load" event is consistently 50-100ms - // later than the contentWindow actually available. - readyPromise = loadPromise(iframe); + loadedPromise = loadPromise(iframe); container.appendChild(iframe); } else { iframe.src = 'about:blank'; @@ -110,21 +109,71 @@ export function installFriendlyIframeEmbed(iframe, container, spec) { const childDoc = iframe.contentWindow.document; childDoc.open(); childDoc.write(html); + // With document.write, `iframe.onload` arrives almost immediately, thus + // we need to wait for child's `window.onload`. + loadedPromise = loadPromise(iframe.contentWindow); childDoc.close(); - // Window is created synchornously in this case. + } + + // Wait for document ready signal. + // This is complicated due to crbug.com/649201 on Chrome and a similar issue + // on Safari where newly created document's `readyState` immediately equals + // `complete`, even though the document itself is not yet available. There's + // no other reliable signal for `readyState` in a child window and thus + // we have to fallback to polling. + let readyPromise; + if (isIframeReady(iframe)) { readyPromise = Promise.resolve(); + } else { + readyPromise = new Promise(resolve => { + const interval = win.setInterval(() => { + if (isIframeReady(iframe)) { + resolve(); + win.clearInterval(interval); + } + }, /* milliseconds */ 5); + + // For safety, make sure we definitely stop polling when child doc is + // loaded. + loadedPromise.catch(error => { + rethrowAsync(error); + }).then(() => { + resolve(); + win.clearInterval(interval); + }); + }); } + return readyPromise.then(() => { // Add extensions. extensions.installExtensionsInChildWindow( iframe.contentWindow, spec.extensionIds || []); // Ready to be shown. iframe.style.visibility = ''; - return new FriendlyIframeEmbed(iframe, spec); + return new FriendlyIframeEmbed(iframe, spec, loadedPromise); }); } +/** + * Returns `true` when iframe is ready. + * @param {!HTMLIFrameElement} iframe + * @return {boolean} + */ +function isIframeReady(iframe) { + // This is complicated due to crbug.com/649201 on Chrome and a similar issue + // on Safari where newly created document's `readyState` immediately equals + // `complete`, even though the document itself is not yet available. There's + // no other reliable signal for `readyState` in a child window and thus + // the best way to check is to see the contents of the body. + const childDoc = iframe.contentWindow && iframe.contentWindow.document; + return (childDoc && + isDocumentReady(childDoc) && + childDoc.body && + childDoc.body.firstChild); +} + + /** * Merges base and fonts into html document. * @param {!FriendlyIframeSpec} spec @@ -202,8 +251,9 @@ export class FriendlyIframeEmbed { /** * @param {!HTMLIFrameElement} iframe * @param {!FriendlyIframeSpec} spec + * @param {!Promise} loadedPromise */ - constructor(iframe, spec) { + constructor(iframe, spec, loadedPromise) { /** @const {!HTMLIFrameElement} */ this.iframe = iframe; @@ -212,6 +262,18 @@ export class FriendlyIframeEmbed { /** @const {!FriendlyIframeSpec} */ this.spec = spec; + + /** @private @const {!Promise} */ + this.loadedPromise_ = loadedPromise; + } + + /** + * Returns promise that will resolve when the child window has fully been + * loaded. + * @return {!Promise} + */ + whenLoaded() { + return this.loadedPromise_; } /** diff --git a/test/functional/test-document-ready.js b/test/functional/test-document-ready.js index dfaa882ab57d..41529d1f74b7 100644 --- a/test/functional/test-document-ready.js +++ b/test/functional/test-document-ready.js @@ -48,12 +48,14 @@ describe('documentReady', () => { afterEach(() => { sandbox.restore(); - }); it('should interpret readyState correctly', () => { expect(isDocumentReady(testDoc)).to.equal(false); + testDoc.readyState = 'uninitialized'; + expect(isDocumentReady(testDoc)).to.equal(false); + testDoc.readyState = 'interactive'; expect(isDocumentReady(testDoc)).to.equal(true); diff --git a/test/functional/test-friendly-iframe-embed.js b/test/functional/test-friendly-iframe-embed.js index 8bf77dc6238e..a298d38ab6f7 100644 --- a/test/functional/test-friendly-iframe-embed.js +++ b/test/functional/test-friendly-iframe-embed.js @@ -241,4 +241,205 @@ describe('friendly-iframe-embed', () => { + 'content'); }); }); + + describe('child document ready and loaded states', () => { + + it('should wait until ready', () => { + const embedPromise = installFriendlyIframeEmbed(iframe, document.body, { + url: 'https://acme.org/url1', + html: '', + }); + return embedPromise.then(() => { + expect(iframe.contentDocument.getElementById('a1')).to.be.ok; + }); + }); + + it('should wait until ready for doc.write case', () => { + setSrcdocSupportedForTesting(false); + const embedPromise = installFriendlyIframeEmbed(iframe, document.body, { + url: 'https://acme.org/url1', + html: '', + }); + return embedPromise.then(() => { + expect(iframe.contentDocument.getElementById('a1')).to.be.ok; + }); + }); + + it('should wait for loaded state', () => { + const embedPromise = installFriendlyIframeEmbed(iframe, document.body, { + url: 'https://acme.org/url1', + html: '', + }); + return embedPromise.then(embed => { + return embed.whenLoaded(); + }); + }); + + it('should wait for loaded state for doc.write case', () => { + setSrcdocSupportedForTesting(false); + const embedPromise = installFriendlyIframeEmbed(iframe, document.body, { + url: 'https://acme.org/url1', + html: '', + }); + return embedPromise.then(embed => { + return embed.whenLoaded(); + }); + }); + }); + + describe('child document ready polling', () => { + let clock; + let win; + let iframe; + let contentWindow; + let contentDocument; + let contentBody; + let container; + let loadListener, errorListener; + let polls; + + beforeEach(() => { + setSrcdocSupportedForTesting(true); + + clock = sandbox.useFakeTimers(); + + polls = []; + win = { + services: { + 'extensions': {obj: { + installExtensionsInChildWindow: () => {}, + loadExtension: () => {}, + }}, + }, + setInterval: function() { + const interval = window.setInterval.apply(window, arguments); + polls.push(interval); + return interval; + }, + clearInterval: function(interval) { + window.clearInterval.apply(window, arguments); + const index = polls.indexOf(interval); + if (index != -1) { + polls.splice(index, 1); + } + }, + }; + + loadListener = undefined; + iframe = { + ownerDocument: {defaultView: win}, + style: {}, + setAttribute: () => {}, + addEventListener: (eventType, listener) => { + if (eventType == 'load') { + loadListener = listener; + } else if (eventType == 'error') { + errorListener = listener; + } + }, + removeEventListener: () => {}, + }; + contentWindow = {}; + contentDocument = {}; + contentBody = {}; + container = { + appendChild: () => {}, + }; + }); + + afterEach(() => { + expect(polls).to.have.length(0); + }); + + it('should not poll if body is already ready', () => { + contentBody.firstChild = {}; + contentDocument.body = contentBody; + contentWindow.document = contentDocument; + iframe.contentWindow = contentWindow; + const embedPromise = installFriendlyIframeEmbed(iframe, container, { + url: 'https://acme.org/url1', + html: '', + }); + expect(polls).to.have.length(0); + let ready = false; + embedPromise.then(() => { + ready = true; + }); + return Promise.race([Promise.resolve(), embedPromise]).then(() => { + expect(ready).to.be.true; + }); + }); + + it('should poll until ready', () => { + const embedPromise = installFriendlyIframeEmbed(iframe, container, { + url: 'https://acme.org/url1', + html: '', + }); + let ready = false; + embedPromise.then(() => { + ready = true; + }); + expect(polls).to.have.length(1); + return Promise.race([Promise.resolve(), embedPromise]).then(() => { + expect(ready).to.be.false; + expect(polls).to.have.length(1); + + // Window is now available. + iframe.contentWindow = contentWindow; + clock.tick(5); + return Promise.race([Promise.resolve(), embedPromise]); + }).then(() => { + expect(ready).to.be.false; + expect(polls).to.have.length(1); + + // Document is now available. + contentWindow.document = contentDocument; + clock.tick(5); + return Promise.race([Promise.resolve(), embedPromise]); + }).then(() => { + expect(ready).to.be.false; + expect(polls).to.have.length(1); + + // Body is now available. + contentDocument.body = contentBody; + clock.tick(5); + return Promise.race([Promise.resolve(), embedPromise]); + }).then(() => { + expect(ready).to.be.false; + expect(polls).to.have.length(1); + + // Body is now not empty. + contentBody.firstChild = {}; + clock.tick(5); + return Promise.race([Promise.resolve(), embedPromise]); + }).then(() => { + expect(ready).to.equal(true, 'Finally ready'); + expect(polls).to.have.length(0); + }); + }); + + it('should stop polling when loaded', () => { + const embedPromise = installFriendlyIframeEmbed(iframe, container, { + url: 'https://acme.org/url1', + html: '', + }); + expect(polls).to.have.length(1); + loadListener(); + return embedPromise.then(() => { + expect(polls).to.have.length(0); + }); + }); + + it('should stop polling when loading failed', () => { + const embedPromise = installFriendlyIframeEmbed(iframe, container, { + url: 'https://acme.org/url1', + html: '', + }); + expect(polls).to.have.length(1); + errorListener(); + return embedPromise.then(() => { + expect(polls).to.have.length(0); + }); + }); + }); });