Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(nuxt,schema): allow showing spa loader til after hydration #29776

Merged
merged 33 commits into from
Dec 9, 2024
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
344940a
fix(nuxt, schema) Keep showing the spa-loading-template until suspens…
RokeAlvo Nov 4, 2024
70cbf80
Merge remote-tracking branch 'origin/main' into fix/21721-spa-loading
danielroe Nov 5, 2024
096b6d6
[autofix.ci] apply automated fixes
autofix-ci[bot] Nov 5, 2024
aed5212
Merge branch 'main' into fix/21721-spa-loading
RokeAlvo Nov 5, 2024
ffe807b
add experimental.spaPreloaderOutside flag
RokeAlvo Nov 7, 2024
b4a279d
add tests for sapPreloaderOutside
RokeAlvo Nov 7, 2024
872da31
Merge branch 'main' into fix/21721-spa-loading
RokeAlvo Nov 7, 2024
45a5a54
[autofix.ci] apply automated fixes
autofix-ci[bot] Nov 7, 2024
d2491a0
Merge branch 'main' into fix/21721-spa-loading
RokeAlvo Nov 9, 2024
86db10e
Merge branch 'main' into fix/21721-spa-loading
RokeAlvo Nov 26, 2024
84d1bcb
perf: tree-shake spa loader handling
danielroe Nov 27, 2024
e1c89be
Merge remote-tracking branch 'origin/main' into fix/21721-spa-loading
danielroe Nov 27, 2024
aa830c5
chore: initialise strings within spa template
danielroe Nov 27, 2024
07cd634
fix: set default `spaPreloaderOutside` when `compatibilityVersion: 4`
danielroe Nov 27, 2024
020f212
refactor: rename to `spaLoadingTemplateLocation`
danielroe Nov 27, 2024
4b5b8ce
docs: add documentation
danielroe Nov 27, 2024
6ca7600
chore: remove default value for `spaLoaderAttrs`
danielroe Nov 27, 2024
df1d397
chore: lint
danielroe Nov 27, 2024
ef927bb
test: use correct test id
danielroe Nov 27, 2024
24a0253
Merge branch 'main' into fix/21721-spa-loading
RokeAlvo Nov 29, 2024
f4106a7
chore: add back default id value for spaLoaderAttrs
danielroe Nov 30, 2024
c5f3d53
skip test in dev mode
RokeAlvo Dec 1, 2024
86b6be7
Merge branch 'main' into fix/21721-spa-loading
RokeAlvo Dec 2, 2024
29f0663
Merge remote-tracking branch 'origin/main' into fix/21721-spa-loading
danielroe Dec 9, 2024
a7726ad
test: remove server dir
danielroe Dec 9, 2024
7ac69b6
chore: update workspace name
danielroe Dec 9, 2024
0651291
chore: remove comment
danielroe Dec 9, 2024
2da2115
tetst: remove newline
danielroe Dec 9, 2024
db27a07
fix: global re
danielroe Dec 9, 2024
2f778d2
chore: returns too
danielroe Dec 9, 2024
fcf076d
Merge remote-tracking branch 'origin/main' into fix/21721-spa-loading
danielroe Dec 9, 2024
967c861
chore: add engines
danielroe Dec 9, 2024
b9ae10c
test: update bundle size
danielroe Dec 9, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion docs/1.getting-started/12.upgrade.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@ export default defineNuxtConfig({
// resetAsyncDataToUndefined: true,
// templateUtils: true,
// relativeWatchPaths: true,
// normalizeComponentNames: false
// normalizeComponentNames: false,
// spaLoadingTemplateLocation: 'within',
// defaults: {
// useAsyncData: {
// deep: true
Expand Down Expand Up @@ -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
<div id="__nuxt">
<!-- spa loading template -->
</div>
```

Now, we default to rendering the template alongside the Nuxt app root:

```html
<div id="__nuxt"></div>
<!-- spa loading template -->
```

##### 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
Expand Down
21 changes: 21 additions & 0 deletions docs/2.guide/3.going-further/1.experimental-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).

Comment on lines +457 to +458
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ› οΈ Refactor suggestion

Documentation should cover the complete feature set.

The current documentation focuses on template location but should also explain:

  1. The enhanced behavior of keeping the loading template visible until suspense:resolve
  2. How to customize the loader element using app.spaLoaderTag and app.spaLoaderAttrs
  3. The purpose and usage of experimental.spaPreloaderOutside

This aligns with the PR objectives and provides users with comprehensive documentation of all available options.

It can be set to `within`, which will render it like this:

```html
<div id="__nuxt">
<!-- spa loading template -->
</div>
```

Alternatively, you can render the template alongside the Nuxt app root by setting it to `body`:

```html
<div id="__nuxt"></div>
<!-- spa loading template -->
```

This avoids a white flash when hydrating a client-only page.
Comment on lines +454 to +474
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ› οΈ Refactor suggestion

Documentation needs configuration example and related options.

While the explanation and HTML examples are clear and helpful, please enhance the documentation by:

  1. Adding a configuration example like other sections:
export default defineNuxtConfig({
  experimental: {
    spaLoadingTemplateLocation: 'body' // or 'within'
  }
})
  1. Including the related configuration options mentioned in the PR:
  • app.spaLoaderTag
  • app.spaLoaderAttrs
  • experimental.spaPreloaderOutside

This will provide a complete reference for users implementing this feature.

9 changes: 8 additions & 1 deletion packages/nuxt/src/app/entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<App<Element>>

Expand Down Expand Up @@ -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) {
Expand Down
14 changes: 12 additions & 2 deletions packages/nuxt/src/core/runtime/nitro/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, spaLoadingTemplateOutside } from '#internal/nuxt.config.mjs'
// @ts-expect-error virtual file
import { buildAssetsURL, publicAssetsURL } from '#internal/nuxt/paths'

Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/nuxt/src/core/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 spaLoadingTemplateOutside = ${ctx.nuxt.options.experimental.spaLoadingTemplateLocation === 'body'}`,
].join('\n\n')
},
}
Expand Down
17 changes: 16 additions & 1 deletion packages/schema/src/config/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ export default defineUntypedSchema({
},

/**
* Customize Nuxt root element tag.
* Customize Nuxt Teleport element tag.
*/
teleportTag: {
$resolve: val => val || 'div',
Expand All @@ -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: {
danielroe marked this conversation as resolved.
Show resolved Hide resolved
id: '__nuxt-loader',
},
danielroe marked this conversation as resolved.
Show resolved Hide resolved
},

/**
Expand Down
11 changes: 11 additions & 0 deletions packages/schema/src/config/experimental.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,5 +417,16 @@ export default defineUntypedSchema({
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)
* @type {'body' | 'within'}
*/
spaLoadingTemplateLocation: {
$resolve: async (val, get) => {
return val ?? (((await get('future') as Record<string, unknown>).compatibilityVersion === 4) ? 'body' : 'within')
},
},
},
})
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion test/bundle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"`)
Expand Down
16 changes: 16 additions & 0 deletions test/fixtures/spa-loader/app.vue
Original file line number Diff line number Diff line change
@@ -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>
1 change: 1 addition & 0 deletions test/fixtures/spa-loader/app/spa-loading-template.html
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<div data-testid="loader">loading...</div>
12 changes: 12 additions & 0 deletions test/fixtures/spa-loader/nuxt.config.ts
Original file line number Diff line number Diff line change
@@ -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',
})
12 changes: 12 additions & 0 deletions test/fixtures/spa-loader/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"name": "nuxt-playground",
"private": true,
"scripts": {
"dev": "nuxi dev",
"build": "nuxi build",
"start": "nuxi preview"
},
"dependencies": {
"nuxt": "workspace:*"
}
}
3 changes: 3 additions & 0 deletions test/fixtures/spa-loader/server/api/test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default eventHandler((_event) => {
return 'Hello!'
})
3 changes: 3 additions & 0 deletions test/fixtures/spa-loader/server/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"extends": "../.nuxt/tsconfig.server.json"
}
3 changes: 3 additions & 0 deletions test/fixtures/spa-loader/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"extends": "./.nuxt/tsconfig.json"
}
63 changes: 63 additions & 0 deletions test/spa-loader/spa-preloader-outside-disabled.test.ts
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know how to fix it.

Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
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: {
spaLoadingTemplateLocation: 'within',
},
},
})

