mirror of
https://github.com/nuxt/nuxt.git
synced 2025-02-16 21:58:19 +00:00
feat(nuxt): enable chunk error handling by default (#19086)
This commit is contained in:
parent
624314600d
commit
df3ae8cb4e
@ -13,6 +13,7 @@ Nuxt 3 is a full-stack framework, which means there are several sources of unpre
|
|||||||
1. Errors during the Vue rendering lifecycle (SSR + SPA)
|
1. Errors during the Vue rendering lifecycle (SSR + SPA)
|
||||||
1. Errors during API or Nitro server lifecycle
|
1. Errors during API or Nitro server lifecycle
|
||||||
1. Server and client startup errors (SSR + SPA)
|
1. Server and client startup errors (SSR + SPA)
|
||||||
|
1. Errors downloading JS chunks
|
||||||
|
|
||||||
### Errors During the Vue Rendering Lifecycle (SSR + SPA)
|
### Errors During the Vue Rendering Lifecycle (SSR + SPA)
|
||||||
|
|
||||||
@ -47,6 +48,13 @@ This includes:
|
|||||||
|
|
||||||
You cannot currently define a server-side handler for these errors, but can render an error page (see the next section).
|
You cannot currently define a server-side handler for these errors, but can render an error page (see the next section).
|
||||||
|
|
||||||
|
|
||||||
|
### Errors downloading JS chunks
|
||||||
|
|
||||||
|
You might encounter chunk loading errors due to a network connectivity failure or a new deployment (which invalidates your old, hashed JS chunk URLs). Nuxt provides built-in support for handling chunk loading errors by performing a hard reload when a chunk fails to load during route navigation.
|
||||||
|
|
||||||
|
You can change this behavior by setting `experimental.emitRouteChunkError` to `false` (to disable hooking into these errors at all) or to `manual` if you want to handle them yourself. If you want to handle chunk loading errors manually, you can check out the [the automatic implementation](https://github.com/nuxt/nuxt/blob/main/packages/nuxt/src/app/plugins/chunk-reload.client.ts) for ideas.
|
||||||
|
|
||||||
## Rendering an Error Page
|
## Rendering an Error Page
|
||||||
|
|
||||||
When Nuxt encounters a fatal error, whether during the server lifecycle, or when rendering your Vue application (both SSR and SPA), it will either render a JSON response (if requested with `Accept: application/json` header) or an HTML error page.
|
When Nuxt encounters a fatal error, whether during the server lifecycle, or when rendering your Vue application (both SSR and SPA), it will either render a JSON response (if requested with `Accept: application/json` header) or an HTML error page.
|
||||||
|
65
docs/3.api/3.utils/reload-nuxt-app.md
Normal file
65
docs/3.api/3.utils/reload-nuxt-app.md
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
---
|
||||||
|
title: "reloadNuxtApp"
|
||||||
|
description: reloadNuxtApp will perform a hard reload of the page.
|
||||||
|
---
|
||||||
|
|
||||||
|
# `reloadNuxtApp`
|
||||||
|
|
||||||
|
`reloadNuxtApp` will perform a hard reload of your app, re-requesting a page and its dependencies from the server.
|
||||||
|
|
||||||
|
By default, it will also save the current `state` of your app (that is, any state you could access with `useState`). You can enable experimental restoration of this state by enabling the `experimental.restoreState` option in your `nuxt.config` file.
|
||||||
|
|
||||||
|
## Type
|
||||||
|
|
||||||
|
```ts
|
||||||
|
reloadNuxtApp(options?: ReloadNuxtAppOptions)
|
||||||
|
|
||||||
|
interface ReloadNuxtAppOptions {
|
||||||
|
ttl?: number
|
||||||
|
force?: boolean
|
||||||
|
path?: string
|
||||||
|
persistState?: boolean
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `options` (optional)
|
||||||
|
|
||||||
|
**Type**: `ReloadNuxtAppOptions`
|
||||||
|
|
||||||
|
An object accepting the following properties:
|
||||||
|
|
||||||
|
- `path` (optional)
|
||||||
|
|
||||||
|
**Type**: `string`
|
||||||
|
|
||||||
|
**Default**: `window.location.pathname`
|
||||||
|
|
||||||
|
The path to reload (defaulting to the current path). If this is different from the current window location it
|
||||||
|
will trigger a navigation and add an entry in the browser history.
|
||||||
|
|
||||||
|
- `ttl` (optional)
|
||||||
|
|
||||||
|
**Type**: `number`
|
||||||
|
|
||||||
|
**Default**: `10000`
|
||||||
|
|
||||||
|
The number of milliseconds in which to ignore future reload requests. If called again within this time period,
|
||||||
|
`reloadNuxtApp` will not reload your app to avoid reload loops.
|
||||||
|
|
||||||
|
- `force` (optional)
|
||||||
|
|
||||||
|
**Type**: `boolean`
|
||||||
|
|
||||||
|
**Default**: `false`
|
||||||
|
|
||||||
|
This option allows bypassing reload loop protection entirely, forcing a reload even if one has occurred within
|
||||||
|
the previously specified TTL.
|
||||||
|
|
||||||
|
- `persistState` (optional)
|
||||||
|
|
||||||
|
**Type**: `boolean`
|
||||||
|
|
||||||
|
**Default**: `false`
|
||||||
|
|
||||||
|
Whether to dump the current Nuxt state to sessionStorage (as `nuxt:reload:state`). By default this will have no
|
||||||
|
effect on reload unless `experimental.restoreState` is also set, or unless you handle restoring the state yourself.
|
58
packages/nuxt/src/app/composables/chunk.ts
Normal file
58
packages/nuxt/src/app/composables/chunk.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import { useNuxtApp } from '#app/nuxt'
|
||||||
|
|
||||||
|
export interface ReloadNuxtAppOptions {
|
||||||
|
/**
|
||||||
|
* Number of milliseconds in which to ignore future reload requests
|
||||||
|
*
|
||||||
|
* @default {10000}
|
||||||
|
*/
|
||||||
|
ttl?: number
|
||||||
|
/**
|
||||||
|
* Force a reload even if one has occurred within the previously specified TTL.
|
||||||
|
*
|
||||||
|
* @default {false}
|
||||||
|
*/
|
||||||
|
force?: boolean
|
||||||
|
/**
|
||||||
|
* Whether to dump the current Nuxt state to sessionStorage (as `nuxt:reload:state`).
|
||||||
|
*
|
||||||
|
* @default {false}
|
||||||
|
*/
|
||||||
|
persistState?: boolean
|
||||||
|
/**
|
||||||
|
* The path to reload. If this is different from the current window location it will
|
||||||
|
* trigger a navigation and add an entry in the browser history.
|
||||||
|
*
|
||||||
|
* @default {window.location.pathname}
|
||||||
|
*/
|
||||||
|
path?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reloadNuxtApp (options: ReloadNuxtAppOptions = {}) {
|
||||||
|
if (process.server) { return }
|
||||||
|
const path = options.path || window.location.pathname
|
||||||
|
|
||||||
|
let handledPath: Record<string, any> = {}
|
||||||
|
try {
|
||||||
|
handledPath = JSON.parse(sessionStorage.getItem('nuxt:reload') || '{}')
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
if (options.force || handledPath?.path !== path || handledPath?.expires < Date.now()) {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem('nuxt:reload', JSON.stringify({ path, expires: Date.now() + (options.ttl ?? 10000) }))
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
if (options.persistState) {
|
||||||
|
try {
|
||||||
|
// TODO: handle serializing/deserializing complex states as JSON: https://github.com/nuxt/nuxt/pull/19205
|
||||||
|
sessionStorage.setItem('nuxt:reload:state', JSON.stringify({ state: useNuxtApp().payload.state }))
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.location.pathname !== path) {
|
||||||
|
window.location.href = path
|
||||||
|
} else {
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -17,3 +17,5 @@ export { preloadComponents, prefetchComponents, preloadRouteComponents } from '.
|
|||||||
export { isPrerendered, loadPayload, preloadPayload } from './payload'
|
export { isPrerendered, loadPayload, preloadPayload } from './payload'
|
||||||
export type { MetaObject } from './head'
|
export type { MetaObject } from './head'
|
||||||
export { useHead, useSeoMeta, useServerSeoMeta } from './head'
|
export { useHead, useSeoMeta, useServerSeoMeta } from './head'
|
||||||
|
export type { ReloadNuxtAppOptions } from './chunk'
|
||||||
|
export { reloadNuxtApp } from './chunk'
|
||||||
|
@ -1,8 +1,11 @@
|
|||||||
import { defineNuxtPlugin } from '#app/nuxt'
|
import { joinURL } from 'ufo'
|
||||||
|
import { defineNuxtPlugin, useRuntimeConfig } from '#app/nuxt'
|
||||||
import { useRouter } from '#app/composables/router'
|
import { useRouter } from '#app/composables/router'
|
||||||
|
import { reloadNuxtApp } from '#app/composables/chunk'
|
||||||
|
|
||||||
export default defineNuxtPlugin((nuxtApp) => {
|
export default defineNuxtPlugin((nuxtApp) => {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
|
||||||
const chunkErrors = new Set()
|
const chunkErrors = new Set()
|
||||||
|
|
||||||
@ -10,16 +13,10 @@ export default defineNuxtPlugin((nuxtApp) => {
|
|||||||
nuxtApp.hook('app:chunkError', ({ error }) => { chunkErrors.add(error) })
|
nuxtApp.hook('app:chunkError', ({ error }) => { chunkErrors.add(error) })
|
||||||
|
|
||||||
router.onError((error, to) => {
|
router.onError((error, to) => {
|
||||||
if (!chunkErrors.has(error)) { return }
|
if (chunkErrors.has(error)) {
|
||||||
|
const isHash = 'href' in to && (to.href as string).startsWith('#')
|
||||||
let handledPath: Record<string, any> = {}
|
const path = isHash ? config.app.baseURL + (to as any).href : joinURL(config.app.baseURL, to.fullPath)
|
||||||
try {
|
reloadNuxtApp({ path, persistState: true })
|
||||||
handledPath = JSON.parse(localStorage.getItem('nuxt:reload') || '{}')
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
if (handledPath?.path !== to.fullPath || handledPath?.expires < Date.now()) {
|
|
||||||
localStorage.setItem('nuxt:reload', JSON.stringify({ path: to.fullPath, expires: Date.now() + 10000 }))
|
|
||||||
window.location.href = to.fullPath
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
13
packages/nuxt/src/app/plugins/restore-state.client.ts
Normal file
13
packages/nuxt/src/app/plugins/restore-state.client.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { defineNuxtPlugin } from '#app/nuxt'
|
||||||
|
|
||||||
|
export default defineNuxtPlugin((nuxtApp) => {
|
||||||
|
nuxtApp.hook('app:mounted', () => {
|
||||||
|
try {
|
||||||
|
const state = sessionStorage.getItem('nuxt:reload:state')
|
||||||
|
if (state) {
|
||||||
|
sessionStorage.removeItem('nuxt:reload:state')
|
||||||
|
Object.assign(nuxtApp.payload.state, JSON.parse(state)?.state)
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
})
|
||||||
|
})
|
@ -240,9 +240,13 @@ async function initNuxt (nuxt: Nuxt) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add experimental page reload support
|
// Add experimental page reload support
|
||||||
if (nuxt.options.experimental.emitRouteChunkError === 'reload') {
|
if (nuxt.options.experimental.emitRouteChunkError === 'automatic') {
|
||||||
addPlugin(resolve(nuxt.options.appDir, 'plugins/chunk-reload.client'))
|
addPlugin(resolve(nuxt.options.appDir, 'plugins/chunk-reload.client'))
|
||||||
}
|
}
|
||||||
|
// Add experimental session restoration support
|
||||||
|
if (nuxt.options.experimental.restoreState) {
|
||||||
|
addPlugin(resolve(nuxt.options.appDir, 'plugins/restore-state.client'))
|
||||||
|
}
|
||||||
|
|
||||||
// Track components used to render for webpack
|
// Track components used to render for webpack
|
||||||
if (nuxt.options.builder === '@nuxt/webpack-builder') {
|
if (nuxt.options.builder === '@nuxt/webpack-builder') {
|
||||||
|
@ -26,6 +26,7 @@ const appPreset = defineUnimportPreset({
|
|||||||
'defineNuxtComponent',
|
'defineNuxtComponent',
|
||||||
'useNuxtApp',
|
'useNuxtApp',
|
||||||
'defineNuxtPlugin',
|
'defineNuxtPlugin',
|
||||||
|
'reloadNuxtApp',
|
||||||
'useRuntimeConfig',
|
'useRuntimeConfig',
|
||||||
'useState',
|
'useState',
|
||||||
'useFetch',
|
'useFetch',
|
||||||
|
@ -31,13 +31,41 @@ export default defineUntypedSchema({
|
|||||||
* Emit `app:chunkError` hook when there is an error loading vite/webpack
|
* Emit `app:chunkError` hook when there is an error loading vite/webpack
|
||||||
* chunks.
|
* chunks.
|
||||||
*
|
*
|
||||||
* You can set this to `reload` to perform a hard reload of the new route
|
* By default, Nuxt will also perform a hard reload of the new route
|
||||||
* when a chunk fails to load when navigating to a new route.
|
* when a chunk fails to load when navigating to a new route.
|
||||||
*
|
*
|
||||||
|
* You can disable automatic handling by setting this to `false`, or handle
|
||||||
|
* chunk errors manually by setting it to `manual`.
|
||||||
|
*
|
||||||
* @see https://github.com/nuxt/nuxt/pull/19038
|
* @see https://github.com/nuxt/nuxt/pull/19038
|
||||||
* @type {boolean | 'reload'}
|
* @type {false | 'manual' | 'automatic'}
|
||||||
*/
|
*/
|
||||||
emitRouteChunkError: false,
|
emitRouteChunkError: {
|
||||||
|
$resolve: val => {
|
||||||
|
if (val === true) {
|
||||||
|
return 'manual'
|
||||||
|
}
|
||||||
|
if (val === 'reload') {
|
||||||
|
return 'automatic'
|
||||||
|
}
|
||||||
|
return val ?? 'automatic'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to restore Nuxt app state from `sessionStorage` when reloading the page
|
||||||
|
* after a chunk error or manual `reloadNuxtApp()` call.
|
||||||
|
*
|
||||||
|
* To avoid hydration errors, it will be applied only after the Vue app has been mounted,
|
||||||
|
* meaning there may be a flicker on initial load.
|
||||||
|
*
|
||||||
|
* Consider carefully before enabling this as it can cause unexpected behavior, and
|
||||||
|
* consider providing explicit keys to `useState` as auto-generated keys may not match
|
||||||
|
* across builds.
|
||||||
|
*
|
||||||
|
* @type {boolean}
|
||||||
|
*/
|
||||||
|
restoreState: false,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Use vite-node for on-demand server chunk loading
|
* Use vite-node for on-demand server chunk loading
|
||||||
|
@ -440,10 +440,14 @@ describe('errors', () => {
|
|||||||
// TODO: need to create test for webpack
|
// TODO: need to create test for webpack
|
||||||
it.runIf(!isDev() && !isWebpack)('should handle chunk loading errors', async () => {
|
it.runIf(!isDev() && !isWebpack)('should handle chunk loading errors', async () => {
|
||||||
const { page, consoleLogs } = await renderPage('/')
|
const { page, consoleLogs } = await renderPage('/')
|
||||||
|
await page.getByText('Increment state').click()
|
||||||
|
await page.getByText('Increment state').click()
|
||||||
await page.getByText('Chunk error').click()
|
await page.getByText('Chunk error').click()
|
||||||
await page.waitForURL(url('/chunk-error'))
|
await page.waitForURL(url('/chunk-error'))
|
||||||
expect(consoleLogs.map(c => c.text).join('')).toContain('caught chunk load error')
|
expect(consoleLogs.map(c => c.text).join('')).toContain('caught chunk load error')
|
||||||
expect(await page.innerText('div')).toContain('Chunk error page')
|
expect(await page.innerText('div')).toContain('Chunk error page')
|
||||||
|
await page.waitForLoadState('networkidle')
|
||||||
|
expect(await page.innerText('div')).toContain('State: 3')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -26,7 +26,7 @@ describe.skipIf(isWindows)('minimal nuxt application', () => {
|
|||||||
|
|
||||||
it('default client bundle size', async () => {
|
it('default client bundle size', async () => {
|
||||||
stats.client = await analyzeSizes('**/*.js', publicDir)
|
stats.client = await analyzeSizes('**/*.js', publicDir)
|
||||||
expect(stats.client.totalBytes).toBeLessThan(106000)
|
expect(stats.client.totalBytes).toBeLessThan(106200)
|
||||||
expect(stats.client.files.map(f => f.replace(/\..*\.js/, '.js'))).toMatchInlineSnapshot(`
|
expect(stats.client.files.map(f => f.replace(/\..*\.js/, '.js'))).toMatchInlineSnapshot(`
|
||||||
[
|
[
|
||||||
"_nuxt/_plugin-vue_export-helper.js",
|
"_nuxt/_plugin-vue_export-helper.js",
|
||||||
|
2
test/fixtures/basic/nuxt.config.ts
vendored
2
test/fixtures/basic/nuxt.config.ts
vendored
@ -169,7 +169,7 @@ export default defineNuxtConfig({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
experimental: {
|
experimental: {
|
||||||
emitRouteChunkError: 'reload',
|
restoreState: true,
|
||||||
inlineSSRStyles: id => !!id && !id.includes('assets.vue'),
|
inlineSSRStyles: id => !!id && !id.includes('assets.vue'),
|
||||||
componentIslands: true,
|
componentIslands: true,
|
||||||
reactivityTransform: true,
|
reactivityTransform: true,
|
||||||
|
3
test/fixtures/basic/pages/chunk-error.vue
vendored
3
test/fixtures/basic/pages/chunk-error.vue
vendored
@ -8,10 +8,13 @@ definePageMeta({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
const someValue = useState('val', () => 1)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
Chunk error page
|
Chunk error page
|
||||||
|
<hr>
|
||||||
|
State: {{ someValue }}
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
5
test/fixtures/basic/pages/index.vue
vendored
5
test/fixtures/basic/pages/index.vue
vendored
@ -18,6 +18,9 @@
|
|||||||
<NuxtLink to="/chunk-error" :prefetch="false">
|
<NuxtLink to="/chunk-error" :prefetch="false">
|
||||||
Chunk error
|
Chunk error
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
<button @click="someValue++">
|
||||||
|
Increment state
|
||||||
|
</button>
|
||||||
<NestedSugarCounter :multiplier="2" />
|
<NestedSugarCounter :multiplier="2" />
|
||||||
<CustomComponent />
|
<CustomComponent />
|
||||||
<Spin>Test</Spin>
|
<Spin>Test</Spin>
|
||||||
@ -37,6 +40,8 @@ setupDevtoolsPlugin({}, () => {}) as any
|
|||||||
|
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
|
|
||||||
|
const someValue = useState('val', () => 1)
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
alias: '/some-alias',
|
alias: '/some-alias',
|
||||||
other: ref('test'),
|
other: ref('test'),
|
||||||
|
Loading…
Reference in New Issue
Block a user