diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml
index 2b15ed43c144..8e7d9289ea83 100644
--- a/.github/workflows/build_and_test.yml
+++ b/.github/workflows/build_and_test.yml
@@ -381,12 +381,12 @@ jobs:
fail-fast: false
matrix:
mode:
- - '--turbopack=false'
- - '--turbopack=true'
+ - '-F turbopack=false'
+ - '-F turbopack=true'
selector:
- - '--scenario=heavy-npm-deps-dev --page=homepage'
- - '--scenario=heavy-npm-deps-build --page=homepage'
- - '--scenario=heavy-npm-deps-build-turbo-cache-enabled --page=homepage'
+ - '--scenario=heavy-npm-deps-dev -F page=homepage'
+ - '--scenario=heavy-npm-deps-build -F page=homepage'
+ - '--scenario=heavy-npm-deps-build-turbo-cache-enabled -F page=homepage'
permissions:
contents: read
id-token: write
diff --git a/.github/workflows/upload_preview_tarballs.yml b/.github/workflows/upload_preview_tarballs.yml
index 4e6b1a55fb23..bbc591772520 100644
--- a/.github/workflows/upload_preview_tarballs.yml
+++ b/.github/workflows/upload_preview_tarballs.yml
@@ -1,7 +1,8 @@
# This workflow uploads preview tarballs to Vercel Blob after build-and-deploy
# completes. It uses workflow_run so it always executes the DEFAULT BRANCH
-# version of this file -- an attacker who modifies this file on a feature branch
-# cannot change the code that touches the blob write token.
+# version of this file, and the upload script is checked out from canary to
+# match -- an attacker who modifies either on a feature branch cannot change
+# the code that exchanges the OIDC token for a scoped upload URL.
name: upload-preview-tarballs
on:
@@ -23,36 +24,16 @@ jobs:
steps:
# Checkout from the default branch (canary) -- workflow_run always uses
# the default branch's version of the workflow file and this checkout
- # matches that, ensuring the upload script is trusted.
+ # matches that, ensuring the upload script is trusted. The script only
+ # uses built-in modules, so no node_modules or setup-node is required.
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: canary
fetch-depth: 1
persist-credentials: false
-
- - name: Setup node
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- with:
- node-version-file: .node-version
- check-latest: true
- package-manager-cache: false
-
- - name: Enable corepack
- run: corepack enable
-
- - name: Setup pnpm
- run: corepack prepare
-
- - name: Cache dependencies
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
- with:
- path: ~/.pnpm-store
- key: ${{ runner.os }}-${{ runner.arch }}-pnpm-v2-${{
- hashFiles('**/pnpm-lock.yaml') }}
- # Do not use restore-keys since it leads to indefinite growth of the cache.
-
- - name: Install node_modules
- run: pnpm install --frozen-lockfile
+ sparse-checkout: |
+ scripts/upload-preview-tarballs.js
+ sparse-checkout-cone-mode: false
- name: Download preview-tarballs artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
diff --git a/crates/next-api/src/aggregate_hmr.rs b/crates/next-api/src/aggregate_hmr.rs
index 42833b9b3904..e9578098b90e 100644
--- a/crates/next-api/src/aggregate_hmr.rs
+++ b/crates/next-api/src/aggregate_hmr.rs
@@ -1,110 +1,100 @@
+use std::{
+ fmt::Display,
+ sync::{Arc, LazyLock},
+};
+
use anyhow::Result;
+use serde::Serialize;
use turbo_rcstr::RcStr;
use turbo_tasks::{
- FxIndexMap, FxIndexSet, NonLocalValue, ReadRef, ResolvedVc, TraitRef, TryJoinIterExt, Vc,
- debug::ValueDebugFormat, trace::TraceRawVcs,
+ FxIndexMap, FxIndexSet, NonLocalValue, ReadRef, ResolvedVc, TryJoinIterExt, Vc,
+ debug::ValueDebugFormat,
+ message_queue::{CompilationEvent, Severity},
+ trace::TraceRawVcs,
+ turbo_tasks,
};
-use turbo_tasks_fs::FileSystemPath;
use turbo_tasks_hash::{Xxh3Hash64Hasher, encode_base64};
-use turbopack_browser::ecmascript::list::content::EcmascriptDevChunkListContent;
use turbopack_core::{
update_instruction::UpdateInstruction,
- version::{PartialUpdate, Update, Version, VersionState, VersionedContent},
+ version::{PartialUpdate, Update, Version},
};
use turbopack_ecmascript::chunk_list::{
merged_update::EcmascriptMergedUpdate,
update::{ChunkListUpdate, ChunkUpdate, EcmascriptUpdateInstruction},
+ version::ChunkListVersion,
+};
+use turbopack_nodejs::ecmascript::node::entry::chunk_list_content::{
+ EcmascriptBuildNodeChunkListContent, compute_update_from_version_operation,
};
-use turbopack_nodejs::ecmascript::node::entry::chunk_list_content::EcmascriptBuildNodeChunkListContent;
-
-use crate::versioned_content_map::VersionedContentMap;
-#[derive(TraceRawVcs, PartialEq, Eq, ValueDebugFormat, NonLocalValue)]
-pub struct HmrChunkWithContent {
- pub path: RcStr,
- pub content: ResolvedVc>,
+#[derive(Clone, TraceRawVcs, PartialEq, Eq, ValueDebugFormat, NonLocalValue)]
+pub struct ServerHmrChunkList {
+ pub relative_path: RcStr,
+ pub versioned_content: ResolvedVc,
}
#[turbo_tasks::value(transparent, serialization = "skip")]
-pub struct HmrChunksWithContent(Vec);
-
-/// Whether `content` is a chunk list, i.e. an entry point of the chunk graph that
-/// an HMR subscription can be anchored on.
-///
-/// Note this must enumerate every chunk list content type. A new chunking context
-/// that introduces one has to be added here, otherwise its chunks silently drop
-/// out of the HMR subscription.
-pub fn is_entry_chunk_list_content(content: ResolvedVc>) -> bool {
- ResolvedVc::try_downcast_type::(content).is_some()
- || ResolvedVc::try_downcast_type::(content).is_some()
+#[derive(Clone)]
+pub struct ServerHmrChunkLists(Vec);
+
+impl ServerHmrChunkLists {
+ pub fn new(chunk_lists: Vec) -> Self {
+ Self(chunk_lists)
+ }
+
+ pub fn as_slice(&self) -> &[ServerHmrChunkList] {
+ &self.0
+ }
+
+ pub fn retain_entry_paths(&mut self, entry_paths: &FxIndexSet) {
+ self.0
+ .retain(|chunk_list| entry_paths.contains(&chunk_list.relative_path));
+ }
}
-/// Per-chunk versions keyed by path
#[turbo_tasks::value(serialization = "skip", shared)]
-pub struct AggregateHmrVersion {
+#[derive(Debug)]
+pub struct ServerHmrChunkListVersion {
#[turbo_tasks(trace_ignore)]
- pub versions: FxIndexMap>>,
+ pub versions_by_chunk_list_path: FxIndexMap>,
}
#[turbo_tasks::value_impl]
-impl Version for AggregateHmrVersion {
+impl Version for ServerHmrChunkListVersion {
#[turbo_tasks::function]
async fn id(&self) -> Result> {
- let entries = self
- .versions
- .iter()
- .map(|(path, version)| {
- let path = path.clone();
- let version = TraitRef::cell(version.clone());
- async move {
- let id = version.id().owned().await?;
- anyhow::Ok((path, id))
- }
- })
- .try_join()
- .await?;
-
let mut hasher = Xxh3Hash64Hasher::new();
- hasher.write_value(entries.len());
- for (path, id) in entries {
+ hasher.write_value(self.versions_by_chunk_list_path.len());
+ for (path, version) in &self.versions_by_chunk_list_path {
hasher.write_value(path.as_str());
- hasher.write_value(id.as_str());
+ hasher.write_value(version.id.as_str());
}
Ok(Vc::cell(encode_base64(hasher.finish()).into()))
}
}
-impl AggregateHmrVersion {
- pub async fn from_map(
- map: Vc,
- root: FileSystemPath,
- ) -> Result>> {
- // An empty `versions` map behaves the same as `NotFoundVersion` would in
- // `diff_chunks_against`, so no special case is needed here.
- let chunks = map.hmr_chunks_in_path(root).await?;
- Ok(Vc::upcast(Self::from_chunks(&chunks).await?))
- }
-
- pub async fn from_chunks(chunks: &[HmrChunkWithContent]) -> Result> {
- let versions = chunks
+impl ServerHmrChunkListVersion {
+ pub async fn from_chunk_lists(chunk_lists: &[ServerHmrChunkList]) -> Result {
+ let versions_by_chunk_list_path = chunk_lists
.iter()
- .map(|HmrChunkWithContent { path, content }| {
- let path = path.clone();
- let content = *content;
+ .map(|chunk_list| {
+ let relative_path = chunk_list.relative_path.clone();
+ let versioned_content = chunk_list.versioned_content;
async move {
- let version = content.version().into_trait_ref().await?;
- anyhow::Ok((path, version))
+ let version = versioned_content.version().await?;
+ anyhow::Ok((relative_path, version))
}
})
.try_join()
.await?
.into_iter()
.collect();
- Ok(Self { versions }.cell())
+ Ok(Self {
+ versions_by_chunk_list_path,
+ })
}
}
-/// Aggregates per-entry HMR instructions into a single combined `ChunkListUpdate`.
#[derive(Default)]
pub struct ChunkListUpdateBuilder {
chunks: FxIndexMap,
@@ -138,77 +128,213 @@ impl ChunkListUpdateBuilder {
self.chunks.is_empty() && self.merged.is_empty()
}
- pub fn build(self, to: TraitRef>) -> Update {
- Update::Partial(PartialUpdate {
- to,
- instruction: ChunkListUpdate {
- chunks: self.chunks,
- merged: self.merged.into_iter().collect(),
- }
- .into_instruction(),
- })
+ pub fn build(self) -> UpdateInstruction {
+ ChunkListUpdate {
+ chunks: self.chunks,
+ merged: self.merged.into_iter().collect(),
+ }
+ .into_instruction()
}
}
-/// Per-chunk [`Update`]s computed against an `AggregateHmrVersion` snapshot.
-/// `has_new_chunks` is true when the current snapshot contains chunks absent
-/// from `from` (e.g. a new endpoint was written); callers decide whether that
-/// affects the batch shape.
-pub struct DiffResult {
- pub chunk_updates: Vec<(RcStr, ReadRef)>,
- pub has_new_chunks: bool,
+/// An update plus the baseline for the next pull.
+#[derive(Debug, TraceRawVcs)]
+pub enum ServerHmrUpdate {
+ /// No runtime update and the graph is equivalent. However, `to` may still advance the pull
+ /// version.
+ NoRuntimeUpdate {
+ to: Option>,
+ },
+ FullReevaluation {
+ to: ReadRef,
+ },
+ Partial {
+ to: ReadRef,
+ instruction: UpdateInstruction,
+ },
}
-/// Diffs each chunk against the [`AggregateHmrVersion`] held by `from`.
-///
-/// If `from` holds some other kind of `Version`, there's nothing meaningful to
-/// diff against, so this returns no updates and leaves it to the caller to
-/// decide what to do.
-pub async fn diff_chunks_against(
- chunks: &[HmrChunkWithContent],
- from: Vc,
-) -> Result {
- if chunks.is_empty() {
- return Ok(DiffResult {
- chunk_updates: Vec::new(),
- has_new_chunks: false,
- });
- }
- let from_resolved = from.get().to_resolved().await?;
- let Some(from_aggregate) = ResolvedVc::try_downcast_type::(from_resolved)
- else {
- return Ok(DiffResult {
- chunk_updates: Vec::new(),
- has_new_chunks: false,
- });
- };
- let from_aggregate = from_aggregate.await?;
+struct DiffResult {
+ chunk_updates: Vec>,
+ membership: ChunkListMembershipChange,
+}
+
+/// Chunk lists that appeared or vanished relative to the pull baseline.
+#[derive(Default, Clone, Copy)]
+struct ChunkListMembershipChange {
+ has_new: bool,
+ has_removed: bool,
+}
+
+static TRACE_DIFFING: LazyLock = LazyLock::new(|| {
+ cfg!(debug_assertions) && std::env::var_os("NEXT_TEST_SERVER_HMR_DIFFING").is_some()
+});
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+struct ServerHmrChunkListDiffEvent {
+ #[serde(rename = "entryPath")]
+ chunk_list_path: RcStr,
+}
+
+impl Display for ServerHmrChunkListDiffEvent {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, "Diffing server HMR entry {}", self.chunk_list_path)
+ }
+}
+
+impl CompilationEvent for ServerHmrChunkListDiffEvent {
+ fn type_name(&self) -> &'static str {
+ "ServerHmrEntryDiffEvent"
+ }
+
+ fn severity(&self) -> Severity {
+ Severity::Trace
+ }
- let mut has_new_chunks = false;
- let chunk_updates = chunks
+ fn message(&self) -> String {
+ self.to_string()
+ }
+
+ fn to_json(&self) -> String {
+ serde_json::to_string(self).expect("server HMR entry diff event serializes")
+ }
+}
+
+async fn diff_chunks_against(
+ chunk_lists: &[ServerHmrChunkList],
+ from: &ServerHmrChunkListVersion,
+) -> Result {
+ let current_chunk_list_paths = chunk_lists
.iter()
- .filter_map(|HmrChunkWithContent { path, content }| {
- let Some(prev) = from_aggregate.versions.get(path).cloned() else {
- has_new_chunks = true;
- return None;
- };
- Some((path.clone(), *content, TraitRef::cell(prev)))
- })
- .map(async |(path, content, prev)| {
- let update = content.update(prev).await?;
- anyhow::Ok((path, update))
+ .map(|chunk_list| &chunk_list.relative_path)
+ .collect::>();
+ let has_removed_chunk_lists = from
+ .versions_by_chunk_list_path
+ .keys()
+ .any(|path| !current_chunk_list_paths.contains(path));
+ let mut has_new_chunk_lists = false;
+ let chunk_updates = chunk_lists
+ .iter()
+ .filter_map(
+ |ServerHmrChunkList {
+ relative_path,
+ versioned_content,
+ }| {
+ if *TRACE_DIFFING {
+ turbo_tasks().send_compilation_event(Arc::new(ServerHmrChunkListDiffEvent {
+ chunk_list_path: relative_path.clone(),
+ }));
+ }
+ let Some(prev) = from.versions_by_chunk_list_path.get(relative_path).cloned()
+ else {
+ has_new_chunk_lists = true;
+ return None;
+ };
+ Some((*versioned_content, prev))
+ },
+ )
+ .map(|(content, prev)| async move {
+ compute_update_from_version_operation(
+ content,
+ turbo_tasks::TransientInstance::new(prev),
+ )
+ .read_strongly_consistent()
+ .await
})
.try_join()
.await?;
Ok(DiffResult {
chunk_updates,
- has_new_chunks,
+ membership: ChunkListMembershipChange {
+ has_new: has_new_chunk_lists,
+ has_removed: has_removed_chunk_lists,
+ },
})
}
+enum ServerHmrChunkUpdate<'a> {
+ None,
+ Missing,
+ Total,
+ Partial(&'a UpdateInstruction),
+}
+
+impl<'a> From<&'a Update> for ServerHmrChunkUpdate<'a> {
+ fn from(update: &'a Update) -> Self {
+ match update {
+ Update::None => Self::None,
+ Update::Missing => Self::Missing,
+ Update::Total(_) => Self::Total,
+ Update::Partial(PartialUpdate { instruction, .. }) => Self::Partial(instruction),
+ }
+ }
+}
+
+fn classify_server_hmr_update<'a>(
+ chunk_updates: impl IntoIterator- >,
+ membership: ChunkListMembershipChange,
+ to: ReadRef,
+) -> ServerHmrUpdate {
+ if membership.has_removed {
+ return ServerHmrUpdate::FullReevaluation { to };
+ }
+
+ let mut builder = ChunkListUpdateBuilder::default();
+ for update in chunk_updates {
+ match update {
+ ServerHmrChunkUpdate::None => {}
+ ServerHmrChunkUpdate::Missing | ServerHmrChunkUpdate::Total => {
+ return ServerHmrUpdate::FullReevaluation { to };
+ }
+ ServerHmrChunkUpdate::Partial(instruction) => builder.add_instruction(instruction),
+ }
+ }
+
+ // New chunks load on demand but must advance the baseline.
+ if builder.is_empty() {
+ return ServerHmrUpdate::NoRuntimeUpdate {
+ to: membership.has_new.then_some(to),
+ };
+ }
+
+ ServerHmrUpdate::Partial {
+ to,
+ instruction: builder.build(),
+ }
+}
+
+/// Kept outside Turbo Tasks so old pull baselines cannot reactivate.
+pub async fn compute_server_hmr_update(
+ chunk_lists: &[ServerHmrChunkList],
+ from: Option<&ServerHmrChunkListVersion>,
+ to: ReadRef,
+) -> Result {
+ if chunk_lists.is_empty() {
+ return Ok(ServerHmrUpdate::NoRuntimeUpdate { to: None });
+ }
+
+ let Some(from) = from else {
+ return Ok(ServerHmrUpdate::NoRuntimeUpdate { to: Some(to) });
+ };
+
+ let DiffResult {
+ chunk_updates,
+ membership,
+ } = diff_chunks_against(chunk_lists, from).await?;
+
+ Ok(classify_server_hmr_update(
+ chunk_updates
+ .iter()
+ .map(|update| ServerHmrChunkUpdate::from(&**update)),
+ membership,
+ to,
+ ))
+}
+
#[cfg(test)]
mod tests {
- use turbo_tasks::{FxIndexMap, FxIndexSet};
+ use turbo_tasks::{FxIndexMap, FxIndexSet, ReadRef};
use turbopack_core::update_instruction::UpdateInstruction;
use turbopack_ecmascript::chunk_list::{
merged_update::{
@@ -217,7 +343,34 @@ mod tests {
update::{ChunkListUpdate, ChunkUpdate, EcmascriptUpdateInstruction},
};
- use super::ChunkListUpdateBuilder;
+ use super::{
+ ChunkListMembershipChange, ChunkListUpdateBuilder, ServerHmrChunkListVersion,
+ ServerHmrChunkUpdate, ServerHmrUpdate, classify_server_hmr_update,
+ };
+
+ fn version() -> ReadRef {
+ ReadRef::new_owned(ServerHmrChunkListVersion {
+ versions_by_chunk_list_path: Default::default(),
+ })
+ }
+
+ fn unchanged_membership() -> ChunkListMembershipChange {
+ ChunkListMembershipChange::default()
+ }
+
+ fn added_chunk_lists() -> ChunkListMembershipChange {
+ ChunkListMembershipChange {
+ has_new: true,
+ has_removed: false,
+ }
+ }
+
+ fn removed_chunk_lists() -> ChunkListMembershipChange {
+ ChunkListMembershipChange {
+ has_new: false,
+ has_removed: true,
+ }
+ }
fn merged(chunk_path: &str) -> EcmascriptMergedUpdate {
EcmascriptMergedUpdate {
@@ -233,6 +386,88 @@ mod tests {
}
}
+ #[test]
+ fn unchanged_chunks_produce_no_runtime_update() {
+ assert!(matches!(
+ classify_server_hmr_update(
+ [ServerHmrChunkUpdate::None],
+ unchanged_membership(),
+ version()
+ ),
+ ServerHmrUpdate::NoRuntimeUpdate { to: None }
+ ));
+ }
+
+ #[test]
+ fn missing_chunk_produces_full_reevaluation() {
+ assert!(matches!(
+ classify_server_hmr_update(
+ [ServerHmrChunkUpdate::Missing],
+ unchanged_membership(),
+ version()
+ ),
+ ServerHmrUpdate::FullReevaluation { .. }
+ ));
+ }
+
+ #[test]
+ fn total_update_produces_full_reevaluation() {
+ assert!(matches!(
+ classify_server_hmr_update(
+ [ServerHmrChunkUpdate::Total],
+ unchanged_membership(),
+ version()
+ ),
+ ServerHmrUpdate::FullReevaluation { .. }
+ ));
+ }
+
+ #[test]
+ fn partial_instructions_are_combined() {
+ let chunk_list = ChunkListUpdate {
+ chunks: FxIndexMap::from_iter([("a.js".into(), ChunkUpdate::Added)]),
+ merged: vec![],
+ }
+ .into_instruction();
+ let merged_instruction =
+ UpdateInstruction::new(EcmascriptUpdateInstruction::Merged(merged("b.js")));
+
+ let ServerHmrUpdate::Partial { instruction, .. } = classify_server_hmr_update(
+ [
+ ServerHmrChunkUpdate::Partial(&chunk_list),
+ ServerHmrChunkUpdate::Partial(&merged_instruction),
+ ],
+ unchanged_membership(),
+ version(),
+ ) else {
+ panic!("partial instructions should produce a partial aggregate update");
+ };
+ let instruction = instruction
+ .downcast_ref::()
+ .expect("aggregate instruction is ECMAScript");
+ let EcmascriptUpdateInstruction::ChunkList(update) = instruction else {
+ panic!("aggregate instruction should be a chunk-list update");
+ };
+ assert_eq!(update.chunks["a.js"], ChunkUpdate::Added);
+ assert_eq!(update.merged, [merged("b.js")]);
+ }
+
+ #[test]
+ fn new_chunk_lists_advance_baseline_without_runtime_update() {
+ assert!(matches!(
+ classify_server_hmr_update([], added_chunk_lists(), version()),
+ ServerHmrUpdate::NoRuntimeUpdate { to: Some(_) }
+ ));
+ }
+
+ #[test]
+ fn removed_chunk_lists_produce_full_reevaluation() {
+ assert!(matches!(
+ classify_server_hmr_update([], removed_chunk_lists(), version()),
+ ServerHmrUpdate::FullReevaluation { .. }
+ ));
+ }
+
#[test]
fn deduplicates_merged_updates_in_first_seen_order() {
let first = merged("first.js");
diff --git a/crates/next-api/src/app.rs b/crates/next-api/src/app.rs
index cb33963ff2dd..ad0afab16809 100644
--- a/crates/next-api/src/app.rs
+++ b/crates/next-api/src/app.rs
@@ -2168,14 +2168,26 @@ impl Endpoint for AppEndpoint {
};
let written_endpoint = match *output.await? {
- AppEndpointOutput::NodeJs { rsc_chunk, .. } => EndpointOutputPaths::NodeJs {
- server_entry_path: node_root
+ AppEndpointOutput::NodeJs { rsc_chunk, .. } => {
+ let server_entry_path: RcStr = node_root
.get_path_to(&*rsc_chunk.path().await?)
.context("Node.js chunk entry path must be inside the node root")?
- .into(),
- server_paths,
- client_paths,
- },
+ .into();
+ let hmr_entry_path = server_entry_path
+ .strip_prefix("server/app/")
+ .unwrap_or(&server_entry_path)
+ .strip_suffix(".js")
+ .unwrap_or(&server_entry_path);
+ EndpointOutputPaths::NodeJs {
+ server_hmr_entry_paths: vec![
+ format!("{hmr_entry_path}.js").into(),
+ format!("{hmr_entry_path}/client-components-ssr.js").into(),
+ ],
+ server_entry_path,
+ server_paths,
+ client_paths,
+ }
+ }
AppEndpointOutput::Edge { .. } => EndpointOutputPaths::Edge {
server_paths,
client_paths,
diff --git a/crates/next-api/src/lib.rs b/crates/next-api/src/lib.rs
index f4e3a9d48d3a..a98f840ec964 100644
--- a/crates/next-api/src/lib.rs
+++ b/crates/next-api/src/lib.rs
@@ -2,7 +2,7 @@
#![feature(arbitrary_self_types_pointers)]
#![feature(impl_trait_in_assoc_type)]
-mod aggregate_hmr;
+pub mod aggregate_hmr;
pub mod analyze;
mod app;
mod asset_hashes_manifest;
diff --git a/crates/next-api/src/pages.rs b/crates/next-api/src/pages.rs
index bff0e3dd6616..cccd703dbbf7 100644
--- a/crates/next-api/src/pages.rs
+++ b/crates/next-api/src/pages.rs
@@ -1736,6 +1736,7 @@ impl Endpoint for PageEndpoint {
EndpointOutputPaths::NodeJs {
server_entry_path,
+ server_hmr_entry_paths: vec![],
server_paths,
client_paths,
}
diff --git a/crates/next-api/src/project.rs b/crates/next-api/src/project.rs
index 20b8200c0a67..631a6c91a99d 100644
--- a/crates/next-api/src/project.rs
+++ b/crates/next-api/src/project.rs
@@ -84,8 +84,7 @@ use turbopack_core::{
reference_type::{CommonJsReferenceSubType, ReferenceType},
resolve::{FindContextFileResult, find_context_file},
version::{
- NotFoundVersion, OptionVersionedContent, PartialUpdate, TotalUpdate, Update, Version,
- VersionState, VersionedContent,
+ NotFoundVersion, OptionVersionedContent, Update, Version, VersionState, VersionedContent,
},
};
#[cfg(all(feature = "process_pool", not(target_family = "wasm")))]
@@ -96,7 +95,7 @@ use turbopack_node::worker_threads_backend;
use turbopack_nodejs::{NodeJsChunkingContext, fs::NodeModulesPathMatcher};
use crate::{
- aggregate_hmr::{AggregateHmrVersion, ChunkListUpdateBuilder, DiffResult, diff_chunks_against},
+ aggregate_hmr::ServerHmrChunkLists,
app::{AppProject, OptionAppProject},
empty::EmptyEndpoint,
entrypoints::Entrypoints,
@@ -2538,98 +2537,25 @@ impl Project {
}
}
- /// Aggregate counterpart to [`Self::hmr_version_state`]: one [`VersionState`]
- /// covering every server HMR-eligible chunk. See [`Self::server_hmr_update`].
- #[turbo_tasks::function(session_dependent)]
- pub async fn server_hmr_version_state(self: ResolvedVc) -> Result> {
- #[tracing::instrument(level = "info", name = "get server HMR version", skip_all)]
- #[turbo_tasks::function(operation, root)]
- async fn server_hmr_version_operation(
- this: ResolvedVc,
- ) -> Result>> {
- let Some(map) = this.await?.versioned_content_map else {
- bail!("must be in dev mode to hmr")
- };
- let root = this.server_hmr_root_path().owned().await?;
- AggregateHmrVersion::from_map(*map, root).await
- }
- let version_op = server_hmr_version_operation(self);
-
- // INVALIDATION: untracked initial read; the subscription drives invalidation.
- let state = VersionState::new(
- version_op
- .read_trait_strongly_consistent()
- .untracked()
- .await?,
- )
- .await?;
- Ok(state)
- }
-
- /// Aggregate counterpart to [`Self::hmr_update`]: a single `Update` whose
- /// combined `ChunkListUpdate` is the union of the server entry chunk diffs.
- ///
- /// Each tracked entry chunk's own update is a `ChunkListUpdate` (carrying
- /// the module deltas for its shared chunks via the merger) or a bare
- /// `EcmascriptMergedUpdate`; both are folded into one `ChunkListUpdate` that
- /// the runtime applies exactly as it would a single chunk list.
- ///
- /// All-or-nothing restart: any chunk needing `Total`/`Missing` escalates
- /// the whole batch to `Total` (the runtime can't partially restart). New
- /// chunks absent from `from` are skipped; the runtime require()s them on
- /// demand.
+ /// Server entry chunks shared by all pull baselines.
#[turbo_tasks::function]
- pub async fn server_hmr_update(self: Vc, from: Vc) -> Result> {
+ pub async fn server_hmr_chunks(self: Vc) -> Result> {
let Some(map) = self.await?.versioned_content_map else {
bail!("must be in dev mode to hmr")
};
let root = self.server_hmr_root_path().owned().await?;
- let chunks_versioned_content = map.hmr_chunks_in_path(root).await?;
-
- // No chunks to diff yet (e.g. before any endpoints have been written).
- if chunks_versioned_content.is_empty() {
- return Ok(Update::None.cell());
- }
-
- // Build `to` up front so we can return it on every escape hatch below.
- let to_aggregate = AggregateHmrVersion::from_chunks(&chunks_versioned_content).await?;
- let to_ref = Vc::upcast::>(to_aggregate)
- .into_trait_ref()
- .await?;
-
- let DiffResult {
- chunk_updates,
- has_new_chunks,
- } = diff_chunks_against(&chunks_versioned_content, from).await?;
-
- // Nothing to apply, but `from` still needs to advance to `to`. Reaching
- // here means `from` held a version we couldn't diff against (it wasn't an
- // `AggregateHmrVersion`), so `diff_chunks_against` gave up and returned
- // nothing. An empty `Partial` moves the subscription's state forward so
- // the *next* change produces a real diff; returning `Total` instead would
- // force a needless full re-evaluation.
- if chunk_updates.is_empty() && !has_new_chunks {
- return Ok(ChunkListUpdateBuilder::default().build(to_ref).cell());
- }
-
- let mut builder = ChunkListUpdateBuilder::default();
- for (_path, update) in chunk_updates {
- match &*update {
- Update::None => {}
- Update::Missing | Update::Total(_) => {
- return Ok(Update::Total(TotalUpdate { to: to_ref }).cell());
- }
- Update::Partial(PartialUpdate { instruction, .. }) => {
- builder.add_instruction(instruction);
- }
- }
- }
-
- if builder.is_empty() && !has_new_chunks {
- return Ok(Update::None.cell());
- }
+ Ok(map.server_hmr_chunks_in_path(root))
+ }
- Ok(builder.build(to_ref).cell())
+ #[turbo_tasks::function]
+ pub async fn server_hmr_chunks_for_entries(
+ self: Vc,
+ entry_paths: Vec,
+ ) -> Result> {
+ let mut chunk_lists =
+ ServerHmrChunkLists::new(self.server_hmr_chunks().await?.as_slice().to_vec());
+ chunk_lists.retain_entry_paths(&entry_paths.into_iter().collect());
+ Ok(chunk_lists.cell())
}
/// Gets a list of all client HMR chunk names that can be subscribed to.
diff --git a/crates/next-api/src/route.rs b/crates/next-api/src/route.rs
index 2aecbb5ce807..60017b70ab4b 100644
--- a/crates/next-api/src/route.rs
+++ b/crates/next-api/src/route.rs
@@ -294,6 +294,7 @@ pub enum EndpointOutputPaths {
NodeJs {
/// Relative to the root_path
server_entry_path: RcStr,
+ server_hmr_entry_paths: Vec,
server_paths: Vec,
client_paths: Vec,
},
diff --git a/crates/next-api/src/versioned_content_map.rs b/crates/next-api/src/versioned_content_map.rs
index 863621d28830..986db4fc2cb9 100644
--- a/crates/next-api/src/versioned_content_map.rs
+++ b/crates/next-api/src/versioned_content_map.rs
@@ -14,10 +14,9 @@ use turbopack_core::{
source_map::GenerateSourceMap,
version::OptionVersionedContent,
};
+use turbopack_nodejs::ecmascript::node::entry::chunk_list_content::EcmascriptBuildNodeChunkListContent;
-use crate::aggregate_hmr::{
- HmrChunkWithContent, HmrChunksWithContent, is_entry_chunk_list_content,
-};
+use crate::aggregate_hmr::{ServerHmrChunkList, ServerHmrChunkLists};
#[derive(
Clone, TraceRawVcs, PartialEq, Eq, ValueDebugFormat, Debug, NonLocalValue, Encode, Decode,
@@ -94,8 +93,8 @@ impl VersionedContentMap {
#[turbo_tasks::value_impl]
impl VersionedContentMap {
/// Lists the aggregate-HMR *entry* chunks under `root` with their
- /// [`VersionedContent`], sorted by path. Only entry-chunk-list content is
- /// returned (see [`is_entry_chunk_list_content`]). Callers scope which
+ /// [`VersionedContent`], sorted by path. Only Node.js chunk-list content is
+ /// returned. Callers scope which
/// entries are included by narrowing `root` (e.g. the aggregate server-HMR
/// subscription passes `server/app` to include App Router entries only).
///
@@ -106,10 +105,10 @@ impl VersionedContentMap {
/// can shift the internals of the map, making iteration order different
/// for the same set of paths.
#[turbo_tasks::function(session_dependent)]
- pub async fn hmr_chunks_in_path(
+ pub async fn server_hmr_chunks_in_path(
self: Vc,
root: FileSystemPath,
- ) -> Result> {
+ ) -> Result> {
let this = self.await?;
// `State::get` returns a lock guard, which can't be held across the
// awaits below, so snapshot the keys and release it.
@@ -138,18 +137,20 @@ impl VersionedContentMap {
// *Important*: only chunk lists are subscribed to. Individual chunks are already
// covered by the chunk list that owns them, so including them here
// would produce duplicate updates for the same change.
- if !is_entry_chunk_list_content(content) {
+ let Some(versioned_content) =
+ ResolvedVc::try_downcast_type::(content)
+ else {
return Ok(None);
- }
+ };
- Ok(Some(HmrChunkWithContent {
- path: name,
- content,
+ Ok(Some(ServerHmrChunkList {
+ relative_path: name,
+ versioned_content,
}))
})
.try_flat_join()
.await?;
- chunks.sort_by(|a, b| a.path.cmp(&b.path));
+ chunks.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
Ok(Vc::cell(chunks))
}
diff --git a/crates/next-napi-bindings/src/next_api/endpoint.rs b/crates/next-napi-bindings/src/next_api/endpoint.rs
index a178160698e2..a417f65ae41e 100644
--- a/crates/next-napi-bindings/src/next_api/endpoint.rs
+++ b/crates/next-napi-bindings/src/next_api/endpoint.rs
@@ -52,6 +52,7 @@ impl From for NapiAssetPath {
pub struct NapiWrittenEndpoint {
pub r#type: String,
pub entry_path: Option,
+ pub server_hmr_entry_paths: Vec,
pub client_paths: Vec,
pub server_paths: Vec,
pub config: NapiEndpointConfig,
@@ -62,11 +63,16 @@ impl From
}>
+
+
+ Loading recommendations…}>
+
+
+
+ )
+}
+
+async function TrackDetails({ params }: { params: Promise<{ slug: string }> }) {
+ const { slug } = await params
+ const track = await getTrack(slug)
+ if (!track) notFound()
+
+ return (
+
+ {track.title}
+ {track.artist}
+
+ )
+}
+
+async function Recommendations({
+ params,
+}: {
+ params: Promise<{ slug: string }>
+}) {
+ const { slug } = await params
+ const recommendations = await getRecommendations(slug)
+
+ return (
+
+ Recommended next
+ {recommendations.map((track) => (
+ {track.title}
+ ))}
+
+ )
+}
diff --git a/evals/evals/agent-046-adopt-partial-prefetching/components/catalog-link.tsx b/evals/evals/agent-046-adopt-partial-prefetching/components/catalog-link.tsx
new file mode 100644
index 000000000000..bbf38fd7ed0f
--- /dev/null
+++ b/evals/evals/agent-046-adopt-partial-prefetching/components/catalog-link.tsx
@@ -0,0 +1,12 @@
+'use client'
+
+import Link from 'next/link'
+import type { ComponentProps } from 'react'
+
+type CatalogLinkProps = Omit, 'prefetch'> & {
+ eager?: boolean
+}
+
+export function CatalogLink({ eager = false, ...props }: CatalogLinkProps) {
+ return
+}
diff --git a/evals/evals/agent-046-adopt-partial-prefetching/lib/tracks.ts b/evals/evals/agent-046-adopt-partial-prefetching/lib/tracks.ts
new file mode 100644
index 000000000000..928f9fe70066
--- /dev/null
+++ b/evals/evals/agent-046-adopt-partial-prefetching/lib/tracks.ts
@@ -0,0 +1,29 @@
+import { cookies } from 'next/headers'
+
+export type Track = {
+ slug: string
+ title: string
+ artist: string
+}
+
+const tracks: Record = {
+ aurora: { slug: 'aurora', title: 'Aurora', artist: 'Echo North' },
+ nebula: { slug: 'nebula', title: 'Nebula', artist: 'Static Gardens' },
+}
+
+export async function getTrack(slug: string) {
+ const cookieStore = await cookies()
+ const listener = cookieStore.get('listener')?.value ?? 'guest'
+ return queryTrack(slug, listener)
+}
+
+async function queryTrack(slug: string, listener: string) {
+ await new Promise((resolve) => setTimeout(resolve, 120))
+ const track = tracks[slug]
+ return track ? { ...track, listener } : null
+}
+
+export async function getRecommendations(slug: string) {
+ await new Promise((resolve) => setTimeout(resolve, 500))
+ return Object.values(tracks).filter((track) => track.slug !== slug)
+}
diff --git a/evals/evals/agent-046-adopt-partial-prefetching/next-env.d.ts b/evals/evals/agent-046-adopt-partial-prefetching/next-env.d.ts
new file mode 100644
index 000000000000..1b3be0840f3f
--- /dev/null
+++ b/evals/evals/agent-046-adopt-partial-prefetching/next-env.d.ts
@@ -0,0 +1,5 @@
+///
+///
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/evals/evals/agent-046-adopt-partial-prefetching/next.config.ts b/evals/evals/agent-046-adopt-partial-prefetching/next.config.ts
new file mode 100644
index 000000000000..fa33c7c54f24
--- /dev/null
+++ b/evals/evals/agent-046-adopt-partial-prefetching/next.config.ts
@@ -0,0 +1,7 @@
+import type { NextConfig } from 'next'
+
+const nextConfig: NextConfig = {
+ cacheComponents: true,
+}
+
+export default nextConfig
diff --git a/evals/evals/agent-046-adopt-partial-prefetching/package.json b/evals/evals/agent-046-adopt-partial-prefetching/package.json
new file mode 100644
index 000000000000..7056dd9ed5f9
--- /dev/null
+++ b/evals/evals/agent-046-adopt-partial-prefetching/package.json
@@ -0,0 +1,23 @@
+{
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start"
+ },
+ "dependencies": {
+ "next": "^16",
+ "react": "19.1.0",
+ "react-dom": "19.1.0"
+ },
+ "devDependencies": {
+ "@types/node": "^20",
+ "@types/react": "^19",
+ "@types/react-dom": "^19",
+ "typescript": "^5",
+ "vitest": "^3.1.3",
+ "@vitejs/plugin-react": "^4.4.1",
+ "vite-tsconfig-paths": "^5.1.4"
+ }
+}
diff --git a/evals/evals/agent-046-adopt-partial-prefetching/tsconfig.json b/evals/evals/agent-046-adopt-partial-prefetching/tsconfig.json
new file mode 100644
index 000000000000..cc321ed0658b
--- /dev/null
+++ b/evals/evals/agent-046-adopt-partial-prefetching/tsconfig.json
@@ -0,0 +1,27 @@
+{
+ "compilerOptions": {
+ "target": "ES2017",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "react-jsx",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": ["./*"]
+ }
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+ "exclude": ["node_modules", "EVAL.ts"]
+}
diff --git a/evals/evals/agent-047-adopt-cache-components/EVAL.ts b/evals/evals/agent-047-adopt-cache-components/EVAL.ts
new file mode 100644
index 000000000000..2fe5b15a8653
--- /dev/null
+++ b/evals/evals/agent-047-adopt-cache-components/EVAL.ts
@@ -0,0 +1,76 @@
+/**
+ * Adopt Cache Components
+ *
+ * Verifies a complete adoption rather than a single directive. The agent must
+ * enable the feature, use targeted caches for reusable data, preserve
+ * request-specific behavior, and create meaningful static shells instead of
+ * silencing blocking routes with opt-outs.
+ */
+
+import { expect, test } from 'vitest'
+import { existsSync, readFileSync, readdirSync, statSync } from 'fs'
+import { join } from 'path'
+import { environment, transcript } from '@vercel/agent-eval/eval'
+
+const IGNORE_DIRS = new Set([
+ '.git',
+ '.next',
+ 'node_modules',
+ 'dist',
+ 'build',
+ 'coverage',
+])
+
+function readSourceFiles(dir: string): string[] {
+ if (!existsSync(dir)) return []
+
+ return readdirSync(dir).flatMap((entry) => {
+ if (IGNORE_DIRS.has(entry)) return []
+ const path = join(dir, entry)
+ if (statSync(path).isDirectory()) return readSourceFiles(path)
+ if (entry === 'EVAL.ts' || !/\.(ts|tsx|js|jsx)$/.test(entry)) return []
+ return readFileSync(path, 'utf-8')
+ })
+}
+
+const source = readSourceFiles(process.cwd()).join('\n')
+
+test('enables Cache Components without route opt-outs', () => {
+ const config = readFileSync(join(process.cwd(), 'next.config.ts'), 'utf-8')
+
+ expect(config).toMatch(/cacheComponents\s*:\s*true/)
+ expect(source).not.toMatch(/export\s+(?:const|var|let)\s+instant\s*=\s*false/)
+})
+
+test('removes incompatible route revalidation config', () => {
+ expect(source).not.toMatch(/export\s+(?:const|var|let)\s+revalidate\s*=/)
+})
+
+test('uses explicit cache lifetime for reusable work', () => {
+ expect(source).toMatch(/['"]use cache(?:: private)?['"]/)
+ expect(source).toMatch(/\bcacheLife\s*\(/)
+})
+
+test('preserves request-specific account behavior and a meaningful shell', async () => {
+ await expect(environment).toSatisfyCriterion(
+ `The account route still reads the display-name cookie at request time and renders it in the greeting. Cookie access is not placed inside a public use-cache function or otherwise shared between users. Request-time account content is isolated behind meaningful Suspense or loading UI while the Account heading or another useful stable frame remains in the static shell.`
+ )
+})
+
+test('preserves the catalog cache and timestamp cadence', async () => {
+ await expect(environment).toSatisfyCriterion(
+ `The catalog keeps the starter route's hourly revalidation behavior after the incompatible revalidate export is removed. A page-level or data-level use-cache boundary with an equivalent cache lifetime is valid. The catalog check timestamp belongs to that same hourly result and refreshes when the cached result refreshes; it is not incorrectly required to change on every request.`
+ )
+})
+
+test('keeps URL-specific product work below a Suspense boundary', async () => {
+ await expect(environment).toSatisfyCriterion(
+ `The /products/[slug] page retains useful route-independent shell content and does not await params at the top of the page before returning its frame. URL-specific params and product rendering happen in a child below a meaningful Suspense boundary.`
+ )
+})
+
+test('diagnosed blocking routes and completed the production migration', async () => {
+ await expect(transcript).toSatisfyCriterion(
+ `The agent enabled cacheComponents, used a production build or Next.js runtime diagnostics to discover the resulting blocking routes, fixed each route according to whether its content was reusable or request-specific, and finished with a successful production build. It did not merely add route-wide opt-outs or stop after the first green type check.`
+ )
+})
diff --git a/evals/evals/agent-047-adopt-cache-components/PROMPT.md b/evals/evals/agent-047-adopt-cache-components/PROMPT.md
new file mode 100644
index 000000000000..b2015e8f224e
--- /dev/null
+++ b/evals/evals/agent-047-adopt-cache-components/PROMPT.md
@@ -0,0 +1 @@
+Move this storefront to Cache Components in one branch. The catalog, product pages, account greeting, and timestamps should keep their current behavior. Finish with a production build and do not leave routes opted out.
diff --git a/evals/evals/agent-047-adopt-cache-components/app/account/page.tsx b/evals/evals/agent-047-adopt-cache-components/app/account/page.tsx
new file mode 100644
index 000000000000..1a334d3a0e68
--- /dev/null
+++ b/evals/evals/agent-047-adopt-cache-components/app/account/page.tsx
@@ -0,0 +1,13 @@
+import { cookies } from 'next/headers'
+
+export default async function AccountPage() {
+ const cookieStore = await cookies()
+ const displayName = cookieStore.get('display-name')?.value ?? 'Guest'
+
+ return (
+
+ Account
+ Welcome back, {displayName}.
+
+ )
+}
diff --git a/evals/evals/agent-047-adopt-cache-components/app/layout.tsx b/evals/evals/agent-047-adopt-cache-components/app/layout.tsx
new file mode 100644
index 000000000000..238684983ebb
--- /dev/null
+++ b/evals/evals/agent-047-adopt-cache-components/app/layout.tsx
@@ -0,0 +1,20 @@
+import type { Metadata } from 'next'
+import type { ReactNode } from 'react'
+import Link from 'next/link'
+
+export const metadata: Metadata = {
+ title: 'Northstar Supply',
+}
+
+export default function RootLayout({ children }: { children: ReactNode }) {
+ return (
+
+
+
+ {children}
+
+
+ )
+}
diff --git a/evals/evals/agent-047-adopt-cache-components/app/page.tsx b/evals/evals/agent-047-adopt-cache-components/app/page.tsx
new file mode 100644
index 000000000000..a5c1730687cc
--- /dev/null
+++ b/evals/evals/agent-047-adopt-cache-components/app/page.tsx
@@ -0,0 +1,22 @@
+import Link from 'next/link'
+import { getProducts } from '@/lib/catalog'
+
+export const revalidate = 3600
+
+export default async function CatalogPage() {
+ const products = await getProducts()
+
+ return (
+
+ Northstar Supply
+ Catalog checked at {new Date().toLocaleTimeString('en-US')}.
+
+ {products.map((product) => (
+ -
+ {product.name}
+
+ ))}
+
+
+ )
+}
diff --git a/evals/evals/agent-047-adopt-cache-components/app/products/[slug]/page.tsx b/evals/evals/agent-047-adopt-cache-components/app/products/[slug]/page.tsx
new file mode 100644
index 000000000000..ffd851921de4
--- /dev/null
+++ b/evals/evals/agent-047-adopt-cache-components/app/products/[slug]/page.tsx
@@ -0,0 +1,19 @@
+import { notFound } from 'next/navigation'
+import { getProduct } from '@/lib/catalog'
+
+export default async function ProductPage({
+ params,
+}: {
+ params: Promise<{ slug: string }>
+}) {
+ const { slug } = await params
+ const product = await getProduct(slug)
+ if (!product) notFound()
+
+ return (
+
+ {product.name}
+ {product.description}
+
+ )
+}
diff --git a/evals/evals/agent-047-adopt-cache-components/lib/catalog.ts b/evals/evals/agent-047-adopt-cache-components/lib/catalog.ts
new file mode 100644
index 000000000000..e8c6b87b0dc5
--- /dev/null
+++ b/evals/evals/agent-047-adopt-cache-components/lib/catalog.ts
@@ -0,0 +1,28 @@
+export type Product = {
+ slug: string
+ name: string
+ description: string
+}
+
+const products: Product[] = [
+ {
+ slug: 'field-notes',
+ name: 'Field Notes',
+ description: 'Weatherproof notes for long days outside.',
+ },
+ {
+ slug: 'trail-light',
+ name: 'Trail Light',
+ description: 'A compact light with a warm reading mode.',
+ },
+]
+
+export async function getProducts() {
+ await new Promise((resolve) => setTimeout(resolve, 100))
+ return products
+}
+
+export async function getProduct(slug: string) {
+ await new Promise((resolve) => setTimeout(resolve, 100))
+ return products.find((product) => product.slug === slug) ?? null
+}
diff --git a/evals/evals/agent-047-adopt-cache-components/next-env.d.ts b/evals/evals/agent-047-adopt-cache-components/next-env.d.ts
new file mode 100644
index 000000000000..1b3be0840f3f
--- /dev/null
+++ b/evals/evals/agent-047-adopt-cache-components/next-env.d.ts
@@ -0,0 +1,5 @@
+///
+///
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/evals/evals/agent-047-adopt-cache-components/next.config.ts b/evals/evals/agent-047-adopt-cache-components/next.config.ts
new file mode 100644
index 000000000000..e4f5738a310b
--- /dev/null
+++ b/evals/evals/agent-047-adopt-cache-components/next.config.ts
@@ -0,0 +1,5 @@
+import type { NextConfig } from 'next'
+
+const nextConfig: NextConfig = {}
+
+export default nextConfig
diff --git a/evals/evals/agent-047-adopt-cache-components/package.json b/evals/evals/agent-047-adopt-cache-components/package.json
new file mode 100644
index 000000000000..7056dd9ed5f9
--- /dev/null
+++ b/evals/evals/agent-047-adopt-cache-components/package.json
@@ -0,0 +1,23 @@
+{
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start"
+ },
+ "dependencies": {
+ "next": "^16",
+ "react": "19.1.0",
+ "react-dom": "19.1.0"
+ },
+ "devDependencies": {
+ "@types/node": "^20",
+ "@types/react": "^19",
+ "@types/react-dom": "^19",
+ "typescript": "^5",
+ "vitest": "^3.1.3",
+ "@vitejs/plugin-react": "^4.4.1",
+ "vite-tsconfig-paths": "^5.1.4"
+ }
+}
diff --git a/evals/evals/agent-047-adopt-cache-components/tsconfig.json b/evals/evals/agent-047-adopt-cache-components/tsconfig.json
new file mode 100644
index 000000000000..cc321ed0658b
--- /dev/null
+++ b/evals/evals/agent-047-adopt-cache-components/tsconfig.json
@@ -0,0 +1,27 @@
+{
+ "compilerOptions": {
+ "target": "ES2017",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "react-jsx",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": ["./*"]
+ }
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+ "exclude": ["node_modules", "EVAL.ts"]
+}
diff --git a/evals/lib/setup.ts b/evals/lib/setup.ts
index 9c3ee9fc7f9c..b2fed8ea214a 100644
--- a/evals/lib/setup.ts
+++ b/evals/lib/setup.ts
@@ -1,6 +1,9 @@
-import { readFileSync } from 'node:fs'
+import { existsSync, readFileSync, readdirSync } from 'node:fs'
+import { join, posix, relative } from 'node:path'
import type { Sandbox } from '@vercel/agent-eval'
+const REPO_ROOT = join(process.cwd(), '..')
+
/**
* Whether the fixture is already a Next.js app.
*
@@ -44,9 +47,10 @@ export async function installNextJs(sandbox: Sandbox): Promise {
'NEXT_EVAL_TARBALL not set. Run evals via `pnpm eval` from the repo root.'
)
}
+ console.log(' Uploading local Next.js tarball...')
await sandbox.writeFiles({
- // @ts-expect-error — upstream types writeFiles as Record
- // but the runtime accepts Buffer. Tarballs are binary; can't send as string.
+ // @ts-expect-error — upstream types only accept strings, but the runtime
+ // accepts Buffer. Tarballs are binary and cannot be sent as strings.
'next.tgz': readFileSync(tarball),
})
const { exitCode, stderr } = await sandbox.runCommand('npm', [
@@ -58,6 +62,7 @@ export async function installNextJs(sandbox: Sandbox): Promise {
`npm install ./next.tgz failed (exit ${exitCode}):\n${stderr}`
)
}
+ console.log(' Installed local Next.js tarball')
}
/**
@@ -87,3 +92,44 @@ Before any Next.js work, find and read the relevant doc in \`node_modules/next/d
'CLAUDE.md': '@AGENTS.md\n',
})
}
+
+/**
+ * Install the current checkout's skill sources before the coding agent starts.
+ *
+ * The docs variant intentionally follows links to the canonical skills. This
+ * helper is for a separate treatment that evaluates unmerged skill changes
+ * without changing the prompt or fixture.
+ */
+export async function installLocalSkills(
+ sandbox: Sandbox,
+ skillNames: string[]
+): Promise {
+ const files: Record = {}
+
+ for (const skillName of skillNames) {
+ const skillDir = join(REPO_ROOT, 'skills', skillName)
+ if (!existsSync(join(skillDir, 'SKILL.md'))) {
+ throw new Error(`Next.js skill not found: ${skillName}`)
+ }
+
+ for (const file of listFiles(skillDir)) {
+ const skillPath = relative(skillDir, file).replaceAll('\\', '/')
+ const content = readFileSync(file, 'utf-8')
+
+ // Claude Code reads .claude/skills. Keep the agent-neutral path in sync
+ // so the same treatment can support additional coding agents later.
+ files[posix.join('.claude', 'skills', skillName, skillPath)] = content
+ files[posix.join('.agents', 'skills', skillName, skillPath)] = content
+ }
+ }
+
+ await sandbox.writeFiles(files)
+ console.log(` Installed local skills: ${skillNames.join(', ')}`)
+}
+
+function listFiles(dir: string): string[] {
+ return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
+ const path = join(dir, entry.name)
+ return entry.isDirectory() ? listFiles(path) : path
+ })
+}
diff --git a/evals/tsconfig.json b/evals/tsconfig.json
index f88c530fa0a0..0ccf880cdbeb 100644
--- a/evals/tsconfig.json
+++ b/evals/tsconfig.json
@@ -5,7 +5,8 @@
"moduleResolution": "NodeNext",
"strict": true,
"skipLibCheck": true,
- "noEmit": true
+ "noEmit": true,
+ "types": ["node"]
},
"include": ["lib", "experiments"]
}
diff --git a/package.json b/package.json
index dedde3d180ec..1124959cdcbd 100644
--- a/package.json
+++ b/package.json
@@ -259,24 +259,24 @@
"pretty-ms": "7.0.0",
"random-seed": "0.3.0",
"react": "19.0.0",
- "react-builtin": "npm:react@19.3.0-canary-29d9d318-20260826",
+ "react-builtin": "npm:react@19.3.0-canary-ff7445e6-20260831",
"react-dom": "19.0.0",
- "react-dom-builtin": "npm:react-dom@19.3.0-canary-29d9d318-20260826",
- "react-dom-experimental-builtin": "npm:react-dom@0.0.0-experimental-29d9d318-20260826",
- "react-experimental-builtin": "npm:react@0.0.0-experimental-29d9d318-20260826",
- "react-is-builtin": "npm:react-is@19.3.0-canary-29d9d318-20260826",
- "react-server-dom-turbopack": "npm:react-server-dom-turbopack@19.3.0-canary-29d9d318-20260826",
- "react-server-dom-turbopack-experimental": "npm:react-server-dom-turbopack@0.0.0-experimental-29d9d318-20260826",
- "react-server-dom-webpack": "npm:react-server-dom-webpack@19.3.0-canary-29d9d318-20260826",
- "react-server-dom-webpack-experimental": "npm:react-server-dom-webpack@0.0.0-experimental-29d9d318-20260826",
+ "react-dom-builtin": "npm:react-dom@19.3.0-canary-ff7445e6-20260831",
+ "react-dom-experimental-builtin": "npm:react-dom@0.0.0-experimental-ff7445e6-20260831",
+ "react-experimental-builtin": "npm:react@0.0.0-experimental-ff7445e6-20260831",
+ "react-is-builtin": "npm:react-is@19.3.0-canary-ff7445e6-20260831",
+ "react-server-dom-turbopack": "npm:react-server-dom-turbopack@19.3.0-canary-ff7445e6-20260831",
+ "react-server-dom-turbopack-experimental": "npm:react-server-dom-turbopack@0.0.0-experimental-ff7445e6-20260831",
+ "react-server-dom-webpack": "npm:react-server-dom-webpack@19.3.0-canary-ff7445e6-20260831",
+ "react-server-dom-webpack-experimental": "npm:react-server-dom-webpack@0.0.0-experimental-ff7445e6-20260831",
"react-ssr-prepass": "1.0.8",
"react-virtualized": "9.22.3",
"request-promise-core": "1.1.2",
"resolve-from": "5.0.0",
"sass": "1.54.0",
"satori": "0.29.0",
- "scheduler-builtin": "npm:scheduler@0.28.0-canary-29d9d318-20260826",
- "scheduler-experimental-builtin": "npm:scheduler@0.0.0-experimental-29d9d318-20260826",
+ "scheduler-builtin": "npm:scheduler@0.28.0-canary-ff7445e6-20260831",
+ "scheduler-experimental-builtin": "npm:scheduler@0.0.0-experimental-ff7445e6-20260831",
"seedrandom": "3.0.5",
"semver": "7.3.7",
"serve-handler": "6.1.6",
@@ -321,10 +321,10 @@
"@types/react-dom": "19.2.4",
"@types/retry": "0.12.0",
"jest-snapshot": "30.0.0-alpha.6",
- "react": "npm:react@19.3.0-canary-29d9d318-20260826",
- "react-dom": "npm:react-dom@19.3.0-canary-29d9d318-20260826",
- "react-is": "npm:react-is@19.3.0-canary-29d9d318-20260826",
- "scheduler": "npm:scheduler@0.28.0-canary-29d9d318-20260826"
+ "react": "npm:react@19.3.0-canary-ff7445e6-20260831",
+ "react-dom": "npm:react-dom@19.3.0-canary-ff7445e6-20260831",
+ "react-is": "npm:react-is@19.3.0-canary-ff7445e6-20260831",
+ "scheduler": "npm:scheduler@0.28.0-canary-ff7445e6-20260831"
},
"packageExtensions": {
"eslint-plugin-react-hooks@0.0.0-experimental-6de32a5a-20250822": {
diff --git a/packages/devlow-bench/README.md b/packages/devlow-bench/README.md
index 672f7fa74c0d..12e4fb64831a 100644
--- a/packages/devlow-bench/README.md
+++ b/packages/devlow-bench/README.md
@@ -10,19 +10,36 @@ npm install devlow-bench
## Usage
-```bash
-Usage: devlow-bench [options]
-## Selecting scenarios
- --scenario=, -s= Only run the scenario with the given name
- --interactive, -i Select scenarios and variants interactively
- --= Filter by any variant property defined in scenarios
-## Output
- --json=, -j= Write the results to the given path as JSON
- --console Print the results to the console
- --datadog[=] Upload the results to Datadog
- (requires DATADOG_API_KEY environment variables)
-## Help
- --help, -h, -? Show this help
+```text
+Usage: devlow-bench [options] [command]
+
+Run developer-workflow benchmarks.
+
+Options:
+ -h, --help display help for command
+
+Commands:
+ run [options] [scenarios...] Run scenario files and report measurements.
+ compare Compare two snapshot CSVs side-by-side.
+ help [command] display help for command
+```
+
+`run` is the default command, so scenario paths can still be passed without
+writing `run` explicitly. Its options include:
+
+```text
+-s, --scenario Only run scenarios whose name matches the filter (repeatable).
+-F, --filter Filter variants by property: key=value (repeatable).
+-i, --interactive Select scenarios and variants interactively.
+--n Run each variant N times.
+--warmup Discard the first N runs before sampling.
+--snapshot Override the snapshot CSV path.
+--compare Print a comparison table after the run.
+--baseline Select a comparison baseline; implies --compare.
+-j, --json Write results as JSON.
+--no-console Suppress console output.
+--datadog [host] Upload results to Datadog.
+--snowflake [batchUri] Upload results to Snowflake.
```
## Scenarios
diff --git a/packages/devlow-bench/package.json b/packages/devlow-bench/package.json
index 44f17f67c562..278791a560a2 100644
--- a/packages/devlow-bench/package.json
+++ b/packages/devlow-bench/package.json
@@ -44,6 +44,7 @@
},
"dependencies": {
"@datadog/datadog-api-client": "^1.13.0",
+ "commander": "^14.0.3",
"inquirer": "^9.2.7",
"jstat": "^1.9.6",
"minimist": "^1.2.8",
diff --git a/packages/devlow-bench/src/cli.ts b/packages/devlow-bench/src/cli.ts
index 6298b98fa4a8..1e851d566bda 100644
--- a/packages/devlow-bench/src/cli.ts
+++ b/packages/devlow-bench/src/cli.ts
@@ -1,51 +1,105 @@
-import minimist from 'minimist'
-import { setCurrentScenarios } from './describe.js'
+import { Command } from 'commander'
import { join } from 'path'
+import { pathToFileURL } from 'url'
+import { groupRows, printComparison } from './compare.js'
+import { setCurrentScenarios } from './describe.js'
import { type Scenario, type ScenarioVariant, runScenarios } from './index.js'
import compose from './interfaces/compose.js'
-import { groupRows, printComparison } from './compare.js'
import { readSnapshot, resolveCompareTarget } from './snapshot.js'
-import { pathToFileURL } from 'url'
-const SUBCOMMANDS = new Set(['run', 'compare'])
+interface RunOptions {
+ scenario?: string[]
+ filter?: string[]
+ interactive?: boolean
+ n?: number
+ warmup?: number
+ // Path override for the always-on snapshot CSV.
+ // undefined means the default path of .devlow-bench/snapshots/.csv
+ snapshot?: string
+ compare?: boolean
+ baseline?: string
+ json?: string
+ console?: boolean
+ datadog?: string | boolean
+ snowflake?: string | boolean
+}
;(async () => {
- // Subcommand dispatch. `devlow-bench run [opts] scenario.mjs` and
- // `devlow-bench compare ` are the explicit forms. A bare
- // `devlow-bench