Skip to content

Commit

Permalink
feat: experimental feature switchLocalePathLinkSSR with `<SwitchLoc…
Browse files Browse the repository at this point in the history
…alePathLink>` component (#2838)

* feat: `switchLocalePath` ssr

* feat: experimental `switchLocalePathLink` SSR component

* refactor: use constant for `SwitchLocalePathLinkSSR` component retrieval

* test: assert `switchLocalePathLinkSSR` functionality

* docs: add documentation for `switchLocalePathLink` component and config

* refactor: locale in wrapper comment to reduce regex matching
  • Loading branch information
BobbieGoede authored Mar 14, 2024
1 parent 3cf8538 commit df92c6a
Show file tree
Hide file tree
Showing 18 changed files with 211 additions and 16 deletions.
5 changes: 4 additions & 1 deletion docs/content/docs/2.guide/8.lang-switcher.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,13 @@ Dealing with dynamic route parameters requires a bit more work because you need
During SSR it matters when and where you set i18n parameters, since there is no reactivity during SSR.

:br :br
Components which have already been rendered do not update by changes introduced by pages and components further down the tree. Instead, these links are updated on the client side, so this should not cause any issues.
Components which have already been rendered do not update by changes introduced by pages and components further down the tree. Instead, these links are updated on the client side during hydration, in most cases this should not cause issues.

:br :br
This is not the case for SEO tags, these are updated correctly during SSR regardless of when and where i18n parameters are set.

:br :br
Check out the experimental [`switchLocalePathLinkSSR`](/docs/options/misc#experimental) feature, which combined with the [`<SwitchLocalePathLink>`](/docs/api/components#switchlocalepathlink) component, correctly renders links during SSR regardless of where and when it is used.
::

An example (replace `slug` with the applicable route parameter):
Expand Down
5 changes: 3 additions & 2 deletions docs/content/docs/3.options/10.misc.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,16 @@ description: Miscellaneous options.
## `experimental`

- type: `object`
- default: `{ localeDetector: '' }`
- default: `{ localeDetector: '', switchLocalePathLinkSSR: false }`

Supported properties:

- `localeDetector` (default: `''`) - Specify the locale detector to be called per request on the server side. You need to specify the filepath where the locale detector is defined.

::callout{type="warning"}
About how to define the locale detector, see the [`defineI18nLocaleDetector` API](/docs/api#definei18nlocaledetector)
::
- `switchLocalePathLinkSSR` (default: `false`) - Changes the way dynamic route parameters are tracked and updated internally, improving language switcher SSR when using the [`SwitchLocalePathLink`](/docs/api/components#switchlocalepathlink) component.


## `customBlocks`

Expand Down
37 changes: 37 additions & 0 deletions docs/content/docs/4.api/2.components.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,40 @@ This component supports all [props documented for `<NuxtLink>`](https://nuxt.com
| Prop | Description |
| -------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `locale` | Optional prop to force localization using passed Locale, it defaults to the current locale. Identical to `locale` argument of `localePath()` |


## `<SwitchLocalePathLink>`

This component acts as a constrained [`<NuxtLink>`](https://nuxt.com/docs/api/components/nuxt-link#nuxtlink) which internally uses `switchLocalePath` to link to the same page in the provided locale.

With [`experimental.switchLocalePathLinkSSR`](/docs/options/misc#experimental) enabled, this component will correctly render dynamic route parameters during server-side rendering.

### Examples

#### Basic usage

```vue
<template>
<SwitchLocalePathLink locale="nl">Dutch</SwitchLocalePathLink>
<SwitchLocalePathLink locale="en">English</SwitchLocalePathLink>
</template>
<!-- equivalent to -->
<script setup>
const switchLocalePath = useSwitchLocalePath()
</script>
<template>
<NuxtLink :to="switchLocalePath('nl')">Dutch</NuxtLink>
<NuxtLink :to="switchLocalePath('en')">English</NuxtLink>
</template>
```

### Props

This component supports most, but not all [props documented for `<NuxtLink>`](https://nuxt.com/docs/api/components/nuxt-link#props) (does not support `to` or `href`) in addition to props described below.

| Prop | Description |
| -------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `locale` | Optional prop to force localization using passed Locale, it defaults to the current locale. Identical to `locale` argument of `switchLocalePath()` |
3 changes: 1 addition & 2 deletions playground/layouts/default.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
<script setup lang="ts">
import { useSetI18nParams, useLocaleHead } from '#i18n'
import { useHead } from '#imports'
import { useLocaleHead } from '#i18n'
const route = useRoute()
const { t } = useI18n()
Expand Down
3 changes: 2 additions & 1 deletion playground/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,8 @@ export default defineNuxtConfig({
// debug: true,
i18n: {
experimental: {
localeDetector: './localeDetector.ts'
localeDetector: './localeDetector.ts',
switchLocalePathLinkSSR: true
},
compilation: {
// jit: false,
Expand Down
3 changes: 1 addition & 2 deletions playground/pages/products.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
<script lang="ts" setup>
const { locale, locales } = useI18n()
const localePath = useLocalePath()
const switchLocalePath = useSwitchLocalePath()
const { data } = await useAsyncData('products', () => $fetch(`/api/products`))
definePageMeta({
Expand All @@ -21,7 +20,7 @@ definePageMeta({
<div>
<nav style="padding: 1em">
<span v-for="locale in locales" :key="locale.code">
<NuxtLink :to="switchLocalePath(locale.code) || ''">{{ locale.name }}</NuxtLink> |
<SwitchLocalePathLink :locale="locale.code">{{ locale.name }}</SwitchLocalePathLink> |
</span>
</nav>
<NuxtLink
Expand Down
58 changes: 58 additions & 0 deletions specs/experimental/switch_locale_path_link_ssr.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { test, expect } from 'vitest'
import { fileURLToPath } from 'node:url'
import { $fetch, setup } from '../utils'
import { getDom, gotoPath, renderPage, waitForURL } from '../helper'

await setup({
rootDir: fileURLToPath(new URL(`../fixtures/basic_usage`, import.meta.url)),
browser: true,
// prerender: true,
// overrides
nuxtConfig: {
runtimeConfig: {
public: {
i18n: {
baseUrl: ''
}
}
},
i18n: {
experimental: {
switchLocalePathLinkSSR: true
}
}
}
})

describe('experimental.switchLocalePathLinkSSR', async () => {
test('dynamic parameters render and update reactively client-side', async () => {
const { page } = await renderPage('/products/big-chair')

expect(await page.locator('#switch-locale-path-link-nl').getAttribute('href')).toEqual('/nl/products/grote-stoel')

await gotoPath(page, '/nl/products/rode-mok')
expect(await page.locator('#switch-locale-path-link-en').getAttribute('href')).toEqual('/products/red-mug')

// Translated params are not lost on query changes
await page.locator('#params-add-query').click()
await waitForURL(page, '/nl/products/rode-mok?test=123')
expect(await page.locator('#switch-locale-path-link-en').getAttribute('href')).toEqual('/products/red-mug?test=123')

await page.locator('#params-remove-query').click()
await waitForURL(page, '/nl/products/rode-mok')
expect(await page.locator('#switch-locale-path-link-en').getAttribute('href')).toEqual('/products/red-mug')
})

test('dynamic parameters rendered correctly during SSR', async () => {
// head tags - alt links are updated server side
const product1Html = await $fetch('/products/big-chair')
const product1Dom = getDom(product1Html)
expect(product1Dom.querySelector('#i18n-alt-nl').href).toEqual('/nl/products/grote-stoel')
expect(product1Dom.querySelector('#switch-locale-path-link-nl').href).toEqual('/nl/products/grote-stoel')

const product2Html = await $fetch('/nl/products/rode-mok')
const product2dom = getDom(product2Html)
expect(product2dom.querySelector('#i18n-alt-en').href).toEqual('/products/red-mug')
expect(product2dom.querySelector('#switch-locale-path-link-en').href).toEqual('/products/red-mug')
})
})
12 changes: 12 additions & 0 deletions specs/fixtures/basic_usage/components/LangSwitcher.vue
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,18 @@ const localesExcludingCurrent = computed(() => {
>{{ locale.name }}</NuxtLink
>
</section>
<section id="lang-switcher-with-switch-locale-path-link">
<strong>Using <code>SwitchLocalePathLink</code></strong
>:
<SwitchLocalePathLink
v-for="(locale, index) in localesExcludingCurrent"
:id="`switch-locale-path-link-${locale.code}`"
:key="index"
:exact="true"
:locale="locale.code"
>{{ locale.name }}</SwitchLocalePathLink
>
</section>
<section id="lang-switcher-with-set-locale">
<strong>Using <code>setLocale()</code></strong
>:
Expand Down
4 changes: 3 additions & 1 deletion src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,12 @@ export const STRATEGIES = {

export const DEFAULT_DYNAMIC_PARAMS_KEY = 'nuxtI18n'
export const DEFAULT_COOKIE_KEY = 'i18n_redirected'
export const SWITCH_LOCALE_PATH_LINK_IDENTIFIER = 'nuxt-i18n-slp'

export const DEFAULT_OPTIONS = {
experimental: {
localeDetector: ''
localeDetector: '',
switchLocalePathLinkSSR: false
},
bundle: {
compositionOnly: true,
Expand Down
14 changes: 13 additions & 1 deletion src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,8 @@ export default defineNuxtModule<NuxtI18nOptions>({
},
{} as Record<string, { domain: string | undefined }>
),
detectBrowserLanguage: options.detectBrowserLanguage ?? DEFAULT_OPTIONS.detectBrowserLanguage
detectBrowserLanguage: options.detectBrowserLanguage ?? DEFAULT_OPTIONS.detectBrowserLanguage,
experimental: options.experimental
// TODO: we should support more i18n module options. welcome PRs :-)
})

Expand Down Expand Up @@ -308,6 +309,11 @@ export default defineNuxtModule<NuxtI18nOptions>({
filePath: resolve(runtimeDir, 'components/NuxtLinkLocale')
})

await addComponent({
name: 'SwitchLocalePathLink',
filePath: resolve(runtimeDir, 'components/SwitchLocalePathLink')
})

await addImports([
{ name: 'useI18n', from: vueI18nPath },
...[
Expand Down Expand Up @@ -356,6 +362,12 @@ export interface ModulePublicRuntimeConfig {
baseUrl: NuxtI18nOptions['baseUrl']
rootRedirect: NuxtI18nOptions['rootRedirect']

/**
* Overwritten at build time, used to pass generated options to runtime
*
* @internal
*/
experimental: NonNullable<NuxtI18nOptions['experimental']>
/**
* Overwritten at build time, used to pass generated options to runtime
*
Expand Down
1 change: 1 addition & 0 deletions src/options.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,6 @@ export const parallelPlugin: boolean
export const NUXT_I18N_MODULE_ID = ''
export const DEFAULT_DYNAMIC_PARAMS_KEY: string
export const DEFAULT_COOKIE_KEY: string
export const SWITCH_LOCALE_PATH_LINK_IDENTIFIER: string

export { NuxtI18nOptions, DetectBrowserLanguageOptions, RootRedirectOptions } from './types'
28 changes: 28 additions & 0 deletions src/runtime/components/SwitchLocalePathLink.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { SWITCH_LOCALE_PATH_LINK_IDENTIFIER } from '#build/i18n.options.mjs'
import { useSwitchLocalePath } from '#i18n'
import { defineNuxtLink } from '#imports'
import { Comment, defineComponent, h } from 'vue'

import type { PropType } from 'vue'

const NuxtLink = defineNuxtLink({ componentName: 'NuxtLink' })

export default defineComponent({
name: 'SwitchLocalePathLink',
props: {
locale: {
type: String as PropType<string>,
required: true
}
},
inheritAttrs: false,
setup(props, { slots, attrs }) {
const switchLocalePath = useSwitchLocalePath()

return () => [
h(Comment, `${SWITCH_LOCALE_PATH_LINK_IDENTIFIER}-[${props.locale}]`),
h(NuxtLink, { ...attrs, to: switchLocalePath(props.locale) }, slots.default),
h(Comment, `/${SWITCH_LOCALE_PATH_LINK_IDENTIFIER}`)
]
}
})
6 changes: 4 additions & 2 deletions src/runtime/composables/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,14 @@ export function useSetI18nParams(seoAttributes?: SeoAttributesOptions): SetI18nP
const locale = getLocale(i18n)
const locales = getNormalizedLocales(getLocales(i18n))
const _i18nParams = ref({})
const experimentalSSR = common.runtimeConfig.public.i18n.experimental.switchLocalePathLinkSSR

const i18nParams = computed({
get() {
return router.currentRoute.value.meta.nuxtI18n ?? {}
return experimentalSSR ? common.metaState.value : router.currentRoute.value.meta.nuxtI18n ?? {}
},
set(val) {
common.metaState.value = val
_i18nParams.value = val
router.currentRoute.value.meta.nuxtI18n = val
}
Expand All @@ -61,7 +63,7 @@ export function useSetI18nParams(seoAttributes?: SeoAttributesOptions): SetI18nP
const stop = watch(
() => router.currentRoute.value.fullPath,
() => {
router.currentRoute.value.meta.nuxtI18n = _i18nParams.value
router.currentRoute.value.meta.nuxtI18n = experimentalSSR ? common.metaState.value : _i18nParams.value
}
)

Expand Down
27 changes: 26 additions & 1 deletion src/runtime/plugins/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ import {
isSSG,
localeLoaders,
parallelPlugin,
normalizedLocales
normalizedLocales,
SWITCH_LOCALE_PATH_LINK_IDENTIFIER
} from '#build/i18n.options.mjs'
import { loadVueI18nOptions, loadInitialMessages, loadLocale } from '../messages'
import { useSwitchLocalePath } from '../composables'
import {
loadAndSetLocale,
detectLocale,
Expand Down Expand Up @@ -391,6 +393,29 @@ export default defineNuxtPlugin({
// inject for nuxt helpers
injectNuxtHelpers(nuxtContext, i18n)

// Replace `SwitchLocalePathLink` href in rendered html for SSR support
if (runtimeI18n.experimental.switchLocalePathLinkSSR === true) {
const switchLocalePath = useSwitchLocalePath()

const switchLocalePathLinkWrapperExpr = new RegExp(
[
`<!--${SWITCH_LOCALE_PATH_LINK_IDENTIFIER}-\\[(\\w+)\\]-->`,
`.+?`,
`<!--\/${SWITCH_LOCALE_PATH_LINK_IDENTIFIER}-->`
].join(''),
'g'
)

nuxt.hook('app:rendered', ctx => {
if (ctx.renderResult?.html == null) return

ctx.renderResult.html = ctx.renderResult.html.replaceAll(
switchLocalePathLinkWrapperExpr,
(match: string, p1: string) => match.replace(/href="([^"]+)"/, `href="${switchLocalePath(p1 ?? '')}"`)
)
})
}

let routeChangeCount = 0

addRouteMiddleware(
Expand Down
7 changes: 6 additions & 1 deletion src/runtime/routing/compatibles/routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,8 +218,13 @@ export function resolveRoute(common: CommonComposableOptions, route: RouteLocati
export const DefaultSwitchLocalePathIntercepter: SwitchLocalePathIntercepter = (path: string) => path

function getLocalizableMetaFromDynamicParams(
common: CommonComposableOptions,
route: RouteLocationNormalizedLoaded
): Record<Locale, Record<string, unknown>> {
if (common.runtimeConfig.public.i18n.experimental.switchLocalePathLinkSSR) {
return unref(common.metaState.value) as Record<Locale, any>
}

const meta = route.meta || {}
return (unref(meta)?.[DEFAULT_DYNAMIC_PARAMS_KEY] || {}) as Record<Locale, any>
}
Expand Down Expand Up @@ -247,7 +252,7 @@ export function switchLocalePath(

const switchLocalePathIntercepter = extendSwitchLocalePathIntercepter(common.runtimeConfig)
const routeCopy = routeToObject(route)
const resolvedParams = getLocalizableMetaFromDynamicParams(route)[locale]
const resolvedParams = getLocalizableMetaFromDynamicParams(common, route)[locale]

const baseRoute = { ...routeCopy, name, params: { ...routeCopy.params, ...resolvedParams } }
const path = localePath(common, baseRoute, locale)
Expand Down
5 changes: 4 additions & 1 deletion src/runtime/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { getLocale, setLocale, getLocaleCodes, getI18nTarget } from './routing/u

import type { I18n, Locale, FallbackLocale, Composer, VueI18n } from 'vue-i18n'
import type { NuxtApp } from '#app'
import type { Ref } from '#imports'
import type { Router } from '#vue-router'
import type { DetectLocaleContext } from './internal'
import type { HeadSafe } from '@unhead/vue'
Expand Down Expand Up @@ -90,12 +91,14 @@ export type CommonComposableOptions = {
router: Router
i18n: I18n
runtimeConfig: RuntimeConfig
metaState: Ref<Record<Locale, any>>
}
export function initCommonComposableOptions(i18n?: I18n): CommonComposableOptions {
return {
i18n: i18n ?? useNuxtApp().$i18n,
router: useRouter(),
runtimeConfig: useRuntimeConfig()
runtimeConfig: useRuntimeConfig(),
metaState: useState<Record<Locale, any>>('nuxt-i18n-meta', () => ({}))
}
}

Expand Down
Loading

0 comments on commit df92c6a

Please sign in to comment.