From 393267977142fafb27d68426cd87c38d5a5840ed Mon Sep 17 00:00:00 2001 From: Sergei Garin Date: Sun, 22 Dec 2024 16:54:52 +0300 Subject: [PATCH] Fix Offline Mode (#11887) Closes: https://github.com/enso-org/cloud-v2/issues/1630 This PR disables the refreshing the session if user is offline --- app/common/package.json | 1 + app/common/src/queryClient.ts | 5 +- app/common/src/text/english.json | 9 +- app/common/src/utilities/errors.ts | 52 ++++++ app/gui/src/dashboard/App.tsx | 23 +++ .../src/dashboard/assets/offline_filled.svg | 5 + .../src/dashboard/assets/offline_outline.svg | 3 + .../Form/components/FormError.tsx | 14 +- .../dashboard/components/ErrorBoundary.tsx | 68 +++++--- .../components/OfflineNotificationManager.tsx | 33 ++-- app/gui/src/dashboard/components/Suspense.tsx | 60 +------ app/gui/src/dashboard/hooks/backendHooks.tsx | 2 - app/gui/src/dashboard/hooks/offlineHooks.ts | 2 +- app/gui/src/dashboard/layouts/Drive.tsx | 79 ++++++--- .../layouts/Settings/SetupTwoFaForm.tsx | 77 +++++---- app/gui/src/dashboard/layouts/UserBar.tsx | 26 ++- .../components/PlanSelectorDialog.tsx | 158 +++++++++--------- .../payments/components/StripeProvider.tsx | 45 +++-- .../dashboard/providers/SessionProvider.tsx | 12 +- .../__test__/SessionProvider.test.tsx | 6 +- app/gui/src/dashboard/utilities/HttpClient.ts | 20 ++- app/gui/src/dashboard/utilities/error.ts | 17 +- 22 files changed, 443 insertions(+), 274 deletions(-) create mode 100644 app/common/src/utilities/errors.ts create mode 100644 app/gui/src/dashboard/assets/offline_filled.svg create mode 100644 app/gui/src/dashboard/assets/offline_outline.svg diff --git a/app/common/package.json b/app/common/package.json index a3bda304841a..b193595dc5b9 100644 --- a/app/common/package.json +++ b/app/common/package.json @@ -15,6 +15,7 @@ "./src/backendQuery": "./src/backendQuery.ts", "./src/queryClient": "./src/queryClient.ts", "./src/utilities/data/array": "./src/utilities/data/array.ts", + "./src/utilities/errors": "./src/utilities/errors.ts", "./src/utilities/data/dateTime": "./src/utilities/data/dateTime.ts", "./src/utilities/data/newtype": "./src/utilities/data/newtype.ts", "./src/utilities/data/object": "./src/utilities/data/object.ts", diff --git a/app/common/src/queryClient.ts b/app/common/src/queryClient.ts index ab69795436d0..9c8829c8f736 100644 --- a/app/common/src/queryClient.ts +++ b/app/common/src/queryClient.ts @@ -83,7 +83,7 @@ export function createQueryClient( storage: persisterStorage, // Prefer online first and don't rely on the local cache if user is online // fallback to the local cache only if the user is offline - maxAge: queryCore.onlineManager.isOnline() ? -1 : DEFAULT_QUERY_PERSIST_TIME_MS, + maxAge: DEFAULT_QUERY_PERSIST_TIME_MS, buster: DEFAULT_BUSTER, filters: { predicate: query => query.meta?.persist !== false }, prefix: 'enso:query-persist:', @@ -130,6 +130,9 @@ export function createQueryClient( defaultOptions: { queries: { ...(persister != null ? { persister } : {}), + // Default set to 'always' to don't pause ongoing queries + // and make them fail. + networkMode: 'always', refetchOnReconnect: 'always', staleTime: DEFAULT_QUERY_STALE_TIME_MS, retry: (failureCount, error: unknown) => { diff --git a/app/common/src/text/english.json b/app/common/src/text/english.json index 77d36c0eed46..b2e332c1836b 100644 --- a/app/common/src/text/english.json +++ b/app/common/src/text/english.json @@ -21,6 +21,9 @@ "deleteAssetError": "Could not delete '$0'", "restoreAssetError": "Could not restore '$0'", + "refetchQueriesPending": "Getting latest updates...", + "refetchQueriesError": "Could not get latest updates. Some information may be outdated", + "localBackendDatalinkError": "Cannot create Datalinks on the local drive", "localBackendSecretError": "Cannot create secrets on the local drive", "offlineUploadFilesError": "Cannot upload files when offline", @@ -482,9 +485,9 @@ "hidePassword": "Hide password", "showPassword": "Show password", "copiedToClipboard": "Copied to clipboard", - "noResultsFound": "No results found.", - "youAreOffline": "You are offline.", - "cannotCreateAssetsHere": "You do not have the permissions to create assets here.", + "noResultsFound": "No results found", + "youAreOffline": "You are offline", + "cannotCreateAssetsHere": "You do not have the permissions to create assets here", "enableVersionChecker": "Enable Version Checker", "enableVersionCheckerDescription": "Show a dialog if the current version of the desktop app does not match the latest version.", "disableAnimations": "Disable animations", diff --git a/app/common/src/utilities/errors.ts b/app/common/src/utilities/errors.ts new file mode 100644 index 000000000000..a359fd3e1a7a --- /dev/null +++ b/app/common/src/utilities/errors.ts @@ -0,0 +1,52 @@ +/** + * An error that occurs when a network request fails. + * + * This error is used to indicate that a network request failed due to a network error, + * such as a timeout or a connection error. + */ +export class NetworkError extends Error { + /** + * Create a new {@link NetworkError} with the specified message. + * @param message - The message to display when the error is thrown. + */ + constructor(message: string, options?: ErrorOptions) { + super(message, options) + this.name = 'NetworkError' + } +} + +/** + * An error that occurs when the user is offline. + * + * This error is used to indicate that the user is offline, such as when they are + * not connected to the internet or when they are on an airplane. + */ +export class OfflineError extends Error { + /** + * Create a new {@link OfflineError} with the specified message. + * @param message - The message to display when the error is thrown. + */ + constructor(message: string = 'User is offline', options?: ErrorOptions) { + super(message, options) + this.name = 'OfflineError' + } +} + +/** + * An error with a display message. + * + * This message can be shown to a user. + */ +export class ErrorWithDisplayMessage extends Error { + readonly displayMessage: string + /** + * Create a new {@link ErrorWithDisplayMessage} with the specified message and display message. + * @param message - The message to display when the error is thrown. + * @param options - The options to pass to the error. + */ + constructor(message: string, options: ErrorOptions & { displayMessage: string }) { + super(message, options) + this.name = 'ErrorWithDisplayMessage' + this.displayMessage = options.displayMessage + } +} diff --git a/app/gui/src/dashboard/App.tsx b/app/gui/src/dashboard/App.tsx index fe8d68ee04e0..1b7123965687 100644 --- a/app/gui/src/dashboard/App.tsx +++ b/app/gui/src/dashboard/App.tsx @@ -98,6 +98,8 @@ import { STATIC_QUERY_OPTIONS } from '#/utilities/reactQuery' import { useInitAuthService } from '#/authentication/service' import { InvitedToOrganizationModal } from '#/modals/InvitedToOrganizationModal' +import { useMutation } from '@tanstack/react-query' +import { useOffline } from './hooks/offlineHooks' // ============================ // === Global configuration === @@ -215,6 +217,9 @@ export default function App(props: AppProps) { }, }) + const { isOffline } = useOffline() + const { getText } = textProvider.useText() + const queryClient = props.queryClient // Force all queries to be stale @@ -236,6 +241,24 @@ export default function App(props: AppProps) { refetchInterval: 2 * 60 * 1000, }) + const { mutate: executeBackgroundUpdate } = useMutation({ + mutationKey: ['refetch-queries', { isOffline }], + scope: { id: 'refetch-queries' }, + mutationFn: () => queryClient.refetchQueries({ type: 'all' }), + networkMode: 'online', + onError: () => { + toastify.toast.error(getText('refetchQueriesError'), { + position: 'bottom-right', + }) + }, + }) + + React.useEffect(() => { + if (!isOffline) { + executeBackgroundUpdate() + } + }, [executeBackgroundUpdate, isOffline]) + // Both `BackendProvider` and `InputBindingsProvider` depend on `LocalStorageProvider`. // Note that the `Router` must be the parent of the `AuthProvider`, because the `AuthProvider` // will redirect the user between the login/register pages and the dashboard. diff --git a/app/gui/src/dashboard/assets/offline_filled.svg b/app/gui/src/dashboard/assets/offline_filled.svg new file mode 100644 index 000000000000..a85d8b336489 --- /dev/null +++ b/app/gui/src/dashboard/assets/offline_filled.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/gui/src/dashboard/assets/offline_outline.svg b/app/gui/src/dashboard/assets/offline_outline.svg new file mode 100644 index 000000000000..da5fe69221db --- /dev/null +++ b/app/gui/src/dashboard/assets/offline_outline.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/gui/src/dashboard/components/AriaComponents/Form/components/FormError.tsx b/app/gui/src/dashboard/components/AriaComponents/Form/components/FormError.tsx index c1f1f32c1910..99e822d36afb 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Form/components/FormError.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Form/components/FormError.tsx @@ -3,9 +3,7 @@ * * Form error component. */ - -import * as React from 'react' - +import Offline from '#/assets/offline_filled.svg' import * as textProvider from '#/providers/TextProvider' import * as reactAriaComponents from '#/components/AriaComponents' @@ -22,7 +20,7 @@ export interface FormErrorProps extends Omit + {offlineMessage} diff --git a/app/gui/src/dashboard/components/ErrorBoundary.tsx b/app/gui/src/dashboard/components/ErrorBoundary.tsx index ee5dc324d4dc..71b71194f997 100644 --- a/app/gui/src/dashboard/components/ErrorBoundary.tsx +++ b/app/gui/src/dashboard/components/ErrorBoundary.tsx @@ -1,4 +1,5 @@ /** @file Catches errors in child components. */ +import Offline from '#/assets/offline_filled.svg' import * as React from 'react' import * as sentry from '@sentry/react' @@ -7,8 +8,6 @@ import * as errorBoundary from 'react-error-boundary' import * as detect from 'enso-common/src/detect' -import * as offlineHooks from '#/hooks/offlineHooks' - import * as textProvider from '#/providers/TextProvider' import * as ariaComponents from '#/components/AriaComponents' @@ -16,6 +15,8 @@ import * as result from '#/components/Result' import { useEventCallback } from '#/hooks/eventCallbackHooks' import * as errorUtils from '#/utilities/error' +import { OfflineError } from '#/utilities/HttpClient' +import SvgMask from './SvgMask' // ===================== // === ErrorBoundary === @@ -38,7 +39,9 @@ export interface ErrorBoundaryProps > > { /** Called before the fallback is shown. */ - readonly onBeforeFallbackShown?: (args: OnBeforeFallbackShownArgs) => void + readonly onBeforeFallbackShown?: ( + args: OnBeforeFallbackShownArgs, + ) => React.ReactNode | null | undefined readonly title?: string readonly subtitle?: string } @@ -53,7 +56,7 @@ export function ErrorBoundary(props: ErrorBoundaryProps) { FallbackComponent = ErrorDisplay, onError = () => {}, onReset = () => {}, - onBeforeFallbackShown = () => {}, + onBeforeFallbackShown = () => null, title, subtitle, ...rest @@ -63,15 +66,19 @@ export function ErrorBoundary(props: ErrorBoundaryProps) { {({ reset }) => ( ( - - )} + FallbackComponent={(fallbackProps) => { + const displayMessage = errorUtils.extractDisplayMessage(fallbackProps.error) + + return ( + + ) + }} onError={(error, info) => { sentry.captureException(error, { extra: { info } }) onError(error, info) @@ -90,39 +97,52 @@ export function ErrorBoundary(props: ErrorBoundaryProps) { /** Props for a {@link ErrorDisplay}. */ export interface ErrorDisplayProps extends errorBoundary.FallbackProps { readonly status?: result.ResultProps['status'] - readonly onBeforeFallbackShown?: (args: OnBeforeFallbackShownArgs) => void + readonly onBeforeFallbackShown?: (args: OnBeforeFallbackShownArgs) => React.ReactNode | undefined readonly resetQueries?: () => void - readonly title?: string | undefined - readonly subtitle?: string | undefined + readonly title?: string | null | undefined + readonly subtitle?: string | null | undefined readonly error: unknown } /** Default fallback component to show when there is an error. */ export function ErrorDisplay(props: ErrorDisplayProps): React.JSX.Element { const { getText } = textProvider.useText() - const { isOffline } = offlineHooks.useOffline() const { error, resetErrorBoundary, - title = getText('somethingWentWrong'), - subtitle = isOffline ? getText('offlineErrorMessage') : getText('arbitraryErrorSubtitle'), - status = isOffline ? 'info' : 'error', + title, + subtitle, + status, onBeforeFallbackShown, resetQueries = () => {}, } = props + const isOfflineError = error instanceof OfflineError + const message = errorUtils.getMessageOrToString(error) const stack = errorUtils.tryGetStack(error) - onBeforeFallbackShown?.({ error, resetErrorBoundary, resetQueries }) + const render = onBeforeFallbackShown?.({ error, resetErrorBoundary, resetQueries }) const onReset = useEventCallback(() => { resetErrorBoundary() }) - return ( - + const finalTitle = title ?? getText('somethingWentWrong') + const finalSubtitle = + subtitle ?? + (isOfflineError ? getText('offlineErrorMessage') : getText('arbitraryErrorSubtitle')) + const finalStatus = + status ?? (isOfflineError ? : 'error') + + const defaultRender = ( + ) + + return <>{render ?? defaultRender} } export { useErrorBoundary, withErrorBoundary } from 'react-error-boundary' diff --git a/app/gui/src/dashboard/components/OfflineNotificationManager.tsx b/app/gui/src/dashboard/components/OfflineNotificationManager.tsx index 5776459e34cc..3f2b4377aca6 100644 --- a/app/gui/src/dashboard/components/OfflineNotificationManager.tsx +++ b/app/gui/src/dashboard/components/OfflineNotificationManager.tsx @@ -32,21 +32,24 @@ export function OfflineNotificationManager(props: OfflineNotificationManagerProp const toastId = 'offline' const { getText } = textProvider.useText() - offlineHooks.useOfflineChange((isOffline) => { - toast.toast.dismiss(toastId) - - if (isOffline) { - toast.toast.info(getText('offlineToastMessage'), { - toastId, - hideProgressBar: true, - }) - } else { - toast.toast.info(getText('onlineToastMessage'), { - toastId, - hideProgressBar: true, - }) - } - }) + offlineHooks.useOfflineChange( + (isOffline) => { + toast.toast.dismiss(toastId) + + if (isOffline) { + toast.toast.info(getText('offlineToastMessage'), { + toastId, + hideProgressBar: true, + }) + } else { + toast.toast.info(getText('onlineToastMessage'), { + toastId, + hideProgressBar: true, + }) + } + }, + { triggerImmediate: false }, + ) return ( diff --git a/app/gui/src/dashboard/components/Suspense.tsx b/app/gui/src/dashboard/components/Suspense.tsx index bf4342246a34..fd833df7fdce 100644 --- a/app/gui/src/dashboard/components/Suspense.tsx +++ b/app/gui/src/dashboard/components/Suspense.tsx @@ -7,26 +7,13 @@ 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' - -import * as result from '#/components/Result' - import * as loader from './Loader' /** Props for {@link Suspense} component. */ export interface SuspenseProps extends React.SuspenseProps { readonly loaderProps?: loader.LoaderProps - readonly offlineFallback?: React.ReactNode - 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. @@ -35,19 +22,10 @@ const OFFLINE_FETCHING_TOGGLE_DELAY_MS = 250 * And handles offline scenarios. */ export function Suspense(props: SuspenseProps) { - const { children, loaderProps, fallback, offlineFallback, offlineFallbackProps } = props + const { children, loaderProps, fallback } = props return ( - - } - > + }> {children} ) @@ -58,8 +36,6 @@ export function Suspense(props: SuspenseProps) { */ interface LoaderProps extends loader.LoaderProps { readonly fallback?: SuspenseProps['fallback'] - readonly offlineFallback?: SuspenseProps['offlineFallback'] - readonly offlineFallbackProps?: SuspenseProps['offlineFallbackProps'] } /** @@ -74,35 +50,7 @@ interface LoaderProps extends loader.LoaderProps { * we want to know if there are ongoing requests once React renders the fallback in suspense */ export function Loader(props: LoaderProps) { - const { fallback, offlineFallbackProps, offlineFallback, ...loaderProps } = props - - const { getText } = textProvider.useText() - - const { isOffline } = offlineHooks.useOffline() - - const paused = reactQuery.useIsFetching({ fetchStatus: 'paused' }) - - 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, - ) + const { fallback, ...loaderProps } = props - if (shouldDisplayOfflineMessage) { - return ( - offlineFallback ?? ( - - ) - ) - } else { - return fallback ?? - } + return fallback ?? } diff --git a/app/gui/src/dashboard/hooks/backendHooks.tsx b/app/gui/src/dashboard/hooks/backendHooks.tsx index fbed7fb15f63..6abe76bc0168 100644 --- a/app/gui/src/dashboard/hooks/backendHooks.tsx +++ b/app/gui/src/dashboard/hooks/backendHooks.tsx @@ -342,8 +342,6 @@ export function listDirectoryQueryOptions(options: ListDirectoryQueryOptions) { } } }, - - meta: { persist: false }, }) } diff --git a/app/gui/src/dashboard/hooks/offlineHooks.ts b/app/gui/src/dashboard/hooks/offlineHooks.ts index 0c2d509f57cd..c20f0c075891 100644 --- a/app/gui/src/dashboard/hooks/offlineHooks.ts +++ b/app/gui/src/dashboard/hooks/offlineHooks.ts @@ -10,7 +10,7 @@ export function useOffline() { const isOnline = React.useSyncExternalStore( reactQuery.onlineManager.subscribe.bind(reactQuery.onlineManager), () => reactQuery.onlineManager.isOnline(), - () => navigator.onLine, + () => false, ) return { isOffline: !isOnline } diff --git a/app/gui/src/dashboard/layouts/Drive.tsx b/app/gui/src/dashboard/layouts/Drive.tsx index 87a10d4bb2ec..e8310d846cf7 100644 --- a/app/gui/src/dashboard/layouts/Drive.tsx +++ b/app/gui/src/dashboard/layouts/Drive.tsx @@ -2,6 +2,7 @@ import * as React from 'react' import * as appUtils from '#/appUtils' +import Offline from '#/assets/offline_filled.svg' import * as offlineHooks from '#/hooks/offlineHooks' import * as toastAndLogHooks from '#/hooks/toastAndLogHooks' @@ -25,6 +26,7 @@ import * as ariaComponents from '#/components/AriaComponents' import * as result from '#/components/Result' import { ErrorBoundary, useErrorBoundary } from '#/components/ErrorBoundary' +import SvgMask from '#/components/SvgMask' import { listDirectoryQueryOptions } from '#/hooks/backendHooks' import { useEventCallback } from '#/hooks/eventCallbackHooks' import { useTargetDirectory } from '#/providers/DriveProvider' @@ -32,6 +34,7 @@ import { DirectoryDoesNotExistError, Plan } from '#/services/Backend' import AssetQuery from '#/utilities/AssetQuery' import * as download from '#/utilities/download' import * as github from '#/utilities/github' +import { OfflineError } from '#/utilities/HttpClient' import { tryFindSelfPermission } from '#/utilities/permissions' import * as tailwindMerge from '#/utilities/tailwindMerge' import { useQueryClient, useSuspenseQuery } from '@tanstack/react-query' @@ -58,7 +61,7 @@ const CATEGORIES_TO_DISPLAY_START_MODAL = ['cloud', 'local', 'local-directory'] /** Contains directory path and directory contents (projects, folders, secrets and files). */ function Drive(props: DriveProps) { - const { category, resetCategory } = props + const { category, resetCategory, setCategory } = props const { isOffline } = offlineHooks.useOffline() const toastAndLog = toastAndLogHooks.useToastAndLog() @@ -122,6 +125,18 @@ function Drive(props: DriveProps) { resetQueries() resetErrorBoundary() } + + if (error instanceof OfflineError) { + return ( + { + setCategory(nextCategory) + resetErrorBoundary() + }} + /> + ) + } }} > @@ -152,7 +167,6 @@ function DriveAssetsView(props: DriveProps) { const { user } = authProvider.useFullUserSession() const localBackend = backendProvider.useLocalBackend() const backend = backendProvider.useBackend(category) - const { getText } = textProvider.useText() const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent() const [query, setQuery] = React.useState(() => AssetQuery.fromString('')) @@ -263,26 +277,7 @@ function DriveAssetsView(props: DriveProps) { {status === 'offline' ? - - {supportLocalBackend && ( - { - setCategory({ type: 'local' }) - }} - > - {getText('switchToLocal')} - - )} - + : + ) diff --git a/app/gui/src/dashboard/modules/payments/components/StripeProvider.tsx b/app/gui/src/dashboard/modules/payments/components/StripeProvider.tsx index b13dceac4314..e0abfddefacc 100644 --- a/app/gui/src/dashboard/modules/payments/components/StripeProvider.tsx +++ b/app/gui/src/dashboard/modules/payments/components/StripeProvider.tsx @@ -6,6 +6,7 @@ import * as React from 'react' +import { OfflineError } from '#/utilities/error' import * as stripeReact from '@stripe/react-stripe-js' import type * as stripeTypes from '@stripe/stripe-js' import * as stripe from '@stripe/stripe-js/pure' @@ -23,27 +24,37 @@ export interface StripeProviderRenderProps { readonly elements: stripeTypes.StripeElements } -export const stripeQuery = reactQuery.queryOptions({ - queryKey: ['stripe', process.env.ENSO_CLOUD_STRIPE_KEY] as const, - staleTime: Infinity, - gcTime: Infinity, - meta: { persist: false }, - queryFn: async ({ queryKey }) => { - const stripeKey = queryKey[1] - - if (stripeKey == null) { - throw new Error('Stripe key not found') - } else { +/** + * Creates options for quering stripe instance + */ +export function stripeQueryOptions() { + return reactQuery.queryOptions({ + queryKey: ['stripe', process.env.ENSO_CLOUD_STRIPE_KEY] as const, + staleTime: Infinity, + gcTime: Infinity, + meta: { persist: false }, + queryFn: async ({ queryKey }) => { + const isOnline = reactQuery.onlineManager.isOnline() + const stripeKey = queryKey[1] + + if (stripeKey == null) { + throw new Error('Stripe key not found') + } + + if (!isOnline) { + throw new OfflineError() + } + return stripe.loadStripe(stripeKey).then((maybeStripeInstance) => { if (maybeStripeInstance == null) { throw new Error('Stripe instance not found') - } else { - return maybeStripeInstance } + + return maybeStripeInstance }) - } - }, -}) + }, + }) +} /** A component that provides a Stripe context. */ export function StripeProvider(props: StripeProviderProps) { @@ -90,5 +101,5 @@ export function useStripe() { * @returns The Stripe instance. */ export function useStripeLoader() { - return reactQuery.useSuspenseQuery(stripeQuery) + return reactQuery.useSuspenseQuery(stripeQueryOptions()) } diff --git a/app/gui/src/dashboard/providers/SessionProvider.tsx b/app/gui/src/dashboard/providers/SessionProvider.tsx index edb984da3d92..a3931a1c33e7 100644 --- a/app/gui/src/dashboard/providers/SessionProvider.tsx +++ b/app/gui/src/dashboard/providers/SessionProvider.tsx @@ -15,6 +15,7 @@ import * as errorModule from '#/utilities/error' import type * as cognito from '#/authentication/cognito' import * as listen from '#/authentication/listen' +import { useOffline } from '#/hooks/offlineHooks' import { useToastAndLog } from '#/hooks/toastAndLogHooks' // ====================== @@ -60,13 +61,7 @@ export interface SessionProviderProps { function createSessionQuery(userSession: (() => Promise) | null) { return reactQuery.queryOptions({ queryKey: ['userSession'], - queryFn: async () => { - const session = (await userSession?.().catch(() => null)) ?? null - return session - }, - refetchOnWindowFocus: 'always', - refetchOnMount: 'always', - refetchOnReconnect: 'always', + queryFn: () => userSession?.().catch(() => null) ?? null, }) } @@ -188,6 +183,8 @@ const SIX_HOURS_MS = 21_600_000 function SessionRefresher(props: SessionRefresherProps) { const { refreshUserSession, session } = props + const { isOffline } = useOffline() + reactQuery.useQuery({ queryKey: ['refreshUserSession', { refreshToken: session.refreshToken }] as const, queryFn: () => refreshUserSession(), @@ -199,6 +196,7 @@ function SessionRefresher(props: SessionRefresherProps) { refetchOnWindowFocus: 'always', refetchOnReconnect: 'always', refetchOnMount: 'always', + enabled: !isOffline, refetchInterval: () => { const expireAt = session.expireAt diff --git a/app/gui/src/dashboard/providers/__test__/SessionProvider.test.tsx b/app/gui/src/dashboard/providers/__test__/SessionProvider.test.tsx index 7869349cf630..6e4157c57da6 100644 --- a/app/gui/src/dashboard/providers/__test__/SessionProvider.test.tsx +++ b/app/gui/src/dashboard/providers/__test__/SessionProvider.test.tsx @@ -99,12 +99,14 @@ describe('SessionProvider', () => { , ) + expect(refreshUserSession).not.toBeCalled() + expect(userSession).toBeCalledTimes(2) + await waitFor(() => { expect(refreshUserSession).toBeCalledTimes(1) expect(screen.getByText(/Hello/)).toBeInTheDocument() - // 2 initial calls(fetching session and refreshing session), 1 mutation call, 1 re-fetch call - expect(userSession).toBeCalledTimes(4) + expect(userSession).toBeCalledTimes(3) }) }) diff --git a/app/gui/src/dashboard/utilities/HttpClient.ts b/app/gui/src/dashboard/utilities/HttpClient.ts index cf6eb2b23140..12978a037a95 100644 --- a/app/gui/src/dashboard/utilities/HttpClient.ts +++ b/app/gui/src/dashboard/utilities/HttpClient.ts @@ -1,5 +1,5 @@ /** @file HTTP client definition that includes default HTTP headers for all sent requests. */ -import isNetworkError from 'is-network-error' +import { NetworkError, OfflineError, isNetworkError } from './error' // ================= // === Constants === @@ -7,6 +7,7 @@ import isNetworkError from 'is-network-error' export const FETCH_SUCCESS_EVENT_NAME = 'fetch-success' export const FETCH_ERROR_EVENT_NAME = 'fetch-error' +export const OFFLINE_EVENT_NAME = 'offline' // ============= // === Types === @@ -138,6 +139,7 @@ export default class HttpClient { private async request(options: HttpClientRequestOptions) { const headers = new Headers(this.defaultHeaders) let payload = options.payload + if (payload != null) { const contentType = options.mimetype ?? 'application/json' headers.set('Content-Type', contentType) @@ -149,6 +151,10 @@ export default class HttpClient { payload = await payload.arrayBuffer() } + if (!navigator.onLine) { + return Promise.reject(new OfflineError('User is offline')) + } + try { // This is an UNSAFE type assertion, however this is a HTTP client // and should only be used to query APIs with known response types. @@ -162,10 +168,22 @@ export default class HttpClient { document.dispatchEvent(new Event(FETCH_SUCCESS_EVENT_NAME)) return response } catch (error) { + // Even though the condition might seem always falsy, + // offline mode might happen during the request + // and this case need to be handled + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!navigator.onLine) { + document.dispatchEvent(new Event(OFFLINE_EVENT_NAME)) + throw new OfflineError('User is offline', { cause: error }) + } + if (isNetworkError(error)) { document.dispatchEvent(new Event(FETCH_ERROR_EVENT_NAME)) + throw new NetworkError(error.message, { cause: error }) } throw error } } } + +export { NetworkError, OfflineError } diff --git a/app/gui/src/dashboard/utilities/error.ts b/app/gui/src/dashboard/utilities/error.ts index 1f9ee12384b2..758be2a8567a 100644 --- a/app/gui/src/dashboard/utilities/error.ts +++ b/app/gui/src/dashboard/utilities/error.ts @@ -1,7 +1,10 @@ /** @file Contains useful error types common across the module. */ +import { ErrorWithDisplayMessage } from 'enso-common/src/utilities/errors' import isNetworkErrorLib from 'is-network-error' import type * as toastify from 'react-toastify' +export * from 'enso-common/src/utilities/errors' + // ===================== // === tryGetMessage === // ===================== @@ -89,6 +92,18 @@ export function getMessageOrToString(error: MustNotBeKnown) { return tryGetMessage(error) ?? String(error) } +/** + * Extracts the display message from an error. + * This is the message that should be displayed to the user. + */ +export function extractDisplayMessage(error: MustNotBeKnown) { + if (error instanceof ErrorWithDisplayMessage) { + return error.displayMessage + } else { + return null + } +} + /** Return a toastify option object that renders an error message. */ export function render(f: (message: string) => string): toastify.UpdateOptions { return { render: ({ data }) => f(getMessageOrToString(data)) } @@ -173,7 +188,7 @@ export function isJSError(error: unknown): boolean { * Checks if the given error is a network error. * Wraps the `is-network-error` library to add additional network errors to the check. */ -export function isNetworkError(error: unknown): boolean { +export function isNetworkError(error: unknown): error is TypeError { const customNetworkErrors = new Set([ // aws amplify network error 'Network error',