diff --git a/test/e2e/spa-preloader-body.test.ts b/test/e2e/spa-preloader-body.test.ts
new file mode 100644
index 0000000000..4125e92281
--- /dev/null
+++ b/test/e2e/spa-preloader-body.test.ts
@@ -0,0 +1,95 @@
+import { fileURLToPath } from 'node:url'
+import { isWindows } from 'std-env'
+import { join } from 'pathe'
+import type { Page } from 'playwright-core'
+import { waitForHydration } from '@nuxt/test-utils'
+import { expect, test } from './test-utils'
+
+/**
+ * This test suite verifies that the SPA loading template is correctly rendered
+ * outside the app tag when spaLoadingTemplateLocation is set to 'body'.
+ */
+
+const isWebpack = process.env.TEST_BUILDER === 'webpack' || process.env.TEST_BUILDER === 'rspack'
+const isDev = process.env.TEST_ENV === 'dev'
+
+const fixtureDir = fileURLToPath(new URL('../fixtures/spa-loader', import.meta.url))
+
+// Skip tests in dev mode
+test.skip(isDev, 'These tests are only relevant in production mode')
+
+const loaderHTML = '
'
+
+test.use({
+ nuxt: {
+ rootDir: fixtureDir,
+ server: true,
+ browser: true,
+ setupTimeout: (isWindows ? 360 : 120) * 1000,
+ nuxtConfig: {
+ buildDir: isDev ? join(fixtureDir, '.nuxt', 'test', Math.random().toString(36).slice(2, 8)) : undefined,
+ builder: isWebpack ? 'webpack' : 'vite',
+ spaLoadingTemplate: true,
+ experimental: {
+ spaLoadingTemplateLocation: 'body',
+ },
+ },
+ },
+})
+
+test.describe('spaLoadingTemplateLocation flag is set to `body`', () => {
+ test('should render loader alongside appTag', async ({ request }) => {
+ const response = await request.get('/spa')
+ const html = await response.text()
+
+ expect(html).toContain(loaderHTML)
+ })
+
+ test('should render spa-loader', async ({ page, fetch }) => {
+ expect(await fetch('/spa').then(r => r.text())).toContain(loaderHTML)
+
+ // Navigate to the SPA page
+ await page.goto('/spa')
+
+ // Verify the loader is visible first and content is hidden
+ expect(await getState(page)).toEqual({
+ loader: true,
+ content: false,
+ })
+
+ page.dispatchEvent('html', 'finishHydration')
+ await waitForHydration(page, '/spa', 'hydration')
+
+ expect(await getState(page)).toEqual({
+ loader: false,
+ content: true,
+ })
+ })
+
+ test('should render content without spa-loader for SSR pages', async ({ page, fetch }) => {
+ expect(await fetch('/ssr').then(r => r.text())).not.toContain(loaderHTML)
+
+ // Navigate to SSR page
+ await page.goto('/ssr')
+
+ // Verify the loader is hidden and content is visible for SSR pages
+ expect(await getState(page)).toEqual({
+ loader: false,
+ content: true,
+ })
+ })
+})
+
+// isVisible is preferred here as we want to snapshot the state of the page at a specific moment, since waiting would make this test flake.
+// https://github.com/nuxt/nuxt/pull/31273#issuecomment-2731002417
+async function getState (page: Page) {
+ const [loader, content] = await Promise.all([
+ page.getByTestId('loader').isVisible(),
+ page.getByTestId('content').isVisible(),
+ ])
+ const state = {
+ loader,
+ content,
+ }
+ return state
+}
diff --git a/test/e2e/spa-preloader-within.test.ts b/test/e2e/spa-preloader-within.test.ts
new file mode 100644
index 0000000000..fae040bf90
--- /dev/null
+++ b/test/e2e/spa-preloader-within.test.ts
@@ -0,0 +1,95 @@
+import { fileURLToPath } from 'node:url'
+import { isWindows } from 'std-env'
+import { join } from 'pathe'
+import type { Page } from 'playwright-core'
+import { waitForHydration } from '@nuxt/test-utils'
+import { expect, test } from './test-utils'
+
+/**
+ * This test suite verifies that the SPA loading template is correctly rendered
+ * inside the app tag when spaLoadingTemplateLocation is set to 'within'.
+ */
+
+const isWebpack = process.env.TEST_BUILDER === 'webpack' || process.env.TEST_BUILDER === 'rspack'
+const isDev = process.env.TEST_ENV === 'dev'
+
+const fixtureDir = fileURLToPath(new URL('../fixtures/spa-loader', import.meta.url))
+
+// Skip tests in dev mode
+test.skip(isDev, 'These tests are only relevant in production mode')
+
+const loaderHTML = ''
+
+test.use({
+ nuxt: {
+ rootDir: fixtureDir,
+ dev: isDev,
+ server: true,
+ browser: true,
+ setupTimeout: (isWindows ? 360 : 120) * 1000,
+ nuxtConfig: {
+ buildDir: isDev ? join(fixtureDir, '.nuxt', 'test', Math.random().toString(36).slice(2, 8)) : undefined,
+ builder: isWebpack ? 'webpack' : 'vite',
+ spaLoadingTemplate: true,
+ experimental: {
+ spaLoadingTemplateLocation: 'within',
+ },
+ },
+ },
+})
+
+test.describe('spaLoadingTemplateLocation flag is set to `within`', () => {
+ test('should render loader inside appTag', async ({ request }) => {
+ const response = await request.get('/spa')
+ const html = await response.text()
+
+ expect(html).toContain(loaderHTML)
+ })
+
+ test('spa-loader does not appear while the app is mounting', async ({ page }) => {
+ // Navigate to the SPA page
+ await page.goto('/spa')
+
+ // wait for intervening (less optimal!) current behaviour
+ await expect(page.getByTestId('loader')).toBeHidden()
+ await expect(page.getByTestId('content')).toBeHidden()
+
+ expect(await getState(page)).toEqual({
+ loader: false,
+ content: false,
+ })
+
+ page.dispatchEvent('html', 'finishHydration')
+ await waitForHydration(page, '/spa', 'hydration')
+
+ // Wait for content to become visible after hydration
+ await expect(page.getByTestId('content')).toBeVisible()
+ })
+
+ test('should render content without spa-loader for SSR pages', async ({ page, fetch }) => {
+ expect(await fetch('/ssr').then(r => r.text())).not.toContain(loaderHTML)
+
+ // Navigate to SSR page
+ await page.goto('/ssr')
+
+ // Verify the loader is hidden and content is visible for SSR pages
+ expect(await getState(page)).toEqual({
+ loader: false,
+ content: true,
+ })
+ })
+})
+
+// isVisible is preferred here as we want to snapshot the state of the page at a specific moment, since waiting that would make this test flake.
+// https://github.com/nuxt/nuxt/pull/31273#issuecomment-2731002417
+async function getState (page: Page) {
+ const [loader, content] = await Promise.all([
+ page.getByTestId('loader').isVisible(),
+ page.getByTestId('content').isVisible(),
+ ])
+ const state = {
+ loader,
+ content,
+ }
+ return state
+}
diff --git a/test/fixtures/spa-loader/app.vue b/test/fixtures/spa-loader/app.vue
index 574b3e6fe0..2196bff703 100644
--- a/test/fixtures/spa-loader/app.vue
+++ b/test/fixtures/spa-loader/app.vue
@@ -1,6 +1,8 @@
@@ -9,7 +11,3 @@ if (import.meta.client) {
app content
-
-
diff --git a/test/fixtures/spa-loader/plugins/delay.client.ts b/test/fixtures/spa-loader/plugins/delay.client.ts
deleted file mode 100644
index 49529b291c..0000000000
--- a/test/fixtures/spa-loader/plugins/delay.client.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export default defineNuxtPlugin(async () => {
- await new Promise(resolve => setTimeout(resolve, 50))
-})
diff --git a/test/spa-loader/spa-preloader-outside-disabled.test.ts b/test/spa-loader/spa-preloader-outside-disabled.test.ts
deleted file mode 100644
index d733699e48..0000000000
--- a/test/spa-loader/spa-preloader-outside-disabled.test.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-import { fileURLToPath } from 'node:url'
-import { describe, expect, it } from 'vitest'
-import { isWindows } from 'std-env'
-import { $fetch, createPage, setup, url } from '@nuxt/test-utils/e2e'
-import { join } from 'pathe'
-
-const isWebpack =
- process.env.TEST_BUILDER === 'webpack' ||
- process.env.TEST_BUILDER === 'rspack'
-
-const isDev = process.env.TEST_ENV === 'dev'
-
-const fixtureDir = fileURLToPath(new URL('../fixtures/spa-loader', import.meta.url))
-
-if (!isDev) {
- await setup({
- rootDir: fixtureDir,
- dev: isDev,
- server: true,
- browser: true,
- setupTimeout: (isWindows ? 360 : 120) * 1000,
- nuxtConfig: {
- buildDir: isDev ? join(fixtureDir, '.nuxt', 'test', Math.random().toString(36).slice(2, 8)) : undefined,
- builder: isWebpack ? 'webpack' : 'vite',
- spaLoadingTemplate: true,
- experimental: {
- spaLoadingTemplateLocation: 'within',
- },
- },
- })
-}
-
-describe.skipIf(isDev)('spaLoadingTemplateLocation flag is set to `within`', () => {
- it('should render loader inside appTag', async () => {
- const html = await $fetch('/spa')
- expect(html).toContain(``)
- })
-
- it('spa-loader does not appear while the app is mounting', async () => {
- const page = await createPage()
- await page.goto(url('/spa'))
-
- await page.getByTestId('loader').waitFor({ state: 'visible' })
- expect(await page.getByTestId('content').isHidden()).toBeTruthy()
-
- await page.waitForFunction(() => window.useNuxtApp?.() && window.useNuxtApp?.().isHydrating)
-
- expect(await page.getByTestId('content').isHidden()).toBeTruthy()
-
- await page.getByTestId('content').waitFor({ state: 'visible' })
-
- 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
deleted file mode 100644
index f381e09332..0000000000
--- a/test/spa-loader/spa-preloader-outside-enabled.test.ts
+++ /dev/null
@@ -1,65 +0,0 @@
-import { fileURLToPath } from 'node:url'
-import { describe, expect, it } from 'vitest'
-import { isWindows } from 'std-env'
-import { createPage, setup, url } from '@nuxt/test-utils/e2e'
-import type { Page } from 'playwright-core'
-import { join } from 'pathe'
-
-const isWebpack = process.env.TEST_BUILDER === 'webpack' || process.env.TEST_BUILDER === 'rspack'
-const isDev = process.env.TEST_ENV === 'dev'
-
-const fixtureDir = fileURLToPath(new URL('../fixtures/spa-loader', import.meta.url))
-
-if (!isDev) {
- await setup({
- rootDir: fixtureDir,
- server: true,
- browser: true,
- setupTimeout: (isWindows ? 360 : 120) * 1000,
- nuxtConfig: {
- buildDir: isDev ? join(fixtureDir, '.nuxt', 'test', Math.random().toString(36).slice(2, 8)) : undefined,
- builder: isWebpack ? 'webpack' : 'vite',
- spaLoadingTemplate: true,
- experimental: {
- spaLoadingTemplateLocation: 'body',
- },
- },
- })
-}
-
-describe.skipIf(isDev)('spaLoadingTemplateLocation flag is set to `body`', () => {
- it('should render spa-loader', async () => {
- const page = await createPage()
- await page.goto(url('/spa'), { waitUntil: 'domcontentloaded' })
-
- await page.getByTestId('loader').waitFor({ state: 'visible' })
- expect(await page.getByTestId('content').isHidden()).toBeTruthy()
-
- await page.getByTestId('content').waitFor({ state: 'visible' })
- expect(await page.getByTestId('loader').isHidden()).toBeTruthy()
-
- await page.close()
- }, 60_000)
-
- it('should render content without spa-loader', async () => {
- const page = await createPage()
- await page.goto(url('/ssr'), { waitUntil: 'domcontentloaded' })
-
- const [loaderIsHidden, contentIsHidden] = await getState(page)
-
- expect(loaderIsHidden).toBeTruthy()
- expect(contentIsHidden).toBeFalsy()
-
- await page.close()
- }, 60_000)
-})
-
-function getState (page: Page) {
- const loader = page.getByTestId('loader')
- const content = page.getByTestId('content')
-
- return Promise.all([
- loader.isHidden(),
- content.isHidden(),
- ])
-}