From 90ec6748aca61ff4b67bb6bd8ed734fa679846a5 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 28 Oct 2026 17:16:35 +0100
Subject: [PATCH] fix: keep the translations out of every page of the Lite
export
---
app/(main)/[locale]/layout.tsx | 13 ++++--
.../__tests__/intl-provider-lite.test.tsx | 45 +++++++++++++++++++
components/providers/intl-provider.tsx | 35 +++++++++------
3 files changed, 76 insertions(+), 17 deletions(-)
create mode 100644 components/providers/__tests__/intl-provider-lite.test.tsx
diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx
index d34288e8a..94ccd69b5 100644
--- a/app/(main)/[locale]/layout.tsx
+++ b/app/(main)/[locale]/layout.tsx
@@ -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 (
diff --git a/components/providers/__tests__/intl-provider-lite.test.tsx b/components/providers/__tests__/intl-provider-lite.test.tsx
new file mode 100644
index 000000000..25b06cc7d
--- /dev/null
+++ b/components/providers/__tests__/intl-provider-lite.test.tsx
@@ -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 {useTranslations('common')('cancel')};
+}
+
+describe('IntlProvider without server messages (Lite)', () => {
+ afterEach(() => {
+ useLocaleStore.setState({ locale: '' });
+ });
+
+ it('prerenders nothing for a non-English locale', () => {
+ expect(renderToString()).toBe('');
+ });
+
+ it('prerenders English straight away from the bundled catalog', () => {
+ expect(renderToString()).toContain('Cancel');
+ });
+
+ it('loads the route locale on the client and renders it', async () => {
+ const { container } = render();
+ 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();
+ expect(await screen.findByText('Annuler')).toBeTruthy();
+ expect(screen.queryByText('Abbrechen')).toBeNull();
+ });
+});
diff --git a/components/providers/intl-provider.tsx b/components/providers/intl-provider.tsx
index 1bddc326e..726bbcb7c 100644
--- a/components/providers/intl-provider.tsx
+++ b/components/providers/intl-provider.tsx
@@ -52,7 +52,8 @@ export const LOCALE_LOADERS: Record Promise> = {
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>({
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 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 (