From 29fed516bfe28f5e4c1e69c572fa9f4db0e7de2a Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Thu, 30 Jul 2026 18:13:46 +0000 Subject: [PATCH] fix(dev): fall back to the loading template from the project's nuxt version --- packages/nuxt-cli/src/dev/loading-template.ts | 39 ++++++++++++++ packages/nuxt-cli/src/dev/utils.ts | 36 ++++++------- .../nuxt-cli/test/e2e/dev-loading.spec.ts | 52 +++++++++++++++++++ 3 files changed, 107 insertions(+), 20 deletions(-) create mode 100644 packages/nuxt-cli/src/dev/loading-template.ts create mode 100644 packages/nuxt-cli/test/e2e/dev-loading.spec.ts diff --git a/packages/nuxt-cli/src/dev/loading-template.ts b/packages/nuxt-cli/src/dev/loading-template.ts new file mode 100644 index 000000000..a613c544d --- /dev/null +++ b/packages/nuxt-cli/src/dev/loading-template.ts @@ -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 | 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 { + return cached ??= importDefaultLoadingTemplate(cwd) +} + +async function importDefaultLoadingTemplate(cwd: string): Promise { + 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) + } +} diff --git a/packages/nuxt-cli/src/dev/utils.ts b/packages/nuxt-cli/src/dev/utils.ts index 3ebf89f9c..370a21f2e 100644 --- a/packages/nuxt-cli/src/dev/utils.ts +++ b/packages/nuxt-cli/src/dev/utils.ts @@ -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' @@ -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' @@ -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 @@ -243,7 +241,15 @@ export class NuxtDevServer extends EventEmitter { 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...') + }) } } } @@ -272,14 +278,13 @@ export class NuxtDevServer extends EventEmitter { } 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 { @@ -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 }) => `

${params.loading}

`) -} - function isPublicHostname(hostname: string | undefined): boolean { return !!hostname && !['localhost', '127.0.0.1', '::1'].includes(hostname) } diff --git a/packages/nuxt-cli/test/e2e/dev-loading.spec.ts b/packages/nuxt-cli/test/e2e/dev-loading.spec.ts new file mode 100644 index 000000000..2ea7b578e --- /dev/null +++ b/packages/nuxt-cli/test/e2e/dev-loading.spec.ts @@ -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() + } + }) +})