Skip to content

Locale files are never invalidated on Vite 8 — dev server serves stale translations until restart #599

Description

@doutatsu

Description

On Vite 8, editing a locale file included via the include option never updates the dev server — the app keeps rendering the previous translations until the dev server is fully restarted. No HMR update and no page reload is emitted.

The cause is the virtual-id path added in #565 (fix(unplugin-vue-i18n): bypass builtin:vite-json on Vite 8 via virtual ids), first shipped in 11.2.1.

configResolved decides which strategy to use based on whether Vite's internal JSON plugin is present:

const jsonPlugin = getVitePlugin(config, 'vite:json')
hasViteJsonPlugin = !!jsonPlugin

Vite 8 no longer registers a vite:json plugin at all (Rolldown parses JSON natively), so hasViteJsonPlugin is false and resolveId rewrites every included locale file to a virtual:intlify-i18n-N id.

That virtual module is what the app actually imports, and Vite has no way to map the virtual id back to src/locales/en.json. So when the file changes:

  • /src/locales/en.json?import is invalidated and serves fresh content
  • /@id/virtual:intlify-i18n-0 — the module the app imports — is never invalidated and keeps serving the previously compiled messages

this.addWatchFile(realId) in load registers the file with the watcher (which is why the file change is detected at all), but it does not associate the virtual module with the file in Vite's dev module graph, so nothing invalidates it.

The handleHotUpdate hook in the plugin is also dead code on Vite 8 — the hook was removed in Vite 8, and it only invalidated the INTLIFY_BUNDLE_IMPORT_ID virtual module anyway, not per-file locale virtual modules.

Expected behavior

Editing a locale file included via include should invalidate the compiled messages and trigger a page reload, as it does on Vite 7.

Direct A/B on the same project, only the Vite version changed:

vite 7.3.6 vite 8.1.5
main.js imports /src/locales/en.json?import (real module) /@id/virtual:intlify-i18n-0 (virtual module)
served after editing en.json goodbye (fresh) hello (stale)
dev server log page reload src/locales/en.json (nothing)

Reproduction

Minimal repro — vue@3.5.38, vue-i18n@11.4.2, @intlify/unplugin-vue-i18n@11.2.4, @vitejs/plugin-vue@6.0.8, vite@8.1.5.

vite.config.js:

import path from 'path';
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import VueI18nPlugin from '@intlify/unplugin-vue-i18n/vite';

export default defineConfig({
  plugins: [
    vue(),
    VueI18nPlugin({ include: path.resolve(__dirname, './src/locales/**') }),
  ],
});

src/locales/en.json:

{ "greeting": "hello" }

src/main.js:

import { createApp, h } from 'vue';
import { createI18n } from 'vue-i18n';
import en from './locales/en.json';

const i18n = createI18n({ legacy: false, locale: 'en', messages: { en } });
createApp({ render: () => h('h1', i18n.global.t('greeting')) }).use(i18n).mount('#app');

Steps:

  1. vite --port 5199
  2. curl -s http://localhost:5199/src/main.js | grep 'import en'import en from "/@id/virtual:intlify-i18n-0";
  3. curl -s 'http://localhost:5199/@id/virtual:intlify-i18n-0' | grep -o hellohello
  4. Change en.json to { "greeting": "goodbye" }
  5. curl -s 'http://localhost:5199/@id/virtual:intlify-i18n-0' | grep -o 'hello\|goodbye' → still hello
  6. curl -s 'http://localhost:5199/src/locales/en.json?import' | grep -o 'hello\|goodbye'goodbye

The browser shows hello until the dev server is restarted. Pinning vite@7.3.6 and repeating makes step 5 return goodbye and logs page reload src/locales/en.json.

Issue Package

unplugin-vue-i18n

System Info

OS: macOS 14.3 (darwin 23.3.0)
@intlify/unplugin-vue-i18n: 11.2.4 (also reproduces on 11.2.1 – 11.2.3; 11.1.2 and 11.2.0 are unaffected, they predate the virtual-id path)
vite: 8.1.5
rolldown: 1.1.5
@vitejs/plugin-vue: 6.0.8
vue: 3.5.38
vue-i18n: 11.4.2
node: 24.15

Screenshot

No response

Additional context

As a workaround, a small dev-only plugin that invalidates the intlify virtual modules and forces a reload restores the previous behaviour:

const localesDir = path.resolve(__dirname, './src/locales');

const i18nLocaleHmr = () => ({
  name: 'i18n-locale-hmr',
  apply: 'serve',
  hotUpdate({ file }) {
    if (!file.startsWith(localesDir)) { return; }

    const { moduleGraph, hot } = this.environment;

    [...moduleGraph.idToModuleMap.values()]
      .filter((mod) => mod.id?.includes('virtual:intlify-i18n-'))
      .forEach((mod) => moduleGraph.invalidateModule(mod));

    hot.send({ type: 'full-reload' });

    return [];
  },
});

A fix inside the plugin would presumably want a hotUpdate hook that maps the changed file back through realPathToVirtualId and invalidates the matching virtual module.

Validations

  • Read the Contributing Guidelines.
  • Read the README
  • Check that there isn't already an issue that reports the same bug to avoid creating a duplicate.
  • Check that this is a concrete bug. For Q&A open a GitHub Discussion.

🤖 Commented by Claude

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions