Skip to content
Merged
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
275 changes: 275 additions & 0 deletions __tests__/vue/component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -401,4 +401,279 @@ describe('component', () => {
// Cleanup
warn.mockRestore()
})

it('uses correct translation context when inside slot (#980)', () => {
// Arrange
// OtherComponent.vue - component that receives slot
const otherComponent = {
template: '<div class="other"><slot /></div>',
fluent: {
'en-US': new FluentResource(ftl`
test = … but this one is used: { $placeholder }
`),
},
}

// SomeComponent.vue - component that passes slot content
const someComponent = {
components: {
OtherComponent: otherComponent,
},
template: `
<OtherComponent>
<i18n path="test" :args="{ placeholder: 'value' }" data-test="i18n"></i18n>
</OtherComponent>
`,
fluent: {
'en-US': new FluentResource(ftl`
test = This should be used: { $placeholder }
`),
},
}

// Act
const mounted = mountWithFluent(fluent, someComponent)

// Assert
// The translation should come from SomeComponent, not OtherComponent
expect(mounted.html()).toEqual('<div class="other"><span data-test="i18n">This should be used: \u{2068}value\u{2069}</span></div>')
})

it('uses correct context with named slots', () => {
// Arrange
const slotRenderer = {
template: '<div><slot name="header" /></div>',
fluent: {
'en-US': new FluentResource(ftl`
greeting = Wrong translation
`),
},
}

const slotOwner = {
components: {
SlotRenderer: slotRenderer,
},
template: `
<SlotRenderer>
<template #header>
<i18n path="greeting"></i18n>
</template>
</SlotRenderer>
`,
fluent: {
'en-US': new FluentResource(ftl`
greeting = Correct translation
`),
},
}

// Act
const mounted = mountWithFluent(fluent, slotOwner)

// Assert
expect(mounted.html()).toEqual('<div><span>Correct translation</span></div>')
})

it('uses correct context with nested slots', () => {
// Arrange
// This tests a complex scenario:
// InnerSlotRenderer renders a slot inside OuterSlotRenderer
// OuterSlotRenderer forwards its slot to InnerSlotRenderer
// The actual slot content (<i18n>) is provided by slotOwner

const innerSlotRenderer = {
template: '<div class="inner"><slot /></div>',
fluent: {
'en-US': new FluentResource(ftl`
message = Inner component message
`),
},
}

const outerSlotRenderer = {
components: {
InnerSlotRenderer: innerSlotRenderer,
},
// OuterSlotRenderer receives slot content and passes it to InnerSlotRenderer
// The <slot/> here is re-rendered in outer's context
template: `
<div class="outer">
<InnerSlotRenderer>
<slot />
</InnerSlotRenderer>
</div>
`,
fluent: {
'en-US': new FluentResource(ftl`
message = Outer component message
`),
},
}

const slotOwner = {
components: {
OuterSlotRenderer: outerSlotRenderer,
},
template: `
<OuterSlotRenderer>
<i18n path="message"></i18n>
</OuterSlotRenderer>
`,
fluent: {
'en-US': new FluentResource(ftl`
message = Owner component message
`),
},
}

// Act
const mounted = mountWithFluent(fluent, slotOwner)

// Assert
expect(mounted.html()).toContain('<span>Owner component message</span>')
})

it('uses global translations when no parent has fluent in slot', () => {
// Arrange
bundle.addResource(
new FluentResource(ftl`
global-key = Global translation
`),
)

const slotRenderer = {
template: '<div><slot /></div>',
// No fluent
}

const slotOwner = {
components: {
SlotRenderer: slotRenderer,
},
template: `
<SlotRenderer>
<i18n path="global-key"></i18n>
</SlotRenderer>
`,
// No fluent
}

// Act
const mounted = mountWithFluent(fluent, slotOwner)

// Assert
expect(mounted.html()).toEqual('<div><span>Global translation</span></div>')
})

it('works with multiple i18n components in same slot', () => {
// Arrange
const slotRenderer = {
template: '<div><slot /></div>',
fluent: {
'en-US': new FluentResource(ftl`
msg1 = Renderer msg1
msg2 = Renderer msg2
`),
},
}

const slotOwner = {
components: {
SlotRenderer: slotRenderer,
},
template: `
<SlotRenderer>
<i18n path="msg1"></i18n><i18n path="msg2"></i18n>
</SlotRenderer>
`,
fluent: {
'en-US': new FluentResource(ftl`
msg1 = Owner msg1
msg2 = Owner msg2
`),
},
}

// Act
const mounted = mountWithFluent(fluent, slotOwner)

// Assert
expect(mounted.html()).toEqual('<div><span>Owner msg1</span><span>Owner msg2</span></div>')
})

