diff --git a/packages/nuxt-cli/src/dev/reason.ts b/packages/nuxt-cli/src/dev/reason.ts index b7409dc0..89acca13 100644 --- a/packages/nuxt-cli/src/dev/reason.ts +++ b/packages/nuxt-cli/src/dev/reason.ts @@ -33,13 +33,22 @@ export function mergeRestartReasons(previous: DevRestartReason | undefined, next } } +/** + * Record which `nuxt.config` keys changed on a config-triggered reload. Keys are + * sorted so the printed line does not reorder when config keys are moved around. + */ +export function withConfigKeys(reason: DevRestartReason | undefined, keys: string[] | undefined): DevRestartReason | undefined { + if (reason?.type !== 'config' || !keys?.length) { + return reason + } + return { ...reason, keys: [...keys].sort() } +} + /** Describe what caused a reload or restart, without saying what happens next. */ export function formatRestartCause(reason: DevRestartReason, options: FormatRestartReasonOptions): string { switch (reason.type) { - case 'config': { - const keys = reason.keys?.length ? ` (${reason.keys.join(', ')})` : '' - return `${formatFileList(reason.files, options)} changed${keys}` - } + case 'config': + return `${formatFileList(reason.files, options)} changed` case 'dist-removed': return 'Build output was removed' case 'hook': @@ -51,6 +60,19 @@ export function formatRestartCause(reason: DevRestartReason, options: FormatRest } } +/** One line saying a config file was written without changing the resolved config. */ +export function formatSkippedReload(reason: Extract, options: FormatRestartReasonOptions): string { + return `${formatFileList(reason.files, options)} saved without changing config.` +} + +/** Follow-up line naming the config keys a reload picked up. */ +export function formatChangedKeys(keys: string[]): string | undefined { + if (!keys.length) { + return undefined + } + return `${formatTruncatedList(keys.map(key => `\`${key}\``), 'key')} updated` +} + /** One line saying what caused a reload or restart and what is happening as a result. */ export function formatRestartReason(reason: DevRestartReason | undefined, options: FormatRestartReasonOptions): string { const action = options.hard ? 'Restarting Nuxt in a new process' : 'Reloading Nuxt' @@ -60,16 +82,20 @@ export function formatRestartReason(reason: DevRestartReason | undefined, option return `${formatRestartCause(reason, options)}. ${action}...` } -const MAX_LISTED_FILES = 2 +const MAX_LISTED_ITEMS = 2 function formatFileList(files: string[], options: FormatRestartReasonOptions): string { const names = files.map(file => options.link === false ? relativeTo(options.rootDir, file, { link: false }) : relativeTo(options.rootDir, file)) if (names.length === 0) { return 'A file' } - if (names.length <= MAX_LISTED_FILES) { - return names.join(' and ') + return formatTruncatedList(names, 'file') +} + +function formatTruncatedList(items: string[], noun: string): string { + if (items.length <= MAX_LISTED_ITEMS) { + return items.join(' and ') } - const remaining = names.length - MAX_LISTED_FILES - return `${names.slice(0, MAX_LISTED_FILES).join(', ')} and ${remaining} other file${remaining === 1 ? '' : 's'}` + const remaining = items.length - MAX_LISTED_ITEMS + return `${items.slice(0, MAX_LISTED_ITEMS).join(', ')} and ${remaining} other ${noun}${remaining === 1 ? '' : 's'}` } diff --git a/packages/nuxt-cli/src/dev/utils.ts b/packages/nuxt-cli/src/dev/utils.ts index 99004fa8..fa8ffdb4 100644 --- a/packages/nuxt-cli/src/dev/utils.ts +++ b/packages/nuxt-cli/src/dev/utils.ts @@ -19,6 +19,7 @@ import { resolveModulePath } from 'exsolve' import { toNodeListener } from 'h3' import { join, resolve } from 'pathe' import { debounce } from 'perfect-debounce' +import colors from 'picocolors' import { toNodeHandler } from 'srvx/node' import { provider } from 'std-env' @@ -27,12 +28,13 @@ import { showBanner } from '../utils/banner' import { clearBuildDir } from '../utils/fs' import { loadKit } from '../utils/kit' import { acquireLock, formatLockError, updateLock } from '../utils/lockfile' +import { debug } from '../utils/logger' import { loadNuxtManifest, resolveNuxtManifest, writeNuxtManifest } from '../utils/nuxt' import { withNodePath } from '../utils/paths' import { renderError, renderErrorAnsi } from './error' import { listen } from './listen' import { resolvePortlessURLs } from './portless' -import { formatRestartReason, mergeRestartReasons } from './reason' +import { formatChangedKeys, formatRestartReason, formatSkippedReload, mergeRestartReasons, withConfigKeys } from './reason' export type NuxtParentIPCMessage = | { type: 'nuxt:internal:dev:context', context: NuxtDevContext, listenOverrides: DevListenOverrides, inspect?: InspectOptions } @@ -150,6 +152,25 @@ export function attachViteHmrServer(server: ViteServerOptions, hmrServer: HttpSe } } +interface NuxtConfigDiffEntry { + key: string +} + +/** + * `onConfigResolved` and `diffNuxtConfig` were added in a later `@nuxt/kit` than the one + * whose types are pinned here, and the kit is resolved from the user's project, so both are + * treated as optional capabilities. Older kits pass the unknown option through to `c12`, + * which ignores it. + */ +type LoadNuxtOptionsWithConfigDiff = Parameters[0] & { + onConfigResolved?: (ctx: { rawConfig: Record }) => void | Promise +} + +function resolveConfigDiffer(kit: Awaited>) { + const differ = (kit as { diffNuxtConfig?: (oldConfig: unknown, newConfig: unknown) => NuxtConfigDiffEntry[] }).diffNuxtConfig + return typeof differ === 'function' ? differ : undefined +} + interface DevServerEventMap { 'loading:error': [error: Error] 'loading': [loadingMessage: string] @@ -172,6 +193,8 @@ export class NuxtDevServer extends EventEmitter { #lockCleanup?: () => void #lockedBuildDir?: string #pendingReason?: DevRestartReason + #rawConfig?: Record + #changedConfigKeys?: string[] loadDebounced: () => void handler: RequestListener @@ -180,9 +203,16 @@ export class NuxtDevServer extends EventEmitter { constructor(private options: NuxtDevServerOptions) { super() - this.loadDebounced = debounce(() => { + this.loadDebounced = debounce(async () => { const reason = this.#pendingReason this.#pendingReason = undefined + + if (reason?.type === 'config' && !this.#loadingError && await this.#isConfigUnchanged()) { + // eslint-disable-next-line no-console + console.info(formatSkippedReload(reason, { rootDir: this.#cwd })) + return + } + return this.load(true, reason) }) @@ -306,10 +336,8 @@ export class NuxtDevServer extends EventEmitter { this.#watchConfig() } - async #loadNuxtInstance(urls?: string[]): Promise { - const kit = await loadKit(this.options.cwd) - - const loadOptions: Parameters[0] = { + #createLoadOptions(urls?: string[]): LoadNuxtOptionsWithConfigDiff { + const loadOptions: LoadNuxtOptionsWithConfigDiff = { cwd: this.options.cwd, dev: true, ready: false, @@ -334,6 +362,93 @@ export class NuxtDevServer extends EventEmitter { loadOptions.defaults = resolveDevServerDefaults({ hostname, https: !!this.listener?.https }, urls) } + return loadOptions + } + + /** + * Resolve the config without instantiating Nuxt, to find out which keys a watched + * config file actually changed. Returns `undefined` when no comparison is possible + * (no baseline, an older `@nuxt/kit`, or a config that failed to load), in which case + * the caller should reload and let the normal path surface any error. + */ + async #resolveConfigChange(urls?: string[]): Promise { + const previous = this.#rawConfig + if (!previous) { + return undefined + } + + const kit = await loadKit(this.options.cwd) + const diffNuxtConfig = resolveConfigDiffer(kit) + if (!diffNuxtConfig || typeof kit.loadNuxtConfig !== 'function') { + return undefined + } + + let keys: string[] | undefined + const loadOptions = this.#createLoadOptions(urls) + loadOptions.onConfigResolved = ({ rawConfig }) => { + try { + keys = diffNuxtConfig(previous, rawConfig).map(entry => entry.key) + } + catch (error) { + // `ohash`'s diff walks the config without a cycle guard + debug('Could not diff nuxt config:', error) + } + } + + try { + await kit.loadNuxtConfig(loadOptions) + } + catch (error) { + debug('Could not resolve nuxt config:', error) + return undefined + } + + return keys + } + + /** + * Report a reload as a single console entry, so the changed keys stay attached to the + * line they explain rather than being prefixed as a separate log message. + */ + #reportReload(reason: DevRestartReason | undefined): void { + const lines = [formatRestartReason(reason, { rootDir: this.#cwd })] + + const changedKeys = reason?.type === 'config' && formatChangedKeys(reason.keys ?? []) + if (changedKeys) { + lines.push(colors.dim(` ${changedKeys}`)) + } + + // eslint-disable-next-line no-console + console.info(lines.join('\n')) + } + + async #isConfigUnchanged(): Promise { + const urls = this.listener?.getURLs().map(({ url }) => url) + return await this.#resolveConfigChange(urls).then(keys => keys?.length === 0) + } + + async #loadNuxtInstance(urls?: string[]): Promise { + const kit = await loadKit(this.options.cwd) + const loadOptions = this.#createLoadOptions(urls) + + const diffNuxtConfig = resolveConfigDiffer(kit) + if (diffNuxtConfig) { + this.#changedConfigKeys = undefined + loadOptions.onConfigResolved = ({ rawConfig }) => { + const previous = this.#rawConfig + this.#rawConfig = rawConfig + if (previous) { + try { + this.#changedConfigKeys = diffNuxtConfig(previous, rawConfig).map(entry => entry.key) + } + catch (error) { + // `ohash`'s diff walks the config without a cycle guard + debug('Could not diff nuxt config:', error) + } + } + } + } + this.#currentNuxt = await kit.loadNuxt(loadOptions) } @@ -615,16 +730,21 @@ export class NuxtDevServer extends EventEmitter { this.#handler = undefined this.#settleInflightResponses() this.emit('loading', this.#loadingMessage) - if (reload) { - // eslint-disable-next-line no-console - console.info(formatRestartReason(reason, { rootDir: this.#cwd })) - } await this.close() const urls = this.listener.getURLs().map(({ url }) => url) - await this.#loadNuxtInstance(urls) + try { + // The changed config keys are only known once the config has been resolved, + // so the reason is reported after loading rather than before. + await this.#loadNuxtInstance(urls) + } + finally { + if (reload) { + this.#reportReload(withConfigKeys(reason, this.#changedConfigKeys)) + } + } // Configure the Nuxt instance (shared logic with initial load) await this.#initializeNuxt(!!reload) diff --git a/packages/nuxt-cli/test/unit/restart-reason.spec.ts b/packages/nuxt-cli/test/unit/restart-reason.spec.ts index c534589d..2692c321 100644 --- a/packages/nuxt-cli/test/unit/restart-reason.spec.ts +++ b/packages/nuxt-cli/test/unit/restart-reason.spec.ts @@ -1,9 +1,10 @@ -import { sep } from 'node:path' +import type { DevRestartReason } from '../../src/dev/reason' +import { sep } from 'node:path' import { join } from 'pathe' import { describe, expect, it } from 'vitest' -import { formatRestartCause, formatRestartReason, mergeRestartReasons } from '../../src/dev/reason' +import { formatChangedKeys, formatRestartCause, formatRestartReason, formatSkippedReload, mergeRestartReasons, withConfigKeys } from '../../src/dev/reason' const rootDir = '/project' const options = { rootDir, link: false } as const @@ -37,9 +38,9 @@ describe('formatRestartCause', () => { .toBe('nuxt.config.ts, .nuxtrc and 1 other file changed') }) - it('should append changed config keys when known', () => { + it('should leave changed config keys to the follow-up line', () => { expect(formatRestartCause({ type: 'config', files: [join(rootDir, 'nuxt.config.ts')], keys: ['ssr', 'modules'] }, options)) - .toBe('nuxt.config.ts changed (ssr, modules)') + .toBe('nuxt.config.ts changed') }) it('should describe non-file causes', () => { @@ -62,6 +63,60 @@ describe('formatRestartReason', () => { }) }) +describe('formatSkippedReload', () => { + it('should name the saved files without promising a reload', () => { + expect(formatSkippedReload({ type: 'config', files: [join(rootDir, 'nuxt.config.ts')] }, options)) + .toBe('nuxt.config.ts saved without changing config.') + }) + + it('should truncate longer file lists', () => { + const files = ['nuxt.config.ts', '.nuxtrc', '.nuxtignore'].map(file => join(rootDir, file)) + expect(formatSkippedReload({ type: 'config', files }, options)) + .toBe('nuxt.config.ts, .nuxtrc and 1 other file saved without changing config.') + }) +}) + +describe('formatChangedKeys', () => { + it('should quote a single key', () => { + expect(formatChangedKeys(['ssr'])).toBe('`ssr` updated') + }) + + it('should join two keys', () => { + expect(formatChangedKeys(['ssr', 'modules'])).toBe('`ssr` and `modules` updated') + }) + + it('should truncate longer key lists', () => { + expect(formatChangedKeys(['ssr', 'modules', 'runtimeConfig.public.foo'])) + .toBe('`ssr`, `modules` and 1 other key updated') + expect(formatChangedKeys(['ssr', 'modules', 'devtools', 'app.head.title'])) + .toBe('`ssr`, `modules` and 2 other keys updated') + }) + + it('should report nothing when no keys changed', () => { + expect(formatChangedKeys([])).toBeUndefined() + }) +}) + +describe('withConfigKeys', () => { + const reason: DevRestartReason = { type: 'config', files: [join(rootDir, 'nuxt.config.ts')] } + + it('should sort the changed keys', () => { + expect(withConfigKeys(reason, ['ssr', 'modules', 'runtimeConfig.public.foo'])) + .toMatchObject({ keys: ['modules', 'runtimeConfig.public.foo', 'ssr'] }) + }) + + it('should leave the reason untouched when there are no keys', () => { + expect(withConfigKeys(reason, [])).toBe(reason) + expect(withConfigKeys(reason, undefined)).toBe(reason) + expect(withConfigKeys(undefined, ['ssr'])).toBeUndefined() + }) + + it('should ignore keys for reasons that are not config changes', () => { + const shortcut = { type: 'shortcut' } as const + expect(withConfigKeys(shortcut, ['ssr'])).toBe(shortcut) + }) +}) + describe('mergeRestartReasons', () => { it('should combine config files and drop duplicates', () => { const merged = mergeRestartReasons(