new-audit(font-display): enforce font-display optional#3831
new-audit(font-display): enforce font-display optional#3831patrickhulce merged 22 commits intomasterfrom
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 |
patrickhulce
left a comment
There was a problem hiding this comment.
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.
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.
can we compare everything in milliseconds instead?
you'll need to multiply the record endtimes by 1000 but keeps things easier than switching :)
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.
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.
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.
is this if necessary? can we just always use document.fonts.ready
also TIL about document.fonts :)
There was a problem hiding this comment.
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.
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.
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.
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.
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.
I follow your reasoning so 👍
| } | ||
|
|
||
| _onStyleSheetRemoved({ stylesheetId }) { | ||
| this.stylesheetIds.splice(this.stylesheetIds.indexOf(stylesheetId), 1); |
There was a problem hiding this comment.
the stylesheetIds look unused at this point, do we still need them?
There was a problem hiding this comment.
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.
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.
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.
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.
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.
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.
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'], |
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
separately i think you'll need some error handling for the case that we have a mismatch.
There was a problem hiding this comment.
nice catch, let me cleanup this audit a bit :) it was just a WIP.
There was a problem hiding this comment.
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.
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
patrickhulce
left a comment
There was a problem hiding this comment.
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.
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()})()`),| '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.
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})`), |
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