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

Fix #118: Improve isVisible for correctness and performance #116

Merged
merged 7 commits into from
Aug 13, 2019

Conversation

biancadanforth
Copy link
Collaborator

The width and height properties in the return value from getComputedStyle are of the form '*px'.

@danielhertenstein
Copy link
Collaborator

Does this mean that rule didn't work exactly properly before?

@erikrose
Copy link
Contributor

Yep!

@@ -468,8 +468,8 @@ export function isVisible(fnodeOrElement) {
if (style.visibility === 'hidden' ||
style.display === 'none' ||
style.opacity === '0' ||
style.width === '0' ||
style.height === '0') {
style.width === '0px' ||
Copy link
Contributor

Choose a reason for hiding this comment

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

While we're Doing Right Things, we might as well also cover cases like "0%", which I just happened to hit while testing this. My gosh, there are going to be a lot of them. :-( Maybe we should just rely on getBoundingClientRect instead? Would that always show a zero height or width when there is one?

Copy link
Collaborator Author

@biancadanforth biancadanforth Jul 19, 2019

Choose a reason for hiding this comment

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

Ah, okay, what if we use a variation on Emilio's initial recommended version (the second one with getBoxQuads()) from the Price Tracker issue which omits getComputedStyle().width/height entirely) with the else statement you have here regarding offscreen elements?

Copy link
Contributor

Choose a reason for hiding this comment

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

Sounds good. I assume we'd add something to maintain this offscreen-detection functionality from the current version:

            // It wasn't hidden based on a computed style. See if it's
            // offscreen:
            const rect = element.getBoundingClientRect();
            const frame = element.ownerDocument.defaultView;  // window or iframe
            if ((rect.right + frame.scrollX < 0) ||
                (rect.bottom + frame.scrollY < 0)) {
                return false;
            }

Copy link
Collaborator Author

@biancadanforth biancadanforth Jul 29, 2019

Choose a reason for hiding this comment

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

The updated implementation should cover that scenario, as we window.scrollTo to update the viewport location before checking for clickability.

I actually ended up going with the third option checking for clickability (see "Updating isVisible: second attempt" in this comment), but added the workaround that doesn't require privileged Firefox code. This yields a 53% reduction in Fathom-related jank (see the commit message for more details and actual profiles).

If/when the CSS working group updates element(s)FromPoint to include the option to ignore viewport clipping, then we can modify this method and we'll see the 81% reduction as described in that comment.

@biancadanforth biancadanforth changed the title Use correct values in isVisible condition Fix #118: Improve isVisible for correctness and performance Jul 24, 2019
@biancadanforth
Copy link
Collaborator Author

Hm... isVisible didn't have any test coverage initially, and I don't see anything set up for functional testing. Perhaps that can be a follow-up issue if desired?

@erikrose
Copy link
Contributor

Yes, there's no testing right now that uses an actual browser. It would be great to rig some for error-prone things like this function. Please open an issue!

@biancadanforth
Copy link
Collaborator Author

biancadanforth commented Jul 29, 2019

Edit: Just amended the commit subject so that it actually closes the issue when merged.

@erikrose Ready for your review! As discussed, this patch includes only the correctness fix and a performance improvement that excludes memoization, which is covered in #121 .

const scrollY = window.pageYOffset;
const absX = rect.x + scrollX;
const absY = rect.y + scrollY;
window.scrollTo(absX, absY);
Copy link
Contributor

@erikrose erikrose Jul 30, 2019

Choose a reason for hiding this comment

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

Does this make the page visibly jump? (That is, after all, its purpose; we're just hoping we can put it back before it redraws.) If not now, is there any guarantee that it won't start visibly jumping in a later Firefox (or other browser)?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Admittedly, this workaround is awkward. Though I got some heartening information from Emilio/the web[1], if the user is scrolling while isVisible happens to be running, there can be jumpiness. I'll have to revise my approach here.

[1]:

  1. I asked Emilio, the graphics/layout engineer who has been helping me analyze alternative approaches:

I think that work around is ok.

Javascript needs to run before rendering, the engine cannot simply
interrupt a script and do layout in order to figure out what changed.

Those calls are sync so I think you're ok.

My only concern is what happens if the user tries to scroll while that
code is running, since scrolling is async. But I think it should be
fine since we only tell the compositor about the main thread scroll
state after painting.

  1. I was reading a document on best practices, and it recommends:

...if you’re in an animation like scrolling, you should ideally be looking to keep your JavaScript to something in the region of 3-4 ms.

When I look at this implementation’s profile in Price Tracker (the Stack Chart, Focus: isVisible), I don’t see any calls exceeding 2 ms, and the average looks to be closer to 1 ms.

Screen Shot 2019-07-31 at 11 34 04 AM

Copy link
Contributor

@erikrose erikrose left a comment

Choose a reason for hiding this comment

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

If we can assert with reasonable confidence that the 2 scrollTo() calls won't make the page visibly jump (like perhaps we know FF never paints while JS is running—though that particular claim is demonstrably false), I'm happy to have this merged. Great performance numbers, Bianca!

window.scrollTo(absX, absY);
const newX = absX - window.pageXOffset;
const newY = absY - window.pageYOffset;
const eles = document.elementsFromPoint(newX, newY);
Copy link
Contributor

Choose a reason for hiding this comment

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

It's a nit, but I'd go with the full elements or something else unabbreviated here. We don't really have a convention of abbreviating things in this codebase (apart from fnodes, I suppose, but that has a glossary entry of its own).

@erikrose
Copy link
Contributor

erikrose commented Aug 3, 2019

In today's team meeting, we decided that scrollTo bore too much risk of making UI seem wonky. We decided to land the implementation that makes privileged code very fast while containing an else clause that makes non-privileged code merely correct (which the existing implementation isn't) and somewhat fast.

biancadanforth added a commit that referenced this pull request Aug 3, 2019
Unfortunately, the previous commit's approach based on clickability proved untenable.[1]

A distant second sync solution is to early return where possible and use the cheapest styles first (the second implementation approach[2]), which reduces Fathom jank by 13%*, though there are a couple differences:
* No 'getBoxQuads'. This is an experimental API only enabled on Nightly.
* Checks if the element is off-screen. The Price Tracker implementation was missing this check.

Unfortunately, this implementation still uses 'ancestors', which causes expensive XRays[3] work in extension applications and still triggers layout flushes at suboptimal times. This is something that can be avoided with an async solution to the tune of a 40% reduction in jank using 'requestAnimationFrame' and 'setTimeout'[4].

On the brighter side, it is more correct than the previous implementation, removing 'getComputedStyle().width' and 'getComputedStyle().height' completely and covering more valid cases than before.

*: This is slightly worse than the expected 16%, because my original implementation in Price Tracker did not check for elements off-screen as the Fathom implementation does. Its profile[5] shows:
 - The largest unresponsive chunk is still caused by Fathom extraction, contributing 399 ms of jank right around page load.
 - `isVisible` made up 238 ms (60%) of this jank.
 - This change reduced overall Fathom-related jank by 61 ms (13%) compared to the original implementation of isVisible[2].

[1]: #116 (comment)
[2]: mozilla/price-tracker#319 (comment)
[3]: https://developer.mozilla.org/en-US/docs/Mozilla/Tech/Xray_vision
[4]: mozilla/price-tracker#319 (comment)
[5]: https://perfht.ml/2T0oYQS
@biancadanforth
Copy link
Collaborator Author

@erikrose , would you like me to submit the Firefox-specific solution as part of this PR or in a separate PR?

@erikrose
Copy link
Contributor

erikrose commented Aug 7, 2019 via email

@biancadanforth
Copy link
Collaborator Author

Okay thanks @erikrose ; I agree with you on that. It's good to keep PRs small. This will make #122 more reasonable as well. I have requested your review then.

Copy link
Contributor

@erikrose erikrose left a comment

Choose a reason for hiding this comment

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

Just a couple of little things. Thanks for your thorough pursuit of this! The code looks good. I especially like your use of the ternary operator, which makes it clear that the various variables are initialized despite the branches.

* Return whether an element is practically visible, considering things like 0
* size or opacity, ``visibility: hidden`` and ``overflow: hidden``.
*
* This could be 5x more efficient if https://github.com/w3c/csswg-drafts/issues/4122
Copy link
Contributor

Choose a reason for hiding this comment

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

This part should be moved to a comment inside the function, as putting it in the doclet (the /** block above the function) causes it to be exposed in the rendered documentation.

*/
export function isVisible(fnodeOrElement) {
const element = toDomElement(fnodeOrElement);
// Avoid reading ``display: none`` due to Bug 1381071
Copy link
Contributor

Choose a reason for hiding this comment

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

Great place for knowledge like this.

return false;
}
// Check if the element is off-screen
if (isElement && ((rect.right + frame.scrollX < 0) || (rect.bottom + frame.scrollY < 0))) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Unless I'm mistaken, you can move this to before the for loop, strip off the isElement condition, and it'll be equivalent.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yes, I can do that. I also modified the check, as previously, I hadn't checked all four conditions (element is above, to the left, to the right or below the visible portion of the page).

The width and height properties in the return value from getComputedStyle are of the form '*px'.
Compared to a baseline profile[1] of Price Tracker's current 'isVisible' implementation (on which Fathom's 'isVisible' method is based), this clickability approach offers a 53% (146 ms) reduction in Fathom-related jank[2] for the same locally hosted sample page.

This is largely due to removing the use of the 'ancestors' Fathom method in 'isVisible'[3], which was performing a lot of redundant layout accesses (and triggering a lot of layout flushes) for the same elements. Also, at least in an extension application, DOM accesses (e.g. repeatedly getting the next 'parentNode' in 'ancestors') are very expensive due to X-Rays[4].

Notes:
* If the proposal to the W3C CSS Working Group (see inline comment in patch) is implemented, this clickability approach could forgo the workaround and see as much as 81% (374 ms) reduction in Fathom-related jank[3].
* This implementation can still benefit from memoization, as the same element (e.g. 'div') could be considered for multiple different 'type's[6].

[1]: https://perfht.ml/30wkWT7
[2]: https://perfht.ml/2Y5FCQ1
[3]: mozilla/price-tracker#319
[4]: https://developer.mozilla.org/en-US/docs/Mozilla/Tech/Xray_vision"
[6]: https://mozilla.github.io/fathom/glossary.html
Unfortunately, the previous commit's approach based on clickability proved untenable.[1]

A distant second sync solution is to early return where possible and use the cheapest styles first (the second implementation approach[2]), which reduces Fathom jank by 13%*, though there are a couple differences:
* No 'getBoxQuads'. This is an experimental API only enabled on Nightly.
* Checks if the element is off-screen. The Price Tracker implementation was missing this check.

Unfortunately, this implementation still uses 'ancestors', which causes expensive XRays[3] work in extension applications and still triggers layout flushes at suboptimal times. This is something that can be avoided with an async solution to the tune of a 40% reduction in jank using 'requestAnimationFrame' and 'setTimeout'[4].

On the brighter side, it is more correct than the previous implementation, removing 'getComputedStyle().width' and 'getComputedStyle().height' completely and covering more valid cases than before.

*: This is slightly worse than the expected 16%, because my original implementation in Price Tracker did not check for elements off-screen as the Fathom implementation does. Its profile[5] shows:
 - The largest unresponsive chunk is still caused by Fathom extraction, contributing 399 ms of jank right around page load.
 - `isVisible` made up 238 ms (60%) of this jank.
 - This change reduced overall Fathom-related jank by 61 ms (13%) compared to the original implementation of isVisible[2].

[1]: #116 (comment)
[2]: mozilla/price-tracker#319 (comment)
[3]: https://developer.mozilla.org/en-US/docs/Mozilla/Tech/Xray_vision
[4]: mozilla/price-tracker#319 (comment)
[5]: https://perfht.ml/2T0oYQS
@biancadanforth
Copy link
Collaborator Author

@erikrose , This is ready for another look!

@erikrose
Copy link
Contributor

erikrose commented Aug 9, 2019

Oh noes! Since you force-pushed, now I don't know what's new anymore. :-( Let me see if I can figure it out.

@biancadanforth
Copy link
Collaborator Author

@erikrose Just the last two commits are new.

@erikrose
Copy link
Contributor

erikrose commented Aug 9, 2019 via email

@biancadanforth
Copy link
Collaborator Author

Yeah I was just editing the commit subject from one of the earlier commits to be more accurate. I'll try to keep rebasing to a minimum to avoid confusion.

const frame = element.ownerDocument.defaultView;
if (elementRect.x + elementRect.width < 0
|| (elementRect.y + elementRect.height) < 0
Copy link
Contributor

Choose a reason for hiding this comment

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

Why parens here but not in the sum above?

Also, if you put the || on the right, these similar lines will line up nicely.

Copy link
Collaborator Author

@biancadanforth biancadanforth Aug 10, 2019

Choose a reason for hiding this comment

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

Do we want to add an eslint rule on parens? e.g. no-extra-parens: error I have no strong opinion. It seems like you may want more nuance than that based on the violations that come up if I were to add a strict ban on extra parens.

It sounds like you do have an opinion about operators in conditional statements, so I added an eslint rule for that in .eslintrc.yml: operator-linebreak: [error, after] and fixed the one other instance of this elsewhere in utilsForFrontend.mjs.

Copy link
Contributor

Choose a reason for hiding this comment

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

I don't see a benefit in making everybody memorize the precedence of every little operator, which would seem to be a requirement for no-extra-parens. I'm just looking for consistency. When I see inconsistency, I think "Aha, the author is trying to show me that something is different about this other case," and I waste time looking for it. That's all.

Copy link
Contributor

Choose a reason for hiding this comment

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

As for the operator placement, I was just trying to make the very similar lines line up for faster scanning.

@@ -475,7 +473,14 @@ export function isVisible(fnodeOrElement) {
if (elementStyle.visibility === 'hidden') {
return false;
}
// Check if the element is off-screen
Copy link
Contributor

Choose a reason for hiding this comment

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

I was saying to Daniel the other day that I like to punctuate comments like sentences if they are, in fact, sentences. I think it's influence from https://www.python.org/dev/peps/pep-0008/#comments. But on comments like this that take up the whole line and refer to code below, I often stick a colon afterward. That way, even if you don't skip a line before, it's clear you're talking about code only below it.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I can get behind the complete sentence thing. Not sure if I will remember your preference around colons, but I'll try to keep that in mind...I added a colon.

const frame = element.ownerDocument.defaultView;
if (elementRect.x + elementRect.width < 0
|| (elementRect.y + elementRect.height) < 0
|| (elementRect.x > frame.innerWidth || elementRect.y > frame.innerHeight)
Copy link
Contributor

Choose a reason for hiding this comment

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

It seems like the parens around this pair of conditions shouldn't be here, since you didn't put them around the top pair (and they don't affect the logic).

* Add a new eslint rule, 'operator-linebreak: [error, after]' in light of style-related feedback and fixed violations
* Ensure comments are complete sentences and are consistent
* Remove extra parens from conditional statements
* Update the first check in 'isVisible' to account for an extra case I found while working on #122
@biancadanforth
Copy link
Collaborator Author

@erikrose ready for another pass!

@@ -160,8 +160,8 @@ export function *inlineTexts(element, shouldTraverse = element => true) {
for (let child of walk(element,
element => !(isBlock(element) ||
element.tagName === 'SCRIPT' &&
element.tagName === 'STYLE')
&& shouldTraverse(element))) {
element.tagName === 'STYLE') &&
Copy link
Contributor

Choose a reason for hiding this comment

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

I think this actually make it harder to understand. I don't remember writing this (though I probably did), but here's how I read the original:

The ! lines up with the &&, suggesting that 2 clauses are ANDed together. That's what's actually going on.

When you move the 2nd &&, the 2 &&s line up exactly, and it starts looking like shouldTraverse is just one more clause in the earlier &&, unless you take the time to painstakingly balance the parens.

So I think the original indentation visually conveys the structure more clearly. Does that make sense?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I see what you mean, though that prevents us from having an eslint rule to cover it, as leaving this as-is is not consistent (from eslint's perspective) with changing the other one.

I'm happy to change it back as you prefer, however.

Copy link
Contributor

@erikrose erikrose left a comment

Choose a reason for hiding this comment

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

So, unless you see a reason not to, can you roll back the utilsForFrontEnd change and the lint rule? And I have that one question about the !== vs. ===. Then we should be ready to merge at last! Thanks, Bianca!

if (elementRect.width === 0 && elementRect.height === 0) {
const elementStyle = getComputedStyle(element);
// Alternative to reading ``display: none`` due to Bug 1381071.
if (elementRect.width === 0 && elementRect.height === 0 && elementStyle.overflow !== 'hidden') {
Copy link
Contributor

Choose a reason for hiding this comment

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

Shouldn't this be elementStyle.overflow === 'hidden'? As you say below, "Zero-sized ancestors don’t make descendants hidden unless the descendant has overflow: hidden".

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

The check you're thinking of is done inside the for loop below. This first check is a proxy for checking display: none, for which just checking width and height === 0 is not sufficient as I learned while working on #122 .

Copy link
Contributor

Choose a reason for hiding this comment

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

Ah, so this is a check strictly for the visibility of the element passed in, not any of its descendents. A child, for example, could still be visible. Callers will have to be aware of this. That'd be worth documenting in the doclet, as I wasn't front-end-savvy enough to realize it.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Hm... I'm not sure I follow what you want me to document. In general, this method is to check if the element passed in is visible. It doesn't make any claims about that element's descendants.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

We discussed this synchronously, and we're just going to leave off any additional comments for now.

@biancadanforth
Copy link
Collaborator Author

@erikrose , Ready for another look.

Copy link
Contributor

@erikrose erikrose left a comment

Choose a reason for hiding this comment

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

Merge it! I'm sorry the review went on so long, but I think we've got something good here. Looking forward to the tests landing as well. (In the future, of course, we'll try to land them together.) Thanks, Bianca!

if (elementRect.width === 0 && elementRect.height === 0) {
const elementStyle = getComputedStyle(element);
// Alternative to reading ``display: none`` due to Bug 1381071.
if (elementRect.width === 0 && elementRect.height === 0 && elementStyle.overflow !== 'hidden') {
Copy link
Contributor

Choose a reason for hiding this comment

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

Ah, so this is a check strictly for the visibility of the element passed in, not any of its descendents. A child, for example, could still be visible. Callers will have to be aware of this. That'd be worth documenting in the doclet, as I wasn't front-end-savvy enough to realize it.

@biancadanforth biancadanforth merged commit 871728f into master Aug 13, 2019
@biancadanforth biancadanforth deleted the is-visible branch August 13, 2019 23:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants