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
This commit is contained in:
Sergei Garin 2024-12-03 16:06:15 +03:00 committed by GitHub
parent 4d13065d00
commit a6d040ecf5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 501 additions and 129 deletions

View File

@ -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",

View File

@ -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<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({
@ -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,

View File

@ -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",

View File

@ -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",

View File

@ -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()

View File

@ -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()

View File

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

View File

@ -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)

View File

@ -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) {
</FocusArea>
)
}
export default memo(InfoBar)

View File

@ -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) => (

View File

@ -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<CognitoUser | null>(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={
<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}>
@ -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">
@ -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>

View File

@ -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')

View File

@ -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<cognito.UserSession | null>) | null
readonly saveAccessToken?: ((accessToken: cognito.UserSession) => void) | null
readonly refreshUserSession: (() => Promise<cognito.UserSession | null>) | 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<cognito.UserSession | null>) | 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 (
<SessionContext.Provider
value={{ session: session.data, sessionQueryKey: sessionQuery.queryKey }}
>
{children}
<SessionContext.Provider value={sessionContextValue}>
{session.data && (
<SessionRefresher
session={session.data}
refreshUserSession={refreshUserSessionMutation.mutateAsync}
/>
)}
{typeof children === 'function' ? children(sessionContextValue) : children}
</SessionContext.Provider>
)
}
/** Props for a {@link SessionRefresher}. */
interface SessionRefresherProps {
readonly session: cognito.UserSession
readonly refreshUserSession: () => Promise<cognito.UserSession | null>
}
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
}

View File

@ -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<UserSession>>(() =>
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(
<Suspense fallback={<div>Loading...</div>}>
<SessionProvider
mainPageUrl={mainPageUrl}
userSession={userSession}
refreshUserSession={refreshUserSession}
registerAuthEventListener={registerAuthEventListener}
saveAccessToken={saveAccessToken}
>
<div>Hello</div>
</SessionProvider>
</Suspense>,
)
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(
<Suspense fallback={<div>Loading...</div>}>
<HttpClientProvider httpClient={httpClient}>
<SessionProvider
mainPageUrl={mainPageUrl}
userSession={userSession}
refreshUserSession={refreshUserSession}
registerAuthEventListener={registerAuthEventListener}
saveAccessToken={saveAccessToken}
>
<div>Hello</div>
</SessionProvider>
</HttpClientProvider>
</Suspense>,
)
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(
<Suspense fallback={<div>Loading...</div>}>
<SessionProvider
mainPageUrl={mainPageUrl}
userSession={userSession}
refreshUserSession={refreshUserSession}
registerAuthEventListener={registerAuthEventListener}
saveAccessToken={saveAccessToken}
>
<div>Hello</div>
</SessionProvider>
</Suspense>,
)
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(
<Suspense fallback={<div>Loading...</div>}>
<SessionProvider
mainPageUrl={mainPageUrl}
userSession={userSession}
refreshUserSession={refreshUserSession}
registerAuthEventListener={registerAuthEventListener}
saveAccessToken={saveAccessToken}
>
{({ session: sessionFromContext }) => {
session = sessionFromContext
return null
}}
</SessionProvider>
</Suspense>,
)
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(
<Suspense fallback={<div>Loading...</div>}>
<SessionProvider
mainPageUrl={mainPageUrl}
userSession={userSession}
refreshUserSession={refreshUserSession}
registerAuthEventListener={registerAuthEventListener}
saveAccessToken={saveAccessToken}
>
<div>Hello</div>
</SessionProvider>
</Suspense>,
)
await waitFor(() => {
expect(registerAuthEventListener).toBeCalled()
})
})
})

View File

