Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
44 changes: 35 additions & 9 deletions packages/nuxt-cli/src/dev/reason.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand All @@ -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<DevRestartReason, { type: 'config' }>, 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'
Expand All @@ -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'}`
}
142 changes: 131 additions & 11 deletions packages/nuxt-cli/src/dev/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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 }
Expand Down Expand Up @@ -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<typeof import('@nuxt/kit').loadNuxt>[0] & {
onConfigResolved?: (ctx: { rawConfig: Record<string, unknown> }) => void | Promise<void>
}

function resolveConfigDiffer(kit: Awaited<ReturnType<typeof loadKit>>) {
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]
Expand All @@ -172,6 +193,8 @@ export class NuxtDevServer extends EventEmitter<DevServerEventMap> {
#lockCleanup?: () => void
#lockedBuildDir?: string
#pendingReason?: DevRestartReason
#rawConfig?: Record<string, unknown>
#changedConfigKeys?: string[]

loadDebounced: () => void
handler: RequestListener
Expand All @@ -180,9 +203,16 @@ export class NuxtDevServer extends EventEmitter<DevServerEventMap> {
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)
})

Expand Down Expand Up @@ -306,10 +336,8 @@ export class NuxtDevServer extends EventEmitter<DevServerEventMap> {
this.#watchConfig()
}

async #loadNuxtInstance(urls?: string[]): Promise<void> {
const kit = await loadKit(this.options.cwd)

const loadOptions: Parameters<typeof kit.loadNuxt>[0] = {
#createLoadOptions(urls?: string[]): LoadNuxtOptionsWithConfigDiff {
const loadOptions: LoadNuxtOptionsWithConfigDiff = {
cwd: this.options.cwd,
dev: true,
ready: false,
Expand All @@ -334,6 +362,93 @@ export class NuxtDevServer extends EventEmitter<DevServerEventMap> {
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<string[] | undefined> {
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<boolean> {
const urls = this.listener?.getURLs().map(({ url }) => url)
return await this.#resolveConfigChange(urls).then(keys => keys?.length === 0)
}

async #loadNuxtInstance(urls?: string[]): Promise<void> {
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)
}

Expand Down Expand Up @@ -615,16 +730,21 @@ export class NuxtDevServer extends EventEmitter<DevServerEventMap> {
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)
Expand Down
63 changes: 59 additions & 4 deletions packages/nuxt-cli/test/unit/restart-reason.spec.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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', () => {
Expand All @@ -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(
Expand Down
Loading