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,
})