From a6d040ecf54b59d774a607669b601fb3bc8d3574 Mon Sep 17 00:00:00 2001 From: Sergei Garin Date: Tue, 3 Dec 2024 16:06:15 +0300 Subject: [PATCH] Fix referesh Interval (#11732) 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 https://github.com/enso-org/enso/pull/11725 Closes: https://github.com/enso-org/cloud-v2/issues/1603 --- app/common/package.json | 1 - app/common/src/queryClient.ts | 64 ++++--- app/common/src/text/english.json | 2 + app/gui/package.json | 1 + app/gui/src/dashboard/App.tsx | 4 +- .../src/dashboard/authentication/cognito.ts | 4 + .../Form/components/FieldValue.tsx | 19 +- .../src/dashboard/layouts/ChatPlaceholder.tsx | 4 +- app/gui/src/dashboard/layouts/InfoBar.tsx | 5 +- .../src/dashboard/layouts/Settings/Tab.tsx | 7 +- .../dashboard/pages/authentication/Login.tsx | 58 +++--- .../providers/HttpClientProvider.tsx | 10 +- .../dashboard/providers/SessionProvider.tsx | 124 ++++++++----- .../__test__/SessionProvider.test.tsx | 168 ++++++++++++++++++ app/gui/src/dashboard/test/testUtils.tsx | 139 +++++++++++++-- app/gui/src/entrypoint.ts | 12 +- app/gui/tsconfig.app.vitest.json | 2 +- pnpm-lock.yaml | 6 +- 18 files changed, 501 insertions(+), 129 deletions(-) create mode 100644 app/gui/src/dashboard/providers/__test__/SessionProvider.test.tsx diff --git a/app/common/package.json b/app/common/package.json index 11bd4a59f39..a3bda304841 100644 --- a/app/common/package.json +++ b/app/common/package.json @@ -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", diff --git a/app/common/src/queryClient.ts b/app/common/src/queryClient.ts index 4ee695375a9..ab69795436d 100644 --- a/app/common/src/queryClient.ts +++ b/app/common/src/queryClient.ts @@ -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. */ @@ -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 { + readonly persisterStorage?: AsyncStorage & { + readonly clear: () => Promise + readonly serialize?: StoragePersisterOptions['serialize'] + readonly deserialize?: StoragePersisterOptions['deserialize'] + } +} + /** Create a new Tanstack Query client. */ -export function createQueryClient(): QueryClient { - const store = idbKeyval.createStore('enso', 'query-persist-cache') +export function createQueryClient( + options: QueryClientOptions = {}, +): QueryClient { + const { persisterStorage } = options + queryCore.onlineManager.setOnline(navigator.onLine) - const persister = persistClientCore.experimental_createPersister({ - storage: { - getItem: key => idbKeyval.get(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> | null = null + if (persisterStorage) { + persister = createPersister({ + 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({ @@ -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' && @@ -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, diff --git a/app/common/src/text/english.json b/app/common/src/text/english.json index 1e8274d3980..f83861dda92 100644 --- a/app/common/src/text/english.json +++ b/app/common/src/text/english.json @@ -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", diff --git a/app/gui/package.json b/app/gui/package.json index f5f08f886ef..fcaf938c4ee 100644 --- a/app/gui/package.json +++ b/app/gui/package.json @@ -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", diff --git a/app/gui/src/dashboard/App.tsx b/app/gui/src/dashboard/App.tsx index 4f2765589c8..d8e4e1cdecb 100644 --- a/app/gui/src/dashboard/App.tsx +++ b/app/gui/src/dashboard/App.tsx @@ -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' @@ -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() diff --git a/app/gui/src/dashboard/authentication/cognito.ts b/app/gui/src/dashboard/authentication/cognito.ts index 063b40fa497..3a5daf7a45f 100644 --- a/app/gui/src/dashboard/authentication/cognito.ts +++ b/app/gui/src/dashboard/authentication/cognito.ts @@ -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((resolve, reject) => { currentUser .unwrap() diff --git a/app/gui/src/dashboard/components/AriaComponents/Form/components/FieldValue.tsx b/app/gui/src/dashboard/components/AriaComponents/Form/components/FieldValue.tsx index c17a328036e..4f3639c3288 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Form/components/FieldValue.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Form/components/FieldValue.tsx @@ -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> { readonly form?: FormInstanceValidated readonly name: TFieldName - readonly children: (value: FieldValues[TFieldName]) => React.ReactNode + readonly children: (value: FieldValues[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>( props: FieldValueProps, ) { - 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 }) - return <>{children(value)} + // 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(deferredValue) } diff --git a/app/gui/src/dashboard/layouts/ChatPlaceholder.tsx b/app/gui/src/dashboard/layouts/ChatPlaceholder.tsx index becc67cdaf9..6122539a7d7 100644 --- a/app/gui/src/dashboard/layouts/ChatPlaceholder.tsx +++ b/app/gui/src/dashboard/layouts/ChatPlaceholder.tsx @@ -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() @@ -93,3 +93,5 @@ export default function ChatPlaceholder(props: ChatPlaceholderProps) { ) } } + +export default React.memo(ChatPlaceholder) diff --git a/app/gui/src/dashboard/layouts/InfoBar.tsx b/app/gui/src/dashboard/layouts/InfoBar.tsx index 5327730822b..0a09a6d488f 100644 --- a/app/gui/src/dashboard/layouts/InfoBar.tsx +++ b/app/gui/src/dashboard/layouts/InfoBar.tsx @@ -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 === @@ -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() @@ -66,3 +67,5 @@ export default function InfoBar(props: InfoBarProps) { ) } + +export default memo(InfoBar) diff --git a/app/gui/src/dashboard/layouts/Settings/Tab.tsx b/app/gui/src/dashboard/layouts/Settings/Tab.tsx index 0490dc557cb..abeedbf67ac 100644 --- a/app/gui/src/dashboard/layouts/Settings/Tab.tsx +++ b/app/gui/src/dashboard/layouts/Settings/Tab.tsx @@ -64,13 +64,16 @@ export default function SettingsTab(props: SettingsTabProps) { } else { const content = columns.length === 1 ? -
+
{sections.map((section) => ( ))}
:
{columns.map((sectionsInColumn, i) => ( diff --git a/app/gui/src/dashboard/pages/authentication/Login.tsx b/app/gui/src/dashboard/pages/authentication/Login.tsx index 3c375d9f964..da4a6bb761e 100644 --- a/app/gui/src/dashboard/pages/authentication/Login.tsx +++ b/app/gui/src/dashboard/pages/authentication/Login.tsx @@ -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' @@ -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' @@ -69,11 +69,10 @@ export default function Login() { }, }) - const [emailInput, setEmailInput] = useState(initialEmail) - const [user, setUser] = useState(null) - const localBackend = useLocalBackend() - const supportsOffline = localBackend != null + + const isElectron = isOnElectron() + const supportsOffline = isElectron const { nextStep, stepperState, previousStep } = Stepper.useStepperState({ steps: 2, @@ -93,17 +92,21 @@ export default function Login() { title={getText('loginToYourAccount')} supportsOffline={supportsOffline} footer={ - { - 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')} - /> + + {(email) => ( + { + const newQuery = new URLSearchParams({ email }).toString() + return isElectron ? + `https://${CLOUD_DASHBOARD_DOMAIN}${REGISTRATION_PATH}?${newQuery}` + : `${REGISTRATION_PATH}?${newQuery}` + })()} + icon={CreateAccountIcon} + text={getText('dontHaveAnAccount')} + /> + )} + } > null}> @@ -129,9 +132,6 @@ export default function Login() { autoComplete="email" icon={AtIcon} placeholder={getText('emailPlaceholder')} - onChange={(event) => { - setEmailInput(event.currentTarget.value) - }} />
@@ -146,14 +146,18 @@ export default function Login() { placeholder={getText('passwordPlaceholder')} /> - + + {(email) => ( + + )} +
diff --git a/app/gui/src/dashboard/providers/HttpClientProvider.tsx b/app/gui/src/dashboard/providers/HttpClientProvider.tsx index 28d5b6d5c79..2fa5c101fdc 100644 --- a/app/gui/src/dashboard/providers/HttpClientProvider.tsx +++ b/app/gui/src/dashboard/providers/HttpClientProvider.tsx @@ -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') diff --git a/app/gui/src/dashboard/providers/SessionProvider.tsx b/app/gui/src/dashboard/providers/SessionProvider.tsx index 708b1987a4c..edb984da3d9 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 { useToastAndLog } from '#/hooks/toastAndLogHooks' // ====================== // === SessionContext === @@ -52,19 +53,20 @@ export interface SessionProviderProps { readonly userSession: (() => Promise) | null readonly saveAccessToken?: ((accessToken: cognito.UserSession) => void) | null readonly refreshUserSession: (() => Promise) | null - readonly children: React.ReactNode + readonly children: React.ReactNode | ((props: SessionContextType) => React.ReactNode) } -const FIVE_MINUTES_MS = 300_000 -const SIX_HOURS_MS = 21_600_000 - /** Create a query for the user session. */ function createSessionQuery(userSession: (() => Promise) | null) { return reactQuery.queryOptions({ queryKey: ['userSession'], - queryFn: () => userSession?.().catch(() => null) ?? null, - refetchOnWindowFocus: true, - refetchIntervalInBackground: true, + queryFn: async () => { + const session = (await userSession?.().catch(() => null)) ?? null + return session + }, + refetchOnWindowFocus: 'always', + refetchOnMount: 'always', + refetchOnReconnect: 'always', }) } @@ -86,40 +88,31 @@ export default function SessionProvider(props: SessionProviderProps) { const httpClient = httpClientProvider.useHttpClient() const queryClient = reactQuery.useQueryClient() + const toastAndLog = useToastAndLog() const sessionQuery = createSessionQuery(userSession) const session = reactQuery.useSuspenseQuery(sessionQuery) - if (session.data) { - httpClient.setSessionToken(session.data.accessToken) - } - - const timeUntilRefresh = - session.data ? - // If the session has not expired, we should refresh it when it is 5 minutes from expiring. - new Date(session.data.expireAt).getTime() - Date.now() - FIVE_MINUTES_MS - : Infinity - const refreshUserSessionMutation = reactQuery.useMutation({ - mutationKey: ['refreshUserSession', session.data?.expireAt], - mutationFn: async () => refreshUserSession?.(), - meta: { invalidates: [sessionQuery.queryKey] }, + mutationKey: ['refreshUserSession', { expireAt: session.data?.expireAt }], + mutationFn: async () => refreshUserSession?.() ?? null, + onSuccess: (data) => { + if (data) { + httpClient?.setSessionToken(data.accessToken) + } + return queryClient.invalidateQueries({ queryKey: sessionQuery.queryKey }) + }, + onError: (error) => { + // Something went wrong with the refresh token, so we need to sign the user out. + toastAndLog('sessionExpiredError', error) + queryClient.setQueryData(sessionQuery.queryKey, null) + }, }) - reactQuery.useQuery({ - queryKey: ['refreshUserSession'], - queryFn: () => refreshUserSessionMutation.mutateAsync(), - meta: { persist: false }, - networkMode: 'online', - initialData: null, - initialDataUpdatedAt: Date.now(), - refetchOnWindowFocus: true, - refetchIntervalInBackground: true, - refetchInterval: timeUntilRefresh < SIX_HOURS_MS ? timeUntilRefresh : SIX_HOURS_MS, - // We don't want to refetch the session if the user is not authenticated - enabled: userSession != null && refreshUserSession != null && session.data != null, - }) + if (session.data) { + httpClient?.setSessionToken(session.data.accessToken) + } // Register an effect that will listen for authentication events. When the event occurs, we // will refresh or clear the user's session, forcing a re-render of the page with the new @@ -161,15 +154,67 @@ export default function SessionProvider(props: SessionProviderProps) { } }, [session.data, saveAccessTokenEventCallback]) + const sessionContextValue = { + session: session.data, + sessionQueryKey: sessionQuery.queryKey, + } + return ( - - {children} + + {session.data && ( + + )} + + {typeof children === 'function' ? children(sessionContextValue) : children} ) } +/** Props for a {@link SessionRefresher}. */ +interface SessionRefresherProps { + readonly session: cognito.UserSession + readonly refreshUserSession: () => Promise +} + +const TEN_SECONDS_MS = 10_000 +const SIX_HOURS_MS = 21_600_000 + +/** + * A component that will refresh the user's session at a given interval. + */ +function SessionRefresher(props: SessionRefresherProps) { + const { refreshUserSession, session } = props + + reactQuery.useQuery({ + queryKey: ['refreshUserSession', { refreshToken: session.refreshToken }] as const, + queryFn: () => refreshUserSession(), + meta: { persist: false }, + networkMode: 'online', + initialData: session, + initialDataUpdatedAt: Date.now(), + refetchIntervalInBackground: true, + refetchOnWindowFocus: 'always', + refetchOnReconnect: 'always', + refetchOnMount: 'always', + refetchInterval: () => { + const expireAt = session.expireAt + + const timeUntilRefresh = + // If the session has not expired, we should refresh it when it is 5 minutes from expiring. + // We use 1 second to ensure that we refresh even if the time is very close to expiring + // and value won't be less than 0. + Math.max(new Date(expireAt).getTime() - Date.now() - TEN_SECONDS_MS, TEN_SECONDS_MS) + + return timeUntilRefresh < SIX_HOURS_MS ? timeUntilRefresh : SIX_HOURS_MS + }, + }) + + return null +} + // ================== // === useSession === // ================== @@ -195,8 +240,5 @@ export function useSessionStrict() { invariant(session != null, 'Session must be defined') - return { - session, - sessionQueryKey, - } as const + return { session, sessionQueryKey } as const } diff --git a/app/gui/src/dashboard/providers/__test__/SessionProvider.test.tsx b/app/gui/src/dashboard/providers/__test__/SessionProvider.test.tsx new file mode 100644 index 00000000000..7869349cf63 --- /dev/null +++ b/app/gui/src/dashboard/providers/__test__/SessionProvider.test.tsx @@ -0,0 +1,168 @@ +import type { UserSession } from '#/authentication/cognito' +import { render, screen, waitFor } from '#/test' +import { Rfc3339DateTime } from '#/utilities/dateTime' +import HttpClient from '#/utilities/HttpClient' +import { Suspense } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { HttpClientProvider } from '../HttpClientProvider' +import SessionProvider from '../SessionProvider' + +describe('SessionProvider', () => { + const mainPageUrl = new URL('https://enso.dev') + const userSession = vi.fn<[], Promise>(() => + Promise.resolve({ + email: 'test@test.com', + accessToken: 'accessToken', + refreshToken: 'refreshToken', + refreshUrl: 'https://enso.dev', + expireAt: Rfc3339DateTime(new Date(Date.now() + 5_000).toJSON()), + clientId: 'clientId', + }), + ) + const refreshUserSession = vi.fn(() => Promise.resolve(null)) + const registerAuthEventListener = vi.fn() + const saveAccessToken = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('Should retrieve the user session', async () => { + const { getByText } = render( + Loading...
}> + +
Hello
+
+ , + ) + + expect(userSession).toBeCalled() + expect(getByText(/Loading/)).toBeInTheDocument() + + await waitFor(() => { + expect(getByText(/Hello/)).toBeInTheDocument() + }) + }) + + it('Should set the access token on the HTTP client', async () => { + const httpClient = new HttpClient() + + httpClient.setSessionToken = vi.fn() + + render( + Loading...
}> + + +
Hello
+
+
+ , + ) + + await waitFor(() => { + expect(httpClient.setSessionToken).toBeCalledWith('accessToken') + }) + }) + + it('Should refresh the expired user session', async () => { + userSession.mockReturnValueOnce( + Promise.resolve({ + ...(await userSession()), + // 24 hours from now + expireAt: Rfc3339DateTime(new Date(Date.now() - 1).toJSON()), + }), + ) + + render( + Loading...}> + +
Hello
+
+
, + ) + + 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) + }) + }) + + it('Should refresh not stale user session', { timeout: 5_000 }, async () => { + userSession.mockReturnValueOnce( + Promise.resolve({ + ...(await userSession()), + expireAt: Rfc3339DateTime(new Date(Date.now() + 1_500).toJSON()), + }), + ) + + let session: UserSession | null = null + + render( + Loading...}> + + {({ session: sessionFromContext }) => { + session = sessionFromContext + return null + }} + + , + ) + + await waitFor( + () => { + expect(refreshUserSession).toBeCalledTimes(1) + expect(session).not.toBeNull() + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + expect(new Date(session!.expireAt).getTime()).toBeGreaterThan(Date.now()) + }, + { timeout: 2_000 }, + ) + }) + + it('Should call registerAuthEventListener when the session is updated', async () => { + render( + Loading...}> + +
Hello
+
+
, + ) + + await waitFor(() => { + expect(registerAuthEventListener).toBeCalled() + }) + }) +}) diff --git a/app/gui/src/dashboard/test/testUtils.tsx b/app/gui/src/dashboard/test/testUtils.tsx index 962ecb61244..f3e3e5e9c1d 100644 --- a/app/gui/src/dashboard/test/testUtils.tsx +++ b/app/gui/src/dashboard/test/testUtils.tsx @@ -4,9 +4,11 @@ * **IMPORTANT**: This file is supposed to be used instead of `@testing-library/react` * It is used to provide a portal root and locale to all tests. */ +/// -import { Form, type FormProps, type TSchema } from '#/components/AriaComponents' +import { Form, type FormInstance, type FormProps, type TSchema } from '#/components/AriaComponents' import UIProviders from '#/components/UIProviders' +import { QueryClientProvider, type QueryClient } from '@tanstack/react-query' import { render, renderHook, @@ -15,7 +17,8 @@ import { type RenderOptions, type RenderResult, } from '@testing-library/react' -import { type PropsWithChildren, type ReactElement } from 'react' +import { createQueryClient } from 'enso-common/src/queryClient' +import { useState, type PropsWithChildren, type ReactElement, type ReactNode } from 'react' /** * A wrapper that passes through its children. @@ -27,11 +30,19 @@ function PassThroughWrapper({ children }: PropsWithChildren) { /** * A wrapper that provides the {@link UIProviders} context. */ -function UIProvidersWrapper({ children }: PropsWithChildren) { +function UIProvidersWrapper({ + children, +}: { + children?: ReactNode | ((props: { queryClient: QueryClient }) => ReactNode) +}) { + const [queryClient] = useState(() => createQueryClient()) + return ( - - {children} - + + + {typeof children === 'function' ? children({ queryClient }) : children} + + ) } @@ -44,20 +55,49 @@ function FormWrapper( return
} +/** + * Result type for {@link renderWithRoot}. + */ +interface RenderWithRootResult extends RenderResult { + readonly queryClient: QueryClient +} + /** * Custom render function for tests. */ -function renderWithRoot(ui: ReactElement, options?: Omit): RenderResult { +function renderWithRoot( + ui: ReactElement, + options?: Omit, +): RenderWithRootResult { const { wrapper: Wrapper = PassThroughWrapper, ...rest } = options ?? {} - return render(ui, { + let queryClient: QueryClient + + const result = render(ui, { wrapper: ({ children }) => ( - {children} + {({ queryClient: queryClientFromWrapper }) => { + queryClient = queryClientFromWrapper + return {children} + }} ), ...rest, }) + + return { + ...result, + // @ts-expect-error - This is safe because we render before returning the result, + // so the queryClient is guaranteed to be set. + queryClient, + } as const +} + +/** + * Result type for {@link renderWithForm}. + */ +interface RenderWithFormResult extends RenderWithRootResult { + readonly form: FormInstance } /** @@ -68,13 +108,36 @@ function renderWithForm( options: Omit & { formProps: FormProps }, -): RenderResult { +): RenderWithFormResult { const { formProps, ...rest } = options - return renderWithRoot(ui, { - wrapper: ({ children }) => {children}, + let form: FormInstance + + const result = renderWithRoot(ui, { + wrapper: ({ children }) => ( + + {({ form: formFromWrapper }) => { + form = formFromWrapper + return <>{children} + }} + + ), ...rest, }) + + return { + ...result, + // @ts-expect-error - This is safe because we render before returning the result, + // so the form is guaranteed to be set. + form, + } as const +} + +/** + * Result type for {@link renderHookWithRoot}. + */ +interface RenderHookWithRootResult extends RenderHookResult { + readonly queryClient: QueryClient } /** @@ -83,8 +146,35 @@ function renderWithForm( function renderHookWithRoot( hook: (props: Props) => Result, options?: Omit, 'queries'>, -): RenderHookResult { - return renderHook(hook, { wrapper: UIProvidersWrapper, ...options }) +): RenderHookWithRootResult { + let queryClient: QueryClient + + const result = renderHook(hook, { + wrapper: ({ children }) => ( + + {({ queryClient: queryClientFromWrapper }) => { + queryClient = queryClientFromWrapper + return <>{children} + }} + + ), + ...options, + }) + + return { + ...result, + // @ts-expect-error - This is safe because we render before returning the result, + // so the queryClient is guaranteed to be set. + queryClient, + } as const +} + +/** + * Result type for {@link renderHookWithForm}. + */ +interface RenderHookWithFormResult + extends RenderHookWithRootResult { + readonly form: FormInstance } /** @@ -95,13 +185,28 @@ function renderHookWithForm, 'queries' | 'wrapper'> & { formProps: FormProps }, -): RenderHookResult { +): RenderHookWithFormResult { const { formProps, ...rest } = options - return renderHookWithRoot(hook, { - wrapper: ({ children }) => {children}, + let form: FormInstance + const result = renderHookWithRoot(hook, { + wrapper: ({ children }) => ( + + {({ form: formFromWrapper }) => { + form = formFromWrapper + return <>{children} + }} + + ), ...rest, }) + + return { + ...result, + // @ts-expect-error - This is safe because we render before returning the result, + // so the form is guaranteed to be set. + form, + } as const } export * from '@testing-library/react' diff --git a/app/gui/src/entrypoint.ts b/app/gui/src/entrypoint.ts index 5f1f72b76b3..5d275f20b43 100644 --- a/app/gui/src/entrypoint.ts +++ b/app/gui/src/entrypoint.ts @@ -7,6 +7,7 @@ import { urlParams } from '@/util/urlParams' import * as vueQuery from '@tanstack/vue-query' import { isOnLinux } from 'enso-common/src/detect' import * as commonQuery from 'enso-common/src/queryClient' +import * as idbKeyval from 'idb-keyval' import { lazyVueInReact } from 'veaury' import { type App } from 'vue' @@ -85,7 +86,16 @@ function main() { const urlWithoutStartupProject = new URL(location.toString()) urlWithoutStartupProject.searchParams.delete('startup.project') history.replaceState(null, '', urlWithoutStartupProject) - const queryClient = commonQuery.createQueryClient() + + const store = idbKeyval.createStore('enso', 'query-persist-cache') + const queryClient = commonQuery.createQueryClient({ + persisterStorage: { + getItem: async (key) => idbKeyval.get(key, store), + setItem: async (key, value) => idbKeyval.set(key, value, store), + removeItem: async (key) => idbKeyval.del(key, store), + clear: async () => idbKeyval.clear(store), + }, + }) const registerPlugins = (app: App) => { app.use(vueQuery.VueQueryPlugin, { queryClient }) diff --git a/app/gui/tsconfig.app.vitest.json b/app/gui/tsconfig.app.vitest.json index 46d159a56a0..80fe2b1e134 100644 --- a/app/gui/tsconfig.app.vitest.json +++ b/app/gui/tsconfig.app.vitest.json @@ -2,6 +2,6 @@ "extends": "./tsconfig.app.json", "exclude": [], "compilerOptions": { - "types": ["node", "jsdom", "vitest/importMeta"] + "types": ["node", "jsdom", "vitest/importMeta", "@testing-library/jest-dom"] } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cad195f2a8f..ad0a15622c3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -85,9 +85,6 @@ importers: '@tanstack/vue-query': specifier: '>= 5.54.0 < 5.56.0' version: 5.54.2(vue@3.5.2(typescript@5.5.3)) - idb-keyval: - specifier: ^6.2.1 - version: 6.2.1 lib0: specifier: ^0.2.85 version: 0.2.94 @@ -235,6 +232,9 @@ importers: hash-sum: specifier: ^2.0.0 version: 2.0.0 + idb-keyval: + specifier: ^6.2.1 + version: 6.2.1 input-otp: specifier: 1.2.4 version: 1.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)