Skip to content

Commit 21fcf81

Browse files
authoredJan 23, 2025··
fix: locale switching middleware in static build (#3323)
1 parent 3a9960d commit 21fcf81

18 files changed

+453
-3
lines changed
 

‎pnpm-lock.yaml

+9
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<template>
2+
<div>
3+
<NuxtPage />
4+
</div>
5+
</template>
6+
7+
<style>
8+
section {
9+
margin: 1rem 0;
10+
}
11+
</style>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<script setup lang="ts">
2+
import { useI18n, useSwitchLocalePath } from '#i18n'
3+
4+
const { locales, locale, setLocale } = useI18n()
5+
const switchLocalePath = useSwitchLocalePath()
6+
7+
const localesExcludingCurrent = computed(() => {
8+
return locales.value.filter(i => i.code !== locale.value)
9+
})
10+
</script>
11+
12+
<template>
13+
<div>
14+
<section id="lang-switcher-with-nuxt-link">
15+
<strong>Using <code>NuxtLink</code></strong
16+
>:
17+
<NuxtLink
18+
v-for="(locale, index) in localesExcludingCurrent"
19+
:key="index"
20+
:id="`lang-switcher-with-nuxt-link-${locale.code}`"
21+
:exact="true"
22+
:to="switchLocalePath(locale.code)"
23+
>{{ locale.name }}</NuxtLink
24+
>
25+
</section>
26+
<section id="lang-switcher-with-set-locale">
27+
<strong>Using <code>setLocale()</code></strong
28+
>:
29+
<a
30+
v-for="(locale, index) in localesExcludingCurrent"
31+
:id="`set-locale-link-${locale.code}`"
32+
:key="`b-${index}`"
33+
href="#"
34+
@click.prevent="setLocale(locale.code)"
35+
>{{ locale.name }}</a
36+
>
37+
</section>
38+
<section id="lang-switcher-current-locale">
39+
<strong
40+
>Current Locale: <code>{{ locale }}</code></strong
41+
>:
42+
</section>
43+
</div>
44+
</template>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { createResolver, defineNuxtModule } from '@nuxt/kit'
2+
3+
export default defineNuxtModule({
4+
async setup(options, nuxt) {
5+
const { resolve } = createResolver(import.meta.url)
6+
nuxt.hook('i18n:registerModule', register => {
7+
register({
8+
langDir: resolve('./locales'),
9+
locales: [
10+
{
11+
code: 'nl',
12+
language: 'nl-NL',
13+
file: 'lazy-locale-module-nl.ts',
14+
name: 'Nederlands'
15+
}
16+
]
17+
})
18+
})
19+
}
20+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export default defineI18nLocale(locale => ({
2+
moduleLayerText: 'This is a merged module layer locale key in Dutch',
3+
welcome: 'Welkom!',
4+
dynamicTime: new Date().toISOString()
5+
}))
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export default {
2+
legacy: false,
3+
messages: {},
4+
fallbackLocale: 'en'
5+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
export default defineI18nLocale(async function (locale) {
2+
return {
3+
html: '<span>This is the danger</span>',
4+
settings: {
5+
nest: {
6+
foo: {
7+
bar: {
8+
profile: 'Profile1'
9+
}
10+
}
11+
}
12+
}
13+
}
14+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default {
2+
settings_nest_foo_bar_profile: 'Profile2'
3+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"home": "Homepage",
3+
"about": "About us",
4+
"posts": "Posts",
5+
"dynamic": "Dynamic",
6+
"html": "<span>This is the danger</span>",
7+
"dynamicTime": "Not dynamic",
8+
"welcome": "Welcome!"
9+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
home: 'Accueil',
3+
about: 'À propos',
4+
posts: 'Articles',
5+
dynamic: 'Dynamique',
6+
dynamicTime: 'Not dynamic'
7+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import i18nModule from './i18n-module'
2+
3+
// https://nuxt.com/docs/guide/directory-structure/nuxt.config
4+
export default defineNuxtConfig({
5+
vite: {
6+
// Prevent reload by optimizing dependency before discovery
7+
optimizeDeps: {
8+
include: ['@unhead/vue']
9+
},
10+
// https://nuxt.com/blog/v3-11#chunk-naming
11+
// We change the chunk file name so we can detect file requests in our tests
12+
$client: {
13+
build: {
14+
rollupOptions: {
15+
output: {
16+
chunkFileNames: '_nuxt/[name].js',
17+
entryFileNames: '_nuxt/[name].js'
18+
}
19+
}
20+
}
21+
}
22+
},
23+
modules: [i18nModule, '@nuxtjs/i18n'],
24+
i18n: {
25+
debug: true,
26+
restructureDir: false,
27+
baseUrl: 'http://localhost:3000',
28+
// langDir: 'lang',
29+
// defaultLocale: 'fr',
30+
detectBrowserLanguage: false,
31+
compilation: {
32+
strictMessage: false
33+
},
34+
defaultLocale: 'en',
35+
langDir: 'lang',
36+
lazy: true,
37+
locales: [
38+
{
39+
code: 'en',
40+
language: 'en-US',
41+
file: 'lazy-locale-en.json',
42+
name: 'English'
43+
},
44+
{
45+
code: 'en-GB',
46+
language: 'en-GB',
47+
files: ['lazy-locale-en.json', 'lazy-locale-en-GB.js', 'lazy-locale-en-GB.ts'],
48+
name: 'English (UK)'
49+
},
50+
{
51+
code: 'fr',
52+
language: 'fr-FR',
53+
file: { path: 'lazy-locale-fr.json5', cache: false },
54+
name: 'Français'
55+
}
56+
]
57+
}
58+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"name": "nuxt3-test-lazy",
3+
"private": true,
4+
"type": "module",
5+
"scripts": {
6+
"dev": "nuxi dev",
7+
"build": "nuxt build",
8+
"generate": "nuxt generate",
9+
"start": "node .output/server/index.mjs"
10+
},
11+
"devDependencies": {
12+
"@nuxtjs/i18n": "latest",
13+
"nuxt": "latest"
14+
}
15+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<script setup lang="ts">
2+
import { computed } from 'vue'
3+
import { useI18n, useLocalePath } from '#i18n'
4+
import LangSwitcher from '../../components/LangSwitcher.vue'
5+
6+
const { localeProperties } = useI18n()
7+
const localePath = useLocalePath()
8+
const code = computed(() => {
9+
return localeProperties.value.code
10+
})
11+
12+
/*
13+
// TODO: defineNuxtI18n macro
14+
defineNuxtI18n({
15+
paths: {
16+
en: '/about-us',
17+
fr: '/a-propos'
18+
}
19+
})
20+
*/
21+
</script>
22+
23+
<template>
24+
<div>
25+
<h1 id="about-header">{{ $t('about') }}</h1>
26+
<LangSwitcher />
27+
<!-- div id="store-path-fr">{{ $store.state.routePathFr }}</div -->
28+
<section>
29+
<strong
30+
>code: <code id="locale-properties-code">{{ code }}</code></strong
31+
>
32+
</section>
33+
<NuxtLink id="link-home" exact :to="localePath('index')">{{ $t('home') }}</NuxtLink>
34+
</div>
35+
</template>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<script setup lang="ts">
2+
import { watchEffect } from 'vue'
3+
import { useAsyncData, useHead } from '#imports'
4+
import { useI18n, useLocalePath, useLocaleHead } from '#i18n'
5+
import LangSwitcher from '../components/LangSwitcher.vue'
6+
7+
const { t } = useI18n()
8+
const localePath = useLocalePath()
9+
const i18nHead = useLocaleHead({ seo: { canonicalQueries: ['page'] } })
10+
const { data, refresh } = useAsyncData('home', () =>
11+
Promise.resolve({
12+
aboutPath: localePath('about'),
13+
aboutTranslation: t('about')
14+
})
15+
)
16+
17+
watchEffect(() => {
18+
refresh()
19+
})
20+
21+
useHead(() => ({
22+
title: t('home'),
23+
htmlAttrs: {
24+
lang: i18nHead.value.htmlAttrs!.lang
25+
},
26+
link: [...(i18nHead.value.link || [])],
27+
meta: [...(i18nHead.value.meta || [])]
28+
}))
29+
</script>
30+
31+
<template>
32+
<div>
33+
<h1 id="home-header">{{ $t('home') }}</h1>
34+
<LangSwitcher />
35+
<section>
36+
<strong>resolve with <code>useAsyncData</code></strong
37+
>:
38+
<code id="home-use-async-data">{{ data }}</code>
39+
</section>
40+
<section>
41+
<strong><code>useHead</code> with <code>useLocaleHead</code></strong
42+
>:
43+
<code id="home-use-locale-head">{{ i18nHead }}</code>
44+
</section>
45+
<NuxtLink id="link-about" exact :to="localePath('about')">{{ $t('about') }}</NuxtLink>
46+
<p id="profile-js">{{ $t('settings.nest.foo.bar.profile') }}</p>
47+
<p id="profile-ts">{{ $t('settings_nest_foo_bar_profile') }}</p>
48+
<p id="html-message" v-html="$t('html')"></p>
49+
<p id="dynamic-time">{{ $t('dynamicTime') }}</p>
50+
</div>
51+
</template>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<script lang="ts" setup>
2+
import { useI18n } from '#i18n'
3+
import { computed } from 'vue'
4+
5+
const { loadLocaleMessages, t } = useI18n()
6+
7+
await loadLocaleMessages('nl')
8+
9+
const welcome = computed(() => t('welcome'))
10+
const welcomeDutch = computed(() => t('welcome', 1, { locale: 'nl' }))
11+
</script>
12+
13+
<template>
14+
<div>
15+
<span id="welcome-english">{{ welcome }}</span>
16+
<span id="welcome-dutch">{{ welcomeDutch }}</span>
17+
</div>
18+
</template>

‎specs/ssg/basic_lazy_load.spec.ts

+147
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { test, expect, describe } from 'vitest'
2+
import { fileURLToPath } from 'node:url'
3+
import { setup, url } from '../utils'
4+
import { getText, getData, waitForMs, renderPage, waitForURL } from '../helper'
5+
6+
describe('basic lazy loading', async () => {
7+
await setup({
8+
rootDir: fileURLToPath(new URL(`../fixtures/lazy-without-server`, import.meta.url)),
9+
browser: true,
10+
prerender: true,
11+
nuxtConfig: {
12+
i18n: {
13+
debug: true
14+
}
15+
}
16+
})
17+
18+
test('dynamic locale files are not cached', async () => {
19+
const { page } = await renderPage('/nl')
20+
21+
page.on('domcontentloaded', () => {
22+
console.log('domcontentload triggered!')
23+
})
24+
25+
// capture dynamicTime - simulates changing api response
26+
const dynamicTime = await getText(page, '#dynamic-time')
27+
28+
await page.click('#lang-switcher-with-nuxt-link-fr')
29+
await waitForURL(page, '/fr')
30+
expect(await getText(page, '#dynamic-time')).toEqual('Not dynamic')
31+
32+
// dynamicTime depends on passage of some time
33+
await waitForMs(100)
34+
35+
// dynamicTime does not match captured dynamicTime
36+
await page.click('#lang-switcher-with-nuxt-link-nl')
37+
await waitForURL(page, '/nl')
38+
expect(await getText(page, '#dynamic-time')).to.not.equal(dynamicTime)
39+
})
40+
41+
test('locales are fetched on demand', async () => {
42+
const home = url('/')
43+
const { page, requests } = await renderPage(home)
44+
45+
const setFromRequests = () => [...new Set(requests)].filter(x => x.includes('lazy-locale-'))
46+
47+
// only default locales are fetched (en)
48+
await page.goto(home)
49+
console.log(setFromRequests())
50+
expect(setFromRequests().filter(locale => locale.includes('fr') || locale.includes('nl'))).toHaveLength(0)
51+
52+
// wait for request after navigation
53+
const localeRequestFr = page.waitForRequest(/lazy-locale-fr/)
54+
await page.click('#lang-switcher-with-nuxt-link-fr')
55+
await localeRequestFr
56+
57+
// `fr` locale has been fetched
58+
expect(setFromRequests().filter(locale => locale.includes('fr'))).toHaveLength(1)
59+
60+
// wait for request after navigation
61+
const localeRequestNl = page.waitForRequest(/lazy-locale-module-nl/)
62+
await page.click('#lang-switcher-with-nuxt-link-nl')
63+
await localeRequestNl
64+
65+
// `nl` (module) locale has been fetched
66+
expect(setFromRequests().filter(locale => locale.includes('nl'))).toHaveLength(1)
67+
})
68+
69+
test('can access to no prefix locale (en): /', async () => {
70+
const { page } = await renderPage('/')
71+
72+
// `en` rendering
73+
expect(await getText(page, '#home-header')).toEqual('Homepage')
74+
expect(await getText(page, 'title')).toEqual('Homepage')
75+
expect(await getText(page, '#link-about')).toEqual('About us')
76+
77+
// lang switcher rendering
78+
expect(await getText(page, '#set-locale-link-fr')).toEqual('Français')
79+
80+
// page path
81+
expect(await getData(page, '#home-use-async-data')).toMatchObject({ aboutPath: '/about' })
82+
83+
// current locale
84+
expect(await getText(page, '#lang-switcher-current-locale code')).toEqual('en')
85+
86+
// html tag `lang` attribute with language code
87+
expect(await page.getAttribute('html', 'lang')).toEqual('en-US')
88+
})
89+
90+
test('can access to prefix locale: /fr', async () => {
91+
const { page } = await renderPage('/fr')
92+
93+
console.log(page.url())
94+
95+
// `fr` rendering
96+
expect(await getText(page, '#home-header')).toEqual('Accueil')
97+
expect(await getText(page, 'title')).toEqual('Accueil')
98+
expect(await getText(page, '#link-about')).toEqual('À propos')
99+
100+
// lang switcher rendering
101+
expect(await getText(page, '#set-locale-link-en')).toEqual('English')
102+
103+
// page path
104+
expect(await getData(page, '#home-use-async-data')).toMatchObject({ aboutPath: '/fr/about' })
105+
106+
// current locale
107+
expect(await getText(page, '#lang-switcher-current-locale code')).toEqual('fr')
108+
109+
// html tag `lang` attribute with language code
110+
expect(await page.getAttribute('html', 'lang')).toEqual('fr-FR')
111+
})
112+
113+
test('mutiple lazy loading', async () => {
114+
const { page } = await renderPage('/en-GB')
115+
116+
// `en` base rendering
117+
expect(await getText(page, '#home-header')).toEqual('Homepage')
118+
expect(await getText(page, 'title')).toEqual('Homepage')
119+
expect(await getText(page, '#link-about')).toEqual('About us')
120+
121+
expect(await getText(page, '#profile-js')).toEqual('Profile1')
122+
expect(await getText(page, '#profile-ts')).toEqual('Profile2')
123+
})
124+
125+
test('files with cache disabled bypass caching', async () => {
126+
const { page, consoleLogs } = await renderPage('/')
127+
128+
await page.click('#lang-switcher-with-nuxt-link-en-GB')
129+
expect([...consoleLogs].filter(log => log.text.includes('lazy-locale-en-GB.js bypassing cache!'))).toHaveLength(1)
130+
131+
await page.click('#lang-switcher-with-nuxt-link-fr')
132+
expect([...consoleLogs].filter(log => log.text.includes('lazy-locale-fr.json5 bypassing cache!'))).toHaveLength(1)
133+
134+
await page.click('#lang-switcher-with-nuxt-link-en-GB')
135+
expect([...consoleLogs].filter(log => log.text.includes('lazy-locale-en-GB.js bypassing cache!'))).toHaveLength(2)
136+
137+
await page.click('#lang-switcher-with-nuxt-link-fr')
138+
expect([...consoleLogs].filter(log => log.text.includes('lazy-locale-fr.json5 bypassing cache!'))).toHaveLength(2)
139+
})
140+
141+
test('manually loaded messages can be used in translations', async () => {
142+
const { page } = await renderPage('/manual-load')
143+
144+
expect(await getText(page, '#welcome-english')).toEqual('Welcome!')
145+
expect(await getText(page, '#welcome-dutch')).toEqual('Welkom!')
146+
})
147+
})

‎specs/utils/server.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ export async function startServer(env: Record<string, unknown> = {}) {
5151
ctx.serverProcess.kill()
5252
throw lastError || new Error('Timeout waiting for dev server!')
5353
} else if (ctx.options.prerender) {
54-
const command = `npx serve -s ${ctx.nuxt!.options.nitro!.output?.publicDir} -l tcp://${host}:${port} --no-port-switching`
54+
const command = `npx serve ${ctx.nuxt!.options.nitro!.output?.publicDir} -l tcp://${host}:${port} --no-port-switching`
5555
// ; (await import('consola')).consola.restoreConsole()
5656
const [_command, ...commandArgs] = command.split(' ')
5757

‎src/runtime/plugins/route-locale-detect.ts

+1-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { unref } from 'vue'
2-
import { hasPages, isSSG } from '#build/i18n.options.mjs'
2+
import { hasPages } from '#build/i18n.options.mjs'
33
import { addRouteMiddleware, defineNuxtPlugin, defineNuxtRouteMiddleware } from '#imports'
44
import { createLogger } from 'virtual:nuxt-i18n-logger'
55
import { detectLocale, detectRedirect, loadAndSetLocale, navigate } from '../utils'
@@ -40,7 +40,6 @@ export default defineNuxtPlugin({
4040
}
4141

4242
const localeChangeMiddleware = defineNuxtRouteMiddleware(async (to, from) => {
43-
if (isSSG && nuxtApp._vueI18n.__firstAccess) return
4443
__DEBUG__ && logger.log('locale-changing middleware', to, from)
4544

4645
const locale = await nuxtApp.runWithContext(() => handleRouteDetect(to))

0 commit comments

Comments
 (0)
Please sign in to comment.