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 = `` + 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 @@ + + + + + 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( + `
loading...
`, + ) + }) + + 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) +})