describe('spaLoadingTemplateLocation flag is set to `within`', () => {
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>`,
)
})

/**
* This test is skipped in dev mode because it not working
* possible fix is set timeout to 1000
* ```
* const browser = await getBrowser()
* const page = await browser.newPage({})
* await page.goto(url('/spa'), { waitUntil: 'domcontentloaded' })
*
* const loader = page.getByTestId('loader')
* if (process.env.TEST_ENV === 'dev') {
* await page.waitForTimeout(1000)
* }
* expect(await loader.isHidden()).toBeTruthy()
*
* await page.close()
*}, 60_000)
*```
*/
it.skipIf(process.env.TEST_ENV === 'dev')(
'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)
})
52 changes: 52 additions & 0 deletions test/spa-loader/spa-preloader-outside-enabled.test.ts
Original file line number Diff line number Diff line change
@@ -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')
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ› οΈ Refactor suggestion

Standardize test IDs across test cases.

The test uses inconsistent test IDs: 'loader' vs '__nuxt-spa-loader'.

Extract test IDs into constants:

const TEST_IDS = {
  LOADER: '__nuxt-spa-loader',
  CONTENT: 'content'
} as const

Then use these constants consistently throughout the tests.

Also applies to: 43-43

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)
Comment on lines +24 to +36
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ› οΈ Refactor suggestion

Add error handling and cleanup for browser operations.

The browser page navigation and operations should include proper error handling and cleanup.

Consider using a try-finally block:

   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()
+    try {
+      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()
+      const content = page.getByTestId('content')
+      await content.waitFor({ state: 'visible' })
+      expect(await loader.isHidden()).toBeTruthy()
+    } finally {
+      await page.close()
+    }
-    await page.close()
   }, 60_000)
πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 spa-loader', async () => {
const browser = await getBrowser()
const page = await browser.newPage({})
try {
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()
} finally {
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()
Copy link
Member

@danielroe danielroe Nov 27, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

seems this test is failing.

maybe we need a slightly different assertion? or modify the implementation so both are not present on the page at the same moment?

Copy link
Contributor Author

@RokeAlvo RokeAlvo Nov 29, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a test for the /ssr page. We should not see the SPA loader at any time:

  • neither during document loading (line 44)
  • nor after resolving promises. (line 48)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm... the first test is broken...


await page.close()
}, 60_000)
})