mirror of
https://github.com/navikt/aksel.git
synced 2026-10-12 16:52:03 +00:00
perf(i18n): optimize useI18n hook (#5037)
This commit is contained in:
6 files changed
+127
-19
No files matched your search
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@navikt/ds-react": patch
|
||||
---
|
||||
|
||||
Performance: Optimized internal i18n hook
|
||||
@@ -729,6 +729,7 @@
|
||||
"build": "yarn i18n-jsdoc && concurrently \"tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json\" \"tsc -p tsconfig.esm.json && tsc-alias -p tsconfig.esm.json && yarn write-packagejson\" && yarn i18n-jsdoc --cleanup && yarn copy-types",
|
||||
"watch": "tsc --watch -p tsconfig.esm.json",
|
||||
"test": "concurrently \"cross-env TZ=UTC vitest run -c tests/vitest.config.ts\" \"yarn test-sb-build\"",
|
||||
"test:bench": "cross-env TZ=UTC vitest bench -c tests/vitest.config.ts",
|
||||
"test-sb-build": "tsc -p tsconfig.sb.json",
|
||||
"test:watch": "TZ=UTC vitest watch -c tests/vitest.config.ts",
|
||||
"copy-types": "copyfiles -f ./src/types/theme.d.ts ./esm/types"
|
||||
|
||||
@@ -36,7 +36,14 @@ export function get(
|
||||
);
|
||||
}
|
||||
|
||||
const keypathCache = new Map<string, string[]>();
|
||||
|
||||
function getKeypath(str: string) {
|
||||
const cached = keypathCache.get(str);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const path: string[] = [];
|
||||
let result = OBJECT_NOTATION_MATCHER.exec(str);
|
||||
|
||||
@@ -46,5 +53,6 @@ function getKeypath(str: string) {
|
||||
result = OBJECT_NOTATION_MATCHER.exec(str);
|
||||
}
|
||||
|
||||
keypathCache.set(str, path);
|
||||
return path;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { bench, describe } from "vitest";
|
||||
import { Provider } from "../../provider";
|
||||
import { get } from "./get";
|
||||
import { useI18n } from "./i18n.hooks";
|
||||
import type { PartialTranslations } from "./i18n.types";
|
||||
import { nb } from "./locales";
|
||||
|
||||
describe("translate function throughput", () => {
|
||||
const { result } = renderHook(() => useI18n("FileUpload"));
|
||||
|
||||
bench("simple key lookup (no replacements)", () => {
|
||||
result.current("item.uploading");
|
||||
});
|
||||
|
||||
bench("key lookup with replacements", () => {
|
||||
result.current("item.uploading", { name: "Test", count: 5 } as never);
|
||||
});
|
||||
});
|
||||
|
||||
describe("translate with local overrides", () => {
|
||||
const local = {
|
||||
item: { uploading: "Hello {name}, you have {count} files" },
|
||||
};
|
||||
const { result } = renderHook(() => useI18n("FileUpload", local));
|
||||
|
||||
bench("local override with replacements", () => {
|
||||
result.current("item.uploading", { name: "Test", count: 5 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("translate with multiple context objects", () => {
|
||||
const i18n1 = { FileUpload: { dropzone: { button: "Override" } } } as const;
|
||||
const i18n2 = { FileUpload: { item: { uploading: "Override2" } } } as const;
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<Provider locale={nb} translations={[i18n1, i18n2]}>
|
||||
{children}
|
||||
</Provider>
|
||||
);
|
||||
const { result } = renderHook(() => useI18n("FileUpload"), { wrapper });
|
||||
|
||||
bench("hit in first context object", () => {
|
||||
result.current("dropzone.button");
|
||||
});
|
||||
|
||||
bench("hit in second context object", () => {
|
||||
result.current("item.uploading");
|
||||
});
|
||||
|
||||
bench("fallback to locale default", () => {
|
||||
result.current("item.downloading");
|
||||
});
|
||||
});
|
||||
|
||||
describe("get() function throughput", () => {
|
||||
const objects: (PartialTranslations | undefined)[] = [
|
||||
undefined,
|
||||
{ FileUpload: { item: { uploading: "First" } } },
|
||||
{ FileUpload: { item: { downloading: "Second" } } },
|
||||
{ FileUpload: nb.FileUpload },
|
||||
];
|
||||
|
||||
bench("shallow key hit in first object", () => {
|
||||
get("item.uploading", objects);
|
||||
});
|
||||
|
||||
bench("key hit in second object (skip first)", () => {
|
||||
get("item.downloading", objects);
|
||||
});
|
||||
|
||||
bench("deep fallback to last object", () => {
|
||||
get("dropzone.button", objects);
|
||||
});
|
||||
|
||||
bench("long key dot notation parsing", () => {
|
||||
get("dropzone.dragAndDropMultiple", [{ FileUpload: nb.FileUpload }]);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Locale } from "date-fns";
|
||||
import { useCallback, useRef } from "react";
|
||||
import { useProvider } from "../../provider/Provider";
|
||||
import { get } from "./get";
|
||||
import type {
|
||||
@@ -11,7 +12,7 @@ import type {
|
||||
/* https://regex101.com/r/LYKWi3/1 */
|
||||
const REPLACE_REGEX = /{[^}]*}/g;
|
||||
|
||||
export function useI18n<T extends Component>(
|
||||
function useI18n<T extends Component>(
|
||||
componentName: T,
|
||||
...localTranslations: (ComponentTranslation<T> | undefined)[]
|
||||
) {
|
||||
@@ -25,32 +26,38 @@ export function useI18n<T extends Component>(
|
||||
context.locale[componentName],
|
||||
];
|
||||
|
||||
const i18nRef = useRef(i18nObjects);
|
||||
i18nRef.current = i18nObjects;
|
||||
|
||||
/* https://github.com/Shopify/polaris/blob/2115f9ba2f5bcbf2ad15745233501bff2db81ecf/polaris-react/src/utilities/i18n/I18n.ts#L24 */
|
||||
const translate: TFunction<T> = (keypath, replacements) => {
|
||||
const text = get(keypath, i18nObjects);
|
||||
const translate = useCallback<TFunction<T>>(
|
||||
(keypath: string, replacements?: Record<string, string | number>) => {
|
||||
const text = get(keypath, i18nRef.current);
|
||||
|
||||
if (replacements) {
|
||||
return text.replace(REPLACE_REGEX, (match) => {
|
||||
const replacement = match.substring(1, match.length - 1);
|
||||
if (replacements) {
|
||||
return text.replace(REPLACE_REGEX, (match) => {
|
||||
const replacement = match.substring(1, match.length - 1);
|
||||
|
||||
if (replacements[replacement] === undefined) {
|
||||
const replacementData = JSON.stringify(replacements);
|
||||
throw new Error(
|
||||
`Error translating key '${keypath}'. No replacement syntax ({}) found for key '${replacement}'. The following replacements were passed: '${replacementData}'`,
|
||||
);
|
||||
}
|
||||
if (replacements[replacement] === undefined) {
|
||||
const replacementData = JSON.stringify(replacements);
|
||||
throw new Error(
|
||||
`Error translating key '${keypath}'. No replacement syntax ({}) found for key '${replacement}'. The following replacements were passed: '${replacementData}'`,
|
||||
);
|
||||
}
|
||||
|
||||
return replacements[replacement] as string; // can also be a number, but JS doesn't mind...
|
||||
});
|
||||
}
|
||||
return String(replacements[replacement]);
|
||||
});
|
||||
}
|
||||
|
||||
return text;
|
||||
};
|
||||
return text;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return translate;
|
||||
}
|
||||
|
||||
export function useDateLocale() {
|
||||
function useDateLocale() {
|
||||
const context = useProvider();
|
||||
const contextTranslations = context.translations || [];
|
||||
const i18nObjects = Array.isArray(contextTranslations)
|
||||
@@ -65,3 +72,5 @@ export function useDateLocale() {
|
||||
}
|
||||
throw new Error("dateLocale not found.");
|
||||
}
|
||||
|
||||
export { useI18n, useDateLocale };
|
||||
+7
-1
@@ -1,4 +1,10 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["**/stories", "**/*.stories.*", "**/*.test.*", "**/*.e2e.*"]
|
||||
"exclude": [
|
||||
"**/stories",
|
||||
"**/*.stories.*",
|
||||
"**/*.test.*",
|
||||
"**/*.e2e.*",
|
||||
"**/*.bench.*"
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user