@chipmobilesdk/rn-i18n 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,103 @@
1
1
  # @chipmobilesdk/rn-i18n
2
2
 
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - One diagnostic stream: install a sink with `installSdkSink` from
8
+ `@chipmobilesdk/rn-core` and it receives what every package observes (events,
9
+ failures, warnings) as `SdkDiagnostic`s, redacted. Listeners are notified through
10
+ rn-core, so a listener that throws no longer stops the others nor fails the call that
11
+ notified, and its error is reported rather than swallowed. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
12
+
13
+ - rn-ads: a banner's auto-refresh is a new cycle, so its `loaded` and `impression`
14
+ events are no longer deduplicated away; the dedupe memory is bounded. A throwing
15
+ status, consent or placement listener is reported as an `internal_error` event.
16
+ - rn-i18n: new `onDiagnostic` config receives every diagnostic (`I18nDiagnostic`,
17
+ with a stable `code`); without it, warnings go to `console.warn` in development
18
+ builds only. The warn-once memory is bounded.
19
+ - rn-chart: now depends on rn-core for its diagnostics.
20
+ - rn-payment: status, entitlement and pending listeners report a throw to the logger
21
+ instead of swallowing it.
22
+
23
+ - The package's errors extend `SdkError` from the new `@chipmobilesdk/rn-core`
24
+ dependency: `isSdkError` recognises them alongside every other package's, and each
25
+ carries `pkg`, `code` and `retryable`. Names and codes are unchanged. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
26
+
27
+ - rn-ai and rn-camera: `retryable` is the new name of `recoverable`, which stays as a
28
+ deprecated alias.
29
+ - rn-auth: `retryable` is true for `network-unavailable`. `redact` also removes URLs,
30
+ e-mail addresses and UUIDs.
31
+ - rn-ads: `redact` also removes JWTs and e-mail addresses, and reduces an `AdsError`
32
+ to its code.
33
+ - rn-ai: an error message quoted from the gateway is pattern-redacted.
34
+ - rn-camera: `redact` also removes JWTs, e-mail addresses and UUIDs.
35
+ - rn-theme: `ThemeConfigError` has `code: 'invalid_config'` and `ThemeContrastError`
36
+ `code: 'contrast'` (new `ThemeErrorCode` type).
37
+ - rn-i18n: `I18nConfigError` has `code: 'invalid_config'` and `UnitConversionError`
38
+ `code: 'unit_conversion'` (new `I18nErrorCode` type).
39
+
40
+ - Hooks read through `useSyncExternalStore` (rn-core's `useExternalStore`) and
41
+ re-render only when what they return changes. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
42
+
43
+ - rn-auth: `useProfile` and `useAccessTier` no longer re-render for a status change,
44
+ and the provider's context no longer changes on every transition. New
45
+ `useAuthSelector`. `auth.getState()` returns the same frozen object until the state
46
+ changes, instead of a copy per call.
47
+ - rn-payment: `useEntitlement` re-renders only when its own entitlement changes; the
48
+ engine keeps unchanged entitlement objects across syncs and does not notify for a
49
+ sync that changed nothing.
50
+ - rn-ads: `usePlacementState` and `useAdsStatus` read tear-free; `useAdsEvents` keeps
51
+ one subscription when given an inline listener.
52
+ - rn-i18n: `useTranslation` returns the same `t` until the locale changes, and
53
+ `useLocale`'s `setLocale` is stable.
54
+ - rn-ai: new `useAiBehaviorState`, with `client.subscribe` and a stable
55
+ `client.getBehaviorState()`.
56
+
57
+ - Every package's own copy is an rn-core message catalog that an app can translate. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
58
+
59
+ - rn-i18n: new `runtimeEntriesFromCatalog(catalog)` registers any SDK catalog in one
60
+ line, converting `{name}` variables to `{{name}}`.
61
+ - rn-ai: new `messageKeyForError(error)` gives the copy for every error code, and
62
+ `aiMessageSet`. The input placeholder default is neutral ("Type a message…"), and
63
+ two keys were added for errors that had none (`ai.error.signIn`, `ai.error.generic`).
64
+ - rn-auth: new `authMessages`, the catalog split into user and developer copy.
65
+ - rn-payment: new `paymentMessages`.
66
+ - rn-chart: summaries, announcements, adjustable-action labels, built-in state text
67
+ and legend hints come from `chartStrings`; pass `strings` to translate them. The
68
+ `accessibility.summaryFormatter` prop, declared before, now replaces the summary.
69
+
70
+ - Formatters come from rn-core's cache instead of being built per call. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
71
+
72
+ - rn-notification: resolving a time zone builds one `Intl.DateTimeFormat` per zone
73
+ instead of one per occurrence; expanding 32 series of 32 occurrences went from
74
+ about 230 ms to under 30 ms on Node. Zone validity is memoized.
75
+ - rn-i18n: number, currency and date formatters are cached. A bad option (an unknown
76
+ currency such as `usd1`) goes through the invalid-format policy instead of
77
+ throwing out of a render, and a locale's `defaultCurrency` must be a three-letter
78
+ ISO 4217 code at startup.
79
+
80
+ - One app-lifecycle source, and a startup that needs no extra frame. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
81
+
82
+ - rn-core: `appLifecycle()`, a shared, reference-counted `AppState` subscription that
83
+ reports changes only (`active`, `inactive`, `background`), with `onForeground` for a
84
+ return from the background and `createManualLifecycle` in `./testing`.
85
+ - rn-camera, rn-ads and rn-notification hear the lifecycle through it instead of
86
+ subscribing to `AppState` each, so an app holds one native listener. The camera
87
+ controller's core layer no longer imports `react-native`.
88
+ - rn-payment: `syncOnForeground(engine)` and `<PaymentProvider syncOnForeground>` call
89
+ `handleForeground()` on each return from the background. Off by default; calling
90
+ `handleForeground()` yourself works as before.
91
+ - rn-i18n: `i18n.initSync()` resolves the locale before the first render when the
92
+ storage adapter has the new optional `getItemSync`, and returns false otherwise.
93
+ `localeStorageFromKeyValue(port)` builds such an adapter from rn-core's
94
+ `KeyValuePort` (MMKV).
95
+
96
+ ### Patch Changes
97
+
98
+ - Updated dependencies ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17), [#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17), [#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17)):
99
+ - @chipmobilesdk/rn-core@0.1.0
100
+
3
101
  ## 0.1.3
4
102
 
5
103
  ### Patch Changes
package/README.md CHANGED
@@ -75,6 +75,13 @@ export const i18n = createI18n<AppI18nKey>({
75
75
  await i18n.init(); // persisted -> device -> primary fallback
76
76
  ```
77
77
 
78
+ With a storage adapter that reads synchronously (see below), resolve the locale before
79
+ the first render instead, so the first frame is already in the user's language:
80
+
81
+ ```ts
82
+ if (!i18n.initSync()) await i18n.init(); // initSync is false only for an async-only store
83
+ ```
84
+
78
85
  Wrap the app and use the hooks:
79
86
 
80
87
  ```tsx
@@ -95,15 +102,15 @@ t('cart.items', { count }, { count }); // plural variant by count
95
102
 
96
103
  | Capability | Member |
97
104
  |------------|--------|
98
- | Setup + validation | `createI18n(config)`, `I18nConfigError` |
105
+ | Setup + validation | `createI18n(config)`, `I18nConfigError` (an `SdkError` from `@chipmobilesdk/rn-core`, `code: 'invalid_config'`; `UnitConversionError` has `code: 'unit_conversion'`) |
99
106
  | Translation lookup | `i18n.t`, `useTranslation` |
100
107
  | Locale state + switching | `i18n.getLocale`, `i18n.setLocale`, `useLocale` |
101
108
  | Locale metadata (RTL/LTR, defaults) | `i18n.getSupportedLocales` |
102
- | Device-locale init | `i18n.init`, `defaultDeviceLocaleProvider` |
109
+ | Device-locale init | `i18n.init`, `i18n.initSync`, `defaultDeviceLocaleProvider` |
103
110
  | Formatting | `i18n.formatNumber/formatDate/formatCurrency/formatUnit` |
104
111
  | Unit conversion | `i18n.convertUnit`, `convertUnit`, `getUnitSymbol`, `SUPPORTED_UNITS`, `UnitConversionError` |
105
112
  | Runtime-only declarations | `i18n.registerRuntimeEntries` |
106
- | Persistence key | `LOCALE_STORAGE_KEY` |
113
+ | Persistence | `LOCALE_STORAGE_KEY`, `localeStorageFromKeyValue` |
107
114
  | React | `I18nProvider` |
108
115
 
109
116
  Public contract types also include the configuration, locale, translation,
@@ -126,9 +133,20 @@ export const storageAdapter: LocaleStorageAdapter = {
126
133
  };
127
134
  ```
128
135
 
129
- An MMKV-backed adapter (wrapping its synchronous API in `Promise.resolve`)
130
- works equally well. If persistence is enabled without an adapter, `createI18n`
131
- throws before any localized content is served.
136
+ An adapter may also have `getItemSync(key)`. With it, `i18n.initSync()` reads the
137
+ saved locale synchronously and the app skips the frame it would otherwise render in
138
+ the fallback language. MMKV through rn-core's `KeyValuePort` is one line:
139
+
140
+ ```ts
141
+ import { openDurable } from '@chipmobilesdk/rn-core';
142
+ import { localeStorageFromKeyValue } from '@chipmobilesdk/rn-i18n';
143
+ import { createMMKV } from 'react-native-mmkv';
144
+
145
+ const storage = localeStorageFromKeyValue(openDurable(() => createMMKV({ id: 'app' })));
146
+ ```
147
+
148
+ If persistence is enabled without an adapter, `createI18n` throws before any localized
149
+ content is served.
132
150
 
133
151
  ### Device-locale provider (optional)
134
152
 
@@ -194,6 +212,47 @@ lookup, fallback, interpolation, and missing-key behavior, and are never
194
212
  removed by synchronization. Policy `add` writes the default text to missing
195
213
  locales; `report` only lists them.
196
214
 
215
+ ### SDK copy
216
+
217
+ Every `@chipmobilesdk` package with user-facing copy exports it as a catalog
218
+ (`key -> English default`). `runtimeEntriesFromCatalog(catalog, { policy? })`
219
+ turns one into runtime entries, converting `{name}` variables to `{{name}}`, so an
220
+ app translates SDK copy like its own:
221
+
222
+ ```ts
223
+ createI18n({
224
+ ...config,
225
+ runtimeEntries: [
226
+ ...runtimeEntriesFromCatalog(aiMessageSet.user),
227
+ ...runtimeEntriesFromCatalog(paymentMessages.user),
228
+ ],
229
+ });
230
+ ```
231
+
232
+ ## Formatting
233
+
234
+ `formatNumber`, `formatCurrency`, `formatDate` and `formatUnit` reuse one cached
235
+ `Intl` formatter per locale and options. A value or option they cannot format (a
236
+ `NaN`, an unknown currency code) is handled by the `invalidFormat` policy: the
237
+ placeholder text, or a thrown error in `error` mode, and an `invalid_format`
238
+ diagnostic either way. It never throws from inside `Intl` during a render. A
239
+ locale's `defaultCurrency` must be a three-letter ISO 4217 code, checked at startup.
240
+
241
+ ## Diagnostics
242
+
243
+ Missing translations and variables, fallbacks, invalid formats, persistence
244
+ failures and a throwing locale listener are each reported once per lookup path as an
245
+ `I18nDiagnostic` (`code`, `message`, and the `key` and `locale` involved):
246
+
247
+ - to `onDiagnostic` in the config, when given;
248
+ - otherwise to `console.warn`, in development builds only;
249
+ - and always to the app's SDK sinks (`installSdkSink` from `@chipmobilesdk/rn-core`),
250
+ as `code` with `key` and `locale`.
251
+
252
+ ```ts
253
+ const i18n = createI18n({ ...config, onDiagnostic: d => crashlytics.log(`${d.code} ${d.key ?? ''}`) });
254
+ ```
255
+
197
256
  ## Compatibility
198
257
 
199
258
  | Surface | Baseline |
@@ -207,7 +266,7 @@ locales; `report` only lists them.
207
266
 
208
267
  ```bash
209
268
  npm test # package + demo suites
210
- npm run typecheck --workspace @chipmobilesdk/rn-i18n # tsc -p packages/rn-i18n/tsconfig.json --noEmit
269
+ npm run typecheck --workspace @chipmobilesdk/rn-i18n # source, tests and the Node CLI
211
270
  npm run pack:check --workspace @chipmobilesdk/rn-i18n # npm pack --workspace @chipmobilesdk/rn-i18n --dry-run
212
271
  npm run i18n:sync # extraction + synchronization
213
272
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chipmobilesdk/rn-i18n",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "main": "src/index.ts",
@@ -32,9 +32,12 @@
32
32
  ],
33
33
  "sideEffects": false,
34
34
  "scripts": {
35
- "typecheck": "tsc -p tsconfig.json --noEmit",
35
+ "typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.test.json && tsc -p tsconfig.tooling.json",
36
36
  "pack:check": "npm pack --dry-run"
37
37
  },
38
+ "dependencies": {
39
+ "@chipmobilesdk/rn-core": "^0.1.0"
40
+ },
38
41
  "peerDependencies": {
39
42
  "react": ">=19",
40
43
  "react-native": ">=0.85"
@@ -40,7 +40,11 @@ Runtime:
40
40
  - `useLocale()` → `UseLocaleResult`
41
41
  - `defaultDeviceLocaleProvider` — device locale detection
42
42
  - `LOCALE_STORAGE_KEY` — where the chosen locale persists
43
- - `I18nConfigError` — thrown on invalid configuration at startup
43
+ - `i18n.initSync()` — resolve the locale before the first render when storage reads
44
+ synchronously; `localeStorageFromKeyValue(port)` makes such storage from an rn-core
45
+ key-value port (MMKV)
46
+ - `I18nConfigError` — thrown on invalid configuration at startup; an rn-core SDK error with an
47
+ `I18nErrorCode`
44
48
 
45
49
  Types: `I18n`, `UnitFormatOptions`, `I18nProviderProps`, `UseLocaleResult`,
46
50
  `UseTranslationResult`
@@ -0,0 +1,25 @@
1
+ import type { RuntimeTranslationEntry } from './types';
2
+
3
+ /**
4
+ * Runtime entries for a package's message catalog (I18N-13), so an app registers
5
+ * an SDK's copy in one line and translates it like its own:
6
+ *
7
+ * ```ts
8
+ * createI18n({ ...config, runtimeEntries: runtimeEntriesFromCatalog(paymentMessages.user) });
9
+ * ```
10
+ *
11
+ * Catalogs mark variables `{name}`; rn-i18n's are `{{name}}`, so they are
12
+ * converted. `policy: 'add'` lets the sync CLI write missing keys into each locale
13
+ * file with the default text.
14
+ */
15
+ export function runtimeEntriesFromCatalog(
16
+ catalog: Readonly<Record<string, string>>,
17
+ options: { readonly policy?: 'add' | 'report'; readonly description?: string } = {},
18
+ ): RuntimeTranslationEntry[] {
19
+ return Object.entries(catalog).map(([key, text]) => ({
20
+ key,
21
+ defaultText: text.replace(/(?<!\{)\{(\w+)\}(?!\})/g, '{{$1}}'),
22
+ policy: options.policy ?? 'report',
23
+ ...(options.description === undefined ? {} : { description: options.description }),
24
+ }));
25
+ }
@@ -0,0 +1,68 @@
1
+ import {
2
+ createDiagnosticChannel,
3
+ redactPatterns,
4
+ type DiagnosticSeverity,
5
+ } from '@chipmobilesdk/rn-core';
6
+
7
+ /** What rn-i18n reports. Stable: an app may branch on these. */
8
+ export type I18nDiagnosticCode =
9
+ | 'missing_translation'
10
+ | 'fallback_used'
11
+ | 'missing_variable'
12
+ | 'invalid_value'
13
+ | 'invalid_format'
14
+ | 'locale_unsupported'
15
+ | 'locale_read_failed'
16
+ | 'locale_write_failed'
17
+ | 'listener_threw';
18
+
19
+ export interface I18nDiagnostic {
20
+ readonly code: I18nDiagnosticCode;
21
+ /** For people: names the key, locale or variable involved. */
22
+ readonly message: string;
23
+ readonly key?: string;
24
+ readonly locale?: string;
25
+ }
26
+
27
+ export type I18nDiagnosticHandler = (diagnostic: I18nDiagnostic) => void;
28
+
29
+ const SEVERITY: Record<I18nDiagnosticCode, DiagnosticSeverity> = {
30
+ missing_translation: 'warn',
31
+ fallback_used: 'info',
32
+ missing_variable: 'warn',
33
+ invalid_value: 'warn',
34
+ invalid_format: 'warn',
35
+ locale_unsupported: 'warn',
36
+ locale_read_failed: 'warn',
37
+ locale_write_failed: 'warn',
38
+ listener_threw: 'error',
39
+ };
40
+
41
+ const channel = createDiagnosticChannel<I18nDiagnosticCode>('rn-i18n');
42
+
43
+ /**
44
+ * One place every diagnostic goes through (I18N-11): the app's SDK sinks always
45
+ * (`installSdkSink` from rn-core), then `onDiagnostic` when the app gave one,
46
+ * otherwise `console.warn` in development builds and tests only.
47
+ */
48
+ export function createReporter(onDiagnostic?: I18nDiagnosticHandler): I18nDiagnosticHandler {
49
+ return diagnostic => {
50
+ channel.emit(diagnostic.code, SEVERITY[diagnostic.code], {
51
+ ...(diagnostic.key === undefined ? {} : { key: diagnostic.key }),
52
+ ...(diagnostic.locale === undefined ? {} : { locale: diagnostic.locale }),
53
+ });
54
+ if (onDiagnostic) {
55
+ onDiagnostic(diagnostic);
56
+ } else if (typeof __DEV__ === 'undefined' || __DEV__) {
57
+ console.warn(`[rn-i18n] ${diagnostic.message}`);
58
+ }
59
+ };
60
+ }
61
+
62
+ /** For the few call sites that run before or outside an i18n instance. */
63
+ export const defaultReporter = createReporter();
64
+
65
+ /** Foreign error text, shaped before it reaches a message. */
66
+ export function describeError(error: unknown): string {
67
+ return redactPatterns(error instanceof Error ? error.message : String(error));
68
+ }
@@ -0,0 +1,28 @@
1
+ import { SdkError } from '@chipmobilesdk/rn-core';
2
+
3
+ /** The package's error codes. */
4
+ export type I18nErrorCode = 'invalid_config' | 'unit_conversion';
5
+
6
+ /**
7
+ * Configuration error listing every detected violation. An `SdkError`
8
+ * (`@chipmobilesdk/rn-core`) with `pkg: 'rn-i18n'`.
9
+ */
10
+ export class I18nConfigError extends SdkError<I18nErrorCode> {
11
+ readonly issues: readonly string[];
12
+
13
+ constructor(issues: readonly string[]) {
14
+ super('rn-i18n', 'invalid_config', {
15
+ message: `Invalid i18n configuration:\n- ${issues.join('\n- ')}`,
16
+ });
17
+ this.name = 'I18nConfigError';
18
+ this.issues = issues;
19
+ }
20
+ }
21
+
22
+ /** A unit conversion the package cannot perform, such as across dimensions. */
23
+ export class UnitConversionError extends SdkError<I18nErrorCode> {
24
+ constructor(message: string) {
25
+ super('rn-i18n', 'unit_conversion', { message });
26
+ this.name = 'UnitConversionError';
27
+ }
28
+ }
@@ -1,12 +1,14 @@
1
1
  /**
2
2
  * Public configuration and contract types for @chipmobilesdk/rn-i18n.
3
3
  *
4
- * The package has zero runtime dependencies: formatting uses the Intl APIs
4
+ * The package has one runtime dependency, rn-core; formatting uses the Intl APIs
5
5
  * built into Hermes (Intl.NumberFormat, Intl.DateTimeFormat), while plural
6
6
  * rules and unit conversion are package-owned because Intl.PluralRules and
7
7
  * Intl unit-style formatting are not reliably available in Hermes.
8
8
  */
9
9
 
10
+ import type { I18nDiagnosticHandler } from './diagnostics';
11
+
10
12
  export type LocaleDirection = 'ltr' | 'rtl';
11
13
 
12
14
  export type PluralCategory = 'zero' | 'one' | 'two' | 'few' | 'many' | 'other';
@@ -93,6 +95,12 @@ export interface LocaleStorageAdapter {
93
95
  getItem(key: string): Promise<string | null>;
94
96
  setItem(key: string, value: string): Promise<void>;
95
97
  removeItem(key: string): Promise<void>;
98
+ /**
99
+ * A synchronous read, for a store that has one (MMKV, rn-core's `KeyValuePort`).
100
+ * With it, `initSync()` resolves the locale before the first render, so the app
101
+ * never paints a frame in the wrong language (PERF-08).
102
+ */
103
+ getItemSync?(key: string): string | null;
96
104
  }
97
105
 
98
106
  /** Returns the device locale, or undefined when detection is unavailable. */
@@ -108,6 +116,12 @@ export interface I18nConfig {
108
116
  persistLocale?: boolean;
109
117
  /** Required when `persistLocale` is not disabled. */
110
118
  storage?: LocaleStorageAdapter;
119
+ /**
120
+ * Receives every diagnostic: missing translations and variables, fallbacks,
121
+ * invalid formats, persistence failures. Defaults to `console.warn` in
122
+ * development builds only. Diagnostics also reach rn-core's SDK sinks.
123
+ */
124
+ onDiagnostic?: I18nDiagnosticHandler;
111
125
  deviceLocale?: DeviceLocaleProvider;
112
126
  /** Default: `{ mode: 'key' }`. */
113
127
  missingTranslation?: MissingTranslationPolicy;
@@ -132,13 +146,5 @@ export interface TranslateOptions {
132
146
  count?: number;
133
147
  }
134
148
 
135
- /** Configuration error listing every detected violation. */
136
- export class I18nConfigError extends Error {
137
- readonly issues: readonly string[];
138
-
139
- constructor(issues: readonly string[]) {
140
- super(`Invalid i18n configuration:\n- ${issues.join('\n- ')}`);
141
- this.name = 'I18nConfigError';
142
- this.issues = issues;
143
- }
144
- }
149
+ // Kept importable from here for compatibility; the errors live in config/errors.ts (I18N-14).
150
+ export { I18nConfigError } from './errors';
@@ -85,6 +85,11 @@ function validateLocale(
85
85
  }
86
86
  if (!locale.defaultCurrency) {
87
87
  issues.push(`Locale "${code}" must declare a default currency.`);
88
+ } else if (!/^[A-Z]{3}$/.test(locale.defaultCurrency)) {
89
+ // An ISO 4217 code; anything else would fail every currency format (I18N-08).
90
+ issues.push(
91
+ `Locale "${code}" default currency "${locale.defaultCurrency}" is not a three-letter ISO 4217 code.`,
92
+ );
88
93
  }
89
94
  if (!knownUnits.has(locale.defaultUnit)) {
90
95
  issues.push(`Locale "${code}" declares unknown default unit "${locale.defaultUnit}".`);
@@ -1,3 +1,4 @@
1
+ import { getNumberFormat } from '@chipmobilesdk/rn-core';
1
2
  import type { InvalidFormatPolicy } from '../config/types';
2
3
  import { applyInvalidFormatPolicy } from './invalid';
3
4
 
@@ -19,9 +20,15 @@ export function formatCurrencyValue(
19
20
  `Cannot format invalid currency value "${String(value)}" for locale "${localeCode}".`,
20
21
  );
21
22
  }
22
- return new Intl.NumberFormat(localeCode, {
23
- ...options,
24
- style: 'currency',
25
- currency: currencyOverride ?? defaultCurrency,
26
- }).format(value);
23
+ const currency = currencyOverride ?? defaultCurrency;
24
+ // rn-core's cached formatter (I18N-03). An unknown currency code (`usd1`) used
25
+ // to throw out of render; it is a format problem now (I18N-08).
26
+ try {
27
+ return getNumberFormat(localeCode, { ...options, style: 'currency', currency }).format(value);
28
+ } catch (error) {
29
+ return applyInvalidFormatPolicy(
30
+ invalidFormat,
31
+ `Cannot format currency "${currency}" for locale "${localeCode}": ${String(error)}`,
32
+ );
33
+ }
27
34
  }
@@ -1,3 +1,4 @@
1
+ import { getDateTimeFormat } from '@chipmobilesdk/rn-core';
1
2
  import type { InvalidFormatPolicy } from '../config/types';
2
3
  import { applyInvalidFormatPolicy } from './invalid';
3
4
 
@@ -22,5 +23,10 @@ export function formatDateValue(
22
23
  );
23
24
  }
24
25
  const options = override ?? defaults;
25
- return new Intl.DateTimeFormat(localeCode, options).format(date);
26
+ // rn-core's cached formatter (I18N-03); a bad option is a format problem (I18N-08).
27
+ try {
28
+ return getDateTimeFormat(localeCode, options).format(date);
29
+ } catch (error) {
30
+ return applyInvalidFormatPolicy(invalidFormat, `Cannot format a date: ${String(error)}`);
31
+ }
26
32
  }
@@ -1,11 +1,20 @@
1
+ import { defaultReporter, type I18nDiagnosticHandler } from '../config/diagnostics';
1
2
  import type { InvalidFormatPolicy } from '../config/types';
2
3
 
4
+ /** The policy as an i18n instance resolves it: with the instance's reporter. */
5
+ export type ResolvedInvalidFormatPolicy = InvalidFormatPolicy & {
6
+ readonly report?: I18nDiagnosticHandler | undefined;
7
+ };
8
+
3
9
  /**
4
10
  * Applies the configured invalid-format behavior: return the placeholder, or
5
11
  * throw a clear formatting error in `error` mode (FR-037, FR-045).
6
12
  */
7
- export function applyInvalidFormatPolicy(policy: InvalidFormatPolicy, message: string): string {
8
- console.warn(`[rn-i18n] ${message}`);
13
+ export function applyInvalidFormatPolicy(
14
+ policy: ResolvedInvalidFormatPolicy,
15
+ message: string,
16
+ ): string {
17
+ (policy.report ?? defaultReporter)({ code: 'invalid_format', message });
9
18
  if (policy.mode === 'error') {
10
19
  throw new RangeError(`[rn-i18n] ${message}`);
11
20
  }
@@ -1,3 +1,4 @@
1
+ import { getNumberFormat } from '@chipmobilesdk/rn-core';
1
2
  import type { InvalidFormatPolicy } from '../config/types';
2
3
  import { applyInvalidFormatPolicy } from './invalid';
3
4
 
@@ -19,5 +20,11 @@ export function formatNumberValue(
19
20
  `Cannot format invalid number value "${String(value)}" for locale "${localeCode}".`,
20
21
  );
21
22
  }
22
- return new Intl.NumberFormat(localeCode, { ...defaults, ...override }).format(value);
23
+ // rn-core's cached formatter (I18N-03); a bad option is a format problem, not a
24
+ // crash in render (I18N-08).
25
+ try {
26
+ return getNumberFormat(localeCode, { ...defaults, ...override }).format(value);
27
+ } catch (error) {
28
+ return applyInvalidFormatPolicy(invalidFormat, `Cannot format a number: ${String(error)}`);
29
+ }
23
30
  }
@@ -39,12 +39,10 @@ export const SUPPORTED_UNITS = Object.keys(UNIT_REGISTRY) as readonly UnitId[];
39
39
 
40
40
  export const KNOWN_UNIT_IDS: ReadonlySet<string> = new Set(SUPPORTED_UNITS);
41
41
 
42
- export class UnitConversionError extends Error {
43
- constructor(message: string) {
44
- super(message);
45
- this.name = 'UnitConversionError';
46
- }
47
- }
42
+ // Kept importable from here for compatibility; the errors live in config/errors.ts.
43
+ import { UnitConversionError } from '../config/errors';
44
+
45
+ export { UnitConversionError };
48
46
 
49
47
  export function getUnitSymbol(unit: UnitId): string {
50
48
  return UNIT_REGISTRY[unit].symbol;
package/src/i18n.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { createReporter } from './config/diagnostics';
2
+ import type { ResolvedInvalidFormatPolicy } from './format/invalid';
1
3
  import type {
2
4
  ActiveLocalePreference,
3
5
  I18nConfig,
@@ -12,7 +14,11 @@ import type {
12
14
  import { validateConfig } from './config/validateConfig';
13
15
  import { createActiveLocaleStore } from './locale/activeLocale';
14
16
  import { defaultDeviceLocaleProvider, matchSupportedLocale } from './locale/deviceLocale';
15
- import { readPersistedLocale, writePersistedLocale } from './locale/persistence';
17
+ import {
18
+ readPersistedLocale,
19
+ readPersistedLocaleSync,
20
+ writePersistedLocale,
21
+ } from './locale/persistence';
16
22
  import { translate } from './translation/resolver';
17
23
  import type { ResolverContext } from './translation/resolver';
18
24
  import { createRuntimeRegistry, registerRuntimeEntries } from './translation/runtimeKeys';
@@ -25,6 +31,13 @@ import type { UnitFormatOptions } from './format/units';
25
31
  export interface I18n<TKey extends string = string> {
26
32
  /** Resolves the initial locale: persisted → device → primary fallback. */
27
33
  init(): Promise<void>;
34
+ /**
35
+ * `init()` without waiting, before the first render (PERF-08). Resolves the locale
36
+ * and returns true when it can do so synchronously: persistence is off, or the
37
+ * storage adapter has `getItemSync`. Returns false, changing nothing, when the
38
+ * adapter can only read asynchronously; call `init()` then.
39
+ */
40
+ initSync(): boolean;
28
41
  t(key: TKey, vars?: TranslateVars, options?: TranslateOptions): string;
29
42
  getLocale(): ActiveLocalePreference;
30
43
  /** Switches at runtime without restart and persists best-effort. */
@@ -55,15 +68,18 @@ export function createI18n<TKey extends string = string>(config: I18nConfig): I1
55
68
  const locales: readonly LocaleConfig[] = [...config.locales];
56
69
  const localesByCode = new Map(locales.map(locale => [locale.code, locale]));
57
70
  const missingTranslation = config.missingTranslation ?? DEFAULT_MISSING;
58
- const invalidFormat = config.invalidFormat ?? DEFAULT_INVALID;
71
+ const report = createReporter(config.onDiagnostic);
72
+ const invalidFormat: ResolvedInvalidFormatPolicy = {
73
+ ...(config.invalidFormat ?? DEFAULT_INVALID),
74
+ report,
75
+ };
59
76
  const persistenceEnabled = config.persistLocale !== false && !!config.storage;
60
77
  const runtimeEntries = createRuntimeRegistry(config.runtimeEntries);
61
78
 
62
- const store = createActiveLocaleStore({
63
- code: config.primaryLocale,
64
- source: 'primaryFallback',
65
- recovered: false,
66
- });
79
+ const store = createActiveLocaleStore(
80
+ { code: config.primaryLocale, source: 'primaryFallback', recovered: false },
81
+ report,
82
+ );
67
83
 
68
84
  const resolverContext: ResolverContext = {
69
85
  localesByCode,
@@ -71,6 +87,7 @@ export function createI18n<TKey extends string = string>(config: I18nConfig): I1
71
87
  runtimeEntries,
72
88
  missingTranslation,
73
89
  warned: new Set(),
90
+ report,
74
91
  };
75
92
 
76
93
  const activeLocale = (): LocaleConfig => {
@@ -82,37 +99,55 @@ export function createI18n<TKey extends string = string>(config: I18nConfig): I1
82
99
  return locale;
83
100
  };
84
101
 
102
+ /** persisted → device → primary fallback; shared by `init` and `initSync`. */
103
+ const resolveInitialLocale = (persisted: string | null): void => {
104
+ if (persisted) {
105
+ if (localesByCode.has(persisted)) {
106
+ store.set({ code: persisted, source: 'persisted', recovered: false });
107
+ return;
108
+ }
109
+ report({
110
+ code: 'locale_unsupported',
111
+ locale: persisted,
112
+ message: `Persisted locale "${persisted}" is no longer supported; recovering to "${config.primaryLocale}".`,
113
+ });
114
+ store.set({ code: config.primaryLocale, source: 'primaryFallback', recovered: true });
115
+ return;
116
+ }
117
+ const provider = config.deviceLocale ?? defaultDeviceLocaleProvider;
118
+ const match = matchSupportedLocale(provider(), locales);
119
+ store.set(
120
+ match
121
+ ? { code: match.code, source: match.source, recovered: false }
122
+ : { code: config.primaryLocale, source: 'primaryFallback', recovered: false },
123
+ );
124
+ };
125
+
85
126
  return {
86
127
  async init() {
87
- if (persistenceEnabled && config.storage) {
88
- const persisted = await readPersistedLocale(config.storage);
89
- if (persisted) {
90
- if (localesByCode.has(persisted)) {
91
- store.set({ code: persisted, source: 'persisted', recovered: false });
92
- return;
93
- }
94
- console.warn(
95
- `[rn-i18n] Persisted locale "${persisted}" is no longer supported; recovering to "${config.primaryLocale}".`,
96
- );
97
- store.set({
98
- code: config.primaryLocale,
99
- source: 'primaryFallback',
100
- recovered: true,
101
- });
102
- return;
103
- }
128
+ resolveInitialLocale(
129
+ persistenceEnabled && config.storage
130
+ ? await readPersistedLocale(config.storage, report)
131
+ : null,
132
+ );
133
+ },
134
+
135
+ initSync() {
136
+ if (!persistenceEnabled || !config.storage) {
137
+ resolveInitialLocale(null);
138
+ return true;
139
+ }
140
+ const { storage } = config;
141
+ if (typeof storage.getItemSync !== 'function') {
142
+ return false;
104
143
  }
105
- const provider = config.deviceLocale ?? defaultDeviceLocaleProvider;
106
- const match = matchSupportedLocale(provider(), locales);
107
- store.set(
108
- match
109
- ? { code: match.code, source: match.source, recovered: false }
110
- : {
111
- code: config.primaryLocale,
112
- source: 'primaryFallback',
113
- recovered: false,
114
- },
144
+ resolveInitialLocale(
145
+ readPersistedLocaleSync(
146
+ storage as typeof storage & { getItemSync(key: string): string | null },
147
+ report,
148
+ ),
115
149
  );
150
+ return true;
116
151
  },
117
152
 
118
153
  t(key, vars, options) {
@@ -129,7 +164,7 @@ export function createI18n<TKey extends string = string>(config: I18nConfig): I1
129
164
  }
130
165
  store.set({ code, source: 'runtimeSwitch', recovered: false });
131
166
  if (persistenceEnabled && config.storage) {
132
- await writePersistedLocale(config.storage, code);
167
+ await writePersistedLocale(config.storage, code, report);
133
168
  }
134
169
  },
135
170
 
package/src/index.ts CHANGED
@@ -11,6 +11,14 @@ export { createI18n } from './i18n';
11
11
  export type { I18n } from './i18n';
12
12
 
13
13
  export { I18nConfigError } from './config/types';
14
+ export { runtimeEntriesFromCatalog } from './config/catalog';
15
+ export { localeStorageFromKeyValue } from './locale/keyValueStorage';
16
+ export type { I18nErrorCode } from './config/errors';
17
+ export type {
18
+ I18nDiagnostic,
19
+ I18nDiagnosticCode,
20
+ I18nDiagnosticHandler,
21
+ } from './config/diagnostics';
14
22
  export type {
15
23
  ActiveLocalePreference,
16
24
  DeviceLocaleProvider,
@@ -1,3 +1,6 @@
1
+ import { createListenerSet } from '@chipmobilesdk/rn-core';
2
+
3
+ import { defaultReporter, describeError, type I18nDiagnosticHandler } from '../config/diagnostics';
1
4
  import type { ActiveLocalePreference } from '../config/types';
2
5
 
3
6
  export interface ActiveLocaleStore {
@@ -8,39 +11,32 @@ export interface ActiveLocaleStore {
8
11
  getVersion(): number;
9
12
  }
10
13
 
11
- export function createActiveLocaleStore(initial: ActiveLocalePreference): ActiveLocaleStore {
14
+ export function createActiveLocaleStore(
15
+ initial: ActiveLocalePreference,
16
+ report: I18nDiagnosticHandler = defaultReporter,
17
+ ): ActiveLocaleStore {
12
18
  let preference = initial;
13
19
  let version = 0;
14
- const listeners = new Set<() => void>();
20
+ const listeners = createListenerSet<[]>({
21
+ onListenerError: error =>
22
+ report({
23
+ code: 'listener_threw',
24
+ message: `A locale listener threw; the locale change still applied: ${describeError(error)}`,
25
+ }),
26
+ });
15
27
 
16
28
  return {
17
29
  get: () => preference,
18
30
  set(next) {
19
31
  preference = next;
20
32
  version += 1;
21
- // Snapshot, and isolate each listener (CORE-03). One that threw used to
22
- // skip every listener after it — useSyncExternalStore subscribers
23
- // included — and made setLocale reject after the switch but before the
24
- // choice was persisted, so the app showed one locale and stored another.
25
- for (const listener of [...listeners]) {
26
- // Unsubscribed by an earlier listener during this notification.
27
- if (!listeners.has(listener)) continue;
28
- try {
29
- listener();
30
- } catch (error) {
31
- console.warn(
32
- '[rn-i18n] A locale listener threw; the locale change still applied.',
33
- error,
34
- );
35
- }
36
- }
37
- },
38
- subscribe(listener) {
39
- listeners.add(listener);
40
- return () => {
41
- listeners.delete(listener);
42
- };
33
+ // Isolated (CORE-03). One that threw used to skip every listener after
34
+ // it — useSyncExternalStore subscribers included — and made setLocale
35
+ // reject after the switch but before the choice was persisted, so the
36
+ // app showed one locale and stored another.
37
+ listeners.notify();
43
38
  },
39
+ subscribe: listener => listeners.add(listener),
44
40
  getVersion: () => version,
45
41
  };
46
42
  }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * A `LocaleStorageAdapter` over rn-core's synchronous `KeyValuePort` (PERF-08).
3
+ *
4
+ * The adapter carries `getItemSync`, so `i18n.initSync()` resolves the persisted
5
+ * locale before the first render. Open the port the way the app opens its other
6
+ * durable state, for example `openDurable(() => createMMKV({ id: 'app' }))`.
7
+ */
8
+ import type { KeyValuePort } from '@chipmobilesdk/rn-core';
9
+
10
+ import type { LocaleStorageAdapter } from '../config/types';
11
+
12
+ export function localeStorageFromKeyValue(
13
+ port: KeyValuePort,
14
+ ): LocaleStorageAdapter & { getItemSync(key: string): string | null } {
15
+ return {
16
+ getItemSync: key => port.getString(key) ?? null,
17
+ async getItem(key) {
18
+ return port.getString(key) ?? null;
19
+ },
20
+ async setItem(key, value) {
21
+ port.set(key, value);
22
+ },
23
+ async removeItem(key) {
24
+ port.remove(key);
25
+ },
26
+ };
27
+ }
@@ -1,3 +1,4 @@
1
+ import { defaultReporter, describeError, type I18nDiagnosticHandler } from '../config/diagnostics';
1
2
  import type { LocaleStorageAdapter } from '../config/types';
2
3
 
3
4
  export const LOCALE_STORAGE_KEY = '@chipmobilesdk/rn-i18n:locale';
@@ -6,11 +7,33 @@ export const LOCALE_STORAGE_KEY = '@chipmobilesdk/rn-i18n:locale';
6
7
  * Reads the persisted locale preference. Adapter failures are reported and
7
8
  * treated as "no persisted value" so locale selection never crashes (FR-015).
8
9
  */
9
- export async function readPersistedLocale(storage: LocaleStorageAdapter): Promise<string | null> {
10
+ export async function readPersistedLocale(
11
+ storage: LocaleStorageAdapter,
12
+ report: I18nDiagnosticHandler = defaultReporter,
13
+ ): Promise<string | null> {
10
14
  try {
11
15
  return await storage.getItem(LOCALE_STORAGE_KEY);
12
16
  } catch (error) {
13
- console.warn(`[rn-i18n] Failed to read the persisted locale preference: ${String(error)}`);
17
+ report({
18
+ code: 'locale_read_failed',
19
+ message: `Failed to read the persisted locale preference: ${describeError(error)}`,
20
+ });
21
+ return null;
22
+ }
23
+ }
24
+
25
+ /** `readPersistedLocale` for a store with a synchronous read; same failure handling. */
26
+ export function readPersistedLocaleSync(
27
+ storage: LocaleStorageAdapter & { getItemSync(key: string): string | null },
28
+ report: I18nDiagnosticHandler = defaultReporter,
29
+ ): string | null {
30
+ try {
31
+ return storage.getItemSync(LOCALE_STORAGE_KEY);
32
+ } catch (error) {
33
+ report({
34
+ code: 'locale_read_failed',
35
+ message: `Failed to read the persisted locale preference: ${describeError(error)}`,
36
+ });
14
37
  return null;
15
38
  }
16
39
  }
@@ -22,10 +45,15 @@ export async function readPersistedLocale(storage: LocaleStorageAdapter): Promis
22
45
  export async function writePersistedLocale(
23
46
  storage: LocaleStorageAdapter,
24
47
  code: string,
48
+ report: I18nDiagnosticHandler = defaultReporter,
25
49
  ): Promise<void> {
26
50
  try {
27
51
  await storage.setItem(LOCALE_STORAGE_KEY, code);
28
52
  } catch (error) {
29
- console.warn(`[rn-i18n] Failed to persist locale "${code}": ${String(error)}`);
53
+ report({
54
+ code: 'locale_write_failed',
55
+ locale: code,
56
+ message: `Failed to persist locale "${code}": ${describeError(error)}`,
57
+ });
30
58
  }
31
59
  }
@@ -1,4 +1,4 @@
1
- import { useSyncExternalStore } from 'react';
1
+ import { useCallback, useMemo, useSyncExternalStore } from 'react';
2
2
  import type {
3
3
  ActiveLocalePreference,
4
4
  LocaleConfig,
@@ -9,8 +9,8 @@ import type {
9
9
  import type { I18n } from '../i18n';
10
10
  import { useI18nContext } from './I18nProvider';
11
11
 
12
- function useLocaleVersion(i18n: I18n<string>): void {
13
- useSyncExternalStore(i18n.subscribe, i18n.getVersion, i18n.getVersion);
12
+ function useLocaleVersion(i18n: I18n<string>): number {
13
+ return useSyncExternalStore(i18n.subscribe, i18n.getVersion, i18n.getVersion);
14
14
  }
15
15
 
16
16
  export interface UseTranslationResult<TKey extends string> {
@@ -25,11 +25,19 @@ export interface UseTranslationResult<TKey extends string> {
25
25
  */
26
26
  export function useTranslation<TKey extends string = string>(): UseTranslationResult<TKey> {
27
27
  const i18n = useI18nContext();
28
- useLocaleVersion(i18n);
29
- return {
30
- t: (key, vars, options) => i18n.t(key, vars, options),
31
- locale: i18n.getLocale().code,
32
- };
28
+ const version = useLocaleVersion(i18n);
29
+ // One `t` and one result per locale version (I18N-05): a memoized child that
30
+ // takes `t` re-renders when the locale changes, not on every parent render.
31
+ return useMemo(
32
+ () => ({
33
+ t: (key: TKey, vars?: TranslateVars, options?: TranslateOptions) =>
34
+ i18n.t(key, vars, options),
35
+ locale: i18n.getLocale().code,
36
+ }),
37
+ // `version` is the dependency that matters: it changes with the locale.
38
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- `version` is read to invalidate, not used
39
+ [i18n, version],
40
+ );
33
41
  }
34
42
 
35
43
  export interface UseLocaleResult {
@@ -44,6 +52,7 @@ export interface UseLocaleResult {
44
52
  export function useLocale(): UseLocaleResult {
45
53
  const i18n = useI18nContext();
46
54
  useLocaleVersion(i18n);
55
+ const setLocale = useCallback((code: string) => i18n.setLocale(code), [i18n]);
47
56
  const preference = i18n.getLocale();
48
57
  const supportedLocales = i18n.getSupportedLocales();
49
58
  const active = supportedLocales.find(locale => locale.code === preference.code);
@@ -52,6 +61,6 @@ export function useLocale(): UseLocaleResult {
52
61
  direction: active?.direction ?? 'ltr',
53
62
  preference,
54
63
  supportedLocales,
55
- setLocale: code => i18n.setLocale(code),
64
+ setLocale,
56
65
  };
57
66
  }
@@ -1,3 +1,4 @@
1
+ import type { I18nDiagnostic, I18nDiagnosticHandler } from '../config/diagnostics';
1
2
  import type {
2
3
  LocaleConfig,
3
4
  MissingTranslationPolicy,
@@ -18,14 +19,30 @@ export interface ResolverContext {
18
19
  missingTranslation: MissingTranslationPolicy;
19
20
  /** Dedupe set so each failed lookup path warns exactly once. */
20
21
  warned: Set<string>;
22
+ report: I18nDiagnosticHandler;
21
23
  }
22
24
 
23
- export function warnOnce(warned: Set<string>, dedupeKey: string, message: string): void {
25
+ /**
26
+ * How many lookup paths are remembered. An app that misses many distinct keys
27
+ * forgets the oldest first, so memory stays bounded (I18N-11).
28
+ */
29
+ const WARNED_CAPACITY = 500;
30
+
31
+ export function warnOnce(
32
+ context: Pick<ResolverContext, 'warned' | 'report'>,
33
+ dedupeKey: string,
34
+ diagnostic: I18nDiagnostic,
35
+ ): void {
36
+ const { warned } = context;
24
37
  if (warned.has(dedupeKey)) {
25
38
  return;
26
39
  }
27
40
  warned.add(dedupeKey);
28
- console.warn(message);
41
+ if (warned.size > WARNED_CAPACITY) {
42
+ const oldest = warned.values().next().value;
43
+ if (oldest !== undefined) warned.delete(oldest);
44
+ }
45
+ context.report(diagnostic);
29
46
  }
30
47
 
31
48
  /** Walks a dot-path key through a nested translation namespace. */
@@ -54,7 +71,7 @@ function pickValue(
54
71
  node: TranslationNode,
55
72
  locale: LocaleConfig,
56
73
  options: TranslateOptions | undefined,
57
- warned: Set<string>,
74
+ warnings: Pick<ResolverContext, 'warned' | 'report'>,
58
75
  key: string,
59
76
  ): string | undefined {
60
77
  if (typeof node === 'string') {
@@ -67,11 +84,12 @@ function pickValue(
67
84
  }
68
85
  return selectPluralVariant(node, resolvePluralRule(locale)(count));
69
86
  }
70
- warnOnce(
71
- warned,
72
- `invalid-value|${locale.code}|${key}`,
73
- `[rn-i18n] Translation value for "${key}" in locale "${locale.code}" is not text or a plural group; treating it as missing.`,
74
- );
87
+ warnOnce(warnings, `invalid-value|${locale.code}|${key}`, {
88
+ code: 'invalid_value',
89
+ key,
90
+ locale: locale.code,
91
+ message: `Translation value for "${key}" in locale "${locale.code}" is not text or a plural group; treating it as missing.`,
92
+ });
75
93
  return undefined;
76
94
  }
77
95
 
@@ -96,11 +114,12 @@ export function translate(
96
114
 
97
115
  const interpolateWith = (text: string, sourceLocale: string) =>
98
116
  interpolate(text, mergedVars, name =>
99
- warnOnce(
100
- context.warned,
101
- `missing-var|${sourceLocale}|${key}|${name}`,
102
- `[rn-i18n] Missing interpolation variable "${name}" for key "${key}" in locale "${sourceLocale}".`,
103
- ),
117
+ warnOnce(context, `missing-var|${sourceLocale}|${key}|${name}`, {
118
+ code: 'missing_variable',
119
+ key,
120
+ locale: sourceLocale,
121
+ message: `Missing interpolation variable "${name}" for key "${key}" in locale "${sourceLocale}".`,
122
+ }),
104
123
  );
105
124
 
106
125
  for (const code of chain) {
@@ -112,16 +131,17 @@ export function translate(
112
131
  if (node === undefined) {
113
132
  continue;
114
133
  }
115
- const value = pickValue(node, locale, options, context.warned, key);
134
+ const value = pickValue(node, locale, options, context, key);
116
135
  if (value === undefined) {
117
136
  continue;
118
137
  }
119
138
  if (code !== activeCode) {
120
- warnOnce(
121
- context.warned,
122
- `fallback|${activeCode}|${key}`,
123
- `[rn-i18n] Key "${key}" is missing in locale "${activeCode}"; served from fallback locale "${code}".`,
124
- );
139
+ warnOnce(context, `fallback|${activeCode}|${key}`, {
140
+ code: 'fallback_used',
141
+ key,
142
+ locale: activeCode,
143
+ message: `Key "${key}" is missing in locale "${activeCode}"; served from fallback locale "${code}".`,
144
+ });
125
145
  }
126
146
  return interpolateWith(value, code);
127
147
  }
@@ -131,10 +151,11 @@ export function translate(
131
151
  return interpolateWith(runtimeEntry.defaultText, activeCode);
132
152
  }
133
153
 
134
- warnOnce(
135
- context.warned,
136
- `missing|${activeCode}|${key}`,
137
- `[rn-i18n] Missing translation for key "${key}" in locale "${activeCode}" and its entire fallback chain.`,
138
- );
154
+ warnOnce(context, `missing|${activeCode}|${key}`, {
155
+ code: 'missing_translation',
156
+ key,
157
+ locale: activeCode,
158
+ message: `Missing translation for key "${key}" in locale "${activeCode}" and its entire fallback chain.`,
159
+ });
139
160
  return context.missingTranslation.mode === 'placeholder' ? context.missingTranslation.text : key;
140
161
  }