From 29b6549a034c33231d3b8e2d75e5d9b393f9963e Mon Sep 17 00:00:00 2001 From: eolme Date: Sat, 30 Apr 2022 19:45:41 +0300 Subject: [PATCH] Use useId hook for key --- README.md | 39 +++++++++++++++++- package.json | 23 +++++------ src/react.ts | 29 +++++++++----- tests/all.spec.ts | 87 +++++++++++++++++++++++++++++++++++++++-- tests/suspense/index.ts | 49 +++++++++++++++++++++++ 5 files changed, 201 insertions(+), 26 deletions(-) create mode 100644 tests/suspense/index.ts diff --git a/README.md b/README.md index b98315c..3149c3d 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# i18nano [![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/eolme/next-sw/blob/master/LICENSE) [![BundlePhobia](https://img.shields.io/bundlephobia/min/i18nano)](https://bundlephobia.com/package/i18nano) +# i18nano [![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/eolme/next-sw/blob/master/LICENSE) [![BundlePhobia](https://img.shields.io/bundlephobia/minzip/i18nano)](https://bundlephobia.com/package/i18nano) [![BundlePhobia](https://img.shields.io/bundlephobia/min/i18nano)](https://bundlephobia.com/package/i18nano) > Internationalization for the react is done simply. @@ -87,6 +87,43 @@ export const LanguageChange = () => { If you use react 18 it is recommended to use `unstable_transition`. Then when you switch languages, the last downloaded translation will be displayed instead of the loader. +## Split + +You can use several TranslationProviders to split up translation files, for example: + +```tsx +import { TranslationProvider, Translation } from 'i18nano'; + +const translations = { + header: { + 'en': () => import('translations/header/en.json') + }, + main: { + 'en': () => import('translations/main/en.json') + } +}; + +export const Header = () => { + return ( + +
+ +
+
+ ); +}; + +export const Main = () => { + return ( + +

+ +

+
+ ); +}; +``` + ## Installation Recommend to use [yarn](https://classic.yarnpkg.com/en/docs/install/) for dependency management: diff --git a/package.json b/package.json index 798eb02..82a837b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "i18nano", - "version": "1.1.0", + "version": "1.2.0", "source": "./src/index.ts", "main": "./lib/index.cjs", "module": "./lib/index.js", @@ -39,23 +39,24 @@ "test": "yarn node --experimental-modules --experimental-vm-modules --enable-source-maps $(yarn bin jest)" }, "devDependencies": { - "@jest/types": "^27.5.1", + "@jest/types": "^28.0.2", "@mntm/eslint-config": "^1.1.2", "@types/get-value": "^3.0.2", "@types/jest": "^27.4.1", - "@types/node": "^17.0.23", - "@types/react": "^17.0.43", - "@types/react-test-renderer": "^17.0.1", - "eslint": "^8.12.0", - "eslint-plugin-jest": "^26.1.3", + "@types/node": "^17.0.30", + "@types/react": "^17.0.44", + "@types/react-test-renderer": "^17.0.2", + "eslint": "^8.14.0", + "eslint-plugin-jest": "^26.1.5", "eslint-plugin-jest-formatting": "^3.1.0", - "jest": "^27.5.1", + "jest": "^28.0.3", "jest-esbuild": "^0.2.6", "jest-ts-webcompat-resolver": "^1.0.0", "nanobundle": "^0.0.27", - "react": "^18.0.0", - "react-test-renderer": "^18.0.0", - "typescript": "^4.6.3" + "react": "^18.1.0", + "react-reconciler": "^0.28.0", + "react-test-renderer": "^18.1.0", + "typescript": "^4.6.4" }, "dependencies": { "get-value": "^3.0.1", diff --git a/src/react.ts b/src/react.ts index c567653..df18080 100644 --- a/src/react.ts +++ b/src/react.ts @@ -15,6 +15,12 @@ import { suspend, preload as suspendPreload } from 'suspend-react'; import { default as get } from 'get-value'; +const EMPTY = ''; + +const GET_OPTIONS = { + default: EMPTY +} as const; + const noop = () => { // Noop }; @@ -23,25 +29,25 @@ const invoke = (scope: () => void) => { scope(); }; +let id = 0; +const useInstanceId = () => React.useMemo(() => `${id++}`, []); + /** * React 18+ concurrent feature */ const { unstable_startTransition = invoke, - startTransition = unstable_startTransition + startTransition = unstable_startTransition, + + unstable_useOpaqueIdentifier = useInstanceId, + useId = unstable_useOpaqueIdentifier } = React as unknown as { unstable_startTransition: typeof invoke; startTransition: typeof invoke; -}; -const EMPTY = ''; - -const GET_OPTIONS = { - default: EMPTY -} as const; - -const PREFIX = '$$'; -const keyed = (key: string) => [PREFIX + key]; + unstable_useOpaqueIdentifier: typeof useInstanceId; + useId: typeof useInstanceId; +}; /** * @param path - property path like 'a.b.c' @@ -93,6 +99,9 @@ export const TranslationProvider: FC = ({ const [lang, setLanguage] = React.useState(language); const [current, setCurrent] = React.useState(language); + const instance = useId(); + const keyed = (key: string) => [instance + key]; + const preload = (next: string) => { suspendPreload(translations[next], keyed(next)); }; diff --git a/tests/all.spec.ts b/tests/all.spec.ts index 1943256..ff1f3a9 100644 --- a/tests/all.spec.ts +++ b/tests/all.spec.ts @@ -1,15 +1,22 @@ import React from 'react'; import renderer from 'react-test-renderer'; +import { waitForSuspense } from './suspense'; import * as Module from '../src'; const TRANSLATIONS = { - ru: async () => ({}), - it: async () => ({}) + ru: async () => ({ + ru: 'ru' + }), + it: async () => ({ + it: 'it' + }) }; const TRANSLATIONS_KEYS = Object.keys(TRANSLATIONS); +const SUSPENSE = 'suspense'; + const NOOP = () => { // Noop }; @@ -72,12 +79,12 @@ describe('all spec', () => { React.createElement(() => { const translation: Record = Module.useTranslationChange(); - return JSON.stringify(translation[by]); + return translation[by]; }) ) ); - expect(component.toJSON()).toBe(JSON.stringify(match)); + expect(component.toJSON()).toStrictEqual(match); }); it.each([ @@ -112,7 +119,79 @@ describe('all spec', () => { expect(component.toJSON()).toBe(from); await renderer.act(NOOP); + await waitForSuspense(NOOP); expect(component.toJSON()).toBe(to); }); + + it.each([ + [TRANSLATIONS_KEYS[0], TRANSLATIONS_KEYS[0], [ + [TRANSLATIONS_KEYS[0], SUSPENSE], + [TRANSLATIONS_KEYS[0], TRANSLATIONS_KEYS[0]], + [TRANSLATIONS_KEYS[0], TRANSLATIONS_KEYS[0]], + [TRANSLATIONS_KEYS[0], TRANSLATIONS_KEYS[0]] + ]], + [TRANSLATIONS_KEYS[0], TRANSLATIONS_KEYS[1], [ + [TRANSLATIONS_KEYS[0], SUSPENSE], + [TRANSLATIONS_KEYS[0], TRANSLATIONS_KEYS[0]], + TRANSLATIONS_KEYS[1], + TRANSLATIONS_KEYS[1] + ]], + [TRANSLATIONS_KEYS[1], TRANSLATIONS_KEYS[0], [ + [TRANSLATIONS_KEYS[1], SUSPENSE], + [TRANSLATIONS_KEYS[1], TRANSLATIONS_KEYS[1]], + TRANSLATIONS_KEYS[0], + TRANSLATIONS_KEYS[0] + ]], + [TRANSLATIONS_KEYS[1], TRANSLATIONS_KEYS[1], [ + [TRANSLATIONS_KEYS[1], SUSPENSE], + [TRANSLATIONS_KEYS[1], TRANSLATIONS_KEYS[1]], + [TRANSLATIONS_KEYS[1], TRANSLATIONS_KEYS[1]], + [TRANSLATIONS_KEYS[1], TRANSLATIONS_KEYS[1]] + ]] + ])('suspend correctly from "%s" to "%s"', async (from, to, cases) => { + expect.assertions(4); + + let change: (lang: string) => void; + + const component = renderer.create( + React.createElement( + Module.TranslationProvider, + { + language: from, + + // Prevent fallback + fallback: 'fallback', + translations: TRANSLATIONS + }, + + // @ts-expect-error DefinitelyTyped issue + React.createElement(() => { + const translation = Module.useTranslationChange(); + + change = translation.change; + + return translation.lang; + }), + React.createElement(Module.Translation, { + path: from + }, SUSPENSE) + ) + ); + + expect(component.toJSON()).toStrictEqual(cases[0]); + + await renderer.act(NOOP); + await waitForSuspense(NOOP); + + expect(component.toJSON()).toStrictEqual(cases[1]); + + await renderer.act(() => change(to)); + + expect(component.toJSON()).toStrictEqual(cases[2]); + + await waitForSuspense(NOOP); + + expect(component.toJSON()).toStrictEqual(cases[3]); + }); }); diff --git a/tests/suspense/index.ts b/tests/suspense/index.ts new file mode 100644 index 0000000..10deda1 --- /dev/null +++ b/tests/suspense/index.ts @@ -0,0 +1,49 @@ +/** + * Adopted from react-suspense-test-utils + * + * @module react-suspense-test-utils + * @see https://github.com/facebook/react/blob/main/packages/react-suspense-test-utils/src/ReactSuspenseTestUtils.js + */ + +import React from 'react'; + +const ReactCurrentDispatcher = (React as any).__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentDispatcher; + +export const waitForSuspense = async (fn: () => T): Promise => { + const cache = new Map(); + const testDispatcher = { + getCacheForType(resourceType: () => R): R { + let entry: R | void = cache.get(resourceType); + + if (typeof entry === 'undefined') { + entry = resourceType(); + cache.set(resourceType, entry); + } + + return entry; + } + }; + + return new Promise((resolve, reject) => { + const retry = () => { + const prevDispatcher = ReactCurrentDispatcher.current; + + ReactCurrentDispatcher.current = testDispatcher; + try { + const result = fn(); + + resolve(result); + } catch (ex: unknown) { + if (ex instanceof Promise) { + ex.then(retry, retry); + } else { + reject(ex); + } + } finally { + ReactCurrentDispatcher.current = prevDispatcher; + } + }; + + retry(); + }); +};