Repository: vercel/next.js
Pull request: Turbopack: Pass asset_suffix_path as Vc

## Summary
- Pass `asset_suffix_path` as `Vc<Option<RcStr>>` instead of eagerly resolving it to `Option<RcStr>` in the chunking context option structs
- Add filesystem cache size growth assertions to e2e tests to detect unbounded cache growth regressions

## Why

Previously, `css_url_suffix` was eagerly awaited in `project.rs` before being passed into `ClientChunkingContextOptions`, `ServerChunkingContextOptions`, and `EdgeChunkingContextOptions`. This caused a new chunking context `Vc` to be created for every build, duplicating the entire build in cache and recompiling it.

By keeping it as a `Vc`, the chunking context identity is stable across builds, preventing unnecessary cache duplication.

## How

- Changed `css_url_suffix` field from `Option<RcStr>` to `Vc<Option<RcStr>>` in all three chunking context option structs
- Removed `.owned().await?.clone()` in `project.rs` (3 call sites), passing the `Vc` directly
- Added `.owned().await?` at the point of use in the 5 context builder functions
- Added cache size measurements to `filesystem-cache.test.ts`: normal changes limited to 10% growth, renames allow up to 50% due to dead cache entries

Changed files:
filename: crates/next-api/src/project.rs
patch: @@ -1440,12 +1440,7 @@ impl Project {
     pub(super) async fn client_chunking_context(
         self: Vc<Self>,
     ) -> Result<Vc<Box<dyn ChunkingContext>>> {
-        let css_url_suffix = self
-            .next_config()
-            .asset_suffix_path()
-            .owned()
-            .await?
-            .clone();
+        let css_url_suffix = self.next_config().asset_suffix_path();
         Ok(get_client_chunking_context(ClientChunkingContextOptions {
             mode: self.next_mode(),
             root_path: self.project_root_path().owned().await?,
@@ -1474,12 +1469,7 @@ impl Project {
         self: Vc<Self>,
         client_assets: bool,
     ) -> Result<Vc<NodeJsChunkingContext>> {
-        let css_url_suffix = self
-            .next_config()
-            .asset_suffix_path()
-            .owned()
-            .await?
-            .clone();
+        let css_url_suffix = self.next_config().asset_suffix_path();
         let options = ServerChunkingContextOptions {
             mode: self.next_mode(),
             root_path: self.project_root_path().owned().await?,
@@ -1513,12 +1503,7 @@ impl Project {
         self: Vc<Self>,
         client_assets: bool,
     ) -> Result<Vc<Box<dyn ChunkingContext>>> {
-        let css_url_suffix = self
-            .next_config()
-            .asset_suffix_path()
-            .owned()
-            .await?
-            .clone();
+        let css_url_suffix = self.next_config().asset_suffix_path();
         let options = EdgeChunkingContextOptions {
             mode: self.next_mode(),
             root_path: self.project_root_path().owned().await?,

filename: crates/next-core/src/next_client/context.rs
patch: @@ -449,7 +449,7 @@ pub struct ClientChunkingContextOptions {
     pub nested_async_chunking: Vc<bool>,
     pub debug_ids: Vc<bool>,
     pub should_use_absolute_url_references: Vc<bool>,
-    pub css_url_suffix: Option<RcStr>,
+    pub css_url_suffix: Vc<Option<RcStr>>,
 }
 
 #[turbo_tasks::function]
@@ -475,6 +475,7 @@ pub async fn get_client_chunking_context(
         should_use_absolute_url_references,
         css_url_suffix,
     } = options;
+    let css_url_suffix = css_url_suffix.owned().await?;
 
     let next_mode = mode.await?;
     let asset_prefix = asset_prefix.owned().await?;

filename: crates/next-core/src/next_edge/context.rs
patch: @@ -223,7 +223,7 @@ pub struct EdgeChunkingContextOptions {
     pub nested_async_chunking: Vc<bool>,
     pub client_root: FileSystemPath,
     pub asset_prefix: RcStr,
-    pub css_url_suffix: Option<RcStr>,
+    pub css_url_suffix: Vc<Option<RcStr>>,
 }
 
 /// Like `get_edge_chunking_context` but all assets are emitted as client assets (so `/_next`)
@@ -249,6 +249,7 @@ pub async fn get_edge_chunking_context_with_client_assets(
         asset_prefix,
         css_url_suffix,
     } = options;
+    let css_url_suffix = css_url_suffix.owned().await?;
     let output_root = node_root.join("server/edge")?;
     let next_mode = mode.await?;
     let mut builder = BrowserChunkingContext::builder(
@@ -326,6 +327,7 @@ pub async fn get_edge_chunking_context(
         asset_prefix,
         css_url_suffix,
     } = options;
+    let css_url_suffix = css_url_suffix.owned().await?;
     let output_root = node_root.join("server/edge")?;
     let next_mode = mode.await?;
     let mut builder = BrowserChunkingContext::builder(

filename: crates/next-core/src/next_server/context.rs
patch: @@ -1035,7 +1035,7 @@ pub struct ServerChunkingContextOptions {
     pub debug_ids: Vc<bool>,
     pub client_root: FileSystemPath,
     pub asset_prefix: RcStr,
-    pub css_url_suffix: Option<RcStr>,
+    pub css_url_suffix: Vc<Option<RcStr>>,
 }
 
 /// Like `get_server_chunking_context` but all assets are emitted as client assets (so `/_next`)
@@ -1062,6 +1062,7 @@ pub async fn get_server_chunking_context_with_client_assets(
         asset_prefix,
         css_url_suffix,
     } = options;
+    let css_url_suffix = css_url_suffix.owned().await?;
 
     let next_mode = mode.await?;
     // TODO(alexkirsz) This should return a trait that can be implemented by the
@@ -1159,6 +1160,7 @@ pub async fn get_server_chunking_context(
         asset_prefix,
         css_url_suffix,
     } = options;
+    let css_url_suffix = css_url_suffix.owned().await?;
     let next_mode = mode.await?;
     // TODO(alexkirsz) This should return a trait that can be implemented by the
     // different server chunking contexts. OR the build chunking context should

filename: test/e2e/filesystem-cache/filesystem-cache.test.ts
patch: @@ -1,6 +1,29 @@
 /* eslint-disable jest/no-standalone-expect */
 import { nextTestSetup, isNextDev } from 'e2e-utils'
 import { waitFor } from 'next-test-utils'
+import fs from 'fs/promises'
+import path from 'path'
+
+async function getDirectorySize(dirPath: string): Promise<number> {
+  try {
+    await fs.access(dirPath)
+  } catch {
+    return 0
+  }
+  let totalSize = 0
+  const entries = await fs.readdir(dirPath, {
+    recursive: true,
+    withFileTypes: true,
+  })
+  for (const entry of entries) {
+    if (entry.isFile()) {
+      const filePath = path.join(entry.parentPath ?? entry.path, entry.name)
+      const stat = await fs.stat(filePath)
+      totalSize += stat.size
+    }
+  }
+  return totalSize
+}
 
 for (const cacheEnabled of [false, true]) {
   describe(`filesystem-caching with cache ${cacheEnabled ? 'enabled' : 'disabled'}`, () => {
@@ -46,9 +69,11 @@ for (const cacheEnabled of [false, true]) {
       ;(next as any).handleDevWatchDelayAfterChange = () => {}
     })
 
-    async function restartCycle() {
+    async function restartCycle(): Promise<number> {
       await stop()
+      const cacheSize = await getCacheSize()
       await start()
+      return cacheSize
     }
 
     async function stop() {
@@ -66,6 +91,12 @@ for (const cacheEnabled of [false, true]) {
       await next.start()
     }
 
+    async function getCacheSize(): Promise<number> {
+      return getDirectorySize(
+        path.join(next.testDir, '.next', 'cache', 'turbopack')
+      )
+    }
+
     // Very flakey with Webpack enabled
     ;(process.env.IS_TURBOPACK_TEST ? it : it.skip)(
       'should cache or not cache loaders',
@@ -95,7 +126,7 @@ for (const cacheEnabled of [false, true]) {
           expect(pagesTimestamp).toMatch(/Timestamp = \d+$/)
           await browser.close()
         }
-        await restartCycle()
+        const initialCacheSize = await restartCycle()
 
         {
           const browser = await next.browser('/')
@@ -141,6 +172,18 @@ for (const cacheEnabled of [false, true]) {
           }
           await browser.close()
         }
+
+        if (cacheEnabled) {
+          const finalCacheSize = await restartCycle()
+          const increasePercent = (
+            (finalCacheSize / initialCacheSize - 1) *
+            100
+          ).toFixed(2)
+          console.log(
+            `Cache size: ${(initialCacheSize / 1024 / 1024).toFixed(2)} MB -> ${(finalCacheSize / 1024 / 1024).toFixed(2)} MB (${increasePercent}%)`
+          )
+          expect(finalCacheSize).toBeLessThanOrEqual(initialCacheSize * 1.1)
+        }
       }
     )
 
@@ -171,6 +214,7 @@ for (const cacheEnabled of [false, true]) {
       withChange(previous: () => Promise<void>): Promise<void>
       checkChanged(): Promise<void>
       fullInvalidation?: boolean
+      largeCacheIncrease?: boolean
     }
     const POTENTIAL_CHANGES: Record<string, Change> = {
       'RSC change': {
@@ -199,6 +243,7 @@ for (const cacheEnabled of [false, true]) {
           }
         },
         checkChanged: makeTextCheck('/add-me', 'hello world'),
+        largeCacheIncrease: true,
       },
       // TODO fix this case with Turbopack
       ...(isTurbopack
@@ -265,11 +310,15 @@ for (const cacheEnabled of [false, true]) {
         `should allow to change files while stopped (${name})`,
         async () => {
           let fullInvalidation = !cacheEnabled
+          let largeCacheIncrease = false
           for (const change of changes) {
             await change.checkInitial()
             if (change.fullInvalidation) {
               fullInvalidation = true
             }
+            if (change.largeCacheIncrease) {
+              largeCacheIncrease = true
+            }
           }
 
           let unchangedTimestamp: string
@@ -294,6 +343,7 @@ for (const cacheEnabled of [false, true]) {
           }
 
           await stop()
+          const initialCacheSize = await getCacheSize()
 
           async function inner() {
             await start()
@@ -313,6 +363,21 @@ for (const cacheEnabled of [false, true]) {
           }
           await current()
 
+          if (cacheEnabled) {
+            const finalCacheSize = await getCacheSize()
+            const maxIncrease = largeCacheIncrease ? 1.5 : 1.1
+            const increasePercent = (
+              (finalCacheSize / initialCacheSize - 1) *
+              100
+            ).toFixed(2)
+            console.log(
+              `Cache size (${name}): ${(initialCacheSize / 1024 / 1024).toFixed(2)} MB -> ${(finalCacheSize / 1024 / 1024).toFixed(2)} MB (${increasePercent}%)`
+            )
+            expect(finalCacheSize).toBeLessThanOrEqual(
+              initialCacheSize * maxIncrease
+            )
+          }
+
           await start()
           for (const change of changes) {
             await change.checkInitial()