Skip to content

Commit

Permalink
Use useId hook for key
Browse files Browse the repository at this point in the history
  • Loading branch information
eolme committed Apr 30, 2022
1 parent 873358a commit 29b6549
Show file tree
Hide file tree
Showing 5 changed files with 201 additions and 26 deletions.
39 changes: 38 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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 (
<TranslationProvider translations={translations.header}>
<header>
<Translation path="title" />
</header>
</TranslationProvider>
);
};

export const Main = () => {
return (
<TranslationProvider translations={translations.main}>
<h1>
<Translation path="title" />
</h1>
</TranslationProvider>
);
};
```

## Installation

Recommend to use [yarn](https://classic.yarnpkg.com/en/docs/install/) for dependency management:
Expand Down
23 changes: 12 additions & 11 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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",
Expand Down
29 changes: 19 additions & 10 deletions src/react.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
Expand All @@ -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'
Expand Down Expand Up @@ -93,6 +99,9 @@ export const TranslationProvider: FC<TranslationProviderProps> = ({
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));
};
Expand Down
87 changes: 83 additions & 4 deletions tests/all.spec.ts
Original file line number Diff line number Diff line change
@@ -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
};
Expand Down Expand Up @@ -72,12 +79,12 @@ describe('all spec', () => {
React.createElement(() => {
const translation: Record<string, unknown> = Module.useTranslationChange();

return JSON.stringify(translation[by]);
return translation[by];
})
)
);

expect(component.toJSON()).toBe(JSON.stringify(match));
expect(component.toJSON()).toStrictEqual(match);
});

it.each([
Expand Down Expand Up @@ -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]);
});
});
49 changes: 49 additions & 0 deletions tests/suspense/index.ts
Original file line number Diff line number Diff line change
@@ -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 <T>(fn: () => T): Promise<T> => {
const cache = new Map();
const testDispatcher = {
getCacheForType<R>(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();
});
};

0 comments on commit 29b6549

Please sign in to comment.