fix: refreshing statuses
Signed-off-by: Camila Belo <camilaibs@gmail.com>
This commit is contained in:
@@ -13,28 +13,20 @@ export function CookieAuthRefreshProvider(
|
||||
// @public
|
||||
export type CookieAuthRefreshProviderProps = {
|
||||
pluginId: string;
|
||||
options?: {
|
||||
path?: string;
|
||||
};
|
||||
path?: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
// @public
|
||||
export function useCookieAuthRefresh(params: {
|
||||
export function useCookieAuthRefresh(options: {
|
||||
pluginId: string;
|
||||
options?: {
|
||||
path?: string;
|
||||
};
|
||||
path?: string;
|
||||
}): {
|
||||
loading: boolean;
|
||||
error: Error | undefined;
|
||||
value:
|
||||
| {
|
||||
expiresAt: string;
|
||||
}
|
||||
| undefined;
|
||||
retry: (...args: unknown[]) => Promise<{
|
||||
status: 'loading' | 'error' | 'success';
|
||||
error?: Error;
|
||||
result?: {
|
||||
expiresAt: string;
|
||||
}>;
|
||||
};
|
||||
retry: () => void;
|
||||
};
|
||||
```
|
||||
|
||||
+6
-9
@@ -27,11 +27,8 @@ import { useCookieAuthRefresh } from '../../hooks';
|
||||
export type CookieAuthRefreshProviderProps = {
|
||||
// The plugin ID to used for discovering the API origin
|
||||
pluginId: string;
|
||||
// Options for configuring the refresh cookie endpoint
|
||||
options?: {
|
||||
// The path to used for calling the refresh cookie endpoint, default to '/cookie'
|
||||
path?: string;
|
||||
};
|
||||
// The path to used for calling the refresh cookie endpoint, default to '/cookie'
|
||||
path?: string;
|
||||
// The children to render when the refresh is successful
|
||||
children: ReactNode;
|
||||
};
|
||||
@@ -43,17 +40,17 @@ export type CookieAuthRefreshProviderProps = {
|
||||
export function CookieAuthRefreshProvider(
|
||||
props: CookieAuthRefreshProviderProps,
|
||||
): JSX.Element {
|
||||
const { children, ...params } = props;
|
||||
const { children, ...options } = props;
|
||||
const app = useApp();
|
||||
const { Progress } = app.getComponents();
|
||||
|
||||
const { loading, error, retry } = useCookieAuthRefresh(params);
|
||||
const { status, error, retry } = useCookieAuthRefresh(options);
|
||||
|
||||
if (loading) {
|
||||
if (status === 'loading') {
|
||||
return <Progress />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
if (status === 'error' && error) {
|
||||
return (
|
||||
<ErrorPanel error={error}>
|
||||
<Button variant="outlined" onClick={retry}>
|
||||
|
||||
@@ -54,7 +54,9 @@ describe('useCookieAuthRefresh', () => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should return a loading status when the refresh is in progress', () => {
|
||||
it('should return a loading status when the refresh is in progress first time', () => {
|
||||
const error = new Error('Failed to get cookie');
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useCookieAuthRefresh({ pluginId: 'techdocs' }),
|
||||
{
|
||||
@@ -64,7 +66,7 @@ describe('useCookieAuthRefresh', () => {
|
||||
[
|
||||
fetchApiRef,
|
||||
{
|
||||
fetch: jest.fn(),
|
||||
fetch: jest.fn().mockRejectedValue(error),
|
||||
},
|
||||
],
|
||||
[storageApiRef, storageApiMock],
|
||||
@@ -77,7 +79,105 @@ describe('useCookieAuthRefresh', () => {
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.current.loading).toBeTruthy();
|
||||
expect(result.current.status).toBe('loading');
|
||||
expect(result.current.error).toBeUndefined();
|
||||
expect(result.current.result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return a loading status when retrying without previous success', async () => {
|
||||
const error = new Error('Failed to get cookie');
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useCookieAuthRefresh({ pluginId: 'techdocs' }),
|
||||
{
|
||||
wrapper: ({ children }) => (
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[
|
||||
fetchApiRef,
|
||||
{
|
||||
fetch: jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(error)
|
||||
.mockReturnValue(new Promise(() => {})),
|
||||
},
|
||||
],
|
||||
[storageApiRef, storageApiMock],
|
||||
[discoveryApiRef, discoveryApiMock],
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</TestApiProvider>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.current.status).toBe('loading');
|
||||
expect(result.current.error).toBeUndefined();
|
||||
expect(result.current.result).toBeUndefined();
|
||||
|
||||
await waitFor(() => expect(result.current.status).toBe('error'));
|
||||
expect(result.current.result).toBeUndefined();
|
||||
expect(result.current.error).toStrictEqual(error);
|
||||
|
||||
result.current.retry();
|
||||
|
||||
await waitFor(() => expect(result.current.status).toBe('loading'));
|
||||
expect(result.current.result).toBeUndefined();
|
||||
expect(result.current.error).toStrictEqual(error);
|
||||
});
|
||||
|
||||
it('should return a loading status when retrying with previous success', async () => {
|
||||
const error = new Error('Failed to get cookie');
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useCookieAuthRefresh({ pluginId: 'techdocs' }),
|
||||
{
|
||||
wrapper: ({ children }) => (
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[
|
||||
fetchApiRef,
|
||||
{
|
||||
fetch: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: jest.fn().mockResolvedValue({ expiresAt }),
|
||||
})
|
||||
.mockRejectedValueOnce(error)
|
||||
.mockReturnValue(new Promise(() => {})),
|
||||
},
|
||||
],
|
||||
[storageApiRef, storageApiMock],
|
||||
[discoveryApiRef, discoveryApiMock],
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</TestApiProvider>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.current.status).toBe('loading');
|
||||
expect(result.current.error).toBeUndefined();
|
||||
expect(result.current.result).toBeUndefined();
|
||||
|
||||
await waitFor(() => expect(result.current.status).toBe('success'));
|
||||
expect(result.current.result).toMatchObject({ expiresAt });
|
||||
expect(result.current.error).toBeUndefined();
|
||||
|
||||
result.current.retry();
|
||||
|
||||
await waitFor(() => expect(result.current.status).toBe('error'));
|
||||
expect(result.current.result).toMatchObject({ expiresAt });
|
||||
expect(result.current.error).toStrictEqual(error);
|
||||
|
||||
result.current.retry();
|
||||
|
||||
await waitFor(() => expect(result.current.status).toBe('loading'));
|
||||
expect(result.current.result).toMatchObject({ expiresAt });
|
||||
expect(result.current.error).toStrictEqual(error);
|
||||
});
|
||||
|
||||
it('should return an error status when the refresh has failed', async () => {
|
||||
@@ -133,7 +233,7 @@ describe('useCookieAuthRefresh', () => {
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current.value).toMatchObject({ expiresAt });
|
||||
expect(result.current.result).toMatchObject({ expiresAt });
|
||||
});
|
||||
|
||||
it('should cancel the refresh when a message is received from another tab', async () => {
|
||||
@@ -197,7 +297,7 @@ describe('useCookieAuthRefresh', () => {
|
||||
|
||||
await waitFor(() => expect(fetchApiMock.fetch).toHaveBeenCalledTimes(1));
|
||||
|
||||
expect(result.current.value).toMatchObject({ expiresAt });
|
||||
expect(result.current.result).toMatchObject({ expiresAt });
|
||||
|
||||
unmount();
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useEffect, useCallback } from 'react';
|
||||
import { useEffect, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
discoveryApiRef,
|
||||
fetchApiRef,
|
||||
@@ -27,18 +27,22 @@ import { ResponseError } from '@backstage/errors';
|
||||
/**
|
||||
* @public
|
||||
* A hook that will refresh the cookie when it is about to expire.
|
||||
* @param options - Options for configuring the refresh cookie endpoint
|
||||
*/
|
||||
export function useCookieAuthRefresh(params: {
|
||||
// The plugin ID to used for discovering the API origin
|
||||
export function useCookieAuthRefresh(options: {
|
||||
// The plugin id to used for discovering the API origin
|
||||
pluginId: string;
|
||||
// Options for configuring the refresh cookie endpoint
|
||||
options?: {
|
||||
// The path to used for calling the refresh cookie endpoint, default to '/cookie'
|
||||
path?: string;
|
||||
// The path to used for calling the refresh cookie endpoint, default to '/cookie'
|
||||
path?: string;
|
||||
}): {
|
||||
status: 'loading' | 'error' | 'success';
|
||||
error?: Error;
|
||||
result?: {
|
||||
expiresAt: string;
|
||||
};
|
||||
}) {
|
||||
const { pluginId, options: { path = '/cookie' } = {} } = params;
|
||||
|
||||
retry: () => void;
|
||||
} {
|
||||
const { pluginId, path = '/cookie' } = options ?? {};
|
||||
const fetchApi = useApi(fetchApiRef);
|
||||
const storageApi = useApi(storageApiRef);
|
||||
const discoveryApi = useApi(discoveryApiRef);
|
||||
@@ -60,10 +64,10 @@ export function useCookieAuthRefresh(params: {
|
||||
useMountEffect(actions.execute);
|
||||
|
||||
const refresh = useCallback(
|
||||
(options: { expiresAt: string }) => {
|
||||
(params: { expiresAt: string }) => {
|
||||
// Randomize the refreshing margin to avoid all tabs refreshing at the same time
|
||||
const margin = (1 + 3 * Math.random()) * 60000;
|
||||
const delay = Date.parse(options.expiresAt) - Date.now() - margin;
|
||||
const delay = Date.parse(params.expiresAt) - Date.now() - margin;
|
||||
const timeout = setTimeout(actions.execute, delay);
|
||||
return () => clearTimeout(timeout);
|
||||
},
|
||||
@@ -71,7 +75,10 @@ export function useCookieAuthRefresh(params: {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!state.result) return () => {};
|
||||
// Only start the refresh process if we have a successful response
|
||||
if (state.status !== 'success' || !state.result) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
store.set('expiresAt', state.result.expiresAt);
|
||||
|
||||
@@ -90,10 +97,43 @@ export function useCookieAuthRefresh(params: {
|
||||
};
|
||||
}, [state, refresh, store]);
|
||||
|
||||
const status = useMemo(() => {
|
||||
// Initialising
|
||||
if (state.status === 'not-executed') {
|
||||
return 'loading';
|
||||
}
|
||||
|
||||
// First refresh or retrying without any success before
|
||||
// Possible states transitions:
|
||||
// e.g. not-executed -> loading (first-refresh)
|
||||
// e.g. not-executed -> loading (first-refresh) -> error -> loading (manual-retry)
|
||||
if (state.status === 'loading' && !state.result) {
|
||||
return 'loading';
|
||||
}
|
||||
|
||||
// Retrying after having succeeding at least once
|
||||
// Current states is: { status: 'loading', result: {...}, error: undefined | Error }
|
||||
// e.g. not-executed -> loading (first-refresh) -> success -> loading (scheduled-refresh) -> error -> loading (manual-retry)
|
||||
if (state.status === 'loading' && state.error) {
|
||||
return 'loading';
|
||||
}
|
||||
|
||||
// Something went wrong during the any situation of a refresh
|
||||
if (state.status === 'error' && state.error) {
|
||||
return 'error';
|
||||
}
|
||||
|
||||
return 'success';
|
||||
}, [state]);
|
||||
|
||||
const retry = useCallback(() => {
|
||||
actions.execute();
|
||||
}, [actions]);
|
||||
|
||||
return {
|
||||
loading: state.status === 'loading',
|
||||
retry,
|
||||
status,
|
||||
result: state.result,
|
||||
error: state.error,
|
||||
value: state.result,
|
||||
retry: actions.execute,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user