Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
bf36824
feat(dev): add supportsSsrConfigHmr capability gate
AndreyYolkin Jun 14, 2026
6a9822b
test(e2e): prove SSR config edit currently forces a dev-server restart
AndreyYolkin Jun 14, 2026
6c84da2
feat(dev): compute canHmrConfig capability flag on context
AndreyYolkin Jun 14, 2026
bae750f
feat(dev): bind config sources to virtual modules via addWatchFile
AndreyYolkin Jun 14, 2026
1c35d32
feat(dev): collect config sources for HMR when supported, restart-wat…
AndreyYolkin Jun 14, 2026
46e75ae
feat(dev): hot-reload SSR config without dev-server restart on suppor…
AndreyYolkin Jun 14, 2026
0274da9
fix(dev): skip nuxt.options mutations on config reload to avoid nitro…
AndreyYolkin Jun 14, 2026
4c3284b
test(e2e): harden config-hmr-ssr against reload-window races and poll…
AndreyYolkin Jun 14, 2026
94c6735
refactor(dev): unify config watch-routing in registerWatcher, drop de…
AndreyYolkin Jun 14, 2026
e63e24f
test(e2e): clear fetch-timeout timer to avoid leaked handles
AndreyYolkin Jun 14, 2026
f606b41
docs: SSR config changes hot-reload without dev-server restart (Nuxt …
AndreyYolkin Jun 14, 2026
b5b5c1b
fix(dev): force SSR vite-node cache eviction so real apps hot-reload …
AndreyYolkin Jun 14, 2026
1ce85ee
fix(dev): broadcast full-reload on config edit so the open browser au…
AndreyYolkin Jun 14, 2026
eb81f10
docs+comments: document icon-set/date-adapter SSR HMR limits and test…
AndreyYolkin Jun 14, 2026
c198c4c
feat(dev): lower SSR config-HMR floor to Nuxt 4.0.0
AndreyYolkin Jun 14, 2026
7ed8d60
feat(dev): lower SSR config-HMR floor to Nuxt 3.18.0
AndreyYolkin Jun 14, 2026
b99b144
refactor: trim verbose comments to concise why-notes
AndreyYolkin Jun 14, 2026
1c07f5e
refactor: minimize comments to one-liners
AndreyYolkin Jun 14, 2026
7feb764
fix(dev): capture SSR module graph independent of experimental.viteEn…
AndreyYolkin Jun 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/guide/configuration/vuetify-options.md
Original file line number Diff line number Diff line change
Expand Up @@ -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** `<head>` 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:
Expand Down
2 changes: 2 additions & 0 deletions packages/vuetify-nuxt-module/src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -134,6 +135,7 @@ export default defineNuxtModule<ModuleOptions>({
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,
Expand Down
7 changes: 7 additions & 0 deletions packages/vuetify-nuxt-module/src/utils/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 3 additions & 10 deletions packages/vuetify-nuxt-module/src/utils/layers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, '/'))
}
}

Expand Down
122 changes: 76 additions & 46 deletions packages/vuetify-nuxt-module/src/utils/loader.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -16,6 +14,7 @@ export async function load (
options: VuetifyModuleOptions,
nuxt: Nuxt,
ctx: VuetifyNuxtContext,
reload = false,
) {
const {
configuration,
Expand Down Expand Up @@ -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 <head> 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 */
Expand All @@ -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)
Expand All @@ -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<M> (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<void>) | 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
}
},
})
}
21 changes: 21 additions & 0 deletions packages/vuetify-nuxt-module/src/utils/ssr-config-hmr.ts
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading