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

new-audit(font-display): enforce font-display optional #3831

Merged
merged 22 commits into from Feb 7, 2018
Merged

Conversation

wardpeet
Copy link
Collaborator

@wardpeet wardpeet commented Nov 16, 2017

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

@addyosmani
Copy link
Member

Thanks for working on the initial PR for this, @wardpeet!

FCP isn't always the correct metric as contenful != meaningful.

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?

@wardpeet
Copy link
Collaborator Author

@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

<html>
  <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
  <body>
    <script>
      document.fonts.ready.then(() => {
        window.__FONTSREADY = performance.now();
      });
    </script>
    <style>
      @font-face {
        font-family: 'Roboto';
        font-style: normal;
        font-weight: 400;
        src: url(https://fonts.gstatic.com/s/roboto/v18/oMMgfZMQthOryQo9n22dcuvvDin1pK8aKteLpeZ5c0A.woff2) format('woff2');
        unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215;
      }
      span {
        font-family: "Roboto";
      }
    </style>
    <span>Font font font</span>
    <p>no special font</p>
  </body>
  </html>

Copy link
Collaborator

@patrickhulce patrickhulce left a 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! :)


// 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)
Copy link
Collaborator

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


return Promise.all([traceOfTabPromise, networkPromise]).then(([tabTrace, networkRecords]) => {
let totalWasted = 0;
const fcpInMS = tabTrace.timestamps.firstContentfulPaint / 1000000;
Copy link
Collaborator

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

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

sure :)

})
// calculate wasted time
.map(record => {
const wastedTime = (record._endTime * 1000 - tabTrace.timestamps.navigationStart / 1000);
Copy link
Collaborator

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

Copy link
Collaborator Author

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') {
Copy link
Collaborator

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

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 think document.fonts.ready didn't resolve anymore if all fonts were already loaded (could be mistaken)

Copy link
Collaborator

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);
Copy link
Collaborator

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,
  ...
})

Copy link
Collaborator Author

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

Copy link
Collaborator

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

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 follow your reasoning so 👍

}

_onStyleSheetRemoved({ stylesheetId }) {
this.stylesheetIds.splice(this.stylesheetIds.indexOf(stylesheetId), 1);
Copy link
Collaborator

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?

Copy link
Collaborator Author

@wardpeet wardpeet Nov 17, 2017

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

Copy link
Collaborator

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

const fcpInMS = tabTrace.timestamps.firstContentfulPaint / 1000000;
const results = networkRecords.filter(record => {
const isFont = record._resourceType === WebInspector.resourceTypes.Font;
const isLoadedBeforeFCP = record._endTime < fcpInMS || true;
Copy link
Collaborator

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?

Copy link
Collaborator Author

@wardpeet wardpeet Nov 17, 2017

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\\((?:"|\')([^"]+)(?:"|\')\\)');
Copy link
Collaborator

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?

Copy link
Collaborator Author

@wardpeet wardpeet Nov 17, 2017

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

Copy link
Collaborator

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 👍

description: 'uses font-display',
failureDescription: 'Your fonts are blocking FCP!',
helpText: 'You should use font-display!!!!',
requiredArtifacts: ['traces', 'Fonts'],
Copy link
Member

Choose a reason for hiding this comment

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

add devtoolsLogs

return {
name: 'webfonts',
description: 'uses font-display',
failureDescription: 'Your fonts are blocking FCP!',
Copy link
Member

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

static get meta() {
return {
name: 'webfonts',
description: 'uses font-display',
Copy link
Member

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

name: 'webfonts',
description: 'uses font-display',
failureDescription: 'Your fonts are blocking FCP!',
helpText: 'You should use font-display!!!!',
Copy link
Member

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.

const UnusedBytes = require('./byte-efficiency/byte-efficiency-audit');
const allowedFontFaceDisplays = ['optional', 'swap', 'fallback'];

class WebFonts extends Audit {
Copy link
Member

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

return {
score: UnusedBytes.scoreForWastedMs(totalWasted),
rawValue: totalWasted,
displayValue: Util.formatMilliseconds(totalWasted, 1),
Copy link
Member

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.

const details = Audit.makeTableDetails(headings, results);

return {
score: UnusedBytes.scoreForWastedMs(totalWasted),
Copy link
Member

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

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'];
Copy link
Member

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 || [];
Copy link
Member

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' } }

Copy link
Member

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 */
 

Copy link
Member

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.

Copy link
Collaborator Author

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.

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 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++) {
Copy link
Member

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.

@wardpeet
Copy link
Collaborator Author

wardpeet commented Dec 2, 2017

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

@addyosmani
Copy link
Member

Third party font foundries currently own own the @font-face definitions and we need a platform solution for allowing developers to use font-display with third-party served web fonts.

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?

@wardpeet
Copy link
Collaborator Author

wardpeet commented Dec 8, 2017

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, ...)

@wardpeet
Copy link
Collaborator Author

Added tests, now just some smoketests are necessary

@wardpeet wardpeet changed the title WIP: webfonts push out FCP - font-display new-audit(font-display) push out FCP - font-display Dec 13, 2017
@wardpeet wardpeet force-pushed the webfonts branch 5 times, most recently from c232e5c to fa39b01 Compare December 15, 2017 22:46
Copy link
Collaborator

@patrickhulce patrickhulce left a 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);})()`),
Copy link
Collaborator

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')
Copy link
Collaborator

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})`),
Copy link
Collaborator

Choose a reason for hiding this comment

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

❤️

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.

audit: Web Font requests take a long time and push out FCP
7 participants