Skip to content

Code improvement, added timeout & interval options, docs update #3

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

Merged
merged 1 commit into from
Nov 11, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Performant asynchronous font loading plugin for Gatsby.
* Preloads the files & preconnects to the URL
* Loads fonts asynchronously to avoid render blocking
* Implemented with [fast loading snippets](https://csswizardry.com/2020/05/the-fastest-google-fonts/)
* Loading status listener for avoiding FOUT & FOUC
* Loading status listener for avoiding FOUT
* Small size & minimal footprint

## Install
Expand All @@ -29,12 +29,18 @@ Add the following snippet to `gatsby-config.js` plugins array.
/* Plugin options */
options: {

/* Enable font loading listener to handle FOUC */
/* Enable font loading listener to handle FOUT */
enableListener: true,

/* Preconnect URL-s. This example is for Google Fonts */
preconnect: ["https://fonts.gstatic.com"],

/* Font listener interval (in ms). Default is 300ms. Recommended: >=300ms */
interval: 300,

/* Font listener timeout value (in ms). Default is 30s (30000ms). Listener will no longer check for loaded fonts after timeout, fonts will still be loaded and displayed, but without handling FOUT. */
timeout: 30000,

/* Self-hosted fonts config. Add font files and font CSS files to "static" folder */
custom: [
{
Expand All @@ -57,9 +63,9 @@ Add the following snippet to `gatsby-config.js` plugins array.
}
```

## Handling FOUC with Font loading listener
## Handling FOUT with Font loading listener

When loading fonts asynchronously, Flash Of Unstyled Content (FOUC) might happen because fonts load few moments later after page is displayed to the user.
When loading fonts asynchronously, Flash Of Unstyled Text (FOUT) might happen because fonts load few moments later after page is displayed to the user.

To avoid this, we can use CSS to style the fallback font to closely match the font size, line height and letter spacing of the main font that is being loaded.

Expand All @@ -75,7 +81,7 @@ Here is the example of how `body` element will look like after all fonts are bei
<body class="wf-lazy-monday--loaded wf-font-awesome-5-brands--loaded wf-font-awesome-5-free--loaded wf-staatliches--loaded wf-henny-penny--loaded">
```

<img alt="FOUC example" src="https://res.cloudinary.com/dazdt97d3/image/upload/v1604140006/fouc.gif">
<img alt="FOUT example" src="https://res.cloudinary.com/dazdt97d3/image/upload/v1604140006/fouc.gif">


## Issues and Contributions
Expand Down
66 changes: 49 additions & 17 deletions components/FontListener.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,41 @@
import React, { useEffect, useState } from "react"
import React, { useEffect, useMemo, useRef, useState } from "react"
import { Helmet } from "react-helmet"

declare var document: { fonts: any }

export const FontListener: React.FC<{ fontNames: string[] }> = ({
interface Props {
fontNames: string[]
interval?: number
timeout?: number
}

export const FontListener: React.FC<Props> = ({
fontNames,
interval = 300,
timeout = 30000,
}) => {
const [hasLoaded, setHasLoaded] = useState<Boolean>(false)
const [loadedFonts, setLoadedFonts] = useState<string[]>([])
const [intervalId, setIntervalId] = useState<number>(-1)
const attempts = useRef<number>(Math.floor(timeout / interval))

const pendingFonts = useMemo(
() => fontNames.filter(fontName => !loadedFonts.includes(fontName)),
[loadedFonts, fontNames]
)

const loadedClassname = useMemo(getLoadedFontClassNames, [loadedFonts])

const apiAvailable = "fonts" in document

useEffect(() => {
if (!apiAvailable) {
setHasLoaded(true)
setLoadedFonts(fontNames)
handleApiError("Font loading API not available")
return
}

if (!hasLoaded && intervalId < 0) {
const id = window.setInterval(isFontLoaded, 100)
if (apiAvailable && !hasLoaded && intervalId < 0) {
const id = window.setInterval(isFontLoaded, interval)
setIntervalId(id)
}
}, [hasLoaded, intervalId, apiAvailable])
Expand All @@ -31,13 +46,9 @@ export const FontListener: React.FC<{ fontNames: string[] }> = ({
}
}, [hasLoaded, intervalId])

const loadedClassname = Boolean(loadedFonts.length)
? loadedFonts.map(fontName => `wf-${kebabCase(fontName)}--loaded`).join(" ")
: ""

return (
<Helmet>
{Boolean(loadedFonts.length) && <body className={loadedClassname} />}
<body className={loadedClassname} />
</Helmet>
)

Expand All @@ -49,18 +60,39 @@ export const FontListener: React.FC<{ fontNames: string[] }> = ({
.join("-")
}

function getLoadedFontClassNames() {
return Boolean(loadedFonts.length)
? loadedFonts
.map(fontName => `wf-${kebabCase(fontName)}--loaded`)
.join(" ")
: ""
}

function errorFallback() {
setHasLoaded(true)
setLoadedFonts(fontNames)
}

function handleApiError(error) {
console.info(`document.fonts API error: ${error}`)
console.info(`Replacing fonts instantly. FOUT handling failed due.`)
errorFallback()
}

function isFontLoaded() {
const loaded = []
attempts.current = attempts.current - 1

if (attempts.current < 0) {
handleApiError("Interval timeout reached, maybe due to slow connection.")
}

const fontsLoading = fontNames.map(fontName => {
const fontsLoading = pendingFonts.map(fontName => {
let hasLoaded = false
try {
hasLoaded = document.fonts.check(`12px '${fontName}'`)
} catch (error) {
console.info(`document.fonts API error: ${error}`)
console.info(`Replacing fonts instantly. FOUT handling failed.`)
setHasLoaded(true)
setLoadedFonts(fontNames)
handleApiError(error)
return
}

Expand All @@ -70,7 +102,7 @@ export const FontListener: React.FC<{ fontNames: string[] }> = ({

const allFontsLoaded = fontsLoading.every(font => font)

if (loadedFonts.length !== loaded.length) {
if (Boolean(loaded.length)) {
setLoadedFonts(loaded)
}

Expand Down
6 changes: 4 additions & 2 deletions gatsby-browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,21 @@ import { getFontFiles, getFontNames } from "./utils"

export const wrapRootElement = (
{ element },
{ custom = [], web = [], enableListener }
{ custom = [], web = [], enableListener, interval, timeout }
) => {
const allFonts = [...custom, ...web]
const fontFiles = getFontFiles(allFonts)
const fontNames = getFontNames(allFonts)

const listenerProps = { fontNames, interval, timeout }

const hasFontFiles = Boolean(fontFiles.length)
const hasFontNames = Boolean(fontNames.length)

return (
<>
{hasFontNames && <AsyncFonts hrefs={fontFiles} />}
{enableListener && hasFontFiles && <FontListener fontNames={fontNames} />}
{enableListener && hasFontFiles && <FontListener {...listenerProps} />}
{element}
</>
)
Expand Down