Skip to content

Error Handling

Comvi is designed to keep your app rendering even when translations are missing or fail to load. Missing keys do not throw by default: they emit a 'missingKey' event and fall back to a configured value or the key itself. Load, plugin, post-processor, event-listener, and rendering errors are reported through onError or specific events.

Some operations can still fail explicitly. init(), setLocaleAsync(), addActiveNamespace(), and reloadTranslations() return promises and may reject when required setup or namespace loading fails. t() can also throw if your onMissingKey handler throws, or when tag interpolation is configured with tagInterpolation.strict: true.

This guide covers default behavior, error events, fallback strategies, and production logging patterns.

When a translation key is not found, t() returns the key itself rather than throwing:

const { t } = useI18n();
t('dashboard.title'); // "Dashboard" (found)
t('nonexistent.key'); // "nonexistent.key" (missing — key returned as fallback)

This keeps your UI readable while still giving you hooks to report the missing key.

Subscribe to specific error types with convenience methods, or use the generic on() event system:

src/i18n.ts
import { createI18n } from '@comvi/core';
const i18n = createI18n({ locale: 'en' });
// Listen for missing translation keys
const unsubscribe = i18n.onMissingKey((key, locale, namespace) => {
console.warn(`Missing key: "${key}" in [${locale}/${namespace}]`);
});
// Listen for translation load failures
i18n.onLoadError((locale, namespace, error) => {
console.error(`Failed to load [${locale}/${namespace}]:`, error);
});
// Or use the generic event system
i18n.on('missingKey', ({ key, locale, namespace }) => {
console.warn(`Missing: ${key}`);
});
i18n.on('loadError', ({ locale, namespace, error }) => {
console.error('Load failed:', error);
});
// Unsubscribe when no longer needed
unsubscribe();

Comvi uses standard JavaScript Error objects paired with an ErrorReportContext that identifies the error source. Configure a global error handler via the onError option:

src/i18n.ts
import { createI18n } from '@comvi/core';
const i18n = createI18n({
locale: 'en',
onError: (error, context) => {
console.error(`[i18n] ${context?.source}: ${error.message}`, context);
},
});

The context object identifies where the error originated:

interface ErrorReportContext {
source:
| 'plugin' // Plugin threw during init or execution
| 'plugin-cleanup' // Plugin threw during cleanup/destroy
| 'init' // i18n.init() failed
| 'translation' // Translation rendering error (e.g. ICU parser, tag handler)
| 'namespace-load' // Namespace loader failed
| 'post-processor' // Post-processor function threw
| 'event'; // Event listener threw
pluginName?: string;
tagName?: string;
key?: string;
locale?: string;
namespace?: string;
event?: string;
}

Common patterns:

  • source: 'plugin' — a plugin threw during initialization (e.g., Fetch Loader couldn’t reach CDN)
  • source: 'namespace-load' — one or more namespaces failed to load (other namespaces may have succeeded)
  • source: 'post-processor' — a post-processor function threw (error in your custom logic)
  • source: 'translation' — an error during translation rendering (e.g., bad ICU syntax, tag handler error)

In addition to the onError handler, Comvi emits specific events you can subscribe to:

EventWhen It FiresPayload
missingKeyt() cannot find a key in any language{ key, locale, namespace }
loadErrorA namespace loader failed for a locale/namespace pair{ locale, namespace, error }
localeChangedActive language was changed{ from, to }
namespaceLoadedA namespace finished loading{ namespace, locale }
loadingStateChangedLoading state changed{ isLoading, isInitializing }

Override the default fallback (key name) with the onMissingKey option. The createI18n() option receives an object with key, locale, and namespace:

src/i18n.ts
const i18n = createI18n({
locale: 'en',
onMissingKey: ({ key, locale, namespace }) => {
// Return empty string instead of the key
return '';
},
});
src/i18n.ts
const i18n = createI18n({
locale: 'en',
onMissingKey: ({ key, locale, namespace }) => {
// Return a visual indicator for developers
if (import.meta.env.DEV) {
return `[MISSING: ${key}]`;
}
return key;
},
});

When a key is missing in the active language, Comvi checks the fallback language before reporting an error:

src/i18n.ts
const i18n = createI18n({
locale: 'de',
fallbackLocale: 'en', // If key is missing in 'de', try 'en'
});

You can also pass an array to define a chain of fallback languages:

src/i18n.ts
const i18n = createI18n({
locale: 'de-AT',
fallbackLocale: ['de', 'en'], // Try 'de' first, then 'en'
});

The resolution order is:

  1. Look up the key in the active language (de-AT)
  2. If missing, try each fallback language in order (de, then en)
  3. If still missing, call onMissingKey handler or return the key

