diff --git a/packages/nuxt/src/app/entry.ts b/packages/nuxt/src/app/entry.ts index dac40f4b97..1e55e2cda1 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, vueAppRootContainer } from '#build/nuxt.config.mjs' let entry: (ssrContext?: CreateOptions['ssrContext']) => Promise> @@ -72,6 +72,11 @@ if (import.meta.client) { if (vueApp.config.errorHandler === handleVueError) { vueApp.config.errorHandler = undefined } }) + // Remove spa loader if present + nuxt.hook('app:suspense:resolve', () => { + if (!isSSR && appSpaLoaderAttrs.id) { 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 4a7037ae44..88b06d8526 100644 --- a/packages/nuxt/src/core/runtime/nitro/renderer.ts +++ b/packages/nuxt/src/core/runtime/nitro/renderer.ts @@ -30,7 +30,7 @@ import { renderSSRHeadOptions } from '#internal/unhead.config.mjs' import type { NuxtPayload, NuxtSSRContext } from '#app' // @ts-expect-error virtual file -import { appHead, appId, appRootAttrs, appRootTag, appTeleportAttrs, appTeleportTag, componentIslands, multiApp } from '#internal/nuxt.config.mjs' +import { appHead, appId, appRootAttrs, appRootTag, appSpaLoaderAttrs, appSpaLoaderTag, appTeleportAttrs, appTeleportTag, componentIslands, multiApp, spaPreloaderOutside } from '#internal/nuxt.config.mjs' // @ts-expect-error virtual file import { buildAssetsURL, publicAssetsURL } from '#internal/nuxt/paths' @@ -144,7 +144,15 @@ 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 (spaPreloaderOutside) { + 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, @@ -222,6 +230,9 @@ async function getIslandContext (event: H3Event): Promise { return ctx } +const APP_SPA_LOADER_OPEN_TAG = `<${appSpaLoaderTag}${propsToString(appSpaLoaderAttrs)}>` +const APP_SPA_LOADER_CLOSE_TAG = `` + const HAS_APP_TELEPORTS = !!(appTeleportTag && appTeleportAttrs.id) const APP_TELEPORT_OPEN_TAG = HAS_APP_TELEPORTS ? `<${appTeleportTag}${propsToString(appTeleportAttrs)}>` : '' const APP_TELEPORT_CLOSE_TAG = HAS_APP_TELEPORTS ? `` : '' diff --git a/packages/nuxt/src/core/templates.ts b/packages/nuxt/src/core/templates.ts index d4fe5166f8..1b44154291 100644 --- a/packages/nuxt/src/core/templates.ts +++ b/packages/nuxt/src/core/templates.ts @@ -525,6 +525,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 spaPreloaderOutside = ${ctx.nuxt.options.experimental.spaPreloaderOutside}`, ].join('\n\n') }, } diff --git a/packages/schema/src/config/app.ts b/packages/schema/src/config/app.ts index 9f5d8aa0b5..406c0d6e54 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,26 @@ 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: { + $resolve: async (val: undefined | null | Record, get) => { + const spaLoaderId = await get('app.spaLoaderId') + return defu(val, { + id: spaLoaderId === false ? undefined : (spaLoaderId || '__nuxt-spa-loader'), + }) + }, + }, }, /** diff --git a/packages/schema/src/config/experimental.ts b/packages/schema/src/config/experimental.ts index 00ae9e2e0c..9396527ef4 100644 --- a/packages/schema/src/config/experimental.ts +++ b/packages/schema/src/config/experimental.ts @@ -407,5 +407,11 @@ export default defineUntypedSchema({ return val ?? ((await get('future') as Record).compatibilityVersion === 4) }, }, + + /** + * Keep showing the spa-loading-template until suspense:resolve + * @see [Nuxt Issues #24770](https://github.com/nuxt/nuxt/issues/21721) + */ + spaPreloaderOutside: false, }, }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ee6c70fec7..483f55b69b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1195,6 +1195,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 515a94c924..0185be06ea 100644 --- a/test/bundle.test.ts +++ b/test/bundle.test.ts @@ -37,7 +37,7 @@ describe.skipIf(process.env.SKIP_BUNDLE_SIZE === 'true' || process.env.ECOSYSTEM const serverDir = join(rootDir, '.output/server') const serverStats = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir) - expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot(`"208k"`) + expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot(`"209k"`) const modules = await analyzeSizes(['node_modules/**/*'], serverDir) expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot(`"1396k"`) 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 @@ + + + + + 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..e40b0471eb --- /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: { + spaPreloaderOutside: false, + }, + 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..7316c14927 --- /dev/null +++ b/test/fixtures/spa-loader/package.json @@ -0,0 +1,12 @@ +{ + "name": "nuxt-playground", + "private": true, + "scripts": { + "dev": "nuxi dev", + "build": "nuxi build", + "start": "nuxi preview" + }, + "dependencies": { + "nuxt": "workspace:*" + } +} diff --git a/test/fixtures/spa-loader/server/api/test.ts b/test/fixtures/spa-loader/server/api/test.ts new file mode 100644 index 0000000000..16be9e4121 --- /dev/null +++ b/test/fixtures/spa-loader/server/api/test.ts @@ -0,0 +1,3 @@ +export default eventHandler((_event) => { + return 'Hello!' +}) diff --git a/test/fixtures/spa-loader/server/tsconfig.json b/test/fixtures/spa-loader/server/tsconfig.json new file mode 100644 index 0000000000..b9ed69c19e --- /dev/null +++ b/test/fixtures/spa-loader/server/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../.nuxt/tsconfig.server.json" +} 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..83259d0ba5 --- /dev/null +++ b/test/spa-loader/spa-preloader-outside-disabled.test.ts @@ -0,0 +1,41 @@ +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' + +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: { + spaPreloaderOutside: false, + }, + }, +}) + +describe('spaPreloaderOutside flag is disabled', () => { + it('shoul be render loader inside appTag', async () => { + const html = await $fetch('/spa') + expect(html).toContain(`
loading...
\n
`) + }) + + it('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('__nuxt-spa-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..71c873bd74 --- /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: { + spaPreloaderOutside: true, + }, + }, +}) + +describe('spaPreloaderOutside flag is enabled', () => { + 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('__nuxt-spa-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) +})