Skip to content

Commit 917c043

Browse files
authored
fix: load locale files that use Nuxt app composables in the app (#4102)
1 parent 9574cc2 commit 917c043

19 files changed

Lines changed: 253 additions & 54 deletions

File tree

docs/content/docs/02.guide/07.lazy-load-translations.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,12 @@ About `defineI18nLocale()`{lang="ts"} details, see the [here](/docs/composables/
7575
This is decided per locale, by reading the locale file at build time: message functions are found through variables, spreads and loader `return`s **within that file**. Ones the file cannot reveal - imported from another module, or built from runtime values - are not detected. Those are reported with a warning in development and while prerendering.
7676
::
7777

78+
::callout{icon="i-heroicons-light-bulb"}
79+
The server runs loader functions outside the Nuxt app, so they can only use APIs that exist in the browser and in Nitro, such as `$fetch()`{lang="ts"} and `useRuntimeConfig()`{lang="ts"}. A locale file calling a Nuxt app composable (`useNuxtApp()`{lang="ts"}, `useState()`{lang="ts"}, `useCookie()`{lang="ts"}, ...) is loaded inside the Nuxt app instead, on both server and client, and is not served from the messages endpoint.
80+
81+
See [where a loader runs](/docs/composables/define-i18n-locale#where-the-loader-runs) for what that costs and what stays undetected.
82+
::
83+
7884
If the function returns an Object available in nuxt i18n module, you can configure the dynamic locale messages, like the API (including external API) or back-end, via fetch:
7985

8086
```ts

docs/content/docs/06.composables/11.define-i18n-locale.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,22 @@ An example of a loader function using a fetch request to load locale messages:
3737
export default defineI18nLocale(locale => {
3838
return $fetch(`https://your-company-product/api/${locale}`)
3939
})
40-
```
40+
```
41+
42+
## Where the loader runs
43+
44+
A loader runs on the server as well as in the browser. In a production build the server runs it outside the Nuxt app - that is what lets its messages be served from the [messages endpoint](/docs/guide/lazy-load-translations) - so it can only use APIs that exist in both places, such as `$fetch()`{lang="ts"} and `useRuntimeConfig()`{lang="ts"}. Nitro-only APIs (the h3 utilities, `useStorage()`{lang="ts"}) are not available in the browser, or in development where loaders always run in the Nuxt app.
45+
46+
Nuxt app composables (`useNuxtApp()`{lang="ts"}, `useState()`{lang="ts"}, `useCookie()`{lang="ts"}, `useRequestHeaders()`{lang="ts"}, ...) are the exception: a locale file calling one keeps its loader inside the Nuxt app instead, and is loaded there on both server and client. That is decided at build time, by reading the calls the locale file makes itself. A composable reached through an imported helper is not visible, and fails when the server loads that locale - call it in the locale file to make it detectable.
47+
48+
```ts [i18n/locales/en.ts]
49+
export default defineI18nLocale(async locale => {
50+
// loaded in the Nuxt app, not through the messages endpoint
51+
const { $tenant } = useNuxtApp()
52+
return $fetch(`/api/messages/${$tenant.id}/${locale}`)
53+
})
54+
```
55+
56+
::callout{icon="i-heroicons-light-bulb"}
57+
Messages for such a locale are produced per request during SSR and again in the browser, and the locale file ships in the client bundle. A loader that reads server-only resources (a database, an internal service) must not use these composables - keep it loadable by the server.
58+
::
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export default defineI18nLocale(() => {
2+
// reading the config through the Nuxt app, which a nitro-side load has no way to reach (#3940)
3+
const nuxt = useNuxtApp()
4+
return {
5+
runtimeConfigKey: `app-context-only:${nuxt.$config.public.myKey}`
6+
}
7+
})
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { fileURLToPath } from 'node:url'
2+
import { describe, expect, test } from 'vitest'
3+
4+
import { setup, $fetch } from '../utils'
5+
6+
await setup({
7+
rootDir: fileURLToPath(new URL(`../fixtures/lazy`, import.meta.url)),
8+
nuxtConfig: {
9+
i18n: {
10+
locales: [
11+
{
12+
code: 'ap',
13+
language: 'en-AU',
14+
name: 'App context',
15+
files: ['lazy-locale-en.json', 'app-context-translation.ts']
16+
}
17+
]
18+
}
19+
}
20+
})
21+
22+
describe('(#3940) a locale file that needs the Nuxt app', () => {
23+
test('produces its messages during SSR instead of failing in nitro', async () => {
24+
const html = await $fetch('/ap')
25+
expect(html).toContain('app-context-only:runtime-config-value')
26+
})
27+
28+
test('is not among the loaders the messages endpoint runs', async () => {
29+
// the handler resolves the locale from the route, any `:hash` segment reaches it
30+
const messages = await $fetch<Record<string, Record<string, unknown>>>('/_i18n/test/ap/messages.json')
31+
32+
// the locale's other file is still served from there, the one needing the app is left out
33+
expect(messages.ap).toHaveProperty('home', 'Homepage')
34+
expect(messages.ap).not.toHaveProperty('runtimeConfigKey')
35+
})
36+
})

src/bundler.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ export function getDefineConfig(
8989
server = false,
9090
nuxt = useNuxt(),
9191
) {
92-
const { options, rawOptions, dynamicLocales, unserializableLocales, localeFileMetas, localeHashes } = ctx
92+
const { options, rawOptions, dynamicLocales, undeliverableLocales, localeFileMetas, localeHashes } = ctx
9393
// every cache site is guarded per loader (`cache`) or per locale (`isLocaleCacheable`), so this
9494
// only decides whether the mechanism exists at all
9595
const cacheLifetime = options.experimental.cacheLifetime
@@ -118,7 +118,7 @@ export function getDefineConfig(
118118
__I18N_CACHE_LIFETIME__: JSON.stringify(cacheLifetime),
119119
__I18N_HTTP_CACHE_DURATION__: JSON.stringify(options.experimental.httpCacheDuration ?? 10),
120120
__I18N_DYNAMIC_LOCALES__: JSON.stringify(dynamicLocales),
121-
__I18N_UNSERIALIZABLE_LOCALES__: JSON.stringify(unserializableLocales),
121+
__I18N_UNDELIVERABLE_LOCALES__: JSON.stringify(undeliverableLocales),
122122
__I18N_STRIP_UNUSED__: JSON.stringify(stripMessagesPayload),
123123
__I18N_PRELOAD__: JSON.stringify(!!options.experimental.preload),
124124

src/context.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,8 @@ export interface ResolvedI18nContext extends I18nNuxtContext {
4040
localeHashes: Record<string, string>
4141
/** Locales whose messages can only be produced by running their loaders */
4242
dynamicLocales: string[]
43-
/** Locales the messages endpoint cannot deliver, because JSON would drop message functions */
44-
unserializableLocales: string[]
43+
/** Locales the messages endpoint has no response for - see `DeliveryConfig.undeliverable` */
44+
undeliverableLocales: string[]
4545
/**
4646
* Whether the build is deployed without a server. Only accurate once `nitro:init` has run:
4747
* `nuxi generate` sets `nuxt.options.nitro.static`, a static preset (`github-pages`, ...) does not.
@@ -56,15 +56,16 @@ const isDynamicMeta = (meta: FileMeta) => meta.type !== 'static' && meta.cache =
5656
export function resolveDeliveryLocales(localeInfo: LocaleInfo[]) {
5757
return {
5858
dynamicLocales: localeInfo.filter(x => x.meta.some(isDynamicMeta)).map(x => x.code),
59-
unserializableLocales: localeInfo.filter(x => x.meta.some(meta => !meta.serializable)).map(x => x.code),
59+
// one list, because nothing downstream cares which of the two reasons applies
60+
undeliverableLocales: localeInfo.filter(x => x.meta.some(m => !m.serializable || m.appContext)).map(x => x.code),
6061
}
6162
}
6263

6364
/** Shares its rule with the runtime `isPrerenderable` rather than restating it - they must agree */
6465
export const prerenderableLocales = (ctx: ResolvedI18nContext) =>
6566
ctx.localeCodes.filter(createPrerenderablePredicate({
6667
dynamic: ctx.dynamicLocales,
67-
unserializable: ctx.unserializableLocales,
68+
undeliverable: ctx.undeliverableLocales,
6869
}))
6970

7071
type LayerWithI18n = { config: NuxtConfigLayer, i18n: Partial<NuxtI18nOptions>, i18nDir: string, i18nDetector?: string }
@@ -118,7 +119,7 @@ export async function resolveContext(ctx: I18nNuxtContext, nuxt: Nuxt): Promise<
118119
}
119120
}
120121

121-
const { dynamicLocales, unserializableLocales } = resolveDeliveryLocales(localeInfo)
122+
const { dynamicLocales, undeliverableLocales } = resolveDeliveryLocales(localeInfo)
122123

123124
const resolved = assign(ctx as ResolvedI18nContext, {
124125
normalizedLocales,
@@ -135,7 +136,7 @@ export async function resolveContext(ctx: I18nNuxtContext, nuxt: Nuxt): Promise<
135136
*/
136137
localeHashes: computeLocaleHashes(localeInfo, vueI18nConfigPaths),
137138
dynamicLocales,
138-
unserializableLocales,
139+
undeliverableLocales,
139140
staticDeploy: !!nuxt.options.nitro.static,
140141
})
141142
// registered before the `nitro:init` hooks that read it - hooks run in registration order

src/env.d.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ declare let __I18N_CACHE_LIFETIME__: number
2323
declare let __I18N_HTTP_CACHE_DURATION__: number
2424
/** Locales whose messages can only be produced by running their loaders at runtime */
2525
declare let __I18N_DYNAMIC_LOCALES__: string[]
26-
/** Locales the messages endpoint cannot deliver, because JSON would drop their message functions */
27-
declare let __I18N_UNSERIALIZABLE_LOCALES__: string[]
26+
/** Locales the messages endpoint has no response for - see `DeliveryConfig.undeliverable` */
27+
declare let __I18N_UNDELIVERABLE_LOCALES__: string[]
2828
declare let __I18N_STRIP_UNUSED__: boolean
2929
declare let __I18N_PRELOAD__: boolean
3030
/** Project has pages and strategy is not `no_prefix` */

src/gen.ts

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ type LocaleLoaderData = {
3131
cache: boolean
3232
}
3333

34+
/** A loader that resolves to no messages, for the graph a locale file is kept out of */
35+
export const STUB_LOADER = '() => Promise.resolve({})'
36+
3437
export function generateLoaderOptions(
3538
ctx: Pick<ResolvedI18nContext, 'options' | 'vueI18nConfigPaths' | 'localeInfo' | 'normalizedLocales' | 'runtimeDir'>,
3639
) {
@@ -48,21 +51,25 @@ export function generateLoaderOptions(
4851
const key = genString(identifier)
4952
const virtualId = asI18nVirtual(meta.hash)
5053

51-
// resources with an `assetKey` ship as nitro server assets, read lazily instead of
52-
// imported eagerly - the message data stays out of the server bundle (see `setupNitro`)
53-
if (meta.assetKey) {
54-
importStatements.add(genImport(resolve(ctx.runtimeDir, 'server/utils/assets'), [{ name: 'readI18nAsset' }]))
55-
} else {
56-
importStatements.add(genImport(virtualId, identifier))
54+
// a file reaching for the Nuxt app has nothing to run in nitro, and dragging app-only
55+
// modules into that bundle is what breaks its build (#3940)
56+
if (!meta.appContext) {
57+
// resources with an `assetKey` ship as nitro server assets, read lazily instead of
58+
// imported eagerly - the message data stays out of the server bundle (see `setupNitro`)
59+
importStatements.add(meta.assetKey
60+
? genImport(resolve(ctx.runtimeDir, 'server/utils/assets'), [{ name: 'readI18nAsset' }])
61+
: genImport(virtualId, identifier))
5762
}
5863
importMapper.set(meta.path, {
5964
key,
6065
virtualId,
6166
cache: meta.cache ?? true,
6267
load: genDynamicImport(virtualId, { comment: `webpackChunkName: ${key}` }),
63-
loadServer: meta.assetKey
64-
? `() => readI18nAsset(${genString(meta.assetKey)})`
65-
: `() => Promise.resolve(${identifier})`,
68+
loadServer: meta.appContext
69+
? STUB_LOADER
70+
: meta.assetKey
71+
? `() => readI18nAsset(${genString(meta.assetKey)})`
72+
: `() => Promise.resolve(${identifier})`,
6673
})
6774
}
6875
localeLoaders[locale.code]!.push(importMapper.get(meta.path)!)

src/runtime/context.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ type MessageStore = Pick<Composer, 'getLocaleMessage' | 'setLocaleMessage' | 'me
9696

9797
export const isPrerenderable = createPrerenderablePredicate({
9898
dynamic: __I18N_DYNAMIC_LOCALES__,
99-
unserializable: __I18N_UNSERIALIZABLE_LOCALES__,
99+
undeliverable: __I18N_UNDELIVERABLE_LOCALES__,
100100
})
101101

102102
/**
@@ -175,7 +175,7 @@ export function createNuxtI18nContext(nuxt: NuxtApp, vueI18n: I18n, defaultLocal
175175
ssg: __IS_SSG__,
176176
prerender: !!import.meta.prerender,
177177
dynamic: __I18N_DYNAMIC_LOCALES__,
178-
unserializable: __I18N_UNSERIALIZABLE_LOCALES__,
178+
undeliverable: __I18N_UNDELIVERABLE_LOCALES__,
179179
})
180180

181181
// only prerendered responses reach the CDN - a dynamic locale in a hybrid build still fetches the

src/runtime/plugins/preload.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export default defineNuxtPlugin({
3232
for (const k in msg) {
3333
// the client loads these from their own chunk and discards the payload copy, which
3434
// `devalue` could not carry anyway - one message function fails the whole payload
35-
serverI18n.messages[k] = __I18N_UNSERIALIZABLE_LOCALES__.includes(k) ? {} : msg[k]!
35+
serverI18n.messages[k] = __I18N_UNDELIVERABLE_LOCALES__.includes(k) ? {} : msg[k]!
3636
}
3737
}
3838
}

0 commit comments

Comments
 (0)