Skip to content
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

Merged
merged 20 commits into from Nov 16, 2018

Conversation

torch2424
Copy link
Contributor

continuation of #19034

This makes the Mutation observer use getBoundingClientRect() to get around the resource scheduler not handling amp elements and updating their Layout Box from getLayoutBox() in time before the mutation pass is called to detect the changes.

cc @jridgewell Just wanted to make you aware of this. If you know of a better way to handle this please let us know. This is the best solution @zhouyx , @lannka , and I could come up with for this specific situation.

Example

This example was pulled by recreating the Fandango site by hand from the output AMP and "decompiling" it.

screen shot 2018-11-06 at 4 18 14 pm

// Than our layout box, in order to avoid race conditions
// with the resource scheduler
if (opt_calledFromMutationObserver) {
elementRect = element./*OK*/getBoundingClientRect();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@torch2424 elementRect is the rect relative to the document not viewport. We can't use getBoundingClientRect() directly.

@torch2424
Copy link
Contributor Author

Made requested changes, this PR is good to go 😄

@zhouyx
Copy link
Contributor

zhouyx commented Nov 7, 2018

@jridgewell would really like to get your feedback on this.

The reason why we need to call Viewport.getLayoutRect() here is because getLayoutBox() hasn't been updated when mutationObserver fire. I know that you want to only observe the document hidden attribute for performance reason, but calling getBoundingClientRect is expensive which I want to avoid.

Do you think it's possible to have mutationObserver only observe the AMP element's attribute change that is selected by visibility trigger. So that in this example InOb will use the <amp-img> layoutBox whenever it has been laid out. However I am not sure how to detect when [hidden] is added to the container. Do you know a good way to detect that.

// Than our layout box, in order to avoid race conditions
// with the resource scheduler
if (opt_calledFromMutationObserver) {
elementRect = this.viewport_.getLayoutRect(element);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jridgewell Do we need different method when layer is enabled?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, layers is corrected implemented in getLayoutRect. Note it returns a viewport relative box (not a page relative box), but that's no different than the current code.

// Than our layout box, in order to avoid race conditions
// with the resource scheduler
if (opt_calledFromMutationObserver) {
elementRect = this.viewport_.getLayoutRect(element);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, layers is corrected implemented in getLayoutRect. Note it returns a viewport relative box (not a page relative box), but that's no different than the current code.

// with the resource scheduler
if (opt_calledFromMutationObserver) {
elementRect = this.viewport_.getLayoutRect(element);
ownerRect = owner && this.viewport_.getLayoutRect(owner);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These need to be inside measure phases.

* 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.
Copy link
Contributor

Choose a reason for hiding this comment

The 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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are the Resource passes usually done fast? As in there is nothing that could make it take longer than a second?

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.

And what is the function/service to wait for this?

There's not currently one to do this. We can add a method to Resources, say onNextPass. Then, at the end of Resources.p.doPass, we resolve call all the pending callbacks, and clear out the array.

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);
  }
}

@lannka lannka removed their request for review November 9, 2018 22:55
@lannka lannka unassigned zhouyx and lannka Nov 9, 2018
@torch2424
Copy link
Contributor Author

cc @jridgewell @zhouyx

Made all requested changes, using the suggested onNextPass. Also, added tests for this new feature on Resources.

Lastly, confirmed that this solution works on my example! 🎉

screen shot 2018-11-09 at 6 42 18 pm

Thank you everyone for the help! 😄

@torch2424
Copy link
Contributor Author

@jridgewell @zhouyx

@aghassemi Just opened a P1 at #19252

Would like to get this pulled in so we can make a fix for this.

Will fix the remaining travis tests once I am sure this is the way we want to go. Thanks! 😄

this.tick(this.viewport_.getRect(), undefined, true);
}
});
this.resources_ = Services.resourcesForDoc(ampdoc);
Copy link
Contributor

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.

Copy link
Contributor Author

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.

Copy link
Contributor

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.

Copy link
Contributor Author

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. 😮

