Repository: intlify/vue-i18n-next (ts)

Issue:
`te` returns invalid value when the given value (or "branch") is an object
### Reporting a bug?



After upgrading to v9.6 we are experiencing that the `te` does not recognize changes anymore. 



### Expected behavior

After `mergeLocaleMessages` is called we expect `te` to return `true`, because the message now exists

### Reproduction

1. Load translations via an API 
2. Merge the message with the `mergeLocaleMessages`
3. The value from `te('common')` should now be `true`

In this example you can see that the "Terug"-translation is loaded, via the external package. 
The first `true` is the `te('common.action.cancel.label')` from the portal, the second false-value `te('common')`.

![image](https://github.com/intlify/vue-i18n-next/assets/14016492/a84223b0-2e84-4748-b023-d73927476363)


Stackblitz: https://stackblitz.com/edit/vitejs-vite-gyzvu8?file=package.json
When downgrading to ~9.5 all values are `true`.


### System Info

```shell
System:
    OS: macOS 14.0
    CPU: (12) x64 Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
    Memory: 217.91 MB / 16.00 GB
    Shell: 5.9 - /bin/zsh
  Binaries:
    Node: 18.15.0 - ~/Library/pnpm/node
    Yarn: 1.22.19 - ~/.yarn/bin/yarn
    npm: 8.19.4 - ~/Documents/frontend/.../node_modules/.bin/npm
    pnpm: 8.15.1 - ~/Library/pnpm/pnpm
  Browsers:
    Chrome: 121.0.6167.160
    Safari: 17.0
  npmPackages:
    @vitejs/plugin-vue: ^5.0.3 => 5.0.3 
    @vue/eslint-config-prettier: ^8.0.0 => 8.0.0 
    @vue/eslint-config-typescript: ^11.0.3 => 11.0.3 
    @vue/test-utils: ^2.4.1 => 2.4.1 
    @vue/tsconfig: ^0.1.3 => 0.1.3 
    vite: ^5.0.12 => 5.0.12 
    vite-plugin-dts: ^3.7.2 => 3.7.2 
    vitest: ^1.2.2 => 1.2.2 
    vue: ^3.4.15 => 3.4.15 
    vue-good-table-next: ^0.2.1 => 0.2.1 
    vue-i18n: ^9.9.1 => 9.9.1 
    vue-router: ^4.2.5 => 4.2.5 
    vue-tsc: ^1.8.27 => 1.8.27 
    vue3-apexcharts: ^1.4.4 => 1.4.4
```


### Screenshot

![Scherm­afbeelding 2024-02-13 om 14 15 05](https://github.com/intlify/vue-i18n-next/assets/14016492/f12f4081-c8b0-47dc-94fb-77fb07c87565)


### Additional context

Because we have a lot of translations, we load them from the API, based on a given `prefix`. For example "load all translations for the employee-prefix"

We use the following setup:

1. A (private) library (installed with `pnpm`) that loads translations based on a `prefix`. e.g. `entity.employee`
2. We use `mergeLocaleMessage` to merge the new translations into the existing translations:

```javascript
const { data } = await axios.get<TranslationsResponse>(`${url}?prefix=${prefix}`)
for (const key in data) {
  const language = key as Language // (for example `nl` or `en` or `es`)
  const translations = data[language]
  mergeLocaleMessage(key, translations)
}
```

The private library aswel as `vue-i18n` is installed in the project.
I discovered that it returns `false` because `te('common')` is not very specific to a translation, but to a branch of translations.

### Validations

- [X] Read the [Contributing Guidelines](https://github.com/intlify/vue-i18n-next/blob/master/.github/CONTRIBUTING.md)
- [X] Read the [Documentation](https://vue-i18n.intlify.dev/)
- [X] Check that there isn't [already an issue](https://github.com/intlify/vue-i18n-next/issues) that reports the same bug to avoid creating a duplicate.
- [X] Check that this is a concrete bug. For Q&A open a [GitHub Discussions](https://github.com/intlify/vue-i18n-next/discussions)

Pull request:
fix: Support for `te` behavior compatibility before v9.6
resolve #1738 
related #1602

Patch:
diff --git a/packages/petite-vue-i18n/src/vue.d.ts b/packages/petite-vue-i18n/src/vue.d.ts
index 13f27950..d700e7a0 100644
--- a/packages/petite-vue-i18n/src/vue.d.ts
+++ b/packages/petite-vue-i18n/src/vue.d.ts
@@ -864,7 +864,7 @@ declare module '@vue/runtime-core' {
      * @param key - A target locale message key
      * @param locale - A locale, optional, override locale that global scope or local scope
      *
-     * @returns If found locale message, `true`, else `false`, Note that `false` is returned even if the value present in the Key is not translatable.
+     * @returns If found locale message, `true`, else `false`, Note that `false` is returned even if the value present in the key is not translatable, yet if `translateExistCompatible` is set to `true`, it will return `true` if the key is available, even if the value is translatable.
      */
     $te<
       Key extends string,
diff --git a/packages/vue-i18n-bridge/src/vue.d.ts b/packages/vue-i18n-bridge/src/vue.d.ts
index 0f118b41..41c28939 100644
--- a/packages/vue-i18n-bridge/src/vue.d.ts
+++ b/packages/vue-i18n-bridge/src/vue.d.ts
@@ -869,7 +869,7 @@ declare module '@vue/runtime-core' {
      * @param key - A target locale message key
      * @param locale - A locale, optional, override locale that global scope or local scope
      *
-     * @returns If found locale message, `true`, else `false`, Note that `false` is returned even if the value present in the Key is not translatable.
+     * @returns If found locale message, `true`, else `false`, Note that `false` is returned even if the value present in the key is not translatable, yet if `translateExistCompatible` is set to `true`, it will return `true` if the key is available, even if the value is translatable.
      */
     $te<
       Key extends string,
diff --git a/packages/vue-i18n-core/src/composer.ts b/packages/vue-i18n-core/src/composer.ts
index c7bf189d..90943254 100644
--- a/packages/vue-i18n-core/src/composer.ts
+++ b/packages/vue-i18n-core/src/composer.ts
@@ -13,7 +13,8 @@ import {
   assign,
   inBrowser,
   deepCopy,
-  hasOwn
+  hasOwn,
+  warnOnce
 } from '@intlify/shared'
 import {
   isTranslateFallbackWarn,
@@ -614,6 +615,24 @@ export interface ComposerOptions<
    * @defaultValue `undefined`
    */
   messageCompiler?: MessageCompiler
+  /**
+   * @remarks
+   * An option to make `te` behavior specification before v9.6
+   *
+   * @VueI18nTip
+   * :new: v9.10+
+   *
+   * @VueI18nWarning
+   * This flag will be removed in v10.
+   *
+   * @VueI18nSee [GitHub Issue](https://github.com/intlify/vue-i18n-next/issues/1738)
+   *
+   * @VueI18nSee [`te`](composition#te-key-locale)
+   *
+   * @defaultValue `false`
+   *
+   */
+  translateExistCompatible?: boolean
 }
 
 /**
@@ -1383,7 +1402,7 @@ export interface Composer<
    * @param key - A target locale message key
    * @param locale - A locale, it will be used over than global scope or local scope
    *
-   * @returns If found locale message, `true`, else `false`, Note that `false` is returned even if the value present in the Key is not translatable.
+   * @returns If found locale message, `true`, else `false`, Note that `false` is returned even if the value present in the key is not translatable, yet if `translateExistCompatible` is set to `true`, it will return `true` if the key is available, even if the value is translatable.
    */
   te<
     Str extends string,
@@ -1860,6 +1879,17 @@ export function createComposer(options: any = {}, VueI18nLegacy?: any): any {
   const flatJson = options.flatJson
   const _ref = inBrowser ? ref : shallowRef
 
+  const translateExistCompatible = !!options.translateExistCompatible
+  if (__DEV__) {
+    if (translateExistCompatible && !__TEST__) {
+      warnOnce(
+        getWarnMessage(
+          I18nWarnCodes.NOTICE_DROP_TRANSLATE_EXIST_COMPATIBLE_FLAG
+        )
+      )
+    }
+  }
+
   let _inheritLocale = isBoolean(options.inheritLocale)
     ? options.inheritLocale
     : true
@@ -2357,11 +2387,11 @@ export function createComposer(options: any = {}, VueI18nLegacy?: any): any {
         const targetLocale = isString(locale) ? locale : _locale.value
         const message = getLocaleMessage(targetLocale)
         const resolved = _context.messageResolver(message, key)
-        return (
-          isMessageAST(resolved) ||
-          isMessageFunction(resolved) ||
-          isString(resolved)
-        )
+        return !translateExistCompatible
+          ? isMessageAST(resolved) ||
+              isMessageFunction(resolved) ||
+              isString(resolved)
+          : resolved != null
       },
       () => [key],
       'translate exists',
diff --git a/packages/vue-i18n-core/src/i18n.ts b/packages/vue-i18n-core/src/i18n.ts
index 7f395efa..5750c228 100644
--- a/packages/vue-i18n-core/src/i18n.ts
+++ b/packages/vue-i18n-core/src/i18n.ts
@@ -234,10 +234,10 @@ export interface I18n<
    * An instance of this property is **global scope***.
    */
   readonly global: Legacy extends true
-    ? VueI18n<Messages, DateTimeFormats, NumberFormats, OptionLocale>
-    : Legacy extends false
-      ? Composer<Messages, DateTimeFormats, NumberFormats, OptionLocale>
-      : unknown
+  ? VueI18n<Messages, DateTimeFormats, NumberFormats, OptionLocale>
+  : Legacy extends false
+  ? Composer<Messages, DateTimeFormats, NumberFormats, OptionLocale>
+  : unknown
   /**
    * The property whether or not the Composition API is available
    *
@@ -529,17 +529,17 @@ export function createI18n(options: any = {}, VueI18nLegacy?: any): any {
   // prettier-ignore
   const __legacyMode = __LITE__
     ? false
-    :  __FEATURE_LEGACY_API__ && isBoolean(options.legacy)
+    : __FEATURE_LEGACY_API__ && isBoolean(options.legacy)
       ? options.legacy
       : __FEATURE_LEGACY_API__
   // prettier-ignore
   const __globalInjection = isBoolean(options.globalInjection)
-      ? options.globalInjection
-      : true
+    ? options.globalInjection
+    : true
   // prettier-ignore
   const __allowComposition = __LITE__
     ? true
-    :  __FEATURE_LEGACY_API__ && __legacyMode
+    : __FEATURE_LEGACY_API__ && __legacyMode
       ? !!options.allowComposition
       : true
   const __instances = new Map<ComponentInternalInstance, VueI18n | Composer>()
@@ -1228,10 +1228,10 @@ function useI18nForLegacy(
   const _locale = ref<Locale>(
     // prettier-ignore
     !isLocalScope || _inheritLocale
-    ? root.locale.value
-    : isString(options.locale)
-      ? options.locale
-      : DEFAULT_LOCALE
+      ? root.locale.value
+      : isString(options.locale)
+        ? options.locale
+        : DEFAULT_LOCALE
   )
 
   const _fallbackLocale = ref<FallbackLocale>(
diff --git a/packages/vue-i18n-core/src/legacy.ts b/packages/vue-i18n-core/src/legacy.ts
index 160d585c..5b92e70b 100644
--- a/packages/vue-i18n-core/src/legacy.ts
+++ b/packages/vue-i18n-core/src/legacy.ts
@@ -1376,6 +1376,8 @@ function convertComposerOptions<
   const datetimeFormats = options.datetimeFormats
   const numberFormats = options.numberFormats
   const flatJson = options.flatJson
+  const translateExistCompatible = (options as unknown as ComposerOptions)
+    .translateExistCompatible
 
   return {
     locale,
@@ -1396,6 +1398,7 @@ function convertComposerOptions<
     escapeParameter,
     messageResolver: options.messageResolver,
     inheritLocale,
+    translateExistCompatible,
     __i18n,
     __root,
     __injectWithOption
diff --git a/packages/vue-i18n-core/src/warnings.ts b/packages/vue-i18n-core/src/warnings.ts
index 3e851202..a9a954ad 100644
--- a/packages/vue-i18n-core/src/warnings.ts
+++ b/packages/vue-i18n-core/src/warnings.ts
@@ -13,7 +13,8 @@ export const I18nWarnCodes = {
   COMPONENT_NAME_LEGACY_COMPATIBLE: inc(), // 14
   NOT_FOUND_PARENT_SCOPE: inc(), // 15
   IGNORE_OBJ_FLATTEN: inc(), // 16
-  NOTICE_DROP_ALLOW_COMPOSITION: inc() // 17
+  NOTICE_DROP_ALLOW_COMPOSITION: inc(), // 17
+  NOTICE_DROP_TRANSLATE_EXIST_COMPATIBLE_FLAG: inc() // 18
 } as const
 
 type I18nWarnCodes = (typeof I18nWarnCodes)[keyof typeof I18nWarnCodes]
@@ -27,7 +28,8 @@ export const warnMessages: { [code: number]: string } = {
   [I18nWarnCodes.COMPONENT_NAME_LEGACY_COMPATIBLE]: `Component name legacy compatible: '{name}' -> 'i18n'`,
   [I18nWarnCodes.NOT_FOUND_PARENT_SCOPE]: `Not found parent scope. use the global scope.`,
   [I18nWarnCodes.IGNORE_OBJ_FLATTEN]: `Ignore object flatten: '{key}' key has an string value`,
-  [I18nWarnCodes.NOTICE_DROP_ALLOW_COMPOSITION]: `'allowComposition' option will be dropped in the next major version. For more information, please see 👉 https://tinyurl.com/2p97mcze`
+  [I18nWarnCodes.NOTICE_DROP_ALLOW_COMPOSITION]: `'allowComposition' option will be dropped in the next major version. For more information, please see 👉 https://tinyurl.com/2p97mcze`,
+  [I18nWarnCodes.NOTICE_DROP_TRANSLATE_EXIST_COMPATIBLE_FLAG]: `'translateExistCompatible' option will be dropped in the next major version.`
 }
 
 export function getWarnMessage(
diff --git a/packages/vue-i18n/src/vue.d.ts b/packages/vue-i18n/src/vue.d.ts
index 0f118b41..41c28939 100644
--- a/packages/vue-i18n/src/vue.d.ts
+++ b/packages/vue-i18n/src/vue.d.ts
@@ -869,7 +869,7 @@ declare module '@vue/runtime-core' {
      * @param key - A target locale message key
      * @param locale - A locale, optional, override locale that global scope or local scope
      *
-     * @returns If found locale message, `true`, else `false`, Note that `false` is returned even if the value present in the Key is not translatable.
+     * @returns If found locale message, `true`, else `false`, Note that `false` is returned even if the value present in the key is not translatable, yet if `translateExistCompatible` is set to `true`, it will return `true` if the key is available, even if the value is translatable.
      */
     $te<
       Key extends string,


Test patch:
diff --git a/packages/vue-i18n-core/test/issues.test.ts b/packages/vue-i18n-core/test/issues.test.ts
index 71d74c29..550e288c 100644
--- a/packages/vue-i18n-core/test/issues.test.ts
+++ b/packages/vue-i18n-core/test/issues.test.ts
@@ -19,7 +19,8 @@ import {
   withDirectives,
   resolveDirective,
   nextTick,
-  getCurrentInstance
+  getCurrentInstance,
+  onMounted
 } from 'vue'
 import {
   setDevToolsHook,
@@ -1229,7 +1230,6 @@ test('issue #1610', async () => {
 })
 
 test('issue #1615', async () => {
-  console.log('----')
   const en = {
     hello: (() => {
       const fn = ctx => {
@@ -1289,3 +1289,72 @@ test('issue #1717', async () => {
     'a.b.c': 'Hello, Vue I18n' // should not be transformed to nested object like in issue
   })
 })
+
+test('issue #1738', async () => {
+  const resources = {
+    en: {
+      messages: {
+        common: {
+          actions: {
+            cancel: 'Cancel'
+          }
+        }
+      }
+    },
+    nl: {
+      messages: {
+        common: {
+          actions: {
+            cancel: 'Cancel'
+          }
+        }
+      }
+    }
+  }
+
+  function loadTranslations(): Promise<typeof resources> {
+    return new Promise(resolve => resolve(resources))
+  }
+
+  function delay(ms: number) {
+    return new Promise(resolve => setTimeout(resolve, ms))
+  }
+
+  const i18n = createI18n({
+    locale: 'nl',
+    legacy: false,
+    translateExistCompatible: true,
+    fallbackLocale: 'en',
+    missingWarn: false,
+    silentFallbackWarn: true
+  })
+
+  const App = defineComponent({
+    setup() {
+      const { mergeLocaleMessage, te } = useI18n()
+      onMounted(() => {
+        setTimeout(async () => {
+          const data = await loadTranslations()
+
+          for (const key in data) {
+            // eslint-disable-next-line @typescript-eslint/no-explicit-any
+            const { messages } = (data as any)[key]
+            mergeLocaleMessage(key, messages)
+          }
+        }, 100)
+      })
+      return { te }
+    },
+    template: `<div>
+  <p id="te1">{{ te('common') }} - expected true</p>
+  <p id="te2">{{ te('common.actions') }} - expected true</p>
+</div>`
+  })
+
+  const wrapper = await mount(App, i18n)
+
+  await delay(110)
+
+  expect(wrapper.find('#te1')?.textContent).toEqual(`true - expected true`)
+  expect(wrapper.find('#te2')?.textContent).toEqual(`true - expected true`)
+})