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 (