From 98e27d3778525663aa8a34b6d5cef620e47ff104 Mon Sep 17 00:00:00 2001 From: Li Hongzhang Date: Tue, 4 Aug 2026 12:27:22 +0800 Subject: [PATCH] refactor(i18n): pre-build lookup map for translations Memoize the translation lookup map and expose it via context instead of resolving keys on each call. This removes per-call chain resolution and the `useCallback` wrapper, improving performance for frequent translations. --- .../src/i18n/InternationalizationContext.ts | 3 + .../src/i18n/InternationalizationProvider.tsx | 9 +- .../core/src/i18n/__tests__/resolve.test.ts | 9 +- packages/core/src/i18n/resolve.ts | 103 +++++++++--------- packages/core/src/i18n/useTranslator.ts | 9 +- 5 files changed, 71 insertions(+), 62 deletions(-) diff --git a/packages/core/src/i18n/InternationalizationContext.ts b/packages/core/src/i18n/InternationalizationContext.ts index 8c8784037402..5557a51d1103 100644 --- a/packages/core/src/i18n/InternationalizationContext.ts +++ b/packages/core/src/i18n/InternationalizationContext.ts @@ -21,6 +21,7 @@ */ import {createContext} from 'react'; +import {getResolve} from './resolve'; import type {Locale, MessagesByLocale, Overrides} from './types'; export interface InternationalizationContextValue { @@ -28,6 +29,7 @@ export interface InternationalizationContextValue { direction: 'ltr' | 'rtl'; messages: MessagesByLocale; overrides?: Overrides; + translate: ReturnType; } /** @@ -39,5 +41,6 @@ export const InternationalizationContext = locale: 'en', direction: 'ltr', messages: {}, + translate: getResolve('en', {}), }); InternationalizationContext.displayName = 'InternationalizationContext'; diff --git a/packages/core/src/i18n/InternationalizationProvider.tsx b/packages/core/src/i18n/InternationalizationProvider.tsx index 66c8f8f569a4..268fe8f61e61 100644 --- a/packages/core/src/i18n/InternationalizationProvider.tsx +++ b/packages/core/src/i18n/InternationalizationProvider.tsx @@ -23,6 +23,7 @@ import {useMemo, type ReactNode} from 'react'; import {InternationalizationContext} from './InternationalizationContext'; import {getLocaleDirection} from './getLocaleDirection'; import type {Locale, MessagesByLocale, Overrides} from './types'; +import {getResolve} from './resolve'; export interface InternationalizationProviderProps { /** @@ -79,7 +80,13 @@ export function InternationalizationProvider({ }: InternationalizationProviderProps) { const direction = dir ?? getLocaleDirection(locale); const value = useMemo( - () => ({locale, direction, messages: messages ?? {}, overrides}), + () => ({ + locale, + direction, + messages: messages ?? {}, + overrides, + translate: getResolve(locale, messages ?? {}, overrides), + }), [locale, direction, messages, overrides], ); return ( diff --git a/packages/core/src/i18n/__tests__/resolve.test.ts b/packages/core/src/i18n/__tests__/resolve.test.ts index 76507d3c0ad8..56c6dea1a96b 100644 --- a/packages/core/src/i18n/__tests__/resolve.test.ts +++ b/packages/core/src/i18n/__tests__/resolve.test.ts @@ -9,9 +9,16 @@ */ import {describe, expect, test, beforeEach, vi} from 'vitest'; -import {__resetForTests, resolve, resolveLocaleChain} from '../resolve'; +import {__resetForTests, getResolve, resolveLocaleChain} from '../resolve'; import type {Catalog, MessagesByLocale, Overrides} from '../types'; +const resolve = ( + ...[key, values, locale, messages, overrides]: [ + ...Parameters>, + ...Parameters, + ] +) => getResolve(locale, messages, overrides)(key, values); + // Reset caches between tests so warn-once and formatter cache don't bleed. beforeEach(() => { __resetForTests(); diff --git a/packages/core/src/i18n/resolve.ts b/packages/core/src/i18n/resolve.ts index d75940868466..3f5e210c13fb 100644 --- a/packages/core/src/i18n/resolve.ts +++ b/packages/core/src/i18n/resolve.ts @@ -73,74 +73,71 @@ export function resolveLocaleChain(locale: Locale): Locale[] { return chain; } -function lookup( - key: string, +function getLookup( locale: Locale, messages: MessagesByLocale, - overrides: Overrides | undefined, -): string | null { + overrides?: Overrides, +): Record { + const lookup = {} as Record; const chain = resolveLocaleChain(locale); - // 1 + 2. Overrides (most specific to least specific in the chain) - if (overrides !== undefined) { - for (const tag of chain) { - const value = overrides[tag]?.[key]; - if (value !== undefined) { - return value; + for (const tag of chain) { + if (overrides?.[tag]) { + for (const [key, value] of Object.entries(overrides[tag])) { + if (lookup[key] === undefined && value !== null) { + lookup[key] = value; + } } } - } - - // 3 + 4. Shipped catalogs (most specific to least specific) - for (const tag of chain) { - const entry = messages[tag]?.[key]; - if (entry !== undefined) { - return entry.defaultMessage; + if (messages[tag]) { + for (const [key, value] of Object.entries(messages[tag])) { + if (lookup[key] === undefined && value?.defaultMessage !== null) { + lookup[key] = value?.defaultMessage; + } + } } } - - // 5. Shipped en catalog — always present - const enEntry = EN_CATALOG[key]; - if (enEntry !== undefined) { - return enEntry.defaultMessage; + for (const [key, value] of Object.entries(EN_CATALOG)) { + if (lookup[key] === undefined && value?.defaultMessage !== null) { + lookup[key] = value?.defaultMessage; + } } - - // 6. Nothing found - return null; + return lookup; } -export function resolve( - key: string, - values: Record | undefined, +export function getResolve( locale: Locale, messages: MessagesByLocale, - overrides: Overrides | undefined, -): string { - const result = lookup(key, locale, messages, overrides); - - if (result === null) { - // Fires ONLY when a key is missing from every source including the - // shipped `en` catalog — a real bug (typo, stale catalog, deleted key). - // Fallback to `en` from a non-en locale is expected and stays silent, - // matching the FormatJS / i18next default. - warnOnce( - `astryx-i18n:${locale}::${key}`, - 'astryx-i18n', - `missing key: ${key} (locale: ${locale})`, - ); - return key; - } + overrides?: Overrides, +) { + const lookup = getLookup(locale, messages, overrides); + + return (key: string, values: Record | undefined) => { + const result = lookup[key]; + if (result === undefined) { + // Fires ONLY when a key is missing from every source including the + // shipped `en` catalog — a real bug (typo, stale catalog, deleted key). + // Fallback to `en` from a non-en locale is expected and stays silent, + // matching the FormatJS / i18next default. + warnOnce( + `astryx-i18n:${locale}::${key}`, + 'astryx-i18n', + `missing key: ${key} (locale: ${locale})`, + ); + return key; + } - if (values === undefined) { - // Static string — skip the parser entirely for the common case - return result; - } + if (values === undefined) { + // Static string — skip the parser entirely for the common case + return result; + } - const formatted = getFormatter(result, locale).format(values); - // IntlMessageFormat.format returns string | (string | React elements) — we - // only ever pass string values so it will be a string; assert for the type - // system. - return formatted as string; + const formatted = getFormatter(result, locale).format(values); + // IntlMessageFormat.format returns string | (string | React elements) — we + // only ever pass string values so it will be a string; assert for the type + // system. + return formatted as string; + }; } /** diff --git a/packages/core/src/i18n/useTranslator.ts b/packages/core/src/i18n/useTranslator.ts index 86a3960be972..c7abd5dc450a 100644 --- a/packages/core/src/i18n/useTranslator.ts +++ b/packages/core/src/i18n/useTranslator.ts @@ -19,9 +19,8 @@ * - /packages/core/src/i18n/t.client.ts */ -import {use, useCallback} from 'react'; +import {use} from 'react'; import {InternationalizationContext} from './InternationalizationContext'; -import {resolve} from './resolve'; export type TranslatorFn = ( key: string, @@ -42,9 +41,5 @@ export type TranslatorFn = ( */ export function useTranslator(): TranslatorFn { const ctx = use(InternationalizationContext); - return useCallback( - (key: string, values?: Record) => - resolve(key, values, ctx.locale, ctx.messages, ctx.overrides), - [ctx.locale, ctx.messages, ctx.overrides], - ); + return ctx.translate; }