return;
if (this.viewport_ && this.resources_) {
this.resources_.onNextPass(() => {
this.tick(this.viewport_.getRect(), undefined, true);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can cause multiple ticks to happen back to back. If tick is not already throttled, it should be.

@@ -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_);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!

this.viewport_.contentHeightChanged();
}
});
}

this.passCallbacks_.forEach(callback => {
Copy link
Contributor

Choose a reason for hiding this comment

The 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 passCallbacks[i]() because that'll leak passCallbacks as the this context of the function invocation. Store fn = passCallbacks[i], then invoke fn().

@@ -1634,6 +1634,34 @@ describe('Resources discoverWork', () => {
});
});
});

describe('passCallbacks_', () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"onNextPass"

resources.onNextPass(passCallback);
resources.doPass();

expect(resources.passCallbacks_.length).to.be.equal(0);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's not write tests on passCallbacks_, since it's private. The intention here is that the fn isn't called twice, so let's run two passes and assert the fn isn't called twice.

@torch2424
Copy link
Contributor Author

@zhouyx @jridgewell

Made requested changes. This is good to go 😄

* @private
*/
handleMutationObserverPass_() {
if (this.viewport_ && this.resources_) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need to check this.

this.tick(this.viewport_.getRect());
});
}
return;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Useless return.

src/intersection-observer-polyfill.js Show resolved Hide resolved
handleMutationObserverPass_() {
if (this.viewport_ && this.resources_) {
this.resources_.onNextPass(() => {
this.tick(this.viewport_.getRect());
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tick still needs to be throttled.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tick is being throttled by the pass isn't it? 😮 Or do you want the pass inside the tick itself?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, I totally forgot we were using a Pass. This is fine, then.

@torch2424
Copy link
Contributor Author

@jridgewell @zhouyx

Replied to comments, and made more requested changes 😄

This is good to go.

const fn = this.passCallbacks_[i];
fn();
}
this.passCallbacks_.length = 0;
Copy link
Contributor

Choose a reason for hiding this comment

The 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 : )

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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 😂


const passCallback = sandbox.spy();
resources.onNextPass(passCallback);
resources.doPass();
Copy link
Contributor

Choose a reason for hiding this comment

The 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 doPass() is called. would be great to add that check as well with expect(passCallback).to.be.calledOnce;

}
});

if (!this.viewport_) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this.viewport_ and this.resources_ used elsewhere? And can element and ampdoc be passed to the handleMutationObserverPass? Asking because would prefer use calling Services.viewportForDoc(element) and Services.resourcesForDoc(ampdoc) directly instead. The cost difference should be minimum.

@torch2424
Copy link
Contributor Author

@jridgewell @zhouyx

Made requested changes 😄

This is good to go.

Copy link
Contributor

@zhouyx zhouyx left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the fix!!

@torch2424
Copy link
Contributor Author

Thank you! @zhouyx

Whenever you get the time @jridgewell let me know what you think and we can merge this in 😄

@torch2424 torch2424 merged commit 2a74533 into ampproject:master Nov 16, 2018
@torch2424 torch2424 deleted the fandango-bug-fix-no-example branch November 16, 2018 00:18
Enriqe pushed a commit to Enriqe/amphtml that referenced this pull request Nov 28, 2018
…ion (ampproject#19168)

* Got a working half-broken page that we can test with

* COnfirmed the bug is that the pass isn't long enough

* Going to go home. But I learned that it doesn't fire until the child image element is created.

* I was doing something

* Fixed the polyfill completely

* Fixed linting

* Fixed all precommit checks

* Removed the one-off fandango example

* Made PR Changes

* Created an onNextPass for resources

* Fixed tests for ampdoc

* Removed the original getLayoutRect

* Removed a dangling opt_calledFromMutationObserver

* Made PR Changes

* Fixed refresh tests for intersection observer

* Removed returns

* Fixed some linting errors

* Made PR Changes

* Fixed linting errors
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

6 participants