Repository: intlify/vue-i18n (TypeScript)

Issue:
Incorrect handling of empty string in pluralization for n = 0
### Reporting a bug?

In the latest update of vue-i18n (checked on `v10.0.1`), there seems to be an issue with pluralization when handling an empty string for the `n = 0` case. The expected behavior is for the translation function to return an empty string when n is 0, but instead, it returns undefined.

### Expected behavior

case `n = 0` should return an `<empty string>` and not `'undefined'`. 

### Reproduction

Please follow the link for the minimal reproduction:
https://stackblitz.com/edit/vitejs-vite-9tydzp?file=src%2FApp.vue

### System Info

```shell
System:
    OS: macOS 13.6.7
    CPU: (8) arm64 Apple M2
    Memory: 124.00 MB / 16.00 GB
    Shell: 5.9 - /bin/zsh
Binaries:
    Node: 20.14.0 - ~/.nvm/versions/node/v20.14.0/bin/node
    npm: 10.7.0 - ~/.nvm/versions/node/v20.14.0/bin/npm
Browsers:
    Chrome: 129.0.6668.59
```


### Screenshot

_No response_

### Additional context

_No response_

### Validations

- [X] Read the [Contributing Guidelines](https://github.com/intlify/vue-i18n/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/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/discussions)


Discussion:

Patch:
diff --git a/packages/core-base/src/format.ts b/packages/core-base/src/format.ts
index 75dcf3751..947ceff7f 100644
--- a/packages/core-base/src/format.ts
+++ b/packages/core-base/src/format.ts
@@ -56,7 +56,7 @@ function formatMessageParts<Message = string>(
   node: MessageNode
 ): MessageFunctionReturn<Message> {
   const _static = node.s || node.static
-  if (_static) {
+  if (_static != null) {
     return ctx.type === 'text'
       ? (_static as MessageFunctionReturn<Message>)
       : ctx.normalize([_static] as MessageType<Message>[])


Test patch:
diff --git a/packages/core-base/test/format.test.ts b/packages/core-base/test/format.test.ts
index 193e87905..aa6794262 100644
--- a/packages/core-base/test/format.test.ts
+++ b/packages/core-base/test/format.test.ts
@@ -130,3 +130,16 @@ describe('features', () => {
     })
   })
 })
+
+describe('edge cases', () => {
+  test('empty string in interpolation', () => {
+    const { ast } = compile(`{''} | {n} test | {n} tests`, {
+      jit: true
+    })
+    const msg = format(ast)
+    const ctx = context({
+      pluralIndex: 0
+    })
+    expect(msg(ctx)).toBe('')
+  })
+})
diff --git a/packages/message-compiler/test/__snapshots__/compiler.test.ts.snap b/packages/message-compiler/test/__snapshots__/compiler.test.ts.snap
index 76abaa3a6..8ffed48ee 100644
--- a/packages/message-compiler/test/__snapshots__/compiler.test.ts.snap
+++ b/packages/message-compiler/test/__snapshots__/compiler.test.ts.snap
@@ -381,6 +381,15 @@ exports[`edge cases > {_field} with the same value already exists. > code 1`] =
 }"
 `;
 
+exports[`edge cases > empty literal string in interpolation > code 1`] = `
+"function __msg__ (ctx) {
+  const { normalize: _normalize } = ctx
+  return _normalize([
+    ""
+  ])
+}"
+`;
+
 exports[`edge cases > hi %s ! > code 1`] = `
 "function __msg__ (ctx) {
   const { normalize: _normalize } = ctx
diff --git a/packages/message-compiler/test/__snapshots__/optimizer.test.ts.snap b/packages/message-compiler/test/__snapshots__/optimizer.test.ts.snap
index c408b2ffb..215dfb033 100644
--- a/packages/message-compiler/test/__snapshots__/optimizer.test.ts.snap
+++ b/packages/message-compiler/test/__snapshots__/optimizer.test.ts.snap
@@ -1,5 +1,20 @@
 // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
 
