feat(nuxt): enable chunk error handling by default (#19086)

This commit is contained in:
Daniel Roe 2023-03-08 12:17:22 +00:00 committed by GitHub
parent 624314600d
commit df3ae8cb4e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 205 additions and 17 deletions

View File

@ -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.

View 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.

View 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()
}
}
}

View File

@ -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'

View File

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

View 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 {}
})
})

View File

@ -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') {

View File

@ -26,6 +26,7 @@ const appPreset = defineUnimportPreset({
'defineNuxtComponent',
'useNuxtApp',
'defineNuxtPlugin',
'reloadNuxtApp',
'useRuntimeConfig',
'useState',
'useFetch',

View File

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

View File

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

View File

@ -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",

View File

@ -169,7 +169,7 @@ export default defineNuxtConfig({
}
},
experimental: {
emitRouteChunkError: 'reload',
restoreState: true,
inlineSSRStyles: id => !!id && !id.includes('assets.vue'),
componentIslands: true,
reactivityTransform: true,

View File

@ -8,10 +8,13 @@ definePageMeta({
}
})
})
const someValue = useState('val', () => 1)
</script>
<template>
<div>
Chunk error page
<hr>
State: {{ someValue }}
</div>
</template>

View File

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