Skip to content

Commit

Permalink
Fix Offline Mode (#11887)
Browse files Browse the repository at this point in the history
Closes: enso-org/cloud-v2#1630

This PR disables the refreshing the session if user is offline
  • Loading branch information
MrFlashAccount authored Dec 22, 2024
1 parent dcc7595 commit 3932679
Show file tree
Hide file tree
Showing 22 changed files with 443 additions and 274 deletions.
1 change: 1 addition & 0 deletions app/common/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 4 additions & 1 deletion app/common/src/queryClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export function createQueryClient<TStorageValue = string>(
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:',
Expand Down Expand Up @@ -130,6 +130,9 @@ export function createQueryClient<TStorageValue = string>(
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) => {
Expand Down
9 changes: 6 additions & 3 deletions app/common/src/text/english.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
52 changes: 52 additions & 0 deletions app/common/src/utilities/errors.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
23 changes: 23 additions & 0 deletions app/gui/src/dashboard/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ===
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions app/gui/src/dashboard/assets/offline_filled.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions app/gui/src/dashboard/assets/offline_outline.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -22,7 +20,7 @@ export interface FormErrorProps extends Omit<reactAriaComponents.AlertProps, 'ch

/** Form error component. */
export function FormError(props: FormErrorProps) {
const { size = 'large', variant = 'error', rounded = 'large', ...alertProps } = props
const { size = 'large', variant = 'error', rounded = 'xxlarge', ...alertProps } = props

const form = formContext.useFormContext(props.form)
const { formState } = form
Expand Down Expand Up @@ -68,7 +66,13 @@ export function FormError(props: FormErrorProps) {

const offlineErrorAlert =
offlineMessage != null ?
<reactAriaComponents.Alert size={size} variant="outline" rounded={rounded} {...alertProps}>
<reactAriaComponents.Alert
size={size}
variant="outline"
rounded={rounded}
icon={Offline}
{...alertProps}
>
<reactAriaComponents.Text variant="body" truncate="3" color="primary">
{offlineMessage}
</reactAriaComponents.Text>
Expand Down
68 changes: 45 additions & 23 deletions app/gui/src/dashboard/components/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -7,15 +8,15 @@ 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'
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 ===
Expand All @@ -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
}
Expand All @@ -53,7 +56,7 @@ export function ErrorBoundary(props: ErrorBoundaryProps) {
FallbackComponent = ErrorDisplay,
onError = () => {},
onReset = () => {},
onBeforeFallbackShown = () => {},
onBeforeFallbackShown = () => null,
title,
subtitle,
...rest
Expand All @@ -63,15 +66,19 @@ export function ErrorBoundary(props: ErrorBoundaryProps) {
<reactQuery.QueryErrorResetBoundary>
{({ reset }) => (
<errorBoundary.ErrorBoundary
FallbackComponent={(fallbackProps) => (
<FallbackComponent
{...fallbackProps}
onBeforeFallbackShown={onBeforeFallbackShown}
resetQueries={reset}
title={title}
subtitle={subtitle}
/>
)}
FallbackComponent={(fallbackProps) => {
const displayMessage = errorUtils.extractDisplayMessage(fallbackProps.error)

return (
<FallbackComponent
{...fallbackProps}
onBeforeFallbackShown={onBeforeFallbackShown}
resetQueries={reset}
title={title}
subtitle={subtitle ?? displayMessage ?? null}
/>
)
}}
onError={(error, info) => {
sentry.captureException(error, { extra: { info } })
onError(error, info)
Expand All @@ -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 (
<result.Result className="h-full" status={status} title={title} subtitle={subtitle}>
const finalTitle = title ?? getText('somethingWentWrong')
const finalSubtitle =
subtitle ??
(isOfflineError ? getText('offlineErrorMessage') : getText('arbitraryErrorSubtitle'))
const finalStatus =
status ?? (isOfflineError ? <SvgMask src={Offline} className="aspect-square w-6" /> : 'error')

const defaultRender = (
<result.Result
className="h-full"
status={finalStatus}
title={finalTitle}
subtitle={finalSubtitle}
>
<ariaComponents.ButtonGroup align="center">
<ariaComponents.Button
variant="submit"
Expand Down Expand Up @@ -165,6 +185,8 @@ export function ErrorDisplay(props: ErrorDisplayProps): React.JSX.Element {
)}
</result.Result>
)

return <>{render ?? defaultRender}</>
}

export { useErrorBoundary, withErrorBoundary } from 'react-error-boundary'
33 changes: 18 additions & 15 deletions app/gui/src/dashboard/components/OfflineNotificationManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<OfflineNotificationManagerContext.Provider value={{ isNested: true, toastId }}>
Expand Down
Loading

0 comments on commit 3932679

Please sign in to comment.