@ -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.
*/
/// <reference types="@testing-library/jest-dom" />
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 (
<UIProviders portalRoot={document.body} locale="en">
{children}
</UIProviders>
<QueryClientProvider client={queryClient}>
<UIProviders portalRoot={document.body} locale="en">
{typeof children === 'function' ? children({ queryClient }) : children}
</UIProviders>
</QueryClientProvider>
)
}
@ -44,20 +55,49 @@ function FormWrapper<Schema extends TSchema, SubmitResult = void>(
return <Form {...props} />
}
/**
* Result type for {@link renderWithRoot}.
*/
interface RenderWithRootResult extends RenderResult {
readonly queryClient: QueryClient
}
/**
* Custom render function for tests.
*/
function renderWithRoot(ui: ReactElement, options?: Omit<RenderOptions, 'queries'>): RenderResult {
function renderWithRoot(
ui: ReactElement,
options?: Omit<RenderOptions, 'queries'>,
): RenderWithRootResult {
const { wrapper: Wrapper = PassThroughWrapper, ...rest } = options ?? {}
return render(ui, {
let queryClient: QueryClient
const result = render(ui, {
wrapper: ({ children }) => (
<UIProvidersWrapper>
<Wrapper>{children}</Wrapper>
{({ queryClient: queryClientFromWrapper }) => {
queryClient = queryClientFromWrapper
return <Wrapper>{children}</Wrapper>
}}
</UIProvidersWrapper>
),
...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<Schema extends TSchema> extends RenderWithRootResult {
readonly form: FormInstance<Schema>
}
/**
@ -68,13 +108,36 @@ function renderWithForm<Schema extends TSchema, SubmitResult = void>(
options: Omit<RenderOptions, 'queries' | 'wrapper'> & {
formProps: FormProps<Schema, SubmitResult>
},
): RenderResult {
): RenderWithFormResult<Schema> {
const { formProps, ...rest } = options
return renderWithRoot(ui, {
wrapper: ({ children }) => <FormWrapper {...formProps}>{children}</FormWrapper>,
let form: FormInstance<Schema>
const result = renderWithRoot(ui, {
wrapper: ({ children }) => (
<FormWrapper {...formProps}>
{({ form: formFromWrapper }) => {
form = formFromWrapper
return <>{children}</>
}}
</FormWrapper>
),
...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<Result, Props> extends RenderHookResult<Result, Props> {
readonly queryClient: QueryClient
}
/**
@ -83,8 +146,35 @@ function renderWithForm<Schema extends TSchema, SubmitResult = void>(
function renderHookWithRoot<Result, Props>(
hook: (props: Props) => Result,
options?: Omit<RenderHookOptions<Props>, 'queries'>,
): RenderHookResult<Result, Props> {
return renderHook(hook, { wrapper: UIProvidersWrapper, ...options })
): RenderHookWithRootResult<Result, Props> {
let queryClient: QueryClient
const result = renderHook(hook, {
wrapper: ({ children }) => (
<UIProvidersWrapper>
{({ queryClient: queryClientFromWrapper }) => {
queryClient = queryClientFromWrapper
return <>{children}</>
}}
</UIProvidersWrapper>
),
...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<Result, Props, Schema extends TSchema>
extends RenderHookWithRootResult<Result, Props> {
readonly form: FormInstance<Schema>
}
/**
@ -95,13 +185,28 @@ function renderHookWithForm<Result, Props, Schema extends TSchema, SubmitResult
options: Omit<RenderHookOptions<Props>, 'queries' | 'wrapper'> & {
formProps: FormProps<Schema, SubmitResult>
},
): RenderHookResult<Result, Props> {
): RenderHookWithFormResult<Result, Props, Schema> {
const { formProps, ...rest } = options
return renderHookWithRoot(hook, {
wrapper: ({ children }) => <FormWrapper {...formProps}>{children}</FormWrapper>,
let form: FormInstance<Schema>
const result = renderHookWithRoot(hook, {
wrapper: ({ children }) => (
<FormWrapper {...formProps}>
{({ form: formFromWrapper }) => {
form = formFromWrapper
return <>{children}</>
}}
</FormWrapper>
),
...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'

View File

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

View File

@ -2,6 +2,6 @@
"extends": "./tsconfig.app.json",
"exclude": [],
"compilerOptions": {
"types": ["node", "jsdom", "vitest/importMeta"]
"types": ["node", "jsdom", "vitest/importMeta", "@testing-library/jest-dom"]
}
}

View File

@ -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)