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
39 changes: 39 additions & 0 deletions packages/nuxt-cli/src/dev/loading-template.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { pathToFileURL } from 'node:url'
import { resolveModulePath } from 'exsolve'
import { debug } from '../utils/logger'
import { withNodePath } from '../utils/paths'

export type LoadingTemplate = (data: { loading?: string }) => string

let cached: Promise<LoadingTemplate | undefined> | undefined

/**
* The loading page Nuxt itself would render, read from the project's own
* `@nuxt/schema` defaults so it matches the installed Nuxt version.
*
* Only needed before a Nuxt instance exists, or when a project has replaced
* `devServer.loadingTemplate` with something unusable.
*/
export function resolveDefaultLoadingTemplate(cwd: string): Promise<LoadingTemplate | undefined> {
return cached ??= importDefaultLoadingTemplate(cwd)
}
Comment on lines +17 to +19

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Key the loading-template cache by project.

Line 18 stores one promise for the entire process. The first call fixes the template for all later cwd values. A later dev server can then use another project's @nuxt/schema template and Nuxt version.

Store cached promises in a Map keyed by cwd or by the canonical resolved schema path. Add a regression test that resolves templates for two different project directories.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/nuxt-cli/src/dev/loading-template.ts` around lines 17 - 19, Update
resolveDefaultLoadingTemplate to replace the single cached promise with a cache
keyed by cwd (or the canonical schema path), ensuring each project resolves and
reuses only its own loading template. Add a regression test covering two
different project directories and verify each receives the correct template.


async function importDefaultLoadingTemplate(cwd: string): Promise<LoadingTemplate | undefined> {
try {
const schemaPath = resolveModulePath('@nuxt/schema', { from: withNodePath(cwd), try: true })
if (!schemaPath) {
return
}

// `NuxtConfigSchema` is typed as a loose `SchemaDefinition`, so the shape we rely on is asserted here
const { NuxtConfigSchema } = await import(pathToFileURL(schemaPath).href) as {
NuxtConfigSchema?: { devServer?: { loadingTemplate?: unknown } }
}
const template = NuxtConfigSchema?.devServer?.loadingTemplate

return typeof template === 'function' ? template as LoadingTemplate : undefined
}
catch (error) {
debug('Could not resolve the default loading template:', error)
}
}
36 changes: 16 additions & 20 deletions packages/nuxt-cli/src/dev/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,8 @@ import { existsSync, readdirSync, statSync, watch } from 'node:fs'
import { mkdir } from 'node:fs/promises'
import process from 'node:process'

import { pathToFileURL } from 'node:url'
import { styleText } from 'node:util'
import defu from 'defu'
import { resolveModulePath } from 'exsolve'
import { toNodeListener } from 'h3'
import { join, resolve } from 'pathe'
import { debounce } from 'perfect-debounce'
Expand All @@ -30,9 +28,9 @@ 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-lazy'
import { listen } from './listen'
import { resolveDefaultLoadingTemplate } from './loading-template'
import { resolvePortlessURLs } from './portless'
import { formatChangedKeys, formatRestartReason, formatSkippedReload, mergeRestartReasons, withConfigKeys } from './reason'

Expand Down Expand Up @@ -73,7 +71,7 @@ interface NuxtDevServerOptions {
envName?: string
clear?: boolean
overrides: NuxtConfig
loadingTemplate?: ({ loading }: { loading: string }) => string
loadingTemplate?: (data: { loading?: string }) => string
showBanner?: boolean
listenOverrides?: DevListenOverrides
handoverFrom?: number
Expand Down Expand Up @@ -243,7 +241,15 @@ export class NuxtDevServer extends EventEmitter<DevServerEventMap> {
this.#handler(req, res)
}
else {
this.#renderLoadingScreen(req, res)
await this.#renderLoadingScreen(req, res).catch((error) => {
debug('Could not render the loading screen:', error)
if (res.headersSent) {
res.end()
return
}
res.statusCode = 503
res.end('Dev server is loading...')
})
}
}
}
Expand Down Expand Up @@ -272,14 +278,13 @@ export class NuxtDevServer extends EventEmitter<DevServerEventMap> {
}

res.setHeader('Content-Type', 'text/html')

const message = this.#loadingMessage || 'Loading...'
const loadingTemplate = this.options.loadingTemplate
|| this.#currentNuxt?.options.devServer.loadingTemplate
|| await resolveLoadingTemplate(this.#cwd)
res.end(
loadingTemplate({
loading: this.#loadingMessage || 'Loading...',
}),
)
|| await resolveDefaultLoadingTemplate(this.#cwd)

res.end(loadingTemplate?.({ loading: message }) ?? message)
}

async init(): Promise<void> {
Expand Down Expand Up @@ -896,15 +901,6 @@ function createConfigDirWatcher(cwd: string, onReload: (path: string) => void) {
return () => configDirWatcher.close()
}

// Fallback for requests that arrive before the Nuxt instance has loaded
async function resolveLoadingTemplate(cwd: string): Promise<({ loading }: { loading?: string }) => string> {
const nuxtPath = resolveModulePath('nuxt', { from: withNodePath(cwd), try: true })
const uiTemplatesPath = resolveModulePath('@nuxt/ui-templates', { from: withNodePath(nuxtPath || cwd) })
const r: { loading: (opts?: { loading?: string }) => string } = await import(pathToFileURL(uiTemplatesPath).href)

return r.loading || ((params: { loading: string }) => `<h2>${params.loading}</h2>`)
}

function isPublicHostname(hostname: string | undefined): boolean {
return !!hostname && !['localhost', '127.0.0.1', '::1'].includes(hostname)
}
52 changes: 52 additions & 0 deletions packages/nuxt-cli/test/e2e/dev-loading.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { randomUUID } from 'node:crypto'
import { rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { getPort } from 'get-port-please'
import { describe, expect, it, vi } from 'vitest'
import { initialize } from '../../src/dev'
import { createDevFixture } from '../utils'

const fixtureDir = await createDevFixture('dev-loading')

describe('dev server loading screen', () => {
it('should fall back to the loading page from the project\'s nuxt version', { timeout: 90_000 }, async () => {
await rm(join(fixtureDir, '.nuxt'), { recursive: true, force: true })
await writeFile(
join(fixtureDir, 'nuxt.config.ts'),
'export default defineNuxtConfig({ devServer: { loadingTemplate: null } })\n',
)

const host = '127.0.0.1'
const port = await getPort({ host, port: 3085 })
const { close, reload } = await initialize({ cwd: fixtureDir, args: {} }, {
listenOverrides: { hostname: host, port },
showBanner: false,
})
const base = `http://${host}:${port}`

try {
const token = randomUUID()
const inflight = fetch(`${base}/api/hang?token=${token}`, { headers: { accept: 'text/html' } })

await vi.waitFor(async () => {
const { started } = await fetch(`${base}/api/hang-state?token=${token}`).then(r => r.json()) as { started: boolean }
expect(started).toBe(true)
}, { timeout: 30_000, interval: 100 })

await reload({ type: 'shortcut' })

const timer = new Promise<'hanging'>(resolve => setTimeout(resolve, 10_000, 'hanging').unref())
const response = await Promise.race([inflight, timer])
expect(response).not.toBe('hanging')

const html = await (response as Response).text()
expect((response as Response).status).toBe(503)
expect((response as Response).headers.get('refresh')).toBe('3')
expect(html).toContain('Reloading Nuxt')
expect(html).toContain('nuxt-loader-bar')
}
finally {
await close()
}
})
})
Loading