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

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

Merged
merged 17 commits into from
Mar 8, 2023
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/1.getting-started/8.error-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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.
Expand Down
56 changes: 56 additions & 0 deletions docs/3.api/3.utils/reload-nuxt-app.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
---
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
}
```

### `options` (optional)

**Type**: `ReloadNuxtAppOptions`

An object accepting the following properties:

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

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

48 changes: 48 additions & 0 deletions packages/nuxt/src/app/composables/chunk.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
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
/**
* 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 = {}) {
const path = options.path || window.location.pathname
danielroe marked this conversation as resolved.
Show resolved Hide resolved

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

try {
sessionStorage.setItem('nuxt:reload:state', JSON.stringify({ state: useNuxtApp().payload.state }))
danielroe marked this conversation as resolved.
Show resolved Hide resolved
danielroe marked this conversation as resolved.
Show resolved Hide resolved
} catch {}

if (window.location.pathname !== path) {
window.location.href = path
} else {
window.location.reload()
}
}
}
2 changes: 2 additions & 0 deletions packages/nuxt/src/app/composables/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
19 changes: 8 additions & 11 deletions packages/nuxt/src/app/plugins/chunk-reload.client.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,22 @@
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()

router.beforeEach(() => { chunkErrors.clear() })
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 })
}
})
})
13 changes: 13 additions & 0 deletions packages/nuxt/src/app/plugins/restore-state.client.ts
Original file line number Diff line number Diff line change
@@ -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 {}
})
})
6 changes: 5 additions & 1 deletion packages/nuxt/src/core/nuxt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
1 change: 1 addition & 0 deletions packages/nuxt/src/imports/presets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const appPreset = defineUnimportPreset({
'defineNuxtComponent',
'useNuxtApp',
'defineNuxtPlugin',
'reloadNuxtApp',
'useRuntimeConfig',
'useState',
'useFetch',
Expand Down
34 changes: 31 additions & 3 deletions packages/schema/src/config/experimental.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
$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}
*/
emitRouteChunkError: false,
restoreState: false,

/**
* Use vite-node for on-demand server chunk loading
Expand Down
4 changes: 4 additions & 0 deletions test/basic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
})

Expand Down
2 changes: 1 addition & 1 deletion test/fixtures/basic/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ export default defineNuxtConfig({
}
},
experimental: {
emitRouteChunkError: 'reload',
restoreState: true,
inlineSSRStyles: id => !!id && !id.includes('assets.vue'),
componentIslands: true,
reactivityTransform: true,
Expand Down
3 changes: 3 additions & 0 deletions test/fixtures/basic/pages/chunk-error.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,13 @@ definePageMeta({
}
})
})
const someValue = useState('val', () => 1)
</script>

<template>
<div>
Chunk error page
<hr>
State: {{ someValue }}
</div>
</template>
5 changes: 5 additions & 0 deletions test/fixtures/basic/pages/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand All @@ -37,6 +40,8 @@ setupDevtoolsPlugin({}, () => {}) as any

const config = useRuntimeConfig()

const someValue = useState('val', () => 1)

definePageMeta({
alias: '/some-alias',
other: ref('test'),
Expand Down