diff --git a/README.md b/README.md index b4c0f33..7ba584a 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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: [ { @@ -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. @@ -75,7 +81,7 @@ Here is the example of how `body` element will look like after all fonts are bei ``` -FOUC example +FOUT example ## Issues and Contributions diff --git a/components/FontListener.tsx b/components/FontListener.tsx index afd2f1b..3d68eac 100644 --- a/components/FontListener.tsx +++ b/components/FontListener.tsx @@ -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 = ({ fontNames, + interval = 300, + timeout = 30000, }) => { const [hasLoaded, setHasLoaded] = useState(false) const [loadedFonts, setLoadedFonts] = useState([]) const [intervalId, setIntervalId] = useState(-1) + const attempts = useRef(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]) @@ -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 ( - {Boolean(loadedFonts.length) && } + ) @@ -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 } @@ -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) } diff --git a/gatsby-browser.js b/gatsby-browser.js index 7c9d70e..372b3ce 100644 --- a/gatsby-browser.js +++ b/gatsby-browser.js @@ -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 && } - {enableListener && hasFontFiles && } + {enableListener && hasFontFiles && } {element} )