From bf36824b6ad1f403eb0b3540826cb43ae61d1c3e Mon Sep 17 00:00:00 2001 From: Andrei Elkin Date: Sun, 14 Jun 2026 13:42:31 +0200 Subject: [PATCH 01/19] feat(dev): add supportsSsrConfigHmr capability gate --- .../src/utils/ssr-config-hmr.ts | 26 +++++++++++++++++++ .../test/ssr-config-hmr.test.ts | 26 +++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 packages/vuetify-nuxt-module/src/utils/ssr-config-hmr.ts create mode 100644 packages/vuetify-nuxt-module/test/ssr-config-hmr.test.ts diff --git a/packages/vuetify-nuxt-module/src/utils/ssr-config-hmr.ts b/packages/vuetify-nuxt-module/src/utils/ssr-config-hmr.ts new file mode 100644 index 0000000..a970604 --- /dev/null +++ b/packages/vuetify-nuxt-module/src/utils/ssr-config-hmr.ts @@ -0,0 +1,26 @@ +import semver from 'semver' + +/** + * Lowest Nuxt version whose `@nuxt/vite-builder` hot-updates SSR-consumed + * virtual modules in dev: the vite-node `invalidates` set is fed by the file + * watcher + module-graph association, evicting our virtual modules (and their + * SSR importers) from the runner cache without a full restart. Below this, a + * config-file change under SSR cannot reach the SSR runner cache, so we fall + * back to `nuxt.callHook('restart')`. + * + * Validated against Nuxt 4.3.1 by `test/e2e/config-hmr-ssr.spec.ts`. + */ +export const MIN_NUXT_VERSION_FOR_SSR_CONFIG_HMR = '4.3.0' + +/** + * Whether the installed Nuxt can hot-update SSR-consumed virtual config + * modules without a dev-server restart. + */ +export function supportsSsrConfigHmr (nuxtVersion: string): boolean { + // Prefer an exact parse (keeps prerelease semantics: 4.3.0-rc.1 < 4.3.0). + const parsed = semver.parse(nuxtVersion) ?? semver.coerce(nuxtVersion) + if (!parsed) { + return false + } + return semver.gte(parsed.version, MIN_NUXT_VERSION_FOR_SSR_CONFIG_HMR) +} diff --git a/packages/vuetify-nuxt-module/test/ssr-config-hmr.test.ts b/packages/vuetify-nuxt-module/test/ssr-config-hmr.test.ts new file mode 100644 index 0000000..921b520 --- /dev/null +++ b/packages/vuetify-nuxt-module/test/ssr-config-hmr.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { MIN_NUXT_VERSION_FOR_SSR_CONFIG_HMR, supportsSsrConfigHmr } from '../src/utils/ssr-config-hmr' + +describe('supportsSsrConfigHmr', () => { + it('exposes the documented floor', () => { + expect(MIN_NUXT_VERSION_FOR_SSR_CONFIG_HMR).toBe('4.3.0') + }) + + it('accepts the validated Nuxt versions (>= 4.3.0)', () => { + expect(supportsSsrConfigHmr('4.3.1')).toBe(true) + expect(supportsSsrConfigHmr('4.3.0')).toBe(true) + expect(supportsSsrConfigHmr('5.0.0')).toBe(true) + expect(supportsSsrConfigHmr('v4.3.2')).toBe(true) + }) + + it('rejects older Nuxt (restart fallback)', () => { + expect(supportsSsrConfigHmr('4.2.5')).toBe(false) + expect(supportsSsrConfigHmr('3.15.0')).toBe(false) + expect(supportsSsrConfigHmr('4.3.0-rc.1')).toBe(false) + }) + + it('rejects unparseable versions', () => { + expect(supportsSsrConfigHmr('')).toBe(false) + expect(supportsSsrConfigHmr('not-a-version')).toBe(false) + }) +}) From 6a9822b227374f81bfcff8b8e5dc4ca82a4d1f6d Mon Sep 17 00:00:00 2001 From: Andrei Elkin Date: Sun, 14 Jun 2026 13:47:41 +0200 Subject: [PATCH 02/19] test(e2e): prove SSR config edit currently forces a dev-server restart --- .../test/e2e/config-hmr-ssr.spec.ts | 82 +++++++++++++++++++ .../test/fixtures/config-hmr-ssr/app.vue | 13 +++ .../config-hmr-ssr/modules/restart-probe.ts | 17 ++++ .../fixtures/config-hmr-ssr/nuxt.config.ts | 9 ++ .../test/fixtures/config-hmr-ssr/package.json | 5 ++ .../fixtures/config-hmr-ssr/vuetify.config.ts | 14 ++++ 6 files changed, 140 insertions(+) create mode 100644 packages/vuetify-nuxt-module/test/e2e/config-hmr-ssr.spec.ts create mode 100644 packages/vuetify-nuxt-module/test/fixtures/config-hmr-ssr/app.vue create mode 100644 packages/vuetify-nuxt-module/test/fixtures/config-hmr-ssr/modules/restart-probe.ts create mode 100644 packages/vuetify-nuxt-module/test/fixtures/config-hmr-ssr/nuxt.config.ts create mode 100644 packages/vuetify-nuxt-module/test/fixtures/config-hmr-ssr/package.json create mode 100644 packages/vuetify-nuxt-module/test/fixtures/config-hmr-ssr/vuetify.config.ts diff --git a/packages/vuetify-nuxt-module/test/e2e/config-hmr-ssr.spec.ts b/packages/vuetify-nuxt-module/test/e2e/config-hmr-ssr.spec.ts new file mode 100644 index 0000000..d45709a --- /dev/null +++ b/packages/vuetify-nuxt-module/test/e2e/config-hmr-ssr.spec.ts @@ -0,0 +1,82 @@ +import { readFileSync, writeFileSync } from 'node:fs' +import { mkdtemp } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { + $fetch, + createTest, + setTestContext, +} from '@nuxt/test-utils/e2e' +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, +} from 'vitest' + +const rootDir = fileURLToPath(new URL('../fixtures/config-hmr-ssr', import.meta.url)) +const configPath = join(rootDir, 'vuetify.config.ts') + +describe('config-hmr-ssr — dev SSR config hot-reload', () => { + let probeFile = '' + let originalConfig = '' + + const hooks = createTest({ + rootDir, + server: true, + browser: true, + build: true, + dev: true, + env: { + RESTART_PROBE_FILE: '__PLACEHOLDER__', + }, + }) + + beforeAll(async () => { + const dir = await mkdtemp(join(tmpdir(), 'vuetify-hmr-')) + probeFile = join(dir, 'restart-probe') + writeFileSync(probeFile, '') + // Inject the resolved probe path into the spawned server env BEFORE the + // server is spawned by hooks.beforeAll(). + hooks.ctx.options.env = { ...hooks.ctx.options.env, RESTART_PROBE_FILE: probeFile } + originalConfig = readFileSync(configPath, 'utf8') + setTestContext(hooks.ctx) + await hooks.beforeAll() + }, hooks.ctx.options.setupTimeout) + + beforeEach(() => setTestContext(hooks.ctx)) + + afterAll(async () => { + // Always restore the fixture file so the repo/tree stays clean. + if (originalConfig) { + writeFileSync(configPath, originalConfig) + } + setTestContext(hooks.ctx) + await hooks.afterAll() + setTestContext(undefined) + }, hooks.ctx.options.teardownTimeout) + + it('reflects a config edit in SSR HTML without restarting the dev server', async () => { + const before = await $fetch('/') + expect(before).toContain('
#ff0000
') + + const bootsBefore = readFileSync(probeFile, 'utf8').length + expect(bootsBefore).toBeGreaterThanOrEqual(1) + + // Edit the config on disk → green primary. + writeFileSync(configPath, originalConfig.replace('#ff0000', '#00ff00')) + + // Poll the SSR output until the change is reflected (HMR reload window). + await expect.poll( + async () => await $fetch('/'), + { timeout: 20_000, interval: 250 }, + ).toContain('
#00ff00
') + + // The dev server must NOT have restarted: boot count is unchanged. + const bootsAfter = readFileSync(probeFile, 'utf8').length + expect(bootsAfter, 'dev server restarted (boot count increased)').toBe(bootsBefore) + }, 60_000) +}) diff --git a/packages/vuetify-nuxt-module/test/fixtures/config-hmr-ssr/app.vue b/packages/vuetify-nuxt-module/test/fixtures/config-hmr-ssr/app.vue new file mode 100644 index 0000000..a07b95a --- /dev/null +++ b/packages/vuetify-nuxt-module/test/fixtures/config-hmr-ssr/app.vue @@ -0,0 +1,13 @@ + + + diff --git a/packages/vuetify-nuxt-module/test/fixtures/config-hmr-ssr/modules/restart-probe.ts b/packages/vuetify-nuxt-module/test/fixtures/config-hmr-ssr/modules/restart-probe.ts new file mode 100644 index 0000000..7e1e128 --- /dev/null +++ b/packages/vuetify-nuxt-module/test/fixtures/config-hmr-ssr/modules/restart-probe.ts @@ -0,0 +1,17 @@ +import { appendFileSync } from 'node:fs' +import process from 'node:process' +import { defineNuxtModule } from '@nuxt/kit' + +// Appends one byte per module setup. Each Nuxt (re)start re-runs module +// setup, so the file length === number of Nuxt boots in the running dev +// process. The path is provided ONLY to the spawned dev server (via the +// `env` option of createTest), so the in-process test build does not write. +export default defineNuxtModule({ + meta: { name: 'restart-probe' }, + setup () { + const file = process.env.RESTART_PROBE_FILE + if (file) { + appendFileSync(file, 'x') + } + }, +}) diff --git a/packages/vuetify-nuxt-module/test/fixtures/config-hmr-ssr/nuxt.config.ts b/packages/vuetify-nuxt-module/test/fixtures/config-hmr-ssr/nuxt.config.ts new file mode 100644 index 0000000..6e708af --- /dev/null +++ b/packages/vuetify-nuxt-module/test/fixtures/config-hmr-ssr/nuxt.config.ts @@ -0,0 +1,9 @@ +import MyModule from '../../../src/module' + +export default defineNuxtConfig({ + modules: [MyModule, '~/modules/restart-probe'], + ssr: true, + vuetify: { + moduleOptions: { styles: true }, + }, +}) diff --git a/packages/vuetify-nuxt-module/test/fixtures/config-hmr-ssr/package.json b/packages/vuetify-nuxt-module/test/fixtures/config-hmr-ssr/package.json new file mode 100644 index 0000000..e3503d0 --- /dev/null +++ b/packages/vuetify-nuxt-module/test/fixtures/config-hmr-ssr/package.json @@ -0,0 +1,5 @@ +{ + "name": "config-hmr-ssr", + "type": "module", + "private": true +} diff --git a/packages/vuetify-nuxt-module/test/fixtures/config-hmr-ssr/vuetify.config.ts b/packages/vuetify-nuxt-module/test/fixtures/config-hmr-ssr/vuetify.config.ts new file mode 100644 index 0000000..98ff8db --- /dev/null +++ b/packages/vuetify-nuxt-module/test/fixtures/config-hmr-ssr/vuetify.config.ts @@ -0,0 +1,14 @@ +import process from 'node:process' +import { defineVuetifyConfiguration } from '../../../custom-configuration.mjs' + +export default defineVuetifyConfiguration({ + theme: { + defaultTheme: 'light', + themes: { + light: { + dark: false, + colors: { primary: process.env.HMR_PRIMARY ?? '#ff0000' }, + }, + }, + }, +}) From 6c84da22b4fede5139a0a37890ccaf5e381846f1 Mon Sep 17 00:00:00 2001 From: Andrei Elkin Date: Sun, 14 Jun 2026 13:50:42 +0200 Subject: [PATCH 03/19] feat(dev): compute canHmrConfig capability flag on context --- packages/vuetify-nuxt-module/src/module.ts | 2 ++ packages/vuetify-nuxt-module/src/utils/config.ts | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/packages/vuetify-nuxt-module/src/module.ts b/packages/vuetify-nuxt-module/src/module.ts index 91556b8..c46aea3 100644 --- a/packages/vuetify-nuxt-module/src/module.ts +++ b/packages/vuetify-nuxt-module/src/module.ts @@ -25,6 +25,7 @@ import { version } from '../package.json' import { configureNuxt } from './utils/configure-nuxt' import { configureVite } from './utils/configure-vite' import { load, registerWatcher } from './utils/loader' +import { supportsSsrConfigHmr } from './utils/ssr-config-hmr' export * from './types' @@ -134,6 +135,7 @@ export default defineNuxtModule({ moduleOptions: undefined!, vuetifyOptions: undefined!, vuetifyFilesToWatch: [], + canHmrConfig: !nuxt.options.ssr || supportsSsrConfigHmr(getNuxtVersion(nuxt)), isSSR: nuxt.options.ssr, isDev: nuxt.options.dev, isNuxtGenerate: !!nuxt.options.nitro.static, diff --git a/packages/vuetify-nuxt-module/src/utils/config.ts b/packages/vuetify-nuxt-module/src/utils/config.ts index 497a9f7..79e85d0 100644 --- a/packages/vuetify-nuxt-module/src/utils/config.ts +++ b/packages/vuetify-nuxt-module/src/utils/config.ts @@ -15,6 +15,13 @@ export interface VuetifyNuxtContext { moduleOptions: MOptions vuetifyOptions: VOptions vuetifyFilesToWatch: string[] + /** + * Whether config-file changes can be hot-reloaded in dev without a full + * `nuxt.callHook('restart')`. True for SPA always, and for SSR on Nuxt + * versions whose vite-node evicts SSR-consumed virtual modules from its + * runner cache (see {@link supportsSsrConfigHmr}). Set once at module setup. + */ + canHmrConfig: boolean isDev: boolean i18n: boolean isSSR: boolean From bae750f26828be54068b517d6093356d6e5f294f Mon Sep 17 00:00:00 2001 From: Andrei Elkin Date: Sun, 14 Jun 2026 13:51:24 +0200 Subject: [PATCH 04/19] feat(dev): bind config sources to virtual modules via addWatchFile --- .../src/vite/vuetify-configuration-plugin.ts | 8 ++++++++ .../src/vite/vuetify-date-configuration-plugin.ts | 8 ++++++++ .../src/vite/vuetify-icons-configuration-plugin.ts | 8 ++++++++ 3 files changed, 24 insertions(+) diff --git a/packages/vuetify-nuxt-module/src/vite/vuetify-configuration-plugin.ts b/packages/vuetify-nuxt-module/src/vite/vuetify-configuration-plugin.ts index 3231a35..69b32f5 100644 --- a/packages/vuetify-nuxt-module/src/vite/vuetify-configuration-plugin.ts +++ b/packages/vuetify-nuxt-module/src/vite/vuetify-configuration-plugin.ts @@ -23,6 +23,14 @@ export function vuetifyConfigurationPlugin (ctx: VuetifyNuxtContext) { }, async load (id) { if (id === RESOLVED_VIRTUAL_VUETIFY_CONFIGURATION) { + // Bind the resolved config sources to this virtual module so an edit + // invalidates it (and its SSR importers) on both client and SSR graphs + // in dev. Only meaningful when hot-reload is supported; harmless else. + if (ctx.isDev && ctx.canHmrConfig) { + for (const file of ctx.vuetifyFilesToWatch) { + this.addWatchFile(file) + } + } const { directives: _directives, date: _date, diff --git a/packages/vuetify-nuxt-module/src/vite/vuetify-date-configuration-plugin.ts b/packages/vuetify-nuxt-module/src/vite/vuetify-date-configuration-plugin.ts index c349c21..ac86c62 100644 --- a/packages/vuetify-nuxt-module/src/vite/vuetify-date-configuration-plugin.ts +++ b/packages/vuetify-nuxt-module/src/vite/vuetify-date-configuration-plugin.ts @@ -14,6 +14,14 @@ export function vuetifyDateConfigurationPlugin (ctx: VuetifyNuxtContext) { }, async load (id) { if (id === RESOLVED_VIRTUAL_VUETIFY_DATE_CONFIGURATION) { + // Bind the resolved config sources to this virtual module so an edit + // invalidates it (and its SSR importers) on both client and SSR graphs + // in dev. Only meaningful when hot-reload is supported; harmless else. + if (ctx.isDev && ctx.canHmrConfig) { + for (const file of ctx.vuetifyFilesToWatch) { + this.addWatchFile(file) + } + } if (!ctx.dateAdapter) { return ` export const enabled = false diff --git a/packages/vuetify-nuxt-module/src/vite/vuetify-icons-configuration-plugin.ts b/packages/vuetify-nuxt-module/src/vite/vuetify-icons-configuration-plugin.ts index 1d1e7e6..a26a098 100644 --- a/packages/vuetify-nuxt-module/src/vite/vuetify-icons-configuration-plugin.ts +++ b/packages/vuetify-nuxt-module/src/vite/vuetify-icons-configuration-plugin.ts @@ -16,6 +16,14 @@ export function vuetifyIconsPlugin (ctx: VuetifyNuxtContext) { }, async load (id) { if (id === RESOLVED_VIRTUAL_VUETIFY_ICONS_CONFIGURATION) { + // Bind the resolved config sources to this virtual module so an edit + // invalidates it (and its SSR importers) on both client and SSR graphs + // in dev. Only meaningful when hot-reload is supported; harmless else. + if (ctx.isDev && ctx.canHmrConfig) { + for (const file of ctx.vuetifyFilesToWatch) { + this.addWatchFile(file) + } + } const { enabled, unocss, From 1c35d320a4100cdfe218a8c698153a43050236f3 Mon Sep 17 00:00:00 2001 From: Andrei Elkin Date: Sun, 14 Jun 2026 13:52:21 +0200 Subject: [PATCH 05/19] feat(dev): collect config sources for HMR when supported, restart-watch otherwise --- packages/vuetify-nuxt-module/src/utils/layers.ts | 15 +++++++++------ packages/vuetify-nuxt-module/src/utils/loader.ts | 2 +- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/vuetify-nuxt-module/src/utils/layers.ts b/packages/vuetify-nuxt-module/src/utils/layers.ts index c6317aa..6ea6f60 100644 --- a/packages/vuetify-nuxt-module/src/utils/layers.ts +++ b/packages/vuetify-nuxt-module/src/utils/layers.ts @@ -1,5 +1,6 @@ import type { Nuxt } from '@nuxt/schema' import type { FontIconSet, IconFontName, InlineModuleOptions, VuetifyModuleOptions } from '../types' +import type { VuetifyNuxtContext } from './config' import defu from 'defu' import { loadVuetifyConfiguration } from './config' @@ -42,7 +43,7 @@ export function finalizeConfiguration (moduleOptions: InlineModuleOptions[]): In /** * Merges project layer with registered vuetify modules */ -export async function mergeVuetifyModules (options: VuetifyModuleOptions, nuxt: Nuxt) { +export async function mergeVuetifyModules (options: VuetifyModuleOptions, nuxt: Nuxt, ctx: VuetifyNuxtContext) { const moduleOptions: InlineModuleOptions[] = [] const vuetifyConfigurationFilesToWatch = new Set() @@ -76,16 +77,18 @@ export async function mergeVuetifyModules (options: VuetifyModuleOptions, nuxt: options.vuetifyOptions, ) - // handle vuetify configuraton files changes only in dev mode + // handle vuetify configuration files changes only in dev mode if (nuxt.options.dev && resolvedOptions.sources.length > 0) { - // we need to restart nuxt dev server when SSR is enabled: vite-node doesn't support HMR in server yet - if (nuxt.options.ssr) { + if (ctx.canHmrConfig) { + // HMR path: watch via the module graph (addWatchFile) + handleHotUpdate. for (const s of resolvedOptions.sources) { - nuxt.options.watch.push(s.replace(/\\/g, '/')) + vuetifyConfigurationFilesToWatch.add(s.replace(/\\/g, '/')) } } else { + // Legacy fallback (older Nuxt SSR): vite-node can't HMR SSR-consumed + // virtual modules, so watch through Nuxt to trigger a full restart. for (const s of resolvedOptions.sources) { - vuetifyConfigurationFilesToWatch.add(s.replace(/\\/g, '/')) + nuxt.options.watch.push(s.replace(/\\/g, '/')) } } } diff --git a/packages/vuetify-nuxt-module/src/utils/loader.ts b/packages/vuetify-nuxt-module/src/utils/loader.ts index 9928a86..486e736 100644 --- a/packages/vuetify-nuxt-module/src/utils/loader.ts +++ b/packages/vuetify-nuxt-module/src/utils/loader.ts @@ -20,7 +20,7 @@ export async function load ( const { configuration, vuetifyConfigurationFilesToWatch, - } = await mergeVuetifyModules(options, nuxt) + } = await mergeVuetifyModules(options, nuxt, ctx) // we only need to load json files once if (ctx.componentsPromise === undefined) { From 46e75ae9290df754517a08d1cdab63effa2b1bf5 Mon Sep 17 00:00:00 2001 From: Andrei Elkin Date: Sun, 14 Jun 2026 14:26:40 +0200 Subject: [PATCH 06/19] feat(dev): hot-reload SSR config without dev-server restart on supported Nuxt --- .../vuetify-nuxt-module/src/utils/loader.ts | 97 +++++++++++-------- 1 file changed, 58 insertions(+), 39 deletions(-) diff --git a/packages/vuetify-nuxt-module/src/utils/loader.ts b/packages/vuetify-nuxt-module/src/utils/loader.ts index 486e736..2c5d310 100644 --- a/packages/vuetify-nuxt-module/src/utils/loader.ts +++ b/packages/vuetify-nuxt-module/src/utils/loader.ts @@ -120,47 +120,66 @@ export async function load ( } export function registerWatcher (options: VuetifyModuleOptions, nuxt: Nuxt, ctx: VuetifyNuxtContext) { - if (nuxt.options.dev) { - let pageReload: (() => Promise) | undefined + if (!nuxt.options.dev) { + return + } - nuxt.hooks.hook('builder:watch', (_event, path) => { - path = relative(nuxt.options.srcDir, resolve(nuxt.options.srcDir, path)) - if (!pageReload && ctx.vuetifyFilesToWatch.includes(path)) { - return nuxt.callHook('restart') - } - }) + // When SSR config HMR is unsupported (older Nuxt), changes to SSR-consumed + // virtual modules can't be evicted from the vite-node runner cache, so fall + // back to a full dev-server restart. + const needsRestart = !ctx.canHmrConfig - nuxt.hook('vite:serverCreated', (server, { isClient }) => { - if (!server.ws || !isClient) { - return - } + let pageReload: (() => Promise) | undefined - pageReload = debounce(async () => { - const modules: ModuleNode[] = [] - for (const v of RESOLVED_VIRTUAL_MODULES) { - const module = server.moduleGraph.getModuleById(v) - if (module) { - modules.push(module) - } - } - // reload configuration always - await load(options, nuxt, ctx) - // TODO: try to change the logic here with custom event and using the moduleGraph + client invalidation - // server.reloadModule will send at least 2 or 3 full page reloads in a row: it is better than server restart - if (modules.length > 0) { - await Promise.all(modules.map(m => server.reloadModule(m))) - } - }, 50, { trailing: false }) - }) - - addVitePlugin({ - name: 'vuetify:configuration:watch', - enforce: 'pre', - handleHotUpdate ({ file }) { - if (pageReload && ctx.vuetifyFilesToWatch.includes(file)) { - return pageReload() + nuxt.hooks.hook('builder:watch', (_event, path) => { + if (!needsRestart) { + return + } + path = relative(nuxt.options.srcDir, resolve(nuxt.options.srcDir, path)) + if (!pageReload && ctx.vuetifyFilesToWatch.includes(path)) { + return nuxt.callHook('restart') + } + }) + + nuxt.hook('vite:serverCreated', (server, { isClient }) => { + if (!server.ws || !isClient) { + return + } + + pageReload = debounce(async () => { + const modules: ModuleNode[] = [] + for (const v of RESOLVED_VIRTUAL_MODULES) { + const module = server.moduleGraph.getModuleById(v) + if (module) { + modules.push(module) } - }, - }) - } + } + // reload configuration always: refresh ctx before the SSR runner + // re-executes the (invalidated) virtual modules on the next render + await load(options, nuxt, ctx) + // server.reloadModule escalates to a full client reload for our + // non-accepting virtual modules, which re-requests the SSR page. + if (modules.length > 0) { + await Promise.all(modules.map(m => server.reloadModule(m))) + } + }, 50, { trailing: false }) + }) + + addVitePlugin({ + name: 'vuetify:configuration:watch', + enforce: 'pre', + handleHotUpdate ({ file }) { + if (!ctx.vuetifyFilesToWatch.includes(file)) { + return + } + if (needsRestart) { + // restart is driven by the builder:watch hook above; suppress the + // default client HMR for the (stale-until-restart) virtual module. + return [] + } + if (pageReload) { + return pageReload() + } + }, + }) } From 0274da902bb7f3a457ae409aa77b96c91ba7a34b Mon Sep 17 00:00:00 2001 From: Andrei Elkin Date: Sun, 14 Jun 2026 14:36:31 +0200 Subject: [PATCH 07/19] fix(dev): skip nuxt.options mutations on config reload to avoid nitro reload --- packages/vuetify-nuxt-module/src/utils/loader.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/vuetify-nuxt-module/src/utils/loader.ts b/packages/vuetify-nuxt-module/src/utils/loader.ts index 2c5d310..dc7da89 100644 --- a/packages/vuetify-nuxt-module/src/utils/loader.ts +++ b/packages/vuetify-nuxt-module/src/utils/loader.ts @@ -16,6 +16,7 @@ export async function load ( options: VuetifyModuleOptions, nuxt: Nuxt, ctx: VuetifyNuxtContext, + reload = false, ) { const { configuration, @@ -74,9 +75,11 @@ export async function load ( } /* handle old stuff */ - const oldIcons = ctx.icons - if (oldIcons && oldIcons.cdn?.length && nuxt.options.app.head.link) { - nuxt.options.app.head.link = nuxt.options.app.head.link.filter(link => !link.key || !oldIcons.cdn.some(([key]) => link.key === key)) + if (!reload) { + const oldIcons = ctx.icons + if (oldIcons && oldIcons.cdn?.length && nuxt.options.app.head.link) { + nuxt.options.app.head.link = nuxt.options.app.head.link.filter(link => !link.key || !oldIcons.cdn.some(([key]) => link.key === key)) + } } /* handle new stuff */ @@ -98,7 +101,7 @@ export async function load ( ctx.logger.warn('`theme.defaultTheme: "system"` cannot be resolved during SSR/SSG: the server has no access to the OS color-scheme preference, so the first paint defaults to light and may flash on dark systems. Set explicit dark/light themes and enable `moduleOptions.ssrClientHints.prefersColorScheme` (optionally `prefersColorSchemeOptions.useBrowserThemeOnly`). See the SSR guide.') } - if (ctx.icons.enabled) { + if (!reload && ctx.icons.enabled) { if (ctx.icons.local) { for (const css of ctx.icons.local) { nuxt.options.css.push(css) @@ -156,7 +159,7 @@ export function registerWatcher (options: VuetifyModuleOptions, nuxt: Nuxt, ctx: } // reload configuration always: refresh ctx before the SSR runner // re-executes the (invalidated) virtual modules on the next render - await load(options, nuxt, ctx) + await load(options, nuxt, ctx, true) // server.reloadModule escalates to a full client reload for our // non-accepting virtual modules, which re-requests the SSR page. if (modules.length > 0) { From 4c3284b1280ca9a58e5126c1b065bb6124781ba3 Mon Sep 17 00:00:00 2001 From: Andrei Elkin Date: Sun, 14 Jun 2026 14:36:31 +0200 Subject: [PATCH 08/19] test(e2e): harden config-hmr-ssr against reload-window races and pollution --- .../test/e2e/config-hmr-ssr.spec.ts | 55 ++++++++++++++----- 1 file changed, 40 insertions(+), 15 deletions(-) diff --git a/packages/vuetify-nuxt-module/test/e2e/config-hmr-ssr.spec.ts b/packages/vuetify-nuxt-module/test/e2e/config-hmr-ssr.spec.ts index d45709a..8ba2b7d 100644 --- a/packages/vuetify-nuxt-module/test/e2e/config-hmr-ssr.spec.ts +++ b/packages/vuetify-nuxt-module/test/e2e/config-hmr-ssr.spec.ts @@ -20,6 +20,16 @@ import { const rootDir = fileURLToPath(new URL('../fixtures/config-hmr-ssr', import.meta.url)) const configPath = join(rootDir, 'vuetify.config.ts') +// `$fetch` can hang if a request lands inside a reload window. Race each +// request against a timeout so a stalled request rejects and `expect.poll` +// retries it, instead of stalling the whole test until the suite timeout. +async function fetchWithTimeout (path: string, ms = 4000): Promise { + return await Promise.race([ + $fetch(path), + new Promise((_, reject) => setTimeout(() => reject(new Error('fetch timeout')), ms)), + ]) +} + describe('config-hmr-ssr — dev SSR config hot-reload', () => { let probeFile = '' let originalConfig = '' @@ -42,7 +52,14 @@ describe('config-hmr-ssr — dev SSR config hot-reload', () => { // Inject the resolved probe path into the spawned server env BEFORE the // server is spawned by hooks.beforeAll(). hooks.ctx.options.env = { ...hooks.ctx.options.env, RESTART_PROBE_FILE: probeFile } - originalConfig = readFileSync(configPath, 'utf8') + // Defensively normalize the fixture to the baseline color before capturing + // it: a prior timed-out run may have left the file at the edited (#00ff00) + // value, which would poison this run's baseline assertion. + const onDisk = readFileSync(configPath, 'utf8') + originalConfig = onDisk.replace(/#00ff00/g, '#ff0000') + if (originalConfig !== onDisk) { + writeFileSync(configPath, originalConfig) + } setTestContext(hooks.ctx) await hooks.beforeAll() }, hooks.ctx.options.setupTimeout) @@ -60,23 +77,31 @@ describe('config-hmr-ssr — dev SSR config hot-reload', () => { }, hooks.ctx.options.teardownTimeout) it('reflects a config edit in SSR HTML without restarting the dev server', async () => { - const before = await $fetch('/') - expect(before).toContain('
#ff0000
') + try { + const before = await fetchWithTimeout('/') + expect(before).toContain('
#ff0000
') - const bootsBefore = readFileSync(probeFile, 'utf8').length - expect(bootsBefore).toBeGreaterThanOrEqual(1) + const bootsBefore = readFileSync(probeFile, 'utf8').length + expect(bootsBefore).toBeGreaterThanOrEqual(1) - // Edit the config on disk → green primary. - writeFileSync(configPath, originalConfig.replace('#ff0000', '#00ff00')) + // Edit the config on disk → green primary. + writeFileSync(configPath, originalConfig.replace('#ff0000', '#00ff00')) - // Poll the SSR output until the change is reflected (HMR reload window). - await expect.poll( - async () => await $fetch('/'), - { timeout: 20_000, interval: 250 }, - ).toContain('
#00ff00
') + // Poll the SSR output until the change is reflected (HMR reload window). + await expect.poll( + async () => await fetchWithTimeout('/'), + { timeout: 20_000, interval: 250 }, + ).toContain('
#00ff00
') - // The dev server must NOT have restarted: boot count is unchanged. - const bootsAfter = readFileSync(probeFile, 'utf8').length - expect(bootsAfter, 'dev server restarted (boot count increased)').toBe(bootsBefore) + // The dev server must NOT have restarted: boot count is unchanged. + const bootsAfter = readFileSync(probeFile, 'utf8').length + expect(bootsAfter, 'dev server restarted (boot count increased)').toBe(bootsBefore) + } finally { + // Restore the fixture even if an assertion above failed mid-edit, so a + // failed run can't poison the next one (in addition to afterAll). + if (originalConfig) { + writeFileSync(configPath, originalConfig) + } + } }, 60_000) }) From 94c6735b72f909f4de2150fcd630d66ae407a9bb Mon Sep 17 00:00:00 2001 From: Andrei Elkin Date: Sun, 14 Jun 2026 14:47:56 +0200 Subject: [PATCH 09/19] refactor(dev): unify config watch-routing in registerWatcher, drop dead builder:watch restart --- .../vuetify-nuxt-module/src/utils/layers.ts | 16 ++----- .../vuetify-nuxt-module/src/utils/loader.ts | 46 ++++++++----------- 2 files changed, 22 insertions(+), 40 deletions(-) diff --git a/packages/vuetify-nuxt-module/src/utils/layers.ts b/packages/vuetify-nuxt-module/src/utils/layers.ts index 6ea6f60..ce953f6 100644 --- a/packages/vuetify-nuxt-module/src/utils/layers.ts +++ b/packages/vuetify-nuxt-module/src/utils/layers.ts @@ -1,6 +1,5 @@ import type { Nuxt } from '@nuxt/schema' import type { FontIconSet, IconFontName, InlineModuleOptions, VuetifyModuleOptions } from '../types' -import type { VuetifyNuxtContext } from './config' import defu from 'defu' import { loadVuetifyConfiguration } from './config' @@ -43,7 +42,7 @@ export function finalizeConfiguration (moduleOptions: InlineModuleOptions[]): In /** * Merges project layer with registered vuetify modules */ -export async function mergeVuetifyModules (options: VuetifyModuleOptions, nuxt: Nuxt, ctx: VuetifyNuxtContext) { +export async function mergeVuetifyModules (options: VuetifyModuleOptions, nuxt: Nuxt) { const moduleOptions: InlineModuleOptions[] = [] const vuetifyConfigurationFilesToWatch = new Set() @@ -79,17 +78,8 @@ export async function mergeVuetifyModules (options: VuetifyModuleOptions, nuxt: // handle vuetify configuration files changes only in dev mode if (nuxt.options.dev && resolvedOptions.sources.length > 0) { - if (ctx.canHmrConfig) { - // HMR path: watch via the module graph (addWatchFile) + handleHotUpdate. - for (const s of resolvedOptions.sources) { - vuetifyConfigurationFilesToWatch.add(s.replace(/\\/g, '/')) - } - } else { - // Legacy fallback (older Nuxt SSR): vite-node can't HMR SSR-consumed - // virtual modules, so watch through Nuxt to trigger a full restart. - for (const s of resolvedOptions.sources) { - nuxt.options.watch.push(s.replace(/\\/g, '/')) - } + for (const s of resolvedOptions.sources) { + vuetifyConfigurationFilesToWatch.add(s.replace(/\\/g, '/')) } } diff --git a/packages/vuetify-nuxt-module/src/utils/loader.ts b/packages/vuetify-nuxt-module/src/utils/loader.ts index dc7da89..3a90d5d 100644 --- a/packages/vuetify-nuxt-module/src/utils/loader.ts +++ b/packages/vuetify-nuxt-module/src/utils/loader.ts @@ -4,7 +4,6 @@ import type { VOptions, VuetifyModuleOptions } from '../types' import type { VuetifyNuxtContext } from './config' import { addVitePlugin } from '@nuxt/kit' import defu from 'defu' -import { relative, resolve } from 'pathe' import { debounce } from 'perfect-debounce' import { RESOLVED_VIRTUAL_MODULES } from '../vite/constants' import { prepareIcons } from './icons' @@ -21,7 +20,7 @@ export async function load ( const { configuration, vuetifyConfigurationFilesToWatch, - } = await mergeVuetifyModules(options, nuxt, ctx) + } = await mergeVuetifyModules(options, nuxt) // we only need to load json files once if (ctx.componentsPromise === undefined) { @@ -75,6 +74,10 @@ export async function load ( } /* handle old stuff */ + // On a dev config reload we deliberately leave nuxt.options (css + head links) + // untouched: re-mutating them would trigger a full Nitro dev:reload (defeating + // the HMR fast-path) and accumulate duplicate entries. Trade-off: changing the + // icon CDN/local CSS in a config file needs a manual restart to update . if (!reload) { const oldIcons = ctx.icons if (oldIcons && oldIcons.cdn?.length && nuxt.options.app.head.link) { @@ -101,6 +104,7 @@ export async function load ( ctx.logger.warn('`theme.defaultTheme: "system"` cannot be resolved during SSR/SSG: the server has no access to the OS color-scheme preference, so the first paint defaults to light and may flash on dark systems. Set explicit dark/light themes and enable `moduleOptions.ssrClientHints.prefersColorScheme` (optionally `prefersColorSchemeOptions.useBrowserThemeOnly`). See the SSR guide.') } + // see note above — skipped on reload if (!reload && ctx.icons.enabled) { if (ctx.icons.local) { for (const css of ctx.icons.local) { @@ -127,23 +131,19 @@ export function registerWatcher (options: VuetifyModuleOptions, nuxt: Nuxt, ctx: return } - // When SSR config HMR is unsupported (older Nuxt), changes to SSR-consumed - // virtual modules can't be evicted from the vite-node runner cache, so fall - // back to a full dev-server restart. - const needsRestart = !ctx.canHmrConfig + // Legacy fallback: on Nuxt versions whose vite-node can't evict SSR-consumed + // virtual modules from its runner cache (ctx.canHmrConfig === false), hot + // reload isn't possible — register the config files with Nuxt so it restarts + // the dev server natively when any of them changes. + if (!ctx.canHmrConfig) { + for (const file of ctx.vuetifyFilesToWatch) { + nuxt.options.watch.push(file) + } + return + } let pageReload: (() => Promise) | undefined - nuxt.hooks.hook('builder:watch', (_event, path) => { - if (!needsRestart) { - return - } - path = relative(nuxt.options.srcDir, resolve(nuxt.options.srcDir, path)) - if (!pageReload && ctx.vuetifyFilesToWatch.includes(path)) { - return nuxt.callHook('restart') - } - }) - nuxt.hook('vite:serverCreated', (server, { isClient }) => { if (!server.ws || !isClient) { return @@ -157,8 +157,8 @@ export function registerWatcher (options: VuetifyModuleOptions, nuxt: Nuxt, ctx: modules.push(module) } } - // reload configuration always: refresh ctx before the SSR runner - // re-executes the (invalidated) virtual modules on the next render + // refresh ctx (without re-mutating nuxt.options — see load()'s `reload` + // flag) before the SSR runner re-executes the invalidated virtual modules await load(options, nuxt, ctx, true) // server.reloadModule escalates to a full client reload for our // non-accepting virtual modules, which re-requests the SSR page. @@ -172,15 +172,7 @@ export function registerWatcher (options: VuetifyModuleOptions, nuxt: Nuxt, ctx: name: 'vuetify:configuration:watch', enforce: 'pre', handleHotUpdate ({ file }) { - if (!ctx.vuetifyFilesToWatch.includes(file)) { - return - } - if (needsRestart) { - // restart is driven by the builder:watch hook above; suppress the - // default client HMR for the (stale-until-restart) virtual module. - return [] - } - if (pageReload) { + if (pageReload && ctx.vuetifyFilesToWatch.includes(file)) { return pageReload() } }, From e63e24ff76081dcec0779043108c893dbf812aef Mon Sep 17 00:00:00 2001 From: Andrei Elkin Date: Sun, 14 Jun 2026 14:47:56 +0200 Subject: [PATCH 10/19] test(e2e): clear fetch-timeout timer to avoid leaked handles --- .../test/e2e/config-hmr-ssr.spec.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/vuetify-nuxt-module/test/e2e/config-hmr-ssr.spec.ts b/packages/vuetify-nuxt-module/test/e2e/config-hmr-ssr.spec.ts index 8ba2b7d..8c8cafe 100644 --- a/packages/vuetify-nuxt-module/test/e2e/config-hmr-ssr.spec.ts +++ b/packages/vuetify-nuxt-module/test/e2e/config-hmr-ssr.spec.ts @@ -24,10 +24,19 @@ const configPath = join(rootDir, 'vuetify.config.ts') // request against a timeout so a stalled request rejects and `expect.poll` // retries it, instead of stalling the whole test until the suite timeout. async function fetchWithTimeout (path: string, ms = 4000): Promise { - return await Promise.race([ - $fetch(path), - new Promise((_, reject) => setTimeout(() => reject(new Error('fetch timeout')), ms)), - ]) + let timer: ReturnType | undefined + try { + return await Promise.race([ + $fetch(path), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('fetch timeout')), ms) + }), + ]) + } finally { + if (timer) { + clearTimeout(timer) + } + } } describe('config-hmr-ssr — dev SSR config hot-reload', () => { From f606b4155247900afdd45ff02c8d1cf4a2aa3067 Mon Sep 17 00:00:00 2001 From: Andrei Elkin Date: Sun, 14 Jun 2026 14:49:14 +0200 Subject: [PATCH 11/19] docs: SSR config changes hot-reload without dev-server restart (Nuxt >= 4.3) --- docs/guide/configuration/vuetify-options.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/guide/configuration/vuetify-options.md b/docs/guide/configuration/vuetify-options.md index 783dcf2..caa8a31 100644 --- a/docs/guide/configuration/vuetify-options.md +++ b/docs/guide/configuration/vuetify-options.md @@ -7,9 +7,9 @@ Support for Nuxt Layers is also available; the module scans for `vuetify.config` During development, the module monitors Vuetify configuration files, focusing on those outside `node_modules`. ::: warning CAVEATS -Modifying the Vuetify configuration during development may trigger a full page reload (sometimes 2-3 times) to invalidate virtual modules without restarting the server. Improvements to this process are planned for future versions. +Modifying the Vuetify configuration during development triggers a full client page reload to pick up the new options — including under SSR, where the server-rendered output is refreshed without restarting the dev server (Nuxt `>= 4.3`). On older Nuxt versions, SSR falls back to a full dev-server restart. -With SSR and external configuration, the Nuxt dev server restarts due to lack of server-side HMR support in Nuxt. +Because the reload intentionally skips re-applying `nuxt.options` (to avoid a slower full server reload), changing the **icon CDN/local CSS** in a configuration file needs a manual dev-server restart to update ``. Other options (theme, defaults, components, …) hot-reload as described above. ::: For example, you can configure: From b5b5c1b0eaf8a55875352f03aba054089e2db8d4 Mon Sep 17 00:00:00 2001 From: Andrei Elkin Date: Sun, 14 Jun 2026 16:10:17 +0200 Subject: [PATCH 12/19] fix(dev): force SSR vite-node cache eviction so real apps hot-reload config The previous approach (addWatchFile + handleHotUpdate reload) worked only on tiny SSR module graphs (the e2e fixture) but not real apps: the vite-node SSR runner never re-executed the virtual config module, so SSR served stale config. Emit a dev-SSR-only side-effect import of the config sources from the virtual config module so the runner records a config-file -> virtual-module dependency edge; adding the config files to the client watcher then makes Nuxt's vite-node invalidate set cascade through that edge to the server entry on edit. Re-read the config and invalidate the SSR transforms inside an awaited handleHotUpdate returning [] (no full-reload, which would suspend SSR for non-browser requests). Verified against the real playground (SSR): theme edit reflected in ~0.7s with no dev-server restart. --- .../vuetify-nuxt-module/src/utils/loader.ts | 70 ++++++++++++------- .../src/vite/vuetify-configuration-plugin.ts | 24 ++++++- 2 files changed, 67 insertions(+), 27 deletions(-) diff --git a/packages/vuetify-nuxt-module/src/utils/loader.ts b/packages/vuetify-nuxt-module/src/utils/loader.ts index 3a90d5d..0b49bdc 100644 --- a/packages/vuetify-nuxt-module/src/utils/loader.ts +++ b/packages/vuetify-nuxt-module/src/utils/loader.ts @@ -1,10 +1,9 @@ import type { Nuxt } from '@nuxt/schema' -import type { ModuleNode } from 'vite' +import type { ViteDevServer } from 'vite' import type { VOptions, VuetifyModuleOptions } from '../types' import type { VuetifyNuxtContext } from './config' import { addVitePlugin } from '@nuxt/kit' import defu from 'defu' -import { debounce } from 'perfect-debounce' import { RESOLVED_VIRTUAL_MODULES } from '../vite/constants' import { prepareIcons } from './icons' import { mergeVuetifyModules } from './layers' @@ -142,38 +141,61 @@ export function registerWatcher (options: VuetifyModuleOptions, nuxt: Nuxt, ctx: return } - let pageReload: (() => Promise) | undefined + let ssrServer: ViteDevServer | undefined nuxt.hook('vite:serverCreated', (server, { isClient }) => { - if (!server.ws || !isClient) { + // The SSR moduleGraph lives on the server-environment vite server. Capture + // it so a config edit can invalidate the virtual config module's SSR + // transform (forcing its load() to re-run with the freshly reloaded ctx). + if (!isClient) { + ssrServer = server + return + } + if (!server.ws) { return } - pageReload = debounce(async () => { - const modules: ModuleNode[] = [] - for (const v of RESOLVED_VIRTUAL_MODULES) { - const module = server.moduleGraph.getModuleById(v) - if (module) { - modules.push(module) - } - } - // refresh ctx (without re-mutating nuxt.options — see load()'s `reload` - // flag) before the SSR runner re-executes the invalidated virtual modules - await load(options, nuxt, ctx, true) - // server.reloadModule escalates to a full client reload for our - // non-accepting virtual modules, which re-requests the SSR page. - if (modules.length > 0) { - await Promise.all(modules.map(m => server.reloadModule(m))) - } - }, 50, { trailing: false }) + // Add the config sources to the client vite server's chokidar watcher so + // Nuxt's vite-node plugin (`clientServer.watcher.on('all')`) adds them to + // its `invalidates` set on edit. On the next SSR render the vite-node + // runner cascades the config file through its importer tree — config file + // -> `\0virtual:vuetify-configuration` (via the dev-SSR-only import edge the + // configuration plugin emits) -> the Vuetify plugin -> the server entry — + // and re-renders, all without a dev-server restart. + server.watcher.add(ctx.vuetifyFilesToWatch) }) + // Re-read the config into ctx (without re-mutating nuxt.options — see load()'s + // `reload` flag) so the virtual modules emit fresh content, then invalidate + // their SSR transforms so the runner re-fetches that content. Awaited inside + // handleHotUpdate so it completes before Nuxt's vite-node runner drains the + // `invalidates` set on the next render — otherwise a request landing between + // the watcher feeding `invalidates` and this invalidation would lock in the + // stale transform (the drain clears the set, so no further re-render fires). + // We deliberately do NOT call server.reloadModule / send a full-reload: that + // suspends SSR for plain (non-browser) requests until a client reconnects. + async function reloadConfig () { + await load(options, nuxt, ctx, true) + if (!ssrServer) { + return + } + for (const id of RESOLVED_VIRTUAL_MODULES) { + const mod = ssrServer.moduleGraph.getModuleById(id) + if (mod) { + ssrServer.moduleGraph.invalidateModule(mod) + } + } + } + addVitePlugin({ name: 'vuetify:configuration:watch', enforce: 'pre', - handleHotUpdate ({ file }) { - if (pageReload && ctx.vuetifyFilesToWatch.includes(file)) { - return pageReload() + async handleHotUpdate ({ file }) { + if (ssrServer && ctx.vuetifyFilesToWatch.includes(file)) { + await reloadConfig() + // Returning an empty array marks the update handled with no modules to + // apply, so vite does not escalate to a client full-reload. + return [] } }, }) diff --git a/packages/vuetify-nuxt-module/src/vite/vuetify-configuration-plugin.ts b/packages/vuetify-nuxt-module/src/vite/vuetify-configuration-plugin.ts index 69b32f5..c555ecc 100644 --- a/packages/vuetify-nuxt-module/src/vite/vuetify-configuration-plugin.ts +++ b/packages/vuetify-nuxt-module/src/vite/vuetify-configuration-plugin.ts @@ -24,8 +24,8 @@ export function vuetifyConfigurationPlugin (ctx: VuetifyNuxtContext) { async load (id) { if (id === RESOLVED_VIRTUAL_VUETIFY_CONFIGURATION) { // Bind the resolved config sources to this virtual module so an edit - // invalidates it (and its SSR importers) on both client and SSR graphs - // in dev. Only meaningful when hot-reload is supported; harmless else. + // invalidates it (and its importers) on both client and SSR graphs in + // dev. Only meaningful when hot-reload is supported; harmless else. if (ctx.isDev && ctx.canHmrConfig) { for (const file of ctx.vuetifyFilesToWatch) { this.addWatchFile(file) @@ -55,7 +55,25 @@ export function vuetifyConfigurationPlugin (ctx: VuetifyNuxtContext) { const result = await buildConfiguration(ctx) const deepCopy = result.messages.length > 0 - return `${result.imports} + // In dev SSR, emit a real (side-effect) import of the config sources so + // the vite-node SSR runner records a dependency edge from this virtual + // module to the config file. On a config edit, Nuxt adds the config file + // to its vite-node `invalidates` set; the runner then cascades that file + // through its importer tree (config file -> this virtual module -> the + // Vuetify plugin -> server entry) and re-renders without a dev-server + // restart. Restricted to the `ssr` environment so the raw user config is + // never shipped to (or executed in) the client bundle. The imported + // value is intentionally unused — the config is serialized below from + // the freshly reloaded `ctx`. + let configDepImports = '' + if (ctx.isDev && ctx.canHmrConfig && this.environment?.name === 'ssr') { + configDepImports = ctx.vuetifyFilesToWatch + .map(file => `import ${JSON.stringify(file)}`) + .join('\n') + } + + return `${configDepImports} +${result.imports} export const isDev = ${ctx.isDev} export function vuetifyConfiguration() { From 1ce85eec509e24e4273f1f133b7d7208cb56df19 Mon Sep 17 00:00:00 2001 From: Andrei Elkin Date: Sun, 14 Jun 2026 16:39:54 +0200 Subject: [PATCH 13/19] fix(dev): broadcast full-reload on config edit so the open browser auto-refreshes --- .../vuetify-nuxt-module/src/utils/loader.ts | 30 +++++++++++++------ 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/packages/vuetify-nuxt-module/src/utils/loader.ts b/packages/vuetify-nuxt-module/src/utils/loader.ts index 0b49bdc..09b1130 100644 --- a/packages/vuetify-nuxt-module/src/utils/loader.ts +++ b/packages/vuetify-nuxt-module/src/utils/loader.ts @@ -142,6 +142,7 @@ export function registerWatcher (options: VuetifyModuleOptions, nuxt: Nuxt, ctx: } let ssrServer: ViteDevServer | undefined + let clientServer: ViteDevServer | undefined nuxt.hook('vite:serverCreated', (server, { isClient }) => { // The SSR moduleGraph lives on the server-environment vite server. Capture @@ -155,6 +156,12 @@ export function registerWatcher (options: VuetifyModuleOptions, nuxt: Nuxt, ctx: return } + // Capture the client vite server so a config edit can broadcast a + // full-reload over its websocket — that's what makes an already-open + // browser tab refresh on its own (the SSR invalidation alone only updates + // the output served to the next fresh request). + clientServer = server + // Add the config sources to the client vite server's chokidar watcher so // Nuxt's vite-node plugin (`clientServer.watcher.on('all')`) adds them to // its `invalidates` set on edit. On the next SSR render the vite-node @@ -172,19 +179,24 @@ export function registerWatcher (options: VuetifyModuleOptions, nuxt: Nuxt, ctx: // `invalidates` set on the next render — otherwise a request landing between // the watcher feeding `invalidates` and this invalidation would lock in the // stale transform (the drain clears the set, so no further re-render fires). - // We deliberately do NOT call server.reloadModule / send a full-reload: that - // suspends SSR for plain (non-browser) requests until a client reconnects. + // We deliberately do NOT call server.reloadModule / let vite escalate to its + // default full-reload: that suspends SSR for plain (non-browser) requests + // until a client reconnects. Instead we invalidate the SSR transforms so the + // next request re-renders, then broadcast a full-reload ourselves purely over + // the CLIENT websocket so an already-open browser tab refreshes on its own. + // That broadcast is just a WS message to connected browsers — it does not + // gate server-side rendering, so a bare `curl` still returns promptly. async function reloadConfig () { await load(options, nuxt, ctx, true) - if (!ssrServer) { - return - } - for (const id of RESOLVED_VIRTUAL_MODULES) { - const mod = ssrServer.moduleGraph.getModuleById(id) - if (mod) { - ssrServer.moduleGraph.invalidateModule(mod) + if (ssrServer) { + for (const id of RESOLVED_VIRTUAL_MODULES) { + const mod = ssrServer.moduleGraph.getModuleById(id) + if (mod) { + ssrServer.moduleGraph.invalidateModule(mod) + } } } + clientServer?.ws.send({ type: 'full-reload' }) } addVitePlugin({ From eb81f10147c8b8ac031c202e652b601e7e57618c Mon Sep 17 00:00:00 2001 From: Andrei Elkin Date: Sun, 14 Jun 2026 16:46:10 +0200 Subject: [PATCH 14/19] docs+comments: document icon-set/date-adapter SSR HMR limits and test scope Address final code review: make the icons/date SSR-eviction gap explicit (no import edge -> not hot-reloaded under SSR, needs restart) in code comments and the docs caveat; clarify that addWatchFile alone does not evict the SSR runner (the dev-SSR import edge does); note the e2e guards no-restart + wiring but not the eviction mechanism; mark the 4.3.0 floor as a conservative inference. --- docs/guide/configuration/vuetify-options.md | 2 +- packages/vuetify-nuxt-module/src/utils/ssr-config-hmr.ts | 6 +++++- .../src/vite/vuetify-configuration-plugin.ts | 5 +++-- .../src/vite/vuetify-date-configuration-plugin.ts | 6 ++++-- .../src/vite/vuetify-icons-configuration-plugin.ts | 7 +++++-- .../vuetify-nuxt-module/test/e2e/config-hmr-ssr.spec.ts | 7 +++++++ 6 files changed, 25 insertions(+), 8 deletions(-) diff --git a/docs/guide/configuration/vuetify-options.md b/docs/guide/configuration/vuetify-options.md index caa8a31..087e9d2 100644 --- a/docs/guide/configuration/vuetify-options.md +++ b/docs/guide/configuration/vuetify-options.md @@ -9,7 +9,7 @@ During development, the module monitors Vuetify configuration files, focusing on ::: warning CAVEATS Modifying the Vuetify configuration during development triggers a full client page reload to pick up the new options — including under SSR, where the server-rendered output is refreshed without restarting the dev server (Nuxt `>= 4.3`). On older Nuxt versions, SSR falls back to a full dev-server restart. -Because the reload intentionally skips re-applying `nuxt.options` (to avoid a slower full server reload), changing the **icon CDN/local CSS** in a configuration file needs a manual dev-server restart to update ``. Other options (theme, defaults, components, …) hot-reload as described above. +Under SSR, hot-reload covers the core options — `theme`, `defaults`, `components`, `aliases`, `directives`, and locale messages. A few changes still need a manual dev-server restart to take effect: the **icon CDN / local CSS** `` links (the reload intentionally skips re-applying `nuxt.options` to avoid a slower full server reload), and **icon-set / date-adapter** changes (their server-rendered modules are not re-evaluated on the fly by the dev SSR runtime). ::: For example, you can configure: diff --git a/packages/vuetify-nuxt-module/src/utils/ssr-config-hmr.ts b/packages/vuetify-nuxt-module/src/utils/ssr-config-hmr.ts index a970604..7120473 100644 --- a/packages/vuetify-nuxt-module/src/utils/ssr-config-hmr.ts +++ b/packages/vuetify-nuxt-module/src/utils/ssr-config-hmr.ts @@ -8,7 +8,11 @@ import semver from 'semver' * config-file change under SSR cannot reach the SSR runner cache, so we fall * back to `nuxt.callHook('restart')`. * - * Validated against Nuxt 4.3.1 by `test/e2e/config-hmr-ssr.spec.ts`. + * Validated against Nuxt 4.3.1 (e2e + the `apps/playground` smoke test). The + * `4.3.0` floor is a conservative inference, not empirically tested below + * 4.3.1; lower it only with verification. Below the floor the safe restart + * fallback applies, so an over-tight floor never breaks — it only forgoes the + * fast path. */ export const MIN_NUXT_VERSION_FOR_SSR_CONFIG_HMR = '4.3.0' diff --git a/packages/vuetify-nuxt-module/src/vite/vuetify-configuration-plugin.ts b/packages/vuetify-nuxt-module/src/vite/vuetify-configuration-plugin.ts index c555ecc..143c2ad 100644 --- a/packages/vuetify-nuxt-module/src/vite/vuetify-configuration-plugin.ts +++ b/packages/vuetify-nuxt-module/src/vite/vuetify-configuration-plugin.ts @@ -24,8 +24,9 @@ export function vuetifyConfigurationPlugin (ctx: VuetifyNuxtContext) { async load (id) { if (id === RESOLVED_VIRTUAL_VUETIFY_CONFIGURATION) { // Bind the resolved config sources to this virtual module so an edit - // invalidates it (and its importers) on both client and SSR graphs in - // dev. Only meaningful when hot-reload is supported; harmless else. + // invalidates it on the client graph in dev. NOTE: this alone does not + // evict the vite-node SSR runner cache — the dev-SSR `import` edge + // emitted below is what forces the SSR re-evaluation. Keep both. if (ctx.isDev && ctx.canHmrConfig) { for (const file of ctx.vuetifyFilesToWatch) { this.addWatchFile(file) diff --git a/packages/vuetify-nuxt-module/src/vite/vuetify-date-configuration-plugin.ts b/packages/vuetify-nuxt-module/src/vite/vuetify-date-configuration-plugin.ts index ac86c62..8aefa7c 100644 --- a/packages/vuetify-nuxt-module/src/vite/vuetify-date-configuration-plugin.ts +++ b/packages/vuetify-nuxt-module/src/vite/vuetify-date-configuration-plugin.ts @@ -15,8 +15,10 @@ export function vuetifyDateConfigurationPlugin (ctx: VuetifyNuxtContext) { async load (id) { if (id === RESOLVED_VIRTUAL_VUETIFY_DATE_CONFIGURATION) { // Bind the resolved config sources to this virtual module so an edit - // invalidates it (and its SSR importers) on both client and SSR graphs - // in dev. Only meaningful when hot-reload is supported; harmless else. + // invalidates it on the client graph in dev. Unlike the main config + // plugin, we do NOT emit a dev-SSR import edge here, so a change to the + // date adapter is not re-evaluated by the SSR runtime on the fly (it + // needs a manual restart). if (ctx.isDev && ctx.canHmrConfig) { for (const file of ctx.vuetifyFilesToWatch) { this.addWatchFile(file) diff --git a/packages/vuetify-nuxt-module/src/vite/vuetify-icons-configuration-plugin.ts b/packages/vuetify-nuxt-module/src/vite/vuetify-icons-configuration-plugin.ts index a26a098..b0280e7 100644 --- a/packages/vuetify-nuxt-module/src/vite/vuetify-icons-configuration-plugin.ts +++ b/packages/vuetify-nuxt-module/src/vite/vuetify-icons-configuration-plugin.ts @@ -17,8 +17,11 @@ export function vuetifyIconsPlugin (ctx: VuetifyNuxtContext) { async load (id) { if (id === RESOLVED_VIRTUAL_VUETIFY_ICONS_CONFIGURATION) { // Bind the resolved config sources to this virtual module so an edit - // invalidates it (and its SSR importers) on both client and SSR graphs - // in dev. Only meaningful when hot-reload is supported; harmless else. + // invalidates it on the client graph in dev. Unlike the main config + // plugin, we do NOT emit a dev-SSR import edge here, so a change to the + // icon set is not re-evaluated by the SSR runtime on the fly (it needs + // a manual restart). Icon `` links are skipped on reload anyway + // (see load()'s `reload` flag), so this stays consistent. if (ctx.isDev && ctx.canHmrConfig) { for (const file of ctx.vuetifyFilesToWatch) { this.addWatchFile(file) diff --git a/packages/vuetify-nuxt-module/test/e2e/config-hmr-ssr.spec.ts b/packages/vuetify-nuxt-module/test/e2e/config-hmr-ssr.spec.ts index 8c8cafe..39b1823 100644 --- a/packages/vuetify-nuxt-module/test/e2e/config-hmr-ssr.spec.ts +++ b/packages/vuetify-nuxt-module/test/e2e/config-hmr-ssr.spec.ts @@ -39,6 +39,13 @@ async function fetchWithTimeout (path: string, ms = 4000): Promise { } } +// SCOPE: this guards the no-restart property (via the boot-count probe) and the +// watcher wiring end-to-end. It does NOT guard the SSR vite-node runner cache +// eviction itself: this fixture's SSR graph is small enough that a config edit +// re-renders SSR even without the dev-SSR import-edge mechanism, so the +// assertion can't distinguish the two. That mechanism is verified manually +// against `apps/playground` (see the fix commit). A future maintainer must not +// treat a green run here as proof the eviction path still works. describe('config-hmr-ssr — dev SSR config hot-reload', () => { let probeFile = '' let originalConfig = '' From c198c4c1d0977e220a63dd25dd79f91481abd6e4 Mon Sep 17 00:00:00 2001 From: Andrei Elkin Date: Sun, 14 Jun 2026 16:56:23 +0200 Subject: [PATCH 15/19] feat(dev): lower SSR config-HMR floor to Nuxt 4.0.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The required @nuxt/vite-builder internals (useInvalidates/markInvalidate/ invalidateDepTree, environments.ssr.fetchModule) and the Vite Environment API (every Nuxt 4.x uses Vite 7) are present since 4.0.0 — verified by inspecting the published 4.0.0-4.3.1 dists and confirmed end-to-end on apps/playground running Nuxt 4.0.0 (config edit hot-reloaded, no dev-server restart). The 4.3.0 floor was an overly conservative untested guess. Nuxt 3 still uses the restart fallback. --- docs/guide/configuration/vuetify-options.md | 2 +- .../src/utils/ssr-config-hmr.ts | 22 +++++++++---------- .../test/ssr-config-hmr.test.ts | 11 +++++----- 3 files changed, 17 insertions(+), 18 deletions(-) diff --git a/docs/guide/configuration/vuetify-options.md b/docs/guide/configuration/vuetify-options.md index 087e9d2..c4f19e3 100644 --- a/docs/guide/configuration/vuetify-options.md +++ b/docs/guide/configuration/vuetify-options.md @@ -7,7 +7,7 @@ Support for Nuxt Layers is also available; the module scans for `vuetify.config` During development, the module monitors Vuetify configuration files, focusing on those outside `node_modules`. ::: warning CAVEATS -Modifying the Vuetify configuration during development triggers a full client page reload to pick up the new options — including under SSR, where the server-rendered output is refreshed without restarting the dev server (Nuxt `>= 4.3`). On older Nuxt versions, SSR falls back to a full dev-server restart. +Modifying the Vuetify configuration during development triggers a full client page reload to pick up the new options — including under SSR, where the server-rendered output is refreshed without restarting the dev server (Nuxt `>= 4.0`). On Nuxt 3, SSR falls back to a full dev-server restart. Under SSR, hot-reload covers the core options — `theme`, `defaults`, `components`, `aliases`, `directives`, and locale messages. A few changes still need a manual dev-server restart to take effect: the **icon CDN / local CSS** `` links (the reload intentionally skips re-applying `nuxt.options` to avoid a slower full server reload), and **icon-set / date-adapter** changes (their server-rendered modules are not re-evaluated on the fly by the dev SSR runtime). ::: diff --git a/packages/vuetify-nuxt-module/src/utils/ssr-config-hmr.ts b/packages/vuetify-nuxt-module/src/utils/ssr-config-hmr.ts index 7120473..630eb6b 100644 --- a/packages/vuetify-nuxt-module/src/utils/ssr-config-hmr.ts +++ b/packages/vuetify-nuxt-module/src/utils/ssr-config-hmr.ts @@ -2,19 +2,17 @@ import semver from 'semver' /** * Lowest Nuxt version whose `@nuxt/vite-builder` hot-updates SSR-consumed - * virtual modules in dev: the vite-node `invalidates` set is fed by the file - * watcher + module-graph association, evicting our virtual modules (and their - * SSR importers) from the runner cache without a full restart. Below this, a - * config-file change under SSR cannot reach the SSR runner cache, so we fall - * back to `nuxt.callHook('restart')`. - * - * Validated against Nuxt 4.3.1 (e2e + the `apps/playground` smoke test). The - * `4.3.0` floor is a conservative inference, not empirically tested below - * 4.3.1; lower it only with verification. Below the floor the safe restart - * fallback applies, so an over-tight floor never breaks — it only forgoes the - * fast path. + * virtual modules in dev. Our fix relies on: the vite-node `invalidates` set + * fed by the file watcher + the runner's importer-cascade eviction + * (`useInvalidates`/`markInvalidate`/`invalidateDepTree`), SSR served through + * `environments.ssr.fetchModule`, and the Vite Environment API for the + * `this.environment.name === 'ssr'` plugin gate. All of these are present in + * `@nuxt/vite-builder` since 4.0.0 (every Nuxt 4.x requires Vite 7), verified + * by inspecting the published 4.0.0–4.3.1 dists and confirmed end-to-end on the + * `apps/playground` SSR app running Nuxt 4.0.0 (config edit hot-reloaded, no + * dev-server restart). Below 4.0 (Nuxt 3) we fall back to `callHook('restart')`. */ -export const MIN_NUXT_VERSION_FOR_SSR_CONFIG_HMR = '4.3.0' +export const MIN_NUXT_VERSION_FOR_SSR_CONFIG_HMR = '4.0.0' /** * Whether the installed Nuxt can hot-update SSR-consumed virtual config diff --git a/packages/vuetify-nuxt-module/test/ssr-config-hmr.test.ts b/packages/vuetify-nuxt-module/test/ssr-config-hmr.test.ts index 921b520..ed5e505 100644 --- a/packages/vuetify-nuxt-module/test/ssr-config-hmr.test.ts +++ b/packages/vuetify-nuxt-module/test/ssr-config-hmr.test.ts @@ -3,20 +3,21 @@ import { MIN_NUXT_VERSION_FOR_SSR_CONFIG_HMR, supportsSsrConfigHmr } from '../sr describe('supportsSsrConfigHmr', () => { it('exposes the documented floor', () => { - expect(MIN_NUXT_VERSION_FOR_SSR_CONFIG_HMR).toBe('4.3.0') + expect(MIN_NUXT_VERSION_FOR_SSR_CONFIG_HMR).toBe('4.0.0') }) - it('accepts the validated Nuxt versions (>= 4.3.0)', () => { + it('accepts the validated Nuxt versions (>= 4.0.0)', () => { + expect(supportsSsrConfigHmr('4.0.0')).toBe(true) + expect(supportsSsrConfigHmr('4.2.5')).toBe(true) expect(supportsSsrConfigHmr('4.3.1')).toBe(true) - expect(supportsSsrConfigHmr('4.3.0')).toBe(true) expect(supportsSsrConfigHmr('5.0.0')).toBe(true) expect(supportsSsrConfigHmr('v4.3.2')).toBe(true) }) it('rejects older Nuxt (restart fallback)', () => { - expect(supportsSsrConfigHmr('4.2.5')).toBe(false) expect(supportsSsrConfigHmr('3.15.0')).toBe(false) - expect(supportsSsrConfigHmr('4.3.0-rc.1')).toBe(false) + expect(supportsSsrConfigHmr('3.17.9')).toBe(false) + expect(supportsSsrConfigHmr('4.0.0-rc.1')).toBe(false) }) it('rejects unparseable versions', () => { From 7ed8d6018b3e075782f1b77ba90995a8f866b632 Mon Sep 17 00:00:00 2001 From: Andrei Elkin Date: Sun, 14 Jun 2026 17:08:40 +0200 Subject: [PATCH 16/19] feat(dev): lower SSR config-HMR floor to Nuxt 3.18.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Vite 7 + Environment-API invalidation mechanism the fix relies on landed in @nuxt/vite-builder 3.18.0 (3.x line) and 4.0.0 (4.x line); Nuxt 3.15-3.17 ship Vite 6 with the older mechanism and don't qualify. Confirmed end-to-end on apps/playground at both Nuxt 3.21.8 and 4.0.0 (config edit hot-reloaded, no restart; pre-fix build stayed stale — valid control). Nuxt 3.15-3.17 keep the restart fallback. --- docs/guide/configuration/vuetify-options.md | 2 +- .../src/utils/ssr-config-hmr.ts | 18 ++++++++++-------- .../test/ssr-config-hmr.test.ts | 13 +++++++------ 3 files changed, 18 insertions(+), 15 deletions(-) diff --git a/docs/guide/configuration/vuetify-options.md b/docs/guide/configuration/vuetify-options.md index c4f19e3..96d9464 100644 --- a/docs/guide/configuration/vuetify-options.md +++ b/docs/guide/configuration/vuetify-options.md @@ -7,7 +7,7 @@ Support for Nuxt Layers is also available; the module scans for `vuetify.config` During development, the module monitors Vuetify configuration files, focusing on those outside `node_modules`. ::: warning CAVEATS -Modifying the Vuetify configuration during development triggers a full client page reload to pick up the new options — including under SSR, where the server-rendered output is refreshed without restarting the dev server (Nuxt `>= 4.0`). On Nuxt 3, SSR falls back to a full dev-server restart. +Modifying the Vuetify configuration during development triggers a full client page reload to pick up the new options — including under SSR, where the server-rendered output is refreshed without restarting the dev server (Nuxt `>= 3.18`, i.e. the Vite 7 line, and all of Nuxt 4). On Nuxt 3.15–3.17 (Vite 6), SSR falls back to a full dev-server restart. Under SSR, hot-reload covers the core options — `theme`, `defaults`, `components`, `aliases`, `directives`, and locale messages. A few changes still need a manual dev-server restart to take effect: the **icon CDN / local CSS** `` links (the reload intentionally skips re-applying `nuxt.options` to avoid a slower full server reload), and **icon-set / date-adapter** changes (their server-rendered modules are not re-evaluated on the fly by the dev SSR runtime). ::: diff --git a/packages/vuetify-nuxt-module/src/utils/ssr-config-hmr.ts b/packages/vuetify-nuxt-module/src/utils/ssr-config-hmr.ts index 630eb6b..bfc29ba 100644 --- a/packages/vuetify-nuxt-module/src/utils/ssr-config-hmr.ts +++ b/packages/vuetify-nuxt-module/src/utils/ssr-config-hmr.ts @@ -4,15 +4,17 @@ import semver from 'semver' * Lowest Nuxt version whose `@nuxt/vite-builder` hot-updates SSR-consumed * virtual modules in dev. Our fix relies on: the vite-node `invalidates` set * fed by the file watcher + the runner's importer-cascade eviction - * (`useInvalidates`/`markInvalidate`/`invalidateDepTree`), SSR served through - * `environments.ssr.fetchModule`, and the Vite Environment API for the - * `this.environment.name === 'ssr'` plugin gate. All of these are present in - * `@nuxt/vite-builder` since 4.0.0 (every Nuxt 4.x requires Vite 7), verified - * by inspecting the published 4.0.0–4.3.1 dists and confirmed end-to-end on the - * `apps/playground` SSR app running Nuxt 4.0.0 (config edit hot-reloaded, no - * dev-server restart). Below 4.0 (Nuxt 3) we fall back to `callHook('restart')`. + * (`useInvalidates`/`markInvalidate`/`invalidateDepTree`) and the Vite + * Environment API for the `this.environment.name === 'ssr'` plugin gate. Those + * pieces (and Vite 7) landed together in `@nuxt/vite-builder` 3.18.0 on the 3.x + * line and 4.0.0 on the 4.x line — Nuxt 3.15–3.17 ship Vite 6 with the older + * mechanism (no `environment.name` gate) and don't qualify. Established by + * inspecting the published 3.15–4.3 dists and confirmed end-to-end on the + * `apps/playground` SSR app at both Nuxt 3.21.8 and 4.0.0 (config edit + * hot-reloaded, no dev-server restart; the pre-fix build stayed stale, proving + * the control). Below 3.18 we fall back to `callHook('restart')`. */ -export const MIN_NUXT_VERSION_FOR_SSR_CONFIG_HMR = '4.0.0' +export const MIN_NUXT_VERSION_FOR_SSR_CONFIG_HMR = '3.18.0' /** * Whether the installed Nuxt can hot-update SSR-consumed virtual config diff --git a/packages/vuetify-nuxt-module/test/ssr-config-hmr.test.ts b/packages/vuetify-nuxt-module/test/ssr-config-hmr.test.ts index ed5e505..663b359 100644 --- a/packages/vuetify-nuxt-module/test/ssr-config-hmr.test.ts +++ b/packages/vuetify-nuxt-module/test/ssr-config-hmr.test.ts @@ -3,21 +3,22 @@ import { MIN_NUXT_VERSION_FOR_SSR_CONFIG_HMR, supportsSsrConfigHmr } from '../sr describe('supportsSsrConfigHmr', () => { it('exposes the documented floor', () => { - expect(MIN_NUXT_VERSION_FOR_SSR_CONFIG_HMR).toBe('4.0.0') + expect(MIN_NUXT_VERSION_FOR_SSR_CONFIG_HMR).toBe('3.18.0') }) - it('accepts the validated Nuxt versions (>= 4.0.0)', () => { + it('accepts Nuxt 3.18+ and all Nuxt 4 (the Vite 7 + env-API mechanism)', () => { + expect(supportsSsrConfigHmr('3.18.0')).toBe(true) + expect(supportsSsrConfigHmr('3.21.8')).toBe(true) expect(supportsSsrConfigHmr('4.0.0')).toBe(true) - expect(supportsSsrConfigHmr('4.2.5')).toBe(true) expect(supportsSsrConfigHmr('4.3.1')).toBe(true) expect(supportsSsrConfigHmr('5.0.0')).toBe(true) expect(supportsSsrConfigHmr('v4.3.2')).toBe(true) }) - it('rejects older Nuxt (restart fallback)', () => { - expect(supportsSsrConfigHmr('3.15.0')).toBe(false) + it('rejects Nuxt 3.15-3.17 and older (Vite 6, restart fallback)', () => { expect(supportsSsrConfigHmr('3.17.9')).toBe(false) - expect(supportsSsrConfigHmr('4.0.0-rc.1')).toBe(false) + expect(supportsSsrConfigHmr('3.15.0')).toBe(false) + expect(supportsSsrConfigHmr('3.18.0-rc.1')).toBe(false) }) it('rejects unparseable versions', () => { From b99b144a4aeeb3c40703c3a2f829b1e1dcc07314 Mon Sep 17 00:00:00 2001 From: Andrei Elkin Date: Sun, 14 Jun 2026 17:12:52 +0200 Subject: [PATCH 17/19] refactor: trim verbose comments to concise why-notes --- .../vuetify-nuxt-module/src/utils/loader.ts | 55 +++++-------------- .../src/utils/ssr-config-hmr.ts | 18 ++---- .../src/vite/vuetify-configuration-plugin.ts | 22 +++----- .../vite/vuetify-date-configuration-plugin.ts | 8 +-- .../vuetify-icons-configuration-plugin.ts | 9 +-- .../test/e2e/config-hmr-ssr.spec.ts | 11 ++-- 6 files changed, 39 insertions(+), 84 deletions(-) diff --git a/packages/vuetify-nuxt-module/src/utils/loader.ts b/packages/vuetify-nuxt-module/src/utils/loader.ts index 09b1130..e03af28 100644 --- a/packages/vuetify-nuxt-module/src/utils/loader.ts +++ b/packages/vuetify-nuxt-module/src/utils/loader.ts @@ -73,10 +73,9 @@ export async function load ( } /* handle old stuff */ - // On a dev config reload we deliberately leave nuxt.options (css + head links) - // untouched: re-mutating them would trigger a full Nitro dev:reload (defeating - // the HMR fast-path) and accumulate duplicate entries. Trade-off: changing the - // icon CDN/local CSS in a config file needs a manual restart to update . + // On reload, leave nuxt.options (css + head links) untouched: re-mutating + // them triggers a Nitro dev:reload and accumulates duplicates. Trade-off: + // icon CDN/local CSS changes then need a manual restart to update . if (!reload) { const oldIcons = ctx.icons if (oldIcons && oldIcons.cdn?.length && nuxt.options.app.head.link) { @@ -130,10 +129,8 @@ export function registerWatcher (options: VuetifyModuleOptions, nuxt: Nuxt, ctx: return } - // Legacy fallback: on Nuxt versions whose vite-node can't evict SSR-consumed - // virtual modules from its runner cache (ctx.canHmrConfig === false), hot - // reload isn't possible — register the config files with Nuxt so it restarts - // the dev server natively when any of them changes. + // Older Nuxt (Vite 6, !canHmrConfig) can't evict the SSR runner cache, so let + // Nuxt restart the dev server natively when a watched config file changes. if (!ctx.canHmrConfig) { for (const file of ctx.vuetifyFilesToWatch) { nuxt.options.watch.push(file) @@ -145,9 +142,6 @@ export function registerWatcher (options: VuetifyModuleOptions, nuxt: Nuxt, ctx: let clientServer: ViteDevServer | undefined nuxt.hook('vite:serverCreated', (server, { isClient }) => { - // The SSR moduleGraph lives on the server-environment vite server. Capture - // it so a config edit can invalidate the virtual config module's SSR - // transform (forcing its load() to re-run with the freshly reloaded ctx). if (!isClient) { ssrServer = server return @@ -156,36 +150,19 @@ export function registerWatcher (options: VuetifyModuleOptions, nuxt: Nuxt, ctx: return } - // Capture the client vite server so a config edit can broadcast a - // full-reload over its websocket — that's what makes an already-open - // browser tab refresh on its own (the SSR invalidation alone only updates - // the output served to the next fresh request). clientServer = server - - // Add the config sources to the client vite server's chokidar watcher so - // Nuxt's vite-node plugin (`clientServer.watcher.on('all')`) adds them to - // its `invalidates` set on edit. On the next SSR render the vite-node - // runner cascades the config file through its importer tree — config file - // -> `\0virtual:vuetify-configuration` (via the dev-SSR-only import edge the - // configuration plugin emits) -> the Vuetify plugin -> the server entry — - // and re-renders, all without a dev-server restart. + // Watch the config sources on the client server so Nuxt's vite-node feeds + // its `invalidates` set on edit; the runner then cascades the change to the + // virtual module (via the dev-SSR import edge the config plugin emits). server.watcher.add(ctx.vuetifyFilesToWatch) }) - // Re-read the config into ctx (without re-mutating nuxt.options — see load()'s - // `reload` flag) so the virtual modules emit fresh content, then invalidate - // their SSR transforms so the runner re-fetches that content. Awaited inside - // handleHotUpdate so it completes before Nuxt's vite-node runner drains the - // `invalidates` set on the next render — otherwise a request landing between - // the watcher feeding `invalidates` and this invalidation would lock in the - // stale transform (the drain clears the set, so no further re-render fires). - // We deliberately do NOT call server.reloadModule / let vite escalate to its - // default full-reload: that suspends SSR for plain (non-browser) requests - // until a client reconnects. Instead we invalidate the SSR transforms so the - // next request re-renders, then broadcast a full-reload ourselves purely over - // the CLIENT websocket so an already-open browser tab refreshes on its own. - // That broadcast is just a WS message to connected browsers — it does not - // gate server-side rendering, so a bare `curl` still returns promptly. + // Refresh ctx (reload=true skips nuxt.options churn), invalidate the SSR + // transforms, then broadcast a full-reload so an open browser refreshes. + // Awaited before returning from handleHotUpdate so it wins the race against + // the runner draining `invalidates` on the next render. We avoid vite's + // default full-reload escalation (returning [] below): the explicit WS + // broadcast refreshes browsers without stalling plain (curl/SSR) requests. async function reloadConfig () { await load(options, nuxt, ctx, true) if (ssrServer) { @@ -205,9 +182,7 @@ export function registerWatcher (options: VuetifyModuleOptions, nuxt: Nuxt, ctx: async handleHotUpdate ({ file }) { if (ssrServer && ctx.vuetifyFilesToWatch.includes(file)) { await reloadConfig() - // Returning an empty array marks the update handled with no modules to - // apply, so vite does not escalate to a client full-reload. - return [] + return [] // handled — don't let vite escalate to its own full-reload } }, }) diff --git a/packages/vuetify-nuxt-module/src/utils/ssr-config-hmr.ts b/packages/vuetify-nuxt-module/src/utils/ssr-config-hmr.ts index bfc29ba..9816912 100644 --- a/packages/vuetify-nuxt-module/src/utils/ssr-config-hmr.ts +++ b/packages/vuetify-nuxt-module/src/utils/ssr-config-hmr.ts @@ -1,18 +1,12 @@ import semver from 'semver' /** - * Lowest Nuxt version whose `@nuxt/vite-builder` hot-updates SSR-consumed - * virtual modules in dev. Our fix relies on: the vite-node `invalidates` set - * fed by the file watcher + the runner's importer-cascade eviction - * (`useInvalidates`/`markInvalidate`/`invalidateDepTree`) and the Vite - * Environment API for the `this.environment.name === 'ssr'` plugin gate. Those - * pieces (and Vite 7) landed together in `@nuxt/vite-builder` 3.18.0 on the 3.x - * line and 4.0.0 on the 4.x line — Nuxt 3.15–3.17 ship Vite 6 with the older - * mechanism (no `environment.name` gate) and don't qualify. Established by - * inspecting the published 3.15–4.3 dists and confirmed end-to-end on the - * `apps/playground` SSR app at both Nuxt 3.21.8 and 4.0.0 (config edit - * hot-reloaded, no dev-server restart; the pre-fix build stayed stale, proving - * the control). Below 3.18 we fall back to `callHook('restart')`. + * Lowest Nuxt version whose `@nuxt/vite-builder` can hot-update SSR-consumed + * virtual modules in dev (vite-node `invalidates` cascade + the Vite 7 + * Environment API our `this.environment.name === 'ssr'` gate needs). These + * landed in 3.18.0 (3.x line) and 4.0.0 (4.x line); Nuxt 3.15–3.17 are Vite 6 + * and don't qualify. Verified on `apps/playground` at Nuxt 3.21.8 and 4.0.0. + * Below 3.18 we fall back to `callHook('restart')`. */ export const MIN_NUXT_VERSION_FOR_SSR_CONFIG_HMR = '3.18.0' diff --git a/packages/vuetify-nuxt-module/src/vite/vuetify-configuration-plugin.ts b/packages/vuetify-nuxt-module/src/vite/vuetify-configuration-plugin.ts index 143c2ad..26d6619 100644 --- a/packages/vuetify-nuxt-module/src/vite/vuetify-configuration-plugin.ts +++ b/packages/vuetify-nuxt-module/src/vite/vuetify-configuration-plugin.ts @@ -23,10 +23,9 @@ export function vuetifyConfigurationPlugin (ctx: VuetifyNuxtContext) { }, async load (id) { if (id === RESOLVED_VIRTUAL_VUETIFY_CONFIGURATION) { - // Bind the resolved config sources to this virtual module so an edit - // invalidates it on the client graph in dev. NOTE: this alone does not - // evict the vite-node SSR runner cache — the dev-SSR `import` edge - // emitted below is what forces the SSR re-evaluation. Keep both. + // Invalidate this module on the client graph when a config file + // changes. NOTE: keep the dev-SSR import edge below too — it (not this) + // is what evicts the vite-node SSR runner cache. if (ctx.isDev && ctx.canHmrConfig) { for (const file of ctx.vuetifyFilesToWatch) { this.addWatchFile(file) @@ -56,16 +55,11 @@ export function vuetifyConfigurationPlugin (ctx: VuetifyNuxtContext) { const result = await buildConfiguration(ctx) const deepCopy = result.messages.length > 0 - // In dev SSR, emit a real (side-effect) import of the config sources so - // the vite-node SSR runner records a dependency edge from this virtual - // module to the config file. On a config edit, Nuxt adds the config file - // to its vite-node `invalidates` set; the runner then cascades that file - // through its importer tree (config file -> this virtual module -> the - // Vuetify plugin -> server entry) and re-renders without a dev-server - // restart. Restricted to the `ssr` environment so the raw user config is - // never shipped to (or executed in) the client bundle. The imported - // value is intentionally unused — the config is serialized below from - // the freshly reloaded `ctx`. + // Dev SSR only: emit a side-effect import of the config sources so the + // vite-node runner records a config-file -> virtual-module edge and + // re-evaluates this module (with the reloaded ctx) when the file + // changes — no dev-server restart. Gated to `ssr` so the raw config is + // never bundled/executed on the client; the imported value is unused. let configDepImports = '' if (ctx.isDev && ctx.canHmrConfig && this.environment?.name === 'ssr') { configDepImports = ctx.vuetifyFilesToWatch diff --git a/packages/vuetify-nuxt-module/src/vite/vuetify-date-configuration-plugin.ts b/packages/vuetify-nuxt-module/src/vite/vuetify-date-configuration-plugin.ts index 8aefa7c..3a20527 100644 --- a/packages/vuetify-nuxt-module/src/vite/vuetify-date-configuration-plugin.ts +++ b/packages/vuetify-nuxt-module/src/vite/vuetify-date-configuration-plugin.ts @@ -14,11 +14,9 @@ export function vuetifyDateConfigurationPlugin (ctx: VuetifyNuxtContext) { }, async load (id) { if (id === RESOLVED_VIRTUAL_VUETIFY_DATE_CONFIGURATION) { - // Bind the resolved config sources to this virtual module so an edit - // invalidates it on the client graph in dev. Unlike the main config - // plugin, we do NOT emit a dev-SSR import edge here, so a change to the - // date adapter is not re-evaluated by the SSR runtime on the fly (it - // needs a manual restart). + // Client-graph invalidation only. No dev-SSR import edge here (unlike + // the main config plugin), so date-adapter changes need a manual + // restart under SSR. if (ctx.isDev && ctx.canHmrConfig) { for (const file of ctx.vuetifyFilesToWatch) { this.addWatchFile(file) diff --git a/packages/vuetify-nuxt-module/src/vite/vuetify-icons-configuration-plugin.ts b/packages/vuetify-nuxt-module/src/vite/vuetify-icons-configuration-plugin.ts index b0280e7..4f4c165 100644 --- a/packages/vuetify-nuxt-module/src/vite/vuetify-icons-configuration-plugin.ts +++ b/packages/vuetify-nuxt-module/src/vite/vuetify-icons-configuration-plugin.ts @@ -16,12 +16,9 @@ export function vuetifyIconsPlugin (ctx: VuetifyNuxtContext) { }, async load (id) { if (id === RESOLVED_VIRTUAL_VUETIFY_ICONS_CONFIGURATION) { - // Bind the resolved config sources to this virtual module so an edit - // invalidates it on the client graph in dev. Unlike the main config - // plugin, we do NOT emit a dev-SSR import edge here, so a change to the - // icon set is not re-evaluated by the SSR runtime on the fly (it needs - // a manual restart). Icon `` links are skipped on reload anyway - // (see load()'s `reload` flag), so this stays consistent. + // Client-graph invalidation only. No dev-SSR import edge here (unlike + // the main config plugin), so icon-set changes need a manual restart + // under SSR — consistent with icon links being skipped on reload. if (ctx.isDev && ctx.canHmrConfig) { for (const file of ctx.vuetifyFilesToWatch) { this.addWatchFile(file) diff --git a/packages/vuetify-nuxt-module/test/e2e/config-hmr-ssr.spec.ts b/packages/vuetify-nuxt-module/test/e2e/config-hmr-ssr.spec.ts index 39b1823..9fee0b8 100644 --- a/packages/vuetify-nuxt-module/test/e2e/config-hmr-ssr.spec.ts +++ b/packages/vuetify-nuxt-module/test/e2e/config-hmr-ssr.spec.ts @@ -39,13 +39,10 @@ async function fetchWithTimeout (path: string, ms = 4000): Promise { } } -// SCOPE: this guards the no-restart property (via the boot-count probe) and the -// watcher wiring end-to-end. It does NOT guard the SSR vite-node runner cache -// eviction itself: this fixture's SSR graph is small enough that a config edit -// re-renders SSR even without the dev-SSR import-edge mechanism, so the -// assertion can't distinguish the two. That mechanism is verified manually -// against `apps/playground` (see the fix commit). A future maintainer must not -// treat a green run here as proof the eviction path still works. +// Guards the no-restart property (boot-count probe) + watcher wiring. It does +// NOT guard the SSR runner-cache eviction: this fixture's graph is small enough +// to re-render via full-reload regardless, so a green run here is not proof the +// eviction path works — that's verified against `apps/playground`. describe('config-hmr-ssr — dev SSR config hot-reload', () => { let probeFile = '' let originalConfig = '' From 1c07f5e9524bd34cd28d0faa1e8b310c5df30199 Mon Sep 17 00:00:00 2001 From: Andrei Elkin Date: Sun, 14 Jun 2026 17:16:49 +0200 Subject: [PATCH 18/19] refactor: minimize comments to one-liners --- .../vuetify-nuxt-module/src/utils/loader.ts | 23 ++++++------------- .../src/utils/ssr-config-hmr.ts | 9 +++----- .../src/vite/vuetify-configuration-plugin.ts | 12 ++++------ .../vite/vuetify-date-configuration-plugin.ts | 4 +--- .../vuetify-icons-configuration-plugin.ts | 4 +--- .../test/e2e/config-hmr-ssr.spec.ts | 6 ++--- 6 files changed, 18 insertions(+), 40 deletions(-) diff --git a/packages/vuetify-nuxt-module/src/utils/loader.ts b/packages/vuetify-nuxt-module/src/utils/loader.ts index e03af28..f5dbe59 100644 --- a/packages/vuetify-nuxt-module/src/utils/loader.ts +++ b/packages/vuetify-nuxt-module/src/utils/loader.ts @@ -73,9 +73,8 @@ export async function load ( } /* handle old stuff */ - // On reload, leave nuxt.options (css + head links) untouched: re-mutating - // them triggers a Nitro dev:reload and accumulates duplicates. Trade-off: - // icon CDN/local CSS changes then need a manual restart to update . + // On reload, skip nuxt.options mutations (they trigger a Nitro dev:reload and + // accumulate duplicates); icon CDN/CSS changes then need a restart. if (!reload) { const oldIcons = ctx.icons if (oldIcons && oldIcons.cdn?.length && nuxt.options.app.head.link) { @@ -102,7 +101,6 @@ export async function load ( ctx.logger.warn('`theme.defaultTheme: "system"` cannot be resolved during SSR/SSG: the server has no access to the OS color-scheme preference, so the first paint defaults to light and may flash on dark systems. Set explicit dark/light themes and enable `moduleOptions.ssrClientHints.prefersColorScheme` (optionally `prefersColorSchemeOptions.useBrowserThemeOnly`). See the SSR guide.') } - // see note above — skipped on reload if (!reload && ctx.icons.enabled) { if (ctx.icons.local) { for (const css of ctx.icons.local) { @@ -129,8 +127,7 @@ export function registerWatcher (options: VuetifyModuleOptions, nuxt: Nuxt, ctx: return } - // Older Nuxt (Vite 6, !canHmrConfig) can't evict the SSR runner cache, so let - // Nuxt restart the dev server natively when a watched config file changes. + // Older Nuxt (Vite 6) can't evict the SSR runner cache — fall back to restart. if (!ctx.canHmrConfig) { for (const file of ctx.vuetifyFilesToWatch) { nuxt.options.watch.push(file) @@ -151,18 +148,12 @@ export function registerWatcher (options: VuetifyModuleOptions, nuxt: Nuxt, ctx: } clientServer = server - // Watch the config sources on the client server so Nuxt's vite-node feeds - // its `invalidates` set on edit; the runner then cascades the change to the - // virtual module (via the dev-SSR import edge the config plugin emits). + // Feed the config files to vite-node's `invalidates` set on edit. server.watcher.add(ctx.vuetifyFilesToWatch) }) - // Refresh ctx (reload=true skips nuxt.options churn), invalidate the SSR - // transforms, then broadcast a full-reload so an open browser refreshes. - // Awaited before returning from handleHotUpdate so it wins the race against - // the runner draining `invalidates` on the next render. We avoid vite's - // default full-reload escalation (returning [] below): the explicit WS - // broadcast refreshes browsers without stalling plain (curl/SSR) requests. + // Refresh ctx, invalidate the SSR transforms, then full-reload the browser. + // Awaited in handleHotUpdate to win the race against the next render's drain. async function reloadConfig () { await load(options, nuxt, ctx, true) if (ssrServer) { @@ -182,7 +173,7 @@ export function registerWatcher (options: VuetifyModuleOptions, nuxt: Nuxt, ctx: async handleHotUpdate ({ file }) { if (ssrServer && ctx.vuetifyFilesToWatch.includes(file)) { await reloadConfig() - return [] // handled — don't let vite escalate to its own full-reload + return [] // handled; skip vite's own full-reload } }, }) diff --git a/packages/vuetify-nuxt-module/src/utils/ssr-config-hmr.ts b/packages/vuetify-nuxt-module/src/utils/ssr-config-hmr.ts index 9816912..4c89c98 100644 --- a/packages/vuetify-nuxt-module/src/utils/ssr-config-hmr.ts +++ b/packages/vuetify-nuxt-module/src/utils/ssr-config-hmr.ts @@ -1,12 +1,9 @@ import semver from 'semver' /** - * Lowest Nuxt version whose `@nuxt/vite-builder` can hot-update SSR-consumed - * virtual modules in dev (vite-node `invalidates` cascade + the Vite 7 - * Environment API our `this.environment.name === 'ssr'` gate needs). These - * landed in 3.18.0 (3.x line) and 4.0.0 (4.x line); Nuxt 3.15–3.17 are Vite 6 - * and don't qualify. Verified on `apps/playground` at Nuxt 3.21.8 and 4.0.0. - * Below 3.18 we fall back to `callHook('restart')`. + * Lowest Nuxt with the Vite 7 + vite-node SSR invalidation our HMR relies on + * (3.18.0 on 3.x, 4.0.0 on 4.x; Nuxt 3.15–3.17 are Vite 6). Below this we fall + * back to `callHook('restart')`. Verified on apps/playground at 3.21.8 and 4.0.0. */ export const MIN_NUXT_VERSION_FOR_SSR_CONFIG_HMR = '3.18.0' diff --git a/packages/vuetify-nuxt-module/src/vite/vuetify-configuration-plugin.ts b/packages/vuetify-nuxt-module/src/vite/vuetify-configuration-plugin.ts index 26d6619..565e4ac 100644 --- a/packages/vuetify-nuxt-module/src/vite/vuetify-configuration-plugin.ts +++ b/packages/vuetify-nuxt-module/src/vite/vuetify-configuration-plugin.ts @@ -23,9 +23,7 @@ export function vuetifyConfigurationPlugin (ctx: VuetifyNuxtContext) { }, async load (id) { if (id === RESOLVED_VIRTUAL_VUETIFY_CONFIGURATION) { - // Invalidate this module on the client graph when a config file - // changes. NOTE: keep the dev-SSR import edge below too — it (not this) - // is what evicts the vite-node SSR runner cache. + // Client-graph invalidation (the dev-SSR import edge below handles SSR). if (ctx.isDev && ctx.canHmrConfig) { for (const file of ctx.vuetifyFilesToWatch) { this.addWatchFile(file) @@ -55,11 +53,9 @@ export function vuetifyConfigurationPlugin (ctx: VuetifyNuxtContext) { const result = await buildConfiguration(ctx) const deepCopy = result.messages.length > 0 - // Dev SSR only: emit a side-effect import of the config sources so the - // vite-node runner records a config-file -> virtual-module edge and - // re-evaluates this module (with the reloaded ctx) when the file - // changes — no dev-server restart. Gated to `ssr` so the raw config is - // never bundled/executed on the client; the imported value is unused. + // Dev SSR only: a side-effect import gives vite-node a config-file -> + // module edge so an edit re-evaluates this module (no restart). Gated to + // `ssr` so the raw config never reaches the client; the value is unused. let configDepImports = '' if (ctx.isDev && ctx.canHmrConfig && this.environment?.name === 'ssr') { configDepImports = ctx.vuetifyFilesToWatch diff --git a/packages/vuetify-nuxt-module/src/vite/vuetify-date-configuration-plugin.ts b/packages/vuetify-nuxt-module/src/vite/vuetify-date-configuration-plugin.ts index 3a20527..ce73347 100644 --- a/packages/vuetify-nuxt-module/src/vite/vuetify-date-configuration-plugin.ts +++ b/packages/vuetify-nuxt-module/src/vite/vuetify-date-configuration-plugin.ts @@ -14,9 +14,7 @@ export function vuetifyDateConfigurationPlugin (ctx: VuetifyNuxtContext) { }, async load (id) { if (id === RESOLVED_VIRTUAL_VUETIFY_DATE_CONFIGURATION) { - // Client-graph invalidation only. No dev-SSR import edge here (unlike - // the main config plugin), so date-adapter changes need a manual - // restart under SSR. + // Client-graph only; no dev-SSR edge, so adapter changes need a restart. if (ctx.isDev && ctx.canHmrConfig) { for (const file of ctx.vuetifyFilesToWatch) { this.addWatchFile(file) diff --git a/packages/vuetify-nuxt-module/src/vite/vuetify-icons-configuration-plugin.ts b/packages/vuetify-nuxt-module/src/vite/vuetify-icons-configuration-plugin.ts index 4f4c165..d0ccc4a 100644 --- a/packages/vuetify-nuxt-module/src/vite/vuetify-icons-configuration-plugin.ts +++ b/packages/vuetify-nuxt-module/src/vite/vuetify-icons-configuration-plugin.ts @@ -16,9 +16,7 @@ export function vuetifyIconsPlugin (ctx: VuetifyNuxtContext) { }, async load (id) { if (id === RESOLVED_VIRTUAL_VUETIFY_ICONS_CONFIGURATION) { - // Client-graph invalidation only. No dev-SSR import edge here (unlike - // the main config plugin), so icon-set changes need a manual restart - // under SSR — consistent with icon links being skipped on reload. + // Client-graph only; no dev-SSR edge, so icon-set changes need a restart. if (ctx.isDev && ctx.canHmrConfig) { for (const file of ctx.vuetifyFilesToWatch) { this.addWatchFile(file) diff --git a/packages/vuetify-nuxt-module/test/e2e/config-hmr-ssr.spec.ts b/packages/vuetify-nuxt-module/test/e2e/config-hmr-ssr.spec.ts index 9fee0b8..a76abb8 100644 --- a/packages/vuetify-nuxt-module/test/e2e/config-hmr-ssr.spec.ts +++ b/packages/vuetify-nuxt-module/test/e2e/config-hmr-ssr.spec.ts @@ -39,10 +39,8 @@ async function fetchWithTimeout (path: string, ms = 4000): Promise { } } -// Guards the no-restart property (boot-count probe) + watcher wiring. It does -// NOT guard the SSR runner-cache eviction: this fixture's graph is small enough -// to re-render via full-reload regardless, so a green run here is not proof the -// eviction path works — that's verified against `apps/playground`. +// Guards no-restart + watcher wiring, NOT the SSR runner-cache eviction (this +// small graph re-renders regardless); the eviction is verified on apps/playground. describe('config-hmr-ssr — dev SSR config hot-reload', () => { let probeFile = '' let originalConfig = '' From 7feb7641a290266d0520f1b2fb87f65b528f357c Mon Sep 17 00:00:00 2001 From: Andrei Elkin Date: Sun, 14 Jun 2026 17:55:44 +0200 Subject: [PATCH 19/19] fix(dev): capture SSR module graph independent of experimental.viteEnvironmentApi The previous code only captured the dedicated SSR dev server (flag off). With experimental.viteEnvironmentApi enabled there's a single server and that event never fires, so SSR config edits silently went stale. Capture the SSR graph from server.environments.ssr in that case too. Verified on apps/playground with the flag both off and on (config edit hot-reloaded in ~1s, no restart). --- .../vuetify-nuxt-module/src/utils/loader.ts | 38 +++++++++++++------ 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/packages/vuetify-nuxt-module/src/utils/loader.ts b/packages/vuetify-nuxt-module/src/utils/loader.ts index f5dbe59..4a6175b 100644 --- a/packages/vuetify-nuxt-module/src/utils/loader.ts +++ b/packages/vuetify-nuxt-module/src/utils/loader.ts @@ -122,6 +122,22 @@ export async function load ( } } +// Returns a fn that invalidates our virtual config modules on the given graph +// (the legacy ModuleGraph or a Vite Environment's EnvironmentModuleGraph). +function bindInvalidator (graph: { + getModuleById: (id: string) => M | null | undefined + invalidateModule: (mod: M) => void +}) { + return () => { + for (const id of RESOLVED_VIRTUAL_MODULES) { + const mod = graph.getModuleById(id) + if (mod) { + graph.invalidateModule(mod) + } + } + } +} + export function registerWatcher (options: VuetifyModuleOptions, nuxt: Nuxt, ctx: VuetifyNuxtContext) { if (!nuxt.options.dev) { return @@ -135,12 +151,15 @@ export function registerWatcher (options: VuetifyModuleOptions, nuxt: Nuxt, ctx: return } - let ssrServer: ViteDevServer | undefined let clientServer: ViteDevServer | undefined + let invalidateSsrModules: (() => void) | undefined nuxt.hook('vite:serverCreated', (server, { isClient }) => { + // Capture the SSR module graph regardless of `experimental.viteEnvironmentApi`: + // a dedicated SSR dev server when it's off, else the single server's `ssr` + // environment (which only emits an isClient event). if (!isClient) { - ssrServer = server + invalidateSsrModules = bindInvalidator(server.moduleGraph) return } if (!server.ws) { @@ -148,6 +167,10 @@ export function registerWatcher (options: VuetifyModuleOptions, nuxt: Nuxt, ctx: } clientServer = server + const ssrEnv = server.environments?.ssr + if (ssrEnv) { + invalidateSsrModules ??= bindInvalidator(ssrEnv.moduleGraph) + } // Feed the config files to vite-node's `invalidates` set on edit. server.watcher.add(ctx.vuetifyFilesToWatch) }) @@ -156,14 +179,7 @@ export function registerWatcher (options: VuetifyModuleOptions, nuxt: Nuxt, ctx: // Awaited in handleHotUpdate to win the race against the next render's drain. async function reloadConfig () { await load(options, nuxt, ctx, true) - if (ssrServer) { - for (const id of RESOLVED_VIRTUAL_MODULES) { - const mod = ssrServer.moduleGraph.getModuleById(id) - if (mod) { - ssrServer.moduleGraph.invalidateModule(mod) - } - } - } + invalidateSsrModules?.() clientServer?.ws.send({ type: 'full-reload' }) } @@ -171,7 +187,7 @@ export function registerWatcher (options: VuetifyModuleOptions, nuxt: Nuxt, ctx: name: 'vuetify:configuration:watch', enforce: 'pre', async handleHotUpdate ({ file }) { - if (ssrServer && ctx.vuetifyFilesToWatch.includes(file)) { + if (clientServer && ctx.vuetifyFilesToWatch.includes(file)) { await reloadConfig() return [] // handled; skip vite's own full-reload }