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 @@ + + + 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) + }) +})