Skip to content

Commit cfdbcae

Browse files
committed
Avoid server action function indirection in Turbopack
As a follow-up to #71572, this PR replaces the server action function indirection in Turbopack's generated action loader module with a direct re-export of the actions, using the action ID as export name. The server action identity test that was added in #71572 was not sufficient to uncover that Turbopack was still using the wrapper functions. To fix that, we've added another test here, which uses a combination of `"use server"` and `"use cache"` functions in a page. This test does fail in Turbopack without the loader changes. In addition, the building of the server reference manifest is adjusted to account for the new structure that expects `{moduleId: string, async: boolean}` instead of `string`. The `async` flag is set based on the loader's chunk item. I believe this is currently always `false` though. However, Turbopack can still resolve the modules just fne, even when there are outgoing async connections.
1 parent 2490439 commit cfdbcae

File tree

8 files changed

+85
-28
lines changed

8 files changed

+85
-28
lines changed

crates/next-api/src/server_actions.rs

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ use std::{collections::BTreeMap, io::Write, iter::once};
33
use anyhow::{bail, Context, Result};
44
use indexmap::map::Entry;
55
use next_core::{
6-
next_manifests::{ActionLayer, ActionManifestWorkerEntry, ServerReferenceManifest},
6+
next_manifests::{
7+
ActionLayer, ActionManifestModuleId, ActionManifestWorkerEntry, ServerReferenceManifest,
8+
},
79
util::NextRuntime,
810
};
911
use swc_core::{
@@ -22,7 +24,7 @@ use turbo_tasks::{
2224
use turbo_tasks_fs::{self, rope::RopeBuilder, File, FileSystemPath};
2325
use turbopack_core::{
2426
asset::AssetContent,
25-
chunk::{ChunkItemExt, ChunkableModule, ChunkingContext, EvaluatableAsset},
27+
chunk::{ChunkItem, ChunkItemExt, ChunkableModule, ChunkingContext, EvaluatableAsset},
2628
context::AssetContext,
2729
file_source::FileSource,
2830
module::{Module, Modules},
@@ -69,11 +71,8 @@ pub(crate) async fn create_server_actions_manifest(
6971
.await?
7072
.context("loader module must be evaluatable")?;
7173

72-
let loader_id = loader
73-
.as_chunk_item(Vc::upcast(chunking_context))
74-
.id()
75-
.to_string();
76-
let manifest = build_manifest(node_root, page_name, runtime, actions, loader_id).await?;
74+
let chunk_item = loader.as_chunk_item(Vc::upcast(chunking_context));
75+
let manifest = build_manifest(node_root, page_name, runtime, actions, chunk_item).await?;
7776
Ok(ServerActionsManifest {
7877
loader: evaluable,
7978
manifest,
@@ -96,11 +95,11 @@ async fn build_server_actions_loader(
9695
) -> Result<Vc<Box<dyn EcmascriptChunkPlaceable>>> {
9796
let actions = actions.await?;
9897

99-
// Every module which exports an action (that is accessible starting from our
100-
// app page entry point) will be present. We generate a single loader file
101-
// which lazily imports the respective module's chunk_item id and invokes
102-
// the exported action function.
103-
let mut contents = RopeBuilder::from("__turbopack_export_value__({\n");
98+
// Every module which exports an action (that is accessible starting from
99+
// our app page entry point) will be present. We generate a single loader
100+
// file which re-exports the respective module's action function using the
101+
// hashed ID as export name.
102+
let mut contents = RopeBuilder::from("");
104103
let mut import_map = FxIndexMap::default();
105104
for (hash_id, (_layer, name, module)) in actions.iter() {
106105
let index = import_map.len();
@@ -109,11 +108,9 @@ async fn build_server_actions_loader(
109108
.or_insert_with(|| format!("ACTIONS_MODULE{index}").into());
110109
writeln!(
111110
contents,
112-
" '{hash_id}': (...args) => Promise.resolve(require('{module_name}')).then(mod => \
113-
(0, mod['{name}'])(...args)),",
111+
"export {{{name} as '{hash_id}'}} from '{module_name}'"
114112
)?;
115113
}
116-
write!(contents, "}});")?;
117114

118115
let output_path =
119116
project_path.join(format!(".next-internal/server/app{page_name}/actions.js").into());
@@ -143,7 +140,7 @@ async fn build_manifest(
143140
page_name: RcStr,
144141
runtime: NextRuntime,
145142
actions: Vc<AllActions>,
146-
loader_id: Vc<RcStr>,
143+
chunk_item: Vc<Box<dyn ChunkItem>>,
147144
) -> Result<Vc<Box<dyn OutputAsset>>> {
148145
let manifest_path_prefix = &page_name;
149146
let manifest_path = node_root
@@ -155,7 +152,7 @@ async fn build_manifest(
155152
let key = format!("app{page_name}");
156153

157154
let actions_value = actions.await?;
158-
let loader_id_value = loader_id.await?;
155+
let loader_id = chunk_item.id().to_string().await?;
159156
let mapping = match runtime {
160157
NextRuntime::Edge => &mut manifest.edge,
161158
NextRuntime::NodeJs => &mut manifest.node,
@@ -165,7 +162,10 @@ async fn build_manifest(
165162
let entry = mapping.entry(hash_id.as_str()).or_default();
166163
entry.workers.insert(
167164
&key,
168-
ActionManifestWorkerEntry::String(loader_id_value.as_str()),
165+
ActionManifestWorkerEntry {
166+
module_id: ActionManifestModuleId::String(loader_id.as_str()),
167+
is_async: *chunk_item.is_self_async().await?,
168+
},
169169
);
170170
entry.layer.insert(&key, *layer);
171171
}

crates/next-core/src/next_manifests/mod.rs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -194,9 +194,16 @@ pub struct ActionManifestEntry<'a> {
194194
}
195195

196196
#[derive(Serialize, Debug)]
197-
#[serde(rename_all = "camelCase")]
197+
pub struct ActionManifestWorkerEntry<'a> {
198+
#[serde(rename = "moduleId")]
199+
pub module_id: ActionManifestModuleId<'a>,
200+
#[serde(rename = "async")]
201+
pub is_async: bool,
202+
}
203+
204+
#[derive(Serialize, Debug)]
198205
#[serde(untagged)]
199-
pub enum ActionManifestWorkerEntry<'a> {
206+
pub enum ActionManifestModuleId<'a> {
200207
String(&'a str),
201208
Number(f64),
202209
}

packages/next/src/build/webpack/plugins/flight-client-entry-plugin.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,7 @@ const PLUGIN_NAME = 'FlightClientEntryPlugin'
5555
type Actions = {
5656
[actionId: string]: {
5757
workers: {
58-
[name: string]:
59-
| { moduleId: string | number; async: boolean }
60-
// TODO: This is legacy for Turbopack, and needs to be changed to the
61-
// object above.
62-
| string
58+
[name: string]: { moduleId: string | number; async: boolean }
6359
}
6460
// Record which layer the action is in (rsc or sc_action), in the specific entry.
6561
layer: {

packages/next/src/server/app-render/action-utils.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ export function createServerModuleMap({
2323
process.env.NEXT_RUNTIME === 'edge' ? 'edge' : 'node'
2424
][id].workers[normalizeWorkerPageName(pageName)]
2525

26-
if (typeof workerEntry === 'string') {
27-
return { id: workerEntry, name: id, chunks: [] }
26+
if (!workerEntry) {
27+
return undefined
2828
}
2929

3030
const { moduleId, async } = workerEntry

packages/next/src/server/use-cache/use-cache-wrapper.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -679,7 +679,7 @@ export function cache(kind: string, id: string, fn: any) {
679679
moduleMap: isEdgeRuntime
680680
? clientReferenceManifest.edgeRscModuleMapping
681681
: clientReferenceManifest.rscModuleMapping,
682-
serverModuleMap: null,
682+
serverModuleMap: getServerModuleMap(),
683683
}
684684

685685
return createFromReadableStream(stream, {
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
'use client'
2+
3+
import { ReactNode } from 'react'
4+
import { useActionState } from 'react'
5+
6+
export function Form({
7+
action,
8+
children,
9+
}: {
10+
action: () => Promise<string>
11+
children: ReactNode
12+
}) {
13+
const [result, formAction] = useActionState(action, 'initial')
14+
15+
return (
16+
<form action={formAction}>
17+
<button>Submit</button>
18+
<p>{result}</p>
19+
{children}
20+
</form>
21+
)
22+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { Form } from './form'
2+
3+
async function action() {
4+
'use server'
5+
6+
return 'result'
7+
}
8+
9+
export default async function Page() {
10+
'use cache'
11+
12+
return (
13+
<Form action={action}>
14+
<p>{Date.now()}</p>
15+
</Form>
16+
)
17+
}

test/e2e/app-dir/use-cache/use-cache.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,4 +231,19 @@ describe('use-cache', () => {
231231
expect(meta.headers['x-next-cache-tags']).toContain('a,c,b')
232232
})
233233
}
234+
235+
// TODO: There's a bug with the server actions manifest singleton during next
236+
// build that would make this test flaky. Enable the test for isNextStart when
237+
// the singleton has been replaced with a proper solution.
238+
if (!isNextStart) {
239+
it('can reference server actions in "use cache" functions', async () => {
240+
const browser = await next.browser('/with-server-action')
241+
expect(await browser.elementByCss('p').text()).toBe('initial')
242+
await browser.elementByCss('button').click()
243+
244+
await retry(async () => {
245+
expect(await browser.elementByCss('p').text()).toBe('result')
246+
})
247+
})
248+
}
234249
})

0 commit comments

Comments
 (0)