diff --git a/specs/fixtures/issues/3988/app.vue b/specs/fixtures/issues/3988/app.vue new file mode 100644 index 000000000..fa4ee3efd --- /dev/null +++ b/specs/fixtures/issues/3988/app.vue @@ -0,0 +1,6 @@ + diff --git a/specs/fixtures/issues/3988/i18n/locales/de.json b/specs/fixtures/issues/3988/i18n/locales/de.json new file mode 100644 index 000000000..3aa350ed3 --- /dev/null +++ b/specs/fixtures/issues/3988/i18n/locales/de.json @@ -0,0 +1,3 @@ +{ + "heading": "Deutsche Ueberschrift" +} diff --git a/specs/fixtures/issues/3988/i18n/locales/en.json b/specs/fixtures/issues/3988/i18n/locales/en.json new file mode 100644 index 000000000..5d76c3f09 --- /dev/null +++ b/specs/fixtures/issues/3988/i18n/locales/en.json @@ -0,0 +1,3 @@ +{ + "heading": "English heading" +} diff --git a/specs/fixtures/issues/3988/nuxt.config.ts b/specs/fixtures/issues/3988/nuxt.config.ts new file mode 100644 index 000000000..d7fa4155c --- /dev/null +++ b/specs/fixtures/issues/3988/nuxt.config.ts @@ -0,0 +1,16 @@ +export default defineNuxtConfig({ + modules: ['@nuxtjs/i18n'], + i18n: { + defaultLocale: 'de', + strategy: 'prefix_except_default', + locales: [ + { code: 'en', language: 'en-US', file: 'en.json' }, + { code: 'de', language: 'de-DE', file: 'de.json' } + ], + detectBrowserLanguage: { + useCookie: true, + cookieKey: 'i18n_redirected', + redirectOn: 'root' + } + } +}) diff --git a/specs/fixtures/issues/3988/package.json b/specs/fixtures/issues/3988/package.json new file mode 100644 index 000000000..b03fba572 --- /dev/null +++ b/specs/fixtures/issues/3988/package.json @@ -0,0 +1,13 @@ +{ + "private": true, + "scripts": { + "build": "nuxt build", + "dev": "nuxt dev", + "generate": "nuxt generate", + "preview": "nuxt preview" + }, + "devDependencies": { + "@nuxtjs/i18n": "latest", + "nuxt": "latest" + } +} diff --git a/specs/fixtures/issues/3988/pages/index.vue b/specs/fixtures/issues/3988/pages/index.vue new file mode 100644 index 000000000..48867ce5d --- /dev/null +++ b/specs/fixtures/issues/3988/pages/index.vue @@ -0,0 +1,16 @@ + + + diff --git a/specs/issues/2790.spec.ts b/specs/issues/2790.spec.ts new file mode 100644 index 000000000..9bbe6f019 --- /dev/null +++ b/specs/issues/2790.spec.ts @@ -0,0 +1,122 @@ +import { readFile, stat } from 'node:fs/promises' +import { createServer } from 'node:http' +import { extname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, test } from 'vitest' +import { getBrowser, setup, useTestContext } from '../utils' + +const contentTypes: Record = { + '.css': 'text/css; charset=utf-8', + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.map': 'application/json; charset=utf-8' +} + +async function resolvePublicAsset(publicDir: string, pathname: string) { + if (pathname === '/') { + return join(publicDir, '200.html') + } + + const exactFile = join(publicDir, pathname.slice(1)) + try { + if ((await stat(exactFile)).isFile()) { + return exactFile + } + } catch {} + + const indexFile = join(publicDir, pathname.slice(1), 'index.html') + try { + if ((await stat(indexFile)).isFile()) { + return indexFile + } + } catch {} + + if (extname(pathname)) { + return null + } + + return join(publicDir, '200.html') +} + +describe('#2790', async () => { + // Reuse the `#3988` fixture so this spec only needs to override the routing strategy to `prefix`. + await setup({ + rootDir: fileURLToPath(new URL(`../fixtures/issues/3988`, import.meta.url)), + browser: true, + prerender: true, + server: false, + nuxtConfig: { + i18n: { + strategy: 'prefix' + } + } + }) + + test('redirects the prerender fallback root to the detected locale without mismatches', async () => { + const publicDir = useTestContext().nuxt!.options.nitro.output!.publicDir! + const server = createServer(async (request, response) => { + try { + const pathname = new URL(request.url || '/', 'http://127.0.0.1').pathname + const filePath = await resolvePublicAsset(publicDir, pathname) + if (filePath == null) { + response.statusCode = 404 + response.end() + return + } + + const contents = await readFile(filePath) + + response.statusCode = 200 + response.setHeader('content-type', contentTypes[extname(filePath)] || 'application/octet-stream') + response.end(contents) + } catch (error) { + console.error(error) + response.statusCode = 500 + response.end() + } + }) + + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', () => resolve()) + }) + + try { + const address = server.address() + if (!address || typeof address === 'string') { + throw new Error('Failed to resolve test server address.') + } + + const browser = await getBrowser() + const page = await browser.newPage({ locale: 'en' }) + const consoleLogs: { type: string, text: string }[] = [] + const pageErrors: Error[] = [] + + page.on('console', message => consoleLogs.push({ type: message.type(), text: message.text() })) + page.on('pageerror', error => pageErrors.push(error)) + + await page.goto(`http://127.0.0.1:${address.port}/`) + await page.waitForURL(/\/en\/?$/) + await page.waitForFunction(() => !window.useNuxtApp?.().isHydrating) + + expect(await page.locator('#translated-heading').innerText()).toEqual('English heading') + expect(await page.locator('#translated-heading-v-text').innerText()).toEqual('English heading') + expect(await page.locator('#translated-heading-v-html').innerText()).toEqual('English heading') + expect(await page.getAttribute('#translated-placeholder', 'placeholder')).toEqual('English heading') + expect(await page.locator('#current-locale').innerText()).toEqual('en') + expect(await page.getAttribute('#localized-home-link', 'href')).toEqual('/en') + expect(await page.getAttribute('#localized-home-link-locale', 'href')).toEqual('/en') + expect(pageErrors).toEqual([]) + expect( + consoleLogs.some(log => + ['warning', 'error'].includes(log.type) && /hydration|mismatch/i.test(log.text) + ) + ).toBe(false) + } finally { + await new Promise((resolve, reject) => { + server.close(error => error ? reject(error) : resolve()) + }) + } + }) +}) diff --git a/specs/issues/3988.spec.ts b/specs/issues/3988.spec.ts new file mode 100644 index 000000000..b10758723 --- /dev/null +++ b/specs/issues/3988.spec.ts @@ -0,0 +1,32 @@ +import { test, expect, describe } from 'vitest' +import { fileURLToPath } from 'node:url' +import { setup, url } from '../utils' +import { renderPage } from '../helper' + +describe('#3988', async () => { + await setup({ + rootDir: fileURLToPath(new URL(`../fixtures/issues/3988`, import.meta.url)), + browser: true, + prerender: true + }) + + test('avoids hydration mismatch when browser detection redirects from prerendered root', async () => { + const { page, consoleLogs, pageErrors } = await renderPage('/', { locale: 'en' }) + + await page.waitForURL(url('/en')) + + expect(await page.locator('#translated-heading').innerText()).toEqual('English heading') + expect(await page.locator('#translated-heading-v-text').innerText()).toEqual('English heading') + expect(await page.locator('#translated-heading-v-html').innerText()).toEqual('English heading') + expect(await page.getAttribute('#translated-placeholder', 'placeholder')).toEqual('English heading') + expect(await page.locator('#current-locale').innerText()).toEqual('en') + expect(await page.getAttribute('#localized-home-link', 'href')).toEqual('/en') + expect(await page.getAttribute('#localized-home-link-locale', 'href')).toEqual('/en') + expect(pageErrors).toEqual([]) + expect( + consoleLogs.some(log => + ['warning', 'error'].includes(log.type) && /hydration|mismatch/i.test(log.text) + ) + ).toBe(false) + }) +}) diff --git a/src/runtime/plugins/route-locale-detect.ts b/src/runtime/plugins/route-locale-detect.ts index b8cd85ee6..129802e46 100644 --- a/src/runtime/plugins/route-locale-detect.ts +++ b/src/runtime/plugins/route-locale-detect.ts @@ -1,5 +1,5 @@ import { useNuxtI18nContext, useResolvedLocale } from '../context' -import { detectLocale, loadAndSetLocale, navigate } from '../utils' +import { detectLocale, detectLocaleFromRouteOrDomain, loadAndSetLocale, navigate } from '../utils' import { addRouteMiddleware, defineNuxtPlugin, defineNuxtRouteMiddleware, useNuxtApp, useRouter } from '#imports' export default defineNuxtPlugin({ @@ -22,10 +22,16 @@ export default defineNuxtPlugin({ } const resolvedLocale = useResolvedLocale() + const isInitialSsgHydration = __IS_SSG__ && import.meta.client && ctx.initial && __I18N_STRATEGY__ !== 'no_prefix' + const initialLocale = isInitialSsgHydration + // Keep the prerendered locale stable through hydration and defer browser detection to the SSG plugin. + ? detectLocaleFromRouteOrDomain(nuxt, nuxt.$router.currentRoute.value) + : (resolvedLocale.value || detectLocale(nuxt, nuxt.$router.currentRoute.value)) await nuxt.runWithContext(() => loadAndSetLocale( nuxt, - (ctx.initial && resolvedLocale.value) || detectLocale(nuxt, nuxt.$router.currentRoute.value), + initialLocale, + { syncCookie: !isInitialSsgHydration }, ), ) @@ -35,6 +41,10 @@ export default defineNuxtPlugin({ addRouteMiddleware( 'locale-changing', defineNuxtRouteMiddleware(async (to) => { + if (__IS_SSG__ && import.meta.client && ctx.initial) { + return + } + const locale = await nuxt.runWithContext(() => loadAndSetLocale(nuxt, detectLocale(nuxt, to))) ctx.initial = false diff --git a/src/runtime/plugins/ssg-detect.ts b/src/runtime/plugins/ssg-detect.ts index eef1da034..bcadc57bb 100644 --- a/src/runtime/plugins/ssg-detect.ts +++ b/src/runtime/plugins/ssg-detect.ts @@ -1,6 +1,6 @@ import { defineNuxtPlugin, useNuxtApp } from '#imports' import { useNuxtI18nContext } from '../context' -import { detectLocale } from '../utils' +import { detectLocale, loadAndSetLocale, navigate } from '../utils' export default defineNuxtPlugin({ name: 'i18n:plugin:ssg-detect', @@ -17,8 +17,12 @@ export default defineNuxtPlugin({ // NOTE: avoid hydration mismatch for SSG mode nuxt.hook('app:mounted', async () => { const detected = detectLocale(nuxt, nuxt.$router.currentRoute.value) - await nuxt.runWithContext(() => nuxt.$i18n.setLocale(detected)) ctx.initial = false + const locale = await nuxt.runWithContext(() => loadAndSetLocale(nuxt, detected)) + // `loadAndSetLocale` can short-circuit when the detected locale already matches the current locale. + // Keep the cookie in sync for that first-visit SSG case as well. + ctx.setCookieLocale(locale) + await nuxt.runWithContext(() => navigate(nuxt, nuxt.$router.currentRoute.value, locale)) }) }, }) diff --git a/src/runtime/server/plugin.ts b/src/runtime/server/plugin.ts index 42a681f25..e58119fe1 100644 --- a/src/runtime/server/plugin.ts +++ b/src/runtime/server/plugin.ts @@ -53,6 +53,127 @@ function createRedirectResponse(event: H3Event, dest: string, code: number) { } } +function serializeInlineScript(value: unknown) { + return JSON.stringify(value) + .replace(/, + detection: ReturnType, + defaultLocale: string, +) { + const locales = runtimeI18n.locales.map(locale => ({ + code: typeof locale === 'string' ? locale : locale.code, + language: typeof locale === 'string' ? locale : (locale.language || locale.code), + })) + const config = { + cookieKey: detection.cookieKey, + defaultLocale, + fallbackLocale: detection.fallbackLocale || '', + strategy: __I18N_STRATEGY__, + useCookie: detection.useCookie, + } + const serializedConfig = serializeInlineScript(config) + const serializedLocales = serializeInlineScript(locales) + + return ` +`.trim() +} + export default defineNitroPlugin(async (nitro) => { const runtimeI18n = useRuntimeI18n() const rootRedirect = resolveRootRedirect(runtimeI18n.rootRedirect) @@ -191,6 +312,28 @@ export default defineNitroPlugin(async (nitro) => { nitro.hooks.hook('render:html', (htmlContext, { event }) => { const ctx = tryUseI18nContext(event) + const requestURL = getRequestURL(event) + const isStaticRootEntry = requestURL.pathname === '/' + || (__I18N_STRATEGY__ === 'prefix' && requestURL.pathname === '/200.html') + + if ( + __IS_SSG__ + && detection.enabled + && detection.redirectOn === 'root' + && __I18N_STRATEGY__ !== 'no_prefix' + && !__DIFFERENT_DOMAINS__ + && !__MULTI_DOMAIN_LOCALES__ + && !rootRedirect + && isStaticRootEntry + ) { + // Static hosting has no request-time redirect. Bootstrap the locale redirect on the + // prerendered root entry before Vue mounts so the initial paint matches the detected locale. + // `strategy: 'prefix'` serves the root request from `200.html`, so include that shell too. + htmlContext.head.unshift( + createStaticRootLocaleRedirectScript(runtimeI18n, detection, ctx?.vueI18nOptions?.defaultLocale || _defaultLocale), + ) + } + if (__I18N_PRELOAD__) { if (ctx == null || Object.keys(ctx.messages ?? {}).length == 0) { return } diff --git a/src/runtime/utils.ts b/src/runtime/utils.ts index 67944d871..d8e4adfd0 100644 --- a/src/runtime/utils.ts +++ b/src/runtime/utils.ts @@ -220,9 +220,14 @@ declare global { } } -export async function loadAndSetLocale(nuxtApp: NuxtApp, locale: Locale): Promise { +export async function loadAndSetLocale( + nuxtApp: NuxtApp, + locale: Locale, + options: { syncCookie?: boolean } = {}, +): Promise { const ctx = useNuxtI18nContext(nuxtApp) const oldLocale = ctx.getLocale() + const syncCookie = options.syncCookie ?? true // skip if locale is already set and there is no pending locale change to a different locale if (locale === oldLocale && !ctx.initial && (!ctx.vueI18n.__pendingLocale || ctx.vueI18n.__pendingLocale === locale)) { @@ -241,7 +246,11 @@ export async function loadAndSetLocale(nuxtApp: NuxtApp, locale: Locale): Promis } await ctx.loadMessages(locale) - await ctx.setLocaleSuspend(locale) + if (syncCookie) { + await ctx.setLocaleSuspend(locale) + } else { + await ctx.setLocale(locale) + } return locale } @@ -297,6 +306,28 @@ export function detectLocale(nuxtApp: NuxtApp, route: string | CompatRoute): str return ctx.getLocale() || ctx.getDefaultLocale() || '' } +// Route and domain detection are hydration-safe for prerendered pages because they match the rendered HTML. +export function detectLocaleFromRouteOrDomain(nuxtApp: NuxtApp, route: string | CompatRoute): string { + const detectConfig = useI18nDetection(nuxtApp) + const detectors = useDetectors(useRequestEvent(nuxtApp), detectConfig, nuxtApp) + const ctx = useNuxtI18nContext(nuxtApp) + const path = isString(route) ? route : route.path + + if (__DIFFERENT_DOMAINS__ || __MULTI_DOMAIN_LOCALES__) { + const hostLocale = detectors.host(path) + if (hostLocale && isSupportedLocale(hostLocale)) { + return hostLocale + } + } + + const routeLocale = detectors.route(route) + if (routeLocale && isSupportedLocale(routeLocale)) { + return routeLocale + } + + return ctx.getLocale() || ctx.getDefaultLocale() || '' +} + export function navigate(nuxtApp: NuxtApp, to: CompatRoute, locale: string) { if (!__I18N_ROUTING__ || __DIFFERENT_DOMAINS__) { return }