Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: ability to have fallbacks and timeouts with backend plugin #3385

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 63 additions & 30 deletions packages/core/src/Controller/Cache/Cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
TreeTranslationsData,
BackendGetRecordInternal,
RecordFetchError,
CachePublicRecord,
} from '../../types';
import { getFallbackArray, isPromise, unique } from '../../helpers';
import { TolgeeStaticData } from '../State/initState';
Expand Down Expand Up @@ -58,28 +59,31 @@ export function Cache(
* Fetches production data
*/
async function fetchProd(keyObject: CacheDescriptorInternal) {
let dataOrPromise = undefined as
| Promise<TreeTranslationsData | undefined>
| undefined;
const staticDataValue = staticData[encodeCacheKey(keyObject)];
if (typeof staticDataValue === 'function') {
dataOrPromise = staticDataValue();
function handleError(e: any) {
const error = new RecordFetchError(keyObject, e);
events.onError.emit(error);
// eslint-disable-next-line no-console
console.error(error);
throw error;
}

if (!dataOrPromise) {
dataOrPromise = backendGetRecord(keyObject);
const dataFromBackend = backendGetRecord(keyObject);
if (isPromise(dataFromBackend)) {
const result = await dataFromBackend.catch(handleError);
if (result !== undefined) {
return result;
}
}

if (isPromise(dataOrPromise)) {
return dataOrPromise?.catch((e) => {
const error = new RecordFetchError(keyObject, e);
events.onError.emit(error);
// eslint-disable-next-line no-console
console.error(error);
throw error;
});
const staticDataValue = staticData[encodeCacheKey(keyObject)];
if (typeof staticDataValue === 'function') {
try {
return await staticDataValue();
} catch (e) {
handleError(e);
}
} else {
return dataOrPromise;
return staticDataValue;
}
}

Expand All @@ -105,8 +109,16 @@ export function Cache(
}

const self = Object.freeze({
addStaticData(data: TolgeeStaticData | undefined) {
if (data) {
addStaticData(data: TolgeeStaticData | CachePublicRecord[] | undefined) {
if (Array.isArray(data)) {
for (const record of data) {
const key = encodeCacheKey(record);
const existing = cache.get(key);
if (!existing || existing.version === 0) {
addRecordInternal(record, record.data, 0);
}
}
} else if (data) {
staticData = { ...staticData, ...data };
Object.entries(data).forEach(([key, value]) => {
if (typeof value !== 'function') {
Expand Down Expand Up @@ -138,7 +150,22 @@ export function Cache(
},

getRecord(descriptor: CacheDescriptor) {
return cache.get(encodeCacheKey(withDefaultNs(descriptor)))?.data;
const descriptorWithNs = withDefaultNs(descriptor);
const cacheKey = encodeCacheKey(descriptorWithNs);
const cacheRecord = cache.get(cacheKey);
if (!cacheRecord) {
return undefined;
}
return {
...descriptorWithNs,
cacheKey,
data: Object.fromEntries(cacheRecord?.data.entries() ?? []),
};
},

getAllRecords() {
const entries = Array.from(cache.entries());
return entries.map(([key]) => self.getRecord(decodeCacheKey(key)));
},

getTranslation(descriptor: CacheDescriptorInternal, key: string) {
Expand Down Expand Up @@ -222,6 +249,20 @@ export function Cache(
);
},

async loadRecordMatrix(
languages: string[],
namespaces: string[],
isDev: boolean
) {
const descriptors: CacheDescriptor[] = [];
languages.forEach((language) => {
namespaces.forEach((namespace) => {
descriptors.push({ language, namespace });
});
});
return self.loadRecords(descriptors, isDev);
},

async loadRecords(descriptors: CacheDescriptor[], isDev: boolean) {
const withPromises = descriptors.map((descriptor) => {
const keyObject = withDefaultNs(descriptor);
Expand Down Expand Up @@ -270,16 +311,8 @@ export function Cache(
fetchingObserver.notify();
loadingObserver.notify();

return withPromises.map((val) => self.getRecord(val.keyObject)!);
},

getAllRecords() {
const entries = Array.from(cache.entries());
return entries.map(([key, entry]) => {
return {
...decodeCacheKey(key),
data: entry.data,
};
return withPromises.map((val) => {
return self.getRecord(val.keyObject)!;
});
},
});
Expand Down
12 changes: 11 additions & 1 deletion packages/core/src/Controller/Controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ export function Controller({ options }: StateServiceProps) {

function loadRequiredRecords(lang?: string, ns?: NsFallback) {
const descriptors = getRequiredRecords(lang, ns);

if (descriptors.length) {
return valueOrPromise(self.loadRecords(descriptors), () => {});
}
Expand Down Expand Up @@ -243,8 +244,17 @@ export function Controller({ options }: StateServiceProps) {
return cache.loadRecords(descriptors, self.isDev());
},

loadMatrix(languages: string[], namespaces?: string[]) {
const resolvedNamespaces = namespaces ?? self.getRequiredNamespaces();
return cache.loadRecordMatrix(
languages,
resolvedNamespaces,
self.isDev()
);
},

async loadRecord(descriptor: CacheDescriptor) {
return (await self.loadRecords([descriptor]))[0];
return (await self.loadRecords([descriptor]))[0]?.data;
},

isLoading(ns?: NsFallback) {
Expand Down
12 changes: 11 additions & 1 deletion packages/core/src/Controller/State/initState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
OnFormatError,
FetchFn,
MissingTranslationHandler,
CachePublicRecord,
} from '../../types';
import { createFetchFunction, sanitizeUrl } from '../../helpers';
import {
Expand Down Expand Up @@ -85,8 +86,17 @@ export type TolgeeOptionsInternal = {
* 'locale:namespace': <translations | async function>
* }
* ```
*
* You can also pass list of `CachePublicRecord`, which is in format:
*
* {
* 'language': <locale>,
* 'namespace': <namespace>
* 'data': <translations>
* }
*
*/
staticData?: TolgeeStaticData;
staticData?: TolgeeStaticData | CachePublicRecord[];

/**
* Switches between invisible and text observer. (Default: invisible)
Expand Down
9 changes: 8 additions & 1 deletion packages/core/src/TolgeeCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,13 @@ function createTolgee(options: TolgeeOptions) {
*/
removeActiveNs: controller.removeActiveNs,

/**
* Manually load all combinations of languages and namespaces from `Backend` (or `DevBackend` when in dev mode)
*
* It loads data together and adds them to cache in one operation, to prevent partly loaded state.
*/
loadMatrix: controller.loadMatrix,

/**
* Manually load multiple records from `Backend` (or `DevBackend` when in dev mode)
*
Expand All @@ -107,7 +114,7 @@ function createTolgee(options: TolgeeOptions) {
loadRecord: controller.loadRecord,

/**
*
* Prefill static data
*/
addStaticData: controller.addStaticData,

Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/__test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,21 +28,21 @@ describe('using tolgee as client', () => {
});
expect(promiseEnTest).toBeCalledTimes(1);
expect(promiseEnCommon).not.toBeCalled();
expect(enTest).toEqual(new Map([['test', 'Test']]));
expect(enTest).toEqual({ test: 'Test' });

const enCommon = await tolgee.loadRecord({
language: 'en',
namespace: 'common',
});
expect(promiseEnCommon).toBeCalledTimes(1);
expect(enCommon).toEqual(new Map([['cancel', 'Cancel']]));
expect(enCommon).toEqual({ cancel: 'Cancel' });

const esTest = await tolgee.loadRecord({
language: 'es',
namespace: 'test',
});
expect(promiseEsTest).toBeCalledTimes(1);
expect(promiseEsCommon).not.toBeCalled();
expect(esTest).toEqual(new Map([['test', 'Testa']]));
expect(esTest).toEqual({ test: 'Testa' });
});
});
2 changes: 1 addition & 1 deletion packages/core/src/__test/languages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ describe('language changes', () => {
'cs:fallback': loadNs,
},
});
tolgee.run();
await tolgee.run();
expect(loadNs).toBeCalledTimes(2);
await tolgee.changeLanguage('cs');
expect(loadNs).toBeCalledTimes(4);
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/types/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ export type TranslationValue = string | undefined | null;

export type TranslationsFlat = Map<string, TranslationValue>;

export type TranslationsFlatRecord = Record<string, TranslationValue>;

export type TreeTranslationsData = {
[key: string]: TranslationValue | TreeTranslationsData;
};
Expand Down Expand Up @@ -31,7 +33,7 @@ export type ChangeTranslationInterface = (
) => TranslationChanger;

export type CachePublicRecord = {
data: TranslationsFlat;
data: TranslationsFlatRecord;
language: string;
namespace: string;
};
2 changes: 1 addition & 1 deletion packages/i18next/src/tolgeeApply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export const tolgeeApply = (tolgee: TolgeeInstance, i18n: i18n) => {
i18n.addResourceBundle(
language,
namespace,
Object.fromEntries(data),
data instanceof Map ? Object.fromEntries(data) : data,
false,
true
);
Expand Down
4 changes: 3 additions & 1 deletion packages/i18next/src/tolgeeBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ export const tolgeeBackend = (tolgee: TolgeeInstance): Module => {
});
callback(
null,
translations ? Object.fromEntries(translations) : undefined
translations instanceof Map
? Object.fromEntries(translations)
: translations
);
} catch (e) {
// eslint-disable-next-line no-console
Expand Down
22 changes: 19 additions & 3 deletions packages/react/src/createServerInstance.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
// @ts-ignore
import { cache } from 'react';
import React from 'react';
import { TFnType } from '@tolgee/web';
import { TolgeeInstance } from '@tolgee/web';

import { TBase } from './TBase';
import { TProps, ParamsTags } from './types';
import React from 'react';
import { TolgeeInstance } from '@tolgee/web';

export type CreateServerInstanceOptions = {
createTolgee: (locale: string) => Promise<TolgeeInstance>;
Expand All @@ -22,6 +22,10 @@ export const createServerInstance = ({
return tolgee;
}) as (locale: string) => Promise<TolgeeInstance>;

const getTolgeeStaticInstance = cache(async (locale: string) => {
return await createTolgee(locale);
});

const getTolgee = async () => {
const locale = await getLocale();
const tolgee = await getTolgeeInstance(locale);
Expand All @@ -33,10 +37,22 @@ export const createServerInstance = ({
return tolgee.t;
};

const loadMatrix = async (languages: string[], namespaces?: string[]) => {
const tolgee = await getTolgeeStaticInstance(languages[0]);
return tolgee.loadMatrix(languages, namespaces);
};

async function T(props: TProps) {
const t = await getTranslate();
return <TBase t={t as TFnType<ParamsTags>} {...props} />;
}

return { getTolgeeInstance, getTolgee, getTranslate, T };
return {
loadMatrix,
getTolgeeInstance,
getTolgeeStaticInstance,
getTolgee,
getTranslate,
T,
};
};
14 changes: 8 additions & 6 deletions packages/react/src/useTolgeeSSR.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
CachePublicRecord,
getTranslateProps,
TolgeeInstance,
TolgeeStaticData,
Expand Down Expand Up @@ -32,7 +33,7 @@ function getTolgeeWithDeactivatedWrapper(
export function useTolgeeSSR(
tolgeeInstance: TolgeeInstance,
language?: string,
staticData?: TolgeeStaticData | undefined
data?: TolgeeStaticData | CachePublicRecord[] | undefined
) {
const [noWrappingTolgee] = useState(() =>
getTolgeeWithDeactivatedWrapper(tolgeeInstance)
Expand All @@ -49,22 +50,23 @@ export function useTolgeeSSR(
// so translations are available right away
// events emitting must be off, to not trigger re-render while rendering
tolgeeInstance.setEmitterActive(false);
tolgeeInstance.addStaticData(staticData);
tolgeeInstance.addStaticData(data);
tolgeeInstance.changeLanguage(language!);
tolgeeInstance.setEmitterActive(true);
}, [language, staticData, tolgeeInstance]);
}, [language, data, tolgeeInstance]);

useState(() => {
// running this function only on first render
if (!tolgeeInstance.isLoaded()) {
// warning user, that static data provided are not sufficient
// for proper SSR render
const missingRecords = tolgeeInstance
.getRequiredRecords(language)
const requiredRecords = tolgeeInstance.getRequiredRecords(language);
const providedRecords = tolgeeInstance.getAllRecords();
const missingRecords = requiredRecords
.map(({ namespace, language }) =>
namespace ? `${namespace}:${language}` : language
)
.filter((key) => !staticData?.[key]);
.filter((key) => !providedRecords.find((r) => r?.cacheKey === key));

// eslint-disable-next-line no-console
console.warn(
Expand Down
Loading