diff --git a/docs/2.guide/1.concepts/3.rendering.md b/docs/2.guide/1.concepts/3.rendering.md index 7428e3683c..b6061f7d23 100644 --- a/docs/2.guide/1.concepts/3.rendering.md +++ b/docs/2.guide/1.concepts/3.rendering.md @@ -67,6 +67,11 @@ export default defineNuxtConfig({ }) ``` +::alert{type=info} +If you do use `ssr: false`, you should also place an HTML file in `~/app/spa-loading-template.html` with some HTML you would like to use to render a loading screen that will be rendered until your app is hydrated. +:ReadMore{link="/docs/api/configuration/nuxt-config#spaloadingindicator"} +:: + ## Hybrid Rendering Hybrid rendering allows different caching rules per route using **Route Rules** and decides how the server should respond to a new request on a given URL. diff --git a/packages/nuxt/src/core/nitro.ts b/packages/nuxt/src/core/nitro.ts index 4e19498921..98171b3f74 100644 --- a/packages/nuxt/src/core/nitro.ts +++ b/packages/nuxt/src/core/nitro.ts @@ -1,4 +1,4 @@ -import { existsSync, promises as fsp } from 'node:fs' +import { existsSync, promises as fsp, readFileSync } from 'node:fs' import { join, relative, resolve } from 'pathe' import { build, copyPublicAssets, createDevServer, createNitro, prepare, prerender, scanHandlers, writeTypes } from 'nitropack' import type { Nitro, NitroConfig } from 'nitropack' @@ -10,6 +10,8 @@ import { dynamicEventHandler } from 'h3' import { createHeadCore } from '@unhead/vue' import { renderSSRHead } from '@unhead/ssr' import type { Nuxt } from 'nuxt/schema' +// @ts-expect-error TODO: add legacy type support for subpath imports +import { template as defaultSpaLoadingTemplate } from '@nuxt/ui-templates/templates/spa-loading-icon.mjs' import { distDir } from '../dirs' import { ImportProtectionPlugin } from './plugins/import-protection' @@ -29,6 +31,13 @@ export async function initNitro (nuxt: Nuxt & { _nitro?: Nitro }) { ? [new RegExp(`node_modules\\/(?!${excludePaths.join('|')})`)] : [/node_modules/] + const spaLoadingTemplatePath = nuxt.options.spaLoadingTemplate ?? resolve(nuxt.options.srcDir, 'app/spa-loading-template.html') + if (spaLoadingTemplatePath !== false && !existsSync(spaLoadingTemplatePath)) { + if (nuxt.options.spaLoadingTemplate) { + console.warn(`[nuxt] Could not load custom \`spaLoadingTemplate\` path as it does not exist: \`${spaLoadingTemplatePath}\`.`) + } + } + const nitroConfig: NitroConfig = defu(_nitroConfig, { debug: nuxt.options.debug, rootDir: nuxt.options.rootDir, @@ -75,7 +84,15 @@ export async function initNitro (nuxt: Nuxt & { _nitro?: Nitro }) { devHandlers: [], baseURL: nuxt.options.app.baseURL, virtual: { - '#internal/nuxt.config.mjs': () => nuxt.vfs['#build/nuxt.config'] + '#internal/nuxt.config.mjs': () => nuxt.vfs['#build/nuxt.config'], + '#spa-template': () => { + try { + if (spaLoadingTemplatePath) { + return `export const template = ${JSON.stringify(readFileSync(spaLoadingTemplatePath, 'utf-8'))}` + } + } catch {} + return `export const template = ${JSON.stringify(defaultSpaLoadingTemplate({}))}` + } }, routeRules: { '/__nuxt_error': { cache: false } diff --git a/packages/nuxt/src/core/runtime/nitro/renderer.ts b/packages/nuxt/src/core/runtime/nitro/renderer.ts index 4244cbf143..572d207ba8 100644 --- a/packages/nuxt/src/core/runtime/nitro/renderer.ts +++ b/packages/nuxt/src/core/runtime/nitro/renderer.ts @@ -112,9 +112,12 @@ const getSSRRenderer = lazyCachedFunction(async () => { const getSPARenderer = lazyCachedFunction(async () => { const manifest = await getClientManifest() + // @ts-expect-error virtual file + const spaTemplate = await import('#spa-template').then(r => r.template).catch(() => '') + const options = { manifest, - renderToString: () => `<${appRootTag} id="${appRootId}">`, + renderToString: () => `<${appRootTag} id="${appRootId}">${spaTemplate}`, buildAssetsURL } // Create SPA renderer and cache the result for all requests diff --git a/packages/schema/src/config/app.ts b/packages/schema/src/config/app.ts index 7bda98abf4..03c45cab87 100644 --- a/packages/schema/src/config/app.ts +++ b/packages/schema/src/config/app.ts @@ -1,5 +1,6 @@ import { defineUntypedSchema } from 'untyped' import { defu } from 'defu' +import { resolve } from 'pathe' import type { AppHeadMetaObject } from '../types/head' export default defineUntypedSchema({ @@ -177,6 +178,65 @@ export default defineUntypedSchema({ rootTag: 'div', }, + /** A path to an HTML file, the contents of which will be inserted into any HTML page + * rendered with `ssr: false`. + * + * By default Nuxt will look in `~/app/spa-loading-template.html` for this file. + * + * You can set this to `false` to disable any loading indicator. + * + * Some good sources for spinners are [SpinKit](https://github.com/tobiasahlin/SpinKit) or [SVG Spinners](https://icones.js.org/collection/svg-spinners). + * + * @example ~/app/spa-loading-template.html + * ```html + * + *
+ * + * ``` + * + * @type {string | false} + */ + spaLoadingTemplate: { + $resolve: async (val, get) => typeof val === 'string' ? resolve(await get('srcDir'), val) : (val ?? null) + }, + /** * An array of nuxt app plugins. * diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bcdc183bb1..3c4ef2875e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5310,6 +5310,17 @@ packages: slash: 3.0.0 dev: true + /globby@13.1.4: + resolution: {integrity: sha512-iui/IiiW+QrJ1X1hKH5qwlMQyv34wJAYwH1vrf8b9kBA4sNiif3gKsMHa+BrdnOpEudWjpotfa7LrTzB1ERS/g==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dependencies: + dir-glob: 3.0.1 + fast-glob: 3.2.12 + ignore: 5.2.4 + merge2: 1.4.1 + slash: 4.0.0 + dev: true + /globby@13.2.0: resolution: {integrity: sha512-jWsQfayf13NvqKUIL3Ta+CIqMnvlaIDFveWE/dpOZ9+3AMEJozsxDvKA02zync9UuvOM8rOXzsD5GqKP4OnWPQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -6425,7 +6436,7 @@ packages: defu: 6.1.2 esbuild: 0.17.19 fs-extra: 11.1.1 - globby: 13.2.0 + globby: 13.1.4 jiti: 1.18.2 mlly: 1.3.0 mri: 1.2.0 @@ -8452,7 +8463,7 @@ packages: consola: 3.1.0 defu: 6.1.2 esbuild: 0.17.19 - globby: 13.2.0 + globby: 13.1.4 hookable: 5.5.3 jiti: 1.18.2 magic-string: 0.30.0 diff --git a/test/bundle.test.ts b/test/bundle.test.ts index dc0faaaa05..a92b125df0 100644 --- a/test/bundle.test.ts +++ b/test/bundle.test.ts @@ -35,7 +35,7 @@ describe.skipIf(process.env.SKIP_BUNDLE_SIZE === 'true' || process.env.ECOSYSTEM it('default server bundle size', async () => { stats.server = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir) - expect.soft(roundToKilobytes(stats.server.totalBytes)).toMatchInlineSnapshot('"61.3k"') + expect.soft(roundToKilobytes(stats.server.totalBytes)).toMatchInlineSnapshot('"62.1k"') const modules = await analyzeSizes('node_modules/**/*', serverDir) expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot('"2295k"')