Skip to content
Comvi

Troubleshooting

View .md

Each entry is symptom, cause, fix. Runtime hooks (onError, onMissingKey, onLoadError) are on Handle missing keys and errors; every error code is on Error codes.

A plural renders as {count, plural, …}, or throws E_ICU_SYNTAX

Section titled “A plural renders as {count, plural, …}, or throws E_ICU_SYNTAX”

Symptom: in development, ingesting a catalog throws [@comvi/core] "{count, plural, …}" in "…" is ICU "plural" syntax; the default compiler supports {param} only. Pass compiler: icuCompiler from "@comvi/core/icu" in the constructor options. In production the braced segment appears on the page verbatim, and E_ICU_SYNTAX is reported once through onError (or console.error).

Cause: the i18n instance is on the default compiler, which handles {param} only. ICU is never enabled for you.

Fix: name the compiler in the constructor options. One option covers the inline catalog and every catalog a loader merges later:

src/i18n.ts
import { createI18n } from '@comvi/core';
import { icuCompiler } from '@comvi/core/icu';
import { loader } from '@comvi/core/loader';
export const i18n = createI18n({ locale: 'en', compiler: icuCompiler }).use(
loader({ uk: () => import('./locales/uk.json') }),
);

The snippet imports from @comvi/core. An app that installed only a binding adds @comvi/core to its own dependencies first: it arrives as a transitive dependency, and pnpm does not hoist one into the app’s node_modules, so the import does not resolve there.

See Pluralize with ICU.

<tag> markup shows up literally in the output

Section titled “<tag> markup shows up literally in the output”

Symptom: t('legal.tos') returns "Read our <link>Terms</link>" with the angle brackets.

Cause: plain t() does not parse tags on an instance with nothing composed.

Fix: render through <T>, which passes the tag grammar per call on any instance, or make the syntax ambient once per bundle with import '@comvi/core/tags'. That import is process-wide and retroactive. An SSR app needs it in the server graph too, or the server renders the literal string and the client renders markup. See Tag interpolation.

useI18n() (or <T>) throws “must be used within a provider”

Section titled “useI18n() (or <T>) throws “must be used within a provider””

Symptom: one of these runtime errors:

  • React / Next.js: [@comvi/react] Hooks must be used within an I18nProvider.
  • Vue: [@comvi/vue] useI18n must be used within a Vue app with i18n plugin installed. Make sure you called app.use(i18n) before using this composable.
  • SolidJS: [@comvi/solid] i18n context not found. Wrap your app with <I18nProvider i18n={i18n}>.
  • Svelte: [@comvi/svelte] i18n context not found. Call setI18nContext(i18n) in your root component (e.g., App.svelte).

Cause: the component is rendered outside the provider, or the provider was never mounted: the provider sits below the component, app.use(i18n) ran after a component rendered, a Next.js Client Component imported useI18n from @comvi/next instead of @comvi/next/client, or a test rendered the component without wrapping it.

Fix: mount the provider at the root, above everything that uses i18n:

src/main.tsx
import { createRoot } from 'react-dom/client';
import { I18nProvider } from '@comvi/react';
import { i18n } from './i18n';
import App from './App';
createRoot(document.getElementById('root')!).render(
<I18nProvider i18n={i18n}>
<App />
</I18nProvider>,
);
src/main.ts
import { createApp } from 'vue';
import { createI18n } from '@comvi/vue'; // NOT @comvi/core: this one is a Vue plugin
import App from './App.vue';
const i18n = createI18n({ locale: 'en', messages: { en: {} } });
createApp(App).use(i18n).mount('#app'); // .use(i18n) BEFORE .mount()

In Next.js Client Components import the hook from @comvi/next/client; in Server Components use await getI18n() from @comvi/next/server. In Svelte call setI18nContext(i18n) once in App.svelte or your root layout.

SSR / hydration mismatch: text flickers or React warns about mismatched HTML

Section titled “SSR / hydration mismatch: text flickers or React warns about mismatched HTML”

Symptom: the server renders one locale, the client another; a hydration warning, or a flash of the wrong language.

Cause: the client i18n instance starts on a different locale than the server used, or the server-rendered translations were not handed to the client, so it re-fetches and re-renders.

