This commit is contained in:
Nikolay 2024-11-19 23:30:27 +02:00 committed by GitHub
commit 3c2bf52d67
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 197 additions and 5 deletions

View File

@ -17,7 +17,7 @@ import plugins from '#build/plugins'
// @ts-expect-error virtual file // @ts-expect-error virtual file
import RootComponent from '#build/root-component.mjs' import RootComponent from '#build/root-component.mjs'
// @ts-expect-error virtual file // @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<App<Element>> let entry: (ssrContext?: CreateOptions['ssrContext']) => Promise<App<Element>>
@ -72,6 +72,11 @@ if (import.meta.client) {
if (vueApp.config.errorHandler === handleVueError) { vueApp.config.errorHandler = undefined } 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 { try {
await applyPlugins(nuxt, plugins) await applyPlugins(nuxt, plugins)
} catch (err) { } catch (err) {

View File

@ -30,7 +30,7 @@ import { renderSSRHeadOptions } from '#internal/unhead.config.mjs'
import type { NuxtPayload, NuxtSSRContext } from '#app' import type { NuxtPayload, NuxtSSRContext } from '#app'
// @ts-expect-error virtual file // @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 // @ts-expect-error virtual file
import { buildAssetsURL, publicAssetsURL } from '#internal/nuxt/paths' import { buildAssetsURL, publicAssetsURL } from '#internal/nuxt/paths'
@ -144,7 +144,15 @@ const getSPARenderer = lazyCachedFunction(async () => {
// @ts-expect-error virtual file // @ts-expect-error virtual file
const spaTemplate = await import('#spa-template').then(r => r.template).catch(() => '') 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 = { const options = {
manifest, manifest,
@ -222,6 +230,9 @@ async function getIslandContext (event: H3Event): Promise<NuxtIslandContext> {
return ctx return ctx
} }
const APP_SPA_LOADER_OPEN_TAG = `<${appSpaLoaderTag}${propsToString(appSpaLoaderAttrs)}>`
const APP_SPA_LOADER_CLOSE_TAG = `</${appSpaLoaderTag}>`
const HAS_APP_TELEPORTS = !!(appTeleportTag && appTeleportAttrs.id) const HAS_APP_TELEPORTS = !!(appTeleportTag && appTeleportAttrs.id)
const APP_TELEPORT_OPEN_TAG = HAS_APP_TELEPORTS ? `<${appTeleportTag}${propsToString(appTeleportAttrs)}>` : '' const APP_TELEPORT_OPEN_TAG = HAS_APP_TELEPORTS ? `<${appTeleportTag}${propsToString(appTeleportAttrs)}>` : ''
const APP_TELEPORT_CLOSE_TAG = HAS_APP_TELEPORTS ? `</${appTeleportTag}>` : '' const APP_TELEPORT_CLOSE_TAG = HAS_APP_TELEPORTS ? `</${appTeleportTag}>` : ''

View File

@ -525,6 +525,7 @@ export const nuxtConfigTemplate: NuxtTemplate = {
`export const multiApp = ${!!ctx.nuxt.options.future.multiApp}`, `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 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 crawlLinks = ${!!((ctx.nuxt as any)._nitro as Nitro).options.prerender.crawlLinks}`,
`export const spaPreloaderOutside = ${ctx.nuxt.options.experimental.spaPreloaderOutside}`,
].join('\n\n') ].join('\n\n')
}, },
} }

View File

@ -235,7 +235,7 @@ export default defineUntypedSchema({
}, },
/** /**
* Customize Nuxt root element tag. * Customize Nuxt Teleport element tag.
*/ */
teleportTag: { teleportTag: {
$resolve: val => val || 'div', $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<string, unknown>, get) => {
const spaLoaderId = await get('app.spaLoaderId')
return defu(val, {
id: spaLoaderId === false ? undefined : (spaLoaderId || '__nuxt-spa-loader'),
})
},
},
}, },
/** /**

View File

@ -407,5 +407,11 @@ export default defineUntypedSchema({
return val ?? ((await get('future') as Record<string, unknown>).compatibilityVersion === 4) return val ?? ((await get('future') as Record<string, unknown>).compatibilityVersion === 4)
}, },
}, },
/**
* Keep showing the spa-loading-template until suspense:resolve
* @see [Nuxt Issues #24770](https://github.com/nuxt/nuxt/issues/21721)
*/
spaPreloaderOutside: false,
}, },
}) })

View File

@ -1195,6 +1195,12 @@ importers:
specifier: workspace:* specifier: workspace:*
version: link:../../../packages/nuxt version: link:../../../packages/nuxt
test/fixtures/spa-loader:
dependencies:
nuxt:
specifier: workspace:*
version: link:../../../packages/nuxt
test/fixtures/suspense: test/fixtures/suspense:
dependencies: dependencies:
nuxt: nuxt:

View File

@ -37,7 +37,7 @@ describe.skipIf(process.env.SKIP_BUNDLE_SIZE === 'true' || process.env.ECOSYSTEM
const serverDir = join(rootDir, '.output/server') const serverDir = join(rootDir, '.output/server')
const serverStats = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir) 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) const modules = await analyzeSizes(['node_modules/**/*'], serverDir)
expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot(`"1396k"`) expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot(`"1396k"`)

16
test/fixtures/spa-loader/app.vue vendored Normal file
View File

@ -0,0 +1,16 @@
<script setup lang="ts">
await useAsyncData(async () => {
await new Promise((r) => { setTimeout(r, 50) })
return 42
})
</script>
<template>
<div data-testid="content">
app content
</div>
</template>
<style scoped>
</style>

View File

@ -0,0 +1 @@
<div data-testid="loader">loading...</div>

12
test/fixtures/spa-loader/nuxt.config.ts vendored Normal file
View File

@ -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',
})

12
test/fixtures/spa-loader/package.json vendored Normal file
View File

@ -0,0 +1,12 @@
{
"name": "nuxt-playground",
"private": true,
"scripts": {
"dev": "nuxi dev",
"build": "nuxi build",
"start": "nuxi preview"
},
"dependencies": {
"nuxt": "workspace:*"
}
}

View File

@ -0,0 +1,3 @@
export default eventHandler((_event) => {
return 'Hello!'
})

View File

@ -0,0 +1,3 @@
{
"extends": "../.nuxt/tsconfig.server.json"
}

View File

@ -0,0 +1,3 @@
{
"extends": "./.nuxt/tsconfig.json"
}

View File

@ -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(`<div id="__nuxt"><div data-testid="loader">loading...</div>\n</div>`)
})
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)
})

View File

@ -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)
})