+exports[`empty literal string in interpolation 1`] = `
+{
+  "body": {
+    "items": [
+      {
+        "type": 9,
+      },
+    ],
+    "static": "",
+    "type": 2,
+  },
+  "type": 0,
+}
+`;
+
 exports[`full text items: foo{'@'}domain.com 1`] = `
 {
   "body": {
diff --git a/packages/message-compiler/test/compiler.test.ts b/packages/message-compiler/test/compiler.test.ts
index aaa26ff62..e9e50257f 100644
--- a/packages/message-compiler/test/compiler.test.ts
+++ b/packages/message-compiler/test/compiler.test.ts
@@ -151,4 +151,9 @@ describe('edge cases', () => {
     const { code } = compile(`%{nickname} %{action} issue %{code}`)
     expect(code).toMatchSnapshot('code')
   })
+
+  test('empty literal string in interpolation', () => {
+    const { code } = compile(`{''}`)
+    expect(code).toMatchSnapshot('code')
+  })
 })
diff --git a/packages/message-compiler/test/optimizer.test.ts b/packages/message-compiler/test/optimizer.test.ts
index 5349f5196..11293a3df 100644
--- a/packages/message-compiler/test/optimizer.test.ts
+++ b/packages/message-compiler/test/optimizer.test.ts
@@ -62,3 +62,12 @@ test(`incldue dynamic node in pluarl: no apples | {0} apple | {n} apples`, () =>
     .filter(Boolean)
   expect(messages).toEqual(['no apples'])
 })
+
+test('empty literal string in interpolation', () => {
+  const parser = createParser({ location: false })
+  const msg = `{''}`
+  const ast = optimize(parser.parse(msg))
+
+  expect(ast).toMatchSnapshot()
+  expect((ast.body as MessageNode).static).toBe('')
+})
diff --git a/packages/message-compiler/test/parser/__snapshots__/literal.test.ts.snap b/packages/message-compiler/test/parser/__snapshots__/literal.test.ts.snap
index 7ef345aea..06547024d 100644
--- a/packages/message-compiler/test/parser/__snapshots__/literal.test.ts.snap
+++ b/packages/message-compiler/test/parser/__snapshots__/literal.test.ts.snap
@@ -188,6 +188,64 @@ exports[`emoji > hi, {'😺'} ! 1`] = `
 }
 `;
 
+exports[`empty string 1`] = `
+{
+  "body": {
+    "end": 4,
+    "items": [
+      {
+        "end": 4,
+        "loc": {
+          "end": {
+            "column": 5,
+            "line": 1,
+            "offset": 4,
+          },
+          "start": {
+            "column": 1,
+            "line": 1,
+            "offset": 0,
+          },
+        },
+        "start": 0,
+        "type": 9,
+        "value": "",
+      },
+    ],
+    "loc": {
+      "end": {
+        "column": 5,
+        "line": 1,
+        "offset": 4,
+      },
+      "start": {
+        "column": 1,
+        "line": 1,
+        "offset": 0,
+      },
+    },
+    "start": 0,
+    "type": 2,
+  },
+  "end": 4,
+  "loc": {
+    "end": {
+      "column": 5,
+      "line": 1,
+      "offset": 4,
+    },
+    "source": "{''}",
+    "start": {
+      "column": 1,
+      "line": 1,
+      "offset": 0,
+    },
+  },
+  "start": 0,
+  "type": 0,
+}
+`;
+
 exports[`errors > include new line: hi { 'foo\\n' } 1`] = `
 {
   "body": {
diff --git a/packages/message-compiler/test/parser/literal.test.ts b/packages/message-compiler/test/parser/literal.test.ts
index 1f7c5405a..6f358b337 100644
--- a/packages/message-compiler/test/parser/literal.test.ts
+++ b/packages/message-compiler/test/parser/literal.test.ts
@@ -152,6 +152,25 @@ describe('unicode', () => {
   })
 })
 
+test('empty string', () => {
+  const text = `{''}`
+  const parser = createParser({ onError: spy })
+  const ast = parser.parse(text)
+
+  expect(ast).toMatchSnapshot()
+  expect(spy).not.toHaveBeenCalled()
+  expect(ast.type).toEqual(NodeTypes.Resource)
+  expect(ast.body.type).toEqual(NodeTypes.Message)
+  const message = ast.body as MessageNode
+  expect(message.items).toHaveLength(1)
+  expect(message.items).toMatchObject([
+    {
+      type: NodeTypes.Literal,
+      value: ''
+    }
+  ])
+})
+
 describe('intlify message syntax special characters', () => {
   const items = ['{', '}', '@', '|', '%']
   for (const ch of items) {
diff --git a/packages/message-compiler/test/tokenizer/literal.test.ts b/packages/message-compiler/test/tokenizer/literal.test.ts
index f49e243f8..ccbb77161 100644
--- a/packages/message-compiler/test/tokenizer/literal.test.ts
+++ b/packages/message-compiler/test/tokenizer/literal.test.ts
@@ -604,6 +604,41 @@ describe('escapes', () => {
       }
     })
   })
+
+  test('empty string', () => {
+    const tokenizer = createTokenizer(`{''}`)
+    expect(tokenizer.nextToken()).toEqual({
+      type: TokenTypes.BraceLeft,
+      value: '{',
+      loc: {
+        start: { line: 1, column: 1, offset: 0 },
+        end: { line: 1, column: 2, offset: 1 }
+      }
+    })
+    expect(tokenizer.nextToken()).toEqual({
+      type: TokenTypes.Literal,
+      value: '',
+      loc: {
+        start: { line: 1, column: 2, offset: 1 },
+        end: { line: 1, column: 4, offset: 3 }
+      }
+    })
+    expect(tokenizer.nextToken()).toEqual({
+      type: TokenTypes.BraceRight,
+      value: '}',
+      loc: {
+        start: { line: 1, column: 4, offset: 3 },
+        end: { line: 1, column: 5, offset: 4 }
+      }
+    })
+    expect(tokenizer.nextToken()).toEqual({
+      type: TokenTypes.EOF,
+      loc: {
+        start: { line: 1, column: 5, offset: 4 },
+        end: { line: 1, column: 5, offset: 4 }
+      }
+    })
+  })
 })
 
 describe('errors', () => {
diff --git a/packages/vue-i18n-core/test/issues.test.ts b/packages/vue-i18n-core/test/issues.test.ts
index 2b3be11e0..552aa9dcb 100644
--- a/packages/vue-i18n-core/test/issues.test.ts
+++ b/packages/vue-i18n-core/test/issues.test.ts
@@ -1405,3 +1405,16 @@ test('#1912', async () => {
 
   expect(el?.innerHTML).include(`No apples found`)
 })
+
+test('#1972', async () => {
+  const i18n = createI18n({
+    legacy: false,
+    locale: 'en',
+    messages: {
+      en: {
+        test: "{''} | {n} test | {n} tests"
+      }
+    }
+  })
+  expect(i18n.global.t('test', 0)).toEqual('')
+})