diff --git a/crates/next-api/src/server_actions.rs b/crates/next-api/src/server_actions.rs index 785624cb9ed09..69290b003fcd1 100644 --- a/crates/next-api/src/server_actions.rs +++ b/crates/next-api/src/server_actions.rs @@ -3,7 +3,9 @@ use std::{collections::BTreeMap, io::Write, iter::once}; use anyhow::{bail, Context, Result}; use indexmap::map::Entry; use next_core::{ - next_manifests::{ActionLayer, ActionManifestWorkerEntry, ServerReferenceManifest}, + next_manifests::{ + ActionLayer, ActionManifestModuleId, ActionManifestWorkerEntry, ServerReferenceManifest, + }, util::NextRuntime, }; use swc_core::{ @@ -22,7 +24,7 @@ use turbo_tasks::{ use turbo_tasks_fs::{self, rope::RopeBuilder, File, FileSystemPath}; use turbopack_core::{ asset::AssetContent, - chunk::{ChunkItemExt, ChunkableModule, ChunkingContext, EvaluatableAsset}, + chunk::{ChunkItem, ChunkItemExt, ChunkableModule, ChunkingContext, EvaluatableAsset}, context::AssetContext, file_source::FileSource, module::{Module, Modules}, @@ -69,11 +71,8 @@ pub(crate) async fn create_server_actions_manifest( .await? .context("loader module must be evaluatable")?; - let loader_id = loader - .as_chunk_item(Vc::upcast(chunking_context)) - .id() - .to_string(); - let manifest = build_manifest(node_root, page_name, runtime, actions, loader_id).await?; + let chunk_item = loader.as_chunk_item(Vc::upcast(chunking_context)); + let manifest = build_manifest(node_root, page_name, runtime, actions, chunk_item).await?; Ok(ServerActionsManifest { loader: evaluable, manifest, @@ -96,11 +95,11 @@ async fn build_server_actions_loader( ) -> Result>> { let actions = actions.await?; - // Every module which exports an action (that is accessible starting from our - // app page entry point) will be present. We generate a single loader file - // which lazily imports the respective module's chunk_item id and invokes - // the exported action function. - let mut contents = RopeBuilder::from("__turbopack_export_value__({\n"); + // Every module which exports an action (that is accessible starting from + // our app page entry point) will be present. We generate a single loader + // file which re-exports the respective module's action function using the + // hashed ID as export name. + let mut contents = RopeBuilder::from(""); let mut import_map = FxIndexMap::default(); for (hash_id, (_layer, name, module)) in actions.iter() { let index = import_map.len(); @@ -109,11 +108,9 @@ async fn build_server_actions_loader( .or_insert_with(|| format!("ACTIONS_MODULE{index}").into()); writeln!( contents, - " '{hash_id}': (...args) => Promise.resolve(require('{module_name}')).then(mod => \ - (0, mod['{name}'])(...args)),", + "export {{{name} as '{hash_id}'}} from '{module_name}'" )?; } - write!(contents, "}});")?; let output_path = project_path.join(format!(".next-internal/server/app{page_name}/actions.js").into()); @@ -143,7 +140,7 @@ async fn build_manifest( page_name: RcStr, runtime: NextRuntime, actions: Vc, - loader_id: Vc, + chunk_item: Vc>, ) -> Result>> { let manifest_path_prefix = &page_name; let manifest_path = node_root @@ -155,7 +152,7 @@ async fn build_manifest( let key = format!("app{page_name}"); let actions_value = actions.await?; - let loader_id_value = loader_id.await?; + let loader_id = chunk_item.id().to_string().await?; let mapping = match runtime { NextRuntime::Edge => &mut manifest.edge, NextRuntime::NodeJs => &mut manifest.node, @@ -165,7 +162,10 @@ async fn build_manifest( let entry = mapping.entry(hash_id.as_str()).or_default(); entry.workers.insert( &key, - ActionManifestWorkerEntry::String(loader_id_value.as_str()), + ActionManifestWorkerEntry { + module_id: ActionManifestModuleId::String(loader_id.as_str()), + is_async: *chunk_item.is_self_async().await?, + }, ); entry.layer.insert(&key, *layer); } diff --git a/crates/next-core/src/next_manifests/mod.rs b/crates/next-core/src/next_manifests/mod.rs index 9f5f2ae487ffc..ac89d4b5daeb2 100644 --- a/crates/next-core/src/next_manifests/mod.rs +++ b/crates/next-core/src/next_manifests/mod.rs @@ -194,9 +194,16 @@ pub struct ActionManifestEntry<'a> { } #[derive(Serialize, Debug)] -#[serde(rename_all = "camelCase")] +pub struct ActionManifestWorkerEntry<'a> { + #[serde(rename = "moduleId")] + pub module_id: ActionManifestModuleId<'a>, + #[serde(rename = "async")] + pub is_async: bool, +} + +#[derive(Serialize, Debug)] #[serde(untagged)] -pub enum ActionManifestWorkerEntry<'a> { +pub enum ActionManifestModuleId<'a> { String(&'a str), Number(f64), } diff --git a/packages/next/src/build/webpack/plugins/flight-client-entry-plugin.ts b/packages/next/src/build/webpack/plugins/flight-client-entry-plugin.ts index 89ff129159c40..90031aca88eaa 100644 --- a/packages/next/src/build/webpack/plugins/flight-client-entry-plugin.ts +++ b/packages/next/src/build/webpack/plugins/flight-client-entry-plugin.ts @@ -55,11 +55,7 @@ const PLUGIN_NAME = 'FlightClientEntryPlugin' type Actions = { [actionId: string]: { workers: { - [name: string]: - | { moduleId: string | number; async: boolean } - // TODO: This is legacy for Turbopack, and needs to be changed to the - // object above. - | string + [name: string]: { moduleId: string | number; async: boolean } } // Record which layer the action is in (rsc or sc_action), in the specific entry. layer: { diff --git a/packages/next/src/server/app-render/action-utils.ts b/packages/next/src/server/app-render/action-utils.ts index 3a115de3b8d43..ac4ee71f499aa 100644 --- a/packages/next/src/server/app-render/action-utils.ts +++ b/packages/next/src/server/app-render/action-utils.ts @@ -26,7 +26,6 @@ export function createServerModuleMap({ let workerEntry: | { moduleId: string | number; async: boolean } - | string | undefined if (workStore) { @@ -46,10 +45,6 @@ export function createServerModuleMap({ return undefined } - if (typeof workerEntry === 'string') { - return { id: workerEntry, name: id, chunks: [] } - } - const { moduleId, async } = workerEntry return { id: moduleId, name: id, chunks: [], async } diff --git a/packages/next/src/server/use-cache/use-cache-wrapper.ts b/packages/next/src/server/use-cache/use-cache-wrapper.ts index 94864d188f7e0..9597aaf1bc0b4 100644 --- a/packages/next/src/server/use-cache/use-cache-wrapper.ts +++ b/packages/next/src/server/use-cache/use-cache-wrapper.ts @@ -679,7 +679,7 @@ export function cache(kind: string, id: string, fn: any) { moduleMap: isEdgeRuntime ? clientReferenceManifest.edgeRscModuleMapping : clientReferenceManifest.rscModuleMapping, - serverModuleMap: null, + serverModuleMap: getServerModuleMap(), } return createFromReadableStream(stream, { diff --git a/test/e2e/app-dir/use-cache/app/with-server-action/form.tsx b/test/e2e/app-dir/use-cache/app/with-server-action/form.tsx new file mode 100644 index 0000000000000..fdd21fd93da6e --- /dev/null +++ b/test/e2e/app-dir/use-cache/app/with-server-action/form.tsx @@ -0,0 +1,22 @@ +'use client' + +import { ReactNode } from 'react' +import { useActionState } from 'react' + +export function Form({ + action, + children, +}: { + action: () => Promise + children: ReactNode +}) { + const [result, formAction] = useActionState(action, 'initial') + + return ( +
+ +

{result}

+ {children} +
+ ) +} diff --git a/test/e2e/app-dir/use-cache/app/with-server-action/layout.tsx b/test/e2e/app-dir/use-cache/app/with-server-action/layout.tsx new file mode 100644 index 0000000000000..7ca1569e06a67 --- /dev/null +++ b/test/e2e/app-dir/use-cache/app/with-server-action/layout.tsx @@ -0,0 +1,14 @@ +import { connection } from 'next/server' + +export default async function DynamicLayout({ + children, +}: { + children: React.ReactNode +}) { + // TODO: This is a workaround for Turbopack. Figure out why this fails during + // prerendering with: + // TypeError: Cannot read properties of undefined (reading 'Form') + await connection() + + return children +} diff --git a/test/e2e/app-dir/use-cache/app/with-server-action/page.tsx b/test/e2e/app-dir/use-cache/app/with-server-action/page.tsx new file mode 100644 index 0000000000000..1aea39cefb409 --- /dev/null +++ b/test/e2e/app-dir/use-cache/app/with-server-action/page.tsx @@ -0,0 +1,17 @@ +import { Form } from './form' + +async function action() { + 'use server' + + return 'result' +} + +export default async function Page() { + 'use cache' + + return ( +
+

{Date.now()}

+
+ ) +} diff --git a/test/e2e/app-dir/use-cache/use-cache.test.ts b/test/e2e/app-dir/use-cache/use-cache.test.ts index d327d1e5e275f..2947448c0149a 100644 --- a/test/e2e/app-dir/use-cache/use-cache.test.ts +++ b/test/e2e/app-dir/use-cache/use-cache.test.ts @@ -231,4 +231,14 @@ describe('use-cache', () => { expect(meta.headers['x-next-cache-tags']).toContain('a,c,b') }) } + + it('can reference server actions in "use cache" functions', async () => { + const browser = await next.browser('/with-server-action') + expect(await browser.elementByCss('p').text()).toBe('initial') + await browser.elementByCss('button').click() + + await retry(async () => { + expect(await browser.elementByCss('p').text()).toBe('result') + }) + }) })