Skip to content

Commit

Permalink
stitch errors
Browse files Browse the repository at this point in the history
  • Loading branch information
huozhi committed Oct 16, 2024
1 parent 5973081 commit 518c51a
Show file tree
Hide file tree
Showing 27 changed files with 568 additions and 112 deletions.
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

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
@@ -1,17 +1,19 @@
import { isNextRouterError } from '../is-next-router-error'
import { handleClientError } from '../react-dev-overlay/internal/helpers/use-error-handler'

export const originConsoleError = window.console.error

// Patch console.error to collect information about hydration errors
export function patchConsoleError() {
// Ensure it's only patched once
if (typeof window === 'undefined') {
return
}

const originConsoleError = window.console.error
window.console.error = (...args) => {
// See https://github.com/facebook/react/blob/d50323eb845c5fde0d720cae888bf35dedd05506/packages/react-reconciler/src/ReactFiberErrorLogger.js#L78
const error = process.env.NODE_ENV !== 'production' ? args[1] : args[0]
const errorIndex = process.env.NODE_ENV !== 'production' ? 1 : 0
const error = args[errorIndex]

if (!isNextRouterError(error)) {
if (process.env.NODE_ENV !== 'production') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ 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 { getReactStitchedError } from '../internal/helpers/stitched-error'

interface ReactDevOverlayState {
reactError: SupportedErrorEvent | null
Expand All @@ -27,15 +28,17 @@ export default class ReactDevOverlay extends React.PureComponent<
> {
state = { reactError: null }

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

const error = getReactStitchedError(err)
return {
reactError: {
id: 0,
event: {
type: ACTION_UNHANDLED_ERROR,
reason: error,
frames: parseStack(error.stack),
frames: parseStack(error.stack || ''),
},
},
}
Expand Down
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,43 @@
import React from 'react'

const captureOwnerStack = process.env.__NEXT_REACT_OWNER_STACK
? (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 {
if (!process.env.__NEXT_REACT_OWNER_STACK) {
return err as any
}

const isErrorInstance = err instanceof Error
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
}
3 changes: 1 addition & 2 deletions packages/next/src/client/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
/* global location */
// imports polyfill from `@next/polyfill-module` after build.
import '../build/polyfills/polyfill-module'

import type Router from '../shared/lib/router/router'
import type {
AppComponent,
Expand Down Expand Up @@ -46,7 +45,7 @@ import {
SearchParamsContext,
PathParamsContext,
} from '../shared/lib/hooks-client-context.shared-runtime'
import { onRecoverableError } from './on-recoverable-error'
import { onRecoverableError } from './react-client-callbacks/shared'
import tracer from './tracing/tracer'
import reportToSocket from './tracing/report-to-socket'

Expand Down
27 changes: 0 additions & 27 deletions packages/next/src/client/on-recoverable-error.ts

This file was deleted.

85 changes: 85 additions & 0 deletions packages/next/src/client/react-client-callbacks/app-router.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// This file is only used in app router due to the specific error state handling.

import type { HydrationOptions } from 'react-dom/client'
import { getReactStitchedError } from '../components/react-dev-overlay/internal/helpers/stitched-error'
import { handleClientError } from '../components/react-dev-overlay/internal/helpers/use-error-handler'
import { isNextRouterError } from '../components/is-next-router-error'
import { isBailoutToCSRError } from '../../shared/lib/lazy-dynamic/bailout-to-csr'
import { reportGlobalError } from './report-global-error'
import isError from '../../lib/is-error'
import { originConsoleError } from '../components/globals/intercept-console-error'

export const onCaughtError: HydrationOptions['onCaughtError'] = (
err,
errorInfo
) => {
// Skip certain custom errors which are not expected to be reported on client
if (isBailoutToCSRError(err) || isNextRouterError(err)) return

const stitchedError = getReactStitchedError(err)

if (process.env.NODE_ENV === 'development') {
const errorBoundaryComponent = errorInfo?.errorBoundary?.constructor
const errorBoundaryName =
// read react component displayName
(errorBoundaryComponent as any)?.displayName ||
errorBoundaryComponent?.name ||
'Unknown'

const componentThatErroredFrame = errorInfo?.componentStack?.split('\n')[1]

// Match chrome or safari stack trace
const matches =
componentThatErroredFrame?.match(/\s+at (\w+)\s+|(\w+)@/) ?? []
const componentThatErroredName = matches[1] || matches[2] || 'Unknown'

// In development mode, pass along the component stack to the error
if (process.env.NODE_ENV === 'development' && errorInfo.componentStack) {
;(stitchedError as any)._componentStack = errorInfo.componentStack
}

// Create error location with errored component and error boundary, to match the behavior of default React onCaughtError handler.
const errorLocation = `The above error occurred in the <${componentThatErroredName}> component. It was handled by the <${errorBoundaryName}> error boundary.`

const originErrorStack = isError(err) ? err.stack || '' : ''

// Always log the modified error instance so the console.error interception side can pick it up easily without constructing an error again.
originConsoleError(originErrorStack + '\n\n' + errorLocation)
handleClientError(stitchedError)
} else {
originConsoleError(err)
}
}

export const onUncaughtError: HydrationOptions['onUncaughtError'] = (
err,
errorInfo
) => {
// Skip certain custom errors which are not expected to be reported on client
if (isBailoutToCSRError(err) || isNextRouterError(err)) return

const stitchedError = getReactStitchedError(err)

if (process.env.NODE_ENV === 'development') {
const componentThatErroredFrame = errorInfo?.componentStack?.split('\n')[1]

// Match chrome or safari stack trace
const matches =
componentThatErroredFrame?.match(/\s+at (\w+)\s+|(\w+)@/) ?? []
const componentThatErroredName = matches[1] || matches[2] || 'Unknown'

// In development mode, pass along the component stack to the error
if (process.env.NODE_ENV === 'development' && errorInfo.componentStack) {
;(stitchedError as any)._componentStack = errorInfo.componentStack
}

// Create error location with errored component and error boundary, to match the behavior of default React onCaughtError handler.
const errorLocation = `The above error occurred in the <${componentThatErroredName}> component.`

originConsoleError(stitchedError.stack + '\n\n' + errorLocation)
// Always log the modified error instance so the console.error interception side can pick it up easily without constructing an error again.
reportGlobalError(stitchedError)
} else {
reportGlobalError(err)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export const reportGlobalError =
typeof reportError === 'function'
? // In modern browsers, reportError will dispatch an error event,
// emulating an uncaught JavaScript error.
reportError
: (error: any) => {
window.console.error(error)
}
20 changes: 20 additions & 0 deletions packages/next/src/client/react-client-callbacks/shared.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// This module can be shared between both pages router and app router

import type { HydrationOptions } from 'react-dom/client'
import { isBailoutToCSRError } from '../../shared/lib/lazy-dynamic/bailout-to-csr'
import { reportGlobalError } from './report-global-error'

export const onRecoverableError: HydrationOptions['onRecoverableError'] = (
err,
errorInfo
) => {
const stitchedError = err // getReactStitchedError(err)
// In development mode, pass along the component stack to the error
if (process.env.NODE_ENV === 'development' && errorInfo.componentStack) {
;(stitchedError as any)._componentStack = errorInfo.componentStack
}
// Skip certain custom errors which are not expected to be reported on client
if (isBailoutToCSRError(err)) return

reportGlobalError(stitchedError)
}
3 changes: 2 additions & 1 deletion packages/next/src/lib/needs-experimental-react.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { NextConfig } from '../server/config-shared'

export function needsExperimentalReact(config: NextConfig) {
return Boolean(config.experimental?.ppr || config.experimental?.taint)
const { ppr, taint, reactOwnerStack } = config.experimental || {}
return Boolean(ppr || taint || reactOwnerStack)
}
1 change: 1 addition & 0 deletions packages/next/src/server/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,7 @@ export const configSchema: zod.ZodType<NextConfig> = z.lazy(() =>
.optional(),
pprFallbacks: z.boolean().optional(),
taint: z.boolean().optional(),
reactOwnerStack: z.boolean().optional(),
prerenderEarlyExit: z.boolean().optional(),
proxyTimeout: z.number().gte(0).optional(),
scrollRestoration: z.boolean().optional(),
Expand Down
Loading

0 comments on commit 518c51a

Please sign in to comment.