Skip to content

Commit d59074a

Browse files
authored
feat(site): add a spinning-logo loading state to the playground (#3548)
1 parent 13dc838 commit d59074a

3 files changed

Lines changed: 95 additions & 19 deletions

File tree

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
'use client';
2+
3+
import Image from 'next/image';
4+
import { useEffect, useState } from 'react';
5+
6+
import logo from '@/public/logo.png';
7+
8+
// The chunk often lands in a couple hundred ms, which reads as a flicker
9+
// rather than a load; hold the overlay long enough for it to look intentional.
10+
const MIN_VISIBLE_MS = 650;
11+
const FADE_MS = 400;
12+
13+
export function LoaderVisual() {
14+
return (
15+
<div className="flex h-dvh flex-col items-center justify-center gap-5">
16+
<Image
17+
src={logo}
18+
alt=""
19+
width={40}
20+
height={36}
21+
priority
22+
className="animate-spin [animation-duration:1.8s] motion-reduce:animate-none"
23+
/>
24+
<p
25+
role="status"
26+
className="text-fd-muted-foreground text-[0.8125rem] tracking-tight"
27+
>
28+
Loading playground…
29+
</p>
30+
</div>
31+
);
32+
}
33+
34+
/**
35+
* Covers the page until `ready` resolves, then fades out. Sits over the
36+
* playground rather than swapping with it, so the editor and the first PDF
37+
* render happen behind the overlay instead of popping in after it.
38+
*/
39+
export function PlaygroundLoader({ ready }: { ready: Promise<unknown> }) {
40+
const [done, setDone] = useState(false);
41+
const [gone, setGone] = useState(false);
42+
43+
useEffect(() => {
44+
let alive = true;
45+
const held = new Promise((resolve) => setTimeout(resolve, MIN_VISIBLE_MS));
46+
47+
Promise.all([ready, held]).then(() => {
48+
if (alive) setDone(true);
49+
});
50+
51+
return () => {
52+
alive = false;
53+
};
54+
}, [ready]);
55+
56+
useEffect(() => {
57+
if (!done) return undefined;
58+
// not `transitionend`: it never fires when the user prefers reduced motion
59+
const timer = setTimeout(() => setGone(true), FADE_MS);
60+
return () => clearTimeout(timer);
61+
}, [done]);
62+
63+
if (gone) return null;
64+
65+
return (
66+
<div
67+
aria-hidden={done}
68+
style={{ transitionDuration: `${FADE_MS}ms` }}
69+
className={`bg-fd-background fixed inset-0 z-50 transition-opacity ease-out ${
70+
done ? 'pointer-events-none opacity-0' : 'opacity-100'
71+
}`}
72+
>
73+
<LoaderVisual />
74+
</div>
75+
);
76+
}

apps/site/app/playground/page.tsx

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,13 @@
11
import { Suspense } from 'react';
22

3+
import { LoaderVisual } from './loader';
34
import { PlaygroundClient } from './playground-client';
45

56
export const metadata = { title: 'Playground' };
67

78
export default function PlaygroundPage() {
89
return (
9-
<Suspense
10-
fallback={
11-
<div className="flex h-dvh items-center justify-center text-fd-muted-foreground">
12-
Loading playground…
13-
</div>
14-
}
15-
>
10+
<Suspense fallback={<LoaderVisual />}>
1611
<PlaygroundClient />
1712
</Suspense>
1813
);
Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,24 @@
11
'use client';
22

33
import dynamic from 'next/dynamic';
4+
import { useState } from 'react';
45

5-
const Playground = dynamic(
6-
() => import('@/components/playground').then((m) => m.Playground),
7-
{
8-
ssr: false,
9-
loading: () => (
10-
<div className="flex h-dvh items-center justify-center text-fd-muted-foreground">
11-
Loading playground…
12-
</div>
13-
),
14-
},
15-
);
6+
import { PlaygroundLoader } from './loader';
7+
8+
const load = () => import('@/components/playground').then((m) => m.Playground);
9+
10+
// `loading` is skipped: the overlay below owns the wait, so that it can outlive
11+
// the chunk arriving and fade out instead of cutting.
12+
const Playground = dynamic(load, { ssr: false, loading: () => null });
1613

1714
export function PlaygroundClient() {
18-
return <Playground />;
15+
// module promises are cached, so this is the same load `dynamic` awaits
16+
const [ready] = useState(load);
17+
18+
return (
19+
<>
20+
<Playground />
21+
<PlaygroundLoader ready={ready} />
22+
</>
23+
);
1924
}

0 commit comments

Comments
 (0)