core-plugin-api: further error handling improvements + fixes and tests for useTranslationRef

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2023-09-16 18:02:47 +02:00
parent f31de6be7c
commit a3a7d64dbd
2 changed files with 107 additions and 27 deletions
@@ -15,7 +15,11 @@
*/
import React, { ReactNode } from 'react';
import { TestApiProvider, withLogCollector } from '@backstage/test-utils';
import {
MockErrorApi,
TestApiProvider,
withLogCollector,
} from '@backstage/test-utils';
import { renderHook } from '@testing-library/react-hooks';
import { createTranslationRef } from './TranslationRef';
import { useTranslationRef } from './useTranslationRef';
@@ -28,6 +32,7 @@ import {
TranslationApi,
translationApiRef,
} from '../alpha';
import { ErrorApi, errorApiRef } from '../apis';
const plainRef = createTranslationRef({
id: 'plain',
@@ -37,10 +42,16 @@ const plainRef = createTranslationRef({
},
});
function makeWrapper(translationApi: TranslationApi) {
function makeWrapper(
translationApi: TranslationApi,
errorApi: ErrorApi = { error$: jest.fn(), post: jest.fn() },
) {
return ({ children }: { children: ReactNode }) => (
<TestApiProvider
apis={[[translationApiRef, translationApi]]}
apis={[
[translationApiRef, translationApi],
[errorApiRef, errorApi],
]}
children={children}
/>
);
@@ -177,6 +188,7 @@ describe('useTranslationRef', () => {
},
});
const errorApi = new MockErrorApi({ collect: true });
const languageApi = AppLanguageSelector.create();
const translationApi = I18nextTranslationApi.create({
languageApi,
@@ -193,21 +205,83 @@ describe('useTranslationRef', () => {
});
const rendered1 = renderHook(() => useTranslationRef(ref), {
wrapper: makeWrapper(translationApi),
wrapper: makeWrapper(translationApi, errorApi),
});
const rendered2 = renderHook(() => useTranslationRef(ref), {
wrapper: makeWrapper(translationApi),
wrapper: makeWrapper(translationApi, errorApi),
});
const { error } = await withLogCollector(['error'], async () => {
await rendered2.waitForNextUpdate();
});
expect(error).toEqual([
"Failed to load translation resource 'test'; caused by Error: NOPE",
const msg =
"Failed to load translation resource 'test'; caused by Error: NOPE";
expect(error).toEqual([msg]);
expect(errorApi.getErrors()).toEqual([
{
error: new Error(msg),
},
]);
expect(rendered1.result.current.t('key')).toBe('default');
expect(rendered2.result.current.t('key')).toBe('default');
});
it('should log once and then ignore loading errors after initial load', async () => {
const ref = createTranslationRef({
id: 'test',
messages: {
key: 'default',
},
});
const errorApi = new MockErrorApi({ collect: true });
const languageApi = AppLanguageSelector.create({
availableLanguages: ['en', 'de'],
});
const translationApi = I18nextTranslationApi.create({
languageApi,
resources: [
createTranslationResource({
ref,
translations: {
de: async () => {
throw new Error('NOPE');
},
},
}),
],
});
const { result } = renderHook(() => useTranslationRef(ref), {
wrapper: makeWrapper(translationApi, errorApi),
});
expect(result.current.t('key')).toBe('default');
languageApi.setLanguage('de');
const { error } = await withLogCollector(['error'], async () => {
const rendered1 = renderHook(() => useTranslationRef(ref), {
wrapper: makeWrapper(translationApi, errorApi),
});
const rendered2 = renderHook(() => useTranslationRef(ref), {
wrapper: makeWrapper(translationApi, errorApi),
});
await new Promise(resolve => setTimeout(resolve)); // Wait a long tick
expect(rendered1.result.current.t('key')).toBe('default');
expect(rendered2.result.current.t('key')).toBe('default');
});
const msg =
"Failed to load translation resource 'test'; caused by Error: NOPE";
expect(error).toEqual([msg]);
expect(errorApi.getErrors()).toEqual([
{
error: new Error(msg),
},
]);
});
});
@@ -14,9 +14,8 @@
* limitations under the License.
*/
import { useEffect, useState } from 'react';
import { useApi } from '../apis';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { errorApiRef, useApi } from '../apis';
import {
translationApiRef,
TranslationFunction,
@@ -33,14 +32,28 @@ export const useTranslationRef = <
>(
translationRef: TranslationRef<string, TMessages>,
): { t: TranslationFunction<TMessages> } => {
const errorApi = useApi(errorApiRef);
const translationApi = useApi(translationApiRef);
const [error, setError] = useState<Error>();
const [snapshot, setSnapshot] = useState<TranslationSnapshot<TMessages>>(() =>
translationApi.getTranslation(translationRef),
);
const [observable] = useState(() =>
translationApi.translation$(translationRef),
const observable = useMemo(
() => translationApi.translation$(translationRef),
[translationApi, translationRef],
);
const onError = useCallback(
(error: Error) => {
if (!loggedRefs.has(translationRef)) {
const errMsg = `Failed to load translation resource '${translationRef.id}'; caused by ${error}`;
// eslint-disable-next-line no-console
console.error(errMsg);
errorApi.post(new Error(errMsg));
loggedRefs.add(translationRef);
}
},
[errorApi, translationRef],
);
useEffect(() => {
@@ -50,17 +63,15 @@ export const useTranslationRef = <
setSnapshot(next);
}
},
error: setError,
error(error) {
onError(error);
},
});
return () => {
subscription.unsubscribe();
};
}, [observable]);
if (error) {
throw error;
}
}, [observable, onError]);
if (!snapshot.ready) {
throw new Promise<void>(resolve => {
@@ -71,14 +82,9 @@ export const useTranslationRef = <
resolve();
}
},
error(err) {
if (!loggedRefs.has(translationRef)) {
// eslint-disable-next-line no-console
console.error(
`Failed to load translation resource '${translationRef.id}'; caused by ${err}`,
);
loggedRefs.add(translationRef);
}
error(error) {
subscription.unsubscribe();
onError(error);
resolve();
},
});