Handle missing keys and errors
Keep the app rendering when a key is missing or a catalog fails to load. t() never throws
on a missing key, and every failure reaches a hook you can wire to your error tracker. Every
hook is a constructor option or an event on the plain @comvi/core instance from the
Quick start, so no binding is required.
Missing keys
Section titled “Missing keys”import { createI18n } from '@comvi/core';
export const i18n = createI18n({ locale: 'de-AT', fallbackLocale: ['de', 'en'], onMissingKey: ({ key }) => (import.meta.env.DEV ? `[MISSING: ${key}]` : ''),});A key missing or empty in de-AT is looked up in de, then en, and only then reported. Set your
most complete locale as fallbackLocale. When every locale misses, t() emits a
missingKey event and returns whatever onMissingKey returns, or the key itself when the
option is not set. A fallback passed to t() outranks both.
Load errors
Section titled “Load errors”import { useI18n, useI18nLoader } from '@comvi/react';import { useEffect, useState } from 'react';
export function TranslatedSection() { const { t } = useI18n(); const { onLoadError } = useI18nLoader(); const [loadFailed, setLoadFailed] = useState(false);
useEffect(() => { const unsubscribe = onLoadError(() => setLoadFailed(true)); return () => unsubscribe(); }, [onLoadError]);
if (loadFailed) return <div className="i18n-error">Some translations failed to load.</div>; return <h1>{t('section.title')}</h1>;}onLoadError fires once per locale:namespace pair a loader failed to load. In a
component it comes from useI18nLoader(), and on the instance from i18n.onLoadError(); both
return an unsubscribe function. useI18nLoader() throws on an instance without the loader
capability, in development and production alike. init(), setLocale(),
addActiveNamespace() and reloadMessages() also reject their promise when every
requested load fails, so await them where you want to handle that at the call site.
Report everything with onError
Section titled “Report everything with onError”import { createI18n } from '@comvi/core';import { cdnLoader } from '@comvi/plugin-fetch-loader';
declare const Sentry: { captureException(error: unknown, hint?: Record<string, unknown>): void; addBreadcrumb(breadcrumb: Record<string, unknown>): void;};
export const i18n = createI18n({ locale: 'en', fallbackLocale: 'en', onError: (error, context) => { Sentry.captureException(error, { tags: { subsystem: 'i18n', source: context?.source }, extra: context, level: context?.source === 'namespace-load' ? 'warning' : 'error', }); },}).use(cdnLoader({ cdnUrl: 'https://cdn.comvi.io/<project-token>' }));
i18n.on('missingKey', ({ key, locale, namespace }) => { Sentry.addBreadcrumb({ category: 'i18n', message: `Missing: ${key}`, data: { locale, namespace } });});onError receives every error the instance reports, with a context.source naming where it
came from: plugin, plugin-cleanup, request-cleanup, init, translation,
namespace-load, post-processor, event or compile; the bindings add setLocale for a
switch they started. The context also carries pluginName,
tagName, key, locale, namespace and event when they apply. A throw inside
onError or an event listener is swallowed, so a buggy handler cannot crash rendering.
Report missing keys as breadcrumbs rather than errors: they are common in development.
Events
Section titled “Events”import { i18n } from './i18n';
const off = i18n.on('loadError', ({ locale, namespace, error }) => { console.error(`Failed to load ${locale}:${namespace}`, error);});
off();| Event | Fires when | Payload |
|---|---|---|
initialized |
init() completed |
void |
destroyed |
destroy() was called |
void |
localeChanged |
the active locale changed | { from, to } |
defaultNamespaceChanged |
the default namespace changed | { from, to } |
messagesCleared |
the cache was cleared | { locale?, namespace? } |
loadingStateChanged |
loading state changed | { isLoading, isInitializing } |
namespaceLoaded |
a catalog landed, from a loader or addMessages() |
{ namespace, locale } |
missingKey |
t() found text for a key in no locale |
{ key, locale, namespace } |
loadError |
a loader failed for one pair | { locale, namespace, error } |
configChanged |
a config setter ran | { source } |
configChanged.source is 'fallbackLocale', 'defaultParams', 'namespaceActivated' or
'namespaceDeactivated'.
The synchronous i18n.locale = setter reports a failed load as a loadError with the
namespace "locale-change", so prefer await setLocale() where the failure matters.
Fail loudly in tests
Section titled “Fail loudly in tests”import { createI18n } from '@comvi/core';import { plugins } from '@comvi/core/plugins';
export const i18nErrors: Array<{ error: Error; source?: string }> = [];
export const i18n = createI18n({ locale: 'en', strict: 'dev', onError: (error, context) => { i18nErrors.push({ error, source: context?.source }); },}).use(plugins());
i18n.onMissingKey((key, locale, namespace) => { throw new Error(`Missing key "${key}" in [${locale}/${namespace}]`);});import { afterEach, expect } from 'vitest';import { i18nErrors } from './i18n.test-setup';
afterEach(() => { expect(i18nErrors, 'i18n recorded errors during this test').toHaveLength(0); i18nErrors.length = 0;});Runtime onMissingKey callbacks and the onMissingKey option run synchronously inside
t() with no try/catch, so a throw there fails the test. strict: 'dev' adds development
diagnostics but does not throw by itself. A load or plugin error is reported before anything
rejects, and most never reject: a partial load and an optional plugin are reported only, while
an all-namespaces failure and a required plugin reject the call after the report. Collect them
in onError and assert the buffer is empty after each test. Runtime
i18n.onMissingKey() lives on the plugin capability, hence .use(plugins()); the
constructor option needs nothing composed.
How it works
Section titled “How it works”The instance has two separate channels, and which one a failure takes decides what the user
sees. A missing key is not an error. It is the normal end of a lookup, handled inside t():
the fallback chain is walked, missingKey is emitted, the runtime callbacks run, and the first
of params.fallback, a callback’s result, the onMissingKey option’s result or the key itself
is returned, in that order, synchronously. Nothing in that path reaches onError, so a tracker
wired to onError alone never hears about a missing key; the missingKey event is where that
report comes from. The user always sees a string, and the only question is which one.
Everything else is an error, and onError is the single sink for it. init() reports with
source: 'init' and rethrows. A namespace that fails to load reports with
source: 'namespace-load' while the other namespaces still land, and the operation rejects only
when every requested namespace failed. An event listener that throws is reported and swallowed,
so one bad subscriber cannot take down a render. A plugin that throws or times out during
init() is reported, and aborts init() only when it was registered as required. In
production, an ICU message the compiler cannot read reports E_ICU_SYNTAX once per compilation
and renders its braces literally. None of these reach t(): a failed load leaves the namespace
empty, so until a retry its keys walk the fallback chain and then the missing-key path, and the
user sees the fallback locale’s text or the key. The failed pair is dropped from the in-flight
set, so the next locale switch or reloadMessages() calls the loader again.
loadError is the event form of the same failure. It fires once per locale:namespace pair,
on the instance through i18n.on('loadError') or onLoadError(), and in a component through
useI18nLoader(). The two channels carry the same error: onError is where a tracker
subscribes once, loadError is where a component decides what to render.
Development and production differ in the text, not the path. Every message is built as
IS_DEV ? 'long text' : 'E_CODE' and the bundler picks the build: the development export
condition serves the long texts, the default build serves the codes. Bundlers set that
condition in development mode, so the same catch sees
[@comvi/core] registerLoader: no entry for "fr:default" on your machine and
E_REGISTER_LOADER_ENTRY in production. Match on the code. Only E_ICU_SYNTAX,
E_USE_INSTANCE_IDENTITY and E_USE_NOT_INSTALLER carry a code property; for the rest the code
is the message. strict: 'dev' adds one more development-only line, [@comvi/core] Translation not found: "key", per miss; it never throws, and a production build logs nothing for a miss, because
the warning call compiles out of that build. Every code, with cause and fix, is on Errors.
Read next
Section titled “Read next”- Errors: every error code with its development message, cause and fix.
- Load translations on demand: loaders, fallback maps and namespaces.
- Test components that translate: a real instance per test, with strict missing keys.