Fix: in Next.js, render the locale layout through <ComviProvider locale>; on the manual setup, await loadMessages(locale) in the layout and pass both locale and messages to <I18nProvider> in your "use client" wrapper. In Nuxt, compose the loader in comvi.i18n.ts: the module pre-loads the active locale on the server and hydrates the client. The full pattern for each framework is on Render on the server.

A translation renders as the key string instead of the text

Section titled “A translation renders as the key string instead of the text”

Symptom: the UI shows home.title literally.

Cause: the key is missing in the active locale and the fallbackLocale chain, its namespace was not loaded, or the key has a typo. A missing key renders the key string.

Fix: confirm the key exists for the active locale and the namespace is loaded (createI18n({ ns: ['default', 'admin'] }) on an instance with a loader composed, or load on demand). Then set a fallback, turn on development diagnostics, or take over the missing-key render:

src/i18n.ts
import { createI18n } from '@comvi/core';
declare function reportToSentry(message: string): void;
createI18n({
locale: 'de',
fallbackLocale: 'en', // a missing key falls back to a complete locale
strict: 'dev', // console warning the moment a key is missing
messages: { en: {}, de: {} },
onMissingKey: ({ key, locale, namespace }) => {
reportToSentry(`Missing i18n key: ${namespace}:${key} (${locale})`);
return ''; // render nothing instead of the key
},
});

The onMissingKey option works with nothing composed. The runtime i18n.onMissingKey(cb) registration is a plugin-capability member (.use(plugins()), or useI18nPlugins() in a component), takes positional (key, locale, namespace) and returns a cleanup function.

Symptom: registerLoader, addActiveNamespace, reloadMessages, onLoadError, registerPlugin or onMissingKey is not a function, or useI18nLoader() / useI18nPlugins() throws:

// development
[@comvi/core] This i18n instance has no loader capability. Compose it: .use(attachLoader) from "@comvi/core/loader", or loader(map).
// production
[@comvi/core] missing loader capability — attach @comvi/core/loader

Cause: the i18n instance never composed the capability. Loader members come from .use(loader(map)) or .use(attachLoader), plugin members from .use(plugins()) or a package installer.

Fix: compose the capability where the instance is created, before init(). In a component, acquire the members from useI18nLoader() and useI18nPlugins() rather than from useI18n().

CDN returns 403, 404, or CORS error when fetching translations

Section titled “CDN returns 403, 404, or CORS error when fetching translations”

Symptom: the network tab shows 403 Forbidden, 404 Not Found, or a CORS failure on a request to cdn.comvi.io/... or your API server.

