-
Notifications
You must be signed in to change notification settings - Fork 9.4k
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
new-audit(font-display): enforce font-display optional #3831
Conversation
Thanks for working on the initial PR for this, @wardpeet!
I was worried about FCP not being able to always capture the right moment here. Are there examples of particular sites where this is more of a pronounced issue than others? |
@addyosmani haven't found a site that has FOIT. We should probably look for sites where fcp is a bit lower than fmp like 10% or so. Haven't seen any sites in the wild. I don't see FOIT that often as I'm on WIFI most of the time you can trick lighthouse by
|
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.
nice ward!! thanks for pushing up the WIP! :)
lighthouse-core/audits/webfonts.js
Outdated
|
||
// Filter font-faces that do not have a display tag with optional or swap | ||
const fontsWithoutProperDisplay = fontFaces.filter(fontFace => | ||
!fontFace.display || !['optional', 'swap'].includes(fontFace.display) |
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 should be fine with fallback too I think, just not pushing for it in our docs
lighthouse-core/audits/webfonts.js
Outdated
|
||
return Promise.all([traceOfTabPromise, networkPromise]).then(([tabTrace, networkRecords]) => { | ||
let totalWasted = 0; | ||
const fcpInMS = tabTrace.timestamps.firstContentfulPaint / 1000000; |
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.
can we compare everything in milliseconds instead?
you'll need to multiply the record endtimes by 1000 but keeps things easier than switching :)
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.
sure :)
lighthouse-core/audits/webfonts.js
Outdated
}) | ||
// calculate wasted time | ||
.map(record => { | ||
const wastedTime = (record._endTime * 1000 - tabTrace.timestamps.navigationStart / 1000); |
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.
using navstart is a little mean since it's definitely not delaying anything until it's been discovered, how about just the record._startTime?
I think @paulirish disagrees with me on this though
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.
wasn't sure but startTime is the best timing we got as I haven't found a way yet to get the initiator timing (is this a domNode that enters the page later on with JS or is it a stylesheet that gets loaded, ???). What if the font got preloaded do we still want to show this error as the font could be just fetched from cache. Should we only consider fonts that take longer than 100ms? I guess cached fonts don't get hit by the network throttling
weight: fontFace.weight, | ||
}); | ||
|
||
if (document.fonts.status === 'loaded') { |
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.
is this if necessary? can we just always use document.fonts.ready
also TIL about document.fonts
:)
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 document.fonts.ready didn't resolve anymore if all fonts were already loaded (could be mistaken)
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 playing around with it on github console seemed to be an already resolved promise so might be ok?
smoketests should help us find out :)
var rule = document.styleSheets[sheet].cssRules[i]; | ||
|
||
if (rule instanceof CSSFontFaceRule) { | ||
const keys = Object.keys(rule.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.
feels a bit odd to iterate over the properties in this way, can we just pull the font data directly?
fontFaceRules.push({
display: rule.style.fontDisplay,
...
})
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.
sure we can, just wanted this audit to be future proof if any more rules were added to font-face that we could use but maybe just add it if when we need 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.
ah, I'd be in favor of clearer code until we need actually more font props
not sure about others
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 follow your reasoning so 👍
} | ||
|
||
_onStyleSheetRemoved({ stylesheetId }) { | ||
this.stylesheetIds.splice(this.stylesheetIds.indexOf(stylesheetId), 1); |
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 stylesheetIds look unused at this point, do we still need them?
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.
yeah I left it here because I wasn't sure if we would go for the javascript approach or devtools protocol approach with devtools I need to get the stylesheet ids so I can parse them in afterpass
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 do it without devtools and just stick to the JS that seems great to me :)
lighthouse-core/audits/webfonts.js
Outdated
const fcpInMS = tabTrace.timestamps.firstContentfulPaint / 1000000; | ||
const results = networkRecords.filter(record => { | ||
const isFont = record._resourceType === WebInspector.resourceTypes.Font; | ||
const isLoadedBeforeFCP = record._endTime < fcpInMS || true; |
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 thought we settled on flagging all fonts since FCP was too early, is this right @addyosmani @paulirish or am I misremembering?
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.
haha my debug is still here with the || true
so basically I don't check fcp 😛 One problem with not using anything like fcp means that we also flag fonts that are using font loader api which in theory do not need the font-display.
'use strict'; | ||
|
||
const Gatherer = require('./gatherer'); | ||
const fontUrlRegex = new RegExp('url\\((?:"|\')([^"]+)(?:"|\')\\)'); |
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 this is just getting the URL as its defined then isn't it? Ideally we could get the resolved URL, but not sure best way.
Perhaps just do this in the page and set the href of a link to get the real value?
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 needed this to parse
@font-face {
font-family: test;
src: local(Test), url('test.woff2'), url("test.woff");
it's probably a good idea to get the full url using the anchor trick
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.
yeah totally get why it's needed just hoping we can report https://example.com/test.woff2
instead of just test.woff2
👍
lighthouse-core/audits/webfonts.js
Outdated
description: 'uses font-display', | ||
failureDescription: 'Your fonts are blocking FCP!', | ||
helpText: 'You should use font-display!!!!', | ||
requiredArtifacts: ['traces', 'Fonts'], |
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.
add devtoolsLogs
lighthouse-core/audits/webfonts.js
Outdated
return { | ||
name: 'webfonts', | ||
description: 'uses font-display', | ||
failureDescription: 'Your fonts are blocking FCP!', |
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.
Avoid invisible text while webfonts are loading
lighthouse-core/audits/webfonts.js
Outdated
static get meta() { | ||
return { | ||
name: 'webfonts', | ||
description: 'uses font-display', |
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.
All text remains visible during webfont loads
lighthouse-core/audits/webfonts.js
Outdated
name: 'webfonts', | ||
description: 'uses font-display', | ||
failureDescription: 'Your fonts are blocking FCP!', | ||
helpText: 'You should use font-display!!!!', |
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.
Leverage the font-display
CSS feature to ensure text is user-visible while webfonts are loading and avoid a FOIT.
lighthouse-core/audits/webfonts.js
Outdated
const UnusedBytes = require('./byte-efficiency/byte-efficiency-audit'); | ||
const allowedFontFaceDisplays = ['optional', 'swap', 'fallback']; | ||
|
||
class WebFonts extends Audit { |
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.
let's name the audit font-display
. change filename, classname, meta.name, etc
lighthouse-core/audits/webfonts.js
Outdated
return { | ||
score: UnusedBytes.scoreForWastedMs(totalWasted), | ||
rawValue: totalWasted, | ||
displayValue: Util.formatMilliseconds(totalWasted, 1), |
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.
let's skip a displayValue for this. i don't think summing these times makes too much sense. nor does showing count of the offending requests.
lighthouse-core/audits/webfonts.js
Outdated
const details = Audit.makeTableDetails(headings, results); | ||
|
||
return { | ||
score: UnusedBytes.scoreForWastedMs(totalWasted), |
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.
how should we determine this is failing/passing?
IMO basically everything should have a font-display on it. if it's an icon-font they might want to explicitly say font-display: block
. But they need to make a call either way.
If we follow this policy, then rawValue: results.length === 0
lighthouse-core/audits/webfonts.js
Outdated
const Util = require('../report/v2/renderer/util'); | ||
const WebInspector = require('../lib/web-inspector'); | ||
const UnusedBytes = require('./byte-efficiency/byte-efficiency-audit'); | ||
const allowedFontFaceDisplays = ['optional', 'swap', 'fallback']; |
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.
similar to our new caching audit, let's accept 'block' as an accepted value.
Slightly better is we include block
ones in the results table, but indicate with text that this was allowed. We can defer this later, however.
'auto' i don't think we should accept.
).then(([loadedFonts, fontFaces]) => { | ||
return loadedFonts.map(fontFace => { | ||
const fontFaceItem = this._findSameFontFamily(fontFace, fontFaces); | ||
fontFace.src = fontFaceItem.src || []; |
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.
running this on paulirish.com i'm getting an exception here that fontFaceItem
is undefined.. https://sentry.io/google-lighthouse/lighthouse/issues/410603773/
to help debug, the call into _findSameFontFamily
has these two arguments:
{ fontFacesList: [] }
{ fontFace:
{ display: 'auto',
family: 'Droid Sans',
stretch: 'normal',
style: 'normal',
weight: '400' } }
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.
figured it out and wrote the patch. :)
diff --git a/lighthouse-core/gather/gatherers/fonts.js b/lighthouse-core/gather/gatherers/fonts.js
index 9b3f82bc..363f8217 100644
--- a/lighthouse-core/gather/gatherers/fonts.js
+++ b/lighthouse-core/gather/gatherers/fonts.js
@@ -23,7 +23,11 @@ function getAllLoadedFonts() {
});
}
function getFontFaceFromStylesheets() {
+ let resolve;
+ const promise = new Promise(fulfill => { resolve = fulfill; });
+
function resolveUrl(url) {
const link = document.createElement('a');
link.href = url;
@@ -34,8 +38,24 @@ function getFontFaceFromStylesheets() {
const fontUrlRegex = new RegExp('url\\((?:"|\')([^"]+)(?:"|\')\\)');
const fontFaceRules = [];
// get all loaded stylesheets
for (let sheet = 0; sheet < document.styleSheets.length; sheet++) {
const stylesheet = document.styleSheets[sheet];
+
+ // Cross-origin stylesheets don't expose cssRules by default. We reload them with CORS headers.
+ if (stylesheet.cssRules === null && stylesheet.href && stylesheet.ownerNode && !stylesheet.ownerNode.crossOrigin) {
+ const oldNode = stylesheet.ownerNode;
+ const newNode = oldNode.cloneNode(true);
+ newNode.addEventListener('load', function onload(){
+ newNode.removeEventListener('load', onload);
+ resolve(getFontFaceFromStylesheets());
+ });
+ newNode.crossOrigin = 'anonymous';
+ oldNode.parentNode.insertBefore(newNode, oldNode);
+ oldNode.remove();
+ return promise;
+ }
+
for (let i = 0; stylesheet.cssRules && i < stylesheet.cssRules.length; i++) {
var rule = stylesheet.cssRules[i];
@@ -61,7 +81,7 @@ function getFontFaceFromStylesheets() {
}
}
- return fontFaceRules;
+ return Promise.resolve(fontFaceRules);
}
/* eslint-enable */
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.
separately i think you'll need some error handling for the case that we have a mismatch.
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.
nice catch, let me cleanup this audit a bit :) it was just a WIP.
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 did a bit differently as I think the way I did it is more readable. I also got a security exception when running stylesheet.cssRules
so I used a try catch but also an if just to be sure to cover all the things
// get all loaded stylesheets | ||
for (let sheet = 0; sheet < document.styleSheets.length; sheet++) { | ||
const stylesheet = document.styleSheets[sheet]; | ||
for (let i = 0; stylesheet.cssRules && i < stylesheet.cssRules.length; i++) { |
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.
might as well do a for..of loop for these two.
I still need to write some tests but I also have a few remarks. This audit doesn't care about people using font loading api or external services like google fonts where a user has no control of. (do we add this in a message somewhere?) |
Third party font foundries currently own own the Given this is the case, I think the messaging here should be focused on self-hosted fonts. Ideally, we'd also say something brief for the case where you don't self-host and are still running into issues. @paulirish what's your take on this? |
If you use a cdn where you self host your font how do we know that it's self-hosted? I believe only way we could is keep a blacklist of domains (googlefonts, typekit, ...) |
Added tests, now just some smoketests are necessary |
c232e5c
to
fa39b01
Compare
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.
looks good after the save-assets-path business and nit! :) thanks for the patience during marathon review @wardpeet 🏃♀️ 🏁
[ | ||
driver.evaluateAsync(`(()=>{` | ||
+ `const args = ${JSON.stringify(fontFaceDescriptors)};` | ||
+ `return (${getAllLoadedFonts.toString()})(args);})()`), |
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.
could stringify outside the eval just to make the call as similar as possible to the other :)
const args = JSON.stringify(...)
return Promise.all([
driver.evaluateAsync(`(${getAllLoadedFonts.toString()})(${args})`),
driver.evaluateAsync(`(${getFontFaceFromStylesheets.toString()})()`),
@@ -265,8 +265,10 @@ const cli = yargs | |||
'config-path': 'The path to the config JSON file', | |||
'expectations-path': 'The path to the expected audit results file', | |||
}) | |||
.array('save-assets-path') |
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'm pretty sure this is already done :) I can't find any other uses on master
const args = JSON.stringify(fontFaceDescriptors); | ||
return Promise.all( | ||
[ | ||
driver.evaluateAsync(`(${getAllLoadedFonts.toString()})(${args})`), |
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 I cooked up a branch that gets fontfaces that are loaded before FCP and do not use font-display swap or optional.
Some issues:
FCP isn't always the correct metric as contenful != meaningful.
The initiator of the font is impossible to find. I can find the stylesheet that holds the font-face but not when it's triggered unless I just take startTime of the networkRecord.
For now I just do end of the network record - navigationstart to calculated wasted time.
I still need to cleanup the code so you shouldn't do a code review
fixes #3107