fix: keep the translations out of every page of the Lite export

This commit is contained in:
Linus Rath
2026-10-28 17:16:35 +01:00
parent 4421c3ce7e
commit 90ec6748ac
3 changed files with 76 additions and 17 deletions
+9 -4
View File
@@ -47,11 +47,16 @@ export default async function LocaleLayout({
// the tree render without request headers (static export).
setRequestLocale(locale);
// Lite leaves the catalog out: a prop here is baked into every exported page
// (the HTML and three RSC payloads per route and locale), which pushed the
// Stalwart zip past its size limit. IntlProvider loads it as a chunk instead.
let messages;
try {
messages = (await import(`@/locales/${locale}/common.json`)).default;
} catch {
notFound();
if (!IS_LITE) {
try {
messages = (await import(`@/locales/${locale}/common.json`)).default;
} catch {
notFound();
}
}
return (
@@ -0,0 +1,45 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { renderToString } from 'react-dom/server';
import { useTranslations } from 'next-intl';
import { IntlProvider } from '../intl-provider';
import { useLocaleStore } from '@/stores/locale-store';
// The Lite export leaves the catalog out of the [locale] layout, because the
// prop was baked into every exported page and pushed the Stalwart zip past its
// size limit. The provider then loads the catalog itself and must render
// nothing until it has it: English text would flash on a German page, and the
// prerendered HTML has to match the client's first render.
vi.unmock('next-intl');
function Cancel() {
return <span>{useTranslations('common')('cancel')}</span>;
}
describe('IntlProvider without server messages (Lite)', () => {
afterEach(() => {
useLocaleStore.setState({ locale: '' });
});
it('prerenders nothing for a non-English locale', () => {
expect(renderToString(<IntlProvider locale="de"><Cancel /></IntlProvider>)).toBe('');
});
it('prerenders English straight away from the bundled catalog', () => {
expect(renderToString(<IntlProvider locale="en"><Cancel /></IntlProvider>)).toContain('Cancel');
});
it('loads the route locale on the client and renders it', async () => {
const { container } = render(<IntlProvider locale="de"><Cancel /></IntlProvider>);
expect(container.textContent).toBe('');
expect(await screen.findByText('Abbrechen')).toBeTruthy();
});
it('follows a stored locale choice over the route locale', async () => {
useLocaleStore.setState({ locale: 'fr' });
render(<IntlProvider locale="de"><Cancel /></IntlProvider>);
expect(await screen.findByText('Annuler')).toBeTruthy();
expect(screen.queryByText('Abbrechen')).toBeNull();
});
});
+22 -13
View File
@@ -52,7 +52,8 @@ export const LOCALE_LOADERS: Record<string, () => Promise<Messages>> = {
interface IntlProviderProps {
locale: string;
messages: Messages;
/** Omitted by the Lite export: the catalog is then loaded on the client. */
messages?: Messages;
children: React.ReactNode;
}
@@ -67,14 +68,16 @@ export function IntlProvider({ locale: initialLocale, messages: initialMessages,
// The catalog currently in use. Seeded with the server-provided messages for
// the SSR-resolved locale, so the first render needs no async work and
// matches the server output. Locale and messages switch together once a
// lazily loaded catalog arrives, never out of sync.
const [catalog, setCatalog] = useState<{ locale: string; messages: Messages }>({
locale: initialLocale,
messages: initialMessages,
});
// lazily loaded catalog arrives, never out of sync. Without server messages
// (Lite) only English is at hand; any other locale renders nothing, on the
// server and on the client alike, until its chunk arrives.
const seed = initialMessages ?? (initialLocale === 'en' ? (enMessages as Messages) : undefined);
const [catalog, setCatalog] = useState<{ locale: string; messages: Messages } | null>(
seed ? { locale: initialLocale, messages: seed } : null,
);
const loadedRef = useRef<Record<string, Messages>>({
en: enMessages as Messages,
[initialLocale]: initialMessages,
...(seed ? { [initialLocale]: seed } : {}),
});
// Detect the browser's timezone on mount (falls back to UTC internally)
@@ -94,7 +97,7 @@ export function IntlProvider({ locale: initialLocale, messages: initialMessages,
const cached = loadedRef.current[target];
if (cached) {
setCatalog((prev) => (prev.locale === target ? prev : { locale: target, messages: cached }));
setCatalog((prev) => (prev?.locale === target ? prev : { locale: target, messages: cached }));
return;
}
@@ -102,7 +105,9 @@ export function IntlProvider({ locale: initialLocale, messages: initialMessages,
const load = LOCALE_LOADERS[target];
// Unknown locale: render English strings under the requested tag, matching
// the old `?? {}` fallback behavior.
(load ? load() : Promise.resolve({} as Messages)).then((messages) => {
// A chunk that fails to load falls back the same way, so a Lite page with
// no seeded catalog never stays blank.
(load ? load().catch(() => ({} as Messages)) : Promise.resolve({} as Messages)).then((messages) => {
if (stale) return;
loadedRef.current[target] = messages;
setCatalog({ locale: target, messages });
@@ -112,21 +117,25 @@ export function IntlProvider({ locale: initialLocale, messages: initialMessages,
}, [currentLocale]);
// Keep <html> lang/dir in sync with the rendered locale (RTL for he/fa).
const activeLocale = catalog?.locale;
useEffect(() => {
document.documentElement.lang = catalog.locale;
document.documentElement.dir = getLocaleDirection(catalog.locale);
}, [catalog.locale]);
if (!activeLocale) return;
document.documentElement.lang = activeLocale;
document.documentElement.dir = getLocaleDirection(activeLocale);
}, [activeLocale]);
// Fall back to English for any key the active locale has not translated, so
// untranslated strings show English text instead of a raw message key.
const messages = useMemo(
() =>
catalog.locale === 'en'
!catalog || catalog.locale === 'en'
? (enMessages as Messages)
: mergeMessages(enMessages as Messages, catalog.messages),
[catalog]
);
if (!catalog) return null;
return (
<NextIntlClientProvider
locale={catalog.locale}