Cause: a wrong cdnUrl (it must be the whole CDN endpoint URL, such as https://cdn.comvi.io/<project-token>), a mismatch between the app’s defaultNs and the namespace stored at the CDN root, a baseUrl or apiKey from another project, or a project not yet published to the CDN.

Fix: copy the endpoint from Settings → Content Deploy (URL format) and publish the project at least once:

src/i18n.ts
import { createI18n } from '@comvi/core';
import { cdnLoader } from '@comvi/plugin-fetch-loader';
export const i18n = createI18n({ locale: 'en' }).use(
cdnLoader({ cdnUrl: 'https://cdn.comvi.io/<project-token>' }),
);

Wherever the i18n instance carries an apiKey, use fetchLoader from @comvi/plugin-fetch-loader/api, which then requests the API at baseUrl instead of the CDN; without apiKey it requests the CDN whatever the build mode (the two loader entries).

The loader fetches {cdnUrl}/{lang}.json for defaultNs and {cdnUrl}/{namespace}/{lang}.json for other namespaces, so a 404 means a mismatched layout or an undeployed file. When a named namespace is defaultNs but a different one sits at the CDN root, pass cdnLayout: { rootNamespace: 'default' }: the loader then requests storefront/en.json for unqualified keys instead of the root en.json. Custom layouts, fallback imports and SSR cache options are on Fetch loader.

setLocale() ran but the UI still shows the old language for a moment

Section titled “setLocale() ran but the UI still shows the old language for a moment”

Symptom: after a switch, old text shows briefly, or vanilla code reads the old locale on the next tick.

Cause: with the loader capability composed, a locale switch loads the namespaces it does not have cached asynchronously, and applies at once only on a full cache hit. The i18n.locale = 'de' setter fires that load and returns immediately.

Fix: await i18n.setLocale('de') before reading translations; it resolves once the new namespaces are loaded. On a plain createI18n() with nothing composed, setLocale() swaps the locale synchronously, so untranslated keys there mean a missing loader. The setLocale from useI18n() is the same method, and framework UIs re-render when loading finishes. See Switch the locale.

Next.js: getI18n() throws “i18n not configured” or “Locale not set”

Section titled “Next.js: getI18n() throws “i18n not configured” or “Locale not set””

Symptom: one of two server errors:

[@comvi/next] i18n not configured. Add withComvi({ config }) to next.config, or call setI18n(i18n) / createNextI18nFromInstance(factory, routing). In a test runner the alias does not apply: alias `@comvi/next/config` to your config module (vitest `resolve.alias`, jest `moduleNameMapper`) or call setI18n(i18n) in test setup. `serverExternalPackages` containing @comvi/next is not supported.
[@comvi/next] Locale not set. Call setRequestLocale(locale) in your layout/page first, or configure middleware.

Cause: next.config has no withComvi({ config }) and nothing called setI18n(i18n); or getI18n() ran without a locale argument outside <ComviProvider>, on a path the middleware does not match.

Fix: export the config through withComvi({ config: './src/i18n/config.ts' }) in next.config.ts and restart the dev server, since next.config is read once. Pass the locale from params: await getI18n({ locale }), in pages and in generateMetadata alike. Where a helper takes no locale, render it under <ComviProvider locale> or call setRequestLocale(locale) first, and confirm middleware.ts exports createMiddleware(routing) with a matcher covering your routes. See Next.js.

Nuxt: useI18n() is undefined / auto-imports don’t resolve

Section titled “Nuxt: useI18n() is undefined / auto-imports don’t resolve”

Symptom: useI18n is not defined, NuxtLinkLocale does not render, or a component throws [@comvi/nuxt] i18n not configured. Add "@comvi/nuxt" to `modules` in nuxt.config.ts.

Cause: @comvi/nuxt is not in modules, or the dev server was not restarted after editing nuxt.config.ts. A build that stops with [@comvi/nuxt] `comvi.detectBrowserLanguage` is not an option: use `localeCookie` and `localeDetection`. (or the same sentence for comvi.fallbackLanguage, runtimeConfig.public.comvi.cookieName or a key inside localeDetection) is a nuxt.config.ts written for an older release: write the option the message names.

Fix: register the module, restart, and compose the loader in comvi.i18n.ts. The generated default instance has nothing composed, so it has no registerLoader:

nuxt.config.ts
export default defineNuxtConfig({
modules: ['@comvi/nuxt'],
comvi: {
locales: [{ code: 'en' }, { code: 'uk' }],
defaultLocale: 'en',
},
});
comvi.i18n.ts
import { createI18n, defineComviI18n, loader } from '@comvi/nuxt/i18n';
// Found by convention as ./comvi.i18n.ts; `comvi.i18nModule` overrides the path.
export default defineComviI18n((options) =>
createI18n(options).use(
loader({
en: () => import('./locales/en.json'),
uk: () => import('./locales/uk.json'),
}),
),
);

To register from comvi.setup.ts instead, destructure core (not i18n, a VueI18n | core union) and pass registerLoader a LoaderFn; a map throws:

[@comvi/core] registerLoader(): argument must be a loader function. For a static import map use .use(loader(map)), or wrap it with createImportMapLoader(map, () => i18n.getDefaultNamespace()) — both from @comvi/core/loader.

The hook needs no type argument: the module writes the factory’s return type into .nuxt/types/, so core.registerLoader compiles exactly when comvi.i18n.ts composed the loader and fails nuxi typecheck otherwise. satisfies NuxtI18nSetup<I18n & I18nLoaderApi> (all three types from @comvi/nuxt) stays supported for a factory the module cannot see. With no loader composed, loadMessages warns once, opening with [@comvi/nuxt] This i18n instance has no loader capability, so SSR loaded nothing. and naming the i18nModule factory, and returns what the catalog already holds. See Nuxt and Setup hook.