diff --git a/app/ide-desktop/lib/dashboard/src/components/Suspense.tsx b/app/ide-desktop/lib/dashboard/src/components/Suspense.tsx index a69207552d5..c4de442de42 100644 --- a/app/ide-desktop/lib/dashboard/src/components/Suspense.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/Suspense.tsx @@ -7,6 +7,9 @@ import * as React from 'react' +import * as reactQuery from '@tanstack/react-query' + +import * as debounceValue from '#/hooks/debounceValueHooks' import * as offlineHooks from '#/hooks/offlineHooks' import * as textProvider from '#/providers/TextProvider' @@ -24,6 +27,8 @@ export interface SuspenseProps extends React.SuspenseProps { readonly offlineFallbackProps?: result.ResultProps } +const OFFLINE_FETCHING_TOGGLE_DELAY_MS = 250 + /** * Suspense is a component that allows you to wrap a part of your application that might suspend, * showing a fallback to the user while waiting for the data to load. @@ -32,22 +37,52 @@ export interface SuspenseProps extends React.SuspenseProps { * And handles offline scenarios. */ export function Suspense(props: SuspenseProps) { - const { children, loaderProps, fallback, offlineFallbackProps, offlineFallback } = props + const { children } = props + + return }>{children} +} + +/** + * Fallback Element + * Checks if ongoing network requests are happening + * And shows either fallback(loader) or offline message + * + * Some request do not require active internet connection, e.g. requests to the local backend + * So we don't want to show misleading information + * + * We check the fetching status in fallback component because + * we want to know if there are ongoing requests once React renders the fallback in suspense + */ +function FallbackElement(props: SuspenseProps) { + const { loaderProps, fallback, offlineFallbackProps, offlineFallback } = props const { getText } = textProvider.useText() + const { isOffline } = offlineHooks.useOffline() - const getFallbackElement = () => { - if (isOffline) { - return ( - offlineFallback ?? ( - - ) - ) - } else { - return fallback ?? - } - } + const paused = reactQuery.useIsFetching({ fetchStatus: 'paused' }) - return {children} + const fetching = reactQuery.useIsFetching({ + predicate: query => + query.state.fetchStatus === 'fetching' || + query.state.status === 'pending' || + query.state.status === 'success', + }) + + // we use small debounce to avoid flickering when query is resolved, + // but fallback is still showing + const shouldDisplayOfflineMessage = debounceValue.useDebounceValue( + isOffline && paused >= 0 && fetching === 0, + OFFLINE_FETCHING_TOGGLE_DELAY_MS + ) + + if (shouldDisplayOfflineMessage) { + return ( + offlineFallback ?? ( + + ) + ) + } else { + return fallback ?? + } } diff --git a/app/ide-desktop/lib/dashboard/src/hooks/debounceCallbackHooks.ts b/app/ide-desktop/lib/dashboard/src/hooks/debounceCallbackHooks.ts new file mode 100644 index 00000000000..017e33b0080 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/hooks/debounceCallbackHooks.ts @@ -0,0 +1,91 @@ +/** + * @file + * + * This file contains the `useDebouncedCallback` hook which is used to debounce a callback function. + */ +import * as React from 'react' + +import * as callbackHooks from './eventCallbackHooks' +import * as unmountEffect from './unmountHooks' + +/** + * Wrap a callback into debounce function + */ +export function useDebouncedCallback unknown>( + callback: Fn, + deps: React.DependencyList, + delay: number, + maxWait = 0 +): DebouncedFunction { + const stableCallback = callbackHooks.useEventCallback(callback) + const timeoutIdRef = React.useRef>() + const waitTimeoutIdRef = React.useRef>() + const lastCallRef = React.useRef<{ args: Parameters }>() + + const clear = () => { + if (timeoutIdRef.current) { + clearTimeout(timeoutIdRef.current) + timeoutIdRef.current = undefined + } + + if (waitTimeoutIdRef.current) { + clearTimeout(waitTimeoutIdRef.current) + waitTimeoutIdRef.current = undefined + } + } + + // cancel scheduled execution on unmount + unmountEffect.useUnmount(clear) + + return React.useMemo(() => { + const execute = () => { + if (!lastCallRef.current) { + // eslint-disable-next-line no-restricted-syntax + return + } + + const context = lastCallRef.current + lastCallRef.current = undefined + + stableCallback(...context.args) + + clear() + } + + // eslint-disable-next-line no-restricted-syntax + const wrapped = (...args: Parameters) => { + if (timeoutIdRef.current) { + clearTimeout(timeoutIdRef.current) + } + + lastCallRef.current = { args } + + if (delay === 0) { + execute() + } else { + // plan regular execution + timeoutIdRef.current = setTimeout(execute, delay) + + // plan maxWait execution if required + if (maxWait > 0 && !waitTimeoutIdRef.current) { + waitTimeoutIdRef.current = setTimeout(execute, maxWait) + } + } + } + + Object.defineProperties(wrapped, { + length: { value: stableCallback.length }, + name: { value: `${stableCallback.name || 'anonymous'}__debounced__${delay}` }, + }) + + return wrapped + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [stableCallback, delay, maxWait, ...deps]) +} + +/** + * + */ +export interface DebouncedFunction unknown> { + (this: ThisParameterType, ...args: Parameters): void +} diff --git a/app/ide-desktop/lib/dashboard/src/hooks/debounceStateHooks.ts b/app/ide-desktop/lib/dashboard/src/hooks/debounceStateHooks.ts new file mode 100644 index 00000000000..29acbf09663 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/hooks/debounceStateHooks.ts @@ -0,0 +1,43 @@ +/** + * @file + * + * This file contains the `useDebounceState` hook, + * which is a custom hook that returns a stateful value and a function to update it that will debounce updates. + */ +import * as React from 'react' + +import * as debouncedCallback from './debounceCallbackHooks' +import * as eventCallbackHooks from './eventCallbackHooks' + +/** + * A hook that returns a stateful value, and a function to update it that will debounce updates. + */ +export function useDebounceState( + initialState: S | (() => S), + delay: number, + maxWait = 0 +): [S, React.Dispatch>] { + const [state, setState] = React.useState(initialState) + const currentValueRef = React.useRef(state) + const [, startTransition] = React.useTransition() + + const debouncedSetState = debouncedCallback.useDebouncedCallback< + React.Dispatch> + >( + value => { + startTransition(() => { + setState(value) + }) + }, + [], + delay, + maxWait + ) + const setValue = eventCallbackHooks.useEventCallback((next: S | ((currentValue: S) => S)) => { + currentValueRef.current = next instanceof Function ? next(currentValueRef.current) : next + + debouncedSetState(currentValueRef.current) + }) + + return [state, setValue] +} diff --git a/app/ide-desktop/lib/dashboard/src/hooks/debounceValueHooks.ts b/app/ide-desktop/lib/dashboard/src/hooks/debounceValueHooks.ts new file mode 100644 index 00000000000..58843ea4d69 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/hooks/debounceValueHooks.ts @@ -0,0 +1,19 @@ +/** + * @file + * + * This file contains the `useDebounceValue` hook. + */ +import * as debounceState from './debounceStateHooks' + +/** + * Debounce a value. + */ +export function useDebounceValue(value: T, delay: number, maxWait?: number) { + const [debouncedValue, setDebouncedValue] = debounceState.useDebounceState(value, delay, maxWait) + + if (value !== debouncedValue) { + setDebouncedValue(value) + } + + return debouncedValue +} diff --git a/app/ide-desktop/lib/dashboard/src/hooks/unmountHooks.ts b/app/ide-desktop/lib/dashboard/src/hooks/unmountHooks.ts new file mode 100644 index 00000000000..7ec6b687a46 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/hooks/unmountHooks.ts @@ -0,0 +1,16 @@ +/** + * @file + */ +import * as React from 'react' + +import * as eventCallback from './eventCallbackHooks' + +/** + * Calls callback when component is unmounted. + */ +export function useUnmount(callback: () => void) { + // by using `useEventCallback` we can ensure that the callback is stable + const callbackEvent = eventCallback.useEventCallback(callback) + + React.useEffect(() => callbackEvent, [callbackEvent]) +} diff --git a/app/ide-desktop/lib/dashboard/src/index.tsx b/app/ide-desktop/lib/dashboard/src/index.tsx index d1a56051a1c..20cde738807 100644 --- a/app/ide-desktop/lib/dashboard/src/index.tsx +++ b/app/ide-desktop/lib/dashboard/src/index.tsx @@ -95,24 +95,26 @@ function run(props: Omit) { const httpClient = new HttpClient() const queryClient = reactQueryClientModule.createReactQueryClient() - reactDOM.createRoot(root).render( - - - - }> - - - + React.startTransition(() => { + reactDOM.createRoot(root).render( + + + + }> + + + - - - - ) + + + + ) + }) } /** Global configuration for the {@link App} component. */ diff --git a/app/ide-desktop/lib/dashboard/src/modals/TermsOfServiceModal.tsx b/app/ide-desktop/lib/dashboard/src/modals/TermsOfServiceModal.tsx index f0cc3a30416..f035c0d3577 100644 --- a/app/ide-desktop/lib/dashboard/src/modals/TermsOfServiceModal.tsx +++ b/app/ide-desktop/lib/dashboard/src/modals/TermsOfServiceModal.tsx @@ -76,7 +76,6 @@ export function TermsOfServiceModal() { // and refetch in the background to check for updates. ...(localVersionHash != null && { initialData: { hash: localVersionHash }, - initialDataUpdatedAt: 0, }), select: data => data.hash, })