From 8a22e6e6caaeb4a265f3b012f3798a124544ce16 Mon Sep 17 00:00:00 2001 From: Danilo Woznica Date: Tue, 25 Oct 2022 09:57:41 +0100 Subject: [PATCH] [Beta] Sandpack bundler improvements (#5164) * cache babel assets * Update SandpackRoot.tsx * Update NavigationBar.tsx * Update SandpackRoot.tsx * Update 7 files * Update 6 files * Update 6 files * Update LoadingOverlay.tsx * Update Preview.tsx * Update LoadingOverlay.tsx * Update 4 files * Update beta/src/components/MDX/Sandpack/LoadingOverlay.tsx Co-authored-by: Shanmughapriyan S * Update sandpack.css * Update Preview.tsx and SandpackRoot.tsx * Update 3 files * Update ErrorMessage.tsx and Preview.tsx * Update Preview.tsx * Update SandpackRoot.tsx * Update Preview.tsx Co-authored-by: Shanmughapriyan S --- .../Sandpack/{Error.tsx => ErrorMessage.tsx} | 6 +- .../MDX/Sandpack/LoadingOverlay.tsx | 142 ++++++++++++++++++ .../components/MDX/Sandpack/NavigationBar.tsx | 14 +- beta/src/components/MDX/Sandpack/Preview.tsx | 122 +++++++-------- .../components/MDX/Sandpack/SandpackRoot.tsx | 3 +- beta/src/styles/sandpack.css | 6 + 6 files changed, 226 insertions(+), 67 deletions(-) rename beta/src/components/MDX/Sandpack/{Error.tsx => ErrorMessage.tsx} (70%) create mode 100644 beta/src/components/MDX/Sandpack/LoadingOverlay.tsx diff --git a/beta/src/components/MDX/Sandpack/Error.tsx b/beta/src/components/MDX/Sandpack/ErrorMessage.tsx similarity index 70% rename from beta/src/components/MDX/Sandpack/Error.tsx rename to beta/src/components/MDX/Sandpack/ErrorMessage.tsx index 61344eba..7c67ee46 100644 --- a/beta/src/components/MDX/Sandpack/Error.tsx +++ b/beta/src/components/MDX/Sandpack/ErrorMessage.tsx @@ -10,13 +10,13 @@ interface ErrorType { path?: string; } -export function Error({error}: {error: ErrorType}) { +export function ErrorMessage({error, ...props}: {error: ErrorType}) { const {message, title} = error; return ( -
+

{title || 'Error'}

-
+      
         {message}
       
diff --git a/beta/src/components/MDX/Sandpack/LoadingOverlay.tsx b/beta/src/components/MDX/Sandpack/LoadingOverlay.tsx new file mode 100644 index 00000000..7c261866 --- /dev/null +++ b/beta/src/components/MDX/Sandpack/LoadingOverlay.tsx @@ -0,0 +1,142 @@ +import {useState} from 'react'; + +import { + LoadingOverlayState, + OpenInCodeSandboxButton, + useSandpack, +} from '@codesandbox/sandpack-react'; +import {useEffect} from 'react'; + +const FADE_ANIMATION_DURATION = 200; + +export const LoadingOverlay = ({ + clientId, + dependenciesLoading, + forceLoading, +}: { + clientId: string; + dependenciesLoading: boolean; + forceLoading: boolean; +} & React.HTMLAttributes): JSX.Element | null => { + const loadingOverlayState = useLoadingOverlayState( + clientId, + dependenciesLoading, + forceLoading + ); + + if (loadingOverlayState === 'HIDDEN') { + return null; + } + + if (loadingOverlayState === 'TIMEOUT') { + return ( +
+
+ Unable to establish connection with the sandpack bundler. Make sure + you are online or try again later. If the problem persists, please + report it via{' '} + + email + {' '} + or submit an issue on{' '} + + GitHub. + +
+
+ ); + } + + const stillLoading = + loadingOverlayState === 'LOADING' || loadingOverlayState === 'PRE_FADING'; + + return ( +
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+ ); +}; + +const useLoadingOverlayState = ( + clientId: string, + dependenciesLoading: boolean, + forceLoading: boolean +): LoadingOverlayState => { + const {sandpack, listen} = useSandpack(); + const [state, setState] = useState('HIDDEN'); + + if (state !== 'LOADING' && forceLoading) { + setState('LOADING'); + } + + /** + * Sandpack listener + */ + const sandpackIdle = sandpack.status === 'idle'; + useEffect(() => { + const unsubscribe = listen((message) => { + if (message.type === 'done') { + setState((prev) => { + return prev === 'LOADING' ? 'PRE_FADING' : 'HIDDEN'; + }); + } + }, clientId); + + return () => { + unsubscribe(); + }; + }, [listen, clientId, sandpackIdle]); + + /** + * Fading transient state + */ + useEffect(() => { + let fadeTimeout: ReturnType; + + if (state === 'PRE_FADING' && !dependenciesLoading) { + setState('FADING'); + } else if (state === 'FADING') { + fadeTimeout = setTimeout( + () => setState('HIDDEN'), + FADE_ANIMATION_DURATION + ); + } + + return () => { + clearTimeout(fadeTimeout); + }; + }, [state, dependenciesLoading]); + + if (sandpack.status === 'timeout') { + return 'TIMEOUT'; + } + + if (sandpack.status !== 'running') { + return 'HIDDEN'; + } + + return state; +}; diff --git a/beta/src/components/MDX/Sandpack/NavigationBar.tsx b/beta/src/components/MDX/Sandpack/NavigationBar.tsx index eedf9fc3..8c884a5d 100644 --- a/beta/src/components/MDX/Sandpack/NavigationBar.tsx +++ b/beta/src/components/MDX/Sandpack/NavigationBar.tsx @@ -22,7 +22,6 @@ import {DownloadButton} from './DownloadButton'; import {IconChevron} from '../../Icon/IconChevron'; import {Listbox} from '@headlessui/react'; -// TODO: Replace with real useEvent. export function useEvent(fn: any): any { const ref = useRef(null); useInsertionEffect(() => { @@ -94,9 +93,20 @@ export function NavigationBar({providedFiles}: {providedFiles: Array}) { }, [isMultiFile]); const handleReset = () => { - if (confirm('Reset all your edits too?')) { + /** + * resetAllFiles must come first, otherwise + * the previous content will appears for a second + * when the iframe loads. + * + * Plus, it should only prompts if there's any file changes + */ + if ( + sandpack.editorState === 'dirty' && + confirm('Reset all your edits too?') + ) { sandpack.resetAllFiles(); } + refresh(); }; diff --git a/beta/src/components/MDX/Sandpack/Preview.tsx b/beta/src/components/MDX/Sandpack/Preview.tsx index 20c8311e..cec510b8 100644 --- a/beta/src/components/MDX/Sandpack/Preview.tsx +++ b/beta/src/components/MDX/Sandpack/Preview.tsx @@ -3,26 +3,17 @@ */ /* eslint-disable react-hooks/exhaustive-deps */ -import {useRef, useState, useEffect, useMemo} from 'react'; -import { - useSandpack, - LoadingOverlay, - SandpackStack, -} from '@codesandbox/sandpack-react'; +import {useRef, useState, useEffect, useMemo, useId} from 'react'; +import {useSandpack, SandpackStack} from '@codesandbox/sandpack-react'; import cn from 'classnames'; -import {Error} from './Error'; +import {ErrorMessage} from './ErrorMessage'; import {SandpackConsole} from './Console'; import type {LintDiagnostic} from './useSandpackLint'; - -/** - * TODO: can we use React.useId? - */ -const generateRandomId = (): string => - Math.floor(Math.random() * 10000).toString(); +import {CSSProperties} from 'react'; +import {LoadingOverlay} from './LoadingOverlay'; type CustomPreviewProps = { className?: string; - customStyle?: Record; isExpanded: boolean; lintErrors: LintDiagnostic; }; @@ -40,13 +31,13 @@ function useDebounced(value: any): any { } export function Preview({ - customStyle, isExpanded, className, lintErrors, }: CustomPreviewProps) { const {sandpack, listen} = useSandpack(); - const [isReady, setIsReady] = useState(false); + const [bundlerIsReady, setBundlerIsReady] = useState(false); + const [showLoading, setShowLoading] = useState(false); const [iframeComputedHeight, setComputedAutoHeight] = useState( null ); @@ -95,7 +86,7 @@ export function Preview({ // It changes too fast, causing flicker. const error = useDebounced(rawError); - const clientId = useRef(generateRandomId()); + const clientId = useId(); const iframeRef = useRef(null); // SandpackPreview immediately registers the custom screens/components so the bundler does not render any of them @@ -104,46 +95,54 @@ export function Preview({ errorScreenRegisteredRef.current = true; loadingScreenRegisteredRef.current = true; + const sandpackIdle = sandpack.status === 'idle'; + useEffect(function createBundler() { const iframeElement = iframeRef.current!; - registerBundler(iframeElement, clientId.current); + registerBundler(iframeElement, clientId); return () => { - unregisterBundler(clientId.current); + unregisterBundler(clientId); }; }, []); useEffect( function bundlerListener() { - const unsubscribe = listen((message: any) => { + let timeout: ReturnType; + + const unsubscribe = listen((message) => { if (message.type === 'resize') { setComputedAutoHeight(message.height); } else if (message.type === 'start') { if (message.firstLoad) { - setIsReady(false); + setBundlerIsReady(false); } + + /** + * The spinner component transition might be longer than + * the bundler loading, so we only show the spinner if + * it takes more than 1s to load the bundler. + */ + timeout = setTimeout(() => { + setShowLoading(true); + }, 500); } else if (message.type === 'done') { - setIsReady(true); + setBundlerIsReady(true); + setShowLoading(false); + clearTimeout(timeout); } - }, clientId.current); + }, clientId); return () => { - setIsReady(false); + clearTimeout(timeout); + setBundlerIsReady(false); setComputedAutoHeight(null); unsubscribe(); }; }, - [status === 'idle'] + [sandpackIdle] ); - const overrideStyle = error - ? { - // Don't collapse errors - maxHeight: undefined, - } - : null; - const hideContent = !isReady || error; - // WARNING: // The layout and styling here is convoluted and really easy to break. // If you make changes to it, you need to test different cases: @@ -159,67 +158,68 @@ export function Preview({ // - It should work on mobile. // The best way to test it is to actually go through some challenges. + const hideContent = error || !iframeComputedHeight || !bundlerIsReady; + + const iframeWrapperPosition = (): CSSProperties => { + if (hideContent) { + return {position: 'relative'}; + } + + if (isExpanded) { + return {position: 'sticky', top: '2em'}; + } + + return {}; + }; + return ( - +
-
+