-
Notifications
You must be signed in to change notification settings - Fork 75
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
Conversation
Does this mean that rule didn't work exactly properly before? |
Yep! |
utilsForFrontend.mjs
Outdated
@@ -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' || |
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.
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?
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, 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?
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.
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;
}
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.
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.
2290a34
to
a711471
Compare
isVisible
for correctness and performance
a711471
to
2c76c08
Compare
Hm... |
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! |
2c76c08
to
6d8c030
Compare
utilsForFrontend.mjs
Outdated
const scrollY = window.pageYOffset; | ||
const absX = rect.x + scrollX; | ||
const absY = rect.y + scrollY; | ||
window.scrollTo(absX, absY); |
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.
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)?
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.
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]:
- 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.
- 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.
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.
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!
utilsForFrontend.mjs
Outdated
window.scrollTo(absX, absY); | ||
const newX = absX - window.pageXOffset; | ||
const newY = absY - window.pageYOffset; | ||
const eles = document.elementsFromPoint(newX, newY); |
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.
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).
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 |
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
@erikrose , would you like me to submit the Firefox-specific solution as part of this PR or in a separate PR? |
Let's do a separate one. I almost always lean toward getting *something* landed earlier so people can start enjoying it—and in case we get interrupted. Thanks! :-D
|
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.
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.
utilsForFrontend.mjs
Outdated
* 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 |
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.
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.
utilsForFrontend.mjs
Outdated
*/ | ||
export function isVisible(fnodeOrElement) { | ||
const element = toDomElement(fnodeOrElement); | ||
// Avoid reading ``display: none`` due to Bug 1381071 |
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.
Great place for knowledge like this.
utilsForFrontend.mjs
Outdated
return false; | ||
} | ||
// Check if the element is off-screen | ||
if (isElement && ((rect.right + frame.scrollX < 0) || (rect.bottom + frame.scrollY < 0))) { |
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.
Unless I'm mistaken, you can move this to before the for
loop, strip off the isElement
condition, and it'll be equivalent.
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.
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
5e4d0b6
to
23f86cf
Compare
@erikrose , This is ready for another look! |
Oh noes! Since you force-pushed, now I don't know what's new anymore. :-( Let me see if I can figure it out. |
@erikrose Just the last two commits are new. |
Thanks. Just as soon as I said that and reloaded the page, GitHub started showing me "These commits are new since you reviewed". :-)
|
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. |
utilsForFrontend.mjs
Outdated
const frame = element.ownerDocument.defaultView; | ||
if (elementRect.x + elementRect.width < 0 | ||
|| (elementRect.y + elementRect.height) < 0 |
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.
Why parens here but not in the sum above?
Also, if you put the ||
on the right, these similar lines will line up nicely.
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.
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
.
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 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.
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.
As for the operator placement, I was just trying to make the very similar lines line up for faster scanning.
utilsForFrontend.mjs
Outdated
@@ -475,7 +473,14 @@ export function isVisible(fnodeOrElement) { | |||
if (elementStyle.visibility === 'hidden') { | |||
return false; | |||
} | |||
// Check if the element is off-screen |
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 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.
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 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.
utilsForFrontend.mjs
Outdated
const frame = element.ownerDocument.defaultView; | ||
if (elementRect.x + elementRect.width < 0 | ||
|| (elementRect.y + elementRect.height) < 0 | ||
|| (elementRect.x > frame.innerWidth || elementRect.y > frame.innerHeight) |
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.
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
@erikrose ready for another pass! |
utilsForFrontend.mjs
Outdated
@@ -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') && |
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 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?
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 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.
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.
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') { |
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.
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".
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.
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 .
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, 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.
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.
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.
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.
We discussed this synchronously, and we're just going to leave off any additional comments for now.
@erikrose , Ready for another look. |
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.
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') { |
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, 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.
The width and height properties in the return value from getComputedStyle are of the form '*px'.