diff --git a/docs/guide/configuration/vuetify-options.md b/docs/guide/configuration/vuetify-options.md
index 783dcf2d..96d9464d 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 `>= 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.
-With SSR and external configuration, the Nuxt dev server restarts due to lack of server-side HMR support in Nuxt.
+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/module.ts b/packages/vuetify-nuxt-module/src/module.ts
index 91556b86..c46aea33 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 497a9f7d..79e85d04 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
diff --git a/packages/vuetify-nuxt-module/src/utils/layers.ts b/packages/vuetify-nuxt-module/src/utils/layers.ts
index c6317aaa..ce953f65 100644
--- a/packages/vuetify-nuxt-module/src/utils/layers.ts
+++ b/packages/vuetify-nuxt-module/src/utils/layers.ts
@@ -76,17 +76,10 @@ 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) {
- for (const s of resolvedOptions.sources) {
- nuxt.options.watch.push(s.replace(/\\/g, '/'))
- }
- } else {
- for (const s of resolvedOptions.sources) {
- vuetifyConfigurationFilesToWatch.add(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 9928a864..4a6175bf 100644
--- a/packages/vuetify-nuxt-module/src/utils/loader.ts
+++ b/packages/vuetify-nuxt-module/src/utils/loader.ts
@@ -1,11 +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 { relative, resolve } from 'pathe'
-import { debounce } from 'perfect-debounce'
import { RESOLVED_VIRTUAL_MODULES } from '../vite/constants'
import { prepareIcons } from './icons'
import { mergeVuetifyModules } from './layers'
@@ -16,6 +14,7 @@ export async function load (
options: VuetifyModuleOptions,
nuxt: Nuxt,
ctx: VuetifyNuxtContext,
+ reload = false,
) {
const {
configuration,
@@ -74,9 +73,13 @@ 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))
+ // 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) {
+ 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)
@@ -119,48 +122,75 @@ 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) {
- 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')
- }
- })
+ // 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)
+ }
+ return
+ }
- nuxt.hook('vite:serverCreated', (server, { isClient }) => {
- if (!server.ws || !isClient) {
- return
- }
+ let clientServer: ViteDevServer | undefined
+ let invalidateSsrModules: (() => void) | 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.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) {
+ invalidateSsrModules = bindInvalidator(server.moduleGraph)
+ return
+ }
+ if (!server.ws) {
+ return
+ }
+
+ 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)
+ })
+
+ // 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)
+ invalidateSsrModules?.()
+ clientServer?.ws.send({ type: 'full-reload' })
}
+
+ addVitePlugin({
+ name: 'vuetify:configuration:watch',
+ enforce: 'pre',
+ async handleHotUpdate ({ file }) {
+ if (clientServer && ctx.vuetifyFilesToWatch.includes(file)) {
+ await reloadConfig()
+ 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
new file mode 100644
index 00000000..4c89c989
--- /dev/null
+++ b/packages/vuetify-nuxt-module/src/utils/ssr-config-hmr.ts
@@ -0,0 +1,21 @@
+import semver from 'semver'
+
+/**
+ * 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'
+
+/**
+ * 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/src/vite/vuetify-configuration-plugin.ts b/packages/vuetify-nuxt-module/src/vite/vuetify-configuration-plugin.ts
index 3231a35d..565e4ace 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,12 @@ export function vuetifyConfigurationPlugin (ctx: VuetifyNuxtContext) {
},
async load (id) {
if (id === RESOLVED_VIRTUAL_VUETIFY_CONFIGURATION) {
+ // 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)
+ }
+ }
const {
directives: _directives,
date: _date,
@@ -47,7 +53,18 @@ export function vuetifyConfigurationPlugin (ctx: VuetifyNuxtContext) {
const result = await buildConfiguration(ctx)
const deepCopy = result.messages.length > 0
- return `${result.imports}
+ // 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
+ .map(file => `import ${JSON.stringify(file)}`)
+ .join('\n')
+ }
+
+ return `${configDepImports}
+${result.imports}
export const isDev = ${ctx.isDev}
export function vuetifyConfiguration() {
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 c349c217..ce733475 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,12 @@ export function vuetifyDateConfigurationPlugin (ctx: VuetifyNuxtContext) {
},
async load (id) {
if (id === RESOLVED_VIRTUAL_VUETIFY_DATE_CONFIGURATION) {
+ // 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)
+ }
+ }
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 1d1e7e62..d0ccc4a5 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,12 @@ export function vuetifyIconsPlugin (ctx: VuetifyNuxtContext) {
},
async load (id) {
if (id === RESOLVED_VIRTUAL_VUETIFY_ICONS_CONFIGURATION) {
+ // 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)
+ }
+ }
const {
enabled,
unocss,
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 00000000..a76abb89
--- /dev/null
+++ b/packages/vuetify-nuxt-module/test/e2e/config-hmr-ssr.spec.ts
@@ -0,0 +1,118 @@
+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')
+
+// `$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 {
+ 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)
+ }
+ }
+}
+
+// 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 = ''
+
+ 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 }
+ // 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)
+
+ 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 () => {
+ try {
+ const before = await fetchWithTimeout('/')
+ 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 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)
+ } 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)
+})
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 00000000..a07b95ae
--- /dev/null
+++ b/packages/vuetify-nuxt-module/test/fixtures/config-hmr-ssr/app.vue
@@ -0,0 +1,13 @@
+
+
+
+
+
+ {{ theme.current.value.colors.primary }}
+
+
+
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 00000000..7e1e1281
--- /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 00000000..6e708af0
--- /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 00000000..e3503d0c
--- /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 00000000..98ff8db8
--- /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' },
+ },
+ },
+ },
+})
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 00000000..663b359f
--- /dev/null
+++ b/packages/vuetify-nuxt-module/test/ssr-config-hmr.test.ts
@@ -0,0 +1,28 @@
+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('3.18.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.3.1')).toBe(true)
+ expect(supportsSsrConfigHmr('5.0.0')).toBe(true)
+ expect(supportsSsrConfigHmr('v4.3.2')).toBe(true)
+ })
+
+ it('rejects Nuxt 3.15-3.17 and older (Vite 6, restart fallback)', () => {
+ expect(supportsSsrConfigHmr('3.17.9')).toBe(false)
+ expect(supportsSsrConfigHmr('3.15.0')).toBe(false)
+ expect(supportsSsrConfigHmr('3.18.0-rc.1')).toBe(false)
+ })
+
+ it('rejects unparseable versions', () => {
+ expect(supportsSsrConfigHmr('')).toBe(false)
+ expect(supportsSsrConfigHmr('not-a-version')).toBe(false)
+ })
+})