Skip to content

Commit

Permalink
Fix referesh Interval (#11732)
Browse files Browse the repository at this point in the history
This PR fixes issue when refetch didn't happen because the session either already expired or very close to expire

This PR fixes the reset interval when it's less that 5 mins or already expired

Based on #11725

Closes: enso-org/cloud-v2#1603
  • Loading branch information
MrFlashAccount authored Dec 3, 2024
1 parent 4d13065 commit a6d040e
Show file tree
Hide file tree
Showing 18 changed files with 501 additions and 129 deletions.
1 change: 0 additions & 1 deletion app/common/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@
"dependencies": {
"@tanstack/query-persist-client-core": "^5.54.0",
"@tanstack/vue-query": ">= 5.54.0 < 5.56.0",
"idb-keyval": "^6.2.1",
"lib0": "^0.2.85",
"react": "^18.3.1",
"vitest": "^1.3.1",
Expand Down
64 changes: 40 additions & 24 deletions app/common/src/queryClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
*/

import * as queryCore from '@tanstack/query-core'
import * as persistClientCore from '@tanstack/query-persist-client-core'
import type { AsyncStorage, StoragePersisterOptions } from '@tanstack/query-persist-client-core'
import { experimental_createPersister as createPersister } from '@tanstack/query-persist-client-core'
import * as vueQuery from '@tanstack/vue-query'
import * as idbKeyval from 'idb-keyval'

declare module '@tanstack/query-core' {
/** Query client with additional methods. */
Expand Down Expand Up @@ -61,26 +61,38 @@ const DEFAULT_QUERY_PERSIST_TIME_MS = 30 * 24 * 60 * 60 * 1000 // 30 days

const DEFAULT_BUSTER = 'v1.1'

export interface QueryClientOptions<TStorageValue = string> {
readonly persisterStorage?: AsyncStorage<TStorageValue> & {
readonly clear: () => Promise<void>
readonly serialize?: StoragePersisterOptions<TStorageValue>['serialize']
readonly deserialize?: StoragePersisterOptions<TStorageValue>['deserialize']
}
}

/** Create a new Tanstack Query client. */
export function createQueryClient(): QueryClient {
const store = idbKeyval.createStore('enso', 'query-persist-cache')
export function createQueryClient<TStorageValue = string>(
options: QueryClientOptions<TStorageValue> = {},
): QueryClient {
const { persisterStorage } = options

queryCore.onlineManager.setOnline(navigator.onLine)

const persister = persistClientCore.experimental_createPersister({
storage: {
getItem: key => idbKeyval.get<persistClientCore.PersistedQuery>(key, store),
setItem: (key, value) => idbKeyval.set(key, value, store),
removeItem: key => idbKeyval.del(key, store),
},
// 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,
buster: DEFAULT_BUSTER,
filters: { predicate: query => query.meta?.persist !== false },
prefix: 'enso:query-persist:',
serialize: persistedQuery => persistedQuery,
deserialize: persistedQuery => persistedQuery,
})
let persister: ReturnType<typeof createPersister<TStorageValue>> | null = null
if (persisterStorage) {
persister = createPersister<TStorageValue>({
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,
buster: DEFAULT_BUSTER,
filters: { predicate: query => query.meta?.persist !== false },
prefix: 'enso:query-persist:',
...(persisterStorage.serialize != null ? { serialize: persisterStorage.serialize } : {}),
...(persisterStorage.deserialize != null ?
{ deserialize: persisterStorage.deserialize }
: {}),
})
}

const queryClient: QueryClient = new vueQuery.QueryClient({
mutationCache: new queryCore.MutationCache({
Expand Down Expand Up @@ -117,11 +129,11 @@ export function createQueryClient(): QueryClient {
}),
defaultOptions: {
queries: {
persister,
...(persister != null ? { persister } : {}),
refetchOnReconnect: 'always',
staleTime: DEFAULT_QUERY_STALE_TIME_MS,
retry: (failureCount, error: unknown) => {
const statusesToIgnore = [401, 403, 404]
const statusesToIgnore = [403, 404]
const errorStatus =
(
typeof error === 'object' &&
Expand All @@ -132,18 +144,22 @@ export function createQueryClient(): QueryClient {
error.status
: -1

if (errorStatus === 401) {
return true
}

if (statusesToIgnore.includes(errorStatus)) {
return false
} else {
return failureCount < 3
}

return failureCount < 3
},
},
},
})

Object.defineProperty(queryClient, 'nukePersister', {
value: () => idbKeyval.clear(store),
value: () => persisterStorage?.clear(),
enumerable: false,
configurable: false,
writable: false,
Expand Down
2 changes: 2 additions & 0 deletions app/common/src/text/english.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@
"passwordValidationMessage": "Your password must include numbers, letters (both lowercase and uppercase) and symbols ( ^$*.[]{}()?\"!@#%&/,><':;|_~`=+-).",
"passwordValidationError": "Your password does not meet the security requirements.",

"sessionExpiredError": "Your session has expired. Please sign in again.",

"confirmSignUpError": "Incorrect email or confirmation code.",
"confirmRegistration": "Sign Up Successful!",
"confirmRegistrationSubtitle": "Please confirm your email to complete registration",
Expand Down
1 change: 1 addition & 0 deletions app/gui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
"clsx": "^2.1.1",
"enso-common": "workspace:*",
"framer-motion": "11.3.0",
"idb-keyval": "^6.2.1",
"input-otp": "1.2.4",
"is-network-error": "^1.0.1",
"monaco-editor": "0.48.0",
Expand Down
4 changes: 2 additions & 2 deletions app/gui/src/dashboard/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ import * as inputBindingsModule from '#/configurations/inputBindings'
import AuthProvider, * as authProvider from '#/providers/AuthProvider'
import BackendProvider, { useLocalBackend } from '#/providers/BackendProvider'
import DriveProvider from '#/providers/DriveProvider'
import { useHttpClient } from '#/providers/HttpClientProvider'
import { useHttpClientStrict } from '#/providers/HttpClientProvider'
import InputBindingsProvider from '#/providers/InputBindingsProvider'
import LocalStorageProvider, * as localStorageProvider from '#/providers/LocalStorageProvider'
import { useLogger } from '#/providers/LoggerProvider'
Expand Down Expand Up @@ -285,7 +285,7 @@ export interface AppRouterProps extends AppProps {
function AppRouter(props: AppRouterProps) {
const { isAuthenticationDisabled, shouldShowDashboard } = props
const { onAuthenticated, projectManagerInstance } = props
const httpClient = useHttpClient()
const httpClient = useHttpClientStrict()
const logger = useLogger()
const navigate = router.useNavigate()

Expand Down
4 changes: 4 additions & 0 deletions app/gui/src/dashboard/authentication/cognito.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,10 @@ export class Cognito {
const currentUser = await currentAuthenticatedUser()
const refreshToken = (await amplify.Auth.currentSession()).getRefreshToken()

if (refreshToken.getToken() === '') {
throw new Error('Refresh token is empty, cannot refresh session, Please sign in again.')
}

return await new Promise<cognito.CognitoUserSession>((resolve, reject) => {
currentUser
.unwrap()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,31 +1,36 @@
/**
* @file
*
* Component that passes the value of a field to its children.
*/
import { useDeferredValue, type ReactNode } from 'react'
import { useWatch } from 'react-hook-form'
import { useFormContext } from './FormProvider'
import type { FieldPath, FieldValues, FormInstanceValidated, TSchema } from './types'

/**
*
* Props for the {@link FieldValue} component.
*/
export interface FieldValueProps<Schema extends TSchema, TFieldName extends FieldPath<Schema>> {
readonly form?: FormInstanceValidated<Schema>
readonly name: TFieldName
readonly children: (value: FieldValues<Schema>[TFieldName]) => React.ReactNode
readonly children: (value: FieldValues<Schema>[TFieldName]) => ReactNode
readonly disabled?: boolean
}

/**
* Component that passes the value of a field to its children.
* Component that subscribes to the value of a field.
*/
export function FieldValue<Schema extends TSchema, TFieldName extends FieldPath<Schema>>(
props: FieldValueProps<Schema, TFieldName>,
) {
const { form, name, children } = props
const { form, name, children, disabled = false } = props

const formInstance = useFormContext(form)
const value = useWatch({ control: formInstance.control, name })
const watchValue = useWatch({ control: formInstance.control, name, disabled })

// We use deferred value here to rate limit the re-renders of the children.
// This is useful when the children are expensive to render, such as a component tree.
const deferredValue = useDeferredValue(watchValue)

return <>{children(value)}</>
return children(deferredValue)
}
4 changes: 3 additions & 1 deletion app/gui/src/dashboard/layouts/ChatPlaceholder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export interface ChatPlaceholderProps {
}

/** A placeholder component replacing `Chat` when a user is not logged in. */
export default function ChatPlaceholder(props: ChatPlaceholderProps) {
function ChatPlaceholder(props: ChatPlaceholderProps) {
const { hideLoginButtons = false, isOpen, doClose } = props
const { getText } = textProvider.useText()
const logger = loggerProvider.useLogger()
Expand Down Expand Up @@ -93,3 +93,5 @@ export default function ChatPlaceholder(props: ChatPlaceholderProps) {
)
}
}

export default React.memo(ChatPlaceholder)
5 changes: 4 additions & 1 deletion app/gui/src/dashboard/layouts/InfoBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import FocusArea from '#/components/styled/FocusArea'
import SvgMask from '#/components/SvgMask'
import InfoMenu from '#/layouts/InfoMenu'
import { useText } from '#/providers/TextProvider'
import { memo } from 'react'

// ===============
// === InfoBar ===
Expand All @@ -18,7 +19,7 @@ export interface InfoBarProps {
}

/** A toolbar containing chat and the user menu. */
export default function InfoBar(props: InfoBarProps) {
function InfoBar(props: InfoBarProps) {
const { isHelpChatOpen, setIsHelpChatOpen } = props
const { getText } = useText()

Expand Down Expand Up @@ -66,3 +67,5 @@ export default function InfoBar(props: InfoBarProps) {
</FocusArea>
)
}

export default memo(InfoBar)
7 changes: 5 additions & 2 deletions app/gui/src/dashboard/layouts/Settings/Tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,16 @@ export default function SettingsTab(props: SettingsTabProps) {
} else {
const content =
columns.length === 1 ?
<div className={twMerge('flex grow flex-col gap-8', classes[0])} {...contentProps}>
<div
className={twMerge('flex max-w-[512px] grow flex-col gap-8', classes[0])}
{...contentProps}
>
{sections.map((section) => (
<SettingsSection key={section.nameId} context={context} data={section} />
))}
</div>
: <div
className="grid min-h-full grow grid-cols-1 gap-8 lg:h-auto lg:grid-cols-2"
className="grid min-h-full max-w-[1024px] grow grid-cols-1 gap-8 lg:h-auto lg:grid-cols-2"
{...contentProps}
>
{columns.map((sectionsInColumn, i) => (
Expand Down
58 changes: 31 additions & 27 deletions app/gui/src/dashboard/pages/authentication/Login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import * as router from 'react-router-dom'

import { CLOUD_DASHBOARD_DOMAIN } from 'enso-common'
import { isOnElectron } from 'enso-common/src/detect'

import { DASHBOARD_PATH, FORGOT_PASSWORD_PATH, REGISTRATION_PATH } from '#/appUtils'
import ArrowRightIcon from '#/assets/arrow_right.svg'
Expand All @@ -18,7 +19,6 @@ import { useEventCallback } from '#/hooks/eventCallbackHooks'
import AuthenticationPage from '#/pages/authentication/AuthenticationPage'
import { passwordSchema } from '#/pages/authentication/schemas'
import { useAuth } from '#/providers/AuthProvider'
import { useLocalBackend } from '#/providers/BackendProvider'
import { useText } from '#/providers/TextProvider'
import { useState } from 'react'

Expand Down Expand Up @@ -69,11 +69,10 @@ export default function Login() {
},
})

const [emailInput, setEmailInput] = useState(initialEmail)

const [user, setUser] = useState<CognitoUser | null>(null)
const localBackend = useLocalBackend()
const supportsOffline = localBackend != null

const isElectron = isOnElectron()
const supportsOffline = isElectron

const { nextStep, stepperState, previousStep } = Stepper.useStepperState({
steps: 2,
Expand All @@ -93,17 +92,21 @@ export default function Login() {
title={getText('loginToYourAccount')}
supportsOffline={supportsOffline}
footer={
<Link
openInBrowser={localBackend != null}
to={(() => {
const newQuery = new URLSearchParams({ email: emailInput }).toString()
return localBackend != null ?
`https://${CLOUD_DASHBOARD_DOMAIN}${REGISTRATION_PATH}?${newQuery}`
: `${REGISTRATION_PATH}?${newQuery}`
})()}
icon={CreateAccountIcon}
text={getText('dontHaveAnAccount')}
/>
<Form.FieldValue form={form} name="email">
{(email) => (
<Link
openInBrowser={isElectron}
to={(() => {
const newQuery = new URLSearchParams({ email }).toString()
return isElectron ?
`https://${CLOUD_DASHBOARD_DOMAIN}${REGISTRATION_PATH}?${newQuery}`
: `${REGISTRATION_PATH}?${newQuery}`
})()}
icon={CreateAccountIcon}
text={getText('dontHaveAnAccount')}
/>
)}
</Form.FieldValue>
}
>
<Stepper state={stepperState} renderStep={() => null}>
Expand All @@ -129,9 +132,6 @@ export default function Login() {
autoComplete="email"
icon={AtIcon}
placeholder={getText('emailPlaceholder')}
onChange={(event) => {
setEmailInput(event.currentTarget.value)
}}
/>

<div className="flex w-full flex-col">
Expand All @@ -146,14 +146,18 @@ export default function Login() {
placeholder={getText('passwordPlaceholder')}
/>

<Button
variant="link"
href={`${FORGOT_PASSWORD_PATH}?${new URLSearchParams({ email: emailInput }).toString()}`}
size="small"
className="self-end"
>
{getText('forgotYourPassword')}
</Button>
<Form.FieldValue form={form} name="email">
{(email) => (
<Button
variant="link"
href={`${FORGOT_PASSWORD_PATH}?${new URLSearchParams({ email }).toString()}`}
size="small"
className="self-end"
>
{getText('forgotYourPassword')}
</Button>
)}
</Form.FieldValue>
</div>

<Form.Submit size="large" icon={ArrowRightIcon} iconPosition="end" fullWidth>
Expand Down
10 changes: 9 additions & 1 deletion app/gui/src/dashboard/providers/HttpClientProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,15 @@ export function HttpClientProvider(props: HttpClientProviderProps) {

/** Returns the HTTP client. */
export function useHttpClient() {
const httpClient = React.useContext(HTTPClientContext)
return React.useContext(HTTPClientContext)
}

/**
* Returns the HTTP client.
* @throws If the HTTP client is not found in context.
*/
export function useHttpClientStrict() {
const httpClient = useHttpClient()

invariant(httpClient, 'HTTP client not found in context')

Expand Down
Loading

0 comments on commit a6d040e

Please sign in to comment.