Skip to content

Commit 1b97f5f

Browse files
piehLekoArts
andauthored
feat(gatsby): loading indicator for query-on-demand (#28562)
* 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]>
1 parent 498fbbf commit 1b97f5f

File tree

11 files changed

+385
-0
lines changed

11 files changed

+385
-0
lines changed

packages/gatsby/cache-dir/app.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import socketIo from "./socketIo"
77
import emitter from "./emitter"
88
import { apiRunner, apiRunnerAsync } from "./api-runner-browser"
99
import { setLoader, publicLoader } from "./loader"
10+
import { Indicator } from "./loading-indicator/indicator"
1011
import DevLoader from "./dev-loader"
1112
import syncRequires from "$virtual/sync-requires"
1213
// Generated during bootstrap
@@ -123,6 +124,30 @@ apiRunnerAsync(`onClientEntry`).then(() => {
123124
ReactDOM.render
124125
)[0]
125126

127+
let dismissLoadingIndicator
128+
if (
129+
process.env.GATSBY_EXPERIMENTAL_QUERY_ON_DEMAND &&
130+
process.env.GATSBY_QUERY_ON_DEMAND_LOADING_INDICATOR === `true`
131+
) {
132+
let indicatorMountElement
133+
134+
const showIndicatorTimeout = setTimeout(() => {
135+
indicatorMountElement = document.createElement(
136+
`first-render-loading-indicator`
137+
)
138+
document.body.append(indicatorMountElement)
139+
ReactDOM.render(<Indicator />, indicatorMountElement)
140+
}, 1000)
141+
142+
dismissLoadingIndicator = () => {
143+
clearTimeout(showIndicatorTimeout)
144+
if (indicatorMountElement) {
145+
ReactDOM.unmountComponentAtNode(indicatorMountElement)
146+
indicatorMountElement.remove()
147+
}
148+
}
149+
}
150+
126151
Promise.all([
127152
loader.loadPage(`/dev-404-page/`),
128153
loader.loadPage(`/404.html`),
@@ -131,6 +156,10 @@ apiRunnerAsync(`onClientEntry`).then(() => {
131156
const preferDefault = m => (m && m.default) || m
132157
const Root = preferDefault(require(`./root`))
133158
domReady(() => {
159+
if (dismissLoadingIndicator) {
160+
dismissLoadingIndicator()
161+
}
162+
134163
renderer(<Root />, rootElement, () => {
135164
apiRunner(`onInitialClientRender`)
136165
})
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// inspired by https://github.com/GoogleChrome/workbox/blob/3d02230f0e977eb1dc86c48f16ea4bcefdae12af/packages/workbox-core/src/_private/logger.ts
2+
3+
const styles = [
4+
`background: rebeccapurple`,
5+
`border-radius: 0.5em`,
6+
`color: white`,
7+
`font-weight: bold`,
8+
`padding: 2px 0.5em`,
9+
].join(`;`)
10+
11+
export function debugLog(...args) {
12+
console.debug(`%cgatsby`, styles, ...args)
13+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import React from "react"
2+
3+
import emitter from "../emitter"
4+
import { Indicator } from "./indicator"
5+
6+
// no hooks because we support react versions without hooks support
7+
export class LoadingIndicatorEventHandler extends React.Component {
8+
state = { visible: false }
9+
10+
show = () => {
11+
this.setState({ visible: true })
12+
}
13+
14+
hide = () => {
15+
this.setState({ visible: false })
16+
}
17+
18+
componentDidMount() {
19+
emitter.on(`onDelayedLoadPageResources`, this.show)
20+
emitter.on(`onRouteUpdate`, this.hide)
21+
}
22+
23+
componentWillUnmount() {
24+
emitter.off(`onDelayedLoadPageResources`, this.show)
25+
emitter.off(`onRouteUpdate`, this.hide)
26+
}
27+
28+
render() {
29+
return <Indicator visible={this.state.visible} />
30+
}
31+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import React from "react"
2+
import Portal from "./portal"
3+
import Style from "./style"
4+
import { isLoadingIndicatorEnabled } from "$virtual/loading-indicator"
5+
import { debugLog } from "../debug-log"
6+
7+
if (typeof window === `undefined`) {
8+
throw new Error(
9+
`Loading indicator should never be imported in code that doesn't target only browsers`
10+
)
11+
}
12+
13+
if (module.hot) {
14+
module.hot.accept(`$virtual/loading-indicator`, () => {
15+
// isLoadingIndicatorEnabled is imported with ES import so no need
16+
// for dedicated handling as HMR just replace it in that case
17+
})
18+
}
19+
20+
// HMR can rerun this, so check if it was set before
21+
// we also set it on window and not just in module scope because of HMR resetting
22+
// module scope
23+
if (typeof window.___gatsbyDidShowLoadingIndicatorBefore === `undefined`) {
24+
window.___gatsbyDidShowLoadingIndicatorBefore = false
25+
}
26+
27+
export function Indicator({ visible = true }) {
28+
if (!isLoadingIndicatorEnabled()) {
29+
return null
30+
}
31+
32+
if (!window.___gatsbyDidShowLoadingIndicatorBefore) {
33+
// not ideal to this in render function, but that's just console info
34+
debugLog(
35+
`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`
36+
)
37+
window.___gatsbyDidShowLoadingIndicatorBefore = true
38+
}
39+
40+
return (
41+
<Portal>
42+
<Style />
43+
<div
44+
data-gatsby-loading-indicator="root"
45+
data-gatsby-loading-indicator-visible={visible}
46+
aria-live="assertive"
47+
>
48+
<div data-gatsby-loading-indicator="spinner" aria-hidden="true">
49+
<svg
50+
xmlns="http://www.w3.org/2000/svg"
51+
viewBox="0 0 24 24"
52+
fill="currentColor"
53+
>
54+
<path d="M0 0h24v24H0z" fill="none" />
55+
<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" />
56+
</svg>
57+
</div>
58+
<div data-gatsby-loading-indicator="text">
59+
{visible ? `Preparing requested page` : ``}
60+
</div>
61+
</div>
62+
</Portal>
63+
)
64+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import * as React from "react"
2+
import { createPortal } from "react-dom"
3+
4+
// this is `fast-refresh-overlay/portal` ported to class component
5+
// because we don't have guarantee that query on demand users will use
6+
// react version that supports hooks
7+
// TO-DO: consolidate both portals into single shared component (need testing)
8+
class ShadowPortal extends React.Component {
9+
mountNode = React.createRef(null)
10+
portalNode = React.createRef(null)
11+
shadowNode = React.createRef(null)
12+
state = {
13+
createdElement: false,
14+
}
15+
16+
componentDidMount() {
17+
const ownerDocument = this.mountNode.current.ownerDocument
18+
this.portalNode.current = ownerDocument.createElement(`gatsby-portal`)
19+
this.shadowNode.current = this.portalNode.current.attachShadow({
20+
mode: `open`,
21+
})
22+
ownerDocument.body.appendChild(this.portalNode.current)
23+
this.setState({ createdElement: true })
24+
}
25+
26+
componentWillUnmount() {
27+
if (this.portalNode.current && this.portalNode.current.ownerDocument) {
28+
this.portalNode.current.ownerDocument.body.removeChild(
29+
this.portalNode.current
30+
)
31+
}
32+
}
33+
34+
render() {
35+
return this.shadowNode.current ? (
36+
createPortal(this.props.children, this.shadowNode.current)
37+
) : (
38+
<span ref={this.mountNode} />
39+
)
40+
}
41+
}
42+
43+
export default ShadowPortal
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import React from "react"
2+
3+
function css(strings, ...keys) {
4+
const lastIndex = strings.length - 1
5+
return (
6+
strings.slice(0, lastIndex).reduce((p, s, i) => p + s + keys[i], ``) +
7+
strings[lastIndex]
8+
)
9+
}
10+
11+
const Style = () => (
12+
<style
13+
dangerouslySetInnerHTML={{
14+
__html: css`
15+
:host {
16+
--purple-60: #663399;
17+
--gatsby: var(--purple-60);
18+
--purple-40: #b17acc;
19+
--purple-20: #f1defa;
20+
--dimmedWhite: rgba(255, 255, 255, 0.8);
21+
--white: #ffffff;
22+
--black: #000000;
23+
--grey-90: #232129;
24+
--radii: 4px;
25+
--z-index-normal: 5;
26+
--z-index-elevated: 10;
27+
--shadow: 0px 2px 4px rgba(46, 41, 51, 0.08),
28+
0px 4px 8px rgba(71, 63, 79, 0.16);
29+
}
30+
31+
[data-gatsby-loading-indicator="root"] {
32+
font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
33+
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
34+
"Segoe UI Symbol" !important;
35+
background: var(--white);
36+
color: var(--grey-90);
37+
position: fixed;
38+
bottom: 1.5em;
39+
left: 1.5em;
40+
box-shadow: var(--shadow);
41+
border-radius: var(--radii);
42+
z-index: var(--z-index-elevated);
43+
border-left: 0.25em solid var(--purple-40);
44+
display: flex;
45+
align-items: center;
46+
justify-content: space-between;
47+
flex-wrap: nowrap;
48+
padding: 0.75em 1.15em;
49+
min-width: 196px;
50+
}
51+
52+
[data-gatsby-loading-indicator-visible="false"] {
53+
opacity: 0;
54+
visibility: hidden;
55+
will-change: opacity, transform;
56+
transform: translateY(45px);
57+
transition: all 0.3s ease-in-out;
58+
}
59+
60+
[data-gatsby-loading-indicator-visible="true"] {
61+
opacity: 1;
62+
visibility: visible;
63+
transform: translateY(0px);
64+
transition: all 0.3s ease-in-out;
65+
}
66+
67+
[data-gatsby-loading-indicator="spinner"] {
68+
animation: spin 1s linear infinite;
69+
height: 18px;
70+
width: 18px;
71+
color: var(--gatsby);
72+
}
73+
74+
[data-gatsby-loading-indicator="text"] {
75+
margin-left: 0.75em;
76+
line-height: 18px;
77+
}
78+
79+
@keyframes spin {
80+
0% {
81+
transform: rotate(0);
82+
}
83+
100% {
84+
transform: rotate(360deg);
85+
}
86+
}
87+
88+
@media (prefers-reduced-motion: reduce) {
89+
[data-gatsby-loading-indicator="spinner"] {
90+
animation: none;
91+
}
92+
[data-gatsby-loading-indicator-visible="false"] {
93+
transition: none;
94+
}
95+
96+
[data-gatsby-loading-indicator-visible="true"] {
97+
transition: none;
98+
}
99+
}
100+
101+
@media (prefers-color-scheme: dark) {
102+
[data-gatsby-loading-indicator="root"] {
103+
background: var(--grey-90);
104+
color: var(--white);
105+
}
106+
[data-gatsby-loading-indicator="spinner"] {
107+
color: var(--purple-20);
108+
}
109+
}
110+
`,
111+
}}
112+
/>
113+
)
114+
115+
export default Style

packages/gatsby/cache-dir/navigation.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,12 @@ const onPreRouteUpdate = (location, prevLocation) => {
4343
const onRouteUpdate = (location, prevLocation) => {
4444
if (!maybeRedirect(location.pathname)) {
4545
apiRunner(`onRouteUpdate`, { location, prevLocation })
46+
if (
47+
process.env.GATSBY_EXPERIMENTAL_QUERY_ON_DEMAND &&
48+
process.env.GATSBY_QUERY_ON_DEMAND_LOADING_INDICATOR === `true`
49+
) {
50+
emitter.emit(`onRouteUpdate`, { location, prevLocation })
51+
}
4652
}
4753
}
4854

packages/gatsby/cache-dir/root.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import EnsureResources from "./ensure-resources"
1414
import FastRefreshOverlay from "./fast-refresh-overlay"
1515

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

1819
// TODO: Remove entire block when we make fast-refresh the default
1920
// In fast-refresh, this logic is all moved into the `error-overlay-handler`
@@ -147,5 +148,9 @@ const ConditionalFastRefreshOverlay = ({ children }) => {
147148
export default () => (
148149
<ConditionalFastRefreshOverlay>
149150
<StaticQueryStore>{WrappedRoot}</StaticQueryStore>
151+
{process.env.GATSBY_EXPERIMENTAL_QUERY_ON_DEMAND &&
152+
process.env.GATSBY_QUERY_ON_DEMAND_LOADING_INDICATOR === `true` && (
153+
<LoadingIndicatorEventHandler />
154+
)}
150155
</ConditionalFastRefreshOverlay>
151156
)

packages/gatsby/src/services/initialize.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,18 @@ export async function initialize({
227227

228228
process.env.GATSBY_HOT_LOADER = getReactHotLoaderStrategy()
229229

230+
// TODO: figure out proper way of disabling loading indicator
231+
// for now GATSBY_QUERY_ON_DEMAND_LOADING_INDICATOR=false gatsby develop
232+
// will work, but we don't want to force users into using env vars
233+
if (
234+
process.env.GATSBY_EXPERIMENTAL_QUERY_ON_DEMAND &&
235+
!process.env.GATSBY_QUERY_ON_DEMAND_LOADING_INDICATOR
236+
) {
237+
// if query on demand is enabled and GATSBY_QUERY_ON_DEMAND_LOADING_INDICATOR was not set at all
238+
// enable loading indicator
239+
process.env.GATSBY_QUERY_ON_DEMAND_LOADING_INDICATOR = `true`
240+
}
241+
230242
// theme gatsby configs can be functions or objects
231243
if (config && config.__experimentalThemes) {
232244
reporter.warn(

0 commit comments

Comments
 (0)