diff --git a/.changeset/lovely-knives-judge.md b/.changeset/lovely-knives-judge.md new file mode 100644 index 000000000..fa1d6d7d7 --- /dev/null +++ b/.changeset/lovely-knives-judge.md @@ -0,0 +1,5 @@ +--- +"@scaleway/use-i18n": major +--- + +Add a onError function for load date-fns locale, fix a bug when a local is removed diff --git a/.changeset/renovate-2327f3b.md b/.changeset/renovate-2327f3b.md new file mode 100644 index 000000000..7fd2abddd --- /dev/null +++ b/.changeset/renovate-2327f3b.md @@ -0,0 +1,5 @@ +--- +'@scaleway/use-i18n': patch +--- + +Updated dependency `date-fns` to `2.x || 3.x`. diff --git a/packages/use-i18n/package.json b/packages/use-i18n/package.json index 93db10edd..53f916c84 100644 --- a/packages/use-i18n/package.json +++ b/packages/use-i18n/package.json @@ -4,7 +4,7 @@ "description": "A small hook to handle i18n", "type": "module", "engines": { - "node": ">=14.x" + "node": ">=18.x" }, "sideEffects": false, "exports": { @@ -31,13 +31,13 @@ "dependencies": { "@formatjs/ecma402-abstract": "1.18.0", "@formatjs/fast-memoize": "2.2.0", - "date-fns": "2.30.0", + "date-fns": "3.0.6", "filesize": "10.1.0", "international-types": "0.8.1", "intl-messageformat": "10.5.8" }, "peerDependencies": { - "date-fns": "2.x", + "date-fns": "3.x", "react": "18.x || 18", "react-dom": "18.x || 18" } diff --git a/packages/use-i18n/src/__tests__/usei18n.tsx b/packages/use-i18n/src/__tests__/usei18n.tsx index 288413755..831ddc458 100644 --- a/packages/use-i18n/src/__tests__/usei18n.tsx +++ b/packages/use-i18n/src/__tests__/usei18n.tsx @@ -31,10 +31,24 @@ type NamespaceLocale = { languages: 'Languages' } +const defaultSupportedLocales = ['en', 'fr', 'es'] + const wrapper = ({ - loadDateLocale = async (locale: string) => - import(`date-fns/locale/${locale}/index`), + loadDateLocale = async (locale: string) => { + if (locale === 'en') { + return (await import('date-fns/locale/en-GB')).enGB + } + if (locale === 'fr') { + return (await import('date-fns/locale/fr')).fr + } + + if (locale === 'es') { + return (await import('date-fns/locale/es')).es + } + + return (await import(`date-fns/locale/en-GB`)).enGB + }, defaultLoad = async ({ locale }: { locale: string }) => import(`./locales/${locale}.json`), defaultLocale = 'en', @@ -42,7 +56,7 @@ const wrapper = enableDebugKey = false, enableDefaultLocale = false, localeItemStorage = LOCALE_ITEM_STORAGE, - supportedLocales = ['en', 'fr', 'es'], + supportedLocales = defaultSupportedLocales, } = {}) => ({ children }: { children: ReactNode }) => ( { expect(result.current.t('title')).toEqual(en.title) }) - act(() => { - result.current.switchLocale('fr') + await act(async () => { + await result.current.switchLocale('fr') }) await waitFor(() => { expect(result.current.t('title')).toEqual(fr.title) }) - act(() => { - result.current.switchLocale('es') + await act(async () => { + await result.current.switchLocale('es') }) await waitFor(() => { @@ -176,8 +190,8 @@ describe('i18n hook', () => { expect(result.current.t('lastName')).toEqual('Last Name') expect(result.current.t('languages')).toEqual('Languages') - act(() => { - result.current.switchLocale('fr') + await act(async () => { + await result.current.switchLocale('fr') }) await waitFor(() => { @@ -221,8 +235,8 @@ describe('i18n hook', () => { // current local will be 'en' based on navigator // await load of locales - act(() => { - result.current.switchLocale('fr') + await act(async () => { + await result.current.switchLocale('fr') }) await waitFor(() => { @@ -264,11 +278,13 @@ describe('i18n hook', () => { } as unknown as Navigator) const mockGetItem = jest.fn().mockImplementation(() => 'en') const mockSetItem = jest.fn() + const mockRemoveItem = jest.fn() const localStorageMock = jest .spyOn(global, 'localStorage', 'get') .mockReturnValue({ getItem: mockGetItem, setItem: mockSetItem, + removeItem: mockRemoveItem, clear: jest.fn(), } as unknown as Storage) @@ -281,7 +297,38 @@ describe('i18n hook', () => { await waitFor(() => { expect(result.current.currentLocale).toEqual('en') - expect(mockGetItem).toHaveBeenCalledTimes(2) + expect(mockGetItem).toHaveBeenCalledTimes(1) + expect(mockGetItem).toHaveBeenCalledWith(LOCALE_ITEM_STORAGE) + }) + localStorageMock.mockRestore() + }) + + it('should not set current locale from localStorage when this value is not supported', async () => { + jest.spyOn(global, 'navigator', 'get').mockReturnValueOnce({ + languages: ['bz'], + } as unknown as Navigator) + const mockGetItem = jest.fn().mockImplementation(() => 're') + const mockSetItem = jest.fn() + const mockRemoveItem = jest.fn() + const localStorageMock = jest + .spyOn(global, 'localStorage', 'get') + .mockReturnValue({ + getItem: mockGetItem, + setItem: mockSetItem, + removeItem: mockRemoveItem, + clear: jest.fn(), + } as unknown as Storage) + + const { result } = renderHook(() => useI18n(), { + wrapper: wrapper({ + defaultLocale: 'en', + supportedLocales: ['en'], + }), + }) + + await waitFor(() => { + expect(result.current.currentLocale).toEqual('en') + expect(mockGetItem).toHaveBeenCalledTimes(1) expect(mockGetItem).toHaveBeenCalledWith(LOCALE_ITEM_STORAGE) }) localStorageMock.mockRestore() @@ -293,11 +340,13 @@ describe('i18n hook', () => { } as unknown as Navigator) const mockGetItem = jest.fn() const mockSetItem = jest.fn() + const mockRemoveItem = jest.fn() const localStorageMock = jest .spyOn(global, 'localStorage', 'get') .mockReturnValueOnce({ getItem: mockGetItem, setItem: mockSetItem, + removeItem: mockRemoveItem, clear: jest.fn(), } as unknown as Storage) @@ -320,11 +369,13 @@ describe('i18n hook', () => { } as unknown as Navigator) const mockGetItem = jest.fn() const mockSetItem = jest.fn() + const mockRemoveItem = jest.fn() const localStorageMock = jest .spyOn(global, 'localStorage', 'get') .mockReturnValueOnce({ getItem: mockGetItem, setItem: mockSetItem, + removeItem: mockRemoveItem, clear: jest.fn(), } as unknown as Storage) @@ -350,10 +401,10 @@ describe('i18n hook', () => { }), }) expect(result.current.currentLocale).toEqual('en') - expect(localStorage.getItem(LOCALE_ITEM_STORAGE)).toBe(null) + expect(localStorage.getItem(LOCALE_ITEM_STORAGE)).toBe('en') - act(() => { - result.current.switchLocale('fr') + await act(async () => { + await result.current.switchLocale('fr') }) await waitFor(() => { @@ -361,8 +412,8 @@ describe('i18n hook', () => { }) expect(localStorage.getItem(LOCALE_ITEM_STORAGE)).toBe('fr') - act(() => { - result.current.switchLocale('es') + await act(async () => { + await result.current.switchLocale('es') }) await waitFor(() => { @@ -370,8 +421,8 @@ describe('i18n hook', () => { }) expect(localStorage.getItem(LOCALE_ITEM_STORAGE)).toBe('es') - act(() => { - result.current.switchLocale('test') + await act(async () => { + await result.current.switchLocale('test') }) await waitFor(() => { @@ -441,8 +492,8 @@ describe('i18n hook', () => { }), ).toEqual('$2.00') - act(() => { - result.current.switchLocale('fr') + await act(async () => { + await result.current.switchLocale('fr') }) // https://stackoverflow.com/questions/58769806/identical-strings-not-matching-in-jest @@ -491,8 +542,8 @@ describe('i18n hook', () => { }), ).toEqual('Motorcycle Bus Car') - act(() => { - result.current.switchLocale('fr') + await act(async () => { + await result.current.switchLocale('fr') }) await waitFor(() => { @@ -565,8 +616,8 @@ describe('i18n hook', () => { }), ).toEqual('12/17/1995') - act(() => { - result.current.switchLocale('fr') + await act(async () => { + await result.current.switchLocale('fr') }) await waitFor(() => { @@ -602,11 +653,12 @@ describe('i18n hook', () => { expect(result.current.relativeTime(date)).toEqual('over 20 years ago') - act(() => { - result.current.switchLocale('fr') + await act(async () => { + await result.current.switchLocale('fr') }) await waitFor(() => { + expect(result.current.dateFnsLocale?.code).toBe('fr') expect(result.current.relativeTime(date)).toEqual('il y a plus de 20 ans') }) }) @@ -621,8 +673,8 @@ describe('i18n hook', () => { const date = new Date('September 13, 2011 15:15:00') expect(result.current.relativeTimeStrict(date)).toEqual('3499 days ago') - act(() => { - result.current.switchLocale('fr') + await act(async () => { + await result.current.switchLocale('fr') }) await waitFor(() => { @@ -642,8 +694,8 @@ describe('i18n hook', () => { expect( result.current.formatUnit(12, { short: false, unit: 'byte' }), ).toEqual('12 bytes') - act(() => { - result.current.switchLocale('fr') + await act(async () => { + await result.current.switchLocale('fr') }) await waitFor(() => { @@ -663,8 +715,8 @@ describe('i18n hook', () => { expect( result.current.formatDate(new Date(2020, 1, 13, 16, 28), 'numericHour'), ).toEqual('2020-02-13 4:28 PM') - act(() => { - result.current.switchLocale('fr') + await act(async () => { + await result.current.switchLocale('fr') }) await waitFor(() => { @@ -674,17 +726,55 @@ describe('i18n hook', () => { }) }) - it('should load default datefns locales', async () => { - const { result } = renderHook(() => useI18n(), { - wrapper: wrapper({ - defaultLocale: 'test', - supportedLocales: ['test'], - }), + describe('date-fns', () => { + it('should load default date-fns locales', async () => { + const { result } = renderHook(() => useI18n(), { + wrapper: wrapper({ + defaultLocale: 'test', + supportedLocales: ['test'], + }), + }) + + await waitFor(() => { + expect(result.current.dateFnsLocale?.code).toEqual('en-GB') + }) }) - expect(result.current.dateFnsLocale).toBe(undefined) - await waitFor(() => { - expect(result.current.dateFnsLocale?.code).toEqual('en-GB') + it('should load correct date-fns based on current local', async () => { + jest.spyOn(global, 'navigator', 'get').mockReturnValueOnce({ + languages: ['fr'], + } as unknown as Navigator) + const mockGetItem = jest.fn().mockImplementation(() => 'fr') + const mockSetItem = jest.fn() + const mockRemoveItem = jest.fn() + const localStorageMock = jest + .spyOn(global, 'localStorage', 'get') + .mockReturnValue({ + getItem: mockGetItem, + setItem: mockSetItem, + removeItem: mockRemoveItem, + clear: jest.fn(), + } as unknown as Storage) + + const { result } = renderHook(() => useI18n(), { + wrapper: wrapper({ + defaultLocale: 'es', + supportedLocales: ['en', 'fr', 'es'], + }), + }) + + await waitFor(() => { + expect(result.current.currentLocale).toEqual('fr') + expect(mockGetItem).toHaveBeenCalledTimes(2) + expect(mockGetItem).toHaveBeenCalledWith(LOCALE_ITEM_STORAGE) + }) + + await waitFor(() => { + expect(result.current.dateFnsLocale?.code).toEqual('fr') + expect(result.current.dateFnsLocale).toMatchObject({ code: 'fr' }) + }) + + localStorageMock.mockRestore() }) }) diff --git a/packages/use-i18n/src/usei18n.tsx b/packages/use-i18n/src/usei18n.tsx index 67499ca7d..f73d799e5 100644 --- a/packages/use-i18n/src/usei18n.tsx +++ b/packages/use-i18n/src/usei18n.tsx @@ -1,6 +1,11 @@ import type { NumberFormatOptions } from '@formatjs/ecma402-abstract' -import type { Locale as DateFnsLocale } from 'date-fns' -import { formatDistanceToNow, formatDistanceToNowStrict } from 'date-fns' +import type { + Locale as DateFnsLocale, + FormatDistanceToNowOptions, + FormatDistanceToNowStrictOptions, +} from 'date-fns' +import { formatDistanceToNow } from 'date-fns/formatDistanceToNow' +import { formatDistanceToNowStrict } from 'date-fns/formatDistanceToNowStrict' import type { BaseLocale } from 'international-types' import type { ReactElement, ReactNode } from 'react' import { @@ -12,15 +17,12 @@ import { useState, } from 'react' import ReactDOM from 'react-dom' -import type { FormatDateOptions } from './formatDate' -import dateFormat from './formatDate' -import type { FormatUnitOptions } from './formatUnit' -import unitFormat from './formatUnit' -import type { IntlListFormatOptions } from './formatters' -import formatters from './formatters' +import dateFormat, { type FormatDateOptions } from './formatDate' +import unitFormat, { type FormatUnitOptions } from './formatUnit' +import formatters, { type IntlListFormatOptions } from './formatters' import type { ReactParamsObject, ScopedTranslateFn, TranslateFn } from './types' -const LOCALE_ITEM_STORAGE = 'locale' +const LOCALE_ITEM_STORAGE = 'locale' as const type TranslationsByLocales = Record type RequiredGenericContext = @@ -53,15 +55,38 @@ const getCurrentLocale = ({ if (typeof window !== 'undefined') { const { languages } = navigator const browserLocales = [...new Set(languages.map(getLocaleFallback))] - const localeStorage = localStorage.getItem(localeItemStorage) - - return ( - localeStorage || - browserLocales.find( - locale => locale && supportedLocales.includes(locale), - ) || - defaultLocale + const currentLocalFromlocalStorage = localStorage.getItem(localeItemStorage) + + if ( + currentLocalFromlocalStorage && + supportedLocales.find( + supportedLocale => supportedLocale === currentLocalFromlocalStorage, + ) + ) { + return currentLocalFromlocalStorage + } + localStorage.removeItem(localeItemStorage) + + const findedBrowserLocal = browserLocales.find( + locale => locale && supportedLocales.includes(locale), ) + + if (findedBrowserLocal) { + localStorage.setItem(localeItemStorage, findedBrowserLocal) + + return findedBrowserLocal + } + + if ( + defaultLocale && + supportedLocales.find( + supportedLocale => supportedLocale === defaultLocale, + ) + ) { + localStorage.setItem(localeItemStorage, defaultLocale) + + return defaultLocale + } } return defaultLocale @@ -90,21 +115,14 @@ type Context = { namespaceTranslation: ScopedTranslateFn relativeTime: ( date: Date | number, - options?: { - includeSeconds?: boolean - addSuffix?: boolean - }, + options?: FormatDistanceToNowOptions, ) => string relativeTimeStrict: ( date: Date | number, - options?: { - addSuffix?: boolean - unit?: 'second' | 'minute' | 'hour' | 'day' | 'month' | 'year' - roundingMethod?: 'floor' | 'ceil' | 'round' - }, + options?: FormatDistanceToNowStrictOptions, ) => string setTranslations: React.Dispatch> - switchLocale: (locale: string) => void + switchLocale: (locale: string) => Promise t: TranslateFn translations: TranslationsByLocales } @@ -163,26 +181,28 @@ type LoadTranslationsFn = ({ namespace: string locale: string }) => Promise<{ default: BaseLocale }> -type LoadLocaleFn = (locale: string) => Promise + +type LoadLocaleFn = (locale: string) => Promise +type LoadDateLocaleError = (error: Error) => void const initialDefaultTranslations = {} const I18nContextProvider = ({ children, defaultLoad, - loadDateLocale, - defaultDateLocale, defaultLocale, defaultTranslations = initialDefaultTranslations, - enableDefaultLocale = false, enableDebugKey = false, + enableDefaultLocale = false, + loadDateLocale, localeItemStorage = LOCALE_ITEM_STORAGE, + onLoadDateLocaleError, supportedLocales, }: { children: ReactNode defaultLoad: LoadTranslationsFn - loadDateLocale?: LoadLocaleFn - defaultDateLocale?: Locale + loadDateLocale: LoadLocaleFn + onLoadDateLocaleError?: LoadDateLocaleError defaultLocale: string defaultTranslations: TranslationsByLocales enableDefaultLocale: boolean @@ -196,15 +216,39 @@ const I18nContextProvider = ({ const [translations, setTranslations] = useState(defaultTranslations) const [namespaces, setNamespaces] = useState([]) - const [dateFnsLocale, setDateFnsLocale] = useState( - defaultDateLocale ?? undefined, + const [dateFnsLocale, setDateFnsLocale] = useState( + undefined, + ) + + const setDateFns = useCallback( + async (locale: string) => { + try { + const dateFns = await loadDateLocale(locale) + setDateFnsLocale(dateFns) + } catch (err) { + if (err instanceof Error && onLoadDateLocaleError) { + onLoadDateLocaleError(err) + } + + setDateFnsLocale(dateFnsLocale) + } + }, + [loadDateLocale, setDateFnsLocale, onLoadDateLocaleError, dateFnsLocale], ) + /** + * At first render when we find a local on the localStorage which is not the same as the default, + * we should switch also the date-fns local related to the current local. + * As the method is async, we obviously need a useEffect to apply this change... + * */ + useEffect(() => { - loadDateLocale?.(currentLocale === 'en' ? 'en-GB' : currentLocale) - .then(setDateFnsLocale) - .catch(() => loadDateLocale('en-GB').then(setDateFnsLocale)) - }, [loadDateLocale, currentLocale]) + if (!dateFnsLocale) { + setDateFns(currentLocale) + .then() + .catch(() => null) + } + }, [currentLocale, dateFnsLocale, setDateFns, setDateFnsLocale]) const loadTranslations = useCallback( async (namespace: string, load: LoadTranslationsFn = defaultLoad) => { @@ -252,13 +296,14 @@ const I18nContextProvider = ({ ) const switchLocale = useCallback( - (locale: string) => { + async (locale: string) => { if (supportedLocales.includes(locale)) { localStorage.setItem(localeItemStorage, locale) setCurrentLocale(locale) + await setDateFns(locale) } }, - [localeItemStorage, setCurrentLocale, supportedLocales], + [setDateFns, localeItemStorage, setCurrentLocale, supportedLocales], ) const formatNumber = useCallback( @@ -297,11 +342,10 @@ const I18nContextProvider = ({ const relativeTimeStrict = useCallback( ( date: Date | number, - options: { - addSuffix?: boolean - unit?: 'second' | 'minute' | 'hour' | 'day' | 'month' | 'year' - roundingMethod?: 'floor' | 'ceil' | 'round' - } = { addSuffix: true, unit: 'day' }, + options: FormatDistanceToNowStrictOptions = { + addSuffix: true, + unit: 'day', + }, ) => { const finalDate = new Date(date) @@ -316,10 +360,7 @@ const I18nContextProvider = ({ const relativeTime = useCallback( ( date: Date | number, - options: { - includeSeconds?: boolean - addSuffix?: boolean - } = { addSuffix: true }, + options: FormatDistanceToNowOptions = { addSuffix: true }, ) => { const finalDate = new Date(date) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6727005c2..ece2e7747 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -267,8 +267,8 @@ importers: specifier: 2.2.0 version: 2.2.0 date-fns: - specifier: 2.30.0 - version: 2.30.0 + specifier: 3.0.6 + version: 3.0.6 filesize: specifier: 10.1.0 version: 10.1.0 @@ -5206,11 +5206,8 @@ packages: resolution: {integrity: sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw==} dev: true - /date-fns@2.30.0: - resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==} - engines: {node: '>=0.11'} - dependencies: - '@babel/runtime': 7.21.5 + /date-fns@3.0.6: + resolution: {integrity: sha512-W+G99rycpKMMF2/YD064b2lE7jJGUe+EjOES7Q8BIGY8sbNdbgcs9XFTZwvzc9Jx1f3k7LB7gZaZa7f8Agzljg==} dev: false /debug@2.6.9: