Skip to content

Commit 72e2f0c

Browse files
authored
Page loading skeleton (#2754)
* SPOOKY SKELETON * do / redirect using redirect for better skeleton behavior * put in crumb and buttons * put msw banner on skeleton too when appropriate
1 parent af255e6 commit 72e2f0c

File tree

4 files changed

+106
-4
lines changed

4 files changed

+106
-4
lines changed

app/components/MswBanner.tsx

+19-1
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,24 @@ function ExternalLink({ href, children }: { href: string; children: ReactNode })
2626
)
2727
}
2828

29-
export function MswBanner() {
29+
type Props = {
30+
/**
31+
* HACK to avoid the user opening the modal while on the loading skeleton
32+
* -- it immediately closes when the page finishes loading because the
33+
* banner is dropped when the HydrateFallback unmounts and re-rendered in
34+
* RootLayout. A more ideal solution would be to render the banner outside
35+
* the RouterProvider and therefore have it be the same banner in both the
36+
* HydrateFallback and normal page situations, but it's a lot more work to
37+
* get the layout right in that case with respect to things like the loading
38+
* bar. When we switch to framework mode, we can manage all this in the root
39+
* route using the Layout export. In the meantime, this is tolerable and only
40+
* applies to the preview deploys, and only burdens someone who manages to
41+
* click the Learn More button in the half second before the content loads.
42+
*/
43+
disableButton?: boolean
44+
}
45+
46+
export function MswBanner({ disableButton }: Props) {
3047
const [isOpen, setIsOpen] = useState(false)
3148
const closeModal = () => setIsOpen(false)
3249
return (
@@ -38,6 +55,7 @@ export function MswBanner() {
3855
type="button"
3956
className="ml-2 flex items-center gap-0.5 text-sans-md hover:text-info"
4057
onClick={() => setIsOpen(true)}
58+
disabled={disableButton}
4159
>
4260
Learn more <NextArrow12Icon />
4361
</button>

app/components/PageSkeleton.tsx

+59
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/*
2+
* This Source Code Form is subject to the terms of the Mozilla Public
3+
* License, v. 2.0. If a copy of the MPL was not distributed with this
4+
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
5+
*
6+
* Copyright Oxide Computer Company
7+
*/
8+
9+
import { useLocation } from 'react-router'
10+
11+
import { PageContainer } from '~/layouts/helpers'
12+
import { classed } from '~/util/classed'
13+
14+
import { MswBanner } from './MswBanner'
15+
16+
const Block = classed.div`motion-safe:animate-pulse2 rounded bg-tertiary`
17+
18+
export function PageSkeleton({ skipPaths }: { skipPaths?: RegExp[] }) {
19+
const { pathname } = useLocation()
20+
21+
// HACK: we can only hang a HydrateFallback off the root route/layout, so in
22+
// order to avoid rendering this skeleton on pages that don't have this grid
23+
// layout, all we can do is match the path
24+
if (skipPaths?.some((regex) => regex.test(pathname))) return null
25+
26+
// we need the msw banner here so it doesn't pop in on load
27+
return (
28+
<>
29+
{process.env.MSW_BANNER ? <MswBanner disableButton /> : null}
30+
<PageContainer>
31+
<div className="flex items-center gap-2 border-b border-r p-3 border-secondary">
32+
<Block className="h-8 w-8" />
33+
<Block className="h-4 w-24" />
34+
</div>
35+
<div className="flex items-center justify-between gap-2 border-b p-3 border-secondary">
36+
<Block className="h-4 w-24" />
37+
<div className="flex items-center gap-2">
38+
<Block className="h-6 w-16" />
39+
<Block className="h-6 w-32" />
40+
</div>
41+
</div>
42+
<div className="border-r p-4 border-secondary">
43+
<Block className="mb-10 h-4 w-full" />
44+
<div className="mb-6 space-y-2">
45+
<Block className="h-4 w-32" />
46+
<Block className="h-4 w-24" />
47+
</div>
48+
<div className="space-y-2">
49+
<Block className="h-4 w-14" />
50+
<Block className="h-4 w-32" />
51+
<Block className="h-4 w-24" />
52+
<Block className="h-4 w-14" />
53+
</div>
54+
</div>
55+
<div className="" />
56+
</PageContainer>
57+
</>
58+
)
59+
}

app/routes.tsx

+18-3
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,13 @@ import type { ReactElement } from 'react'
99
import {
1010
createRoutesFromElements,
1111
Navigate,
12+
redirect,
1213
Route,
1314
type LoaderFunctionArgs,
14-
type redirect,
1515
} from 'react-router'
1616

1717
import { NotFound } from './components/ErrorPage'
18+
import { PageSkeleton } from './components/PageSkeleton.tsx'
1819
import { makeCrumb, type Crumb } from './hooks/use-crumbs'
1920
import { getInstanceSelector, getVpcSelector } from './hooks/use-params'
2021
import { pb } from './util/path-builder'
@@ -29,6 +30,7 @@ type RouteModule = {
2930
shouldRevalidate?: () => boolean
3031
ErrorBoundary?: () => ReactElement
3132
handle?: Crumb
33+
hydrateFallbackElement?: ReactElement
3234
// trick to get a nice type error when we forget to convert loader to
3335
// clientLoader in the module
3436
loader?: never
@@ -51,7 +53,20 @@ const redirectWithLoader = (to: string) => (mod: RouteModule) => ({
5153
})
5254

5355
export const routes = createRoutesFromElements(
54-
<Route lazy={() => import('./layouts/RootLayout').then(convert)}>
56+
<Route
57+
lazy={() => import('./layouts/RootLayout').then(convert)}
58+
// This only works here, not on any lower layouts. In framework mode they
59+
// make clearer that only the root can have a `HydrateFallback` -- that
60+
// restriction appears to be in place implicitly in library mode. This is
61+
// why we need skipPaths: there are layouts that don't have the grid that
62+
// matches skeleton, so we can't show the skeleton on those pages.
63+
//
64+
// Also notable: this only works when explicitly added here as a prop,
65+
// not as an export from the lazy-loaded route module, which makes sense
66+
// because the loading of that route module is itself part of "hydration"
67+
// (confusingly, not what React calls hydration).
68+
hydrateFallbackElement={<PageSkeleton skipPaths={[/^\/login\//, /^\/device\//]} />}
69+
>
5570
<Route path="*" element={<NotFound />} />
5671
<Route lazy={() => import('./layouts/LoginLayout.tsx').then(convert)}>
5772
<Route
@@ -192,7 +207,7 @@ export const routes = createRoutesFromElements(
192207
</Route>
193208
</Route>
194209

195-
<Route index element={<Navigate to={pb.projects()} replace />} />
210+
<Route index loader={() => redirect(pb.projects())} />
196211

197212
<Route lazy={() => import('./layouts/SiloLayout').then(convert)}>
198213
<Route

tailwind.config.ts

+10
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,16 @@ export default {
6565
animation: {
6666
'spin-slow': 'spin 5s linear infinite',
6767
pulse: 'pulse 2s cubic-bezier(.4,0,.6,1) infinite',
68+
// used by PageSkeleton
69+
pulse2: 'pulse2 1.3s cubic-bezier(.4,0,.6,1) infinite',
70+
},
71+
keyframes: {
72+
// different from pulse in that we go up a little before we go back down.
73+
// pulse starts at opacity 1
74+
pulse2: {
75+
'0%, 100%': { opacity: '0.75' },
76+
'50%': { opacity: '1' },
77+
},
6878
},
6979
},
7080
plugins: [

0 commit comments

Comments
 (0)