diff --git a/docs/content/3.docs/1.usage/8.error-handling.md b/docs/content/3.docs/1.usage/8.error-handling.md new file mode 100644 index 0000000000..21a8cc3b1c --- /dev/null +++ b/docs/content/3.docs/1.usage/8.error-handling.md @@ -0,0 +1,90 @@ +# Error handling + +## Handling errors + +Nuxt 3 is a full-stack framework, which means there are several sources of unpreventable user runtime errors that can happen in different contexts: + +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) + +### Errors during the Vue rendering lifecycle (SSR + SPA) + +You can hook into Vue errors using [`onErrorCaptured`](https://vuejs.org/api/composition-api-lifecycle.html#onerrorcaptured). + +In addition, Nuxt provides a `vue:error` hook that will be called if there are any errors that propagate up to the top level. + +If you are using a error reporting framework, you can provide a global handler through [`vueApp.config.errorHandler`](https://vuejs.org/api/application.html#app-config-errorhandler). It will receive all Vue errors, even if they are handled. + +#### Example with global error reporting framework + +```js +export default defineNuxtPlugin((nuxtApp) => { + nuxtApp.vueApp.config.errorHandler = (error, context) => { + // ... + } +}) +``` + +### Server and client startup errors (SSR + SPA) + +Nuxt provides an `app:error` hook that will be called if there are any errors in starting your Nuxt application. + +This includes: + +* running Nuxt plugins +* processing `app:created` and `app:beforeMount` hooks +* mounting the app (on client-side), though you should handle this case with `onErrorCaptured` or with `vue:error` +* processing the `app:mounted` hook + +### Errors during API or Nitro server lifecycle + +You cannot currently define a server-side handler for these errors, but can render an error page (see the next section). + +## 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. + +You can customize this error page by adding `~/error.vue` in the source directory of your application, alongside `app.vue`. This page has a single prop - `error` which contains an error for you to handle. + +When you are ready to remove the error page, you can call the `clearError` helper function, which takes an optional path to redirect to (for example, if you want to navigate to a 'safe' page). + +::alert{type="warning"} +Make sure to check before using anything dependent on Nuxt plugins, such as `$route` or `useRouter`, as if a plugin threw an error, then it won't be re-run until you clear the error. +:: + +### Example + +```vue [error.vue] + + + +``` + +## Error helper methods + +### useError + +* `function useError (): Ref` + +This function will return the global Nuxt error that is being handled. + +### throwError + +* `function throwError (err: string | Error): Error` + +You can call this function at any point on client-side, or (on server side) directly within middleware, plugins or `setup()` functions. It will trigger a full-screen error page (as above) which you can clear with `clearError`. + +### clearError + +* `function clearError (redirect?: string): Promise` + +This function will clear the currently handled Nuxt error. It also takes an optional path to redirect to (for example, if you want to navigate to a 'safe' page). diff --git a/examples/with-errors/app.vue b/examples/with-errors/app.vue new file mode 100644 index 0000000000..8632613624 --- /dev/null +++ b/examples/with-errors/app.vue @@ -0,0 +1,45 @@ + + + diff --git a/examples/with-errors/error.vue b/examples/with-errors/error.vue new file mode 100644 index 0000000000..2b48035938 --- /dev/null +++ b/examples/with-errors/error.vue @@ -0,0 +1,26 @@ + + + diff --git a/examples/with-errors/middleware/error.global.ts b/examples/with-errors/middleware/error.global.ts new file mode 100644 index 0000000000..a8d658adf0 --- /dev/null +++ b/examples/with-errors/middleware/error.global.ts @@ -0,0 +1,5 @@ +export default defineNuxtRouteMiddleware((to) => { + if ('middleware' in to.query) { + return throwError('error in middleware') + } +}) diff --git a/examples/with-errors/nuxt.config.ts b/examples/with-errors/nuxt.config.ts new file mode 100644 index 0000000000..9850816d15 --- /dev/null +++ b/examples/with-errors/nuxt.config.ts @@ -0,0 +1,7 @@ +import { defineNuxtConfig } from 'nuxt3' + +export default defineNuxtConfig({ + modules: [ + '@nuxt/ui' + ] +}) diff --git a/examples/with-errors/package.json b/examples/with-errors/package.json new file mode 100644 index 0000000000..e73ff53ed4 --- /dev/null +++ b/examples/with-errors/package.json @@ -0,0 +1,13 @@ +{ + "name": "example-with-errors", + "private": true, + "scripts": { + "build": "nuxi build", + "dev": "nuxi dev", + "start": "nuxi preview" + }, + "devDependencies": { + "@nuxt/ui": "npm:@nuxt/ui-edge@latest", + "nuxt3": "latest" + } +} diff --git a/examples/with-errors/pages/index.vue b/examples/with-errors/pages/index.vue new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/with-errors/plugins/error.ts b/examples/with-errors/plugins/error.ts new file mode 100644 index 0000000000..b06a954765 --- /dev/null +++ b/examples/with-errors/plugins/error.ts @@ -0,0 +1,20 @@ +export default defineNuxtPlugin((nuxtApp) => { + nuxtApp.hook('vue:error', (..._args) => { + console.log('vue:error') + // if (process.client) { + // console.log(..._args) + // } + }) + nuxtApp.hook('app:error', (..._args) => { + console.log('app:error') + // if (process.client) { + // console.log(..._args) + // } + }) + nuxtApp.vueApp.config.errorHandler = (..._args) => { + console.log('global error handler') + // if (process.client) { + // console.log(..._args) + // } + } +}) diff --git a/examples/with-errors/server/middleware/error.ts b/examples/with-errors/server/middleware/error.ts new file mode 100644 index 0000000000..0575be5532 --- /dev/null +++ b/examples/with-errors/server/middleware/error.ts @@ -0,0 +1,8 @@ +import { useQuery, defineMiddleware } from 'h3' + +export default defineMiddleware((req, res, next) => { + if ('api' in useQuery(req)) { + throw new Error('Server middleware error') + } + next() +}) diff --git a/examples/with-errors/tsconfig.json b/examples/with-errors/tsconfig.json new file mode 100644 index 0000000000..4b34df1571 --- /dev/null +++ b/examples/with-errors/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./.nuxt/tsconfig.json" +} diff --git a/packages/bridge/src/auto-imports.ts b/packages/bridge/src/auto-imports.ts index bce37f8f7c..b5e108eda3 100644 --- a/packages/bridge/src/auto-imports.ts +++ b/packages/bridge/src/auto-imports.ts @@ -4,7 +4,7 @@ import type { Preset } from 'unimport' import autoImports from '../../nuxt3/src/auto-imports/module' import { vuePreset, commonPresets, appPreset } from '../../nuxt3/src/auto-imports/presets' -const UnsupportedImports = new Set(['useAsyncData', 'useFetch']) +const UnsupportedImports = new Set(['useAsyncData', 'useFetch', 'useError', 'throwError', 'clearError']) const CapiHelpers = new Set(Object.keys(CompositionApi)) export function setupAutoImports () { diff --git a/packages/nitro/package.json b/packages/nitro/package.json index 84468cf40c..c4b979ee7f 100644 --- a/packages/nitro/package.json +++ b/packages/nitro/package.json @@ -15,9 +15,9 @@ "dependencies": { "@cloudflare/kv-asset-handler": "^0.2.0", "@netlify/functions": "^1.0.0", - "@nuxt/design": "0.1.5", "@nuxt/devalue": "^2.0.0", "@nuxt/kit": "3.0.0", + "@nuxt/ui-templates": "npm:@nuxt/ui-templates-edge@latest", "@rollup/plugin-alias": "^3.1.9", "@rollup/plugin-commonjs": "^21.0.2", "@rollup/plugin-inject": "^4.0.4", diff --git a/packages/nitro/src/runtime/app/render.ts b/packages/nitro/src/runtime/app/render.ts index 73e8d9880f..657564b4f8 100644 --- a/packages/nitro/src/runtime/app/render.ts +++ b/packages/nitro/src/runtime/app/render.ts @@ -1,6 +1,7 @@ import type { ServerResponse } from 'http' import { createRenderer } from 'vue-bundle-renderer' import devalue from '@nuxt/devalue' +import { useQuery } from 'h3' import { privateConfig, publicConfig } from './config' import { buildAssetsURL } from './paths' // @ts-ignore @@ -70,7 +71,9 @@ function renderToString (ssrContext) { } export async function renderMiddleware (req, res: ServerResponse) { - let url = req.url + // Whether we're rendering an error page + const ssrError = req.url.startsWith('/__error') ? useQuery(req) : null + let url = ssrError?.url || req.url // payload.json request detection let isPayloadReq = false @@ -86,15 +89,23 @@ export async function renderMiddleware (req, res: ServerResponse) { res, runtimeConfig: { private: privateConfig, public: publicConfig }, noSSR: req.spa || req.headers['x-nuxt-no-ssr'], - ...(req.context || {}) + ...(req.context || {}), + error: ssrError } // Render app - const rendered = await renderToString(ssrContext) + const rendered = await renderToString(ssrContext).catch((e) => { + if (!ssrError) { throw e } + }) + + // If we error on rendering error page, we bail out and directly return to the error handler + if (!rendered) { return } // Handle errors - if (ssrContext.error) { - throw ssrContext.error + const error = ssrContext.error /* nuxt 3 */ || ssrContext.nuxt?.error /* nuxt 2 */ + if (error && !ssrError) { + // trigger a re-render by onError handler + throw error } if (ssrContext.redirected || res.writableEnded) { @@ -107,7 +118,6 @@ export async function renderMiddleware (req, res: ServerResponse) { // TODO: nuxt3 should not reuse `nuxt` property for different purpose! const payload = ssrContext.payload /* nuxt 3 */ || ssrContext.nuxt /* nuxt 2 */ - if (process.env.NUXT_FULL_STATIC) { payload.staticAssetsBase = STATIC_ASSETS_BASE } @@ -121,8 +131,7 @@ export async function renderMiddleware (req, res: ServerResponse) { res.setHeader('Content-Type', 'text/html;charset=UTF-8') } - const error = ssrContext.nuxt && ssrContext.nuxt.error - res.statusCode = error ? error.statusCode : 200 + res.statusCode = res.statusCode || 200 res.end(data, 'utf-8') } diff --git a/packages/nitro/src/runtime/server/error.ts b/packages/nitro/src/runtime/server/error.ts index b38088cd1d..7e35b5748c 100644 --- a/packages/nitro/src/runtime/server/error.ts +++ b/packages/nitro/src/runtime/server/error.ts @@ -1,13 +1,14 @@ // import ansiHTML from 'ansi-html' import type { IncomingMessage, ServerResponse } from 'http' -import { error500, error404, errorDev } from '@nuxt/design' +import { withQuery } from 'ufo' +import { $fetch } from '.' const cwd = process.cwd() const hasReqHeader = (req, header, includes) => req.headers[header] && req.headers[header].toLowerCase().includes(includes) const isDev = process.env.NODE_ENV === 'development' -export function handleError (error, req: IncomingMessage, res: ServerResponse) { +export async function handleError (error, req: IncomingMessage, res: ServerResponse) { const isJsonRequest = hasReqHeader(req, 'accept', 'application/json') || hasReqHeader(req, 'user-agent', 'curl/') || hasReqHeader(req, 'user-agent', 'httpie/') const stack = (error.stack || '') @@ -31,18 +32,17 @@ export function handleError (error, req: IncomingMessage, res: ServerResponse) { const is404 = error.statusCode === 404 const errorObject = { + url: req.url, statusCode: error.statusCode || 500, - statusMessage: is404 ? 'Page Not Found' : 'Internal Server Error', + statusMessage: error.statusMessage ?? is404 ? 'Page Not Found' : 'Internal Server Error', + message: error.message || error.toString(), description: isDev && !is404 - ? ` -

