New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
🐛Intersection Observer Polyfill Mutation Observer - Fixed Race Condition #19168
Changes from 11 commits
7ef9930
ad491aa
63caf4b
2be1f3b
59f561e
c17cb5c
87ec925
f96fd1b
0eba342
d5051b9
9892c8e
42a9964
c7282f8
241a4fe
05f8046
e75f2b5
14fc692
095f94f
9bf91c1
5861c3b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -14,7 +14,6 @@ | |
* limitations under the License. | ||
*/ | ||
|
||
import {Pass} from './pass'; | ||
import {Services} from './services'; | ||
import {SubscriptionApi} from './iframe-helper'; | ||
import {dev} from './log'; | ||
|
@@ -245,8 +244,8 @@ export class IntersectionObserverPolyfill { | |
/** @private {?./service/viewport/viewport-impl.Viewport} */ | ||
this.viewport_ = null; | ||
|
||
/** @private {?Pass} */ | ||
this.mutationPass_ = null; | ||
/** @private {?./service/resources-impl.Resources} */ | ||
this.resources_ = null; | ||
} | ||
|
||
/** | ||
|
@@ -295,11 +294,7 @@ export class IntersectionObserverPolyfill { | |
const ampdoc = Services.ampdoc(element); | ||
if (ampdoc.win.MutationObserver && !this.mutationObserver_) { | ||
this.viewport_ = Services.viewportForDoc(element); | ||
this.mutationPass_ = new Pass(ampdoc.win, () => { | ||
if (this.viewport_) { | ||
this.tick(this.viewport_.getRect()); | ||
} | ||
}); | ||
this.resources_ = Services.resourcesForDoc(ampdoc); | ||
this.mutationObserver_ = new ampdoc.win.MutationObserver( | ||
this.handleMutationObserverNotification_.bind(this) | ||
); | ||
|
@@ -339,8 +334,9 @@ export class IntersectionObserverPolyfill { | |
* The iframe must be a non-scrollable iframe. | ||
* @param {!./layout-rect.LayoutRectDef} hostViewport | ||
* @param {./layout-rect.LayoutRectDef=} opt_iframe | ||
* @param {boolean=} opt_calledFromMutationObserver | ||
*/ | ||
tick(hostViewport, opt_iframe) { | ||
tick(hostViewport, opt_iframe, opt_calledFromMutationObserver) { | ||
if (opt_iframe) { | ||
// If element inside an iframe. Adjust origin to the iframe.left/top. | ||
hostViewport = | ||
|
@@ -356,7 +352,11 @@ export class IntersectionObserverPolyfill { | |
|
||
for (let i = 0; i < this.observeEntries_.length; i++) { | ||
const change = this.getValidIntersectionChangeEntry_( | ||
this.observeEntries_[i], hostViewport, opt_iframe); | ||
this.observeEntries_[i], | ||
hostViewport, | ||
opt_iframe, | ||
opt_calledFromMutationObserver | ||
); | ||
if (change) { | ||
changes.push(change); | ||
} | ||
|
@@ -376,22 +376,37 @@ export class IntersectionObserverPolyfill { | |
* @param {!ElementIntersectionStateDef} state | ||
* @param {!./layout-rect.LayoutRectDef} hostViewport hostViewport's rect | ||
* @param {./layout-rect.LayoutRectDef=} opt_iframe iframe container rect | ||
* If opt_iframe is provided, all LayoutRect has position relative to | ||
* the iframe. If opt_iframe is not provided, | ||
* all LayoutRect has position relative to the host document. | ||
* @param {boolean=} opt_calledFromMutationObserver | ||
* Whether this was called from a mutation observer | ||
* This will signal that the layout of the elements | ||
* must use getBoundingClientRect, as Resource Scheduler | ||
* could be called too late, and not clear the layout cache. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You could instead trigger tick on the next Resource pass, which will mean everything's been remeasured. Then we don't need booleans floating around. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Oh nice! Yeah let's try that, I think that would be the most optimal solution. Are the Resource passes usually done fast? As in there is nothing that could make it take longer than a second? And what is the function/service to wait for this? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
They can take up to 30s if there's no activity on the page (or it's in the background). But since there was a mutation, it'll happen in the next 100ms.
There's not currently one to do this. We can add a method to class Resources {
constructor() {
// ...
/** @const @private {!Array<function()>} */
this.onNextPass_ = [];
}
doPass() {
//...
for (let i = 0; i < this.onNextPass_.length; i++) {
const fn = this.onNextPass_[i];
fn();
}
this.onNextPass_.length = 0;
}
/**
* Registers a callback to be called when the next pass happens.
* @param {function()}
*/
onNextPass(fn) {
this.onNextPass_.push(fn);
}
} |
||
* @return {?IntersectionObserverEntry} A valid change entry or null if ratio | ||
* @private | ||
*/ | ||
getValidIntersectionChangeEntry_(state, hostViewport, opt_iframe) { | ||
getValidIntersectionChangeEntry_(state, hostViewport, | ||
opt_iframe, opt_calledFromMutationObserver) { | ||
const {element} = state; | ||
|
||
// Normalize container LayoutRect to be relative to page | ||
let elementRect = null; | ||
const owner = element.getOwner(); | ||
let ownerRect = null; | ||
|
||
// If opt_iframe is provided, all LayoutRect has position relative to | ||
// the iframe. | ||
// If opt_iframe is not provided, all LayoutRect has position relative to | ||
// the host document. | ||
const elementRect = element.getLayoutBox(); | ||
const owner = element.getOwner(); | ||
ownerRect = owner && owner.getLayoutBox(); | ||
// If opt_calledFromMutationObserver is provided, | ||
// we need to get our bounding rect rather | ||
// Than our layout box, in order to avoid race conditions | ||
// with the resource scheduler | ||
if (opt_calledFromMutationObserver) { | ||
elementRect = this.viewport_.getLayoutRect(element); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @jridgewell Do we need different method when layer is enabled? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No, layers is corrected implemented in |
||
ownerRect = owner && this.viewport_.getLayoutRect(owner); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These need to be inside measure phases. |
||
} else { | ||
elementRect = element.getLayoutBox(); | ||
ownerRect = owner && owner.getLayoutBox(); | ||
} | ||
|
||
// calculate intersectionRect. that the element intersects with hostViewport | ||
// and intersects with owner element and container iframe if exists. | ||
|
@@ -420,12 +435,11 @@ export class IntersectionObserverPolyfill { | |
* @private | ||
*/ | ||
handleMutationObserverNotification_() { | ||
if (this.mutationPass_.isPending()) { | ||
return; | ||
if (this.viewport_ && this.resources_) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No need to check this. |
||
this.resources_.onNextPass(() => { | ||
this.tick(this.viewport_.getRect(), undefined, true); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This can cause multiple ticks to happen back to back. If |
||
}); | ||
} | ||
|
||
// Wait one animation frame so that other mutations may arrive. | ||
this.mutationPass_.schedule(16); | ||
return; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Useless return. |
||
} | ||
|
||
|
@@ -439,10 +453,7 @@ export class IntersectionObserverPolyfill { | |
} | ||
this.mutationObserver_ = null; | ||
this.viewport_ = null; | ||
if (this.mutationPass_) { | ||
this.mutationPass_.cancel(); | ||
} | ||
this.mutationPass_ = null; | ||
this.resources_ = null; | ||
} | ||
} | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -211,6 +211,9 @@ export class Resources { | |
/** @private {boolean} */ | ||
this.maybeChangeHeight_ = false; | ||
|
||
/** @const @private {!Array<function()>} */ | ||
this.passCallbacks_ = []; | ||
|
||
/** @private @const {!FiniteStateMachine<!VisibilityState>} */ | ||
this.visibilityStateMachine_ = new FiniteStateMachine( | ||
this.viewer_.getVisibilityState() | ||
|
@@ -1140,6 +1143,14 @@ export class Resources { | |
this.schedulePass(); | ||
} | ||
|
||
/** | ||
* Registers a callback to be called when the next pass happens. | ||
* @param {function()} callback | ||
*/ | ||
onNextPass(callback) { | ||
this.passCallbacks_.push(callback); | ||
} | ||
|
||
/** | ||
* Runs a pass immediately. | ||
*/ | ||
|
@@ -1169,7 +1180,7 @@ export class Resources { | |
this.contentHeight_ = this.viewport_.getContentHeight(); | ||
this.viewer_.sendMessage('documentHeight', | ||
dict({'height': this.contentHeight_}), /* cancelUnsent */true); | ||
dev().fine(TAG_, 'document height on load: ' + this.contentHeight_); | ||
dev().fine(TAG_, 'document height on load: %s', this.contentHeight_); | ||
} | ||
|
||
const viewportSize = this.viewport_.getSize(); | ||
|
@@ -1201,11 +1212,16 @@ export class Resources { | |
this.viewer_.sendMessage('documentHeight', | ||
dict({'height': measuredContentHeight}), /* cancelUnsent */true); | ||
this.contentHeight_ = measuredContentHeight; | ||
dev().fine(TAG_, 'document height changed: ' + this.contentHeight_); | ||
dev().fine(TAG_, 'document height changed: %s', this.contentHeight_); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks! |
||
this.viewport_.contentHeightChanged(); | ||
} | ||
}); | ||
} | ||
|
||
this.passCallbacks_.forEach(callback => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Runtime has a different set of performance requirements. Please use a for-loop here. Also when you fix this, don't do |
||
callback(); | ||
}); | ||
this.passCallbacks_.length = 0; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ha! Didn't know we can set length to 0 to clean an array : ) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Gotta give the credit to @jridgewell on that one, I didn't know either 😂 |
||
} | ||
|
||
/** | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -1634,6 +1634,34 @@ describe('Resources discoverWork', () => { | |
}); | ||
}); | ||
}); | ||
|
||
describe('passCallbacks_', () => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. "onNextPass" |
||
|
||
it('should run passCallbacks_ added from onNextPass()', () => { | ||
resources.isRuntimeOn_ = true; | ||
resources.documentReady_ = true; | ||
resources.firstPassAfterDocumentReady_ = true; | ||
|
||
const passCallback = sandbox.spy(); | ||
resources.onNextPass(passCallback); | ||
resources.doPass(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: if I understand it correctly, the callback will be called the first |
||
|
||
expect(passCallback).to.be.called; | ||
}); | ||
|
||
it('should remove passCallbacks_ added from onNextPass(), ' + | ||
' after the pass', () => { | ||
resources.isRuntimeOn_ = true; | ||
resources.documentReady_ = true; | ||
resources.firstPassAfterDocumentReady_ = true; | ||
|
||
const passCallback = sandbox.spy(); | ||
resources.onNextPass(passCallback); | ||
resources.doPass(); | ||
|
||
expect(resources.passCallbacks_.length).to.be.equal(0); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's not write tests on |
||
}); | ||
}); | ||
}); | ||
|
||
describes.realWin('Resources contentHeight', { | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There's no need to set and unset this. Just store it as a const reference.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I can keep the reference, however, I can't store it as a constant, as we do a check for if the window has MutationObserver before attempting to bind everything.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There's no need to gate the resources reference on whether the browser supports
MutationObserver
.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah yes! Good Point 😄 But another thing is, we don't have access to the ampdoc, until we get our first element observe. As the constructor of the polyfill only takes in a callback. 😮