diff --git a/web/packages/teleterm/src/ui/services/modals/modalsService.ts b/web/packages/teleterm/src/ui/services/modals/modalsService.ts index d457a99f076c8..cbb293b73caea 100644 --- a/web/packages/teleterm/src/ui/services/modals/modalsService.ts +++ b/web/packages/teleterm/src/ui/services/modals/modalsService.ts @@ -52,11 +52,13 @@ export class ModalsService extends ImmutableStore { * * Calling openRegularDialog while another regular dialog is displayed will simply overwrite the * old dialog with the new one. + * The old dialog is canceled, if possible. * * The returned closeDialog function can be used to close the dialog and automatically call the * dialog's onCancel callback (if present). */ openRegularDialog(dialog: Dialog): { closeDialog: () => void } { + this.state.regular['onCancel']?.(); this.setState(draftState => { draftState.regular = dialog; }); @@ -80,11 +82,13 @@ export class ModalsService extends ImmutableStore { * * Calling openImportantDialog while another important dialog is displayed will simply overwrite * the old dialog with the new one. + * The old dialog is canceled, if possible. * * The returned closeDialog function can be used to close the dialog and automatically call the * dialog's onCancel callback (if present). */ openImportantDialog(dialog: Dialog): { closeDialog: () => void } { + this.state.important['onCancel']?.(); this.setState(draftState => { draftState.important = dialog; }); diff --git a/web/packages/teleterm/src/ui/utils/retryWithRelogin.test.ts b/web/packages/teleterm/src/ui/utils/retryWithRelogin.test.ts index ec403000bba37..4096100f6ea36 100644 --- a/web/packages/teleterm/src/ui/utils/retryWithRelogin.test.ts +++ b/web/packages/teleterm/src/ui/utils/retryWithRelogin.test.ts @@ -16,6 +16,8 @@ * along with this program. If not, see . */ +import { waitFor } from '@testing-library/react'; + import { MockAppContext } from 'teleterm/ui/fixtures/mocks'; import Logger, { NullService } from 'teleterm/logger'; @@ -166,3 +168,72 @@ it('calls actionToRetry again if relogin attempt was canceled', async () => { expect(actionToRetry).toHaveBeenCalledTimes(2); expect(actualReturnValue).toEqual(expectedReturnValue); }); + +it('concurrent requests wait for the single login modal to resolve', async () => { + const appContext = new MockAppContext(); + + let logIn: () => void; + jest + .spyOn(appContext.modalsService, 'openRegularDialog') + .mockImplementation(dialog => { + if (dialog.kind === 'cluster-connect') { + logIn = () => dialog.onSuccess('/clusters/foo'); + } else { + throw new Error(`Got unexpected dialog ${dialog.kind}`); + } + + // Dialog cancel function. + return { + closeDialog: () => {}, + }; + }); + + jest + .spyOn(appContext.workspacesService, 'doesResourceBelongToActiveWorkspace') + .mockImplementation(() => true); + + const firstExpectedReturnValue = Symbol('firstExpectedReturnValue'); + const secondExpectedReturnValue = Symbol('secondExpectedReturnValue'); + const firstActionToRetry = jest + .fn() + .mockRejectedValueOnce(makeRetryableError()) + .mockResolvedValueOnce(firstExpectedReturnValue); + const secondActionToRetry = jest + .fn() + .mockRejectedValueOnce(makeRetryableError()) + .mockResolvedValueOnce(secondExpectedReturnValue); + + const firstAction = retryWithRelogin( + appContext, + '/clusters/foo/servers/bar', + firstActionToRetry + ); + const secondAction = retryWithRelogin( + appContext, + '/clusters/foo/servers/xyz', + secondActionToRetry + ); + + const openRegularDialogSpy = appContext.modalsService.openRegularDialog; + await waitFor(() => { + expect(openRegularDialogSpy).toHaveBeenCalledTimes(1); + }); + expect(openRegularDialogSpy).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'cluster-connect', + clusterUri: '/clusters/foo', + }) + ); + + logIn(); + + const firstActionExpectedReturnValue = await firstAction; + const secondActionExpectedReturnValue = await secondAction; + + expect(firstActionToRetry).toHaveBeenCalledTimes(2); + expect(secondActionToRetry).toHaveBeenCalledTimes(2); + expect(firstActionExpectedReturnValue).toEqual(firstExpectedReturnValue); + expect(secondActionExpectedReturnValue).toEqual(secondExpectedReturnValue); + + expect(openRegularDialogSpy).toHaveBeenCalledTimes(1); +}); diff --git a/web/packages/teleterm/src/ui/utils/retryWithRelogin.ts b/web/packages/teleterm/src/ui/utils/retryWithRelogin.ts index 5fcf4563764eb..5c05b7b0d733a 100644 --- a/web/packages/teleterm/src/ui/utils/retryWithRelogin.ts +++ b/web/packages/teleterm/src/ui/utils/retryWithRelogin.ts @@ -22,6 +22,8 @@ import Logger from 'teleterm/logger'; const logger = new Logger('retryWithRelogin'); +let pendingLoginDialog: Promise | undefined; + /** * `retryWithRelogin` executes `actionToRetry`. If `actionToRetry` throws an error, it checks if the * error can be resolved by the user logging in, according to metadata returned from the tshd @@ -31,6 +33,10 @@ const logger = new Logger('retryWithRelogin'); * argument) and if so, it shows a login modal. After the user successfully logs in, it calls * `actionToRetry` again. * + * `retryWithRelogin` supports concurrent requests. + * If multiple actions need to show a login modal at the same time, + * it will be displayed only once, and other actions will wait for it to be resolved. + * * Each place using `retryWithRelogin` must be able to show the error to the user in case the * relogin attempt fails. Each place should also offer the user a way to manually retry the action * which results in a call to the tshd client. @@ -75,7 +81,13 @@ export async function retryWithRelogin( const rootClusterUri = routing.ensureRootClusterUri(resourceUri); - await login(appContext, rootClusterUri); + if (!pendingLoginDialog) { + pendingLoginDialog = login(appContext, rootClusterUri).finally(() => { + pendingLoginDialog = undefined; + }); + } + + await pendingLoginDialog; return await actionToRetry(); }