From b8c2854dafaeeccaa2d179740101eaf80c866983 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Tue, 19 May 2026 22:08:50 +0200 Subject: [PATCH 1/7] Allow inspecting prerender worker again (#93947) --- packages/next/src/build/index.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/next/src/build/index.ts b/packages/next/src/build/index.ts index ed0149b61654..e67068ffc7cf 100644 --- a/packages/next/src/build/index.ts +++ b/packages/next/src/build/index.ts @@ -16,7 +16,7 @@ import { bold, yellow } from '../lib/picocolors' import { makeRe } from 'next/dist/compiled/picomatch' import { existsSync, promises as fs } from 'fs' import os from 'os' -import { Worker } from '../lib/worker' +import { getNextBuildDebuggerPortOffset, Worker } from '../lib/worker' import { defaultConfig, getNextConfigRuntime } from '../server/config-shared' import devalue from 'next/dist/compiled/devalue' import findUp from 'next/dist/compiled/find-up' @@ -2047,7 +2047,9 @@ export default async function build( staticWorker = createStaticWorker(config, { numberOfWorkers, - debuggerPortOffset: -1, + debuggerPortOffset: getNextBuildDebuggerPortOffset({ + kind: 'export-page', + }), }) const analysisBegin = process.hrtime() From 2af85b58a451cac8727567df49700c98ae9488be Mon Sep 17 00:00:00 2001 From: Luke Sandberg Date: Tue, 19 May 2026 14:11:15 -0700 Subject: [PATCH 2/7] make rcstrs on the heap/static slightly smaller (#93805) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### What? Splits the previously unified `PrehashedString` (which held a `Payload` enum of `String | &'static str`) into two separate types: `StaticPrehashedString { value: &'static str, hash: u64 }` for atoms produced by `rcstr!` / `make_const_prehashed_string`, and `DynamicPrehashedString { value: Box, hash: u64 }` for atoms held in an `Arc`. The static and dynamic paths were already distinguished by the `STATIC_TAG` / `DYNAMIC_TAG` bits in `RcStr`, so the runtime branch on enum discriminant was redundant. ### Why? - **16 bytes saved per heap-allocated `RcStr` value .** Dynamic atoms drop the `String::capacity` field (which was always equal to `len` since the contents are immutable) and because they are stored in a `triomphe::Arc` this drops the Arc payload to 32 bytes instead of 40 which matches a mimalloc bucket (previously we were rounded up to a 48 byte bucket) so we save 16 bytes. - **8 bytes saved per static `RcStr` value.** The linker will optimally align our 24 byte struct. - Removes a layer of dispatch on the hot path (`as_str`, `==`, `Hash`) — typed deref to the correct variant instead of matching on `Payload`. (i.o.w. one 'descreminent' traversal instead of 2) ### How? - `dynamic.rs`: `Payload` enum removed; two structs replace `PrehashedString`. `deref_from` split into `deref_static` and `deref_dynamic`. `restore_arc` returns `Arc`. - `lib.rs`: `as_str` and `into_owned` dispatch on `tag()` (STATIC vs DYNAMIC vs INLINE) rather than `location()`. New `heap_hash_and_str` helper for `PartialEq` and `Hash` to share the static/dynamic branch. `into_owned`'s `try_unwrap` arm uses `String::from(Box)` which reuses the box allocation (still O(1)). - `turbo-rcstr-macros`: emit `::turbo_rcstr::StaticPrehashedString` instead of `::turbo_rcstr::PrehashedString`. - Added a comment on `DynamicPrehashedString` noting the future move to `triomphe::ThinArc` to fold the two heap allocations (Arc header + boxed bytes) into one. Deferred because that change would make `RcStr::from(String)` copy the bytes, invalidating the documented cheap `String -> RcStr -> String` round-trip — wants a separate evaluation. --- .../crates/turbo-rcstr-macros/src/lib.rs | 4 +- turbopack/crates/turbo-rcstr/src/dynamic.rs | 82 +++++------ turbopack/crates/turbo-rcstr/src/lib.rs | 134 +++++++++--------- 3 files changed, 110 insertions(+), 110 deletions(-) diff --git a/turbopack/crates/turbo-rcstr-macros/src/lib.rs b/turbopack/crates/turbo-rcstr-macros/src/lib.rs index d63b666f5d97..37a6915dd896 100644 --- a/turbopack/crates/turbo-rcstr-macros/src/lib.rs +++ b/turbopack/crates/turbo-rcstr-macros/src/lib.rs @@ -40,7 +40,7 @@ pub fn rcstr(input: TokenStream) -> TokenStream { format!("::turbo_rcstr::inline_atom({lit}).unwrap()") } else { format!( - "{{ static RCSTR_STORAGE: ::turbo_rcstr::PrehashedString = \ + "{{ static RCSTR_STORAGE: ::turbo_rcstr::StaticPrehashedString = \ ::turbo_rcstr::make_const_prehashed_string({lit}); const RCSTR: \ ::turbo_rcstr::RcStr = ::turbo_rcstr::from_static(&RCSTR_STORAGE); \ ::turbo_rcstr::__rcstr_inventory_submit!( \ @@ -51,7 +51,7 @@ pub fn rcstr(input: TokenStream) -> TokenStream { format!( "{{ const TEXT: &str = {input}; if ::turbo_rcstr::is_atom_inlineable(TEXT) {{ \ ::turbo_rcstr::inline_atom(TEXT).unwrap() }} else {{ static RCSTR_STORAGE: \ - ::turbo_rcstr::PrehashedString = \ + ::turbo_rcstr::StaticPrehashedString = \ ::turbo_rcstr::make_const_prehashed_string(TEXT); const RCSTR: \ ::turbo_rcstr::RcStr = ::turbo_rcstr::from_static(&RCSTR_STORAGE); \ ::turbo_rcstr::__rcstr_inventory_submit!( \ diff --git a/turbopack/crates/turbo-rcstr/src/dynamic.rs b/turbopack/crates/turbo-rcstr/src/dynamic.rs index 912f2d7e7d37..93bc7a7a07bc 100644 --- a/turbopack/crates/turbo-rcstr/src/dynamic.rs +++ b/turbopack/crates/turbo-rcstr/src/dynamic.rs @@ -7,49 +7,41 @@ use crate::{ is_atom_inlineable, tagged_value::TaggedValue, }; -pub enum Payload { - String(String), - Ref(&'static str), -} - -impl Payload { - pub(crate) fn as_str(&self) -> &str { - match self { - Payload::String(s) => s, - Payload::Ref(s) => s, - } - } - pub(crate) fn into_string(self) -> String { - match self { - Payload::String(s) => s, - Payload::Ref(r) => r.to_string(), - } - } -} -impl PartialEq for Payload { - fn eq(&self, other: &Self) -> bool { - self.as_str() == other.as_str() - } -} - -pub struct PrehashedString { - pub value: Payload, +/// Read-only header for atoms allocated in static storage by the `rcstr!` +/// macro. The value lives for `'static`, so we store a `&'static str` rather +/// than owning the bytes. +pub struct StaticPrehashedString { + pub value: &'static str, /// This is not the actual `fxhash`, but rather it's a value that passed to /// `write_u64` of [rustc_hash::FxHasher]. pub hash: u64, } -pub unsafe fn cast(ptr: TaggedValue) -> *const PrehashedString { - ptr.get_ptr().cast() +/// Heap-owned header for atoms held in an [`Arc`]. `Box` instead of +/// `String` because the contents are immutable — we save the `capacity` field +/// (and a tag/padding byte vs the old `Payload` enum) per atom. +/// +/// TODO: collapse the two allocations (this header + the boxed bytes) into a +/// single [`triomphe::ThinArc`] so the hash and bytes share one allocation and +/// the inline pointer in [`crate::RcStr`] is one word. That change would make +/// `RcStr::from(String)` copy the bytes, which would invalidate the current +/// "cheap `String -> RcStr -> String`" property — worth a separate evaluation. +pub struct DynamicPrehashedString { + pub value: Box, + pub hash: u64, +} + +pub(crate) unsafe fn deref_static<'i>(ptr: TaggedValue) -> &'i StaticPrehashedString { + unsafe { &*(ptr.get_ptr() as *const StaticPrehashedString) } } -pub(crate) unsafe fn deref_from<'i>(ptr: TaggedValue) -> &'i PrehashedString { - unsafe { &*cast(ptr) } +pub(crate) unsafe fn deref_dynamic<'i>(ptr: TaggedValue) -> &'i DynamicPrehashedString { + unsafe { &*(ptr.get_ptr() as *const DynamicPrehashedString) } } /// Caller should call `forget` (or `clone`) on the returned `Arc` -pub unsafe fn restore_arc(v: TaggedValue) -> Arc { - let ptr = v.get_ptr() as *const PrehashedString; +pub unsafe fn restore_arc(v: TaggedValue) -> Arc { + let ptr = v.get_ptr() as *const DynamicPrehashedString; unsafe { Arc::from_raw(ptr) } } @@ -70,20 +62,22 @@ pub(crate) fn new_atom + Into>(text: T) -> RcStr { let hash = hash_bytes(text.as_bytes()); - let prehashed = PrehashedString { - value: Payload::String(text.into()), + let prehashed = DynamicPrehashedString { + // NOTE: This will capture as a Box which will essentially + // `shrink_to_fit` the bytes. + value: text.into(), hash, }; new_atom_from_prehashed(prehashed) } -/// Construct a new dynamic RcStr from a PrehashedString -pub(crate) fn new_atom_from_prehashed(prehashed: PrehashedString) -> RcStr { - let entry: Arc = Arc::new(prehashed); +/// Construct a new dynamic RcStr from a DynamicPrehashedString +pub(crate) fn new_atom_from_prehashed(prehashed: DynamicPrehashedString) -> RcStr { + let entry: Arc = Arc::new(prehashed); let mut entry = Arc::into_raw(entry); debug_assert!(0 == entry as u8 & TAG_MASK); - entry = ((entry as usize) | DYNAMIC_TAG as usize) as *mut PrehashedString; - let ptr: NonNull = unsafe { + entry = ((entry as usize) | DYNAMIC_TAG as usize) as *mut DynamicPrehashedString; + let ptr: NonNull = unsafe { // Safety: Arc::into_raw returns a non-null pointer NonNull::new_unchecked(entry as *mut _) }; @@ -94,15 +88,15 @@ pub(crate) fn new_atom_from_prehashed(prehashed: PrehashedString) -> RcStr { } #[inline(always)] -pub(crate) const fn new_static_atom(string: &'static PrehashedString) -> RcStr { - let entry = string as *const PrehashedString; +pub(crate) const fn new_static_atom(string: &'static StaticPrehashedString) -> RcStr { + let entry = string as *const StaticPrehashedString; const { - debug_assert!(align_of::() >= 4); + debug_assert!(align_of::() >= 4); // This must be 00, so that we don't have to remove the tag, which we can't since it would // require pointer-integer casts in a const context. debug_assert!(STATIC_TAG == 0b_00); } - let ptr: NonNull = unsafe { + let ptr: NonNull = unsafe { // Safety: references always return a non-null pointers NonNull::new_unchecked(entry as *mut _) }; diff --git a/turbopack/crates/turbo-rcstr/src/lib.rs b/turbopack/crates/turbo-rcstr/src/lib.rs index 39aeb18ccaad..8b044d87107f 100644 --- a/turbopack/crates/turbo-rcstr/src/lib.rs +++ b/turbopack/crates/turbo-rcstr/src/lib.rs @@ -32,7 +32,10 @@ use triomphe::Arc; use turbo_tasks_hash::{DeterministicHash, DeterministicHasher}; use crate::{ - dynamic::{deref_from, hash_bytes, new_atom, new_atom_from_prehashed, new_static_atom}, + dynamic::{ + DynamicPrehashedString, deref_dynamic, deref_static, hash_bytes, new_atom, + new_atom_from_prehashed, new_static_atom, + }, tagged_value::{MAX_INLINE_LEN, TaggedValue}, }; @@ -96,15 +99,12 @@ unsafe impl Sync for RcStr {} // Marks a payload that is stored in an Arc const DYNAMIC_TAG: u8 = 0b_10; -const PREHASHED_STRING_LOCATION: u8 = 0b_0; // Marks a payload that has been leaked since it has a static lifetime const STATIC_TAG: u8 = 0b_00; // The payload is stored inline const INLINE_TAG: u8 = 0b_01; // len in upper nybble -const INLINE_LOCATION: u8 = 0b_1; const INLINE_TAG_INIT: NonZeroU8 = NonZeroU8::new(INLINE_TAG).unwrap(); const TAG_MASK: u8 = 0b_11; -const LOCATION_MASK: u8 = 0b_1; // For inline tags the length is stored in the upper 4 bits of the tag byte const LEN_OFFSET: usize = 4; const LEN_MASK: u8 = 0xf0; @@ -114,33 +114,24 @@ impl RcStr { fn tag(&self) -> u8 { self.unsafe_data.tag_byte() & TAG_MASK } - #[inline(always)] - fn location(&self) -> u8 { - self.unsafe_data.tag_byte() & LOCATION_MASK - } #[inline(never)] pub fn as_str(&self) -> &str { - match self.location() { - PREHASHED_STRING_LOCATION => self.prehashed_string_as_str(), - INLINE_LOCATION => self.inline_as_str(), + match self.tag() { + STATIC_TAG => unsafe { deref_static(self.unsafe_data).value }, + DYNAMIC_TAG => unsafe { &deref_dynamic(self.unsafe_data).value }, + INLINE_TAG => self.inline_as_str(), _ => unsafe { debug_unreachable!() }, } } fn inline_as_str(&self) -> &str { - debug_assert!(self.location() == INLINE_LOCATION); + debug_assert!(self.tag() == INLINE_TAG); let len = (self.unsafe_data.tag_byte() & LEN_MASK) >> LEN_OFFSET; let src = self.unsafe_data.data(); unsafe { std::str::from_utf8_unchecked(&src[..(len as usize)]) } } - // Extract the str reference from a string stored in a PrehashedString - fn prehashed_string_as_str(&self) -> &str { - debug_assert!(self.location() == PREHASHED_STRING_LOCATION); - unsafe { dynamic::deref_from(self.unsafe_data).value.as_str() } - } - /// Returns an owned mutable [`String`]. /// /// This implementation is more efficient than [`ToString::to_string`]: @@ -154,12 +145,13 @@ impl RcStr { // convert `self` into `arc` let arc = unsafe { dynamic::restore_arc(ManuallyDrop::new(self).unsafe_data) }; match Arc::try_unwrap(arc) { - Ok(v) => v.value.into_string(), - Err(arc) => arc.value.as_str().to_string(), + // `String::from(Box)` reuses the boxed allocation, so this is O(1). + Ok(v) => String::from(v.value), + Err(arc) => arc.value.to_string(), } } INLINE_TAG => self.inline_as_str().to_string(), - STATIC_TAG => self.prehashed_string_as_str().to_string(), + STATIC_TAG => unsafe { deref_static(self.unsafe_data).value.to_string() }, _ => unsafe { debug_unreachable!() }, } } @@ -179,13 +171,13 @@ impl RcStr { let hash = hash_bytes(s.as_bytes()); // Check the static table if let Some(entries) = STATIC_TABLE.get(&hash) - && let Some(static_phs) = entries.iter().find(|phs| phs.value.as_str() == s) + && let Some(static_phs) = entries.iter().find(|phs| phs.value == s) { new_static_atom(static_phs) } else { - new_atom_from_prehashed(PrehashedString { + new_atom_from_prehashed(DynamicPrehashedString { hash, - value: dynamic::Payload::String(s.into()), + value: s.into(), }) } } else { @@ -330,18 +322,19 @@ impl From for PathBuf { impl Clone for RcStr { #[inline(always)] fn clone(&self) -> Self { - let alias = self.unsafe_data; - // We only need to increment the ref count for DYNAMIC_TAG values + // We only need to increment the ref count for DYNAMIC_TAG values. // For STATIC_TAG and INLINE_TAG we can just copy the value. - if alias.tag_byte() & TAG_MASK == DYNAMIC_TAG { + if self.tag() == DYNAMIC_TAG { unsafe { - let arc = dynamic::restore_arc(alias); + let arc = dynamic::restore_arc(self.unsafe_data); forget(arc.clone()); forget(arc); } } - RcStr { unsafe_data: alias } + RcStr { + unsafe_data: self.unsafe_data, + } } } @@ -358,17 +351,33 @@ impl PartialEq for RcStr { if self.unsafe_data == other.unsafe_data { return true; } - // They can still be equal if they are both stored on the heap - match (self.location(), other.location()) { - (PREHASHED_STRING_LOCATION, PREHASHED_STRING_LOCATION) => { - let l = unsafe { deref_from(self.unsafe_data) }; - let r = unsafe { deref_from(other.unsafe_data) }; - l.hash == r.hash && l.value == r.value - } - // NOTE: it is never possible for an inline storage string to compare equal to a dynamic - // allocated string, the construction routines separate the strings based on length. - _ => false, + // If either side is inline, they can't be equal: an inline string is always shorter than + // any heap-allocated one (construction splits on length), and two inline strings would + // have been caught by the `unsafe_data == unsafe_data` check above. + if self.tag() == INLINE_TAG || other.tag() == INLINE_TAG { + return false; + } + + // slow path compare precomputed hashes and string refs + let (l_hash, l_str) = unsafe { heap_hash_and_str(self) }; + let (r_hash, r_str) = unsafe { heap_hash_and_str(other) }; + l_hash == r_hash && l_str == r_str + } +} + +/// Caller must ensure `s.tag()` is `STATIC_TAG` or `DYNAMIC_TAG`. +#[inline] +unsafe fn heap_hash_and_str(s: &RcStr) -> (u64, &str) { + match s.tag() { + STATIC_TAG => { + let p = unsafe { deref_static(s.unsafe_data) }; + (p.hash, p.value) + } + DYNAMIC_TAG => { + let p = unsafe { deref_dynamic(s.unsafe_data) }; + (p.hash, &p.value) } + _ => unsafe { debug_unreachable!() }, } } @@ -388,13 +397,16 @@ impl Ord for RcStr { impl Hash for RcStr { fn hash(&self, state: &mut H) { - match self.location() { - PREHASHED_STRING_LOCATION => { - let l = unsafe { deref_from(self.unsafe_data) }; - state.write_u64(l.hash); + match self.tag() { + STATIC_TAG => { + state.write_u64(unsafe { deref_static(self.unsafe_data).hash }); state.write_u8(0xff); // matches the implementation of the `str` Hash impl } - INLINE_LOCATION => { + DYNAMIC_TAG => { + state.write_u64(unsafe { deref_dynamic(self.unsafe_data).hash }); + state.write_u8(0xff); // matches the implementation of the `str` Hash impl + } + INLINE_TAG => { self.inline_as_str().hash(state); } _ => unsafe { debug_unreachable!() }, @@ -472,11 +484,8 @@ impl Drop for RcStr { fn drop(&mut self) { match self.tag() { DYNAMIC_TAG => unsafe { drop(dynamic::restore_arc(self.unsafe_data)) }, - STATIC_TAG => { - // do nothing, these are never deallocated - } - INLINE_TAG => { - // do nothing, these payloads need no drop logic + INLINE_TAG | STATIC_TAG => { + // no-ops } _ => unsafe { debug_unreachable!() }, } @@ -497,16 +506,16 @@ pub const fn is_atom_inlineable(s: &str) -> bool { #[doc(hidden)] #[inline(always)] -pub const fn from_static(s: &'static PrehashedString) -> RcStr { +pub const fn from_static(s: &'static StaticPrehashedString) -> RcStr { dynamic::new_static_atom(s) } #[doc(hidden)] -pub use dynamic::PrehashedString; +pub use dynamic::StaticPrehashedString; #[doc(hidden)] -pub const fn make_const_prehashed_string(text: &'static str) -> PrehashedString { - PrehashedString { - value: dynamic::Payload::Ref(text), +pub const fn make_const_prehashed_string(text: &'static str) -> StaticPrehashedString { + StaticPrehashedString { + value: text, hash: hash_bytes(text.as_bytes()), } } @@ -517,7 +526,7 @@ pub use inventory; /// Wrapper for collecting `rcstr!` static constants via `inventory`. #[doc(hidden)] -pub struct StaticRcStr(pub &'static PrehashedString); +pub struct StaticRcStr(pub &'static StaticPrehashedString); inventory::collect!(StaticRcStr); @@ -535,21 +544,21 @@ macro_rules! __rcstr_inventory_submit { }; } -/// Read-only lookup table mapping precomputed hash -> static PrehashedString. +/// Read-only lookup table mapping precomputed hash -> static StaticPrehashedString. /// Built once on first access from all `rcstr!` constants collected by `inventory`. /// /// Multiple `rcstr!` calls with the same string content will each submit to /// inventory, but we deduplicate by content here so only one entry per unique /// string is stored. static STATIC_TABLE: LazyLock< - HashMap, FxBuildHasher>, + HashMap, FxBuildHasher>, > = LazyLock::new(|| { - let mut map: HashMap, FxBuildHasher> = + let mut map: HashMap, FxBuildHasher> = HashMap::with_hasher(FxBuildHasher); for StaticRcStr(phs) in inventory::iter:: { - if phs.value.as_str().len() <= MAX_INLINE_LEN { + if phs.value.len() <= MAX_INLINE_LEN { // This is rare, but possible if our macro cannot determine the length of the string at - // macro time we may end up with a wasted PrehashedString submitted to inventory. + // macro time we may end up with a wasted StaticPrehashedString submitted to inventory. // Just skip it continue; @@ -558,10 +567,7 @@ static STATIC_TABLE: LazyLock< // Deduplicate: skip if an entry with the same string content exists // Mostly linkers will merge static strings but this isn't guaranteed so we cannot just rely // on pointer equality. - if !entries - .iter() - .any(|e| e.value.as_str() == phs.value.as_str()) - { + if !entries.iter().any(|e| e.value == phs.value) { entries.push(phs); } } From f49ed2270f8bd1a4f2a2dec5f2326f9a67f65808 Mon Sep 17 00:00:00 2001 From: Luke Sandberg Date: Tue, 19 May 2026 14:46:19 -0700 Subject: [PATCH 3/7] Add MCP `compile_route` tool (#93337) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### What? Adds a `compile_route` MCP tool that triggers on-demand compilation of a specific route (app or pages) without issuing an HTTP request, and returns any compilation issues. ### Why? Coding agents and benchmarking workflows need a way to warm the module graph or measure compile time for a route without standing up a live backend to satisfy the request. The existing path — hitting the URL — requires the route's runtime dependencies to be available and couples compile timing to request handling. ### How? - New tool `mcp/compile_route` registered in `get-or-create-mcp-server.ts`, backed by a `compileRoute({ page, clientOnly })` callback plumbed from the Turbopack hot reloader - Reuses the dev server's existing on-demand entry path (`ensurePage` / `handleRouteType`), so the call path matches a first navigation. - Adds a `subscribeToChanges` opt-out on `ensurePage` and threads it through `handleRouteType` / `handlePagesErrorRoute`. One-shot MCP compilations skip HMR subscription wiring — without this, each call would leak a subscription that fires on every subsequent file change for the life of the dev server. - Telemetry: registers `mcp/compile_route` in the `McpToolName` union. - e2e test in `test/development/mcp-server/mcp-server-compile-route.test.ts`. --- docs/01-app/02-guides/mcp.mdx | 2 + packages/next/errors.json | 5 +- .../src/server/dev/hot-reloader-turbopack.ts | 121 ++++++++- .../next/src/server/dev/hot-reloader-types.ts | 12 +- .../src/server/dev/hot-reloader-webpack.ts | 5 + .../next/src/server/dev/turbopack-utils.ts | 5 + .../server/mcp/get-or-create-mcp-server.ts | 10 + .../src/server/mcp/tools/compile-route.ts | 102 ++++++++ .../mcp/tools/utils/resolve-path-to-route.ts | 46 ++++ packages/next/src/telemetry/events/build.ts | 1 + .../mcp-server-compile-route.test.ts | 232 ++++++++++++++++++ 11 files changed, 538 insertions(+), 3 deletions(-) create mode 100644 packages/next/src/server/mcp/tools/compile-route.ts create mode 100644 packages/next/src/server/mcp/tools/utils/resolve-path-to-route.ts create mode 100644 test/development/mcp-server/mcp-server-compile-route.test.ts diff --git a/docs/01-app/02-guides/mcp.mdx b/docs/01-app/02-guides/mcp.mdx index 0e0485531515..d94f6ae327c8 100644 --- a/docs/01-app/02-guides/mcp.mdx +++ b/docs/01-app/02-guides/mcp.mdx @@ -86,6 +86,8 @@ Through `next-devtools-mcp`, agents can use the following tools: - **`get_project_metadata`**: Retrieve project structure, configuration, and dev server URL - **`get_routes`**: Get all routes that will become entry points by scanning the filesystem. Returns routes grouped by router type (appRouter, pagesRouter). Dynamic segments appear as `[param]` or `[...slug]` patterns - **`get_server_action_by_id`**: Look up Server Actions by their ID to find the source file and function name +- **`get_compilation_issues`**: Retrieve compilation warnings and errors for the whole project from the bundler. Turbopack only. +- **`compile_route`**: Trigger on-demand compilation of a specific route without making an HTTP request to it. Accepts either a `routeSpecifier` (e.g. `/blog/[slug]`, as returned by `get_routes`) or a `path` (e.g. `/blog/hello-world`) which is resolved to the matching route using the dev router's live route table. Returns any compilation issues for the route. Turbopack only. ## Using with agents diff --git a/packages/next/errors.json b/packages/next/errors.json index a0d180d3b8bd..e771e451a2ad 100644 --- a/packages/next/errors.json +++ b/packages/next/errors.json @@ -1252,5 +1252,8 @@ "1251": "Route \"%s\": Next.js encountered runtime data during the initial render or a navigation.\\n\\n\\`cookies()\\`, \\`headers()\\`, \\`params\\`, or \\`searchParams\\` accessed outside of \\`\\` prevents the route from being prerendered or the navigation from being instant, leading to a slower user experience.\\n\\nWays to fix this:\\n - Provide a placeholder with \\`\\` around the data access\\n - Use \\`generateStaticParams\\` to make route params static\\n - Set \\`export const instant = false\\` to allow a blocking route\\n\\nLearn more: https://nextjs.org/docs/messages/blocking-route", "1252": "\\`experimental.cssChunking: \"graph\"\\` is only supported with Turbopack. Please remove the option or run Next.js with Turbopack in %s.", "1253": "\\`experimental.cssChunking: \"strict\"\\` is only supported with webpack. Please remove the option or run Next.js with webpack in %s.", - "1254": "\\`experimental.cssChunking: false\\` is only supported with webpack. Please remove the option or run Next.js with webpack in %s." + "1254": "\\`experimental.cssChunking: false\\` is only supported with webpack. Please remove the option or run Next.js with webpack in %s.", + "1255": "Compilation failed but no issues were recorded", + "1256": "no route matched for path \"%s\"", + "1257": "compileRoute: either routeSpecifier or path is required" } diff --git a/packages/next/src/server/dev/hot-reloader-turbopack.ts b/packages/next/src/server/dev/hot-reloader-turbopack.ts index a105385fa7c4..8eb315fa7ae9 100644 --- a/packages/next/src/server/dev/hot-reloader-turbopack.ts +++ b/packages/next/src/server/dev/hot-reloader-turbopack.ts @@ -59,6 +59,7 @@ import { processTopLevelIssues, printNonFatalIssue, normalizedPageToTurbopackStructureRoute, + type StartChangeSubscription, } from './turbopack-utils' import { propagateServerField, @@ -89,6 +90,7 @@ import { formatIssue, isFileSystemCacheEnabledForDev, isWellKnownError, + ModuleBuildError, processIssues, renderStyledStringToErrorAnsi, type EntryIssuesMap, @@ -120,6 +122,8 @@ import { matchNextPageBundleRequest, } from './hot-reloader-shared-utils' import { getMcpMiddleware } from '../mcp/get-mcp-middleware' +import { formatCompilationIssues } from '../mcp/tools/utils/format-compilation-issues' +import { resolvePathToRoute } from '../mcp/tools/utils/resolve-path-to-route' import { handleErrorStateResponse } from '../mcp/tools/get-errors' import { handlePageMetadataResponse } from '../mcp/tools/get-page-metadata' import { setStackFrameResolver } from '../mcp/tools/utils/format-errors' @@ -1039,6 +1043,116 @@ export async function createHotReloaderTurbopack( clientsWithoutHtmlRequestId.size + clientsByHtmlRequestId.size, getDevServerUrl: () => process.env.__NEXT_PRIVATE_ORIGIN, getTurbopackProject: () => project, + compileRoute: async ({ routeSpecifier, path }) => { + // Resolve the caller's input to a concrete route specifier. The + // path-mode branch reuses the dev router's own live route table + // (opts.fsChecker) — the same one resolve-routes.ts consults on + // every incoming HTTP request — so first-match ordering and live + // route updates are inherited for free. + let page: string + if (routeSpecifier != null) { + page = routeSpecifier + } else if (path != null) { + const resolved = resolvePathToRoute(path, { + appFiles: opts.fsChecker.appFiles, + pageFiles: opts.fsChecker.pageFiles, + dynamicRoutes: opts.fsChecker.getDynamicRoutes(), + }) + if ('notFound' in resolved) { + const err: NodeJS.ErrnoException = new Error( + `no route matched for path "${resolved.pathname}"` + ) + err.code = 'ENOENT' + throw err + } + page = resolved.routeSpecifier + } else { + // Tool handler rejects the empty case; defend the boundary. + throw new Error( + 'compileRoute: either routeSpecifier or path is required' + ) + } + + // ensurePage uses findPagePathData when no definition is provided, + // which calls normalizePagePath("/") → "/index" then findPageFile + // looking for "index.tsx" — neither of which matches "page.tsx" in + // the app dir. Pass a synthetic definition instead. + // + // currentEntrypoints.app is keyed by originalName which includes the + // trailing /page or /route segment (e.g. "/page" for the root route, + // "/blog/[slug]/page" for a dynamic page). Use normalizeAppPath to + // strip that suffix and find the entry matching the user-facing route. + let extraOptions: object | undefined = undefined + for (const [name] of currentEntrypoints.app) { + if (normalizeAppPath(name) === page) { + extraOptions = { + // Synthesize a definition so ensurePage bypasses findPagePathData. + // Only page and bundlePath are used from the definition: + // - page: the originalName used as the route key for currentEntrypoints lookup + // - bundlePath: must start with "app/" to set isInsideAppDir=true + definition: { + page: name, + bundlePath: `app${name}`, + filename: '', + } as any, + } + break + } + } + const ensureOpts = { + page, + // Compile both server and client bundles, matching what happens + // on a real page navigation. Client-only compilation isn't a + // meaningful MCP use case so we don't expose it as a knob. + clientOnly: false, + // Skip wiring HMR subscriptions: there is no client to receive + // updates for routes compiled this way, and these subscriptions + // are never unsubscribed (see TODOs in handleRouteType). + subscribeToChanges: false, + ...extraOptions, + } + + // Snapshot the current issue maps before compilation so we can + // identify which entry keys were added or updated by this call. + // processIssues always creates a new Map() reference, so identity + // comparison detects changes even for re-compilations. + const snapshotBefore = new Map(currentEntryIssues) + + // For app-page routes, processIssues is called with throwIssue=true, + // meaning it throws ModuleBuildError when there are compile errors—but + // it still writes the issues into currentEntryIssues before throwing. + // Catch ModuleBuildError so we can read those issues and return them + // as structured output rather than propagating the throw. + let moduleBuildError: ModuleBuildError | undefined + try { + await hotReloader.ensurePage(ensureOpts) + } catch (err) { + if (err instanceof ModuleBuildError) { + moduleBuildError = err + } else { + throw err + } + } + + const rawIssues = [] + for (const [key, issueMap] of currentEntryIssues) { + if (snapshotBefore.get(key) !== issueMap) { + rawIssues.push(...issueMap.values()) + } + } + + // If ensurePage threw ModuleBuildError but we found no new issues in + // the map (shouldn't happen, but be safe), re-surface the original + // error so its message and stack are preserved. + if (moduleBuildError && rawIssues.length === 0) { + throw moduleBuildError + } + + return { + routeSpecifier: page, + issues: formatCompilationIssues(rawIssues), + } + }, }), ] : []), @@ -1532,6 +1646,7 @@ export async function createHotReloaderTurbopack( definition, isApp, url: requestUrl, + subscribeToChanges = true, }) { // When there is no route definition this is an internal file not a route the user added. // Middleware and instrumentation are handled in turbpack-utils.ts handleEntrypoints instead. @@ -1682,7 +1797,11 @@ export async function createHotReloaderTurbopack( logErrors: true, hooks: { - subscribeToChanges: subscribeToClientChanges, + // Pass a no-o subscribeToChanges to skip wiring HMR subscriptions for + // one-shot compilations (e.g. compile_route MCP tool). + subscribeToChanges: subscribeToChanges + ? subscribeToClientChanges + : ((async () => {}) as StartChangeSubscription), handleWrittenEndpoint: (id, result, forceDeleteCache) => { currentWrittenEntrypoints.set(id, result) assetMapper.setPathsForKey(id, result.clientPaths) diff --git a/packages/next/src/server/dev/hot-reloader-types.ts b/packages/next/src/server/dev/hot-reloader-types.ts index c620de4a707b..df507062fcc9 100644 --- a/packages/next/src/server/dev/hot-reloader-types.ts +++ b/packages/next/src/server/dev/hot-reloader-types.ts @@ -267,13 +267,23 @@ export interface NextJsHotReloaderInterface { definition, isApp, url, + subscribeToChanges, }: { page: string clientOnly: boolean appPaths?: ReadonlyArray | null isApp?: boolean - definition: RouteDefinition | undefined + definition?: RouteDefinition url?: string + /** + * Whether to wire HMR change subscriptions for the compiled entry. + * Defaults to true (the dev server uses these to push updates to + * connected browsers). Pass false for one-shot compilations (e.g. + * the `compile_route` MCP tool) where there is no client to receive + * HMR updates — without this, repeated calls leak subscriptions that + * keep firing on every file change for the life of the dev server. + */ + subscribeToChanges?: boolean }): Promise close(): void } diff --git a/packages/next/src/server/dev/hot-reloader-webpack.ts b/packages/next/src/server/dev/hot-reloader-webpack.ts index 4b646dcfdc8e..063ebe195a89 100644 --- a/packages/next/src/server/dev/hot-reloader-webpack.ts +++ b/packages/next/src/server/dev/hot-reloader-webpack.ts @@ -1690,6 +1690,7 @@ export default class HotReloaderWebpack implements NextJsHotReloaderInterface { getActiveConnectionCount: () => this.webpackHotMiddleware?.getClientCount() ?? 0, getDevServerUrl: () => process.env.__NEXT_PRIVATE_ORIGIN, + // compile_route is Turbopack-only; intentionally omitted here. }), ] : []) @@ -1844,6 +1845,10 @@ export default class HotReloaderWebpack implements NextJsHotReloaderInterface { isApp?: boolean definition?: RouteDefinition url?: string + // subscribeToChanges is accepted for interface compatibility but is a + // no-op for webpack: webpack's on-demand entry handler does not wire HMR + // subscriptions per entry the way Turbopack does. + subscribeToChanges?: boolean }): Promise { return this.hotReloaderSpan .traceChild('ensure-page', { diff --git a/packages/next/src/server/dev/turbopack-utils.ts b/packages/next/src/server/dev/turbopack-utils.ts index f195d6761ccf..ce29911a9ca9 100644 --- a/packages/next/src/server/dev/turbopack-utils.ts +++ b/packages/next/src/server/dev/turbopack-utils.ts @@ -135,6 +135,9 @@ export type ClientState = { export type ClientStateMap = WeakMap // hooks only used by the dev server. +// subscribeToChanges is optional: omit it to skip wiring HMR subscriptions +// for one-shot compilations (e.g. the compile_route MCP tool) where there +// is no client to receive updates and no unsubscribe path. type HandleRouteTypeHooks = { handleWrittenEndpoint: HandleWrittenEndpoint subscribeToChanges: StartChangeSubscription @@ -168,6 +171,8 @@ export async function handleRouteType({ readyIds?: ReadyIds // dev + // hooks.subscribeToChanges may be omitted to skip HMR subscriptions for + // one-shot compilations (e.g. the compile_route MCP tool). hooks?: HandleRouteTypeHooks // dev }) { switch (route.type) { diff --git a/packages/next/src/server/mcp/get-or-create-mcp-server.ts b/packages/next/src/server/mcp/get-or-create-mcp-server.ts index c9d523a4c6b7..5bd9108f8cda 100644 --- a/packages/next/src/server/mcp/get-or-create-mcp-server.ts +++ b/packages/next/src/server/mcp/get-or-create-mcp-server.ts @@ -6,9 +6,11 @@ import { registerGetLogsTool } from './tools/get-logs' import { registerGetActionByIdTool } from './tools/get-server-action-by-id' import { registerGetRoutesTool } from './tools/get-routes' import { registerGetCompilationIssuesTool } from './tools/get-compilation-issues' +import { registerCompileRouteTool } from './tools/compile-route' import type { HmrMessageSentToBrowser } from '../dev/hot-reloader-types' import type { NextConfigComplete } from '../config-shared' import type { Project } from '../../build/swc/types' +import type { FormattedIssue } from './tools/utils/format-compilation-issues' export interface McpServerOptions { projectPath: string @@ -20,6 +22,10 @@ export interface McpServerOptions { getActiveConnectionCount: () => number getDevServerUrl: () => string | undefined getTurbopackProject?: () => Project | undefined + compileRoute?: (opts: { + routeSpecifier?: string + path?: string + }) => Promise<{ routeSpecifier: string; issues: FormattedIssue[] }> } let mcpServer: McpServer | undefined @@ -62,5 +68,9 @@ export const getOrCreateMcpServer = (options: McpServerOptions) => { registerGetCompilationIssuesTool(mcpServer, options.getTurbopackProject) } + if (options.compileRoute) { + registerCompileRouteTool(mcpServer, options.compileRoute) + } + return mcpServer } diff --git a/packages/next/src/server/mcp/tools/compile-route.ts b/packages/next/src/server/mcp/tools/compile-route.ts new file mode 100644 index 000000000000..2f90eb4ce47d --- /dev/null +++ b/packages/next/src/server/mcp/tools/compile-route.ts @@ -0,0 +1,102 @@ +/** + * MCP tool for compiling a specific route via the on-demand entry handler. + * + * Triggers on-demand compilation so the route's assets are built without making an + * HTTP request to the route. This is the same call path the dev server uses + * when a route is first navigated to, making it useful for warming the module + * graph, measuring compile time, or pre-compiling routes for memory + * benchmarking without requiring live backends. + */ +import type { McpServer } from 'next/dist/compiled/@modelcontextprotocol/sdk/server/mcp' +import { mcpTelemetryTracker } from '../mcp-telemetry-tracker' +import type { FormattedIssue } from './utils/format-compilation-issues' +import z from 'next/dist/compiled/zod' + +export function registerCompileRouteTool( + server: McpServer, + compileRoute: (opts: { + routeSpecifier?: string + path?: string + }) => Promise<{ routeSpecifier: string; issues: FormattedIssue[] }> +) { + server.registerTool( + 'compile_route', + { + description: + 'Compile a specific route (page or API route) without making an HTTP request. ' + + 'Triggers the same on-demand compilation the dev server uses when a route is first visited. ' + + 'Useful for warming up the module graph, measuring compile time, or pre-compiling routes for memory benchmarking. ' + + 'Returns { routeSpecifier, issues } on success where routeSpecifier is the resolved route and issues contains any compilation warnings or errors. ' + + 'Returns an error if no matching route exists.', + inputSchema: { + routeSpecifier: z + .string() + .describe( + 'A route specifier as returned by the get_routes tool (e.g. "/", "/blog/[slug]", "/api/users/[id]"). ' + + 'Mutually exclusive with `path`; provide exactly one.' + ) + .optional(), + path: z + .string() + .describe( + 'A URL path on this site (e.g. "/blog/hello-world", "/docs/a/b/c"). ' + + 'Query strings are allowed and ignored. Do not include scheme/host/port. ' + + "The path is resolved to its matching route specifier using the dev router's live route table. " + + 'Mutually exclusive with `routeSpecifier`; provide exactly one.' + ) + .optional(), + }, + }, + async ({ routeSpecifier, path }) => { + mcpTelemetryTracker.recordToolCall('mcp/compile_route') + + if ((routeSpecifier == null) === (path == null)) { + return { + isError: true, + content: [ + { + type: 'text', + text: JSON.stringify({ + error: 'Provide exactly one of `routeSpecifier` or `path`.', + }), + }, + ], + } + } + + try { + const { routeSpecifier: resolvedRouteSpecifier, issues } = + await compileRoute({ routeSpecifier, path }) + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + routeSpecifier: resolvedRouteSpecifier, + issues, + }), + }, + ], + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + const notFound = + error instanceof Error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + return { + isError: true, + content: [ + { + type: 'text', + text: JSON.stringify( + notFound + ? { notFound: true, input: path ?? routeSpecifier } + : { input: path ?? routeSpecifier, error: message } + ), + }, + ], + } + } + } + ) +} diff --git a/packages/next/src/server/mcp/tools/utils/resolve-path-to-route.ts b/packages/next/src/server/mcp/tools/utils/resolve-path-to-route.ts new file mode 100644 index 000000000000..aa75550e549a --- /dev/null +++ b/packages/next/src/server/mcp/tools/utils/resolve-path-to-route.ts @@ -0,0 +1,46 @@ +/** + * Resolves a URL path (e.g. "/blog/hello-world") to its matching Next.js route + * specifier (e.g. "/blog/[slug]") using the dev router's own live route table. + * + * The `matchers` argument is a thin view of `fsChecker` from the router-server + * process — the same data structure `resolve-routes.ts` iterates on every + * incoming HTTP request — so first-match ordering and live route updates are + * inherited for free. + */ +export interface RouteMatcherView { + appFiles: ReadonlySet + pageFiles: ReadonlySet + dynamicRoutes: ReadonlyArray<{ + page: string + match: (pathname: string) => false | object + }> +} + +export function resolvePathToRoute( + path: string, + matchers: RouteMatcherView +): { routeSpecifier: string } | { notFound: true; pathname: string } { + let pathname = path + const q = pathname.indexOf('?') + if (q >= 0) pathname = pathname.slice(0, q) + const h = pathname.indexOf('#') + if (h >= 0) pathname = pathname.slice(0, h) + if (!pathname.startsWith('/')) pathname = '/' + pathname + if (pathname !== '/' && pathname.endsWith('/')) { + pathname = pathname.slice(0, -1) + } + + if (matchers.appFiles.has(pathname) || matchers.pageFiles.has(pathname)) { + return { routeSpecifier: pathname } + } + + for (const route of matchers.dynamicRoutes) { + // Skip SSG/SSP data-route variants prepended by setup-dev-bundler. + if (route.page.startsWith('/_next/data/')) continue + if (route.match(pathname)) { + return { routeSpecifier: route.page } + } + } + + return { notFound: true, pathname } +} diff --git a/packages/next/src/telemetry/events/build.ts b/packages/next/src/telemetry/events/build.ts index dbd2a63aea92..ab1c71d9fc51 100644 --- a/packages/next/src/telemetry/events/build.ts +++ b/packages/next/src/telemetry/events/build.ts @@ -280,6 +280,7 @@ export type McpToolName = | 'mcp/get_routes' | 'mcp/get_server_action_by_id' | 'mcp/get_compilation_issues' + | 'mcp/compile_route' export type EventMcpToolUsage = { toolName: McpToolName diff --git a/test/development/mcp-server/mcp-server-compile-route.test.ts b/test/development/mcp-server/mcp-server-compile-route.test.ts new file mode 100644 index 000000000000..6e7fe1c45066 --- /dev/null +++ b/test/development/mcp-server/mcp-server-compile-route.test.ts @@ -0,0 +1,232 @@ +import path from 'path' +import { nextTestSetup } from 'e2e-utils' + +async function callMcpTool( + url: string, + toolName: string, + args: Record = {} +): Promise { + const response = await fetch(`${url}/_next/mcp`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: toolName + '-' + Date.now(), + method: 'tools/call', + params: { name: toolName, arguments: args }, + }), + }) + const text = await response.text() + const match = text.match(/data: ({.*})/s) + expect(match).toBeTruthy() + const envelope = JSON.parse(match![1]) + return JSON.parse(envelope.result?.content?.[0]?.text) +} + +// compile_route is Turbopack-only; it is not registered on webpack dev servers. +;(process.env.IS_TURBOPACK_TEST ? describe : describe.skip)( + 'mcp-server compile_route tool', + () => { + const { next, skipped } = nextTestSetup({ + files: path.join(__dirname, 'fixtures', 'dynamic-routes-app'), + }) + + if (skipped) { + return + } + + describe('routeSpecifier input', () => { + it('should compile a valid app router root route', async () => { + const result = await callMcpTool(next.url, 'compile_route', { + routeSpecifier: '/', + }) + expect(result).toMatchObject({ routeSpecifier: '/', issues: [] }) + }) + + it('should compile a valid dynamic app router route', async () => { + const result = await callMcpTool(next.url, 'compile_route', { + routeSpecifier: '/blog/[slug]', + }) + expect(result).toMatchObject({ + routeSpecifier: '/blog/[slug]', + issues: [], + }) + }) + + it('should compile a valid pages router route', async () => { + const result = await callMcpTool(next.url, 'compile_route', { + routeSpecifier: '/about', + }) + expect(result).toMatchObject({ routeSpecifier: '/about', issues: [] }) + }) + + it('should compile a valid app router API route', async () => { + const result = await callMcpTool(next.url, 'compile_route', { + routeSpecifier: '/api/users/[id]', + }) + expect(result).toMatchObject({ + routeSpecifier: '/api/users/[id]', + issues: [], + }) + }) + + it('should compile a valid pages router API route', async () => { + const result = await callMcpTool(next.url, 'compile_route', { + routeSpecifier: '/api/legacy', + }) + expect(result).toMatchObject({ + routeSpecifier: '/api/legacy', + issues: [], + }) + }) + + it('should return notFound for a non-existent specifier', async () => { + const result = (await callMcpTool(next.url, 'compile_route', { + routeSpecifier: '/does-not-exist', + })) as any + expect(result).toMatchObject({ + notFound: true, + input: '/does-not-exist', + }) + }) + }) + + describe('path input', () => { + it('should resolve a static app route path', async () => { + const result = await callMcpTool(next.url, 'compile_route', { + path: '/', + }) + expect(result).toMatchObject({ routeSpecifier: '/', issues: [] }) + }) + + it('should resolve a dynamic app route path', async () => { + const result = await callMcpTool(next.url, 'compile_route', { + path: '/blog/hello-world', + }) + expect(result).toMatchObject({ + routeSpecifier: '/blog/[slug]', + issues: [], + }) + }) + + it('should resolve a catchall app route path', async () => { + const result = await callMcpTool(next.url, 'compile_route', { + path: '/docs/a/b/c', + }) + expect(result).toMatchObject({ + routeSpecifier: '/docs/[...slug]', + issues: [], + }) + }) + + it('should strip query string before matching', async () => { + const result = await callMcpTool(next.url, 'compile_route', { + path: '/products/42?ref=x', + }) + expect(result).toMatchObject({ + routeSpecifier: '/products/[id]', + issues: [], + }) + }) + + it('should resolve a pages router dynamic path', async () => { + const result = await callMcpTool(next.url, 'compile_route', { + path: '/posts/7', + }) + expect(result).toMatchObject({ + routeSpecifier: '/posts/[id]', + issues: [], + }) + }) + + it('should resolve an app router API path', async () => { + const result = await callMcpTool(next.url, 'compile_route', { + path: '/api/users/42', + }) + expect(result).toMatchObject({ + routeSpecifier: '/api/users/[id]', + issues: [], + }) + }) + + it('should resolve a static pages router path', async () => { + const result = await callMcpTool(next.url, 'compile_route', { + path: '/about', + }) + expect(result).toMatchObject({ routeSpecifier: '/about', issues: [] }) + }) + + it('should strip a trailing slash before matching', async () => { + const result = await callMcpTool(next.url, 'compile_route', { + path: '/about/', + }) + expect(result).toMatchObject({ routeSpecifier: '/about', issues: [] }) + }) + + it('should return notFound when no route matches', async () => { + const result = (await callMcpTool(next.url, 'compile_route', { + path: '/nope/x', + })) as any + expect(result).toMatchObject({ notFound: true, input: '/nope/x' }) + }) + }) + + describe('input validation', () => { + it('should error when both routeSpecifier and path are provided', async () => { + const result = (await callMcpTool(next.url, 'compile_route', { + routeSpecifier: '/', + path: '/', + })) as any + expect(result).toMatchObject({ + error: expect.stringContaining('exactly one'), + }) + }) + + it('should error when neither routeSpecifier nor path is provided', async () => { + const result = (await callMcpTool(next.url, 'compile_route', {})) as any + expect(result).toMatchObject({ + error: expect.stringContaining('exactly one'), + }) + }) + }) + } +) + +// Compilation errors don't throw from ensurePage — they are collected from +// Turbopack's per-entry issue map and returned directly in the compile_route +// response, so no second round-trip to get_compilation_issues is needed. +;(process.env.IS_TURBOPACK_TEST ? describe : describe.skip)( + 'mcp-server compile_route with compilation errors', + () => { + const { next, skipped } = nextTestSetup({ + files: path.join(__dirname, 'fixtures', 'compilation-errors-app'), + }) + + if (skipped) { + return + } + + it('should return compilation issues inline in the response', async () => { + const result = (await callMcpTool(next.url, 'compile_route', { + routeSpecifier: '/missing-module', + })) as { + routeSpecifier: string + issues: Array<{ severity: string; filePath: string; title: string }> + } + + expect(result.routeSpecifier).toBe('/missing-module') + expect(result.issues.length).toBeGreaterThan(0) + + const moduleNotFound = result.issues.find( + (issue) => + (issue.severity === 'error' || issue.severity === 'fatal') && + (issue.filePath.includes('missing-module') || + issue.title.includes('non-existent-module')) + ) + expect(moduleNotFound).toBeDefined() + }) + } +) From f7dee1b8eceb9a0a3263d3063f088b39564a3c90 Mon Sep 17 00:00:00 2001 From: Luke Sandberg Date: Tue, 19 May 2026 15:05:34 -0700 Subject: [PATCH 4/7] [turbopack-trace-server] Add memory samples to the mcp tool results (#93338) Expose memory data to the MCP tool for the turbo trace server --- .../src/turbo_trace_server.rs | 13 +++++++ .../next/src/build/swc/generated-native.d.ts | 10 ++++++ .../src/cli/internal/turbo-trace-server.ts | 35 ++++++++++++++++++- .../crates/turbopack-trace-server/src/lib.rs | 33 +++++++++++++++++ .../turbopack-trace-server/src/store.rs | 24 +++++++++++-- 5 files changed, 111 insertions(+), 4 deletions(-) diff --git a/crates/next-napi-bindings/src/turbo_trace_server.rs b/crates/next-napi-bindings/src/turbo_trace_server.rs index f43383108907..87c2cc772624 100644 --- a/crates/next-napi-bindings/src/turbo_trace_server.rs +++ b/crates/next-napi-bindings/src/turbo_trace_server.rs @@ -62,6 +62,14 @@ pub struct TraceSpanInfo { pub avg_corrected_duration: Option, /// Raw span ID for aggregated groups (the index of the first span). pub first_span_id: Option, + /// TurboMalloc memory-usage samples recorded while this span + /// (or its example span, for aggregated groups) was live. + /// + /// Each entry is `[ts_offset_from_span_start_in_ticks, bytes, pressure]`, + /// where `pressure` is the memory-pressure byte (0 = no pressure, higher + /// = more pressure). `100 ticks = 1 µs`. The offset is always `>= 0` and + /// `<= span_duration`. Capped and downsampled by the store. + pub memory_samples: Vec>, } /// The result of a `query_trace_spans` call. @@ -125,6 +133,11 @@ pub fn query_trace_spans( total_corrected_duration: s.total_corrected_duration.map(|v| v as i64), avg_corrected_duration: s.avg_corrected_duration.map(|v| v as i64), first_span_id: s.first_span_id, + memory_samples: s + .memory_samples + .into_iter() + .map(|(ts, mem, pressure)| vec![ts, mem as i64, pressure as i64]) + .collect(), }) .collect(), page: result.page as u32, diff --git a/packages/next/src/build/swc/generated-native.d.ts b/packages/next/src/build/swc/generated-native.d.ts index c5d495219f1f..91a56593d095 100644 --- a/packages/next/src/build/swc/generated-native.d.ts +++ b/packages/next/src/build/swc/generated-native.d.ts @@ -670,6 +670,16 @@ export interface TraceSpanInfo { avgCorrectedDuration?: number /** Raw span ID for aggregated groups (the index of the first span). */ firstSpanId?: string + /** + * TurboMalloc memory-usage samples recorded while this span + * (or its example span, for aggregated groups) was live. + * + * Each entry is `[ts_offset_from_span_start_in_ticks, bytes, pressure]`, + * where `pressure` is the memory-pressure byte (0 = no pressure, higher + * = more pressure). `100 ticks = 1 µs`. The offset is always `>= 0` and + * `<= span_duration`. Capped and downsampled by the store. + */ + memorySamples: Array> } /** The result of a `query_trace_spans` call. */ export interface TraceQueryResult { diff --git a/packages/next/src/cli/internal/turbo-trace-server.ts b/packages/next/src/cli/internal/turbo-trace-server.ts index ae6052987f43..54d8aeb70b6f 100644 --- a/packages/next/src/cli/internal/turbo-trace-server.ts +++ b/packages/next/src/cli/internal/turbo-trace-server.ts @@ -34,6 +34,34 @@ function formatRelative(ticks: number): string { return prefix + formatDuration(Math.abs(ticks)) } +function formatBytes(bytes: number): string { + const KB = 1024 + const MB = KB * 1024 + const GB = MB * 1024 + if (bytes >= GB) return `${(bytes / GB).toFixed(2)} GB` + if (bytes >= MB) return `${(bytes / MB).toFixed(2)} MB` + if (bytes >= KB) return `${(bytes / KB).toFixed(2)} KB` + return `${bytes} B` +} + +function summarizeMemorySamples(samples: number[][]): string | null { + if (!samples || samples.length === 0) return null + const bytes = samples.map((s) => s[1]) + const pressures = samples.map((s) => s[2] ?? 0) + const min = Math.min(...bytes) + const max = Math.max(...bytes) + const first = bytes[0] + const last = bytes[bytes.length - 1] + const delta = last - first + const deltaSign = delta >= 0 ? '+' : '-' + const maxPressure = Math.max(...pressures) + return ( + `samples=${samples.length}, min=${formatBytes(min)}, max=${formatBytes(max)}, ` + + `start=${formatBytes(first)}, end=${formatBytes(last)}, ` + + `Δ=${deltaSign}${formatBytes(Math.abs(delta))}, maxPressure=${maxPressure}` + ) +} + /** * Render a single span (or aggregated span group) as a markdown section. */ @@ -74,6 +102,11 @@ function renderSpanMarkdown(span: TraceSpanInfo): string { } } + const memSummary = summarizeMemorySamples(span.memorySamples) + if (memSummary) { + md += `\n**Memory (TurboMalloc live bytes):** ${memSummary}\n` + } + md += '\n---\n\n' return md } @@ -121,7 +154,7 @@ export async function startTurboTraceServerCli( 'query_spans', { description: - 'Query spans from a turbopack trace file. Returns spans with timing, CPU usage, and attribute details. Set `outputType` to "json" for machine-readable output or "markdown" (default) for human-readable output. Use the `parent` parameter (with an ID from a previous result) to drill into children. Results are paginated to 20 spans per page.', + 'Query spans from a turbopack trace file. Returns spans with timing, CPU usage, attribute details, and TurboMalloc live-memory samples recorded while each span was active. Set `outputType` to "json" for machine-readable output (including the raw `memorySamples` array of `[ts_offset_ticks, bytes, pressure]` triples per span — pressure is 0 = none, higher = more memory pressure) or "markdown" (default) for a human-readable summary. Use the `parent` parameter (with an ID from a previous result) to drill into children. Results are paginated to 20 spans per page.', inputSchema: { parent: z .string() diff --git a/turbopack/crates/turbopack-trace-server/src/lib.rs b/turbopack/crates/turbopack-trace-server/src/lib.rs index 80f4e295edc7..a3874769b37a 100644 --- a/turbopack/crates/turbopack-trace-server/src/lib.rs +++ b/turbopack/crates/turbopack-trace-server/src/lib.rs @@ -129,6 +129,19 @@ pub struct SpanInfo { pub avg_corrected_duration: Option, /// Raw span ID for aggregated groups (the index of the first span). pub first_span_id: Option, + /// TurboMalloc memory-usage samples recorded while this span (or its + /// example span, for aggregated groups) was live. + /// + /// Each tuple is `(ts_offset_from_span_start_in_ticks, bytes, pressure)`, + /// where `pressure` is the memory-pressure byte recorded with the sample + /// (0 = no pressure, higher = more pressure). `100 ticks = 1 µs`. The + /// offset is always `>= 0` and `<= span_duration`. + /// + /// The store caps the series at `MAX_MEMORY_SAMPLES`; when more samples + /// exist in the range, consecutive groups are merged by picking the + /// group's max-memory sample (timestamp, value, and pressure kept + /// together). + pub memory_samples: Vec<(i64, u64, u8)>, } /// Result of a `query_spans` call. @@ -283,6 +296,15 @@ pub fn query_spans(store: &Arc, options: QueryOptions) -> QueryR let rel_start = (span_start as i64) - (parent_start as i64); let rel_end = (span_end as i64) - (parent_start as i64); + let first_start_ticks = *first.start(); + let memory_samples: Vec<(i64, u64, u8)> = store_ref + .memory_samples_for_range_with_ts(first.start(), first.end()) + .into_iter() + .map(|(ts, mem, pressure)| { + ((*ts as i64) - (first_start_ticks as i64), mem, pressure) + }) + .collect(); + SpanInfo { id: graph_id, name, @@ -301,6 +323,7 @@ pub fn query_spans(store: &Arc, options: QueryOptions) -> QueryR total_corrected_duration: Some(total_corrected), avg_corrected_duration: Some(avg_corrected), first_span_id: Some(first_index.to_string()), + memory_samples, } }) .collect(); @@ -361,6 +384,15 @@ pub fn query_spans(store: &Arc, options: QueryOptions) -> QueryR let rel_start = (span_start as i64) - (parent_start as i64); let rel_end = (span_end as i64) - (parent_start as i64); + let raw_span_start = span_start; + let memory_samples: Vec<(i64, u64, u8)> = store_ref + .memory_samples_for_range_with_ts(span.start(), span.end()) + .into_iter() + .map(|(ts, mem, pressure)| { + ((*ts as i64) - (raw_span_start as i64), mem, pressure) + }) + .collect(); + SpanInfo { id: build_span_id(options.parent.as_deref(), &span.index.to_string()), name, @@ -379,6 +411,7 @@ pub fn query_spans(store: &Arc, options: QueryOptions) -> QueryR total_corrected_duration: None, avg_corrected_duration: None, first_span_id: None, + memory_samples, } }) .collect(); diff --git a/turbopack/crates/turbopack-trace-server/src/store.rs b/turbopack/crates/turbopack-trace-server/src/store.rs index ef5a57c5ac72..2983d5bd56bb 100644 --- a/turbopack/crates/turbopack-trace-server/src/store.rs +++ b/turbopack/crates/turbopack-trace-server/src/store.rs @@ -357,6 +357,23 @@ impl Store { /// `[start, end]`. When more samples exist, groups of N consecutive /// samples are merged by taking the maximum memory value in each group. pub fn memory_samples_for_range(&self, start: Timestamp, end: Timestamp) -> Vec { + self.memory_samples_for_range_with_ts(start, end) + .into_iter() + .map(|(_, mem, _)| mem) + .collect() + } + + /// Like `memory_samples_for_range` but keeps the timestamps and the + /// memory-pressure byte. Timestamps are absolute store timestamps (same + /// reference frame as span start/end). When the raw slice exceeds + /// `MAX_MEMORY_SAMPLES`, each merged group is represented by the sample + /// whose memory value was the group's max (its timestamp and pressure + /// byte are kept alongside it). + pub fn memory_samples_for_range_with_ts( + &self, + start: Timestamp, + end: Timestamp, + ) -> Vec { let slice = self.memory_samples_slice(start, end); let count = slice.len(); if count == 0 { @@ -364,14 +381,15 @@ impl Store { } if count <= MAX_MEMORY_SAMPLES { - return slice.iter().map(|(_, mem, _)| *mem).collect(); + return slice.to_vec(); } - // Merge groups of N samples, taking the max memory in each group. + // Merge groups of N samples, taking the max memory in each group and + // keeping the timestamp and pressure of that max sample. let n = count.div_ceil(MAX_MEMORY_SAMPLES); slice .chunks(n) - .map(|chunk| chunk.iter().map(|(_, mem, _)| *mem).max().unwrap()) + .map(|chunk| *chunk.iter().max_by_key(|(_, mem, _)| *mem).unwrap()) .collect() } From a9323da29399e11a9e787ae40d64fe74bead9f13 Mon Sep 17 00:00:00 2001 From: Luke Sandberg Date: Tue, 19 May 2026 15:07:46 -0700 Subject: [PATCH 5/7] Stop pinning compiled chunk source on `EcmascriptBuildNodeChunkVersion`. (#93807) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Stop pinning compiled chunk source on `EcmascriptBuildNodeChunkVersion`. The struct previously held a `Vec>` for every module in the chunk, even though the HMR update path only needs the bytes for the *changed* subset and the bytes are already on disk. That field was also forcing the upstream `CodeAndIds` / `BatchGroupCodeAndIds` tasks to stay `serialization = "skip"`, so warm restarts had to re-walk every module and re-hash its source to rebuild `entries_hashes`. This change mirrors the browser-side pattern: a new `EcmascriptBuildNodeChunkContentEntries` task lives on the chunk content and holds `ResolvedVc` + `ResolvedVc` per module. The version struct shrinks to `{ chunk_path, minify_type, entries_hashes }`, drops `serialization = "skip"`, and switches `chunk_path` from `String` to `RcStr`. `update_ecmascript_node_chunk_content` now resolves entries lazily, only when an added or modified module actually needs its code shipped. ## Why Two wins, both for dev sessions starting against a warm filesystem cache: - **Memory.** The version no longer transitively pins every module's compiled `Rope` in heap — those bytes can stay on disk until HMR actually needs them. - **Warm-restart CPU.** `entries_hashes` is sourced from the per-module `Code::source_code_hash()` task (already cached) and the version itself now round-trips through the persistent cache, so we don't re-hash anything on warm start. The HMR payload shape is unchanged. ## Perf This should speed up warm builds a bit but the major benefit is not recomputing node outputs and keeping them in ram measuring v0 after loading the main route Branch: Cold: 12.3G Warm: 7.34G Canary: Cold 12.3G Warm: 8.5G The trace file confirms the recomputations are gone and the heap measurements confirm we trimming ~1.1g of ram Using the devlow benchmarks i was able to confirm a possible small progression ``` # canary chat dev startup build=warm: root page = 23.96 s (from root page/start) chat dev startup build=warm: root page = 21.21 s (from root page/start) chat dev startup build=warm: root page = 22.70 s (from root page/start) # branch chat dev startup build=warm: root page = 20.94 s (from root page/start) chat dev startup build=warm: root page = 19.43 s (from root page/start) chat dev startup build=warm: root page = 23.49 s (from root page/start) ``` ## Tests I added a new integration test to ensure we don't accidentally regress here. Which confirms that 'clean warm builds' run nothing and 'clean warm dev sessions' just set up HMR session infra --- Cargo.lock | 1 + test/.gitignore | 2 + test/e2e/filesystem-cache/tsconfig.json | 32 ++++ .../warm-restart-task-stats.test.ts | 151 ++++++++++++++++++ .../crates/turbo-tasks/src/task_statistics.rs | 16 +- .../src/ecmascript/content.rs | 14 +- .../src/ecmascript/list/content.rs | 7 +- .../src/ecmascript/list/update.rs | 3 +- .../turbopack-browser/src/ecmascript/mod.rs | 1 - .../src/ecmascript/version.rs | 20 +-- .../src/chunk}/content_entry.rs | 27 ++-- .../turbopack-ecmascript/src/chunk/mod.rs | 2 + turbopack/crates/turbopack-nodejs/Cargo.toml | 1 + .../src/ecmascript/node/content.rs | 11 +- .../src/ecmascript/node/update.rs | 38 +++-- .../src/ecmascript/node/version.rs | 32 ++-- 16 files changed, 283 insertions(+), 75 deletions(-) create mode 100644 test/e2e/filesystem-cache/tsconfig.json create mode 100644 test/e2e/filesystem-cache/warm-restart-task-stats.test.ts rename turbopack/crates/{turbopack-browser/src/ecmascript => turbopack-ecmascript/src/chunk}/content_entry.rs (80%) diff --git a/Cargo.lock b/Cargo.lock index cfa4822ed82d..68ec8239d171 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10606,6 +10606,7 @@ version = "0.1.0" dependencies = [ "anyhow", "bincode 2.0.1", + "either", "indoc", "serde", "serde_json", diff --git a/test/.gitignore b/test/.gitignore index 15b4698e6bab..5782cc142201 100644 --- a/test/.gitignore +++ b/test/.gitignore @@ -2,6 +2,8 @@ .vscode e2e/**/tsconfig.json +# See PACK-6932: we need to submit this so that builds are more consistent +!e2e/filesystem-cache/tsconfig.json production/**/tsconfig.json development/**/tsconfig.json diff --git a/test/e2e/filesystem-cache/tsconfig.json b/test/e2e/filesystem-cache/tsconfig.json new file mode 100644 index 000000000000..5f1e3fcbda12 --- /dev/null +++ b/test/e2e/filesystem-cache/tsconfig.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": false, + "noEmit": true, + "incremental": true, + "module": "esnext", + "esModuleInterop": true, + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "plugins": [ + { + "name": "next" + } + ], + "strictNullChecks": true + }, + "include": [ + "next-env.d.ts", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts", + "**/*.mts", + "**/*.ts", + "**/*.tsx" + ], + "exclude": ["node_modules", "**/*.test.ts", "**/*.test.tsx"] +} diff --git a/test/e2e/filesystem-cache/warm-restart-task-stats.test.ts b/test/e2e/filesystem-cache/warm-restart-task-stats.test.ts new file mode 100644 index 000000000000..9d719ab2509d --- /dev/null +++ b/test/e2e/filesystem-cache/warm-restart-task-stats.test.ts @@ -0,0 +1,151 @@ +import { nextTestSetup, isNextDev } from 'e2e-utils' +import { waitFor } from 'next-test-utils' +import fs from 'fs/promises' +import path from 'path' + +// Regression guard for warm-cache work: after one cold cycle, run a warm +// cycle and snapshot the set of turbo-tasks functions that had any cache +// miss. If a task previously cached (e.g. via a persistable entries map) +// regresses to recomputing on warm restart, this snapshot will change and +// fail the test. +// +// When the snapshot moves: +// - FEWER entries → that's a win. Run with `-u` and commit the new snapshot. +// - MORE entries → something is recomputing on warm start. Investigate +// before updating. +// +// Dev and start (build) snapshots are kept separate because the warm graphs +// differ: dev brings up HMR/Fast Refresh infra that build doesn't. + +interface TaskFunctionStatistics { + cache_hit: number + cache_miss: number +} +type TaskStats = Record + +const STATS_RELATIVE_PATH = '.next/warm-restart-task-stats.json' + +;(process.env.IS_TURBOPACK_TEST ? describe : describe.skip)( + 'warm-restart task statistics', + () => { + const env = [ + 'ENABLE_CACHING=1', + 'TURBO_ENGINE_IGNORE_DIRTY=1', + 'TURBO_ENGINE_SNAPSHOT_IDLE_TIMEOUT_MILLIS=1000', + `NEXT_TURBOPACK_TASK_STATISTICS=${STATS_RELATIVE_PATH}`, + // The task-statistics file is written by an `on_exit` handler in the + // napi binding. In dev that handler only runs if the child process + // gets a chance to clean up (i.e. SIGTERM, not SIGKILL). The parent + // `next dev` process gives the child 100ms by default before + // escalating to SIGKILL — bump that so the on-exit handler can flush. + 'NEXT_EXIT_TIMEOUT_MS=30000', + ].join(' ') + + const { skipped, next } = nextTestSetup({ + files: __dirname, + skipDeployment: true, + packageJson: { + packageManager: 'npm@10.9.2', + scripts: { + build: `${env} next build`, + dev: `${env} next dev`, + start: 'next start', + }, + }, + installCommand: 'npm i', + buildCommand: 'npm run build', + startCommand: isNextDev ? 'npm run dev' : 'npm run start', + }) + + if (skipped) { + return + } + + beforeAll(() => { + // No file edits in this test — skip HMR debounce. + ;(next as any).handleDevWatchDelayBeforeChange = () => {} + ;(next as any).handleDevWatchDelayAfterChange = () => {} + }) + + async function stop() { + if (isNextDev) { + // Persistent cache snapshot is on a 1s idle timer; give it room. + await waitFor(3000) + // SIGTERM (not the harness default SIGKILL) so the dev server gets + // to run its cleanup, which is what flushes the task-stats file. + await next.stop('SIGTERM') + } else { + await next.stop() + } + } + + async function readMissedTaskNames(): Promise { + const absPath = path.join(next.testDir, STATS_RELATIVE_PATH) + const raw = await fs.readFile(absPath, 'utf8') + const stats = JSON.parse(raw) as TaskStats + const misses: string[] = [] + for (const [name, s] of Object.entries(stats)) { + if (s.cache_miss > 0) misses.push(name) + } + // Turbopack sorts on the Rust side, but sort again here to be robust + // against the JSON parser's object iteration order. + misses.sort() + return misses + } + + if (isNextDev) { + it('snapshot of tasks with cache misses on warm dev restart', async () => { + // Cycle 1: the harness already auto-started; perform a request so the + // SSR/Node chunk work runs and gets persisted on shutdown. + async function runDevCycle() { + const browser = await next.browser('/') + // Wait for actual rendered content before declaring "ready". + await browser.elementByCss('p') + // Settle: the dev server keeps doing background work after first + // paint (HMR socket handshake, tail compilation). A fixed sleep + // is the least-bad option here since next.js doesn't expose + // turbopack's internal idle signal. If this proves flaky in CI, + // raise the value. + await waitFor(2000) + await browser.close() + } + await runDevCycle() + await stop() + + // Cycle 2: warm. + await next.start() + await runDevCycle() + await stop() + + const missed = await readMissedTaskNames() + expect(missed).toMatchInlineSnapshot(` + [ + "::update", + "::update", + "::update", + "next_api::project::Project::hmr_update", + "next_api::project::Project::hmr_version_state", + "next_napi_bindings::next_api::project::hmr_update_with_issues_operation", + "next_napi_bindings::next_api::project::project_hmr_update_operation", + "turbopack_core::version::VersionState::get", + ] + `) + }, 180_000) + } else { + it('snapshot of tasks with cache misses on warm build', async () => { + // In start mode the harness auto-runs `next build` then `next start`. + // The build is cycle 1. We stop the server (we don't care about it) + // and run a second build manually for the warm measurement. + await stop() + + const result = await (next as any).build() + if (result.exitCode !== 0) { + throw new Error(`next build exited with ${result.exitCode}`) + } + + const missed = await readMissedTaskNames() + expect(missed).toMatchInlineSnapshot(`[]`) + }, 240_000) + } + } +) diff --git a/turbopack/crates/turbo-tasks/src/task_statistics.rs b/turbopack/crates/turbo-tasks/src/task_statistics.rs index 0f5a6575cfd9..c0eaab14eb2e 100644 --- a/turbopack/crates/turbo-tasks/src/task_statistics.rs +++ b/turbopack/crates/turbo-tasks/src/task_statistics.rs @@ -71,9 +71,19 @@ impl Serialize for TaskStatistics { where S: Serializer, { - let mut map = serializer.serialize_map(Some(self.inner.len()))?; - for entry in &self.inner { - map.serialize_entry(entry.key().ty.global_name, entry.value())?; + // Sort by `global_name` so the emitted JSON is deterministic — the + // underlying `FxDashMap` has unspecified iteration order. The map is + // small (~1500 entries in practice), so the sort cost is negligible + // and not worth optimizing. + let mut entries: Vec<_> = self + .inner + .iter() + .map(|e| (e.key().ty.global_name, e.value().clone())) + .collect(); + entries.sort_unstable_by_key(|(name, _)| *name); + let mut map = serializer.serialize_map(Some(entries.len()))?; + for (name, stats) in &entries { + map.serialize_entry(name, stats)?; } map.end() } diff --git a/turbopack/crates/turbopack-browser/src/ecmascript/content.rs b/turbopack/crates/turbopack-browser/src/ecmascript/content.rs index d64336dbef3b..a6633a3e75f2 100644 --- a/turbopack/crates/turbopack-browser/src/ecmascript/content.rs +++ b/turbopack/crates/turbopack-browser/src/ecmascript/content.rs @@ -13,11 +13,15 @@ use turbopack_core::{ source_map::{GenerateSourceMap, SourceMapAsset}, version::{MergeableVersionedContent, Version, VersionedContent, VersionedContentMerger}, }; -use turbopack_ecmascript::{chunk::EcmascriptChunkContent, minify::minify, utils::StringifyJs}; +use turbopack_ecmascript::{ + chunk::{EcmascriptChunkContent, EcmascriptChunkContentEntries}, + minify::minify, + utils::StringifyJs, +}; use super::{ - chunk::EcmascriptBrowserChunk, content_entry::EcmascriptBrowserChunkContentEntries, - merged::merger::EcmascriptBrowserChunkContentMerger, version::EcmascriptBrowserChunkVersion, + chunk::EcmascriptBrowserChunk, merged::merger::EcmascriptBrowserChunkContentMerger, + version::EcmascriptBrowserChunkVersion, }; use crate::{ BrowserChunkingContext, @@ -51,8 +55,8 @@ impl EcmascriptBrowserChunkContent { } #[turbo_tasks::function] - pub fn entries(&self) -> Vc { - EcmascriptBrowserChunkContentEntries::new(*self.content) + pub fn entries(&self) -> Vc { + EcmascriptChunkContentEntries::new(*self.content) } } diff --git a/turbopack/crates/turbopack-browser/src/ecmascript/list/content.rs b/turbopack/crates/turbopack-browser/src/ecmascript/list/content.rs index db4658df4878..b0d34a5b5d2a 100644 --- a/turbopack/crates/turbopack-browser/src/ecmascript/list/content.rs +++ b/turbopack/crates/turbopack-browser/src/ecmascript/list/content.rs @@ -196,7 +196,10 @@ impl VersionedContent for EcmascriptDevChunkListContent { } #[turbo_tasks::function] - fn update(self: Vc, from_version: Vc>) -> Vc { - update_chunk_list(self, from_version) + async fn update( + self: ResolvedVc, + from_version: ResolvedVc>, + ) -> Result> { + update_chunk_list(self, from_version).await } } diff --git a/turbopack/crates/turbopack-browser/src/ecmascript/list/update.rs b/turbopack/crates/turbopack-browser/src/ecmascript/list/update.rs index 35770006da9b..379f0cebea86 100644 --- a/turbopack/crates/turbopack-browser/src/ecmascript/list/update.rs +++ b/turbopack/crates/turbopack-browser/src/ecmascript/list/update.rs @@ -47,9 +47,8 @@ impl ChunkListUpdate<'_> { } /// Computes the update of a chunk list from one version to another. -#[turbo_tasks::function] pub(super) async fn update_chunk_list( - content: Vc, + content: ResolvedVc, from_version: ResolvedVc>, ) -> Result> { let to_version = content.version(); diff --git a/turbopack/crates/turbopack-browser/src/ecmascript/mod.rs b/turbopack/crates/turbopack-browser/src/ecmascript/mod.rs index d80b785e8ba6..e06084fb07ab 100644 --- a/turbopack/crates/turbopack-browser/src/ecmascript/mod.rs +++ b/turbopack/crates/turbopack-browser/src/ecmascript/mod.rs @@ -1,6 +1,5 @@ pub(crate) mod chunk; pub(crate) mod content; -pub(crate) mod content_entry; pub(crate) mod evaluate; pub(crate) mod list; pub(crate) mod merged; diff --git a/turbopack/crates/turbopack-browser/src/ecmascript/version.rs b/turbopack/crates/turbopack-browser/src/ecmascript/version.rs index 777dbfca6310..cc8fe2929f02 100644 --- a/turbopack/crates/turbopack-browser/src/ecmascript/version.rs +++ b/turbopack/crates/turbopack-browser/src/ecmascript/version.rs @@ -1,12 +1,10 @@ use anyhow::Result; use turbo_rcstr::RcStr; -use turbo_tasks::{FxIndexMap, Vc, turbobail}; +use turbo_tasks::{FxIndexMap, TryJoinIterExt, Vc, turbobail}; use turbo_tasks_fs::FileSystemPath; use turbo_tasks_hash::{Xxh3Hash64Hasher, encode_base64}; use turbopack_core::{chunk::ModuleId, version::Version}; -use turbopack_ecmascript::chunk::EcmascriptChunkContent; - -use super::content_entry::EcmascriptBrowserChunkContentEntries; +use turbopack_ecmascript::chunk::{EcmascriptChunkContent, EcmascriptChunkContentEntries}; #[turbo_tasks::value(serialization = "skip")] pub(super) struct EcmascriptBrowserChunkVersion { @@ -29,12 +27,14 @@ impl EcmascriptBrowserChunkVersion { } else { turbobail!("chunk path {chunk_path} is not in client root {output_root}"); }; - let entries = EcmascriptBrowserChunkContentEntries::new(content).await?; - let mut entries_hashes = - FxIndexMap::with_capacity_and_hasher(entries.len(), Default::default()); - for (id, entry) in entries.iter() { - entries_hashes.insert(id.clone(), *entry.hash.await?); - } + let entries_hashes = EcmascriptChunkContentEntries::new(content) + .await? + .iter() + .map(async |(id, entry)| Ok((id.clone(), *entry.hash.await?))) + .try_join() + .await? + .into_iter() + .collect(); Ok(EcmascriptBrowserChunkVersion { chunk_path: chunk_path.to_string(), entries_hashes, diff --git a/turbopack/crates/turbopack-browser/src/ecmascript/content_entry.rs b/turbopack/crates/turbopack-ecmascript/src/chunk/content_entry.rs similarity index 80% rename from turbopack/crates/turbopack-browser/src/ecmascript/content_entry.rs rename to turbopack/crates/turbopack-ecmascript/src/chunk/content_entry.rs index 675745919680..4a785463d5bc 100644 --- a/turbopack/crates/turbopack-browser/src/ecmascript/content_entry.rs +++ b/turbopack/crates/turbopack-ecmascript/src/chunk/content_entry.rs @@ -5,7 +5,8 @@ use turbopack_core::{ chunk::{AsyncModuleInfo, ChunkItemExt, ModuleId}, code_builder::Code, }; -use turbopack_ecmascript::chunk::{ + +use crate::chunk::{ EcmascriptChunkContent, EcmascriptChunkItem, EcmascriptChunkItemExt, EcmascriptChunkItemOrBatchWithAsyncInfo, EcmascriptChunkItemWithAsyncInfo, }; @@ -18,18 +19,18 @@ use turbopack_ecmascript::chunk::{ /// computing updates. #[turbo_tasks::value] #[derive(Debug)] -pub struct EcmascriptDevChunkContentEntry { +pub struct EcmascriptChunkContentEntry { pub code: ResolvedVc, pub hash: ResolvedVc, } -impl EcmascriptDevChunkContentEntry { +impl EcmascriptChunkContentEntry { pub async fn new( chunk_item: ResolvedVc>, async_module_info: Option>, ) -> Result { let code = chunk_item.code(async_module_info).to_resolved().await?; - Ok(EcmascriptDevChunkContentEntry { + Ok(EcmascriptChunkContentEntry { code, hash: code.source_code_hash().to_resolved().await?, }) @@ -37,17 +38,16 @@ impl EcmascriptDevChunkContentEntry { } #[turbo_tasks::value(transparent)] -pub struct EcmascriptBrowserChunkContentEntries( - #[bincode(with = "turbo_bincode::indexmap")] - FxIndexMap, +pub struct EcmascriptChunkContentEntries( + #[bincode(with = "turbo_bincode::indexmap")] FxIndexMap, ); #[turbo_tasks::value_impl] -impl EcmascriptBrowserChunkContentEntries { +impl EcmascriptChunkContentEntries { #[turbo_tasks::function] pub async fn new( chunk_content: Vc, - ) -> Result> { + ) -> Result> { let chunk_content = chunk_content.await?; let entries: FxIndexMap<_, _> = chunk_content @@ -62,11 +62,8 @@ impl EcmascriptBrowserChunkContentEntries { }, ) => Either::Left(std::iter::once(( chunk_item.id().await?, - EcmascriptDevChunkContentEntry::new( - chunk_item, - async_info.map(|info| *info), - ) - .await?, + EcmascriptChunkContentEntry::new(chunk_item, async_info.map(|info| *info)) + .await?, ))), EcmascriptChunkItemOrBatchWithAsyncInfo::Batch(batch) => { let batch = batch.await?; @@ -77,7 +74,7 @@ impl EcmascriptBrowserChunkContentEntries { .map(|item| async move { Ok(( item.chunk_item.id().await?, - EcmascriptDevChunkContentEntry::new( + EcmascriptChunkContentEntry::new( item.chunk_item, item.async_info.map(|info| *info), ) diff --git a/turbopack/crates/turbopack-ecmascript/src/chunk/mod.rs b/turbopack/crates/turbopack-ecmascript/src/chunk/mod.rs index 6d3d6b589670..8e60d2d49e18 100644 --- a/turbopack/crates/turbopack-ecmascript/src/chunk/mod.rs +++ b/turbopack/crates/turbopack-ecmascript/src/chunk/mod.rs @@ -2,6 +2,7 @@ pub(crate) mod batch; pub(crate) mod chunk_type; pub(crate) mod code_and_ids; pub(crate) mod content; +pub(crate) mod content_entry; pub(crate) mod data; pub(crate) mod item; pub(crate) mod placeable; @@ -31,6 +32,7 @@ pub use self::{ chunk_type::EcmascriptChunkType, code_and_ids::{BatchGroupCodeAndIds, CodeAndIds, batch_group_code_and_ids, item_code_and_ids}, content::EcmascriptChunkContent, + content_entry::{EcmascriptChunkContentEntries, EcmascriptChunkContentEntry}, data::EcmascriptChunkData, item::{ EcmascriptChunkItem, EcmascriptChunkItemContent, EcmascriptChunkItemExt, diff --git a/turbopack/crates/turbopack-nodejs/Cargo.toml b/turbopack/crates/turbopack-nodejs/Cargo.toml index e245a93696c2..4e1b876dcfbe 100644 --- a/turbopack/crates/turbopack-nodejs/Cargo.toml +++ b/turbopack/crates/turbopack-nodejs/Cargo.toml @@ -21,6 +21,7 @@ workspace = true [dependencies] anyhow = { workspace = true } bincode = { workspace = true } +either = { workspace = true } indoc = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/turbopack/crates/turbopack-nodejs/src/ecmascript/node/content.rs b/turbopack/crates/turbopack-nodejs/src/ecmascript/node/content.rs index 251c0498521a..00453783514f 100644 --- a/turbopack/crates/turbopack-nodejs/src/ecmascript/node/content.rs +++ b/turbopack/crates/turbopack-nodejs/src/ecmascript/node/content.rs @@ -9,7 +9,11 @@ use turbopack_core::{ source_map::{GenerateSourceMap, SourceMapAsset}, version::{Update, Version, VersionedContent}, }; -use turbopack_ecmascript::{chunk::EcmascriptChunkContent, minify::minify, utils::StringifyJs}; +use turbopack_ecmascript::{ + chunk::{EcmascriptChunkContent, EcmascriptChunkContentEntries}, + minify::minify, + utils::StringifyJs, +}; use super::{ chunk::EcmascriptBuildNodeChunk, update::update_node_chunk, @@ -42,6 +46,11 @@ impl EcmascriptBuildNodeChunkContent { } .cell() } + + #[turbo_tasks::function] + pub(crate) fn entries(&self) -> Vc { + EcmascriptChunkContentEntries::new(*self.content) + } } #[turbo_tasks::value_impl] diff --git a/turbopack/crates/turbopack-nodejs/src/ecmascript/node/update.rs b/turbopack/crates/turbopack-nodejs/src/ecmascript/node/update.rs index 3b47a0bee1b2..93ea0387c5ca 100644 --- a/turbopack/crates/turbopack-nodejs/src/ecmascript/node/update.rs +++ b/turbopack/crates/turbopack-nodejs/src/ecmascript/node/update.rs @@ -105,8 +105,8 @@ pub(super) async fn update_node_chunk( return Ok(Update::None); } - let chunk_path = &to.chunk_path; - let chunk_update = update_ecmascript_node_chunk_content(&to, &from).await?; + let chunk_path = to.chunk_path.as_str(); + let chunk_update = update_ecmascript_node_chunk_content(content, &to, &from).await?; let mut merged_update = EcmascriptMergedUpdate::default(); @@ -171,6 +171,7 @@ enum NodeChunkUpdate { } async fn update_ecmascript_node_chunk_content( + content: Vc, to: &ReadRef, from: &ReadRef, ) -> Result { @@ -178,22 +179,23 @@ async fn update_ecmascript_node_chunk_content( let mut modified = FxIndexMap::default(); let mut deleted = FxIndexMap::default(); - // Build a map of module_id -> Vc for the "to" version - let mut to_entries: FxIndexMap> = FxIndexMap::default(); - for item in &to.chunk_items { - for (id, code) in item { - // Convert ReadRef to Vc - to_entries.insert(id.clone(), ReadRef::cell(code.clone())); - } - } + // Lazily resolve the entries map only when we actually need to ship code + // bytes for an added or modified module. For chunks that only have deletions + // (or no changes that need code beyond hashes), this avoids materializing + // any `Vc`. + let mut entries_ref = None; // Check for deleted and modified modules for (id, from_hash) in &from.entries_hashes { if let Some(to_hash) = to.entries_hashes.get(id) { if *to_hash != *from_hash { // Module was modified - if let Some(code) = to_entries.get(id) { - modified.insert(id.clone(), *code); + let entries = match &entries_ref { + Some(entries) => entries, + None => entries_ref.insert(content.entries().await?), + }; + if let Some(entry) = entries.get(id) { + modified.insert(id.clone(), *entry.code); } } } else { @@ -204,10 +206,14 @@ async fn update_ecmascript_node_chunk_content( // Check for added modules for (id, _hash) in &to.entries_hashes { - if !from.entries_hashes.contains_key(id) - && let Some(code) = to_entries.get(id) - { - added.insert(id.clone(), *code); + if !from.entries_hashes.contains_key(id) { + let entries = match &entries_ref { + Some(entries) => entries, + None => entries_ref.insert(content.entries().await?), + }; + if let Some(entry) = entries.get(id) { + added.insert(id.clone(), *entry.code); + } } } diff --git a/turbopack/crates/turbopack-nodejs/src/ecmascript/node/version.rs b/turbopack/crates/turbopack-nodejs/src/ecmascript/node/version.rs index 62d127481d3f..80f4241ca42e 100644 --- a/turbopack/crates/turbopack-nodejs/src/ecmascript/node/version.rs +++ b/turbopack/crates/turbopack-nodejs/src/ecmascript/node/version.rs @@ -1,18 +1,17 @@ use anyhow::Result; use turbo_rcstr::RcStr; -use turbo_tasks::{FxIndexMap, ReadRef, Vc, turbobail}; +use turbo_tasks::{FxIndexMap, TryJoinIterExt, Vc, turbobail}; use turbo_tasks_fs::FileSystemPath; use turbo_tasks_hash::{Xxh3Hash64Hasher, encode_base64}; use turbopack_core::{ chunk::{MinifyType, ModuleId}, version::Version, }; -use turbopack_ecmascript::chunk::{CodeAndIds, EcmascriptChunkContent}; +use turbopack_ecmascript::chunk::{EcmascriptChunkContent, EcmascriptChunkContentEntries}; #[turbo_tasks::value(serialization = "skip")] pub(super) struct EcmascriptBuildNodeChunkVersion { - pub(super) chunk_path: String, - pub(super) chunk_items: Vec>, + pub(super) chunk_path: RcStr, pub(super) minify_type: MinifyType, pub(super) entries_hashes: FxIndexMap, } @@ -33,24 +32,17 @@ impl EcmascriptBuildNodeChunkVersion { } else { turbobail!("chunk path {chunk_path} is not in client root {output_root}"); }; - let chunk_items = content.await?.chunk_item_code_and_ids().await?; - - // Compute per-module hashes for fine-grained HMR tracking - let mut entries_hashes = FxIndexMap::default(); - for item in &chunk_items { - for (module_id, code) in item { - let mut hasher = Xxh3Hash64Hasher::new(); - let source = code.source_code(); - hasher.write_ref(source); - let hash = hasher.finish(); - - entries_hashes.insert(module_id.clone(), hash); - } - } + let entries_hashes = EcmascriptChunkContentEntries::new(content) + .await? + .iter() + .map(async |(id, entry)| Ok((id.clone(), *entry.hash.await?))) + .try_join() + .await? + .into_iter() + .collect(); Ok(EcmascriptBuildNodeChunkVersion { - chunk_path: chunk_path.to_string(), - chunk_items, + chunk_path: chunk_path.into(), minify_type, entries_hashes, } From a07cba752f931023fe0add53ffb7ccb3f4b1a61a Mon Sep 17 00:00:00 2001 From: Luke Sandberg Date: Tue, 19 May 2026 15:49:34 -0700 Subject: [PATCH 6/7] [turbopack] Add trace metadata about the persist size (#93339) Record simple counters for how many items were persisted. This should be cheap enough to always do --- .../turbo-tasks-backend/src/backend/mod.rs | 25 ++++++++++++-- .../src/backing_storage.rs | 34 +++++++++++++++++-- .../src/kv_backing_storage.rs | 30 ++++++++++++---- 3 files changed, 76 insertions(+), 13 deletions(-) diff --git a/turbopack/crates/turbo-tasks-backend/src/backend/mod.rs b/turbopack/crates/turbo-tasks-backend/src/backend/mod.rs index c7f547215bcc..bf167ba22928 100644 --- a/turbopack/crates/turbo-tasks-backend/src/backend/mod.rs +++ b/turbopack/crates/turbo-tasks-backend/src/backend/mod.rs @@ -68,7 +68,7 @@ use crate::{ storage::Storage, storage_schema::{TaskStorage, TaskStorageAccessors}, }, - backing_storage::{BackingStorage, SnapshotItem, compute_task_type_hash}, + backing_storage::{BackingStorage, SnapshotItem, SnapshotMeta, compute_task_type_hash}, data::{ ActivenessState, CellRef, CollectibleRef, CollectiblesRef, Dirtyness, InProgressCellState, InProgressState, InProgressStateInner, OutputValue, TransientTask, @@ -1233,13 +1233,32 @@ impl TurboTasksBackendInner { } let persist_start = Instant::now(); - let _span = tracing::info_span!(parent: parent_span, "persist", reason = reason).entered(); + let span = tracing::info_span!( + parent: parent_span, + "persist", + reason = reason, + data_items= tracing::field::Empty, + meta_items= tracing::field::Empty, + task_cache_items= tracing::field::Empty, + next_task_id= tracing::field::Empty,) + .entered(); { // Tasks were already consumed by take_snapshot, so a future snapshot // would not re-persist them — returning an error signals to the caller // that further persist attempts would corrupt the task graph in storage. - self.backing_storage + let SnapshotMeta { + task_cache_items, + data_items, + meta_items, + max_next_task_id, + } = self + .backing_storage .save_snapshot(suspended_operations, task_snapshots)?; + span.record("data_items", data_items); + span.record("meta_items", meta_items); + span.record("task_cache_items", task_cache_items); + span.record("next_task_id", max_next_task_id); + #[cfg(feature = "print_cache_item_size")] { let mut task_cache_stats = task_cache_stats diff --git a/turbopack/crates/turbo-tasks-backend/src/backing_storage.rs b/turbopack/crates/turbo-tasks-backend/src/backing_storage.rs index ae10bc3c0406..7252b6a0bf3b 100644 --- a/turbopack/crates/turbo-tasks-backend/src/backing_storage.rs +++ b/turbopack/crates/turbo-tasks-backend/src/backing_storage.rs @@ -1,4 +1,4 @@ -use std::sync::Arc; +use std::{cmp::max, sync::Arc}; use anyhow::Result; use either::Either; @@ -82,6 +82,26 @@ pub trait BackingStorage: BackingStorageSealed { fn invalidate(&self, reason_code: &str) -> Result<()>; } +#[derive(Copy, Clone, Debug, Default)] +pub struct SnapshotMeta { + pub data_items: usize, + pub meta_items: usize, + pub task_cache_items: usize, + pub max_next_task_id: u32, +} + +impl SnapshotMeta { + /// Merge two snapshots, summing the counts and `max`'ing the task id + pub fn merge(&self, rhs: Self) -> Self { + Self { + data_items: self.data_items + rhs.data_items, + meta_items: self.meta_items + rhs.meta_items, + task_cache_items: self.task_cache_items + rhs.task_cache_items, + max_next_task_id: max(self.max_next_task_id, rhs.max_next_task_id), + } + } +} + /// Private methods used by [`BackingStorage`]. This trait is `pub` (because of the sealed-trait /// pattern), but should not be exported outside of the crate. /// @@ -91,7 +111,11 @@ pub trait BackingStorageSealed: 'static + Send + Sync { fn next_free_task_id(&self) -> Result; fn uncompleted_operations(&self) -> Result>; - fn save_snapshot(&self, operations: Vec>, snapshots: Vec) -> Result<()> + fn save_snapshot( + &self, + operations: Vec>, + snapshots: Vec, + ) -> Result where I: IntoIterator + Send + Sync; /// Returns all task IDs that match the given task type (hash collision candidates). @@ -160,7 +184,11 @@ where either::for_both!(self, this => this.uncompleted_operations()) } - fn save_snapshot(&self, operations: Vec>, snapshots: Vec) -> Result<()> + fn save_snapshot( + &self, + operations: Vec>, + snapshots: Vec, + ) -> Result where I: IntoIterator + Send + Sync, { diff --git a/turbopack/crates/turbo-tasks-backend/src/kv_backing_storage.rs b/turbopack/crates/turbo-tasks-backend/src/kv_backing_storage.rs index 660851928a39..d23511fc2747 100644 --- a/turbopack/crates/turbo-tasks-backend/src/kv_backing_storage.rs +++ b/turbopack/crates/turbo-tasks-backend/src/kv_backing_storage.rs @@ -19,7 +19,8 @@ use crate::{ GitVersionInfo, backend::{AnyOperation, SpecificTaskDataCategory, storage_schema::TaskStorage}, backing_storage::{ - BackingStorage, BackingStorageSealed, SnapshotItem, compute_task_type_hash_from_components, + BackingStorage, BackingStorageSealed, SnapshotItem, SnapshotMeta, + compute_task_type_hash_from_components, }, database::{ db_invalidation::{StartupCacheState, check_db_invalidation_and_cleanup, invalidate_db}, @@ -224,7 +225,11 @@ impl BackingStorageSealed get(&self.inner.database).context("Unable to read uncompleted operations from database") } - fn save_snapshot(&self, operations: Vec>, snapshots: Vec) -> Result<()> + fn save_snapshot( + &self, + operations: Vec>, + snapshots: Vec, + ) -> Result where I: IntoIterator + Send + Sync, { @@ -233,9 +238,12 @@ impl BackingStorageSealed { let _span = tracing::trace_span!("update task data").entered(); - let max_new_task_id = + let snapshot_meta = parallel::map_collect_owned::<_, _, Result>>(snapshots, |shard: I| { let mut max_new_task_id = 0; + let mut data_items = 0; + let mut meta_items = 0; + let mut task_cache_items = 0; for SnapshotItem { task_id, meta, @@ -251,6 +259,7 @@ impl BackingStorageSealed WriteBuffer::Borrowed(key), WriteBuffer::SmallVec(meta), )?; + meta_items += 1; } if let Some(data) = data { batch.put( @@ -258,6 +267,7 @@ impl BackingStorageSealed WriteBuffer::Borrowed(key), WriteBuffer::SmallVec(data), )?; + data_items += 1; } // Write task cache entry inline if this is a new task if let Some(task_type_hash) = task_type_hash { @@ -266,13 +276,19 @@ impl BackingStorageSealed WriteBuffer::Borrowed(&task_type_hash), WriteBuffer::Borrowed(key), )?; + task_cache_items += 1; max_new_task_id = max_new_task_id.max(*task_id); } } - Ok(max_new_task_id) + Ok(SnapshotMeta { + data_items, + meta_items, + task_cache_items, + max_next_task_id: max_new_task_id, + }) })? .into_iter() - .max() + .reduce(|t1, t2| t1.merge(t2)) .unwrap_or_default(); let span = tracing::trace_span!("flush task data").entered(); @@ -287,14 +303,14 @@ impl BackingStorageSealed )?; let mut next_task_id = get_next_free_task_id(&batch)?; - next_task_id = next_task_id.max(max_new_task_id + 1); + next_task_id = next_task_id.max(snapshot_meta.max_next_task_id + 1); save_infra(&batch, next_task_id, operations)?; { let _span = tracing::trace_span!("commit").entered(); batch.commit().context("Unable to commit operations")?; } - Ok(()) + Ok(snapshot_meta) } } From 338d59cc46e1b56b56ab88c6bbadab867dcbef49 Mon Sep 17 00:00:00 2001 From: "next-js-bot[bot]" <279046576+next-js-bot[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 23:41:21 +0000 Subject: [PATCH 7/7] v16.3.0-canary.24 --- lerna.json | 2 +- packages/create-next-app/package.json | 2 +- packages/eslint-config-next/package.json | 4 ++-- packages/eslint-plugin-internal/package.json | 2 +- packages/eslint-plugin-next/package.json | 2 +- packages/font/package.json | 2 +- packages/next-bundle-analyzer/package.json | 2 +- packages/next-codemod/package.json | 2 +- packages/next-env/package.json | 2 +- packages/next-mdx/package.json | 2 +- packages/next-playwright/package.json | 2 +- packages/next-plugin-storybook/package.json | 2 +- packages/next-polyfill-module/package.json | 2 +- packages/next-polyfill-nomodule/package.json | 2 +- packages/next-routing/package.json | 2 +- packages/next-rspack/package.json | 2 +- packages/next-swc/package.json | 2 +- packages/next/package.json | 14 +++++++------- packages/react-refresh-utils/package.json | 2 +- packages/third-parties/package.json | 4 ++-- pnpm-lock.yaml | 16 ++++++++-------- 21 files changed, 36 insertions(+), 36 deletions(-) diff --git a/lerna.json b/lerna.json index 6bdfc9ba1821..ca86b8a0aca4 100644 --- a/lerna.json +++ b/lerna.json @@ -15,5 +15,5 @@ "registry": "https://registry.npmjs.org/" } }, - "version": "16.3.0-canary.23" + "version": "16.3.0-canary.24" } \ No newline at end of file diff --git a/packages/create-next-app/package.json b/packages/create-next-app/package.json index e73a4367c9dd..f1299f37c586 100644 --- a/packages/create-next-app/package.json +++ b/packages/create-next-app/package.json @@ -1,6 +1,6 @@ { "name": "create-next-app", - "version": "16.3.0-canary.23", + "version": "16.3.0-canary.24", "keywords": [ "react", "next", diff --git a/packages/eslint-config-next/package.json b/packages/eslint-config-next/package.json index 4bbbb5397380..9eff5a3ccd05 100644 --- a/packages/eslint-config-next/package.json +++ b/packages/eslint-config-next/package.json @@ -1,6 +1,6 @@ { "name": "eslint-config-next", - "version": "16.3.0-canary.23", + "version": "16.3.0-canary.24", "description": "ESLint configuration used by Next.js.", "license": "MIT", "repository": { @@ -12,7 +12,7 @@ "dist" ], "dependencies": { - "@next/eslint-plugin-next": "16.3.0-canary.23", + "@next/eslint-plugin-next": "16.3.0-canary.24", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", diff --git a/packages/eslint-plugin-internal/package.json b/packages/eslint-plugin-internal/package.json index ae03a232210f..7f66a2ab6320 100644 --- a/packages/eslint-plugin-internal/package.json +++ b/packages/eslint-plugin-internal/package.json @@ -1,7 +1,7 @@ { "name": "@next/eslint-plugin-internal", "private": true, - "version": "16.3.0-canary.23", + "version": "16.3.0-canary.24", "description": "ESLint plugin for working on Next.js.", "exports": { ".": "./src/eslint-plugin-internal.js" diff --git a/packages/eslint-plugin-next/package.json b/packages/eslint-plugin-next/package.json index eaf8662db9e8..2443d6725559 100644 --- a/packages/eslint-plugin-next/package.json +++ b/packages/eslint-plugin-next/package.json @@ -1,6 +1,6 @@ { "name": "@next/eslint-plugin-next", - "version": "16.3.0-canary.23", + "version": "16.3.0-canary.24", "description": "ESLint plugin for Next.js.", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/font/package.json b/packages/font/package.json index 12bc21e94c5d..9c9b771b5b0a 100644 --- a/packages/font/package.json +++ b/packages/font/package.json @@ -1,7 +1,7 @@ { "name": "@next/font", "private": true, - "version": "16.3.0-canary.23", + "version": "16.3.0-canary.24", "repository": { "url": "vercel/next.js", "directory": "packages/font" diff --git a/packages/next-bundle-analyzer/package.json b/packages/next-bundle-analyzer/package.json index e5ccb617655d..3d0be1330434 100644 --- a/packages/next-bundle-analyzer/package.json +++ b/packages/next-bundle-analyzer/package.json @@ -1,6 +1,6 @@ { "name": "@next/bundle-analyzer", - "version": "16.3.0-canary.23", + "version": "16.3.0-canary.24", "main": "index.js", "types": "index.d.ts", "license": "MIT", diff --git a/packages/next-codemod/package.json b/packages/next-codemod/package.json index 2cc80858a406..2e44b5e51fa6 100644 --- a/packages/next-codemod/package.json +++ b/packages/next-codemod/package.json @@ -1,6 +1,6 @@ { "name": "@next/codemod", - "version": "16.3.0-canary.23", + "version": "16.3.0-canary.24", "license": "MIT", "repository": { "type": "git", diff --git a/packages/next-env/package.json b/packages/next-env/package.json index 369d9d17f947..de4637c2a0a6 100644 --- a/packages/next-env/package.json +++ b/packages/next-env/package.json @@ -1,6 +1,6 @@ { "name": "@next/env", - "version": "16.3.0-canary.23", + "version": "16.3.0-canary.24", "keywords": [ "react", "next", diff --git a/packages/next-mdx/package.json b/packages/next-mdx/package.json index e29cf35322fe..6c46c5a1da16 100644 --- a/packages/next-mdx/package.json +++ b/packages/next-mdx/package.json @@ -1,6 +1,6 @@ { "name": "@next/mdx", - "version": "16.3.0-canary.23", + "version": "16.3.0-canary.24", "main": "index.js", "license": "MIT", "repository": { diff --git a/packages/next-playwright/package.json b/packages/next-playwright/package.json index 946a455ba18a..dcae17df4936 100644 --- a/packages/next-playwright/package.json +++ b/packages/next-playwright/package.json @@ -1,6 +1,6 @@ { "name": "@next/playwright", - "version": "16.3.0-canary.23", + "version": "16.3.0-canary.24", "repository": { "url": "vercel/next.js", "directory": "packages/next-playwright" diff --git a/packages/next-plugin-storybook/package.json b/packages/next-plugin-storybook/package.json index 4b9c617a630e..0666b1088583 100644 --- a/packages/next-plugin-storybook/package.json +++ b/packages/next-plugin-storybook/package.json @@ -1,6 +1,6 @@ { "name": "@next/plugin-storybook", - "version": "16.3.0-canary.23", + "version": "16.3.0-canary.24", "repository": { "url": "vercel/next.js", "directory": "packages/next-plugin-storybook" diff --git a/packages/next-polyfill-module/package.json b/packages/next-polyfill-module/package.json index d2737829d41f..4041112bd048 100644 --- a/packages/next-polyfill-module/package.json +++ b/packages/next-polyfill-module/package.json @@ -1,6 +1,6 @@ { "name": "@next/polyfill-module", - "version": "16.3.0-canary.23", + "version": "16.3.0-canary.24", "description": "A standard library polyfill for ES Modules supporting browsers (Edge 16+, Firefox 60+, Chrome 61+, Safari 10.1+)", "main": "dist/polyfill-module.js", "license": "MIT", diff --git a/packages/next-polyfill-nomodule/package.json b/packages/next-polyfill-nomodule/package.json index 1c0e47132d25..6c5fdf7189da 100644 --- a/packages/next-polyfill-nomodule/package.json +++ b/packages/next-polyfill-nomodule/package.json @@ -1,6 +1,6 @@ { "name": "@next/polyfill-nomodule", - "version": "16.3.0-canary.23", + "version": "16.3.0-canary.24", "description": "A polyfill for non-dead, nomodule browsers.", "main": "dist/polyfill-nomodule.js", "license": "MIT", diff --git a/packages/next-routing/package.json b/packages/next-routing/package.json index a1ac3ef65bd8..2236563ae7d5 100644 --- a/packages/next-routing/package.json +++ b/packages/next-routing/package.json @@ -1,6 +1,6 @@ { "name": "@next/routing", - "version": "16.3.0-canary.23", + "version": "16.3.0-canary.24", "keywords": [ "react", "next", diff --git a/packages/next-rspack/package.json b/packages/next-rspack/package.json index bfd9560ee7e1..8328bd4e2437 100644 --- a/packages/next-rspack/package.json +++ b/packages/next-rspack/package.json @@ -1,6 +1,6 @@ { "name": "next-rspack", - "version": "16.3.0-canary.23", + "version": "16.3.0-canary.24", "repository": { "url": "vercel/next.js", "directory": "packages/next-rspack" diff --git a/packages/next-swc/package.json b/packages/next-swc/package.json index e3d80445c3b8..c0e505e1ab57 100644 --- a/packages/next-swc/package.json +++ b/packages/next-swc/package.json @@ -1,6 +1,6 @@ { "name": "@next/swc", - "version": "16.3.0-canary.23", + "version": "16.3.0-canary.24", "private": true, "files": [ "native/" diff --git a/packages/next/package.json b/packages/next/package.json index 0b796859b0b6..fb3cbfcb2caf 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -1,6 +1,6 @@ { "name": "next", - "version": "16.3.0-canary.23", + "version": "16.3.0-canary.24", "description": "The React Framework", "main": "./dist/server/next.js", "license": "MIT", @@ -101,7 +101,7 @@ ] }, "dependencies": { - "@next/env": "16.3.0-canary.23", + "@next/env": "16.3.0-canary.24", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", @@ -165,11 +165,11 @@ "@modelcontextprotocol/sdk": "1.18.1", "@mswjs/interceptors": "0.23.0", "@napi-rs/triples": "1.2.0", - "@next/font": "16.3.0-canary.23", - "@next/polyfill-module": "16.3.0-canary.23", - "@next/polyfill-nomodule": "16.3.0-canary.23", - "@next/react-refresh-utils": "16.3.0-canary.23", - "@next/swc": "16.3.0-canary.23", + "@next/font": "16.3.0-canary.24", + "@next/polyfill-module": "16.3.0-canary.24", + "@next/polyfill-nomodule": "16.3.0-canary.24", + "@next/react-refresh-utils": "16.3.0-canary.24", + "@next/swc": "16.3.0-canary.24", "@opentelemetry/api": "1.6.0", "@playwright/test": "1.58.2", "@rspack/core": "1.6.7", diff --git a/packages/react-refresh-utils/package.json b/packages/react-refresh-utils/package.json index cb5aeb402d82..a013c0c31a0d 100644 --- a/packages/react-refresh-utils/package.json +++ b/packages/react-refresh-utils/package.json @@ -1,6 +1,6 @@ { "name": "@next/react-refresh-utils", - "version": "16.3.0-canary.23", + "version": "16.3.0-canary.24", "description": "An experimental package providing utilities for React Refresh.", "repository": { "url": "vercel/next.js", diff --git a/packages/third-parties/package.json b/packages/third-parties/package.json index afb455428ff6..67b8bcb7226a 100644 --- a/packages/third-parties/package.json +++ b/packages/third-parties/package.json @@ -1,6 +1,6 @@ { "name": "@next/third-parties", - "version": "16.3.0-canary.23", + "version": "16.3.0-canary.24", "repository": { "url": "vercel/next.js", "directory": "packages/third-parties" @@ -27,7 +27,7 @@ "third-party-capital": "1.0.20" }, "devDependencies": { - "next": "16.3.0-canary.23", + "next": "16.3.0-canary.24", "outdent": "0.8.0", "prettier": "2.5.1", "typescript": "6.0.2" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d953ad71ae8d..e4b73758a95f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -986,7 +986,7 @@ importers: packages/eslint-config-next: dependencies: '@next/eslint-plugin-next': - specifier: 16.3.0-canary.23 + specifier: 16.3.0-canary.24 version: link:../eslint-plugin-next eslint: specifier: '>=9.0.0' @@ -1063,7 +1063,7 @@ importers: packages/next: dependencies: '@next/env': - specifier: 16.3.0-canary.23 + specifier: 16.3.0-canary.24 version: link:../next-env '@swc/helpers': specifier: 0.5.15 @@ -1184,19 +1184,19 @@ importers: specifier: 1.2.0 version: 1.2.0 '@next/font': - specifier: 16.3.0-canary.23 + specifier: 16.3.0-canary.24 version: link:../font '@next/polyfill-module': - specifier: 16.3.0-canary.23 + specifier: 16.3.0-canary.24 version: link:../next-polyfill-module '@next/polyfill-nomodule': - specifier: 16.3.0-canary.23 + specifier: 16.3.0-canary.24 version: link:../next-polyfill-nomodule '@next/react-refresh-utils': - specifier: 16.3.0-canary.23 + specifier: 16.3.0-canary.24 version: link:../react-refresh-utils '@next/swc': - specifier: 16.3.0-canary.23 + specifier: 16.3.0-canary.24 version: link:../next-swc '@opentelemetry/api': specifier: 1.6.0 @@ -1930,7 +1930,7 @@ importers: version: 1.0.20 devDependencies: next: - specifier: 16.3.0-canary.23 + specifier: 16.3.0-canary.24 version: link:../next outdent: specifier: 0.8.0