Repository: vercel/next.js
Pull request: [test] Add test fixture for runtime error in `'use cache'`

In production, when throwing an error in `'use cache'` at runtime, we are currently logging the obfuscated error that React is producing when crossing the cache-server boundary. This is not ideal for investigating production issues so we're also logging the original error in the `'use cache'` wrapper. But this error does not have a digest, which makes it non-obvious that it's the same error as the obfuscated one.

This PR is just adding a test fixture for this case, so that we can see the changes when improving the error handling in the upstack PR.

Changed files:
filename: test/e2e/app-dir/cache-components-errors/cache-components-errors.test.ts
patch: @@ -1,6 +1,6 @@
 import { isNextDev, nextTestSetup } from 'e2e-utils'
-import { waitForNoErrorToast } from 'next-test-utils'
-import { getPrerenderOutput } from './utils'
+import { retry, waitForNoErrorToast } from 'next-test-utils'
+import { getDeterministicOutput, getPrerenderOutput } from './utils'
 
 describe('Cache Components Errors', () => {
   const { next, isTurbopack, isNextStart, skipped, isRspack } = nextTestSetup({
@@ -2648,7 +2648,6 @@ describe('Cache Components Errors', () => {
 
       describe('reading fallback params', () => {
         if (isNextDev) {
-          // FIXME: This should show a redbox error, but currently does not.
           it('should show a redbox error', async () => {
             const browser = await next.browser('/use-cache-params/foo')
 
@@ -2776,6 +2775,73 @@ describe('Cache Components Errors', () => {
           })
         }
       })
+
+      describe('throwing an error at runtime', () => {
+        if (isNextDev) {
+          it('should show a redbox error', async () => {
+            const browser = await next.browser('/use-cache-runtime-error')
+
+            await expect(browser).toDisplayRedbox(`
+             {
+               "description": "Kaputt!",
+               "environmentLabel": "Cache",
+               "label": "Runtime Error",
+               "source": "app/use-cache-runtime-error/page.tsx (15:9) @ throwAnError
+             > 15 |   throw new Error('Kaputt!')
+                  |         ^",
+               "stack": [
+                 "throwAnError app/use-cache-runtime-error/page.tsx (15:9)",
+                 "ThrowingComponent app/use-cache-runtime-error/page.tsx (21:3)",
+               ],
+             }
+            `)
+          })
+        } else {
+          it('should show an error at runtime', async () => {
+            try {
+              await prerender('/use-cache-runtime-error')
+            } catch (error) {
+              throw new Error('expected build not to fail', { cause: error })
+            }
+
+            await next.start({ skipBuild: true })
+            cliOutputLength = next.cliOutput.length
+            await next.fetch('/use-cache-runtime-error')
+
+            await retry(async () => {
+              expect(next.cliOutput.slice(cliOutputLength)).toContain('Error')
+            })
+
+            const output = getDeterministicOutput(
+              next.cliOutput.slice(cliOutputLength),
+              { isMinified: !isDebugPrerender }
+            )
+
+            // TODO: The obfuscated error should be hidden, and the original
+            // error should have a digest.
+            if (isDebugPrerender) {
+              expect(output).toMatchInlineSnapshot(`
+               "Error: Kaputt!
+                   at throwAnError (<next-dist-dir>)
+                   at ThrowingComponent (<next-dist-dir>)
+                   at Object.then (<next-dist-dir>)
+               ⨯ [Error: An error occurred in the Server Components render. The specific message is omitted in production builds to avoid leaking sensitive details. A digest property is included on this error instance which may provide additional details about the nature of the error.] {
+                 digest: '<error-digest>'
+               }"
+              `)
+            } else {
+              expect(output).toMatchInlineSnapshot(`
+               "Error: Kaputt!
+                   at a (<next-dist-dir>)
+                   at b (<next-dist-dir>)
+               ⨯ [Error: An error occurred in the Server Components render. The specific message is omitted in production builds to avoid leaking sensitive details. A digest property is included on this error instance which may provide additional details about the nature of the error.] {
+                 digest: '<error-digest>'
+               }"
+              `)
+            }
+          })
+        }
+      })
     })
 
     describe('With `use cache: private`', () => {

filename: test/e2e/app-dir/cache-components-errors/fixtures/default/app/use-cache-runtime-error/layout.tsx
patch: @@ -0,0 +1,13 @@
+import { Suspense } from 'react'
+
+export default function Root({ children }: { children: React.ReactNode }) {
+  return (
+    <html>
+      <body>
+        <main>
+          <Suspense>{children}</Suspense>
+        </main>
+      </body>
+    </html>
+  )
+}

filename: test/e2e/app-dir/cache-components-errors/fixtures/default/app/use-cache-runtime-error/page.tsx
patch: @@ -0,0 +1,24 @@
+import { connection } from 'next/server'
+
+export default async function Page() {
+  await connection()
+
+  return (
+    <>
+      <p>This page throws an error at runtime in `'use cache'`.</p>
+      <ThrowingComponent />
+    </>
+  )
+}
+
+function throwAnError() {
+  throw new Error('Kaputt!')
+}
+
+async function ThrowingComponent() {
+  'use cache: remote'
+
+  throwAnError()
+
+  return null
+}

filename: test/e2e/app-dir/cache-components-errors/utils.ts
patch: @@ -21,22 +21,28 @@ export function convertModuleFunctionSequenceExpression(
   return output.replace(/\(0 , \w+\.(\w+)\)\(\.\.\.\)/, '<module-function>()')
 }
 
-export function getPrerenderOutput(
+export function getDeterministicOutput(
   cliOutput: string,
-  { isMinified }: { isMinified: boolean }
+  {
+    isMinified,
+    startingLineMatch,
+  }: { isMinified: boolean; startingLineMatch?: string }
 ): string {
   const lines: string[] = []
-  let foundPrerenderingLine = false
+
+  // If no starting line match is provided, we start from the beginning.
+  let foundStartingLine = !startingLineMatch
   let a = 0
   let n = 0
 
-  const replaceNextDistStackFrame = () =>
-    `at ${abc[a++ % abc.length]} (<next-dist-dir>)`
+  const replaceNextDistStackFrame = (_m: string, name: string) =>
+    `at ${isMinified ? abc[a++ % abc.length] : name} (<next-dist-dir>)`
 
   const isLikelyLibraryInternalStackFrame = (line: string) => {
     return line.startsWith('    at InnerLayoutRouter (')
   }
-  const replaceAnonymousStackFrame = (_m, name) => {
+
+  const replaceAnonymousStackFrame = (_m: string, name: string) => {
     const deterministicName = hostElementsUsedInFixtures.includes(name)
       ? name
       : abc[a++ % abc.length]
@@ -73,8 +79,8 @@ export function getPrerenderOutput(
         isErrorWithStackTraceStartingInLibraryInternals = false
       }
     }
-    if (line.includes('Collecting page data')) {
-      foundPrerenderingLine = true
+    if (startingLineMatch && line.includes(startingLineMatch)) {
+      foundStartingLine = true
       continue
     }
 
@@ -87,7 +93,7 @@ export function getPrerenderOutput(
     }
 
     if (
-      foundPrerenderingLine &&
+      foundStartingLine &&
       !ignoredLines.some((ignoredLine) => line.includes(ignoredLine))
     ) {
       if (isMinified) {
@@ -103,7 +109,7 @@ export function getPrerenderOutput(
       }
 
       line = line
-        .replace(/at .+? \(.next[^)]+\)/, replaceNextDistStackFrame)
+        .replace(/at (.+?) \(.next[^)]+\)/, replaceNextDistStackFrame)
         .replace(
           // Single-letter lower-case names are likely minified.
           /at [a-z] \((?!(<next-dist-dir>|<anonymous>))/,
@@ -118,3 +124,13 @@ export function getPrerenderOutput(
 
   return lines.join('\n').trim()
 }
+
+export function getPrerenderOutput(
+  cliOutput: string,
+  { isMinified }: { isMinified: boolean }
+): string {
+  return getDeterministicOutput(cliOutput, {
+    isMinified,
+    startingLineMatch: 'Collecting page data',
+  })
+}