it('uses correct scope when i18n is in a component without fluent that is slotted', () => {
// Arrange
// This verifies that when a component has no fluent translations,
// it falls back to global translations (not parent's translations)
bundle.addResource(
new FluentResource(ftl`
scope-test = Global translation
`),
)

const slotRenderer = {
name: 'SlotRenderer',
template: '<div><slot /></div>',
fluent: {
'en-US': new FluentResource(ftl`
scope-test = Wrong: Renderer
`),
},
}

const wrapper = {
name: 'Wrapper',
template: '<i18n path="scope-test"></i18n>',
// No fluent here - should use global translations
}

const parent = {
name: 'Parent',
components: { SlotRenderer: slotRenderer, Wrapper: wrapper },
template: '<SlotRenderer><Wrapper /></SlotRenderer>',
fluent: {
'en-US': new FluentResource(ftl`
scope-test = Wrong: Parent
`),
},
}

// Act
const mounted = mountWithFluent(fluent, parent)

// Assert
expect(mounted.html()).toEqual('<div><span>Global translation</span></div>')
})

it('handles i18n in slot with dynamic component rendering', () => {
// Arrange
// Ensures $vnode.context works with dynamic :is components
const dynamicSlot = {
name: 'DynamicSlot',
template: '<div><slot /></div>',
fluent: {
'en-US': new FluentResource(ftl`
dynamic-test = Wrong: Dynamic Slot
`),
},
}

const owner = {
name: 'Owner',
components: { DynamicSlot: dynamicSlot },
template: '<DynamicSlot><i18n path="dynamic-test"></i18n></DynamicSlot>',
fluent: {
'en-US': new FluentResource(ftl`
dynamic-test = Correct: Owner
`),
},
}

// Act
const mounted = mountWithFluent(fluent, owner)

// Assert
expect(mounted.html()).toEqual('<div><span>Correct: Owner</span></div>')
})
})
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@
"peerDependencies": {
"@fluent/bundle": ">=0.17.0",
"@vue/composition-api": ">=1.0.0-rc.1",
"vue": "^2.6.11 || >=3.0.0"
"vue": "^2.6.11 || >=3.2.45"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
Expand Down
3 changes: 1 addition & 2 deletions src/getContext.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type { FluentResource } from '@fluent/bundle'
import type { VueComponent } from './types/typesCompat'
import { CachedSyncIterable } from 'cached-iterable'
import { computed } from 'vue-demi'

Expand All @@ -19,7 +18,7 @@ export function getContext(
if (instance == null)
return rootContext

const options = (instance as VueComponent).$options
const options = instance.$options ?? instance.type

if (options._fluent != null)
return options._fluent
Expand Down
24 changes: 7 additions & 17 deletions src/vue/component.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,18 @@
import type { ResolvedOptions, SimpleNode } from 'src/types'
import type { VueComponent } from 'src/types/typesCompat'
import type { PropType } from 'vue-demi'

import type { PropType } from 'vue-demi'
import type { TranslationContext } from '../TranslationContext'
import {
computed,
defineComponent,
getCurrentInstance,
h,
isVue2,

} from 'vue-demi'
import { getContext } from '../getContext'
import { camelize } from '../util/camelize'
import { warn } from '../util/warn'

function getParentWithFluent(
instance: VueComponent | null | undefined,
): VueComponent | null | undefined {
const parent = instance?.$parent
const target = parent?.$options

if (target != null && target.fluent == null)
return getParentWithFluent(parent)

return parent
}

// Match the opening angle bracket (<) in HTML tags, and HTML entities like
// &amp;, &#0038;, &#x0026;.
const reMarkup = /<|&#?\w+;/
Expand All @@ -43,8 +29,12 @@ export function createComponent(options: ResolvedOptions, rootContext: Translati
},
setup(props, { slots, attrs }) {
const instance = getCurrentInstance()
const parent = getParentWithFluent(instance?.proxy)
const fluent = getContext(rootContext, parent)

const fluent = getContext(
rootContext,
// @ts-expect-error This is internal Vue feature added in https://github.com/vuejs/core/commit/11214eedd2699e15106c44927f4d1206b111fbd3
instance?.vnode?.ctx /* Vue 3 */ ?? instance?.proxy?.$vnode?.context /* Vue 2 */,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

from vuejs/core@11214ee:

this sounds exactly right! good find 👍🏼

)

const translation = computed(() => {
const fluentParams = Object.assign(
Expand Down
Loading