diff --git a/packages/next/src/client/components/react-dev-overlay/app/hot-reloader-client.tsx b/packages/next/src/client/components/react-dev-overlay/app/hot-reloader-client.tsx index f73f436381a22..21156e195c1b6 100644 --- a/packages/next/src/client/components/react-dev-overlay/app/hot-reloader-client.tsx +++ b/packages/next/src/client/components/react-dev-overlay/app/hot-reloader-client.tsx @@ -4,6 +4,7 @@ import stripAnsi from 'next/dist/compiled/strip-ansi' import formatWebpackMessages from '../internal/helpers/format-webpack-messages' import { useRouter } from '../../navigation' import { + ACTION_AFTER_ERROR, ACTION_BEFORE_REFRESH, ACTION_BUILD_ERROR, ACTION_BUILD_OK, @@ -38,10 +39,12 @@ import type { HydrationErrorState } from '../internal/helpers/hydration-error-in import type { DebugInfo } from '../types' import { useUntrackedPathname } from '../../navigation-untracked' import { getReactStitchedError } from '../internal/helpers/stitched-error' +import { decorateServerError } from '../../../../shared/lib/error-source' export interface Dispatcher { onBuildOk(): void onBuildError(message: string): void + onAfterError(error: Error): void onVersionInfo(versionInfo: VersionInfo): void onDebugInfo(debugInfo: DebugInfo): void onBeforeRefresh(): void @@ -276,7 +279,19 @@ function processMessage( return } - function handleErrors(errors: ReadonlyArray) { + function handleAfterErrors(errors: ReadonlyArray) { + console.log('handleAfterErrors', errors) + for (const error of errors) { + dispatcher.onAfterError(error) + } + + // // Also log them to the console. + // for (let i = 0; i < errors.length; i++) { + // console.error(errors[i]) + // } + } + + function handleBuildErrors(errors: ReadonlyArray) { // "Massage" webpack messages. const formatted = formatWebpackMessages({ errors: errors, @@ -387,7 +402,7 @@ function processMessage( }) ) - handleErrors(errors) + handleBuildErrors(errors) return } @@ -501,13 +516,24 @@ function processMessage( // TODO-APP: potentially only refresh if the currently viewed page was added/removed. return router.hmrRefresh() } + case HMR_ACTIONS_SENT_TO_BROWSER.AFTER_ERROR: { + const { errorJSON, source } = obj + if (errorJSON) { + const { message, stack } = JSON.parse(errorJSON) + const error = new Error(message) + error.stack = stack + decorateServerError(error, source ?? 'server') + handleAfterErrors([error]) + } + return + } case HMR_ACTIONS_SENT_TO_BROWSER.SERVER_ERROR: { const { errorJSON } = obj if (errorJSON) { const { message, stack } = JSON.parse(errorJSON) const error = new Error(message) error.stack = stack - handleErrors([error]) + handleBuildErrors([error]) } return } @@ -536,6 +562,13 @@ export default function HotReload({ onBuildError(message) { dispatch({ type: ACTION_BUILD_ERROR, message }) }, + onAfterError(reason) { + dispatch({ + type: ACTION_AFTER_ERROR, + reason, + frames: parseStack(reason.stack!), + }) + }, onBeforeRefresh() { dispatch({ type: ACTION_BEFORE_REFRESH }) }, diff --git a/packages/next/src/client/components/react-dev-overlay/internal/container/Errors.tsx b/packages/next/src/client/components/react-dev-overlay/internal/container/Errors.tsx index 45f10f21d6173..8f3bbaac068a0 100644 --- a/packages/next/src/client/components/react-dev-overlay/internal/container/Errors.tsx +++ b/packages/next/src/client/components/react-dev-overlay/internal/container/Errors.tsx @@ -1,7 +1,9 @@ import { useState, useEffect, useMemo, useCallback } from 'react' import { + ACTION_AFTER_ERROR, ACTION_UNHANDLED_ERROR, ACTION_UNHANDLED_REJECTION, + type AfterErrorAction, type UnhandledErrorAction, type UnhandledRejectionAction, } from '../../shared' @@ -38,7 +40,7 @@ import { export type SupportedErrorEvent = { id: number - event: UnhandledErrorAction | UnhandledRejectionAction + event: UnhandledErrorAction | UnhandledRejectionAction | AfterErrorAction } export type ErrorsProps = { isAppDir: boolean @@ -98,6 +100,7 @@ function ErrorDescription({ function getErrorSignature(ev: SupportedErrorEvent): string { const { event } = ev switch (event.type) { + case ACTION_AFTER_ERROR: case ACTION_UNHANDLED_ERROR: case ACTION_UNHANDLED_REJECTION: { return `${event.reason.name}::${event.reason.message}::${event.reason.stack}` diff --git a/packages/next/src/client/components/react-dev-overlay/internal/helpers/get-error-by-type.ts b/packages/next/src/client/components/react-dev-overlay/internal/helpers/get-error-by-type.ts index 2b8157e35e9e0..7bd118b467d04 100644 --- a/packages/next/src/client/components/react-dev-overlay/internal/helpers/get-error-by-type.ts +++ b/packages/next/src/client/components/react-dev-overlay/internal/helpers/get-error-by-type.ts @@ -1,4 +1,5 @@ import { + ACTION_AFTER_ERROR, ACTION_UNHANDLED_ERROR, ACTION_UNHANDLED_REJECTION, } from '../../shared' @@ -22,6 +23,7 @@ export async function getErrorByType( ): Promise { const { id, event } = ev switch (event.type) { + case ACTION_AFTER_ERROR: case ACTION_UNHANDLED_ERROR: case ACTION_UNHANDLED_REJECTION: { const readyRuntimeError: ReadyRuntimeError = { diff --git a/packages/next/src/client/components/react-dev-overlay/shared.ts b/packages/next/src/client/components/react-dev-overlay/shared.ts index b465a8f661c3d..02390501b331a 100644 --- a/packages/next/src/client/components/react-dev-overlay/shared.ts +++ b/packages/next/src/client/components/react-dev-overlay/shared.ts @@ -32,6 +32,7 @@ export const ACTION_REFRESH = 'fast-refresh' export const ACTION_VERSION_INFO = 'version-info' export const ACTION_UNHANDLED_ERROR = 'unhandled-error' export const ACTION_UNHANDLED_REJECTION = 'unhandled-rejection' +export const ACTION_AFTER_ERROR = 'after-error' export const ACTION_DEBUG_INFO = 'debug-info' interface StaticIndicatorAction { @@ -66,6 +67,12 @@ export interface UnhandledRejectionAction { frames: StackFrame[] } +export interface AfterErrorAction { + type: typeof ACTION_AFTER_ERROR + reason: Error + frames: StackFrame[] +} + export interface DebugInfoAction { type: typeof ACTION_DEBUG_INFO debugInfo: any @@ -83,6 +90,7 @@ export type BusEvent = | FastRefreshAction | UnhandledErrorAction | UnhandledRejectionAction + | AfterErrorAction | VersionInfoAction | StaticIndicatorAction | DebugInfoAction @@ -147,8 +155,10 @@ export function useErrorOverlayReducer() { refreshState: { type: 'idle' }, } } + case ACTION_AFTER_ERROR: case ACTION_UNHANDLED_ERROR: case ACTION_UNHANDLED_REJECTION: { + console.log('useErrorOverlayReducer', action) switch (_state.refreshState.type) { case 'idle': { return { diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts index f51d8f3de918c..b15838835520d 100644 --- a/packages/next/src/server/base-server.ts +++ b/packages/next/src/server/base-server.ts @@ -366,6 +366,8 @@ export default abstract class Server< protected readonly enabledDirectories: NextEnabledDirectories protected abstract getEnabledDirectories(dev: boolean): NextEnabledDirectories + protected afterTaskErrorHandler: ((error: unknown) => void) | undefined + protected readonly experimentalTestProxy?: boolean protected abstract findPageComponents(params: { @@ -2437,7 +2439,7 @@ export default abstract class Server< postponed, waitUntil: this.getWaitUntil(), onClose: res.onClose.bind(res), - onAfterTaskError: undefined, + onAfterTaskError: this.afterTaskErrorHandler, // only available in dev setAppIsrStatus: (this as any).setAppIsrStatus, } @@ -2484,7 +2486,7 @@ export default abstract class Server< isRevalidate: isSSG, waitUntil: this.getWaitUntil(), onClose: res.onClose.bind(res), - onAfterTaskError: undefined, + onAfterTaskError: this.afterTaskErrorHandler, onInstrumentationRequestError: this.renderOpts.onInstrumentationRequestError, buildId: this.renderOpts.buildId, diff --git a/packages/next/src/server/dev/hot-reloader-types.ts b/packages/next/src/server/dev/hot-reloader-types.ts index 58f0a0c297859..284c989019268 100644 --- a/packages/next/src/server/dev/hot-reloader-types.ts +++ b/packages/next/src/server/dev/hot-reloader-types.ts @@ -7,6 +7,7 @@ import type { RouteDefinition } from '../route-definitions/route-definition' import type { Project, Update as TurbopackUpdate } from '../../build/swc/types' import type { VersionInfo } from './parse-version-info' import type { DebugInfo } from '../../client/components/react-dev-overlay/types' +import type { ErrorSourceType } from '../../shared/lib/error-source' export const enum HMR_ACTIONS_SENT_TO_BROWSER { ADDED_PAGE = 'addedPage', @@ -22,6 +23,7 @@ export const enum HMR_ACTIONS_SENT_TO_BROWSER { DEV_PAGES_MANIFEST_UPDATE = 'devPagesManifestUpdate', TURBOPACK_MESSAGE = 'turbopack-message', SERVER_ERROR = 'serverError', + AFTER_ERROR = 'afterError', TURBOPACK_CONNECTED = 'turbopack-connected', APP_ISR_MANIFEST = 'appIsrManifest', } @@ -31,6 +33,12 @@ interface ServerErrorAction { errorJSON: string } +export interface AfterErrorAction { + action: HMR_ACTIONS_SENT_TO_BROWSER.AFTER_ERROR + source: ErrorSourceType + errorJSON: string +} + export interface TurbopackMessageAction { action: HMR_ACTIONS_SENT_TO_BROWSER.TURBOPACK_MESSAGE data: TurbopackUpdate | TurbopackUpdate[] @@ -130,6 +138,7 @@ export type HMR_ACTION_TYPES = | ServerOnlyChangesAction | DevPagesManifestUpdateAction | ServerErrorAction + | AfterErrorAction | AppIsrManifestAction export type TurbopackMsgToBrowser = diff --git a/packages/next/src/server/dev/next-dev-server.ts b/packages/next/src/server/dev/next-dev-server.ts index cc85998b43d94..49e3a498a75ce 100644 --- a/packages/next/src/server/dev/next-dev-server.ts +++ b/packages/next/src/server/dev/next-dev-server.ts @@ -200,6 +200,15 @@ export default class DevServer extends Server { this.pagesDir = pagesDir this.appDir = appDir + if (this.nextConfig.experimental.after) { + this.afterTaskErrorHandler = (error: unknown) => { + return this.bundlerService.reportAfterTaskError( + error, + process.env.NEXT_RUNTIME === 'edge' ? 'edge-server' : 'server' + ) + } + } + if (this.nextConfig.experimental.serverComponentsHmrCache) { this.serverComponentsHmrCache = new LRUCache( this.nextConfig.cacheMaxMemorySize, diff --git a/packages/next/src/server/lib/dev-bundler-service.ts b/packages/next/src/server/lib/dev-bundler-service.ts index 3cd148df9d7a5..66b93d526e183 100644 --- a/packages/next/src/server/lib/dev-bundler-service.ts +++ b/packages/next/src/server/lib/dev-bundler-service.ts @@ -36,6 +36,11 @@ export class DevBundlerService { public logErrorWithOriginalStack = this.bundler.logErrorWithOriginalStack.bind(this.bundler) + public reportAfterTaskError: typeof this.bundler.reportAfterTaskError = + async (...args) => { + return await this.bundler.reportAfterTaskError(...args) + } + public async getFallbackErrorComponents(url?: string) { await this.bundler.hotReloader.buildFallbackError() // Build the error page to ensure the fallback is built too. diff --git a/packages/next/src/server/lib/router-utils/setup-dev-bundler.ts b/packages/next/src/server/lib/router-utils/setup-dev-bundler.ts index a8dab602215ec..fbd60aa36b5ae 100644 --- a/packages/next/src/server/lib/router-utils/setup-dev-bundler.ts +++ b/packages/next/src/server/lib/router-utils/setup-dev-bundler.ts @@ -75,6 +75,9 @@ import { normalizeMetadataPageToRoute } from '../../../lib/metadata/get-metadata import { createEnvDefinitions } from '../experimental/create-env-definitions' import { JsConfigPathsPlugin } from '../../../build/webpack/plugins/jsconfig-paths-plugin' import { store as consoleStore } from '../../../build/output/store' +import { type ErrorSourceType } from '../../../shared/lib/error-source' +import { stringifyError } from '../../../shared/lib/utils' +import isError from '../../../lib/is-error' export type SetupOpts = { renderServer: LazyRenderServerInstance @@ -950,11 +953,32 @@ async function startWatcher(opts: SetupOpts) { } } + async function reportAfterTaskError(error: unknown, source: ErrorSourceType) { + const serializeErrorToJSON = (err: unknown) => { + if (isError(err)) { + return stringifyError(err) + } + + try { + return JSON.stringify(err) + } catch (_) { + return '' + } + } + + hotReloader.send({ + action: HMR_ACTIONS_SENT_TO_BROWSER.AFTER_ERROR, + source: source, + errorJSON: serializeErrorToJSON(error), + }) + } + return { serverFields, hotReloader, requestHandler, logErrorWithOriginalStack, + reportAfterTaskError, async ensureMiddleware(requestUrl?: string) { if (!serverFields.actualMiddlewareFile) return diff --git a/test/e2e/app-dir/next-after-app/app/test/layout.js b/test/e2e/app-dir/next-after-app/app/test/layout.js new file mode 100644 index 0000000000000..8525f5f8c0b2a --- /dev/null +++ b/test/e2e/app-dir/next-after-app/app/test/layout.js @@ -0,0 +1,12 @@ +export const metadata = { + title: 'Next.js', + description: 'Generated by Next.js', +} + +export default function RootLayout({ children }) { + return ( + + {children} + + ) +} diff --git a/test/e2e/app-dir/next-after-app/app/test/nested/page.js b/test/e2e/app-dir/next-after-app/app/test/nested/page.js new file mode 100644 index 0000000000000..7ae9e9f79b4d4 --- /dev/null +++ b/test/e2e/app-dir/next-after-app/app/test/nested/page.js @@ -0,0 +1,25 @@ +import { unstable_after } from 'next/server' +import { setTimeout } from 'timers/promises' + +export default function Page() { + helper() + return null +} + +function helper() { + unstable_after(async () => { + await setTimeout(500) + nestedHelper() + }) +} + +function nestedHelper() { + unstable_after(async () => { + await setTimeout(500) + throws() + }) +} + +function throws() { + throw new Error('kaboom') +} diff --git a/test/e2e/app-dir/next-after-app/app/test/page.js b/test/e2e/app-dir/next-after-app/app/test/page.js new file mode 100644 index 0000000000000..ebd1f170c31c6 --- /dev/null +++ b/test/e2e/app-dir/next-after-app/app/test/page.js @@ -0,0 +1,21 @@ +import { unstable_after } from 'next/server' +import { setTimeout } from 'timers/promises' + +export default function Page() { + helper() + return null +} + +function helper() { + unstable_after(async () => { + // TODO(after): this delay is load-bearing, otherwise + // the client-side won't have booted yet and our `HMR_ACTIONS_SENT_TO_BROWSER.AFTER_ERROR` + // will be dropped on the floor, so we won't display anything + await setTimeout(1000) + throws() + }) +} + +function throws() { + throw new Error('kaboom') +}