Repository: vercel/next.js
Pull request: [CC] Fix dev validation error from server action bound args

In Cache Components, bound args encryption needs to be cached (and tracked with cacheSignal) because it's tasky. We use the serialized bound args themselves as a cache key. However, in dev, the key ends up containing debug info (in particular, timing information) so it's different every time . This causes a cache miss and results in a static shell validation failure.

The fix is to use a dummy debug channel to pipe the problematic debug info into the void and thus make the key be deterministic.

Changed files:
filename: packages/next/src/server/app-render/encryption.ts
patch: @@ -153,11 +153,35 @@ export const encryptActionBoundArgs = React.cache(
       })
     }
 
+    const prerenderResumeDataCache = workUnitStore
+      ? getPrerenderResumeDataCache(workUnitStore)
+      : null
+    const renderResumeDataCache = workUnitStore
+      ? getRenderResumeDataCache(workUnitStore)
+      : null
+
     // Using Flight to serialize the args into a string.
     const serialized = await streamToString(
       renderToReadableStream(args, clientModules, {
         filterStackFrame,
         signal: hangingInputAbortSignal,
+        debugChannel:
+          // In Cache Components, we want to cache the encrypted result,
+          // and we use the unencrypted bound args as a cache key.
+          // In order to do that we need to strip debug info, because it
+          // contains timing information and thus changes each time we serialize the args.
+          // We can do this by piping debug info into a debug channel that throws it away.
+          //
+          // Note that this can result in dangling debug info references when we decode the bound args,
+          // but React ignores those as long as no debug channel is passed on the decode side, so it's fine:
+          // https://github.com/facebook/react/blob/bb8a76c6cc77ea2976d690ea09f5a1b3d9b1792a/packages/react-client/src/ReactFlightClient.js#L1711-L1729
+          // https://github.com/facebook/react/blob/bb8a76c6cc77ea2976d690ea09f5a1b3d9b1792a/packages/react-client/src/ReactFlightClient.js#L4005-L4025
+          process.env.NODE_ENV === 'development' &&
+          (prerenderResumeDataCache || renderResumeDataCache)
+            ? {
+                writable: new WritableStream(),
+              }
+            : undefined,
         onError(err) {
           if (hangingInputAbortSignal?.aborted) {
             return
@@ -201,8 +225,6 @@ export const encryptActionBoundArgs = React.cache(
 
     startReadOnce()
 
-    const prerenderResumeDataCache = getPrerenderResumeDataCache(workUnitStore)
-    const renderResumeDataCache = getRenderResumeDataCache(workUnitStore)
     const cacheKey = actionId + serialized
 
     const cachedEncrypted =
@@ -291,6 +313,12 @@ export async function decryptActionBoundArgs(
     }),
     {
       findSourceMapURL,
+      // NOTE: When we serialized the bound args, we may have used a dummy debug channel to strip debug info.
+      // In that case, it's important that we also *don't* pass a debug channel here, because that will make
+      // the Flight Client ignore the dangling references:
+      // https://github.com/facebook/react/blob/bb8a76c6cc77ea2976d690ea09f5a1b3d9b1792a/packages/react-client/src/ReactFlightClient.js#L1711-L1729
+      // https://github.com/facebook/react/blob/bb8a76c6cc77ea2976d690ea09f5a1b3d9b1792a/packages/react-client/src/ReactFlightClient.js#L4005-L4025
+      debugChannel: undefined,
       serverConsumerManifest: {
         // moduleLoading must be null because we don't want to trigger preloads of ClientReferences
         // to be added to the current execution. Instead, we'll wait for any ClientReference

filename: test/e2e/app-dir/cache-components/app/server-action-inline/form.tsx
patch: @@ -1,9 +1,9 @@
 'use client'
 
-import { useActionState } from 'react'
+import { ReactNode, useActionState } from 'react'
 import { getSentinelValue } from '../getSentinelValue'
 
-export function Form({ action }) {
+export function Form({ action }: { action: () => Promise<ReactNode> }) {
   const [result, formAction] = useActionState(action, 'initial')
 
   return (

filename: test/e2e/app-dir/cache-components/app/server-action-inline/page.tsx
patch: @@ -1,14 +1,26 @@
 import { Form } from './form'
 
 export default function Page() {
-  const value = 'result'
-
+  const simpleValue = 'result'
+  // JSX has debug info, which affects the serialized result
+  const jsxValue = <span>and more</span>
+  // Async components emit timing chunks
+  const timedValue = <HasTimingInfo />
   return (
     <Form
       action={async () => {
         'use server'
-        return value
+        return (
+          <>
+            {simpleValue} {jsxValue} {timedValue}
+          </>
+        )
       }}
     />
   )
 }
+
+async function HasTimingInfo() {
+  await Promise.resolve()
+  return 'and even more'
+}

filename: test/e2e/app-dir/cache-components/cache-components.server-action.test.ts
patch: @@ -19,21 +19,18 @@ describe('cache-components', () => {
   it('should not have cache components errors when encoding bound args for inline server actions', async () => {
     const browser = await next.browser('/server-action-inline')
     expect(await browser.elementByCss('p').text()).toBe('initial')
-    await browser.elementByCss('button').click()
+    if (isNextDev) {
+      await waitForNoRedbox(browser)
+    }
 
+    await browser.elementByCss('button').click()
     await retry(async () => {
-      expect(await browser.elementByCss('p').text()).toBe('result')
+      expect(await browser.elementByCss('p').text()).toBe(
+        'result and more and even more'
+      )
     })
 
-    if (isNextDev) {
-      // TODO(react-time-info): For experimental React in dev mode, the
-      // inclusion of server timings in the RSC payload makes the serialized
-      // bound args not suitable to be used as a cache key. When this is fixed
-      // we expect this error not to be logged anymore.
-      expect(next.cliOutput).toMatch('Error: Route "/server-action-inline"')
-
-      await waitForNoRedbox(browser)
-    }
+    expect(next.cliOutput).not.toInclude('Error: Route "/server-action-inline"')
   })
 
   it('should prerender pages with inline server actions', async () => {