Skip to content
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
37 changes: 22 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,6 @@ Add the following snippet to `gatsby-config.js` plugins array.
/* 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 Down Expand Up @@ -96,10 +90,15 @@ Add the following snippet to `gatsby-config.js` plugins array.
</thead>
<tbody>
<tr>
</tr>
<td>mode</td>
<td>Can be set to <code>"async"</code> (default) or <code>"render-blocking"</code>. In <code>async</code> mode, fonts are loaded in optimal way, but FOUT is visible. In <code>render-blocking</code> mode FOUT will happen in rare cases, but the font files will become render-blocking.</td>
<td>Can be set to <code>async</code> (default) or <code>render-blocking</code>. In <code>async</code> mode, fonts are loaded in optimal way, but FOUT is visible. In <code>render-blocking</code> mode FOUT will happen in rare cases, but the font files will become render-blocking.</td>
<td>async</td>
</tr>
<tr>
<td>scope</td>
<td>Can be set to <code>body</code> (default) or <code>html</code>. Sets the target element for HTML classnames to be applied to.</td>
<td>body</td>
</tr>
<tr>
<td>enableListener</td>
<td>Works in <code>async</code> mode. Enable font loading listener to handle Flash Of Unstyled Text. If enabled, CSS classes will be applied to HTML once each font has finished loading.</td>
Expand All @@ -117,23 +116,23 @@ Add the following snippet to `gatsby-config.js` plugins array.
</tr>
<tr>
<td>custom</td>
<td>Self-hosted fonts config. Add font files and font CSS files to "static" folder. Array of <code>{name: "Font name", file: "https://url-to-font-css.path"}</code> objects.</td>
<td>[]</td>
<td>Self-hosted fonts config. Add font files and font CSS files to <code>static</code> folder. Array of <code>{name: "Font name", file: "https://url-to-font-css.path"}</code> objects.</td>
<td><code>[]</code></td>
</tr>
<tr>
<td>web</td>
<td>Web fonts config. File link should point to font CSS file. Array of <code>{name: "Font name", file: "https://url-to-font-css.path"}</code> objects.</td>
<td>[]</td>
<td><code>[]</code></td>
</tr>
<tr>
<td>preconnect</td>
<td>URLs used for preconnect meta. Base URL where <strong>font files</strong> are hosted.</td>
<td>[]</td>
<td><code>[]</code></td>
</tr>
<tr>
<td>preload</td>
<td>Additional URLs used for preload meta. Preload for URLs provided under `file` attribute of `custom` and `web` fonts are automatically generated.</td>
<td>[]</td>
<td>Additional URLs used for preload meta. Preload for URLs provided under <code>file</code> attribute of <code>custom</code> and <code>web</code> fonts are automatically generated.</td>
<td><code>[]</code></td>
</tr>
<tbody>
</table>
Expand Down Expand Up @@ -181,7 +180,15 @@ Feel free to [report issues](https://github.com/codeAdrian/gatsby-omni-font-load

Contributions are welcome and appreciated!

## Thank you for the support
## Code contributors

Thank you for your contribution!

[Henrik](https://github.com/henrikdahl) • [Lennart](https://github.com/LekoArts) • [Francis Champagne](https://github.com/fcisio)

## Sponsors

Thank you for your support!

[Roboto Studio](https://roboto.studio/) • [Your Name Here](https://www.buymeacoffee.com/ubnZ8GgDJ/e/11337)

Expand Down
106 changes: 6 additions & 100 deletions components/FontListener.tsx
Original file line number Diff line number Diff line change
@@ -1,106 +1,12 @@
import React, { useEffect, useMemo, useRef, useState } from "react"
import { Helmet } from "react-helmet"
import { kebabCase } from "../utils"

declare var document: { fonts: any }
import React from "react"
import { hookOptions, useFontListener } from "../hooks"

interface Props {
fontNames: string[]
interval: number
timeout: number
options: hookOptions
}

export const FontListener: React.FC<Props> = ({
fontNames,
interval,
timeout,
}) => {
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) {
handleApiError("Font loading API not available")
return
}

if (apiAvailable && !hasLoaded && intervalId < 0) {
const id = window.setInterval(isFontLoaded, interval)
setIntervalId(id)
}
}, [hasLoaded, intervalId, apiAvailable])

useEffect(() => {
if (hasLoaded && intervalId > 0) {
clearInterval(intervalId)
}
}, [hasLoaded, intervalId])

return (
<Helmet>
<body className={loadedClassname} />
</Helmet>
)

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 = pendingFonts.map(fontName => {
let hasLoaded = false
try {
hasLoaded = document.fonts.check(`12px '${fontName}'`)
} catch (error) {
handleApiError(error)
return
}

if (hasLoaded) loaded.push(fontName)
return hasLoaded
})

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

if (Boolean(loaded.length)) {
setLoadedFonts(loaded)
}
export const FontListener: React.FC<Props> = ({ children, options }) => {
useFontListener(options)

if (allFontsLoaded) {
setHasLoaded(true)
}
}
return <>{children}</>
}
2 changes: 2 additions & 0 deletions consts/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@ export const INTERVAL_DEFAULT = 300
export const TIMEOUT_DEFAULT = 30000

export const MODE_DEFAULT = "async"

export const SCOPE_DEFAULT = "body"
19 changes: 15 additions & 4 deletions gatsby-browser.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import React from "react"
import { AsyncFonts, FontListener } from "./components"
import { INTERVAL_DEFAULT, MODE_DEFAULT, TIMEOUT_DEFAULT } from "./consts"
import {
INTERVAL_DEFAULT,
MODE_DEFAULT,
TIMEOUT_DEFAULT,
SCOPE_DEFAULT,
} from "./consts"
import { getFontFiles, getFontNames } from "./utils"

export const wrapRootElement = (
Expand All @@ -11,6 +16,7 @@ export const wrapRootElement = (
enableListener,
interval = INTERVAL_DEFAULT,
timeout = TIMEOUT_DEFAULT,
scope = SCOPE_DEFAULT,
mode = MODE_DEFAULT,
}
) => {
Expand All @@ -22,16 +28,21 @@ export const wrapRootElement = (
const fontFiles = getFontFiles(allFonts)
const fontNames = getFontNames(allFonts)

const listenerProps = { fontNames, interval, timeout }
const listenerProps = { fontNames, interval, timeout, scope }

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

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

if (!hasFontFiles || !enableListener) {
return children
}

return <FontListener options={listenerProps}>{children}</FontListener>
}
1 change: 1 addition & 0 deletions hooks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./useFontListener"
108 changes: 108 additions & 0 deletions hooks/useFontListener.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { useEffect, useMemo, useRef, useState } from "react"
import { kebabCase } from "../utils"

declare var document: { fonts: any }

export type hookOptions = {
fontNames: string[]
interval: number
timeout: number
scope: string
}

type fontListenerHook = (options: hookOptions) => void

export const useFontListener: fontListenerHook = ({
fontNames,
interval,
timeout,
scope,
}) => {
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 hasFonts = fontNames && Boolean(fontNames.length)

const pendingFonts = useMemo(
() => fontNames.filter(fontName => !loadedFonts.includes(fontName)),
[loadedFonts, fontNames]
)
const targetElement = useMemo(
() => (scope === "html" ? "documentElement" : "body"),
[scope]
)

const apiAvailable = "fonts" in document

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

if (hasFonts && apiAvailable && !hasLoaded && intervalId < 0) {
const id = window.setInterval(isFontLoaded, interval)
setIntervalId(id)
}
}, [hasFonts, hasLoaded, intervalId, apiAvailable])

useEffect(() => {
if (hasLoaded && intervalId > 0) {
clearInterval(intervalId)
}
}, [hasLoaded, intervalId])

function errorFallback() {
setHasLoaded(true)
setLoadedFonts(fontNames)
fontNames.forEach(addClassName)
}

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

function addClassName(fontName) {
document[targetElement].classList.add(`wf-${kebabCase(fontName)}--loaded`)
}

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

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

const fontsLoading = pendingFonts.map(fontName => {
let hasLoaded = false
try {
hasLoaded = document.fonts.check(`12px '${fontName}'`)
} catch (error) {
handleApiError(error)
return
}

if (hasLoaded) {
addClassName(fontName)
loaded.push(fontName)
}

return hasLoaded
})

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

if (Boolean(loaded.length)) {
setLoadedFonts(loaded)
}

if (allFontsLoaded) {
setHasLoaded(true)
}
}
}