Skip to content
Comvi

Handle missing keys and errors

View .md

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.

src/i18n.ts
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.

src/components/TranslatedSection.tsx
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.

src/i18n.ts
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.

src/events.ts
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.

src/i18n.test-setup.ts
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}]`);
});
src/i18n.test.ts
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.

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.