Skip to content

Commit

Permalink
feat(gatsby): loading indicator for query-on-demand (#28562)
Browse files Browse the repository at this point in the history
* initial setup to show loading indicator when loading page resources takes over a second

this will show both for client side navigation and first render

* style loading indicator

* add gatsby specifc debugLog function for runtime to show 'gatsby' prefixed debug logs

* allow to enable/disable loading indicator via /___loading-indicator/disable get request

* print console message on first render of indicator how to disable it

* fix non query on demand bundling, don't even add loading components if loading indicator is not enabled, disable by default in cypress env

* fix /___loading-indicator/disable check

* add instructions about disabling via flag config

* announce loading indicator + add media query for reduced motion and dark theme

* don't announce out

* cleanup DOM after first-render loading indicator

* drop config flag support - this need more thinking because it would set precedent that would add configuration options to flags and we might regret it later

* re-word browser console message, thanks @pragmaticpat

Co-authored-by: LekoArts <[email protected]>
  • Loading branch information
pieh and LekoArts authored Dec 10, 2020
1 parent 498fbbf commit 1b97f5f
Show file tree
Hide file tree
Showing 11 changed files with 385 additions and 0 deletions.
29 changes: 29 additions & 0 deletions packages/gatsby/cache-dir/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import socketIo from "./socketIo"
import emitter from "./emitter"
import { apiRunner, apiRunnerAsync } from "./api-runner-browser"
import { setLoader, publicLoader } from "./loader"
import { Indicator } from "./loading-indicator/indicator"
import DevLoader from "./dev-loader"
import syncRequires from "$virtual/sync-requires"
// Generated during bootstrap
Expand Down Expand Up @@ -123,6 +124,30 @@ apiRunnerAsync(`onClientEntry`).then(() => {
ReactDOM.render
)[0]

let dismissLoadingIndicator
if (
process.env.GATSBY_EXPERIMENTAL_QUERY_ON_DEMAND &&
process.env.GATSBY_QUERY_ON_DEMAND_LOADING_INDICATOR === `true`
) {
let indicatorMountElement

const showIndicatorTimeout = setTimeout(() => {
indicatorMountElement = document.createElement(
`first-render-loading-indicator`
)
document.body.append(indicatorMountElement)
ReactDOM.render(<Indicator />, indicatorMountElement)
}, 1000)

dismissLoadingIndicator = () => {
clearTimeout(showIndicatorTimeout)
if (indicatorMountElement) {
ReactDOM.unmountComponentAtNode(indicatorMountElement)
indicatorMountElement.remove()
}
}
}

Promise.all([
loader.loadPage(`/dev-404-page/`),
loader.loadPage(`/404.html`),
Expand All @@ -131,6 +156,10 @@ apiRunnerAsync(`onClientEntry`).then(() => {
const preferDefault = m => (m && m.default) || m
const Root = preferDefault(require(`./root`))
domReady(() => {
if (dismissLoadingIndicator) {
dismissLoadingIndicator()
}

renderer(<Root />, rootElement, () => {
apiRunner(`onInitialClientRender`)
})
Expand Down
13 changes: 13 additions & 0 deletions packages/gatsby/cache-dir/debug-log.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// inspired by https://github.com/GoogleChrome/workbox/blob/3d02230f0e977eb1dc86c48f16ea4bcefdae12af/packages/workbox-core/src/_private/logger.ts

const styles = [
`background: rebeccapurple`,
`border-radius: 0.5em`,
`color: white`,
`font-weight: bold`,
`padding: 2px 0.5em`,
].join(`;`)

export function debugLog(...args) {
console.debug(`%cgatsby`, styles, ...args)
}
31 changes: 31 additions & 0 deletions packages/gatsby/cache-dir/loading-indicator/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import React from "react"

import emitter from "../emitter"
import { Indicator } from "./indicator"

// no hooks because we support react versions without hooks support
export class LoadingIndicatorEventHandler extends React.Component {
state = { visible: false }

show = () => {
this.setState({ visible: true })
}

hide = () => {
this.setState({ visible: false })
}

componentDidMount() {
emitter.on(`onDelayedLoadPageResources`, this.show)
emitter.on(`onRouteUpdate`, this.hide)
}

componentWillUnmount() {
emitter.off(`onDelayedLoadPageResources`, this.show)
emitter.off(`onRouteUpdate`, this.hide)
}

render() {
return <Indicator visible={this.state.visible} />
}
}
64 changes: 64 additions & 0 deletions packages/gatsby/cache-dir/loading-indicator/indicator.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import React from "react"
import Portal from "./portal"
import Style from "./style"
import { isLoadingIndicatorEnabled } from "$virtual/loading-indicator"
import { debugLog } from "../debug-log"

if (typeof window === `undefined`) {
throw new Error(
`Loading indicator should never be imported in code that doesn't target only browsers`
)
}

if (module.hot) {
module.hot.accept(`$virtual/loading-indicator`, () => {
// isLoadingIndicatorEnabled is imported with ES import so no need
// for dedicated handling as HMR just replace it in that case
})
}

// HMR can rerun this, so check if it was set before
// we also set it on window and not just in module scope because of HMR resetting
// module scope
if (typeof window.___gatsbyDidShowLoadingIndicatorBefore === `undefined`) {
window.___gatsbyDidShowLoadingIndicatorBefore = false
}

export function Indicator({ visible = true }) {
if (!isLoadingIndicatorEnabled()) {
return null
}

if (!window.___gatsbyDidShowLoadingIndicatorBefore) {
// not ideal to this in render function, but that's just console info
debugLog(
`A loading indicator is displayed in-browser whenever content is being requested upon navigation (Query On Demand).\n\nYou can disable the loading indicator for your current session by visiting ${window.location.origin}/___loading-indicator/disable`
)
window.___gatsbyDidShowLoadingIndicatorBefore = true
}

return (
<Portal>
<Style />
<div
data-gatsby-loading-indicator="root"
data-gatsby-loading-indicator-visible={visible}
aria-live="assertive"
>
<div data-gatsby-loading-indicator="spinner" aria-hidden="true">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M0 0h24v24H0z" fill="none" />
<path d="M12 6v3l4-4-4-4v3c-4.42 0-8 3.58-8 8 0 1.57.46 3.03 1.24 4.26L6.7 14.8c-.45-.83-.7-1.79-.7-2.8 0-3.31 2.69-6 6-6zm6.76 1.74L17.3 9.2c.44.84.7 1.79.7 2.8 0 3.31-2.69 6-6 6v-3l-4 4 4 4v-3c4.42 0 8-3.58 8-8 0-1.57-.46-3.03-1.24-4.26z" />
</svg>
</div>
<div data-gatsby-loading-indicator="text">
{visible ? `Preparing requested page` : ``}
</div>
</div>
</Portal>
)
}
43 changes: 43 additions & 0 deletions packages/gatsby/cache-dir/loading-indicator/portal.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import * as React from "react"
import { createPortal } from "react-dom"

// this is `fast-refresh-overlay/portal` ported to class component
// because we don't have guarantee that query on demand users will use
// react version that supports hooks
// TO-DO: consolidate both portals into single shared component (need testing)
class ShadowPortal extends React.Component {
mountNode = React.createRef(null)
portalNode = React.createRef(null)
shadowNode = React.createRef(null)
state = {
createdElement: false,
}

componentDidMount() {
const ownerDocument = this.mountNode.current.ownerDocument
this.portalNode.current = ownerDocument.createElement(`gatsby-portal`)
this.shadowNode.current = this.portalNode.current.attachShadow({
mode: `open`,
})
ownerDocument.body.appendChild(this.portalNode.current)
this.setState({ createdElement: true })
}

componentWillUnmount() {
if (this.portalNode.current && this.portalNode.current.ownerDocument) {
this.portalNode.current.ownerDocument.body.removeChild(
this.portalNode.current
)
}
}

render() {
return this.shadowNode.current ? (
createPortal(this.props.children, this.shadowNode.current)
) : (
<span ref={this.mountNode} />
)
}
}

export default ShadowPortal
115 changes: 115 additions & 0 deletions packages/gatsby/cache-dir/loading-indicator/style.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import React from "react"

function css(strings, ...keys) {
const lastIndex = strings.length - 1
return (
strings.slice(0, lastIndex).reduce((p, s, i) => p + s + keys[i], ``) +
strings[lastIndex]
)
}

const Style = () => (
<style
dangerouslySetInnerHTML={{
__html: css`
:host {
--purple-60: #663399;
--gatsby: var(--purple-60);
--purple-40: #b17acc;
--purple-20: #f1defa;
--dimmedWhite: rgba(255, 255, 255, 0.8);
--white: #ffffff;
--black: #000000;
--grey-90: #232129;
--radii: 4px;
--z-index-normal: 5;
--z-index-elevated: 10;
--shadow: 0px 2px 4px rgba(46, 41, 51, 0.08),
0px 4px 8px rgba(71, 63, 79, 0.16);
}
[data-gatsby-loading-indicator="root"] {
font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
"Segoe UI Symbol" !important;
background: var(--white);
color: var(--grey-90);
position: fixed;
bottom: 1.5em;
left: 1.5em;
box-shadow: var(--shadow);
border-radius: var(--radii);
z-index: var(--z-index-elevated);
border-left: 0.25em solid var(--purple-40);
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: nowrap;
padding: 0.75em 1.15em;
min-width: 196px;
}
[data-gatsby-loading-indicator-visible="false"] {
opacity: 0;
visibility: hidden;
will-change: opacity, transform;
transform: translateY(45px);
transition: all 0.3s ease-in-out;
}
[data-gatsby-loading-indicator-visible="true"] {
opacity: 1;
visibility: visible;
transform: translateY(0px);
transition: all 0.3s ease-in-out;
}
[data-gatsby-loading-indicator="spinner"] {
animation: spin 1s linear infinite;
height: 18px;
width: 18px;
color: var(--gatsby);
}
[data-gatsby-loading-indicator="text"] {
margin-left: 0.75em;
line-height: 18px;
}
@keyframes spin {
0% {
transform: rotate(0);
}
100% {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
[data-gatsby-loading-indicator="spinner"] {
animation: none;
}
[data-gatsby-loading-indicator-visible="false"] {
transition: none;
}
[data-gatsby-loading-indicator-visible="true"] {
transition: none;
}
}
@media (prefers-color-scheme: dark) {
[data-gatsby-loading-indicator="root"] {
background: var(--grey-90);
color: var(--white);
}
[data-gatsby-loading-indicator="spinner"] {
color: var(--purple-20);
}
}
`,
}}
/>
)

export default Style
6 changes: 6 additions & 0 deletions packages/gatsby/cache-dir/navigation.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ const onPreRouteUpdate = (location, prevLocation) => {
const onRouteUpdate = (location, prevLocation) => {
if (!maybeRedirect(location.pathname)) {
apiRunner(`onRouteUpdate`, { location, prevLocation })
if (
process.env.GATSBY_EXPERIMENTAL_QUERY_ON_DEMAND &&
process.env.GATSBY_QUERY_ON_DEMAND_LOADING_INDICATOR === `true`
) {
emitter.emit(`onRouteUpdate`, { location, prevLocation })
}
}
}

Expand Down
5 changes: 5 additions & 0 deletions packages/gatsby/cache-dir/root.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import EnsureResources from "./ensure-resources"
import FastRefreshOverlay from "./fast-refresh-overlay"

import { reportError, clearError } from "./error-overlay-handler"
import { LoadingIndicatorEventHandler } from "./loading-indicator"

// TODO: Remove entire block when we make fast-refresh the default
// In fast-refresh, this logic is all moved into the `error-overlay-handler`
Expand Down Expand Up @@ -147,5 +148,9 @@ const ConditionalFastRefreshOverlay = ({ children }) => {
export default () => (
<ConditionalFastRefreshOverlay>
<StaticQueryStore>{WrappedRoot}</StaticQueryStore>
{process.env.GATSBY_EXPERIMENTAL_QUERY_ON_DEMAND &&
process.env.GATSBY_QUERY_ON_DEMAND_LOADING_INDICATOR === `true` && (
<LoadingIndicatorEventHandler />
)}
</ConditionalFastRefreshOverlay>
)
12 changes: 12 additions & 0 deletions packages/gatsby/src/services/initialize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,18 @@ export async function initialize({

process.env.GATSBY_HOT_LOADER = getReactHotLoaderStrategy()

// TODO: figure out proper way of disabling loading indicator
// for now GATSBY_QUERY_ON_DEMAND_LOADING_INDICATOR=false gatsby develop
// will work, but we don't want to force users into using env vars
if (
process.env.GATSBY_EXPERIMENTAL_QUERY_ON_DEMAND &&
!process.env.GATSBY_QUERY_ON_DEMAND_LOADING_INDICATOR
) {
// if query on demand is enabled and GATSBY_QUERY_ON_DEMAND_LOADING_INDICATOR was not set at all
// enable loading indicator
process.env.GATSBY_QUERY_ON_DEMAND_LOADING_INDICATOR = `true`
}

// theme gatsby configs can be functions or objects
if (config && config.__experimentalThemes) {
reporter.warn(
Expand Down
Loading

0 comments on commit 1b97f5f

Please sign in to comment.