Repository: vercel/next.js
Pull request: Fix tracing of server actions imported by client components

This fixes us failing to trace actions imported by client components due to the transform removing the actions import so our tracing isn't able to discover the action import correctly. This was previously handled in https://github.com/vercel/next.js/pull/73710/files#diff-f39646a8a89ce97754025c8258dc0709f67e4340c07735a3a82a87595ba01c10L526 but removed as part of the experiment. 

A regression test for the `use client` importing an actions file with file needing to be traced has been added. 

Changed files:
filename: packages/next/src/build/webpack-config.ts
patch: @@ -1962,6 +1962,7 @@ export default async function getBaseWebpackConfig(
             appDirEnabled: hasAppDir,
             traceIgnores: [],
             compilerType,
+            swcLoaderConfig: swcDefaultLoader,
           }
         ),
       // Moment.js is an extremely popular library that bundles large locale files

filename: packages/next/src/build/webpack/plugins/next-trace-entrypoints-plugin.ts
patch: @@ -18,8 +18,10 @@ import picomatch from 'next/dist/compiled/picomatch'
 import { getModuleBuildInfo } from '../loaders/get-module-build-info'
 import { getPageFilePath } from '../../entries'
 import { resolveExternal } from '../../handle-externals'
+import swcLoader, { type SWCLoaderOptions } from '../loaders/next-swc-loader'
 import { isMetadataRouteFile } from '../../../lib/metadata/is-metadata-route'
 import { getCompilationSpan } from '../utils'
+import { isClientComponentEntryModule } from '../loaders/utils'
 
 const PLUGIN_NAME = 'TraceEntryPointsPlugin'
 export const TRACE_IGNORES = [
@@ -137,6 +139,10 @@ export class TraceEntryPointsPlugin implements webpack.WebpackPluginInstance {
   private traceIgnores: string[]
   private esmExternals?: NextConfigComplete['experimental']['esmExternals']
   private compilerType: CompilerNameValues
+  private swcLoaderConfig: {
+    loader: string
+    options: Partial<SWCLoaderOptions>
+  }
 
   constructor({
     rootDir,
@@ -147,6 +153,7 @@ export class TraceEntryPointsPlugin implements webpack.WebpackPluginInstance {
     traceIgnores,
     esmExternals,
     outputFileTracingRoot,
+    swcLoaderConfig,
   }: {
     rootDir: string
     compilerType: CompilerNameValues
@@ -156,6 +163,7 @@ export class TraceEntryPointsPlugin implements webpack.WebpackPluginInstance {
     traceIgnores?: string[]
     outputFileTracingRoot?: string
     esmExternals?: NextConfigComplete['experimental']['esmExternals']
+    swcLoaderConfig: TraceEntryPointsPlugin['swcLoaderConfig']
   }) {
     this.rootDir = rootDir
     this.appDir = appDir
@@ -166,6 +174,7 @@ export class TraceEntryPointsPlugin implements webpack.WebpackPluginInstance {
     this.traceIgnores = traceIgnores || []
     this.tracingRoot = outputFileTracingRoot || rootDir
     this.compilerType = compilerType
+    this.swcLoaderConfig = swcLoaderConfig
   }
 
   // Here we output all traced assets and webpack chunks to a
@@ -400,6 +409,18 @@ export class TraceEntryPointsPlugin implements webpack.WebpackPluginInstance {
                 }
               })
 
+            const readOriginalSource = (path: string) => {
+              return new Promise<string | Buffer>((resolve) => {
+                compilation.inputFileSystem.readFile(path, (err, result) => {
+                  if (err) {
+                    // we can't throw here as that crashes build un-necessarily
+                    return resolve('')
+                  }
+                  resolve(result || '')
+                })
+              })
+            }
+
             const readFile = async (
               path: string
             ): Promise<Buffer | string | null> => {
@@ -408,6 +429,60 @@ export class TraceEntryPointsPlugin implements webpack.WebpackPluginInstance {
               // map the transpiled source when available to avoid
               // parse errors in node-file-trace
               let source: Buffer | string = mod?.originalSource?.()?.buffer()
+
+              try {
+                // fallback to reading raw source file, this may fail
+                // due to unsupported syntax but best effort attempt
+                let usingOriginalSource = false
+                if (!source || isClientComponentEntryModule(mod)) {
+                  source = await readOriginalSource(path)
+                  usingOriginalSource = true
+                }
+                const sourceString = source.toString()
+
+                // If this is a client component we need to trace the
+                // original transpiled source not the client proxy which is
+                // applied before this plugin is run due to the
+                // client-module-loader
+                if (
+                  usingOriginalSource &&
+                  // don't attempt transpiling CSS or image imports
+                  path.match(/\.(tsx|ts|js|cjs|mjs|jsx)$/)
+                ) {
+                  let transformResolve: (result: string) => void
+                  let transformReject: (error: unknown) => void
+                  const transformPromise = new Promise<string>(
+                    (resolve, reject) => {
+                      transformResolve = resolve
+                      transformReject = reject
+                    }
+                  )
+
+                  // TODO: should we apply all loaders except the
+                  // client-module-loader?
+                  swcLoader.apply(
+                    {
+                      resourcePath: path,
+                      getOptions: () => {
+                        return this.swcLoaderConfig.options
+                      },
+                      async: () => {
+                        return (err: unknown, result: string) => {
+                          if (err) {
+                            return transformReject(err)
+                          }
+                          return transformResolve(result)
+                        }
+                      },
+                    },
+                    [sourceString, undefined]
+                  )
+                  source = await transformPromise
+                }
+              } catch {
+                /* non-fatal */
+              }
+
               return source || ''
             }
 

filename: test/e2e/app-dir/actions/app-action.test.ts
patch: @@ -10,7 +10,7 @@ import {
 import type { Request, Response } from 'playwright'
 import fs from 'fs-extra'
 import nodeFs from 'fs'
-import { join } from 'path'
+import path, { join } from 'path'
 import { outdent } from 'outdent'
 
 const GENERIC_RSC_ERROR =
@@ -31,6 +31,17 @@ describe('app-dir action handling', () => {
       },
     })
 
+  if (isNextStart) {
+    it('should trace server action imported by client correctly', async () => {
+      const traceData = await next.readJSON(
+        path.join('.next', 'server', 'app', 'client', 'page.js.nft.json')
+      )
+      expect(traceData.files.some((file) => file.includes('data.txt'))).toBe(
+        true
+      )
+    })
+  }
+
   it('should handle action correctly with middleware rewrite', async () => {
     const browser = await next.browser('/rewrite-to-static-first')
     let actionRequestStatus: number | undefined

filename: test/e2e/app-dir/actions/app/client/actions.js
patch: @@ -5,6 +5,13 @@ import 'server-only'
 import { redirect } from 'next/navigation'
 import { headers, cookies } from 'next/headers'
 
+try {
+  require('fs').readFileSync(
+    require('path').join(process.cwd(), 'data.txt'),
+    'utf8'
+  )
+} catch {}
+
 export async function getHeaders() {
   console.log('accept header:', (await headers()).get('accept'))
   ;(await cookies()).set('test-cookie', Date.now())

filename: test/e2e/app-dir/actions/data.txt
patch: @@ -0,0 +1 @@
+hello world
\ No newline at end of file