mirror of
https://github.com/nuxt/nuxt.git
synced 2024-11-25 15:15: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 API or Nitro server lifecycle
|
||||
1. Server and client startup errors (SSR + SPA)
|
||||
1. Errors downloading JS chunks
|
||||
|
||||
### 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).
|
||||
|
||||
|
||||
### 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
|
||||
|
||||
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 type { MetaObject } 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 { reloadNuxtApp } from '#app/composables/chunk'
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
const router = useRouter()
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
const chunkErrors = new Set()
|
||||
|
||||
@ -10,16 +13,10 @@ export default defineNuxtPlugin((nuxtApp) => {
|
||||
nuxtApp.hook('app:chunkError', ({ error }) => { chunkErrors.add(error) })
|
||||
|
||||
router.onError((error, to) => {
|
||||
if (!chunkErrors.has(error)) { return }
|
||||
|
||||
let handledPath: Record<string, any> = {}
|
||||
try {
|
||||
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
|
||||
if (chunkErrors.has(error)) {
|
||||
const isHash = 'href' in to && (to.href as string).startsWith('#')
|
||||
const path = isHash ? config.app.baseURL + (to as any).href : joinURL(config.app.baseURL, to.fullPath)
|
||||
reloadNuxtApp({ path, persistState: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
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
|
||||
if (nuxt.options.experimental.emitRouteChunkError === 'reload') {
|
||||
if (nuxt.options.experimental.emitRouteChunkError === 'automatic') {
|
||||
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
|
||||
if (nuxt.options.builder === '@nuxt/webpack-builder') {
|
||||
|
@ -26,6 +26,7 @@ const appPreset = defineUnimportPreset({
|
||||
'defineNuxtComponent',
|
||||
'useNuxtApp',
|
||||
'defineNuxtPlugin',
|
||||
'reloadNuxtApp',
|
||||
'useRuntimeConfig',
|
||||
'useState',
|
||||
'useFetch',
|
||||
|
@ -31,13 +31,41 @@ export default defineUntypedSchema({
|
||||
* Emit `app:chunkError` hook when there is an error loading vite/webpack
|
||||
* 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.
|
||||
*
|
||||
* 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
|
||||
* @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
|
||||
|
@ -440,10 +440,14 @@ describe('errors', () => {
|
||||
// TODO: need to create test for webpack
|
||||
it.runIf(!isDev() && !isWebpack)('should handle chunk loading errors', async () => {
|
||||
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.waitForURL(url('/chunk-error'))
|
||||
expect(consoleLogs.map(c => c.text).join('')).toContain('caught chunk load error')
|
||||
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 () => {
|
||||
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(`
|
||||
[
|
||||
"_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: {
|
||||
emitRouteChunkError: 'reload',
|
||||
restoreState: true,
|
||||
inlineSSRStyles: id => !!id && !id.includes('assets.vue'),
|
||||
componentIslands: 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>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
Chunk error page
|
||||
<hr>
|
||||
State: {{ someValue }}
|
||||
</div>
|
||||
</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">
|
||||
Chunk error
|
||||
</NuxtLink>
|
||||
<button @click="someValue++">
|
||||
Increment state
|
||||
</button>
|
||||
<NestedSugarCounter :multiplier="2" />
|
||||
<CustomComponent />
|
||||
<Spin>Test</Spin>
|
||||
@ -37,6 +40,8 @@ setupDevtoolsPlugin({}, () => {}) as any
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
const someValue = useState('val', () => 1)
|
||||
|
||||
definePageMeta({
|
||||
alias: '/some-alias',
|
||||
other: ref('test'),
|
||||
|
Loading…
Reference in New Issue
Block a user