Skip to content

Commit b07d647

Browse files
authored
feat(dev): hot-reload Vuetify config under SSR without a dev-server restart (#380)
* feat(dev): add supportsSsrConfigHmr capability gate * test(e2e): prove SSR config edit currently forces a dev-server restart * feat(dev): compute canHmrConfig capability flag on context * feat(dev): bind config sources to virtual modules via addWatchFile * feat(dev): collect config sources for HMR when supported, restart-watch otherwise * feat(dev): hot-reload SSR config without dev-server restart on supported Nuxt * fix(dev): skip nuxt.options mutations on config reload to avoid nitro reload * test(e2e): harden config-hmr-ssr against reload-window races and pollution * refactor(dev): unify config watch-routing in registerWatcher, drop dead builder:watch restart * test(e2e): clear fetch-timeout timer to avoid leaked handles * docs: SSR config changes hot-reload without dev-server restart (Nuxt >= 4.3) * 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. * fix(dev): broadcast full-reload on config edit so the open browser auto-refreshes * 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. * feat(dev): lower SSR config-HMR floor to Nuxt 4.0.0 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. * feat(dev): lower SSR config-HMR floor to Nuxt 3.18.0 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. * refactor: trim verbose comments to concise why-notes * refactor: minimize comments to one-liners * 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).
1 parent dfe5234 commit b07d647

16 files changed

Lines changed: 345 additions & 59 deletions

docs/guide/configuration/vuetify-options.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@ Support for Nuxt Layers is also available; the module scans for `vuetify.config`
77
During development, the module monitors Vuetify configuration files, focusing on those outside `node_modules`.
88

99
::: warning CAVEATS
10-
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.
10+
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.
1111

12-
With SSR and external configuration, the Nuxt dev server restarts due to lack of server-side HMR support in Nuxt.
12+
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).
1313
:::
1414

1515
For example, you can configure:

packages/vuetify-nuxt-module/src/module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { version } from '../package.json'
2525
import { configureNuxt } from './utils/configure-nuxt'
2626
import { configureVite } from './utils/configure-vite'
2727
import { load, registerWatcher } from './utils/loader'
28+
import { supportsSsrConfigHmr } from './utils/ssr-config-hmr'
2829

2930
export * from './types'
3031