${error.message}

-
${stack.map(i => `${i.text}`).join('\n')}
- ` + ? `
${stack.map(i => `${i.text}`).join('\n')}
` : '' } - res.statusCode = error.statusCode || 500 - res.statusMessage = error.statusMessage || 'Internal Server Error' + res.statusCode = errorObject.statusCode + res.statusMessage = errorObject.statusMessage // Console output if (!is404) { @@ -56,8 +56,9 @@ export function handleError (error, req: IncomingMessage, res: ServerResponse) { } // HTML response - const errorTemplate = is404 ? error404 : (isDev ? errorDev : error500) - const html = errorTemplate(errorObject) + const url = withQuery('/_nitro/__error', errorObject) + const html = await $fetch(url).catch(() => errorObject.statusMessage) + res.setHeader('Content-Type', 'text/html;charset=UTF-8') res.end(html) } diff --git a/packages/nitro/src/runtime/server/index.ts b/packages/nitro/src/runtime/server/index.ts index ce38f7c0b2..feb628f949 100644 --- a/packages/nitro/src/runtime/server/index.ts +++ b/packages/nitro/src/runtime/server/index.ts @@ -1,4 +1,4 @@ -import { createApp, useBase } from 'h3' +import { createApp, lazyHandle, useBase } from 'h3' import { createFetch, Headers } from 'ohmyfetch' import destr from 'destr' import { createCall, createFetch as createLocalFetch } from 'unenv/runtime/fetch/index' @@ -13,9 +13,12 @@ const app = createApp({ onError: handleError }) +const renderMiddleware = lazyHandle(() => import('../app/render').then(e => e.renderMiddleware)) + +app.use('/_nitro', renderMiddleware) app.use(timingMiddleware) app.use(serverMiddleware) -app.use(() => import('../app/render').then(e => e.renderMiddleware), { lazy: true }) +app.use(renderMiddleware) export const stack = app.stack export const handle = useBase(baseURL(), app) diff --git a/packages/nitro/src/server/dev.ts b/packages/nitro/src/server/dev.ts index b0e10ae6ee..fa54419587 100644 --- a/packages/nitro/src/server/dev.ts +++ b/packages/nitro/src/server/dev.ts @@ -2,7 +2,7 @@ import { Worker } from 'worker_threads' import { IncomingMessage, ServerResponse } from 'http' import { existsSync, promises as fsp } from 'fs' -import { loading as loadingTemplate } from '@nuxt/design' +import { loading as loadingTemplate } from '@nuxt/ui-templates' import chokidar, { FSWatcher } from 'chokidar' import debounce from 'p-debounce' import { promisifyHandle, createApp, Middleware, useBase } from 'h3' diff --git a/packages/nuxi/package.json b/packages/nuxi/package.json index 22fcf9bdc2..37802b889b 100644 --- a/packages/nuxi/package.json +++ b/packages/nuxi/package.json @@ -17,9 +17,9 @@ "prepack": "unbuild" }, "devDependencies": { - "@nuxt/design": "0.1.5", "@nuxt/kit": "3.0.0", "@nuxt/schema": "3.0.0", + "@nuxt/ui-templates": "npm:@nuxt/ui-templates-edge@latest", "@types/clear": "^0", "@types/mri": "^1.1.1", "@types/rimraf": "^3", diff --git a/packages/nuxi/src/commands/dev.ts b/packages/nuxi/src/commands/dev.ts index e08bb5d685..faf79451fb 100644 --- a/packages/nuxi/src/commands/dev.ts +++ b/packages/nuxi/src/commands/dev.ts @@ -86,7 +86,7 @@ export default defineNuxtCommand({ dLoad(true, `Directory \`${dir}/\` ${event === 'addDir' ? 'created' : 'removed'}`) } } else if (isFileChange) { - if (file.match(/app\.(js|ts|mjs|jsx|tsx|vue)$/)) { + if (file.match(/(app|error)\.(js|ts|mjs|jsx|tsx|vue)$/)) { dLoad(true, `\`${relative(rootDir, file)}\` ${event === 'add' ? 'created' : 'removed'}`) } } diff --git a/packages/nuxi/src/utils/server.ts b/packages/nuxi/src/utils/server.ts index 43aaf5223e..015386cefa 100644 --- a/packages/nuxi/src/utils/server.ts +++ b/packages/nuxi/src/utils/server.ts @@ -1,6 +1,6 @@ import type { RequestListener } from 'http' import type { ListenOptions } from 'listhen' -import { loading } from '@nuxt/design' +import { loading } from '@nuxt/ui-templates' export function createServer (defaultApp?) { const listener = createDynamicFunction(defaultApp || createLoadingHandler('Loading...')) diff --git a/packages/nuxt3/package.json b/packages/nuxt3/package.json index bc36929930..95ebfde32b 100644 --- a/packages/nuxt3/package.json +++ b/packages/nuxt3/package.json @@ -30,10 +30,10 @@ "prepack": "unbuild" }, "dependencies": { - "@nuxt/design": "^0.1.5", "@nuxt/kit": "3.0.0", "@nuxt/nitro": "3.0.0", "@nuxt/schema": "3.0.0", + "@nuxt/ui-templates": "npm:@nuxt/ui-templates-edge@latest", "@nuxt/vite-builder": "3.0.0", "@vue/reactivity": "^3.2.31", "@vue/shared": "^3.2.31", diff --git a/packages/nuxt3/src/app/components/nuxt-error-page.vue b/packages/nuxt3/src/app/components/nuxt-error-page.vue new file mode 100644 index 0000000000..1d14f6869d --- /dev/null +++ b/packages/nuxt3/src/app/components/nuxt-error-page.vue @@ -0,0 +1,42 @@ + + + diff --git a/packages/nuxt3/src/app/components/nuxt-root.vue b/packages/nuxt3/src/app/components/nuxt-root.vue index 6b9efdd1c1..d52459c563 100644 --- a/packages/nuxt3/src/app/components/nuxt-root.vue +++ b/packages/nuxt3/src/app/components/nuxt-root.vue @@ -1,22 +1,31 @@ - diff --git a/packages/nuxt3/src/app/components/nuxt-welcome.vue b/packages/nuxt3/src/app/components/nuxt-welcome.vue deleted file mode 100644 index 9e3fb49ea3..0000000000 --- a/packages/nuxt3/src/app/components/nuxt-welcome.vue +++ /dev/null @@ -1,13 +0,0 @@ - - - diff --git a/packages/nuxt3/src/app/composables/error.ts b/packages/nuxt3/src/app/composables/error.ts new file mode 100644 index 0000000000..cdee4541c7 --- /dev/null +++ b/packages/nuxt3/src/app/composables/error.ts @@ -0,0 +1,30 @@ +import { useNuxtApp, useState } from '#app' + +export const useError = () => { + const nuxtApp = useNuxtApp() + return useState('error', () => process.server ? nuxtApp.ssrContext.error : nuxtApp.payload.error) +} + +export const throwError = (_err: string | Error) => { + const nuxtApp = useNuxtApp() + const error = useError() + const err = typeof _err === 'string' ? new Error(_err) : _err + nuxtApp.callHook('app:error', err) + if (process.server) { + nuxtApp.ssrContext.error = nuxtApp.ssrContext.error || err + } else { + error.value = error.value || err + } + + return err +} + +export const clearError = async (options: { redirect?: string } = {}) => { + const nuxtApp = useNuxtApp() + const error = useError() + nuxtApp.callHook('app:error:cleared', options) + if (options.redirect) { + await nuxtApp.$router.replace(options.redirect) + } + error.value = null +} diff --git a/packages/nuxt3/src/app/composables/index.ts b/packages/nuxt3/src/app/composables/index.ts index d078732225..9f47d42c53 100644 --- a/packages/nuxt3/src/app/composables/index.ts +++ b/packages/nuxt3/src/app/composables/index.ts @@ -3,6 +3,7 @@ export { useAsyncData, useLazyAsyncData } from './asyncData' export type { AsyncDataOptions, AsyncData } from './asyncData' export { useHydration } from './hydrate' export { useState } from './state' +export { clearError, throwError, useError } from './error' export { useFetch, useLazyFetch } from './fetch' export type { FetchResult, UseFetchOptions } from './fetch' export { useCookie } from './cookie' diff --git a/packages/nuxt3/src/app/entry.ts b/packages/nuxt3/src/app/entry.ts index 03cfd67e23..e62eefb455 100644 --- a/packages/nuxt3/src/app/entry.ts +++ b/packages/nuxt3/src/app/entry.ts @@ -19,9 +19,13 @@ if (process.server) { const nuxt = createNuxtApp({ vueApp, ssrContext }) - await applyPlugins(nuxt, plugins) - - await nuxt.hooks.callHook('app:created', vueApp) + try { + await applyPlugins(nuxt, plugins) + await nuxt.hooks.callHook('app:created', vueApp) + } catch (err) { + await nuxt.callHook('app:error', err) + ssrContext.error = ssrContext.error || err + } return vueApp } @@ -43,19 +47,27 @@ if (process.client) { const nuxt = createNuxtApp({ vueApp }) - await applyPlugins(nuxt, plugins) - - await nuxt.hooks.callHook('app:created', vueApp) - await nuxt.hooks.callHook('app:beforeMount', vueApp) - nuxt.hooks.hookOnce('app:suspense:resolve', () => { nuxt.isHydrating = false }) - vueApp.mount('#__nuxt') + try { + await applyPlugins(nuxt, plugins) + } catch (err) { + await nuxt.callHook('app:error', err) + nuxt.payload.error = nuxt.payload.error || err + } - await nuxt.hooks.callHook('app:mounted', vueApp) - await nextTick() + try { + await nuxt.hooks.callHook('app:created', vueApp) + await nuxt.hooks.callHook('app:beforeMount', vueApp) + vueApp.mount('#__nuxt') + await nuxt.hooks.callHook('app:mounted', vueApp) + await nextTick() + } catch (err) { + await nuxt.callHook('app:error', err) + nuxt.payload.error = nuxt.payload.error || err + } } entry().catch((error) => { diff --git a/packages/nuxt3/src/app/nuxt.ts b/packages/nuxt3/src/app/nuxt.ts index c07f9b45a5..0b7b182bb9 100644 --- a/packages/nuxt3/src/app/nuxt.ts +++ b/packages/nuxt3/src/app/nuxt.ts @@ -1,6 +1,6 @@ /* eslint-disable no-use-before-define */ import { getCurrentInstance, reactive } from 'vue' -import type { App, VNode } from 'vue' +import type { App, onErrorCaptured, VNode } from 'vue' import { createHooks, Hookable } from 'hookable' import type { RuntimeConfig } from '@nuxt/schema' import { legacyPlugin, LegacyContext } from './compat/legacy-app' @@ -21,10 +21,13 @@ export interface RuntimeNuxtHooks { 'app:mounted': (app: App) => HookResult 'app:rendered': () => HookResult 'app:suspense:resolve': (Component?: VNode) => HookResult + 'app:error': (err: any) => HookResult + 'app:error:cleared': (options: { redirect?: string }) => HookResult 'page:start': (Component?: VNode) => HookResult 'page:finish': (Component?: VNode) => HookResult 'meta:register': (metaRenderers: Array<(nuxt: NuxtApp) => NuxtMeta | Promise>) => HookResult 'vue:setup': () => void + 'vue:error': (...args: Parameters[0]>) => HookResult } interface _NuxtApp { diff --git a/packages/nuxt3/src/app/plugins/router.ts b/packages/nuxt3/src/app/plugins/router.ts index c64a4ff8a6..3e0aca287e 100644 --- a/packages/nuxt3/src/app/plugins/router.ts +++ b/packages/nuxt3/src/app/plugins/router.ts @@ -4,6 +4,7 @@ import { NuxtApp } from '@nuxt/schema' import { createError } from 'h3' import { defineNuxtPlugin } from '..' import { callWithNuxt } from '../nuxt' +import { clearError, throwError } from '#app' declare module 'vue' { export interface GlobalComponents { @@ -106,6 +107,12 @@ export default defineNuxtPlugin<{ route: Route, router: Router }>((nuxtApp) => { try { // Resolve route const to = getRouteFromPath(url) + + if (process.client && !nuxtApp.isHydrating) { + // Clear any existing errors + await callWithNuxt(nuxtApp as NuxtApp, clearError) + } + // Run beforeEach hooks for (const middleware of hooks['navigate:before']) { const result = await middleware(to, route) @@ -196,8 +203,7 @@ export default defineNuxtPlugin<{ route: Route, router: Router }>((nuxtApp) => { const error = result || createError({ statusMessage: `Route navigation aborted: ${nuxtApp.ssrContext.url}` }) - nuxtApp.ssrContext.error = error - throw error + return callWithNuxt(nuxtApp, throwError, [error]) } } if (result || result === false) { return result } diff --git a/packages/nuxt3/src/auto-imports/presets.ts b/packages/nuxt3/src/auto-imports/presets.ts index 42ea854ab7..1195525492 100644 --- a/packages/nuxt3/src/auto-imports/presets.ts +++ b/packages/nuxt3/src/auto-imports/presets.ts @@ -37,7 +37,10 @@ export const appPreset = defineUnimportPreset({ 'defineNuxtRouteMiddleware', 'navigateTo', 'abortNavigation', - 'addRouteMiddleware' + 'addRouteMiddleware', + 'throwError', + 'clearError', + 'useError' ] }) diff --git a/packages/nuxt3/src/core/app.ts b/packages/nuxt3/src/core/app.ts index d912f7a442..67120a258a 100644 --- a/packages/nuxt3/src/core/app.ts +++ b/packages/nuxt3/src/core/app.ts @@ -2,7 +2,7 @@ import { promises as fsp } from 'fs' import { dirname, resolve } from 'pathe' import defu from 'defu' import type { Nuxt, NuxtApp, NuxtPlugin } from '@nuxt/schema' -import { findPath, resolveFiles, normalizePlugin, normalizeTemplate, compileTemplate, templateUtils } from '@nuxt/kit' +import { findPath, resolveFiles, normalizePlugin, normalizeTemplate, compileTemplate, templateUtils, tryResolveModule } from '@nuxt/kit' import * as defaultTemplates from './templates' @@ -59,12 +59,17 @@ export async function resolveApp (nuxt: Nuxt, app: NuxtApp) { app.mainComponent = await findPath(['~/App', '~/app']) } if (!app.mainComponent) { - app.mainComponent = resolve(nuxt.options.appDir, 'components/nuxt-welcome.vue') + app.mainComponent = tryResolveModule('@nuxt/ui-templates/templates/welcome.vue') } // Default root component app.rootComponent = resolve(nuxt.options.appDir, 'components/nuxt-root.vue') + // Resolve error component + if (!app.errorComponent) { + app.errorComponent = (await findPath(['~/error'])) || resolve(nuxt.options.appDir, 'components/nuxt-error-page.vue') + } + // Resolve plugins for (const config of [...nuxt.options._extends.map(layer => layer.config), nuxt.options]) { app.plugins.push(...[ diff --git a/packages/nuxt3/src/core/builder.ts b/packages/nuxt3/src/core/builder.ts index 70c25b86fc..d196af81cf 100644 --- a/packages/nuxt3/src/core/builder.ts +++ b/packages/nuxt3/src/core/builder.ts @@ -10,10 +10,13 @@ export async function build (nuxt: Nuxt) { if (nuxt.options.dev) { watch(nuxt) nuxt.hook('builder:watch', async (event, path) => { - if (event !== 'change' && /app|plugins/i.test(path)) { + if (event !== 'change' && /app|error|plugins/i.test(path)) { if (path.match(/app/i)) { app.mainComponent = null } + if (path.match(/error/i)) { + app.errorComponent = null + } await generateApp(nuxt, app) } }) diff --git a/packages/nuxt3/src/core/nuxt.ts b/packages/nuxt3/src/core/nuxt.ts index a98a0e3acc..0af512349c 100644 --- a/packages/nuxt3/src/core/nuxt.ts +++ b/packages/nuxt3/src/core/nuxt.ts @@ -1,7 +1,7 @@ import { resolve } from 'pathe' import { createHooks } from 'hookable' import type { Nuxt, NuxtOptions, NuxtConfig, ModuleContainer, NuxtHooks } from '@nuxt/schema' -import { loadNuxtConfig, LoadNuxtOptions, nuxtCtx, installModule, addComponent, addVitePlugin, addWebpackPlugin } from '@nuxt/kit' +import { loadNuxtConfig, LoadNuxtOptions, nuxtCtx, installModule, addComponent, addVitePlugin, addWebpackPlugin, tryResolveModule } from '@nuxt/kit' // Temporary until finding better placement /* eslint-disable import/no-restricted-paths */ import pagesModule from '../pages/module' @@ -76,7 +76,7 @@ async function initNuxt (nuxt: Nuxt) { // Add addComponent({ name: 'NuxtWelcome', - filePath: resolve(nuxt.options.appDir, 'components/nuxt-welcome.vue') + filePath: tryResolveModule('@nuxt/ui-templates/templates/welcome.vue') }) // Add @@ -108,6 +108,7 @@ export async function loadNuxt (opts: LoadNuxtOptions): Promise { options._majorVersion = 3 options._modules.push(pagesModule, metaModule, componentsModule, autoImportsModule) options.modulesDir.push(resolve(pkgDir, 'node_modules')) + options.build.transpile.push('@nuxt/ui-templates') options.alias['vue-demi'] = resolve(options.appDir, 'compat/vue-demi') options.alias['@vue/composition-api'] = resolve(options.appDir, 'compat/capi') diff --git a/packages/nuxt3/src/core/templates.ts b/packages/nuxt3/src/core/templates.ts index 2c5f8bb7e8..280943fc5e 100644 --- a/packages/nuxt3/src/core/templates.ts +++ b/packages/nuxt3/src/core/templates.ts @@ -33,6 +33,11 @@ export const rootComponentTemplate = { filename: 'root-component.mjs', getContents: (ctx: TemplateContext) => genExport(ctx.app.rootComponent, ['default']) } +// TODO: Use an alias +export const errorComponentTemplate = { + filename: 'error-component.mjs', + getContents: (ctx: TemplateContext) => genExport(ctx.app.errorComponent, ['default']) +} export const cssTemplate = { filename: 'css.mjs', diff --git a/packages/nuxt3/src/pages/module.ts b/packages/nuxt3/src/pages/module.ts index 0527cc1974..ef5563d824 100644 --- a/packages/nuxt3/src/pages/module.ts +++ b/packages/nuxt3/src/pages/module.ts @@ -42,7 +42,7 @@ export default defineNuxtModule({ nuxt.hook('app:resolve', (app) => { // Add default layout for pages - if (app.mainComponent.includes('nuxt-welcome')) { + if (app.mainComponent.includes('@nuxt/ui-templates')) { app.mainComponent = resolve(runtimeDir, 'app.vue') } }) diff --git a/packages/nuxt3/src/pages/runtime/router.ts b/packages/nuxt3/src/pages/runtime/router.ts index 0e9f8a5aa1..27efae368a 100644 --- a/packages/nuxt3/src/pages/runtime/router.ts +++ b/packages/nuxt3/src/pages/runtime/router.ts @@ -9,7 +9,7 @@ import { import { createError } from 'h3' import NuxtPage from './page' import NuxtLayout from './layout' -import { callWithNuxt, defineNuxtPlugin, useRuntimeConfig, NuxtApp } from '#app' +import { callWithNuxt, defineNuxtPlugin, useRuntimeConfig, NuxtApp, throwError, clearError } from '#app' // @ts-ignore import routes from '#build/routes' // @ts-ignore @@ -86,6 +86,11 @@ export default defineNuxtPlugin((nuxtApp) => { } } + if (process.client && !nuxtApp.isHydrating) { + // Clear any existing errors + await callWithNuxt(nuxtApp as NuxtApp, clearError) + } + for (const entry of middlewareEntries) { const middleware = typeof entry === 'string' ? nuxtApp._middleware.named[entry] || await namedMiddleware[entry]?.().then(r => r.default || r) : entry @@ -96,9 +101,10 @@ export default defineNuxtPlugin((nuxtApp) => { const result = await callWithNuxt(nuxtApp as NuxtApp, middleware, [to, from]) if (process.server) { if (result === false || result instanceof Error) { - return result || createError({ + const error = result || createError({ statusMessage: `Route navigation aborted: ${nuxtApp.ssrContext.url}` }) + return callWithNuxt(nuxtApp, throwError, [error]) } } if (result || result === false) { return result } @@ -110,6 +116,15 @@ export default defineNuxtPlugin((nuxtApp) => { }) nuxtApp.hook('app:created', async () => { + router.afterEach((to) => { + if (to.matched.length === 0) { + callWithNuxt(nuxtApp, throwError, [createError({ + statusCode: 404, + statusMessage: `Page not found: ${to.fullPath}` + })]) + } + }) + if (process.server) { router.push(nuxtApp.ssrContext.url) @@ -124,16 +139,8 @@ export default defineNuxtPlugin((nuxtApp) => { try { await router.isReady() - - const is404 = router.currentRoute.value.matched.length === 0 - if (process.server && is404) { - throw createError({ - statusCode: 404, - statusMessage: `Page not found: ${nuxtApp.ssrContext.url}` - }) - } } catch (error) { - nuxtApp.ssrContext.error = error + callWithNuxt(nuxtApp, throwError, [error]) } }) diff --git a/packages/schema/src/types/nuxt.ts b/packages/schema/src/types/nuxt.ts index 2f5988bc5b..82254090f4 100644 --- a/packages/schema/src/types/nuxt.ts +++ b/packages/schema/src/types/nuxt.ts @@ -53,6 +53,7 @@ export interface NuxtPlugin { export interface NuxtApp { mainComponent?: string rootComponent?: string + errorComponent?: string dir: string extensions: string[] plugins: NuxtPlugin[] diff --git a/yarn.lock b/yarn.lock index 92b50ae562..3721d193cd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2892,13 +2892,6 @@ __metadata: languageName: node linkType: hard -"@nuxt/design@npm:0.1.5, @nuxt/design@npm:^0.1.5": - version: 0.1.5 - resolution: "@nuxt/design@npm:0.1.5" - checksum: 1e75ba0c7e9519754d185133e1bc97d2056201b2c6ad99a9196ffb94e787a4ee9fa2a64b1dd868febece5b3370c8a50388033b65455edc939fe67bcc1f4bbe8b - languageName: node - linkType: hard - "@nuxt/devalue@npm:^1.2.5": version: 1.2.5 resolution: "@nuxt/devalue@npm:1.2.5" @@ -3035,10 +3028,10 @@ __metadata: dependencies: "@cloudflare/kv-asset-handler": ^0.2.0 "@netlify/functions": ^1.0.0 - "@nuxt/design": 0.1.5 "@nuxt/devalue": ^2.0.0 "@nuxt/kit": 3.0.0 "@nuxt/schema": 3.0.0 + "@nuxt/ui-templates": "npm:@nuxt/ui-templates-edge@latest" "@rollup/plugin-alias": ^3.1.9 "@rollup/plugin-commonjs": ^21.0.2 "@rollup/plugin-inject": ^4.0.4 @@ -3294,6 +3287,13 @@ __metadata: languageName: node linkType: hard +"@nuxt/ui-templates@npm:@nuxt/ui-templates-edge@latest": + version: 0.0.0-27449202.0cb54cc + resolution: "@nuxt/ui-templates-edge@npm:0.0.0-27449202.0cb54cc" + checksum: bbc709d35bf1c586bc604835caf8b6000e4cab22ad56d71d231a7d4e987ab8dd33a9adbba11af684f70826007ad3094480f38524160bf7c6f1a9dc1fcb375f79 + languageName: node + linkType: hard + "@nuxt/ui@npm:@nuxt/ui-edge@latest": version: 0.0.0-27376194.a859489 resolution: "@nuxt/ui-edge@npm:0.0.0-27376194.a859489" @@ -10570,6 +10570,15 @@ __metadata: languageName: unknown linkType: soft +"example-with-errors@workspace:examples/with-errors": + version: 0.0.0-use.local + resolution: "example-with-errors@workspace:examples/with-errors" + dependencies: + "@nuxt/ui": "npm:@nuxt/ui-edge@latest" + nuxt3: latest + languageName: unknown + linkType: soft + "example-with-layouts@workspace:examples/with-layouts": version: 0.0.0-use.local resolution: "example-with-layouts@workspace:examples/with-layouts" @@ -15369,9 +15378,9 @@ __metadata: version: 0.0.0-use.local resolution: "nuxi@workspace:packages/nuxi" dependencies: - "@nuxt/design": 0.1.5 "@nuxt/kit": 3.0.0 "@nuxt/schema": 3.0.0 + "@nuxt/ui-templates": "npm:@nuxt/ui-templates-edge@latest" "@types/clear": ^0 "@types/mri": ^1.1.1 "@types/rimraf": ^3 @@ -15476,10 +15485,10 @@ __metadata: version: 0.0.0-use.local resolution: "nuxt3@workspace:packages/nuxt3" dependencies: - "@nuxt/design": ^0.1.5 "@nuxt/kit": 3.0.0 "@nuxt/nitro": 3.0.0 "@nuxt/schema": 3.0.0 + "@nuxt/ui-templates": "npm:@nuxt/ui-templates-edge@latest" "@nuxt/vite-builder": 3.0.0 "@types/fs-extra": ^9.0.13 "@types/hash-sum": ^1.0.0