perf(i18n): optimize useI18n hook (#5037)

This commit is contained in:
Ken authored and GitHub committed 2026-07-06 11:17:00 +02:00
1 parent 26c1a5635f
commit c53714148f
6 files changed
+127 -19

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
---
"@navikt/ds-react": patch
---
Performance: Optimized internal i18n hook
+1
View File
@@ -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"
+8
View File
@@ -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 }]);
});
});
+27 -18
View File
@@ -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
View File
@@ -1,4 +1,10 @@
{
"extends": "./tsconfig.json",
"exclude": ["**/stories", "**/*.stories.*", "**/*.test.*", "**/*.e2e.*"]
"exclude": [
"**/stories",
"**/*.stories.*",
"**/*.test.*",
"**/*.e2e.*",
"**/*.bench.*"
]
}