diff --git a/docs/1.getting-started/12.upgrade.md b/docs/1.getting-started/12.upgrade.md
index f90236f58f..717f49c265 100644
--- a/docs/1.getting-started/12.upgrade.md
+++ b/docs/1.getting-started/12.upgrade.md
@@ -74,6 +74,7 @@ export default defineNuxtConfig({
// templateUtils: true,
// relativeWatchPaths: true,
// normalizeComponentNames: false,
+ // spaLoadingTemplateLocation: 'within',
// defaults: {
// useAsyncData: {
// deep: true
@@ -237,6 +238,45 @@ export default defineNuxtConfig({
})
```
+#### New DOM Location for SPA Loading Screen
+
+🚦 **Impact Level**: Minimal
+
+##### What Changed
+
+When rendering a client-only page (with `ssr: false`), we optionally render a loading screen (from `app/spa-loading-template.html`), within the Nuxt app root:
+
+```html
+
+
+
+```
+
+Now, we default to rendering the template alongside the Nuxt app root:
+
+```html
+
+
+```
+
+##### Reasons for Change
+
+This allows the spa loading template to remain in the DOM until the Vue app suspense resolves, preventing a flash of white.
+
+##### Migration Steps
+
+If you were targeting the spa loading template with CSS or `document.queryElement` you will need to update your selectors. For this purpose you can use the new `app.spaLoaderTag` and `app.spaLoaderAttrs` configuration options.
+
+Alternatively, you can revert to the previous behaviour with:
+
+```ts twoslash [nuxt.config.ts]
+export default defineNuxtConfig({
+ experimental: {
+ spaLoadingTemplateLocation: 'within',
+ }
+})
+```
+
#### Scan Page Meta After Resolution
🚦 **Impact Level**: Minimal
diff --git a/docs/2.guide/3.going-further/1.experimental-features.md b/docs/2.guide/3.going-further/1.experimental-features.md
index 35280ba7ea..d23638e5af 100644
--- a/docs/2.guide/3.going-further/1.experimental-features.md
+++ b/docs/2.guide/3.going-further/1.experimental-features.md
@@ -451,3 +451,24 @@ In this case, the component name would be `MyComponent`, as far as Vue is concer
But in order to auto-import it, you would need to use `SomeFolderMyComponent`.
By setting `experimental.normalizeComponentNames`, these two values match, and Vue will generate a component name that matches the Nuxt pattern for component naming.
+
+## spaLoadingTemplateLocation
+
+When rendering a client-only page (with `ssr: false`), we optionally render a loading screen (from `app/spa-loading-template.html`).
+
+It can be set to `within`, which will render it like this:
+
+```html
+
+
+
+```
+
+Alternatively, you can render the template alongside the Nuxt app root by setting it to `body`:
+
+```html
+
+
+```
+
+This avoids a white flash when hydrating a client-only page.
diff --git a/packages/nuxt/src/app/entry.ts b/packages/nuxt/src/app/entry.ts
index dac40f4b97..2e9ac9e80c 100644
--- a/packages/nuxt/src/app/entry.ts
+++ b/packages/nuxt/src/app/entry.ts
@@ -17,7 +17,7 @@ import plugins from '#build/plugins'
// @ts-expect-error virtual file
import RootComponent from '#build/root-component.mjs'
// @ts-expect-error virtual file
-import { appId, multiApp, vueAppRootContainer } from '#build/nuxt.config.mjs'
+import { appId, appSpaLoaderAttrs, multiApp, spaLoadingTemplateOutside, vueAppRootContainer } from '#build/nuxt.config.mjs'
let entry: (ssrContext?: CreateOptions['ssrContext']) => Promise>
@@ -72,6 +72,13 @@ if (import.meta.client) {
if (vueApp.config.errorHandler === handleVueError) { vueApp.config.errorHandler = undefined }
})
+ if (spaLoadingTemplateOutside && !isSSR && appSpaLoaderAttrs.id) {
+ // Remove spa loader if present
+ nuxt.hook('app:suspense:resolve', () => {
+ document.getElementById(appSpaLoaderAttrs.id)?.remove()
+ })
+ }
+
try {
await applyPlugins(nuxt, plugins)
} catch (err) {
diff --git a/packages/nuxt/src/core/runtime/nitro/renderer.ts b/packages/nuxt/src/core/runtime/nitro/renderer.ts
index 89de122e2f..bed864aeb5 100644
--- a/packages/nuxt/src/core/runtime/nitro/renderer.ts
+++ b/packages/nuxt/src/core/runtime/nitro/renderer.ts
@@ -30,7 +30,7 @@ import unheadPlugins from '#internal/unhead-plugins.mjs'
import { renderSSRHeadOptions } from '#internal/unhead.config.mjs'
// @ts-expect-error virtual file
-import { appHead, appId, appRootAttrs, appRootTag, appTeleportAttrs, appTeleportTag, componentIslands, appManifest as isAppManifestEnabled, multiApp } from '#internal/nuxt.config.mjs'
+import { appHead, appId, appRootAttrs, appRootTag, appSpaLoaderAttrs, appSpaLoaderTag, appTeleportAttrs, appTeleportTag, componentIslands, appManifest as isAppManifestEnabled, multiApp, spaLoadingTemplateOutside } from '#internal/nuxt.config.mjs'
// @ts-expect-error virtual file
import { buildAssetsURL, publicAssetsURL } from '#internal/nuxt/paths'
@@ -144,7 +144,17 @@ const getSPARenderer = lazyCachedFunction(async () => {
// @ts-expect-error virtual file
const spaTemplate = await import('#spa-template').then(r => r.template).catch(() => '')
- .then(r => APP_ROOT_OPEN_TAG + r + APP_ROOT_CLOSE_TAG)
+ .then((r) => {
+ if (spaLoadingTemplateOutside) {
+ const APP_SPA_LOADER_OPEN_TAG = `<${appSpaLoaderTag}${propsToString(appSpaLoaderAttrs)}>`
+ const APP_SPA_LOADER_CLOSE_TAG = `${appSpaLoaderTag}>`
+ const appTemplate = APP_ROOT_OPEN_TAG + APP_ROOT_CLOSE_TAG
+ const loaderTemplate = r ? APP_SPA_LOADER_OPEN_TAG + r + APP_SPA_LOADER_CLOSE_TAG : ''
+ return appTemplate + loaderTemplate
+ } else {
+ return APP_ROOT_OPEN_TAG + r + APP_ROOT_CLOSE_TAG
+ }
+ })
const options = {
manifest,
diff --git a/packages/nuxt/src/core/templates.ts b/packages/nuxt/src/core/templates.ts
index 0ec7df4860..aef60133d0 100644
--- a/packages/nuxt/src/core/templates.ts
+++ b/packages/nuxt/src/core/templates.ts
@@ -524,6 +524,7 @@ export const nuxtConfigTemplate: NuxtTemplate = {
`export const multiApp = ${!!ctx.nuxt.options.future.multiApp}`,
`export const chunkErrorEvent = ${ctx.nuxt.options.experimental.emitRouteChunkError ? ctx.nuxt.options.builder === '@nuxt/vite-builder' ? '"vite:preloadError"' : '"nuxt:preloadError"' : 'false'}`,
`export const crawlLinks = ${!!((ctx.nuxt as any)._nitro as Nitro).options.prerender.crawlLinks}`,
+ `export const spaLoadingTemplateOutside = ${ctx.nuxt.options.experimental.spaLoadingTemplateLocation === 'body'}`,
].join('\n\n')
},
}
diff --git a/packages/schema/src/config/app.ts b/packages/schema/src/config/app.ts
index 9f5d8aa0b5..7b597244eb 100644
--- a/packages/schema/src/config/app.ts
+++ b/packages/schema/src/config/app.ts
@@ -235,7 +235,7 @@ export default defineUntypedSchema({
},
/**
- * Customize Nuxt root element tag.
+ * Customize Nuxt Teleport element tag.
*/
teleportTag: {
$resolve: val => val || 'div',
@@ -262,6 +262,21 @@ export default defineUntypedSchema({
})
},
},
+
+ /**
+ * Customize Nuxt SpaLoader element tag.
+ */
+ spaLoaderTag: {
+ $resolve: val => val || 'div',
+ },
+
+ /**
+ * Customize Nuxt Nuxt SpaLoader element attributes.
+ * @type {typeof import('@unhead/schema').HtmlAttributes}
+ */
+ spaLoaderAttrs: {
+ id: '__nuxt-loader',
+ },
},
/**
diff --git a/packages/schema/src/config/experimental.ts b/packages/schema/src/config/experimental.ts
index 399b491a15..35d340e2c4 100644
--- a/packages/schema/src/config/experimental.ts
+++ b/packages/schema/src/config/experimental.ts
@@ -418,6 +418,17 @@ export default defineUntypedSchema({
},
},
+ /**
+ * Keep showing the spa-loading-template until suspense:resolve
+ * @see [Nuxt Issues #24770](https://github.com/nuxt/nuxt/issues/21721)
+ * @type {'body' | 'within'}
+ */
+ spaLoadingTemplateLocation: {
+ $resolve: async (val, get) => {
+ return val ?? (((await get('future') as Record).compatibilityVersion === 4) ? 'body' : 'within')
+ },
+ },
+
/**
* Enable timings for Nuxt application hooks in the performance panel of Chromium-based browsers.
*
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 75062c73df..6a7706e808 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1147,6 +1147,12 @@ importers:
specifier: workspace:*
version: link:../../../packages/nuxt
+ test/fixtures/spa-loader:
+ dependencies:
+ nuxt:
+ specifier: workspace:*
+ version: link:../../../packages/nuxt
+
test/fixtures/suspense:
dependencies:
nuxt:
diff --git a/test/bundle.test.ts b/test/bundle.test.ts
index 0185be06ea..d15e9ec9f2 100644
--- a/test/bundle.test.ts
+++ b/test/bundle.test.ts
@@ -78,7 +78,7 @@ describe.skipIf(process.env.SKIP_BUNDLE_SIZE === 'true' || process.env.ECOSYSTEM
const serverDir = join(rootDir, '.output-inline/server')
const serverStats = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir)
- expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot(`"559k"`)
+ expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot(`"560k"`)
const modules = await analyzeSizes(['node_modules/**/*'], serverDir)
expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot(`"94.4k"`)
diff --git a/test/fixtures/spa-loader/app.vue b/test/fixtures/spa-loader/app.vue
new file mode 100644
index 0000000000..b654005857
--- /dev/null
+++ b/test/fixtures/spa-loader/app.vue
@@ -0,0 +1,16 @@
+
+
+
+
+ app content
+
+
+
+
diff --git a/test/fixtures/spa-loader/app/spa-loading-template.html b/test/fixtures/spa-loader/app/spa-loading-template.html
new file mode 100644
index 0000000000..b683d1e597
--- /dev/null
+++ b/test/fixtures/spa-loader/app/spa-loading-template.html
@@ -0,0 +1 @@
+loading...
diff --git a/test/fixtures/spa-loader/nuxt.config.ts b/test/fixtures/spa-loader/nuxt.config.ts
new file mode 100644
index 0000000000..06849bbb95
--- /dev/null
+++ b/test/fixtures/spa-loader/nuxt.config.ts
@@ -0,0 +1,12 @@
+export default defineNuxtConfig({
+ devtools: { enabled: false },
+ spaLoadingTemplate: true,
+ routeRules: {
+ '/spa': { ssr: false },
+ '/ssr': { ssr: true },
+ },
+ experimental: {
+ spaLoadingTemplateLocation: 'within',
+ },
+ compatibilityDate: '2024-06-28',
+})
diff --git a/test/fixtures/spa-loader/package.json b/test/fixtures/spa-loader/package.json
new file mode 100644
index 0000000000..c6ded69cca
--- /dev/null
+++ b/test/fixtures/spa-loader/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "fixture-spa-loader",
+ "private": true,
+ "scripts": {
+ "dev": "nuxi dev",
+ "build": "nuxi build",
+ "start": "nuxi preview"
+ },
+ "dependencies": {
+ "nuxt": "workspace:*"
+ },
+ "engines": {
+ "node": "^18.12.0 || ^20.9.0 || >=22.0.0"
+ }
+}
diff --git a/test/fixtures/spa-loader/tsconfig.json b/test/fixtures/spa-loader/tsconfig.json
new file mode 100644
index 0000000000..4b34df1571
--- /dev/null
+++ b/test/fixtures/spa-loader/tsconfig.json
@@ -0,0 +1,3 @@
+{
+ "extends": "./.nuxt/tsconfig.json"
+}
diff --git a/test/spa-loader/spa-preloader-outside-disabled.test.ts b/test/spa-loader/spa-preloader-outside-disabled.test.ts
new file mode 100644
index 0000000000..1a6e1efbf5
--- /dev/null
+++ b/test/spa-loader/spa-preloader-outside-disabled.test.ts
@@ -0,0 +1,45 @@
+import { fileURLToPath } from 'node:url'
+import { describe, expect, it } from 'vitest'
+import { isWindows } from 'std-env'
+import { $fetch, getBrowser, setup, url } from '@nuxt/test-utils'
+
+const isWebpack =
+ process.env.TEST_BUILDER === 'webpack' ||
+ process.env.TEST_BUILDER === 'rspack'
+
+const isDev = process.env.TEST_ENV === 'dev'
+
+await setup({
+ rootDir: fileURLToPath(new URL('../fixtures/spa-loader', import.meta.url)),
+ dev: isDev,
+ server: true,
+ browser: true,
+ setupTimeout: (isWindows ? 360 : 120) * 1000,
+ nuxtConfig: {
+ builder: isWebpack ? 'webpack' : 'vite',
+ spaLoadingTemplate: true,
+ experimental: {
+ spaLoadingTemplateLocation: 'within',
+ },
+ },
+})
+
+describe('spaLoadingTemplateLocation flag is set to `within`', () => {
+ it('shoul be render loader inside appTag', async () => {
+ const html = await $fetch('/spa')
+ expect(html.replace(/[\n\r]+/g, '')).toContain(
+ ``,
+ )
+ })
+
+ it.skipIf(isDev)('spa-loader does not appear while the app is mounting', async () => {
+ const browser = await getBrowser()
+ const page = await browser.newPage({})
+ await page.goto(url('/spa'), { waitUntil: 'domcontentloaded' })
+
+ const loader = page.getByTestId('loader')
+ expect(await loader.isHidden()).toBeTruthy()
+
+ await page.close()
+ }, 60_000)
+})
diff --git a/test/spa-loader/spa-preloader-outside-enabled.test.ts b/test/spa-loader/spa-preloader-outside-enabled.test.ts
new file mode 100644
index 0000000000..ac894f2d13
--- /dev/null
+++ b/test/spa-loader/spa-preloader-outside-enabled.test.ts
@@ -0,0 +1,52 @@
+import { fileURLToPath } from 'node:url'
+import { describe, expect, it } from 'vitest'
+import { isWindows } from 'std-env'
+import { getBrowser, setup, url } from '@nuxt/test-utils'
+
+const isWebpack = process.env.TEST_BUILDER === 'webpack' || process.env.TEST_BUILDER === 'rspack'
+
+await setup({
+ rootDir: fileURLToPath(new URL('../fixtures/spa-loader', import.meta.url)),
+ dev: process.env.TEST_ENV === 'dev',
+ server: true,
+ browser: true,
+ setupTimeout: (isWindows ? 360 : 120) * 1000,
+ nuxtConfig: {
+ builder: isWebpack ? 'webpack' : 'vite',
+ spaLoadingTemplate: true,
+ experimental: {
+ spaLoadingTemplateLocation: 'body',
+ },
+ },
+})
+
+describe('spaLoadingTemplateLocation flag is set to `body`', () => {
+ it('should render spa-loader', async () => {
+ const browser = await getBrowser()
+ const page = await browser.newPage({})
+ await page.goto(url('/spa'), { waitUntil: 'domcontentloaded' })
+ const loader = page.getByTestId('loader')
+ expect(await loader.isVisible()).toBeTruthy()
+
+ const content = page.getByTestId('content')
+ await content.waitFor({ state: 'visible' })
+ expect(await loader.isHidden()).toBeTruthy()
+
+ await page.close()
+ }, 60_000)
+
+ it('should render content without spa-loader', async () => {
+ const browser = await getBrowser()
+ const page = await browser.newPage({})
+ await page.goto(url('/ssr'), { waitUntil: 'domcontentloaded' })
+
+ const loader = page.getByTestId('loader')
+ expect(await loader.isHidden()).toBeTruthy()
+
+ const content = page.getByTestId('content')
+ await content.waitFor({ state: 'visible' })
+ expect(await loader.isHidden()).toBeTruthy()
+
+ await page.close()
+ }, 60_000)
+})