diff --git a/src/store.ts b/src/store.ts index b563410..2cd75bc 100644 --- a/src/store.ts +++ b/src/store.ts @@ -19,7 +19,19 @@ export class Store { if (match([locale], [this.options.sourceLocale], '') && this.options.sourceDictionary) { dict = this.options.sourceDictionary; } else if (this.options.dicts instanceof Function) { - dict = this.options.dicts(locale); + try { + dict = this.options.dicts(locale); + + if (dict instanceof Promise) { + dict = dict.catch(() => { + console.warn(`Failed to load dictionary for locale "${locale}"`); + return null; + }); + } + } catch { + console.warn(`Failed to load dictionary for locale "${locale}"`); + dict = null; + } } else if (this.options.dicts) { const availableLocales = Object.keys(this.options.dicts); const matching = match([locale], availableLocales, locale); @@ -28,15 +40,13 @@ export class Store { } if (dict instanceof Promise) { - entry = dict - .then((resolvedDict) => { - const flatDict = resolvedDict && flattenDict(resolvedDict); - if (this.dicts.get(locale) === entry) { - this.dicts.set(locale, flatDict); - } - return flatDict; - }) - .catch(() => null); + entry = dict.then((resolvedDict) => { + const flatDict = resolvedDict && flattenDict(resolvedDict); + if (this.dicts.get(locale) === entry) { + this.dicts.set(locale, flatDict); + } + return flatDict; + }); } else { entry = dict && flattenDict(dict); } diff --git a/test/react.test.tsx b/test/react.test.tsx index 8b773d1..3e35c78 100644 --- a/test/react.test.tsx +++ b/test/react.test.tsx @@ -1,6 +1,6 @@ import { act, fireEvent, render, screen } from '@testing-library/react'; import React, { ReactNode, useState } from 'react'; -import { beforeEach, expect, test } from 'vitest'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { FlattenDict } from '../src'; import { HookTranslator, MaybePromise, TranslationContextProvider, createTranslator } from '../src/react'; import { dictDe, dictEn, dictEs, wait } from './_helpers'; @@ -25,6 +25,11 @@ beforeEach(() => { })); }); +const originalConsoleWarn = console.warn; +afterEach(() => { + console.warn = originalConsoleWarn; +}); + function App({ id, locales = ['en', 'de', 'es'], @@ -441,3 +446,55 @@ test('provided args', async () => { expect(div.textContent).toBe('1'); }); + +describe('error in dict loader', () => { + test('sync error', async () => { + console.warn = vi.fn(); + + const { t: _t } = createTranslator({ + sourceLocale: 'en', + sourceDictionary: dictEn, + fallbackLocale: 'en', + dicts(locale) { + throw new Error(`dicts error: ${locale}`); + }, + }); + + render( + + {_t('key1')} + , + ); + const div = screen.getByTestId('error'); + + expect(div.textContent).toBe('key1:en'); + expect(console.warn).toHaveBeenCalledWith('Failed to load dictionary for locale "de"'); + }); + + test('async error', async () => { + console.warn = vi.fn(); + + const { t: _t } = createTranslator({ + sourceLocale: 'en', + sourceDictionary: dictEn, + fallbackLocale: 'en', + placeholder: '...', + dicts(locale) { + return Promise.reject(new Error(`dicts error: ${locale}`)); + }, + }); + + render( + + {_t('key1')} + , + ); + const div = screen.getByTestId('error'); + await act(async () => { + await wait(10); + }); + + expect(div.textContent).toBe('key1:en'); + expect(console.warn).toHaveBeenCalledWith('Failed to load dictionary for locale "de"'); + }); +}); diff --git a/test/translator.test.ts b/test/translator.test.ts index b98faff..836cc65 100644 --- a/test/translator.test.ts +++ b/test/translator.test.ts @@ -488,182 +488,180 @@ describe('ignoreMissingArgs', () => { const _t = await getTranslator('en'); expect(_t('nested.key2', {} as any)).toBe('key2:en ignore-value2-key2:en {value2}}'); }); +}); - describe('select types', () => { - test('select', async () => { - const _t = await getTranslator('en'); +describe('select types', () => { + test('select', async () => { + const _t = await getTranslator('en'); - expect(_t('select', { value: 'option1' })).toBe('text text1 text'); - expect(_t('select', { value: 'option2' })).toBe('text text2 text'); - // @ts-expect-error only listed options are allowed - expect(_t('select', { value: 'foo' })).toMatchInlineSnapshot( - '"Wrong format: Error: Invalid values for \\"value\\": \\"foo\\". Options are \\"0\\", \\"1\\""', - ); - }); + expect(_t('select', { value: 'option1' })).toBe('text text1 text'); + expect(_t('select', { value: 'option2' })).toBe('text text2 text'); + // @ts-expect-error only listed options are allowed + expect(_t('select', { value: 'foo' })).toMatchInlineSnapshot( + '"Wrong format: Error: Invalid values for \\"value\\": \\"foo\\". Options are \\"0\\", \\"1\\""', + ); + }); - test('select with other', async () => { - const _t = await getTranslator('en'); - expect(_t('selectWithOther', { value: 'option1' })).toBe('text text1 text'); - expect(_t('selectWithOther', { value: 'option2' })).toBe('text text2 text'); - expect(_t('selectWithOther', { value: 'foo' })).toBe('text text3 text'); - }); + test('select with other', async () => { + const _t = await getTranslator('en'); + expect(_t('selectWithOther', { value: 'option1' })).toBe('text text1 text'); + expect(_t('selectWithOther', { value: 'option2' })).toBe('text text2 text'); + expect(_t('selectWithOther', { value: 'foo' })).toBe('text text3 text'); + }); - test('select with nested', async () => { - const _t = await getTranslator('en'); - //@ts-expect-error for options1, nested is required - expect(_t('selectWithNested', { value: 'option1' })).toMatchInlineSnapshot( - '"Wrong format: Error: The intl string context variable \\"nested\\" was not provided to the string \\"undefined\\""', - ); - expect(_t('selectWithNested', { value: 'option1', nested: 'nestedText' })).toBe('text text1 nestedText text'); - expect(_t('selectWithNested', { value: 'option2' })).toBe('text text2 text'); - }); + test('select with nested', async () => { + const _t = await getTranslator('en'); + //@ts-expect-error for options1, nested is required + expect(_t('selectWithNested', { value: 'option1' })).toMatchInlineSnapshot( + '"Wrong format: Error: The intl string context variable \\"nested\\" was not provided to the string \\"undefined\\""', + ); + expect(_t('selectWithNested', { value: 'option1', nested: 'nestedText' })).toBe('text text1 nestedText text'); + expect(_t('selectWithNested', { value: 'option2' })).toBe('text text2 text'); + }); - test('select with other nested', async () => { - const _t = await getTranslator('en'); - //@ts-expect-error for option1, nested1 is required - expect(_t('selectWithOtherNested', { value: 'option1' })).toMatchInlineSnapshot( - '"Wrong format: Error: The intl string context variable \\"nested1\\" was not provided to the string \\"undefined\\""', - ); - expect(_t('selectWithOtherNested', { value: 'option1', nested1: 'n1' })).toBe('text text1 n1 text'); - //@ts-expect-error for option1, nested1 is required - expect(_t('selectWithOtherNested', { value: 'option1', nested3: 'n3' })).toMatchInlineSnapshot( - '"Wrong format: Error: The intl string context variable \\"nested1\\" was not provided to the string \\"undefined\\""', - ); - expect(_t('selectWithOtherNested', { value: 'option1', nested1: 'n1', nested2: 'n2', nested3: 'n3' })).toBe('text text1 n1 text'); - - //@ts-expect-error for option2, nested2 is required - expect(_t('selectWithOtherNested', { value: 'option2' })).toMatchInlineSnapshot( - '"Wrong format: Error: The intl string context variable \\"nested2\\" was not provided to the string \\"undefined\\""', - ); - expect(_t('selectWithOtherNested', { value: 'option2', nested2: 'n2' })).toBe('text text2 n2 text'); - //@ts-expect-error for option2, nested2 is required - expect(_t('selectWithOtherNested', { value: 'option2', nested3: 'n3' })).toMatchInlineSnapshot( - '"Wrong format: Error: The intl string context variable \\"nested2\\" was not provided to the string \\"undefined\\""', - ); - expect(_t('selectWithOtherNested', { value: 'option2', nested1: 'n1', nested2: 'n2', nested3: 'n3' })).toBe('text text2 n2 text'); - - // @ts-expect-error for other, nested3 is required - expect(_t('selectWithOtherNested', { value: 'foo' as OtherString })).toMatchInlineSnapshot( - '"Wrong format: Error: The intl string context variable \\"nested3\\" was not provided to the string \\"undefined\\""', - ); - expect(_t('selectWithOtherNested', { value: 'foo' as OtherString, nested3: 'n3' })).toBe('text text3 n3 text'); - // @ts-expect-error for other, nested3 is required - expect(_t('selectWithOtherNested', { value: 'foo' as OtherString, nested1: 'n1' })).toMatchInlineSnapshot( - '"Wrong format: Error: The intl string context variable \\"nested3\\" was not provided to the string \\"undefined\\""', - ); - expect(_t('selectWithOtherNested', { value: 'foo' as OtherString, nested1: 'n1', nested2: 'n2', nested3: 'n3' })).toBe( - 'text text3 n3 text', - ); - - // @ts-expect-error for string, all nested args are required - expect(_t('selectWithOtherNested', { value: 'foo' as string })).toMatchInlineSnapshot( - '"Wrong format: Error: The intl string context variable \\"nested3\\" was not provided to the string \\"undefined\\""', - ); - // @ts-expect-error for string, all nested args are required - expect(_t('selectWithOtherNested', { value: 'foo' as string, nested3: 'n3' })).toBe('text text3 n3 text'); - expect(_t('selectWithOtherNested', { value: 'foo' as string, nested1: 'n1', nested2: 'n2', nested3: 'n3' })).toBe( - 'text text3 n3 text', - ); - - expectTypeOf(_t.dynamic<'nested.key2' | 'selectWithOtherNested'>).parameters.toEqualTypeOf< - [ - id: 'nested.key2' | 'selectWithOtherNested', - ...args: - | [ - values: { value2: ICUArgument } & ( - | { value: 'option1'; nested1: ICUArgument } - | { value: 'option2'; nested2: ICUArgument } - | { value: OtherString; nested3: ICUArgument } - | { - value: (string & {}) | 'option1' | 'option2'; - nested1: ICUArgument; - nested2: ICUArgument; - nested3: ICUArgument; - } - ), - options?: any, - ], - ] - >(); - }); + test('select with other nested', async () => { + const _t = await getTranslator('en'); + //@ts-expect-error for option1, nested1 is required + expect(_t('selectWithOtherNested', { value: 'option1' })).toMatchInlineSnapshot( + '"Wrong format: Error: The intl string context variable \\"nested1\\" was not provided to the string \\"undefined\\""', + ); + expect(_t('selectWithOtherNested', { value: 'option1', nested1: 'n1' })).toBe('text text1 n1 text'); + //@ts-expect-error for option1, nested1 is required + expect(_t('selectWithOtherNested', { value: 'option1', nested3: 'n3' })).toMatchInlineSnapshot( + '"Wrong format: Error: The intl string context variable \\"nested1\\" was not provided to the string \\"undefined\\""', + ); + expect(_t('selectWithOtherNested', { value: 'option1', nested1: 'n1', nested2: 'n2', nested3: 'n3' })).toBe('text text1 n1 text'); + + //@ts-expect-error for option2, nested2 is required + expect(_t('selectWithOtherNested', { value: 'option2' })).toMatchInlineSnapshot( + '"Wrong format: Error: The intl string context variable \\"nested2\\" was not provided to the string \\"undefined\\""', + ); + expect(_t('selectWithOtherNested', { value: 'option2', nested2: 'n2' })).toBe('text text2 n2 text'); + //@ts-expect-error for option2, nested2 is required + expect(_t('selectWithOtherNested', { value: 'option2', nested3: 'n3' })).toMatchInlineSnapshot( + '"Wrong format: Error: The intl string context variable \\"nested2\\" was not provided to the string \\"undefined\\""', + ); + expect(_t('selectWithOtherNested', { value: 'option2', nested1: 'n1', nested2: 'n2', nested3: 'n3' })).toBe('text text2 n2 text'); + + // @ts-expect-error for other, nested3 is required + expect(_t('selectWithOtherNested', { value: 'foo' as OtherString })).toMatchInlineSnapshot( + '"Wrong format: Error: The intl string context variable \\"nested3\\" was not provided to the string \\"undefined\\""', + ); + expect(_t('selectWithOtherNested', { value: 'foo' as OtherString, nested3: 'n3' })).toBe('text text3 n3 text'); + // @ts-expect-error for other, nested3 is required + expect(_t('selectWithOtherNested', { value: 'foo' as OtherString, nested1: 'n1' })).toMatchInlineSnapshot( + '"Wrong format: Error: The intl string context variable \\"nested3\\" was not provided to the string \\"undefined\\""', + ); + expect(_t('selectWithOtherNested', { value: 'foo' as OtherString, nested1: 'n1', nested2: 'n2', nested3: 'n3' })).toBe( + 'text text3 n3 text', + ); + + // @ts-expect-error for string, all nested args are required + expect(_t('selectWithOtherNested', { value: 'foo' as string })).toMatchInlineSnapshot( + '"Wrong format: Error: The intl string context variable \\"nested3\\" was not provided to the string \\"undefined\\""', + ); + // @ts-expect-error for string, all nested args are required + expect(_t('selectWithOtherNested', { value: 'foo' as string, nested3: 'n3' })).toBe('text text3 n3 text'); + expect(_t('selectWithOtherNested', { value: 'foo' as string, nested1: 'n1', nested2: 'n2', nested3: 'n3' })).toBe('text text3 n3 text'); + + expectTypeOf(_t.dynamic<'nested.key2' | 'selectWithOtherNested'>).parameters.toEqualTypeOf< + [ + id: 'nested.key2' | 'selectWithOtherNested', + ...args: + | [ + values: { value2: ICUArgument } & ( + | { value: 'option1'; nested1: ICUArgument } + | { value: 'option2'; nested2: ICUArgument } + | { value: OtherString; nested3: ICUArgument } + | { + value: (string & {}) | 'option1' | 'option2'; + nested1: ICUArgument; + nested2: ICUArgument; + nested3: ICUArgument; + } + ), + options?: any, + ], + ] + >(); }); +}); - describe('escape sequences', () => { - test('escape single', async () => { - const _t = await getTranslator('en'); - expect(_t('escapeSingle')).toBe('text {word1} {word2}'); - }); +describe('escape sequences', () => { + test('escape single', async () => { + const _t = await getTranslator('en'); + expect(_t('escapeSingle')).toBe('text {word1} {word2}'); + }); - test('escape pair', async () => { - const _t = await getTranslator('en'); - expect(_t('escapePair')).toBe('text {word1} {word2}'); - }); + test('escape pair', async () => { + const _t = await getTranslator('en'); + expect(_t('escapePair')).toBe('text {word1} {word2}'); + }); - test('escape escaped', async () => { - const _t = await getTranslator('en'); - expect(_t('escapeEscaped', { word1: 'text1' })).toBe(`text 'text1`); - }); + test('escape escaped', async () => { + const _t = await getTranslator('en'); + expect(_t('escapeEscaped', { word1: 'text1' })).toBe(`text 'text1`); + }); - test('escape non escapable', async () => { - const _t = await getTranslator('en'); - expect(_t('escapeNonEscapable', { word1: 'text1' })).toBe(`text ' text text1`); - }); + test('escape non escapable', async () => { + const _t = await getTranslator('en'); + expect(_t('escapeNonEscapable', { word1: 'text1' })).toBe(`text ' text text1`); + }); - test('escape sharp in plural', async () => { - const _t = await getTranslator('en'); - // @ts-expect-error this escape is currently not supported - expect(_t('escapeSharpInPlural', { value: 1 })).toBe(`text # times {word}`); - }); + test('escape sharp in plural', async () => { + const _t = await getTranslator('en'); + // @ts-expect-error this escape is currently not supported + expect(_t('escapeSharpInPlural', { value: 1 })).toBe(`text # times {word}`); + }); - test('escape sharp outside plural', async () => { - const _t = await getTranslator('en'); - expect(_t('escapeSharpOutsidePlural', { word: 'text' })).toBe(`text '# times text`); - }); + test('escape sharp outside plural', async () => { + const _t = await getTranslator('en'); + expect(_t('escapeSharpOutsidePlural', { word: 'text' })).toBe(`text '# times text`); }); +}); - describe('provided args', () => { - test('provided args directly', async () => { - const { getTranslator } = createTranslator({ - sourceDictionary: { - foo: '{foo}', - bar: '{bar}', - baz: '{foo} and {bar}', - } as const, - sourceLocale: 'en', - provideArgs: { bar: 'x' }, - }); - - const t = await getTranslator('en'); - expectTypeOf(t<'foo'>).parameters.toEqualTypeOf<[key: 'foo', values: { foo: ICUArgument }, options?: any]>(); - expectTypeOf(t<'bar'>).parameters.toEqualTypeOf<[key: 'bar', values?: { bar?: ICUArgument }, options?: any]>(); - expectTypeOf(t<'baz'>).parameters.toEqualTypeOf<[key: 'baz', values: { foo: ICUArgument; bar: ICUArgument }, options?: any]>; - - expect(t('bar')).toBe('x'); - expect(t('bar', { bar: 'y' })).toBe('y'); +describe('provided args', () => { + test('provided args directly', async () => { + const { getTranslator } = createTranslator({ + sourceDictionary: { + foo: '{foo}', + bar: '{bar}', + baz: '{foo} and {bar}', + } as const, + sourceLocale: 'en', + provideArgs: { bar: 'x' }, }); - test('provided args subscribed', async () => { - let value = 0; - - const { getTranslator } = createTranslator({ - sourceDictionary: { - foo: '{value}', - } as const, - sourceLocale: 'en', - provideArgs: { - value: { - get: () => value, - subscribe: () => () => undefined, - }, - }, - }); + const t = await getTranslator('en'); + expectTypeOf(t<'foo'>).parameters.toEqualTypeOf<[key: 'foo', values: { foo: ICUArgument }, options?: any]>(); + expectTypeOf(t<'bar'>).parameters.toEqualTypeOf<[key: 'bar', values?: { bar?: ICUArgument }, options?: any]>(); + expectTypeOf(t<'baz'>).parameters.toEqualTypeOf<[key: 'baz', values: { foo: ICUArgument; bar: ICUArgument }, options?: any]>; + + expect(t('bar')).toBe('x'); + expect(t('bar', { bar: 'y' })).toBe('y'); + }); - const t = await getTranslator('en'); - expect(t('foo')).toBe('0'); + test('provided args subscribed', async () => { + let value = 0; - value = 1; - // listener?.(); - expect(t('foo')).toBe('1'); + const { getTranslator } = createTranslator({ + sourceDictionary: { + foo: '{value}', + } as const, + sourceLocale: 'en', + provideArgs: { + value: { + get: () => value, + subscribe: () => () => undefined, + }, + }, }); + + const t = await getTranslator('en'); + expect(t('foo')).toBe('0'); + + value = 1; + // listener?.(); + expect(t('foo')).toBe('1'); }); });