Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions specs/fixtures/issues/3988/app.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<template>
<div>
<NuxtRouteAnnouncer />
<NuxtPage />
</div>
</template>
3 changes: 3 additions & 0 deletions specs/fixtures/issues/3988/i18n/locales/de.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"heading": "Deutsche Ueberschrift"
}
3 changes: 3 additions & 0 deletions specs/fixtures/issues/3988/i18n/locales/en.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"heading": "English heading"
}
16 changes: 16 additions & 0 deletions specs/fixtures/issues/3988/nuxt.config.ts
Original file line number Diff line number Diff line change
@@ -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'
}
}
})
13 changes: 13 additions & 0 deletions specs/fixtures/issues/3988/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
16 changes: 16 additions & 0 deletions specs/fixtures/issues/3988/pages/index.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<template>
<main>
<h1 id="translated-heading">{{ $t('heading') }}</h1>
<p id="translated-heading-v-text" v-text="$t('heading')" />
<p id="translated-heading-v-html" v-html="$t('heading')" />
<input id="translated-placeholder" :placeholder="$t('heading')">
<p id="current-locale">{{ locale }}</p>
<NuxtLink id="localized-home-link" :to="localePath('/')">Localized home</NuxtLink>
<NuxtLinkLocale id="localized-home-link-locale" to="/">Localized home locale</NuxtLinkLocale>
</main>
</template>

<script setup lang="ts">
const localePath = useLocalePath()
const { locale } = useI18n()
</script>
122 changes: 122 additions & 0 deletions specs/issues/2790.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
'.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<void>((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<void>((resolve, reject) => {
server.close(error => error ? reject(error) : resolve())
})
}
})
})
32 changes: 32 additions & 0 deletions specs/issues/3988.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
14 changes: 12 additions & 2 deletions src/runtime/plugins/route-locale-detect.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -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 },
),
)

Expand All @@ -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
Expand Down
8 changes: 6 additions & 2 deletions src/runtime/plugins/ssg-detect.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -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))
})
},
})
Loading
Loading