Repository: vercel/next.js
Pull request: Turbopack: use keyed cell access for AsyncModulesInfo

### What?

Switch `AsyncModulesInfo` to use `cell = "keyed"` and per-key access (`is_async()`) instead of reading the full `FxHashSet` via `.await?`.

### Why?

`AsyncModulesInfo` is a large set that gets read from many call sites. With the default cell, any change to any module's async status invalidates all readers. The `cell = "keyed"` annotation enables turbo-tasks to track reads at the key level, so a change to one module's async status only invalidates tasks that queried that specific module.

This reduces unnecessary recomputation during incremental rebuilds where only a small subset of modules change their async status.

### How?

**Core change** (`async_module_info.rs`):
- Added `cell = "keyed"` to the `#[turbo_tasks::value]` annotation on `AsyncModulesInfo`
- Added `is_async()` method that uses `contains_key()` for keyed reads instead of full-set reads

**Call site migration** (5 files):
- All call sites changed from `.async_module_info().await?` (reads full set) → `.async_module_info()` (returns `Vc`, defers reads)
- `attach_async_info_to_chunkable_module` and `from_chunkable_module_or_batch` now take `Vc<AsyncModulesInfo>` instead of `&ReadRef<AsyncModulesInfo>`
- Each call site uses `async_module_info.is_async(module).await?` for per-key reads

**`referenced_async_modules`** (`mod.rs`):
- Collects neighbor candidates first, then filters via `is_async()` with `try_flat_join` for concurrent keyed reads
- Simplified: uses in-place `.reverse()` instead of `.rev().collect()` double allocation

**`compute_merged_modules`** (`merged_modules.rs`):
- Pre-fetches async status for all mergeable modules into a local `FxHashSet` before the synchronous fixed-point traversal (which cannot do async reads)
- Added tracing span `"pre-fetch async module status"` for observability

**Cleanup:**
- Consistent variable naming (`async_module_info`) across all call sites
- Doc comment on `attach_async_info_to_chunkable_module` explaining the keyed access pattern

Changed files:
filename: turbopack/crates/turbopack-core/src/chunk/chunk_group.rs
patch: @@ -81,7 +81,7 @@ pub async fn make_chunk_group(
     )
     .await?;
 
-    let async_modules_info = module_graph.async_module_info().await?;
+    let async_module_info = module_graph.async_module_info();
 
     // Attach async info to chunkable modules
     let mut chunk_items = chunkable_items
