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> for NapiWrittenEndpoint { match written_endpoint { Some(EndpointOutputPaths::NodeJs { server_entry_path, + server_hmr_entry_paths, server_paths, client_paths, }) => Self { r#type: "nodejs".to_string(), entry_path: Some(server_entry_path.into_owned()), + server_hmr_entry_paths: server_hmr_entry_paths + .into_iter() + .map(String::from) + .collect(), client_paths: client_paths.into_iter().map(From::from).collect(), server_paths: server_paths.into_iter().map(From::from).collect(), ..Default::default() diff --git a/crates/next-napi-bindings/src/next_api/project.rs b/crates/next-napi-bindings/src/next_api/project.rs index a7888dbae2f9..e77462d9ecd3 100644 --- a/crates/next-napi-bindings/src/next_api/project.rs +++ b/crates/next-napi-bindings/src/next_api/project.rs @@ -19,6 +19,9 @@ use napi::{ }; use napi_derive::napi; use next_api::{ + aggregate_hmr::{ + ServerHmrChunkListVersion, ServerHmrChunkLists, ServerHmrUpdate, compute_server_hmr_update, + }, entrypoints::Entrypoints, next_server_nft::next_server_nft_assets, operation::{ @@ -1854,124 +1857,147 @@ async fn hmr_update_with_issues_operation( .cell()) } -/// Aggregate counterpart to [`project_hmr_update_operation`]. +#[turbo_tasks::value(serialization = "skip")] +struct ServerHmrSnapshotWithEffects { + chunk_lists: ReadRef, + version: ReadRef, + issues: Arc>>, + effects: Arc, +} + +#[turbo_tasks::value(serialization = "skip")] +struct ServerHmrSnapshot { + chunk_lists: ReadRef, + version: ReadRef, +} + #[turbo_tasks::function(operation, root)] -fn project_server_hmr_update_operation( +async fn project_server_hmr_snapshot_operation( project: ResolvedVc, - state: ResolvedVc, -) -> Vc { - project.server_hmr_update(*state) + entry_paths: Vec, +) -> Result> { + let chunk_lists = project.server_hmr_chunks_for_entries(entry_paths).await?; + let version = ServerHmrChunkListVersion::from_chunk_lists(chunk_lists.as_slice()) + .await? + .cell() + .await?; + Ok(ServerHmrSnapshot { + chunk_lists, + version, + } + .cell()) } -/// Aggregate counterpart to [`hmr_update_with_issues_operation`]. -#[tracing::instrument(level = "info", name = "server hmr subscription", skip_all)] +/// Snapshot only; diffing here would keep old baselines active. +#[tracing::instrument(level = "info", name = "server hmr snapshot", skip_all)] #[turbo_tasks::function(operation, root)] -async fn server_hmr_update_with_issues_operation( +async fn server_hmr_snapshot_with_effects_operation( project: ResolvedVc, - state: ResolvedVc, -) -> Result> { - tracing::info!("server hmr subscription"); - let update_op = project_server_hmr_update_operation(project, state); - // See `hmr_update_with_issues_operation`: the JS consumer relies on this - // read *throwing* on build-graph failures; don't swallow errors. - let update = update_op + entry_paths: Vec, +) -> Result> { + tracing::info!("server hmr snapshot"); + let snapshot_op = project_server_hmr_snapshot_operation(project, entry_paths); + // Build-graph failures must reach the JS recovery path. + let snapshot = snapshot_op .read_strongly_consistent() .final_read_hint() .await?; let filter = project.issue_filter().await?; - let issues = get_issues(update_op, &filter).await?; - let effects = Arc::new(take_effects(update_op).await?); - Ok(HmrUpdateWithIssues { - update, + let issues = get_issues(snapshot_op, &filter).await?; + let effects = Arc::new(take_effects(snapshot_op).await?); + Ok(ServerHmrSnapshotWithEffects { + chunk_lists: snapshot.chunk_lists.clone(), + version: snapshot.version.clone(), issues, effects, } .cell()) } -#[tracing::instrument( - level = "info", - name = "get server HMR events", - skip(env, project, func) -)] -#[napi(ts_return_type = "{ __napiType: \"RootTask\" }")] -pub fn project_server_hmr_events( - env: Env, +pub struct ServerHmrVersion(ReadRef); + +#[napi(object, object_from_js = false)] +pub struct NapiServerHmrUpdate { + #[napi(ts_type = "\"none\" | \"partial\" | \"restart\"")] + pub kind: String, + /// `unknown` forces the TypeScript boundary to narrow the payload. + #[napi(ts_type = "unknown")] + pub instruction: Option, + pub version: Option>, +} + +impl NapiServerHmrUpdate { + /// Flattens the union for napi; `swc/types.ts` restores it. + fn new(update: &ServerHmrUpdate) -> Result { + let (kind, to, instruction) = match update { + ServerHmrUpdate::NoRuntimeUpdate { to } => ("none", to.as_ref(), None), + ServerHmrUpdate::FullReevaluation { to } => ("restart", Some(to), None), + ServerHmrUpdate::Partial { to, instruction } => { + ("partial", Some(to), Some(instruction)) + } + }; + + Ok(Self { + kind: kind.into(), + instruction: instruction.map(serde_json::to_value).transpose()?, + version: to.map(|to| External::new(ServerHmrVersion(to.clone()))), + }) + } +} + +#[tracing::instrument(level = "info", name = "get server HMR update", skip_all)] +#[napi] +pub async fn project_get_server_hmr_update( #[napi(ts_arg_type = "{ __napiType: \"Project\" }")] project: &External, - #[napi(ts_arg_type = "(err: Error, value: TurbopackResult) => void")] - func: FunctionRef>, ()>, -) -> napi::Result> { + from: Option<&External>, + entry_paths: Vec, +) -> napi::Result> { let container = project.container; - // Sentinel resource id for the aggregated stream (no real chunk path). - let identifier_path: RcStr = rcstr!("__next_all_hmr__"); - subscribe( - project.turbopack_ctx.clone(), - &env, - &func, - move || async move { + let turbo_tasks = project.turbopack_ctx.turbo_tasks(); + let from = from.map(|from| from.0.clone()); + + let (project, read) = turbo_tasks + .run(async move { // HACK(bgw): Remove this unmark call unmark_top_level_task_may_leak_eventually_consistent_state(); - let project = container.project().to_resolved().await?; - let state = project.server_hmr_version_state().to_resolved().await?; - - let update_op = server_hmr_update_with_issues_operation(project, state); - // HACK(bgw): Remove this mark call mark_top_level_task(); - + let snapshot_op = server_hmr_snapshot_with_effects_operation(project, entry_paths); let read = - read_strongly_consistent_and_apply_effects(update_op, |v| &v.effects).await?; - - // HACK(bgw): Remove this unmark call - unmark_top_level_task_may_leak_eventually_consistent_state(); - - let HmrUpdateWithIssues { update, issues, .. } = &*read; - match &**update { - Update::Missing | Update::None => {} - Update::Total(TotalUpdate { to }) => { - state.set(to.clone()).await?; - } - Update::Partial(PartialUpdate { to, .. }) => { - state.set(to.clone()).await?; - } - } - Ok((Some(update.clone()), issues.clone())) - }, - move |ctx| { - let (update, issues) = ctx.value; - - let napi_issues = issues - .iter() - .map(|issue| NapiIssue::from(&**issue)) - .collect(); - let update_issues = issues - .iter() - .map(|issue| Issue::from(&**issue)) - .collect::>(); + read_strongly_consistent_and_apply_effects(snapshot_op, |v| &v.effects).await?; + Ok((project, read)) + }) + .await + .map_err(|e| napi::Error::from_reason(PrettyPrintError(&e.into()).to_string()))?; - let identifier = ResourceIdentifier { - path: identifier_path.clone(), - headers: None, - }; - let update = match update.as_deref() { - None | Some(Update::Missing) | Some(Update::Total(_)) => { - ClientUpdateInstruction::restart(&identifier, &update_issues) - } - Some(Update::Partial(update)) => ClientUpdateInstruction::partial( - &identifier, - &update.instruction, - &update_issues, - ), - Some(Update::None) => ClientUpdateInstruction::issues(&identifier, &update_issues), - }; + // Diffing must not remain active after the pull completes. + let (update, issues) = turbo_tasks + .run(async move { + // The snapshot's chunk-list `Vc`s are only valid while the project is held. + let _project_keep_alive = project; + let ServerHmrSnapshotWithEffects { + chunk_lists, + version, + issues, + .. + } = &*read; + let update = + compute_server_hmr_update(chunk_lists.as_slice(), from.as_deref(), version.clone()) + .await?; + Ok::<_, anyhow::Error>((update, issues.clone())) + }) + .await + .map_err(|e| napi::Error::from_reason(PrettyPrintError(&e.into()).to_string()))?; - Ok(TurbopackResult { - result: ctx.env.to_js_value(&update)?, - issues: napi_issues, - }) - }, - ) + Ok(TurbopackResult { + result: NapiServerHmrUpdate::new(&update) + .map_err(|error| napi::Error::from_reason(PrettyPrintError(&error).to_string()))?, + issues: issues + .iter() + .map(|issue| NapiIssue::from(&**issue)) + .collect(), + }) } #[tracing::instrument(level = "info", name = "get client HMR events", skip(env, project, func), fields(chunk_name = %chunk_name))] diff --git a/docs/01-app/01-getting-started/01-installation.mdx b/docs/01-app/01-getting-started/01-installation.mdx index e078332b06fe..4cfa318dbb4b 100644 --- a/docs/01-app/01-getting-started/01-installation.mdx +++ b/docs/01-app/01-getting-started/01-installation.mdx @@ -86,7 +86,7 @@ On installation, you'll see the following prompts: ```txt filename="Terminal" What is your project named? my-app Would you like to use the recommended Next.js defaults? - Yes, use recommended defaults - TypeScript, ESLint, Tailwind CSS, App Router, AGENTS.md + Yes, use recommended defaults - TypeScript, ESLint, No React Compiler, Tailwind CSS, No src/ directory, App Router, No Cache Components, AGENTS.md No, reuse previous settings No, customize settings - Choose your own preferences ``` @@ -100,6 +100,7 @@ Would you like to use React Compiler? No / Yes Would you like to use Tailwind CSS? No / Yes Would you like your code inside a `src/` directory? No / Yes Would you like to use App Router? (recommended) No / Yes +Would you like to use Cache Components? No / Yes Would you like to customize the import alias (`@/*` by default)? No / Yes What import alias would you like configured? @/* Would you like to include AGENTS.md to guide coding agents to write up-to-date Next.js code? No / Yes diff --git a/docs/01-app/03-api-reference/06-cli/create-next-app.mdx b/docs/01-app/03-api-reference/06-cli/create-next-app.mdx index 6bb3f01d1ede..116c69442d30 100644 --- a/docs/01-app/03-api-reference/06-cli/create-next-app.mdx +++ b/docs/01-app/03-api-reference/06-cli/create-next-app.mdx @@ -38,6 +38,7 @@ The following options are available: | `--js` or `--javascript` | Initialize as a JavaScript project | | `--tailwind` | Initialize with Tailwind CSS config (default) | | `--react-compiler` | Initialize with React Compiler enabled | +| `--cache-components` | Initialize with Cache Components enabled | | `--eslint` | Initialize with ESLint config | | `--biome` | Initialize with Biome config | | `--no-linter` | Skip linter configuration | @@ -87,7 +88,7 @@ On installation, you'll see the following prompts: ```txt filename="Terminal" What is your project named? my-app Would you like to use the recommended Next.js defaults? - Yes, use recommended defaults - TypeScript, ESLint, Tailwind CSS, App Router, AGENTS.md + Yes, use recommended defaults - TypeScript, ESLint, No React Compiler, Tailwind CSS, No src/ directory, App Router, No Cache Components, AGENTS.md No, reuse previous settings No, customize settings - Choose your own preferences ``` @@ -101,6 +102,7 @@ Would you like to use React Compiler? No / Yes Would you like to use Tailwind CSS? No / Yes Would you like your code inside a `src/` directory? No / Yes Would you like to use App Router? (recommended) No / Yes +Would you like to use Cache Components? No / Yes Would you like to customize the import alias (`@/*` by default)? No / Yes What import alias would you like configured? @/* Would you like to include AGENTS.md to guide coding agents to write up-to-date Next.js code? No / Yes diff --git a/evals/README.md b/evals/README.md index dbbb4057650a..46d1a3a7a9c2 100644 --- a/evals/README.md +++ b/evals/README.md @@ -8,7 +8,7 @@ The point: find places where agents get Next.js wrong because their training dat The runner is [`@vercel/agent-eval`](https://github.com/vercel-labs/agent-eval). It spins up a sandbox (Vercel or local Docker), copies the fixture in, runs the coding agent against `PROMPT.md`, then executes `EVAL.ts` as a vitest file against whatever the agent wrote. The `PROMPT.md` / `EVAL.ts` / fixture-dir convention you'll see below is that package's convention — see its README for the full spec. -`run-evals.js` is a thin wrapper around it: pack the local `next` build into a tarball, generate two experiment configs (`baseline` and `agents-md`) that differ only in whether they drop an `AGENTS.md` pointing at the bundled docs, then invoke `agent-eval run-all`. Everything from "spawn sandbox" onward is `@vercel/agent-eval`'s job. +`run-evals.js` is a thin wrapper around it: pack the local `next` build into a tarball, generate the configured experiments, then invoke `agent-eval`. The two default experiments (`baseline` and `agents-md`) differ only in whether they drop an `AGENTS.md` pointing at the bundled docs. Everything from "spawn sandbox" onward is `@vercel/agent-eval`'s job. ## One-time setup @@ -58,7 +58,7 @@ test('exports instant', () => { pnpm eval agent-042-your-thing ``` -This runs two variants in parallel and prints pass/fail for each: +This runs the two default variants in parallel and prints pass/fail for each: ``` ✗ baseline/agent-042-your-thing (81s) @@ -67,6 +67,21 @@ This runs two variants in parallel and prints pass/fail for each: `agents-md` drops an AGENTS.md into the sandbox telling the agent to check `node_modules/next/dist/docs/` first. `baseline` doesn't. That's the whole difference — same prompt, same model, one extra file. If `agents-md` passes and `baseline` doesn't, the bundled docs are doing their job. +### Evaluating a local skill + +Docs can link to a canonical skill, but an unmerged skill revision isn't part of the `next` package tarball. To compare the current checkout's skill with the baseline and bundled-docs variants, add the fixture to `evals/eval.config.json`: + +```json filename="evals/eval.config.json" +{ + "agent-046-adopt-partial-prefetching": { + "skills": ["next-partial-prefetching-adoption"], + "timeout": 1800 + } +} +``` + +The runner then adds a third `skills` variant for that fixture. It installs the listed directories from the local `skills/` folder before the coding agent starts, while keeping the prompt, app, and assertions identical. It does not also inject the `agents-md` instruction: the skill treatment measures whether the skill itself leads the agent to the canonical bundled guide. The optional timeout lets end-to-end workflows run longer than the 12-minute default. Fixtures without an entry continue to run only `baseline` and `agents-md`. + A run takes ~2–5 min. To validate a fixture without executing: ```bash @@ -99,6 +114,7 @@ Full transcripts land in `evals/results////run-1/`. Gr ``` evals/ +├── eval.config.json # optional skill and timeout settings by fixture ├── evals/agent-*/ # fixtures ├── lib/setup.ts # uploads tarball, writes AGENTS.md (shared by all evals) ├── experiments/ # generated per-run, gitignored diff --git a/evals/eval.config.json b/evals/eval.config.json new file mode 100644 index 000000000000..45b3b89547d7 --- /dev/null +++ b/evals/eval.config.json @@ -0,0 +1,10 @@ +{ + "agent-046-adopt-partial-prefetching": { + "skills": ["next-partial-prefetching-adoption"], + "timeout": 1800 + }, + "agent-047-adopt-cache-components": { + "skills": ["next-cache-components-adoption"], + "timeout": 1800 + } +} diff --git a/evals/evals/agent-046-adopt-partial-prefetching/EVAL.ts b/evals/evals/agent-046-adopt-partial-prefetching/EVAL.ts new file mode 100644 index 000000000000..715796a12312 --- /dev/null +++ b/evals/evals/agent-046-adopt-partial-prefetching/EVAL.ts @@ -0,0 +1,74 @@ +/** + * Adopt Partial Prefetching + * + * Verifies the full adoption workflow rather than isolated API recall. The + * agent must audit effective legacy `prefetch={true}` links, capture their + * selected prefetched UI with `instant()`, migrate the destination, and enable + * the global flag without broadening the preservation suite to automatic or + * disabled links. + */ + +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 Partial Prefetching globally', () => { + const config = readFileSync(join(process.cwd(), 'next.config.ts'), 'utf-8') + + expect(config).toMatch(/cacheComponents\s*:\s*true/) + expect(config).toMatch(/partialPrefetching\s*:\s*true/) +}) + +test('removes temporary route adoption exports', () => { + expect(source).not.toMatch( + /export\s+(?:const|var|let)\s+prefetch\s*=\s*['"]partial['"]/ + ) +}) + +test('retains an instant client-navigation regression test', () => { + expect(source).toMatch(/from\s+['"]@next\/playwright['"]/) + expect(source).toMatch(/\binstant\s*\(/) + expect(source).toMatch(/\.click\s*\(/) +}) + +test('preserves the selected eager-link contract without broadening it', async () => { + await expect(environment).toSatisfyCriterion( + `The final project has production-mode @next/playwright instant() regression coverage for client navigation from the home page to /tracks/aurora. The tests prove that the Aurora title and Echo North artist are ready inside instant(). They cover all three equivalent eager navigations: the explicit prefetch={true} Link, the bare prefetch Link, and CatalogLink with eager enabled. The default, prefetch="auto", and prefetch={false} Nebula links are not treated as legacy full-prefetch preservation targets. Recommendations are allowed to stream after navigation.` + ) +}) + +test('uses a targeted cache boundary without leaking session data', async () => { + await expect(environment).toSatisfyCriterion( + `The Aurora title and artist are available to Partial Prefetching through a small use-cache data function rather than by caching the route page component. The cached function does not call cookies() or headers(). Either of these implementations must pass: (1) remove the unused listener variation and cache by slug, or (2) read the listener cookie outside the cached function and pass listener as an explicit argument, which safely keys the cache by both slug and listener. In particular, queryTrack(slug, listener) with 'use cache' is correct when getTrack reads cookies() and passes listener into it; that does not leak data between listeners. Recommendations remain outside this cache and may stream.` + ) +}) + +test('captured the passing legacy baseline before adoption', async () => { + await expect(transcript).toSatisfyCriterion( + `The agent followed a preservation loop: while partialPrefetching was still disabled, it wrote and successfully ran the instant() client-navigation assertions for the selected Aurora title and artist against the legacy eager prefetch. It then adopted the destination and enabled Partial Prefetching while keeping those positive assertions unchanged, and reran them successfully under the final global configuration. Merely writing tests after enabling the flag does not satisfy this criterion.` + ) +}) diff --git a/evals/evals/agent-046-adopt-partial-prefetching/PROMPT.md b/evals/evals/agent-046-adopt-partial-prefetching/PROMPT.md new file mode 100644 index 000000000000..7c5758be7744 --- /dev/null +++ b/evals/evals/agent-046-adopt-partial-prefetching/PROMPT.md @@ -0,0 +1 @@ +Enable Partial Prefetching across this music catalog. Navigating from the home page through any of the three eager Aurora links must still have the track title and artist ready immediately. Recommendations can stream after navigation. Preserve this behavior with regression coverage. diff --git a/evals/evals/agent-046-adopt-partial-prefetching/app/layout.tsx b/evals/evals/agent-046-adopt-partial-prefetching/app/layout.tsx new file mode 100644 index 000000000000..ca97a1147ab4 --- /dev/null +++ b/evals/evals/agent-046-adopt-partial-prefetching/app/layout.tsx @@ -0,0 +1,14 @@ +import type { Metadata } from 'next' +import type { ReactNode } from 'react' + +export const metadata: Metadata = { + title: 'Signal Records', +} + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} diff --git a/evals/evals/agent-046-adopt-partial-prefetching/app/page.tsx b/evals/evals/agent-046-adopt-partial-prefetching/app/page.tsx new file mode 100644 index 000000000000..c749614cdf63 --- /dev/null +++ b/evals/evals/agent-046-adopt-partial-prefetching/app/page.tsx @@ -0,0 +1,40 @@ +import Link from 'next/link' +import { CatalogLink } from '@/components/catalog-link' + +export default function HomePage() { + return ( +
+

Signal Records

+
    +
  • + + Aurora — explicit eager link + +
  • +
  • + + Aurora — bare eager link + +
  • +
  • + + Aurora — eager catalog wrapper + +
  • +
  • + Nebula — default link +
  • +
  • + + Nebula — automatic link + +
  • +
  • + + Nebula — prefetch disabled + +
  • +
+
+ ) +} diff --git a/evals/evals/agent-046-adopt-partial-prefetching/app/tracks/[slug]/page.tsx b/evals/evals/agent-046-adopt-partial-prefetching/app/tracks/[slug]/page.tsx new file mode 100644 index 000000000000..dc8f6bfcbf5d --- /dev/null +++ b/evals/evals/agent-046-adopt-partial-prefetching/app/tracks/[slug]/page.tsx @@ -0,0 +1,51 @@ +import { notFound } from 'next/navigation' +import { Suspense } from 'react' +import { getRecommendations, getTrack } from '@/lib/tracks' + +export default function TrackPage({ + params, +}: { + params: Promise<{ slug: string }> +}) { + return ( +
+ Loading track…

}> + +
+ 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