diff --git a/apps/site/app/playground/loader.tsx b/apps/site/app/playground/loader.tsx new file mode 100644 index 000000000..ed994906a --- /dev/null +++ b/apps/site/app/playground/loader.tsx @@ -0,0 +1,76 @@ +'use client'; + +import Image from 'next/image'; +import { useEffect, useState } from 'react'; + +import logo from '@/public/logo.png'; + +// The chunk often lands in a couple hundred ms, which reads as a flicker +// rather than a load; hold the overlay long enough for it to look intentional. +const MIN_VISIBLE_MS = 650; +const FADE_MS = 400; + +export function LoaderVisual() { + return ( +
+ +

+ Loading playground… +

+
+ ); +} + +/** + * Covers the page until `ready` resolves, then fades out. Sits over the + * playground rather than swapping with it, so the editor and the first PDF + * render happen behind the overlay instead of popping in after it. + */ +export function PlaygroundLoader({ ready }: { ready: Promise }) { + const [done, setDone] = useState(false); + const [gone, setGone] = useState(false); + + useEffect(() => { + let alive = true; + const held = new Promise((resolve) => setTimeout(resolve, MIN_VISIBLE_MS)); + + Promise.all([ready, held]).then(() => { + if (alive) setDone(true); + }); + + return () => { + alive = false; + }; + }, [ready]); + + useEffect(() => { + if (!done) return undefined; + // not `transitionend`: it never fires when the user prefers reduced motion + const timer = setTimeout(() => setGone(true), FADE_MS); + return () => clearTimeout(timer); + }, [done]); + + if (gone) return null; + + return ( +
+ +
+ ); +} diff --git a/apps/site/app/playground/page.tsx b/apps/site/app/playground/page.tsx index b54a246f5..c7d9d5931 100644 --- a/apps/site/app/playground/page.tsx +++ b/apps/site/app/playground/page.tsx @@ -1,18 +1,13 @@ import { Suspense } from 'react'; +import { LoaderVisual } from './loader'; import { PlaygroundClient } from './playground-client'; export const metadata = { title: 'Playground' }; export default function PlaygroundPage() { return ( - - Loading playground… - - } - > + }> ); diff --git a/apps/site/app/playground/playground-client.tsx b/apps/site/app/playground/playground-client.tsx index 60f0ca8ff..9a2053d5d 100644 --- a/apps/site/app/playground/playground-client.tsx +++ b/apps/site/app/playground/playground-client.tsx @@ -1,19 +1,24 @@ 'use client'; import dynamic from 'next/dynamic'; +import { useState } from 'react'; -const Playground = dynamic( - () => import('@/components/playground').then((m) => m.Playground), - { - ssr: false, - loading: () => ( -
- Loading playground… -
- ), - }, -); +import { PlaygroundLoader } from './loader'; + +const load = () => import('@/components/playground').then((m) => m.Playground); + +// `loading` is skipped: the overlay below owns the wait, so that it can outlive +// the chunk arriving and fade out instead of cutting. +const Playground = dynamic(load, { ssr: false, loading: () => null }); export function PlaygroundClient() { - return ; + // module promises are cached, so this is the same load `dynamic` awaits + const [ready] = useState(load); + + return ( + <> + + + + ); }