@@ -90,7 +90,7 @@ pub async fn make_chunk_group(
         .map(|m| {
             ChunkItemOrBatchWithAsyncModuleInfo::from_chunkable_module_or_batch(
                 m,
-                &async_modules_info,
+                async_module_info,
                 module_graph,
                 *chunking_context,
             )

filename: turbopack/crates/turbopack-core/src/chunk/chunk_item_batch.rs
patch: @@ -20,14 +20,19 @@ use crate::{
     },
 };
 
+/// Converts a [`ChunkableModule`] into a [`ChunkItemWithAsyncModuleInfo`] by resolving its chunk
+/// item and, if the module is async, looking up its referenced async modules from the graph.
+///
+/// Uses keyed access on `async_module_info` so only the queried module's entry is read,
+/// enabling per-key invalidation via `cell = "keyed"` on [`AsyncModulesInfo`].
 pub async fn attach_async_info_to_chunkable_module(
     module: ResolvedVc<Box<dyn ChunkableModule>>,
-    async_module_info: &ReadRef<AsyncModulesInfo>,
+    async_module_info: Vc<AsyncModulesInfo>,
     module_graph: Vc<ModuleGraph>,
     chunking_context: Vc<Box<dyn ChunkingContext>>,
 ) -> Result<ChunkItemWithAsyncModuleInfo> {
     let general_module = ResolvedVc::upcast(module);
-    let async_info = if async_module_info.contains(&general_module) {
+    let async_info = if async_module_info.is_async(general_module).await? {
         Some(
             module_graph
                 .referenced_async_modules(*general_module)
@@ -67,7 +72,7 @@ pub struct ChunkItemOrBatchWithAsyncModuleInfos(Vec<ChunkItemOrBatchWithAsyncMod
 impl ChunkItemOrBatchWithAsyncModuleInfo {
     pub async fn from_chunkable_module_or_batch(
         chunkable_module_or_batch: ChunkableModuleOrBatch,
-        async_module_info: &ReadRef<AsyncModulesInfo>,
+        async_module_info: Vc<AsyncModulesInfo>,
         module_graph: Vc<ModuleGraph>,
         chunking_context: Vc<Box<dyn ChunkingContext>>,
     ) -> Result<Option<Self>> {
@@ -131,15 +136,15 @@ impl ChunkItemBatchWithAsyncModuleInfo {
         module_graph: Vc<ModuleGraph>,
         chunking_context: Vc<Box<dyn ChunkingContext>>,
     ) -> Result<Vc<Self>> {
-        let async_module_info = module_graph.async_module_info().await?;
+        let async_module_info = module_graph.async_module_info();
         let batch = batch.await?;
         let chunk_items = batch
             .modules
             .iter()
             .map(|module| {
                 attach_async_info_to_chunkable_module(
                     *module,
-                    &async_module_info,
+                    async_module_info,
                     module_graph,
                     chunking_context,
                 )
@@ -242,15 +247,15 @@ impl ChunkItemBatchGroup {
         module_graph: Vc<ModuleGraph>,
         chunking_context: Vc<Box<dyn ChunkingContext>>,
     ) -> Result<Vc<Self>> {
-        let async_module_info = module_graph.async_module_info().await?;
+        let async_module_info = module_graph.async_module_info();
         let batch_group = batch_group.await?;
         let items = batch_group
             .items
             .iter()
             .map(|&batch| {
                 ChunkItemOrBatchWithAsyncModuleInfo::from_chunkable_module_or_batch(
                     batch,
-                    &async_module_info,
+                    async_module_info,
                     module_graph,
                     chunking_context,
                 )

filename: turbopack/crates/turbopack-core/src/module_graph/merged_modules.rs
patch: @@ -93,7 +93,7 @@ pub async fn compute_merged_modules(module_graph: Vc<ModuleGraph>) -> Result<Vc<
 
     let span = span_outer.clone();
     async move {
-        let async_module_info = module_graph.async_module_info().await?;
+        let async_module_info = module_graph.async_module_info();
         let chunk_group_info = module_graph.chunk_group_info().await?;
         let module_graph = module_graph.await?;
 
@@ -157,6 +157,18 @@ pub async fn compute_merged_modules(module_graph: Vc<ModuleGraph>) -> Result<Vc<
             .into_iter()
             .collect::<FxHashSet<_>>();
 
+        // Pre-fetch async status for all mergeable modules using keyed access to avoid
+        // reading the full AsyncModulesInfo set during the synchronous traversal below.
+        let inner_span = tracing::info_span!("pre-fetch async module status");
+        let async_modules: FxHashSet<_> = mergeable
+            .iter()
+            .map(async |&module| Ok(async_module_info.is_async(module).await?.then_some(module)))
+            .try_flat_join()
+            .instrument(inner_span)
+            .await?
+            .into_iter()
+            .collect();
+
         let inner_span = tracing::info_span!("fixed point traversal").entered();
 
         let mut next_index = 0u32;
@@ -191,8 +203,8 @@ pub async fn compute_merged_modules(module_graph: Vc<ModuleGraph>) -> Result<Vc<
                     && let Some(parent_module) = parent_module
                     && mergeable.contains(&parent_module)
                     && mergeable.contains(&module)
-                    && !async_module_info.contains(&parent_module)
-                    && !async_module_info.contains(&module)
+                    && !async_modules.contains(&parent_module)
+                    && !async_modules.contains(&module)
                 {
                     // ^ TODO technically we could merge a sync child into an async parent
 

filename: turbopack/crates/turbopack-core/src/module_graph/mod.rs
patch: @@ -17,7 +17,7 @@ use tracing::{Instrument, Level, Span};
 use turbo_rcstr::RcStr;
 use turbo_tasks::{
     CollectiblesSource, FxIndexMap, NonLocalValue, OperationVc, ReadRef, ResolvedVc,
-    TryJoinIterExt, ValueToString, Vc,
+    TryFlatJoinIterExt, TryJoinIterExt, ValueToString, Vc,
     debug::ValueDebugFormat,
     graph::{AdjacencyMap, GraphTraversal, Visit, VisitControlFlow},
     trace::TraceRawVcs,
@@ -815,7 +815,7 @@ impl ModuleGraph {
         module: ResolvedVc<Box<dyn Module>>,
     ) -> Result<Vc<AsyncModuleInfo>> {
         let graph_ref = self.await?;
-        let async_modules_info = self.async_module_info().await?;
+        let async_module_info = self.async_module_info();
 
         let entry = graph_ref.get_entry(module)?;
         let referenced_modules = graph_ref
@@ -828,9 +828,9 @@ impl ModuleGraph {
             .collect::<Result<Vec<_>>>()?
             .into_iter()
             .rev()
-            .filter(|m| async_modules_info.contains(m))
-            .map(|m| *m)
-            .collect();
+            .map(async |m| Ok(async_module_info.is_async(m).await?.then_some(*m)))
+            .try_flat_join()
+            .await?;
 
         Ok(AsyncModuleInfo::new(referenced_modules))
     }

filename: turbopack/crates/turbopack-core/src/module_graph/style_groups.rs
patch: @@ -79,7 +79,7 @@ pub async fn compute_style_groups(
     let batches_graph = module_graph
         .module_batches(chunking_context.batching_config())
         .await?;
-    let async_info = module_graph.async_module_info().await?;
+    let async_module_info = module_graph.async_module_info();
     let mut module_info_map: FxIndexMap<ResolvedVc<Box<dyn ChunkableModule>>, Option<ModuleInfo>> =
         FxIndexMap::default();
 
@@ -227,7 +227,7 @@ pub async fn compute_style_groups(
         .map(async |&module| {
             let chunk_item = attach_async_info_to_chunkable_module(
                 module,
-                &async_info,
+                async_module_info,
                 module_graph,
                 chunking_context,
             )