@@ -134,6 +135,7 @@ export default defineNuxtModule<ModuleOptions>({
134135
moduleOptions: undefined!,
135136
vuetifyOptions: undefined!,
136137
vuetifyFilesToWatch: [],
138+
canHmrConfig: !nuxt.options.ssr || supportsSsrConfigHmr(getNuxtVersion(nuxt)),
137139
isSSR: nuxt.options.ssr,
138140
isDev: nuxt.options.dev,
139141
isNuxtGenerate: !!nuxt.options.nitro.static,

packages/vuetify-nuxt-module/src/utils/config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@ export interface VuetifyNuxtContext {
1515
moduleOptions: MOptions
1616
vuetifyOptions: VOptions
1717
vuetifyFilesToWatch: string[]
18+
/**
19+
* Whether config-file changes can be hot-reloaded in dev without a full
20+
* `nuxt.callHook('restart')`. True for SPA always, and for SSR on Nuxt
21+
* versions whose vite-node evicts SSR-consumed virtual modules from its
22+
* runner cache (see {@link supportsSsrConfigHmr}). Set once at module setup.
23+
*/
24+
canHmrConfig: boolean
1825
isDev: boolean
1926
i18n: boolean
2027
isSSR: boolean

packages/vuetify-nuxt-module/src/utils/layers.ts

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -76,17 +76,10 @@ export async function mergeVuetifyModules (options: VuetifyModuleOptions, nuxt:
7676
options.vuetifyOptions,
7777
)
7878

79-
// handle vuetify configuraton files changes only in dev mode
79+
// handle vuetify configuration files changes only in dev mode
8080
if (nuxt.options.dev && resolvedOptions.sources.length > 0) {
81-
// we need to restart nuxt dev server when SSR is enabled: vite-node doesn't support HMR in server yet
82-
if (nuxt.options.ssr) {
83-
for (const s of resolvedOptions.sources) {
84-
nuxt.options.watch.push(s.replace(/\\/g, '/'))
85-
}
86-
} else {
87-
for (const s of resolvedOptions.sources) {
88-
vuetifyConfigurationFilesToWatch.add(s.replace(/\\/g, '/'))
89-
}
81+
for (const s of resolvedOptions.sources) {
82+
vuetifyConfigurationFilesToWatch.add(s.replace(/\\/g, '/'))
9083
}
9184
}
9285

packages/vuetify-nuxt-module/src/utils/loader.ts

Lines changed: 76 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
import type { Nuxt } from '@nuxt/schema'
2-
import type { ModuleNode } from 'vite'
2+
import type { ViteDevServer } from 'vite'
33
import type { VOptions, VuetifyModuleOptions } from '../types'
44
import type { VuetifyNuxtContext } from './config'
55
import { addVitePlugin } from '@nuxt/kit'
66
import defu from 'defu'
7-
import { relative, resolve } from 'pathe'
8-
import { debounce } from 'perfect-debounce'
97
import { RESOLVED_VIRTUAL_MODULES } from '../vite/constants'
108
import { prepareIcons } from './icons'
119
import { mergeVuetifyModules } from './layers'
@@ -16,6 +14,7 @@ export async function load (
1614
options: VuetifyModuleOptions,
1715
nuxt: Nuxt,
1816
ctx: VuetifyNuxtContext,
17+
reload = false,
1918
) {
2019
const {
2120
configuration,
@@ -74,9 +73,13 @@ export async function load (
7473
}
7574

7675
/* handle old stuff */
77-
const oldIcons = ctx.icons
78-
if (oldIcons && oldIcons.cdn?.length && nuxt.options.app.head.link) {
79-
nuxt.options.app.head.link = nuxt.options.app.head.link.filter(link => !link.key || !oldIcons.cdn.some(([key]) => link.key === key))
76+
// On reload, skip nuxt.options mutations (they trigger a Nitro dev:reload and
77+
// accumulate duplicates); icon CDN/CSS <head> changes then need a restart.
78+
if (!reload) {
79+
const oldIcons = ctx.icons
80+
if (oldIcons && oldIcons.cdn?.length && nuxt.options.app.head.link) {
81+
nuxt.options.app.head.link = nuxt.options.app.head.link.filter(link => !link.key || !oldIcons.cdn.some(([key]) => link.key === key))
82+
}
8083
}
8184

8285
/* handle new stuff */
@@ -98,7 +101,7 @@ export async function load (
98101
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.')
99102
}
100103

101-
if (ctx.icons.enabled) {
104+
if (!reload && ctx.icons.enabled) {
102105
if (ctx.icons.local) {
103106
for (const css of ctx.icons.local) {
104107
nuxt.options.css.push(css)
@@ -119,48 +122,75 @@ export async function load (
119122
}
120123
}
121124

125+
// Returns a fn that invalidates our virtual config modules on the given graph
126+
// (the legacy ModuleGraph or a Vite Environment's EnvironmentModuleGraph).
127+
function bindInvalidator<M> (graph: {
128+
getModuleById: (id: string) => M | null | undefined
129+
invalidateModule: (mod: M) => void
130+
}) {
131+
return () => {
132+
for (const id of RESOLVED_VIRTUAL_MODULES) {
133+
const mod = graph.getModuleById(id)
134+
if (mod) {
135+
graph.invalidateModule(mod)
136+
}
137+
}
138+
}
139+
}
140+
122141
export function registerWatcher (options: VuetifyModuleOptions, nuxt: Nuxt, ctx: VuetifyNuxtContext) {
123-
if (nuxt.options.dev) {
124-
let pageReload: (() => Promise<void>) | undefined
142+
if (!nuxt.options.dev) {
143+
return
144+
}
125145

126-
nuxt.hooks.hook('builder:watch', (_event, path) => {
127-
path = relative(nuxt.options.srcDir, resolve(nuxt.options.srcDir, path))
128-
if (!pageReload && ctx.vuetifyFilesToWatch.includes(path)) {
129-
return nuxt.callHook('restart')
130-
}
131-
})
146+
// Older Nuxt (Vite 6) can't evict the SSR runner cache — fall back to restart.
147+
if (!ctx.canHmrConfig) {
148+
for (const file of ctx.vuetifyFilesToWatch) {
149+
nuxt.options.watch.push(file)
150+
}
151+
return
152+
}
132153

133-
nuxt.hook('vite:serverCreated', (server, { isClient }) => {
134-
if (!server.ws || !isClient) {
135-
return
136-
}
154+
let clientServer: ViteDevServer | undefined
155+
let invalidateSsrModules: (() => void) | undefined
137156

138-
pageReload = debounce(async () => {
139-
const modules: ModuleNode[] = []
140-
for (const v of RESOLVED_VIRTUAL_MODULES) {
141-
const module = server.moduleGraph.getModuleById(v)
142-
if (module) {
143-
modules.push(module)
144-
}
145-
}
146-
// reload configuration always
147-
await load(options, nuxt, ctx)
148-
// TODO: try to change the logic here with custom event and using the moduleGraph + client invalidation
149-
// server.reloadModule will send at least 2 or 3 full page reloads in a row: it is better than server restart
150-
if (modules.length > 0) {
151-
await Promise.all(modules.map(m => server.reloadModule(m)))
152-
}
153-
}, 50, { trailing: false })
154-
})
155-
156-
addVitePlugin({
157-
name: 'vuetify:configuration:watch',
158-
enforce: 'pre',
159-
handleHotUpdate ({ file }) {
160-
if (pageReload && ctx.vuetifyFilesToWatch.includes(file)) {
161-
return pageReload()
162-
}
163-
},
164-
})
157+
nuxt.hook('vite:serverCreated', (server, { isClient }) => {
158+
// Capture the SSR module graph regardless of `experimental.viteEnvironmentApi`:
159+
// a dedicated SSR dev server when it's off, else the single server's `ssr`
160+
// environment (which only emits an isClient event).
161+
if (!isClient) {
162+
invalidateSsrModules = bindInvalidator(server.moduleGraph)
163+
return
164+
}
165+
if (!server.ws) {
166+
return
167+
}
168+
169+
clientServer = server
170+
const ssrEnv = server.environments?.ssr
171+
if (ssrEnv) {
172+
invalidateSsrModules ??= bindInvalidator(ssrEnv.moduleGraph)
173+
}
174+
// Feed the config files to vite-node's `invalidates` set on edit.
175+
server.watcher.add(ctx.vuetifyFilesToWatch)
176+
})
177+
178+
// Refresh ctx, invalidate the SSR transforms, then full-reload the browser.
179+
// Awaited in handleHotUpdate to win the race against the next render's drain.
180+
async function reloadConfig () {
181+
await load(options, nuxt, ctx, true)
182+
invalidateSsrModules?.()
183+
clientServer?.ws.send({ type: 'full-reload' })
165184
}
185+
186+
addVitePlugin({
187+
name: 'vuetify:configuration:watch',
188+
enforce: 'pre',
189+
async handleHotUpdate ({ file }) {
190+
if (clientServer && ctx.vuetifyFilesToWatch.includes(file)) {
191+
await reloadConfig()
192+
return [] // handled; skip vite's own full-reload
193+
}
194+
},
195+
})
166196
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import semver from 'semver'
2+
3+
/**
4+
* Lowest Nuxt with the Vite 7 + vite-node SSR invalidation our HMR relies on
5+
* (3.18.0 on 3.x, 4.0.0 on 4.x; Nuxt 3.15–3.17 are Vite 6). Below this we fall
6+
* back to `callHook('restart')`. Verified on apps/playground at 3.21.8 and 4.0.0.
7+
*/
8+
export const MIN_NUXT_VERSION_FOR_SSR_CONFIG_HMR = '3.18.0'
9+
10+
/**
11+
* Whether the installed Nuxt can hot-update SSR-consumed virtual config
12+
* modules without a dev-server restart.
13+
*/
14+
export function supportsSsrConfigHmr (nuxtVersion: string): boolean {
15+
// Prefer an exact parse (keeps prerelease semantics: 4.3.0-rc.1 < 4.3.0).
16+
const parsed = semver.parse(nuxtVersion) ?? semver.coerce(nuxtVersion)
17+
if (!parsed) {
18+
return false
19+
}
20+
return semver.gte(parsed.version, MIN_NUXT_VERSION_FOR_SSR_CONFIG_HMR)
21+
}

packages/vuetify-nuxt-module/src/vite/vuetify-configuration-plugin.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ export function vuetifyConfigurationPlugin (ctx: VuetifyNuxtContext) {
2323
},
2424
async load (id) {
2525
if (id === RESOLVED_VIRTUAL_VUETIFY_CONFIGURATION) {
26+
// Client-graph invalidation (the dev-SSR import edge below handles SSR).
27+
if (ctx.isDev && ctx.canHmrConfig) {
28+
for (const file of ctx.vuetifyFilesToWatch) {
29+
this.addWatchFile(file)
30+
}
31+
}
2632
const {
2733
directives: _directives,
2834
date: _date,
@@ -47,7 +53,18 @@ export function vuetifyConfigurationPlugin (ctx: VuetifyNuxtContext) {
4753
const result = await buildConfiguration(ctx)
4854
const deepCopy = result.messages.length > 0
4955

50-
return `${result.imports}
56+
// Dev SSR only: a side-effect import gives vite-node a config-file ->
57+
// module edge so an edit re-evaluates this module (no restart). Gated to
58+
// `ssr` so the raw config never reaches the client; the value is unused.
59+
let configDepImports = ''
60+
if (ctx.isDev && ctx.canHmrConfig && this.environment?.name === 'ssr') {
61+
configDepImports = ctx.vuetifyFilesToWatch
62+
.map(file => `import ${JSON.stringify(file)}`)
63+
.join('\n')
64+
}
65+
66+
return `${configDepImports}
67+
${result.imports}
5168
5269
export const isDev = ${ctx.isDev}
5370
export function vuetifyConfiguration() {

packages/vuetify-nuxt-module/src/vite/vuetify-date-configuration-plugin.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ export function vuetifyDateConfigurationPlugin (ctx: VuetifyNuxtContext) {
1414
},
1515
async load (id) {
1616
if (id === RESOLVED_VIRTUAL_VUETIFY_DATE_CONFIGURATION) {
17+
// Client-graph only; no dev-SSR edge, so adapter changes need a restart.
18+
if (ctx.isDev && ctx.canHmrConfig) {
19+
for (const file of ctx.vuetifyFilesToWatch) {
20+
this.addWatchFile(file)
21+
}
22+
}
1723
if (!ctx.dateAdapter) {
1824
return `
1925
export const enabled = false

packages/vuetify-nuxt-module/src/vite/vuetify-icons-configuration-plugin.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ export function vuetifyIconsPlugin (ctx: VuetifyNuxtContext) {
1616
},
1717
async load (id) {
1818
if (id === RESOLVED_VIRTUAL_VUETIFY_ICONS_CONFIGURATION) {
19+
// Client-graph only; no dev-SSR edge, so icon-set changes need a restart.
20+
if (ctx.isDev && ctx.canHmrConfig) {
21+
for (const file of ctx.vuetifyFilesToWatch) {
22+
this.addWatchFile(file)
23+
}
24+
}
1925
const {
2026
enabled,
2127
unocss,

0 commit comments

Comments
 (0)