Repository: vercel/next.js
Pull request: [devtools] port runtime error handling to hook

This will be used for sharing the error across the Error Overlay and the DevTools Panel.

Changed files:
filename: packages/next/src/next-devtools/dev-overlay/container/errors.tsx
patch: @@ -1,11 +1,10 @@
-import { useState, useMemo, useRef, Suspense } from 'react'
+import { useMemo, useRef, Suspense } from 'react'
 import type { DebugInfo } from '../../shared/types'
 import { Overlay, OverlayBackdrop } from '../components/overlay'
 import { RuntimeError } from './runtime-error'
 import { getErrorSource } from '../../../shared/lib/error-source'
 import { HotlinkedText } from '../components/hot-linked-text'
 import { PseudoHtmlDiff } from './runtime-error/component-stack-pseudo-html'
-import { extractNextErrorCode } from '../../../lib/error-telemetry-utils'
 import {
   ErrorOverlayLayout,
   type ErrorOverlayLayoutProps,
@@ -18,6 +17,7 @@ import {
 import type { ReadyRuntimeError } from '../utils/get-error-by-type'
 import type { ErrorBaseProps } from '../components/errors/error-overlay/error-overlay'
 import type { HydrationErrorState } from '../../shared/hydration-error'
+import { useActiveRuntimeError } from '../hooks/use-active-runtime-error'
 
 export interface ErrorsProps extends ErrorBaseProps {
   getSquashedHydrationErrorDetails: (error: Error) => HydrationErrorState | null
@@ -26,8 +26,6 @@ export interface ErrorsProps extends ErrorBaseProps {
   onClose: () => void
 }
 
-type ReadyErrorEvent = ReadyRuntimeError
-
 function isNextjsLink(text: string): boolean {
   return text.startsWith('https://nextjs.org')
 }
@@ -55,7 +53,7 @@ function GenericErrorDescription({ error }: { error: Error }) {
   )
 }
 
-function getErrorTypeLabel(
+export function getErrorTypeLabel(
   error: Error,
   type: ReadyRuntimeError['type']
 ): ErrorOverlayLayoutProps['errorType'] {
@@ -73,7 +71,7 @@ const noErrorDetails = {
   notes: null,
   reactOutputComponentDiff: null,
 }
-function useErrorDetails(
+export function useErrorDetails(
   error: Error | undefined,
   getSquashedHydrationErrorDetails: (error: Error) => HydrationErrorState | null
 ): {
@@ -122,20 +120,17 @@ export function Errors({
 }: ErrorsProps) {
   const dialogResizerRef = useRef<HTMLDivElement | null>(null)
 
-  const isLoading = useMemo<boolean>(() => {
-    return runtimeErrors.length < 1
-  }, [runtimeErrors.length])
-
-  const [activeIdx, setActiveIndex] = useState<number>(0)
-
-  const activeError = useMemo<ReadyErrorEvent | null>(
-    () => runtimeErrors[activeIdx] ?? null,
-    [activeIdx, runtimeErrors]
-  )
-  const errorDetails = useErrorDetails(
-    activeError?.error,
-    getSquashedHydrationErrorDetails
-  )
+  const {
+    isLoading,
+    errorCode,
+    errorType,
+    notes,
+    hydrationWarning,
+    activeIdx,
+    errorDetails,
+    activeError,
+    setActiveIndex,
+  } = useActiveRuntimeError({ runtimeErrors, getSquashedHydrationErrorDetails })
 
   if (isLoading) {
     // TODO: better loading state
@@ -154,12 +149,6 @@ export function Errors({
   const isServerError = ['server', 'edge-server'].includes(
     getErrorSource(error) || ''
   )
-  const errorType = getErrorTypeLabel(error, activeError.type)
-  // TOOD: May be better to always treat everything past the first blank line as notes
-  // We're currently only special casing hydration error messages.
-  const notes = errorDetails.notes
-  const hydrationWarning = errorDetails.hydrationWarning
-  const errorCode = extractNextErrorCode(error)
 
   return (
     <ErrorOverlayLayout

filename: packages/next/src/next-devtools/dev-overlay/hooks/use-active-runtime-error.ts
patch: @@ -0,0 +1,65 @@
+import type { ReadyRuntimeError } from '../utils/get-error-by-type'
+import type { HydrationErrorState } from '../../shared/hydration-error'
+
+import { useMemo, useState } from 'react'
+import { getErrorTypeLabel, useErrorDetails } from '../container/errors'
+import { extractNextErrorCode } from '../../../lib/error-telemetry-utils'
+
+export function useActiveRuntimeError({
+  runtimeErrors,
+  getSquashedHydrationErrorDetails,
+}: {
+  runtimeErrors: ReadyRuntimeError[]
+  getSquashedHydrationErrorDetails: (error: Error) => HydrationErrorState | null
+}) {
+  const [activeIdx, setActiveIndex] = useState<number>(0)
+
+  const isLoading = useMemo<boolean>(() => {
+    return runtimeErrors.length === 0
+  }, [runtimeErrors.length])
+
+  const activeError = useMemo<ReadyRuntimeError | null>(
+    () => runtimeErrors[activeIdx] ?? null,
+    [activeIdx, runtimeErrors]
+  )
+
+  const errorDetails = useErrorDetails(
+    activeError?.error,
+    getSquashedHydrationErrorDetails
+  )
+
+  if (isLoading || !activeError) {
+    return {
+      isLoading,
+      activeIdx,
+      setActiveIndex,
+      activeError: null,
+      errorDetails: null,
+      errorCode: null,
+      errorType: null,
+      notes: null,
+      hydrationWarning: null,
+    }
+  }
+
+  const error = activeError.error
+  const errorCode = extractNextErrorCode(error)
+  const errorType = getErrorTypeLabel(error, activeError.type)
+
+  // TODO(GH#78140): May be better to always treat everything past the first blank line as notes
+  // We're currently only special casing hydration error messages.
+  const notes = errorDetails.notes
+  const hydrationWarning = errorDetails.hydrationWarning
+
+  return {
+    isLoading,
+    activeIdx,
+    setActiveIndex,
+    activeError,
+    errorDetails,
+    errorCode,
+    errorType,
+    notes,
+    hydrationWarning,
+  }
+}