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: stitch errors with react owner stack #70393

Merged
merged 6 commits into from
Oct 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions crates/next-core/src/next_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,7 @@ pub struct ExperimentalConfig {
/// directory.
ppr: Option<ExperimentalPartialPrerendering>,
taint: Option<bool>,
react_owner_stack: Option<bool>,
#[serde(rename = "dynamicIO")]
dynamic_io: Option<bool>,
proxy_timeout: Option<f64>,
Expand Down Expand Up @@ -1148,6 +1149,13 @@ impl NextConfig {
Vc::cell(self.experimental.taint.unwrap_or(false))
}

#[turbo_tasks::function]
pub async fn enable_react_owner_stack(self: Vc<Self>) -> Result<Vc<bool>> {
Ok(Vc::cell(
self.await?.experimental.react_owner_stack.unwrap_or(false),
))
}

#[turbo_tasks::function]
pub fn use_swc_css(&self) -> Vc<bool> {
Vc::cell(
Expand Down
21 changes: 14 additions & 7 deletions crates/next-core/src/next_import_map.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,12 +116,14 @@ pub async fn get_next_client_import_map(
match ty.into_value() {
ClientContextType::Pages { .. } => {}
ClientContextType::App { app_dir } => {
let react_flavor =
if *next_config.enable_ppr().await? || *next_config.enable_taint().await? {
"-experimental"
} else {
""
};
let react_flavor = if *next_config.enable_ppr().await?
|| *next_config.enable_taint().await?
|| *next_config.enable_react_owner_stack().await?
{
"-experimental"
} else {
""
};

import_map.insert_exact_alias(
"react",
Expand Down Expand Up @@ -683,7 +685,12 @@ async fn rsc_aliases(
) -> Result<()> {
let ppr = *next_config.enable_ppr().await?;
let taint = *next_config.enable_taint().await?;
let react_channel = if ppr || taint { "-experimental" } else { "" };
let react_owner_stack = *next_config.enable_react_owner_stack().await?;
let react_channel = if ppr || taint || react_owner_stack {
"-experimental"
} else {
""
};
let react_client_package = get_react_client_package(&next_config).await?;

let mut alias = FxIndexMap::default();
Expand Down
3 changes: 3 additions & 0 deletions packages/next/src/build/webpack/plugins/define-env-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,9 @@ export function getDefineEnv({
),
'process.env.__NEXT_PPR': checkIsAppPPREnabled(config.experimental.ppr),
'process.env.__NEXT_DYNAMIC_IO': !!config.experimental.dynamicIO,
'process.env.__NEXT_REACT_OWNER_STACK': Boolean(
config.experimental.reactOwnerStack
),
'process.env.__NEXT_AFTER': config.experimental.after ?? false,
'process.env.NEXT_DEPLOYMENT_ID': config.deploymentId || false,
'process.env.__NEXT_FETCH_CACHE_KEY_PREFIX': fetchCacheKeyPrefix ?? '',
Expand Down
17 changes: 16 additions & 1 deletion packages/next/src/client/app-index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ import React, { use } from 'react'
// eslint-disable-next-line import/no-extraneous-dependencies
import { createFromReadableStream } from 'react-server-dom-webpack/client'
import { HeadManagerContext } from '../shared/lib/head-manager-context.shared-runtime'
import { onRecoverableError } from './on-recoverable-error'
import { onRecoverableError } from './react-client-callbacks/shared'
import {
onCaughtError,
onUncaughtError,
} from './react-client-callbacks/app-router'
import { callServer } from './app-call-server'
import { findSourceMapURL } from './app-find-source-map-url'
import {
Expand All @@ -23,6 +27,8 @@ import { MissingSlotContext } from '../shared/lib/app-router-context.shared-runt

/// <reference types="react-dom/experimental" />

const isReactOwnerStackEnabled = !!process.env.__NEXT_REACT_OWNER_STACK
huozhi marked this conversation as resolved.
Show resolved Hide resolved

const appElement: HTMLElement | Document | null = document

const encoder = new TextEncoder()
Expand Down Expand Up @@ -227,8 +233,17 @@ export function hydrate() {
const rootLayoutMissingTags = window.__next_root_layout_missing_tags
const hasMissingTags = !!rootLayoutMissingTags?.length

const errorCallbacks =
isReactOwnerStackEnabled && process.env.NODE_ENV !== 'production'
? {
onCaughtError,
onUncaughtError,
}
: undefined

const options = {
onRecoverableError,
...errorCallbacks,
} satisfies ReactDOMClient.RootOptions
const isError =
document.documentElement.id === '__next_error__' || hasMissingTags
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import isError from '../../../lib/is-error'
import { isNextRouterError } from '../is-next-router-error'
import { handleClientError } from '../react-dev-overlay/internal/helpers/use-error-handler'

const originConsoleError = window.console.error
export const originConsoleError = window.console.error

// Patch console.error to collect information about hydration errors
export function patchConsoleError() {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
import * as React from 'react'
import React from 'react'
import { ACTION_UNHANDLED_ERROR, type OverlayState } from '../shared'

import { ShadowPortal } from '../internal/components/ShadowPortal'
import { BuildError } from '../internal/container/BuildError'
import { Errors } from '../internal/container/Errors'
import { StaticIndicator } from '../internal/container/StaticIndicator'
import type { SupportedErrorEvent } from '../internal/container/Errors'
import { Errors, type SupportedErrorEvent } from '../internal/container/Errors'
import { parseStack } from '../internal/helpers/parse-stack'
import { StaticIndicator } from '../internal/container/StaticIndicator'
import { Base } from '../internal/styles/Base'
import { ComponentStyles } from '../internal/styles/ComponentStyles'
import { CssReset } from '../internal/styles/CssReset'
import { RootLayoutMissingTagsError } from '../internal/container/root-layout-missing-tags-error'
import type { Dispatcher } from './hot-reloader-client'
import { RuntimeErrorHandler } from '../internal/helpers/runtime-error-handler'

interface ReactDevOverlayState {
reactError: SupportedErrorEvent | null
Expand All @@ -21,14 +20,15 @@ export default class ReactDevOverlay extends React.PureComponent<
state: OverlayState
dispatcher?: Dispatcher
children: React.ReactNode
onReactError: (error: Error) => void
},
ReactDevOverlayState
> {
state = { reactError: null }

static getDerivedStateFromError(error: Error): ReactDevOverlayState {
if (!error.stack) return { reactError: null }

RuntimeErrorHandler.hadRuntimeError = true
return {
reactError: {
id: 0,
Expand All @@ -41,10 +41,6 @@ export default class ReactDevOverlay extends React.PureComponent<
}
}

componentDidCatch(componentErr: Error) {
this.props.onReactError(componentErr)
}

render() {
const { state, children, dispatcher } = this.props
const { reactError } = this.state
Expand Down Expand Up @@ -79,20 +75,11 @@ export default class ReactDevOverlay extends React.PureComponent<
/>
) : (
<>
{reactError ? (
<Errors
isAppDir={true}
versionInfo={state.versionInfo}
initialDisplayState="fullscreen"
errors={[reactError]}
hasStaticIndicator={hasStaticIndicator}
debugInfo={debugInfo}
/>
) : hasRuntimeErrors ? (
{hasRuntimeErrors ? (
<Errors
isAppDir={true}
initialDisplayState="minimized"
errors={state.errors}
initialDisplayState={reactError ? 'fullscreen' : 'minimized'}
errors={reactError ? [reactError] : state.errors}
versionInfo={state.versionInfo}
hasStaticIndicator={hasStaticIndicator}
debugInfo={debugInfo}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { REACT_REFRESH_FULL_RELOAD_FROM_ERROR } from '../shared'
import type { HydrationErrorState } from '../internal/helpers/hydration-error-info'
import type { DebugInfo } from '../types'
import { useUntrackedPathname } from '../../navigation-untracked'
import { getReactStitchedError } from '../internal/helpers/stitched-error'

export interface Dispatcher {
onBuildOk(): void
Expand Down Expand Up @@ -563,10 +564,12 @@ export default function HotReload({
const componentStackTrace =
(error as any)._componentStack || errorDetails?.componentStack
const warning = errorDetails?.warning
const stitchedError = getReactStitchedError(error)

dispatch({
type: ACTION_UNHANDLED_ERROR,
reason: error,
frames: parseStack(error.stack),
reason: stitchedError,
frames: parseStack(stitchedError.stack || ''),
componentStackFrames:
typeof componentStackTrace === 'string'
? parseComponentStack(componentStackTrace)
Expand All @@ -576,19 +579,18 @@ export default function HotReload({
},
[dispatch]
)

const handleOnUnhandledRejection = useCallback(
(reason: Error): void => {
const stitchedError = getReactStitchedError(reason)
dispatch({
type: ACTION_UNHANDLED_REJECTION,
reason: reason,
frames: parseStack(reason.stack!),
reason: stitchedError,
frames: parseStack(stitchedError.stack || ''),
})
},
[dispatch]
)
const handleOnReactError = useCallback(() => {
RuntimeErrorHandler.hadRuntimeError = true
}, [])
useErrorHandler(handleOnUnhandledError, handleOnUnhandledRejection)

const webSocketRef = useWebsocket(assetPrefix)
Expand Down Expand Up @@ -674,11 +676,7 @@ export default function HotReload({
])

return (
<ReactDevOverlay
onReactError={handleOnReactError}
state={state}
dispatcher={dispatcher}
>
<ReactDevOverlay state={state} dispatcher={dispatcher}>
{children}
</ReactDevOverlay>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ export function createDevOverlayElement(reactEl: React.ReactElement) {
<FallbackLayout>
<ReactDevOverlay
state={{ ...INITIAL_OVERLAY_STATE, rootLayoutMissingTags }}
onReactError={() => {}}
>
{reactEl}
</ReactDevOverlay>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import {
} from '../helpers/hydration-error-info'
import { NodejsInspectorCopyButton } from '../components/nodejs-inspector'
import { CopyButton } from '../components/copy-button'
import { ConsoleError } from '../helpers/console-error'
import { isUnhandledConsoleOrRejection } from '../helpers/console-error'

export type SupportedErrorEvent = {
id: number
Expand Down Expand Up @@ -61,11 +61,11 @@ function ErrorDescription({
error: Error
hydrationWarning: string | null
}) {
const isFromConsoleError = error instanceof ConsoleError
const isUnhandledError = isUnhandledConsoleOrRejection(error)
// If there's hydration warning or console error, skip displaying the error name
return (
<>
{isFromConsoleError || hydrationWarning ? '' : error.name + ': '}
{isUnhandledError || hydrationWarning ? '' : error.name + ': '}
<HotlinkedText
text={hydrationWarning || error.message}
matcher={isNextjsLink}
Expand Down Expand Up @@ -257,8 +257,7 @@ export function Errors({
const isServerError = ['server', 'edge-server'].includes(
getErrorSource(error) || ''
)
const isFromConsoleError = error instanceof ConsoleError

const isUnhandledError = isUnhandledConsoleOrRejection(error)
const errorDetails: HydrationErrorState = (error as any).details || {}
const notes = errorDetails.notes || ''
const [warningTemplate, serverContent, clientContent] =
Expand Down Expand Up @@ -308,7 +307,7 @@ export function Errors({
>
{isServerError
? 'Server Error'
: isFromConsoleError
: isUnhandledError
? 'Console Error'
: 'Unhandled Runtime Error'}
</h1>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
export class ConsoleError extends Error {
constructor(message: string) {
super(message)
this.name = 'ConsoleError'
}
// Represent non Error shape unhandled promise rejections or console.error errors.
// Those errors will be captured and displayed in Error Overlay.
type UnhandledError = Error & { digest: 'NEXT_UNHANDLED_ERROR' }

export function createUnhandledError(message: string): UnhandledError {
const error = new Error(message) as UnhandledError
error.digest = 'NEXT_UNHANDLED_ERROR'
return error
}

export const isUnhandledConsoleOrRejection = (
error: any
): error is UnhandledError => {
return error && error.digest === 'NEXT_UNHANDLED_ERROR'
}
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export function parseComponentStack(
): ComponentStackFrame[] {
const componentStackFrames: ComponentStackFrame[] = []
for (const line of componentStack.trim().split('\n')) {
// TODO: support safari stack trace
// Get component and file from the component stack line
const match = /at ([^ ]+)( \((.*)\))?/.exec(line)
if (match?.[1]) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import React from 'react'
import isError from '../../../../../lib/is-error'

const captureOwnerStack = (React as any).captureOwnerStack || (() => '')

const REACT_ERROR_STACK_BOTTOM_FRAME = 'react-stack-bottom-frame'
const REACT_ERROR_STACK_BOTTOM_FRAME_REGEX = new RegExp(
`(at ${REACT_ERROR_STACK_BOTTOM_FRAME} )|(${REACT_ERROR_STACK_BOTTOM_FRAME}\\@)`
)

export function getReactStitchedError<T = unknown>(err: T): Error | T {
if (!process.env.__NEXT_REACT_OWNER_STACK) {
return err
}

const isErrorInstance = isError(err)
const originStack = isErrorInstance ? err.stack || '' : ''
const originMessage = isErrorInstance ? err.message : ''
const stackLines = originStack.split('\n')
const indexOfSplit = stackLines.findIndex((line) =>
REACT_ERROR_STACK_BOTTOM_FRAME_REGEX.test(line)
)
const isOriginalReactError = indexOfSplit >= 0 // has the react-stack-bottom-frame
let newStack = isOriginalReactError
? stackLines.slice(0, indexOfSplit).join('\n')
: originStack

const newError = new Error(originMessage)
// Copy all enumerable properties, e.g. digest
Object.assign(newError, err)
newError.stack = newStack

// Avoid duplicate overriding stack frames
const ownerStack = captureOwnerStack()
if (ownerStack && newStack.endsWith(ownerStack) === false) {
newStack += ownerStack
// Override stack
newError.stack = newStack
}

return newError
}
Loading
Loading