Skip to content

Commit

Permalink
Friendly iframes: a more precise document.ready signal (ampproject#5174)
Browse files Browse the repository at this point in the history
* Friendly iframes: a more precise document.ready signal

* more docs
  • Loading branch information
dvoytenko authored Sep 22, 2016
1 parent c75bcc7 commit 74c40e0
Show file tree
Hide file tree
Showing 4 changed files with 275 additions and 10 deletions.
2 changes: 1 addition & 1 deletion src/document-ready.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
* @return {boolean}
*/
export function isDocumentReady(doc) {
return doc.readyState != 'loading';
return doc.readyState != 'loading' && doc.readyState != 'uninitialized';
}

/**
Expand Down
78 changes: 70 additions & 8 deletions src/friendly-iframe-embed.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';


/**
Expand Down Expand Up @@ -96,35 +98,82 @@ 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';
container.appendChild(iframe);
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
Expand Down Expand Up @@ -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;

Expand All @@ -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_;
}

/**
Expand Down
4 changes: 3 additions & 1 deletion test/functional/test-document-ready.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
201 changes: 201 additions & 0 deletions test/functional/test-friendly-iframe-embed.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: '<a id="a1"></a>',
});
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: '<a id="a1"></a>',
});
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: '<a id="a1"></a>',
});
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: '<a id="a1"></a>',
});
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: '<body></body>',
});
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: '<body></body>',
});
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: '<body></body>',
});
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: '<body></body>',
});
expect(polls).to.have.length(1);
errorListener();
return embedPromise.then(() => {
expect(polls).to.have.length(0);
});
});
});
});

0 comments on commit 74c40e0

Please sign in to comment.