Repository: vercel/next.js
Pull request: Include owner stack for forwarded errors if available

For forwarded logs, we can also attach the owner stack if it's available, then we can see the proper errors trace shown in the terminal.

In this way agent can directly detect where the error is happening from reading terminal log, and fix it

| After | Before |
|:---|:---|
| <img width="937" height="778" alt="image" src="https://github.com/user-attachments/assets/666cc012-4e79-41f4-9d6f-b9c1969316bf" /> | <img width="942" height="641" alt="image" src="https://github.com/user-attachments/assets/a44f795a-d297-4fb8-9efe-25e6e3684014" /> |


Changed files:
filename: packages/next/src/next-devtools/userspace/app/errors/stitched-error.ts
patch: @@ -17,7 +17,13 @@ export function coerceError(value: unknown): Error {
 export function setOwnerStackIfAvailable(error: Error): void {
   // React 18 and prod does not have `captureOwnerStack`
   if ('captureOwnerStack' in React) {
-    setOwnerStack(error, React.captureOwnerStack())
+    const ownerStack = React.captureOwnerStack()
+    // Only set if we captured a valid owner stack, or if none exists yet.
+    // This prevents overwriting a valid owner stack captured earlier
+    // (e.g., in onRecoverableError) with null captured later.
+    if (ownerStack || !ownerStacks.has(error)) {
+      setOwnerStack(error, ownerStack)
+    }
   }
 }
 

filename: packages/next/src/next-devtools/userspace/app/forward-logs.ts
patch: @@ -13,6 +13,7 @@ import {
   logStringify,
   safeStringifyWithDepth,
 } from './forward-logs-utils'
+import { getOwnerStack } from './errors/stitched-error'
 
 // Client-side file logger for browser logs
 class ClientFileLogger {
@@ -324,11 +325,10 @@ const stringifyUserArg = (
 }
 
 const createErrorArg = (error: Error) => {
-  const stack = getErrorStack(error)
   return {
     kind: 'formatted-error-arg' as const,
     prefix: error.message ? `${error.name}: ${error.message}` : `${error.name}`,
-    stack,
+    stack: getErrorStackWithOwnerStack(error),
   }
 }
 
@@ -431,6 +431,13 @@ const getErrorStack = (error: Error) => {
   return error.stack || ''
 }
 
+// Get error stack with owner stack appended for source mapping on the server
+const getErrorStackWithOwnerStack = (error: Error) => {
+  const errorStack = getErrorStack(error)
+  const ownerStack = getOwnerStack(error)
+  return ownerStack ? `${errorStack}\n${ownerStack}` : errorStack
+}
+
 export function logUnhandledRejection(reason: unknown) {
   // Always log to client file logger
   const message =
@@ -445,7 +452,10 @@ export function logUnhandledRejection(reason: unknown) {
   }
 
   if (reason instanceof Error) {
-    createUnhandledRejectionErrorEntry(reason, getErrorStack(reason))
+    createUnhandledRejectionErrorEntry(
+      reason,
+      getErrorStackWithOwnerStack(reason)
+    )
     return
   }
   createUnhandledRejectionNonErrorEntry(reason)
@@ -540,7 +550,11 @@ export function forwardUnhandledError(error: Error) {
     return
   }
 
-  createUncaughtErrorEntry(error.name, error.message, getErrorStack(error))
+  createUncaughtErrorEntry(
+    error.name,
+    error.message,
+    getErrorStackWithOwnerStack(error)
+  )
 }
 
 // TODO: this router check is brittle, we need to update based on the current router the user is using

filename: test/development/browser-logs/browser-logs.test.ts
patch: @@ -268,6 +268,104 @@ describe(`Terminal Logging (${bundlerName})`, () => {
     })
   })
 
