diff --git a/docs/1.getting-started/8.error-handling.md b/docs/1.getting-started/8.error-handling.md index b46ec2a0c8..573180d701 100644 --- a/docs/1.getting-started/8.error-handling.md +++ b/docs/1.getting-started/8.error-handling.md @@ -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. diff --git a/docs/3.api/3.utils/reload-nuxt-app.md b/docs/3.api/3.utils/reload-nuxt-app.md new file mode 100644 index 0000000000..79502aa56a --- /dev/null +++ b/docs/3.api/3.utils/reload-nuxt-app.md @@ -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. diff --git a/packages/nuxt/src/app/composables/chunk.ts b/packages/nuxt/src/app/composables/chunk.ts new file mode 100644 index 0000000000..1e0c1e4f13 --- /dev/null +++ b/packages/nuxt/src/app/composables/chunk.ts @@ -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 = {} + 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() + } + } +} diff --git a/packages/nuxt/src/app/composables/index.ts b/packages/nuxt/src/app/composables/index.ts index 68034d0848..11ba3266bd 100644 --- a/packages/nuxt/src/app/composables/index.ts +++ b/packages/nuxt/src/app/composables/index.ts @@ -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' diff --git a/packages/nuxt/src/app/plugins/chunk-reload.client.ts b/packages/nuxt/src/app/plugins/chunk-reload.client.ts index dd5abb8eb8..55a1211e3f 100644 --- a/packages/nuxt/src/app/plugins/chunk-reload.client.ts +++ b/packages/nuxt/src/app/plugins/chunk-reload.client.ts @@ -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 = {} - 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 }) } }) }) diff --git a/packages/nuxt/src/app/plugins/restore-state.client.ts b/packages/nuxt/src/app/plugins/restore-state.client.ts new file mode 100644 index 0000000000..6533650ce9 --- /dev/null +++ b/packages/nuxt/src/app/plugins/restore-state.client.ts @@ -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 {} + }) +}) diff --git a/packages/nuxt/src/core/nuxt.ts b/packages/nuxt/src/core/nuxt.ts index d6e5e6f442..c09ac1ad64 100644 --- a/packages/nuxt/src/core/nuxt.ts +++ b/packages/nuxt/src/core/nuxt.ts @@ -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') { diff --git a/packages/nuxt/src/imports/presets.ts b/packages/nuxt/src/imports/presets.ts index c7ca272d10..bc8f03706a 100644 --- a/packages/nuxt/src/imports/presets.ts +++ b/packages/nuxt/src/imports/presets.ts @@ -26,6 +26,7 @@ const appPreset = defineUnimportPreset({ 'defineNuxtComponent', 'useNuxtApp', 'defineNuxtPlugin', + 'reloadNuxtApp', 'useRuntimeConfig', 'useState', 'useFetch', diff --git a/packages/schema/src/config/experimental.ts b/packages/schema/src/config/experimental.ts index 112233deab..c860fa8fda 100644 --- a/packages/schema/src/config/experimental.ts +++ b/packages/schema/src/config/experimental.ts @@ -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 diff --git a/test/basic.test.ts b/test/basic.test.ts index 47d2143160..84e26d953c 100644 --- a/test/basic.test.ts +++ b/test/basic.test.ts @@ -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') }) }) diff --git a/test/bundle.test.ts b/test/bundle.test.ts index 3e56199bb8..5a43c03f3f 100644 --- a/test/bundle.test.ts +++ b/test/bundle.test.ts @@ -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", diff --git a/test/fixtures/basic/nuxt.config.ts b/test/fixtures/basic/nuxt.config.ts index 95d8da941f..9cefb04769 100644 --- a/test/fixtures/basic/nuxt.config.ts +++ b/test/fixtures/basic/nuxt.config.ts @@ -169,7 +169,7 @@ export default defineNuxtConfig({ } }, experimental: { - emitRouteChunkError: 'reload', + restoreState: true, inlineSSRStyles: id => !!id && !id.includes('assets.vue'), componentIslands: true, reactivityTransform: true, diff --git a/test/fixtures/basic/pages/chunk-error.vue b/test/fixtures/basic/pages/chunk-error.vue index 3c8178dab9..f0785e59df 100644 --- a/test/fixtures/basic/pages/chunk-error.vue +++ b/test/fixtures/basic/pages/chunk-error.vue @@ -8,10 +8,13 @@ definePageMeta({ } }) }) +const someValue = useState('val', () => 1) diff --git a/test/fixtures/basic/pages/index.vue b/test/fixtures/basic/pages/index.vue index ae4af0270c..8661c1760f 100644 --- a/test/fixtures/basic/pages/index.vue +++ b/test/fixtures/basic/pages/index.vue @@ -18,6 +18,9 @@ Chunk error + Test @@ -37,6 +40,8 @@ setupDevtoolsPlugin({}, () => {}) as any const config = useRuntimeConfig() +const someValue = useState('val', () => 1) + definePageMeta({ alias: '/some-alias', other: ref('test'),