Use onLoadError and onMissingKey from useI18n() to handle errors at the component level. Both return an unsubscribe function you should call on cleanup:

src/components/TranslatedSection.tsx
import { useI18n } from '@comvi/react';
import { useEffect, useState } from 'react';
function TranslatedSection() {
const { t, onLoadError } = useI18n();
const [loadFailed, setLoadFailed] = useState(false);
useEffect(() => {
const unsubscribe = onLoadError((locale, namespace, error) => {
setLoadFailed(true);
});
return () => unsubscribe();
}, [onLoadError]);
if (loadFailed) {
return <div className="i18n-error">Some translations failed to load.</div>;
}
return <h1>{t('section.title')}</h1>;
}

In production, pipe translation errors to your error tracking service. Here is a Sentry integration example:

src/i18n.ts
import * as Sentry from '@sentry/browser';
import { createI18n } from '@comvi/core';
import { FetchLoader } from '@comvi/plugin-fetch-loader';
const i18n = createI18n({
locale: 'en',
fallbackLocale: 'en',
// Catch i18n errors (plugin failures, init errors, load failures, etc.)
onError: (error, context) => {
// Namespace load errors are expected in some cases (offline, CDN down)
// Report at warning level, not error
Sentry.captureException(error, {
tags: { subsystem: 'i18n', source: context?.source },
extra: context,
level: context?.source === 'namespace-load' ? 'warning' : 'error',
});
},
})
.use(FetchLoader({
cdnUrl: 'https://cdn.comvi.io/your-distribution-id',
}));
// Report missing keys as breadcrumbs (not full errors)
i18n.onMissingKey((key, locale, namespace) => {
Sentry.addBreadcrumb({
category: 'i18n',
message: `Missing translation: ${key}`,
data: { namespace, locale },
level: 'info',
});
});

By default, Comvi operates in resilient mode: missing keys return a fallback value, post-processor errors are reported and skipped, and throws inside onError or event listeners are intentionally swallowed by core so a buggy listener can’t crash unrelated code.

The missing-key paths that do propagate exceptions are the onMissingKey option and runtime i18n.onMissingKey() callbacks. They are invoked synchronously from inside t() without an internal try/catch, which makes them the right hook when you want tests to fail on missing keys.

strict: 'dev' enables development diagnostics for missing keys, but it does not throw by itself. If you need hard failures, throw from onMissingKey().

For load and plugin errors, use promise assertions or a buffer-and-assert pattern: collect errors in onError (and/or 'loadError') and assert the buffer is empty in your test teardown.

src/i18n.test-setup.ts
import { createI18n } from '@comvi/core';
export const i18nErrors: Array<{ error: Error; source?: string }> = [];
export const i18n = createI18n({
locale: 'en',
strict: 'dev',
// Throwing from onError would be swallowed — collect instead.
onError: (error, context) => {
i18nErrors.push({ error, source: context?.source });
},
});
// Throws here DO propagate out of t(), which fails the test loudly.
i18n.onMissingKey((key, locale, namespace) => {
throw new Error(`[i18n] Missing key "${key}" in [${locale}/${namespace}]`);
});
vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
setupFiles: ['./src/i18n.test-setup.ts'],
},
});
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;
});

This pattern gives you strict validation in tests (missing keys throw, load/plugin errors fail the test in afterEach) while keeping production resilient.

Error: [i18n] Plugin threw during init

Causes:

  • CDN is down or unreachable
  • API key is invalid or expired
  • Network timeout during first fetch

Fix:

.use(FetchLoader({
cdnUrl: 'https://cdn.comvi.io/your-distribution-id',
timeout: 15000, // increase from default 10s
}), { required: false }) // make plugin optional

Error: [i18n] Failed to load translations for [de/admin]

Causes:

  • Namespace doesn’t exist in the project
  • CDN 404 or authentication failure
  • Network timeout

Fix:

i18n.onLoadError((locale, namespace, error) => {
console.warn(`Failed to load ${locale}:${namespace}. Using fallback.`);
// Optionally load from fallback map or show error UI
});

Error: t('unknown.key') returns 'unknown.key'

Causes:

  • Key doesn’t exist in translations
  • Typo in key name
  • Namespace mismatch

Fix:

i18n.onMissingKey((key, locale, namespace) => {
if (import.meta.env.DEV) {
console.warn(`Missing translation: [${locale}/${namespace}] "${key}"`);
}
});

Use this checklist to make sure your app handles translation errors well:

  • fallbackLocale is set to your most complete language
  • onError is wired to your error tracking service (catches plugin, init, load, and rendering errors)
  • Missing-key handlers report gaps without generating alert noise
  • Components using isLoading show a skeleton or spinner while translations load
  • Tests use strict error handling to catch missing keys before they reach production
  • Plugins are marked required: false if failures should not block app initialization