+  describe('App Router - Hydration Errors', () => {
+    let next: NextInstance
+    let logs: string[] = []
+    let logCapture: ReturnType<typeof setupLogCapture>
+
+    beforeAll(async () => {
+      logCapture = setupLogCapture()
+      logs = logCapture.logs
+
+      next = await createNext({
+        files: {
+          app: new FileRef(join(__dirname, 'fixtures/app')),
+          'next.config.js': new FileRef(
+            join(__dirname, 'fixtures/next.config.js')
+          ),
+        },
+      })
+    })
+
+    afterAll(async () => {
+      logCapture.restore()
+      await next.destroy()
+    })
+
+    beforeEach(() => {
+      logCapture.clearLogs()
+    })
+
+    it('should show hydration errors with owner stack trace', async () => {
+      const browser = await webdriver(next.url, '/hydration-error')
+
+      let hydrationErrorLog = ''
+      await retry(() => {
+        const logOutput = logs.join('\n')
+        // Find the hydration error log entry
+        const hydrationMatch = logOutput.match(
+          /\[browser\].*Hydration[\s\S]*?(?=\n\[browser\]|\n *○|\n *⨯|$)/
+        )
+        expect(hydrationMatch).not.toBeNull()
+        hydrationErrorLog = hydrationMatch![0]
+        // Verify the Page component is in the forwarded stack trace with source location
+        expect(hydrationErrorLog).toMatch(/Page/)
+        expect(hydrationErrorLog).toMatch(/app\/hydration-error\/page/)
+      })
+
+      // Assert the entire hydration error message including owner stack trace
+      expect(hydrationErrorLog).toMatchInlineSnapshot(`
+       "[browser] Uncaught Error: Hydration failed because the server rendered text didn't match the client. As a result this tree will be regenerated on the client. This can happen if a SSR-ed Client Component used:
+
+       - A server/client branch \`if (typeof window !== 'undefined')\`.
+       - Variable input such as \`Date.now()\` or \`Math.random()\` which changes each time it's called.
+       - Date formatting in a user's locale which doesn't match the server.
+       - External changing data without sending a snapshot of it along with the HTML.
+       - Invalid HTML tag nesting.
+
+       It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
+
+       https://react.dev/link/hydration-mismatch
+
+         ...
+           <RenderFromTemplateContext>
+             <ScrollAndFocusHandler segmentPath={[...]}>
+               <InnerScrollAndFocusHandler segmentPath={[...]} focusAndScrollRef={{apply:false, ...}}>
+                 <ErrorBoundary errorComponent={undefined} errorStyles={undefined} errorScripts={undefined}>
+                   <LoadingBoundary name="hydration-..." loading={null}>
+                     <HTTPAccessFallbackBoundary notFound={undefined} forbidden={undefined} unauthorized={undefined}>
+                       <RedirectBoundary>
+                         <RedirectErrorBoundary router={{...}}>
+                           <InnerLayoutRouter url="/hydration..." tree={[...]} params={{}} cacheNode={{rsc:<Fragment>, ...}} ...>
+                             <SegmentViewNode type="page" pagePath="hydration-...">
+                               <SegmentTrieNode>
+                               <ClientPageRoot Component={function Page} serverProvidedParams={{...}}>
+                                 <Page params={Promise} searchParams={Promise}>
+                                   <div>
+                                     <p>
+       +                               client
+       -                               server
+                             ...
+                           ...
+                 ...
+
+           at <unknown> (https://react.dev/link/hydration-mismatch)
+           at p (<anonymous>)
+           at Page (app/hydration-error/page.js:7:7)
+          5 |   return (
+          6 |     <div>
+       >  7 |       <p>{isClient ? 'client' : 'server'}</p>
+            |       ^
+          8 |     </div>
+          9 |   )
+         10 | }
+       "
+      `)
+
+      await browser.close()
+    })
+  })
+
   describe('App Router - Edge Runtime', () => {
     let next: NextInstance
     let logs: string[] = []

filename: test/development/browser-logs/fixtures/app/hydration-error/page.js
patch: @@ -0,0 +1,10 @@
+'use client'
+
+export default function Page() {
+  const isClient = typeof window !== 'undefined'
+  return (
+    <div>
+      <p>{isClient ? 'client' : 'server'}</p>
+    </div>
+  )
+}