diff --git a/crates/next-custom-transforms/src/transforms/server_actions.rs b/crates/next-custom-transforms/src/transforms/server_actions.rs index ac1084a7baddc..a6d4a2c45293d 100644 --- a/crates/next-custom-transforms/src/transforms/server_actions.rs +++ b/crates/next-custom-transforms/src/transforms/server_actions.rs @@ -2578,8 +2578,16 @@ fn collect_idents_in_pat(pat: &Pat, idents: &mut Vec) { } fn collect_decl_idents_in_stmt(stmt: &Stmt, idents: &mut Vec) { - if let Stmt::Decl(Decl::Var(var)) = &stmt { - collect_idents_in_var_decls(&var.decls, idents); + if let Stmt::Decl(decl) = stmt { + match decl { + Decl::Var(var) => { + collect_idents_in_var_decls(&var.decls, idents); + } + Decl::Fn(fn_decl) => { + idents.push(fn_decl.ident.clone()); + } + _ => {} + } } } diff --git a/crates/next-custom-transforms/tests/fixture/server-actions/server/58/input.js b/crates/next-custom-transforms/tests/fixture/server-actions/server/58/input.js new file mode 100644 index 0000000000000..ae6bed76bb423 --- /dev/null +++ b/crates/next-custom-transforms/tests/fixture/server-actions/server/58/input.js @@ -0,0 +1,21 @@ +function createCachedFn(start) { + function fn() { + return start + Math.random() + } + + return async () => { + 'use cache' + return fn() + } +} + +function createServerAction(start) { + function fn() { + return start + Math.random() + } + + return async () => { + 'use server' + console.log(fn()) + } +} diff --git a/crates/next-custom-transforms/tests/fixture/server-actions/server/58/output.js b/crates/next-custom-transforms/tests/fixture/server-actions/server/58/output.js new file mode 100644 index 0000000000000..8d8f50843fc64 --- /dev/null +++ b/crates/next-custom-transforms/tests/fixture/server-actions/server/58/output.js @@ -0,0 +1,27 @@ +/* __next_internal_action_entry_do_not_use__ {"401c36b06e398c97abe5d5d7ae8c672bfddf4e1b91":"$$RSC_SERVER_ACTION_2","c03128060c414d59f8552e4788b846c0d2b7f74743":"$$RSC_SERVER_CACHE_0"} */ import { registerServerReference } from "private-next-rsc-server-reference"; +import { encryptActionBoundArgs, decryptActionBoundArgs } from "private-next-rsc-action-encryption"; +import { cache as $$cache__ } from "private-next-rsc-cache-wrapper"; +export var $$RSC_SERVER_CACHE_0 = $$cache__("default", "c03128060c414d59f8552e4788b846c0d2b7f74743", 1, async function([$$ACTION_ARG_0]) { + return $$ACTION_ARG_0(); +}); +function createCachedFn(start) { + function fn() { + return start + Math.random(); + } + return $$RSC_SERVER_REF_1.bind(null, encryptActionBoundArgs("c03128060c414d59f8552e4788b846c0d2b7f74743", [ + fn + ])); +} +var $$RSC_SERVER_REF_1 = /*#__TURBOPACK_DISABLE_EXPORT_MERGING__*/ registerServerReference($$RSC_SERVER_CACHE_0, "c03128060c414d59f8552e4788b846c0d2b7f74743", null); +export const /*#__TURBOPACK_DISABLE_EXPORT_MERGING__*/ $$RSC_SERVER_ACTION_2 = async function($$ACTION_CLOSURE_BOUND) { + var [$$ACTION_ARG_0] = await decryptActionBoundArgs("401c36b06e398c97abe5d5d7ae8c672bfddf4e1b91", $$ACTION_CLOSURE_BOUND); + console.log($$ACTION_ARG_0()); +}; +function createServerAction(start) { + function fn() { + return start + Math.random(); + } + return registerServerReference($$RSC_SERVER_ACTION_2, "401c36b06e398c97abe5d5d7ae8c672bfddf4e1b91", null).bind(null, encryptActionBoundArgs("401c36b06e398c97abe5d5d7ae8c672bfddf4e1b91", [ + fn + ])); +} diff --git a/packages/next/src/server/app-render/encryption.ts b/packages/next/src/server/app-render/encryption.ts index 32b9259dbe850..9e9a5b63bc66b 100644 --- a/packages/next/src/server/app-render/encryption.ts +++ b/packages/next/src/server/app-render/encryption.ts @@ -73,11 +73,43 @@ async function encodeActionBoundArg(actionId: string, arg: string) { export async function encryptActionBoundArgs(actionId: string, args: any[]) { const { clientModules } = getClientReferenceManifestForRsc() + // An error stack that's created here looks like this: + // Error: + // at encryptActionBoundArg + // at + const stack = new Error().stack!.split('\n').slice(2).join('\n') + + let error: Error | undefined + // Using Flight to serialize the args into a string. const serialized = await streamToString( - renderToReadableStream(args, clientModules) + renderToReadableStream(args, clientModules, { + onError(err) { + // We're only reporting one error at a time, starting with the first. + if (error) { + return + } + + // Use the original error message... + error = err instanceof Error ? err : new Error(String(err)) + // ...and attach the previously created stack, because err.stack is a + // useless Flight Server call stack. + error.stack = stack + }, + }) ) + if (error) { + if (process.env.NODE_ENV === 'development') { + // Logging the error is needed for server functions that are passed to the + // client where the decryption is not done during rendering. Console + // replaying allows us to still show the error dev overlay in this case. + console.error(error) + } + + throw error + } + // Encrypt the serialized string with the action id as the salt. // Add a prefix to later ensure that the payload is correctly decrypted, similar // to a checksum. diff --git a/test/e2e/app-dir/use-cache-close-over-function/app/client/client.tsx b/test/e2e/app-dir/use-cache-close-over-function/app/client/client.tsx new file mode 100644 index 0000000000000..543f17e84489a --- /dev/null +++ b/test/e2e/app-dir/use-cache-close-over-function/app/client/client.tsx @@ -0,0 +1,14 @@ +'use client' + +import { useActionState } from 'react' + +export function Client({ getValue }) { + const [result, formAction] = useActionState(getValue, 0) + + return ( +
+

{result}

+ +
+ ) +} diff --git a/test/e2e/app-dir/use-cache-close-over-function/app/client/page.tsx b/test/e2e/app-dir/use-cache-close-over-function/app/client/page.tsx new file mode 100644 index 0000000000000..536ed93038763 --- /dev/null +++ b/test/e2e/app-dir/use-cache-close-over-function/app/client/page.tsx @@ -0,0 +1,16 @@ +import { Client } from './client' + +function createCachedFn(start: number) { + function fn() { + return start + } + + return async () => { + 'use cache' + return Math.random() + fn() + } +} + +export default async function Page() { + return +} diff --git a/test/e2e/app-dir/use-cache-close-over-function/app/layout.tsx b/test/e2e/app-dir/use-cache-close-over-function/app/layout.tsx new file mode 100644 index 0000000000000..f5e76d17e5881 --- /dev/null +++ b/test/e2e/app-dir/use-cache-close-over-function/app/layout.tsx @@ -0,0 +1,11 @@ +import { Suspense } from 'react' + +export default function Root({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + ) +} diff --git a/test/e2e/app-dir/use-cache-close-over-function/app/page.tsx b/test/e2e/app-dir/use-cache-close-over-function/app/page.tsx new file mode 100644 index 0000000000000..82ec79467618c --- /dev/null +++ b/test/e2e/app-dir/use-cache-close-over-function/app/page.tsx @@ -0,0 +1,14 @@ +import Link from 'next/link' + +export default function Page() { + return ( + <> +

+ Client +

+

+ Server +

+ + ) +} diff --git a/test/e2e/app-dir/use-cache-close-over-function/app/server/page.tsx b/test/e2e/app-dir/use-cache-close-over-function/app/server/page.tsx new file mode 100644 index 0000000000000..645d1532a7b24 --- /dev/null +++ b/test/e2e/app-dir/use-cache-close-over-function/app/server/page.tsx @@ -0,0 +1,16 @@ +function createCachedFn(start: number) { + function fn() { + return start + } + + return async () => { + 'use cache' + return Math.random() + fn() + } +} + +const getCachedValue = createCachedFn(42) + +export default async function Page() { + return

{getCachedValue()}

+} diff --git a/test/e2e/app-dir/use-cache-close-over-function/next.config.js b/test/e2e/app-dir/use-cache-close-over-function/next.config.js new file mode 100644 index 0000000000000..2e3b04951d3b6 --- /dev/null +++ b/test/e2e/app-dir/use-cache-close-over-function/next.config.js @@ -0,0 +1,11 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = { + experimental: { + dynamicIO: true, + serverSourceMaps: true, + }, +} + +module.exports = nextConfig diff --git a/test/e2e/app-dir/use-cache-close-over-function/use-cache-close-over-function.test.ts b/test/e2e/app-dir/use-cache-close-over-function/use-cache-close-over-function.test.ts new file mode 100644 index 0000000000000..31e4d91c2b671 --- /dev/null +++ b/test/e2e/app-dir/use-cache-close-over-function/use-cache-close-over-function.test.ts @@ -0,0 +1,88 @@ +import { nextTestSetup } from 'e2e-utils' +import { + assertHasRedbox, + getRedboxDescription, + getRedboxSource, + openRedbox, +} from 'next-test-utils' + +describe('use-cache-close-over-function', () => { + const { next, isNextDev, skipped } = nextTestSetup({ + files: __dirname, + skipDeployment: true, + skipStart: process.env.NEXT_TEST_MODE !== 'dev', + }) + + if (skipped) { + return + } + + if (isNextDev) { + it('should show an error toast for client-side usage', async () => { + const browser = await next.browser('/client') + + await openRedbox(browser) + + const errorDescription = await getRedboxDescription(browser) + const errorSource = await getRedboxSource(browser) + + expect(errorDescription).toMatchInlineSnapshot(` + "[ Prerender ] Error: Functions cannot be passed directly to Client Components unless you explicitly expose it by marking it with "use server". Or maybe you meant to call this function rather than return it. + [function fn] + ^^^^^^^^^^^" + `) + + expect(errorSource).toMatchInlineSnapshot(` + "app/client/page.tsx (8:3) @ createCachedFn + + 6 | } + 7 | + > 8 | return async () => { + | ^ + 9 | 'use cache' + 10 | return Math.random() + fn() + 11 | }" + `) + }) + + it('should show the error overlay for server-side usage', async () => { + const browser = await next.browser('/server') + + await assertHasRedbox(browser) + + const errorDescription = await getRedboxDescription(browser) + const errorSource = await getRedboxSource(browser) + + expect(errorDescription).toMatchInlineSnapshot(` + "[ Prerender ] Error: Functions cannot be passed directly to Client Components unless you explicitly expose it by marking it with "use server". Or maybe you meant to call this function rather than return it. + [function fn] + ^^^^^^^^^^^" + `) + + expect(errorSource).toMatchInlineSnapshot(` + "app/server/page.tsx (6:3) @ createCachedFn + + 4 | } + 5 | + > 6 | return async () => { + | ^ + 7 | 'use cache' + 8 | return Math.random() + fn() + 9 | }" + `) + }) + } else { + it('should fail the build with an error', async () => { + const { cliOutput } = await next.build() + + expect(cliOutput).toInclude(` +Error: Functions cannot be passed directly to Client Components unless you explicitly expose it by marking it with "use server". Or maybe you meant to call this function rather than return it. + [function] + ^^^^^^^^`) + + expect(cliOutput).toMatch( + /Error occurred prerendering page "\/(client|server)"/ + ) + }) + } +})