From 6eb0126dc008d325c61b41cd021e3a2e9d6d90e2 Mon Sep 17 00:00:00 2001 From: Niklas Mischkulnig <4586894+mischnic@users.noreply.github.com> Date: Wed, 29 Apr 2026 16:18:50 +0200 Subject: [PATCH 1/6] Turbopack: delete unused turbo-static crate (#93324) Added in https://github.com/vercel/turborepo/pull/8037 Nobody uses this, and it's also the only crate that uses the lsp-server dependency. --- Cargo.lock | 67 ---- turbopack/crates/turbo-static/.gitignore | 2 - turbopack/crates/turbo-static/Cargo.toml | 25 -- turbopack/crates/turbo-static/readme.md | 33 -- .../crates/turbo-static/src/call_resolver.rs | 166 ---------- .../crates/turbo-static/src/identifier.rs | 96 ------ .../crates/turbo-static/src/lsp_client.rs | 161 ---------- turbopack/crates/turbo-static/src/main.rs | 302 ------------------ turbopack/crates/turbo-static/src/visitor.rs | 274 ---------------- 9 files changed, 1126 deletions(-) delete mode 100644 turbopack/crates/turbo-static/.gitignore delete mode 100644 turbopack/crates/turbo-static/Cargo.toml delete mode 100644 turbopack/crates/turbo-static/readme.md delete mode 100644 turbopack/crates/turbo-static/src/call_resolver.rs delete mode 100644 turbopack/crates/turbo-static/src/identifier.rs delete mode 100644 turbopack/crates/turbo-static/src/lsp_client.rs delete mode 100644 turbopack/crates/turbo-static/src/main.rs delete mode 100644 turbopack/crates/turbo-static/src/visitor.rs diff --git a/Cargo.lock b/Cargo.lock index 2102ab754492..de2e72b70120 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2022,16 +2022,6 @@ version = "0.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a949c44fcacbbbb7ada007dc7acb34603dd97cd47de5d054f2b6493ecebb483" -[[package]] -name = "ctrlc" -version = "3.4.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46f93780a459b7d656ef7f071fe699c4d3d2cb201c4b24d085b6ddc505276e73" -dependencies = [ - "nix", - "windows-sys 0.59.0", -] - [[package]] name = "cty" version = "0.2.2" @@ -4300,31 +4290,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" -[[package]] -name = "lsp-server" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "248f65b78f6db5d8e1b1604b4098a28b43d21a8eb1deeca22b1c421b276c7095" -dependencies = [ - "crossbeam-channel", - "log", - "serde", - "serde_json", -] - -[[package]] -name = "lsp-types" -version = "0.95.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e34d33a8e9b006cd3fc4fe69a921affa097bae4bb65f76271f4644f9a334365" -dependencies = [ - "bitflags 1.3.2", - "serde", - "serde_json", - "serde_repr", - "url", -] - [[package]] name = "lz4_flex" version = "0.11.3" @@ -7113,17 +7078,6 @@ dependencies = [ "serde", ] -[[package]] -name = "serde_repr" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.104", -] - [[package]] name = "serde_spanned" version = "0.6.9" @@ -9859,27 +9813,6 @@ dependencies = [ "unty", ] -[[package]] -name = "turbo-static" -version = "0.1.0" -dependencies = [ - "bincode 1.3.3", - "clap", - "ctrlc", - "ignore", - "itertools 0.10.5", - "lsp-server", - "lsp-types", - "proc-macro2", - "rustc-hash 2.1.1", - "serde", - "serde_json", - "serde_path_to_error", - "syn 2.0.104", - "tracing", - "tracing-subscriber", -] - [[package]] name = "turbo-tasks" version = "0.1.0" diff --git a/turbopack/crates/turbo-static/.gitignore b/turbopack/crates/turbo-static/.gitignore deleted file mode 100644 index 32d96908cdc6..000000000000 --- a/turbopack/crates/turbo-static/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -call_resolver.bincode -graph.cypherl diff --git a/turbopack/crates/turbo-static/Cargo.toml b/turbopack/crates/turbo-static/Cargo.toml deleted file mode 100644 index 36b4b3d5da64..000000000000 --- a/turbopack/crates/turbo-static/Cargo.toml +++ /dev/null @@ -1,25 +0,0 @@ -[package] -name = "turbo-static" -version = "0.1.0" -edition = "2024" -license = "MIT" - -[dependencies] -bincode = "1.3.3" -clap = { workspace = true, features = ["derive"] } -ctrlc = "3.4.4" -ignore = "0.4.22" -itertools.workspace = true -lsp-server = "0.7.6" -lsp-types = "0.95.1" -proc-macro2 = { workspace = true, features = ["span-locations"] } -rustc-hash = { workspace = true } -serde = { workspace = true, features = ["derive"] } -serde_json.workspace = true -serde_path_to_error = "0.1.16" -syn = { version = "2", features = ["parsing", "full", "visit", "extra-traits"] } -tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } -tracing.workspace = true - -[lints] -workspace = true diff --git a/turbopack/crates/turbo-static/readme.md b/turbopack/crates/turbo-static/readme.md deleted file mode 100644 index ab9b74ab266d..000000000000 --- a/turbopack/crates/turbo-static/readme.md +++ /dev/null @@ -1,33 +0,0 @@ -# Turbo Static - -Leverages rust-analyzer to build a complete view into the static dependency -graph for your turbo tasks project. - -## How it works - -- find all occurrences of #[turbo_tasks::function] across all the packages you - want to query -- for each of the tasks we find, query rust analyzer to see which tasks call - them -- apply some very basis control flow analysis to determine whether the call is - made 1 time, 0/1 times, or 0+ times, corresponding to direct calls, - conditionals, or for loops -- produce a cypher file that can be loaded into a graph database to query the - static dependency graph - -## Usage - -This uses an in memory persisted database to cache rust-analyzer queries. -To reset the cache, pass the `--reindex` flag. Running will produce a -`graph.cypherl` file which can be loaded into any cypher-compatible database. - -```bash -# pass in the root folders you want to analyze. the system will recursively -# parse all rust code looking for turbo tasks functions -cargo run --release -- ../../../turbo ../../../next.js -# now you can load graph.cypherl into your database of choice, such as neo4j -docker run \ - --publish=7474:7474 --publish=7687:7687 \ - --volume=$HOME/neo4j/data:/data \ - neo4j -``` diff --git a/turbopack/crates/turbo-static/src/call_resolver.rs b/turbopack/crates/turbo-static/src/call_resolver.rs deleted file mode 100644 index a44418f5d667..000000000000 --- a/turbopack/crates/turbo-static/src/call_resolver.rs +++ /dev/null @@ -1,166 +0,0 @@ -use std::{fs::OpenOptions, path::PathBuf}; - -use rustc_hash::FxHashMap; - -use crate::{Identifier, IdentifierReference, lsp_client::RAClient}; - -/// A wrapper around a rust-analyzer client that can resolve call references. -/// This is quite expensive so we cache the results in an on-disk key-value -/// store. -pub struct CallResolver<'a> { - client: &'a mut RAClient, - state: FxHashMap>, - path: Option, -} - -/// On drop, serialize the state to disk -impl Drop for CallResolver<'_> { - fn drop(&mut self) { - let file = OpenOptions::new() - .create(true) - .truncate(false) - .write(true) - .open(self.path.as_ref().unwrap()) - .unwrap(); - bincode::serialize_into(file, &self.state).unwrap(); - } -} - -impl<'a> CallResolver<'a> { - pub fn new(client: &'a mut RAClient, path: Option) -> Self { - // load bincode-encoded FxHashMap from path - let state = path - .as_ref() - .and_then(|path| { - let file = OpenOptions::new() - .create(true) - .truncate(false) - .read(true) - .write(true) - .open(path) - .unwrap(); - let reader = std::io::BufReader::new(file); - bincode::deserialize_from::<_, FxHashMap>>( - reader, - ) - .inspect_err(|_| { - tracing::warn!("failed to load existing cache, restarting"); - }) - .ok() - }) - .unwrap_or_default(); - Self { - client, - state, - path, - } - } - - pub fn cached_count(&self) -> usize { - self.state.len() - } - - pub fn cleared(mut self) -> Self { - // delete file if exists and clear state - self.state = Default::default(); - if let Some(path) = self.path.as_ref() { - std::fs::remove_file(path).unwrap(); - } - self - } - - pub fn resolve(&mut self, ident: &Identifier) -> Vec { - if let Some(data) = self.state.get(ident) { - tracing::info!("skipping {}", ident); - return data.to_owned(); - }; - - tracing::info!("checking {}", ident); - - let mut count = 0; - let _response = loop { - let Some(response) = self.client.request(lsp_server::Request { - id: 1.into(), - method: "textDocument/prepareCallHierarchy".to_string(), - params: serde_json::to_value(&lsp_types::CallHierarchyPrepareParams { - text_document_position_params: lsp_types::TextDocumentPositionParams { - position: ident.range.start, - text_document: lsp_types::TextDocumentIdentifier { - uri: lsp_types::Url::from_file_path(&ident.path).unwrap(), - }, - }, - work_done_progress_params: lsp_types::WorkDoneProgressParams { - work_done_token: Some(lsp_types::ProgressToken::String( - "prepare".to_string(), - )), - }, - }) - .unwrap(), - }) else { - tracing::warn!("RA server shut down"); - return vec![]; - }; - - if let Some(Some(value)) = response.result.as_ref().map(|r| r.as_array()) { - if !value.is_empty() { - break value.to_owned(); - } - count += 1; - } - - // textDocument/prepareCallHierarchy will sometimes return an empty array so try - // at most 5 times - if count > 5 { - tracing::warn!("discovered isolated task {}", ident); - break vec![]; - } - - std::thread::sleep(std::time::Duration::from_secs(1)); - }; - - // callHierarchy/incomingCalls - let Some(response) = self.client.request(lsp_server::Request { - id: 1.into(), - method: "callHierarchy/incomingCalls".to_string(), - params: serde_json::to_value(lsp_types::CallHierarchyIncomingCallsParams { - partial_result_params: lsp_types::PartialResultParams::default(), - item: lsp_types::CallHierarchyItem { - name: ident.name.to_owned(), - kind: lsp_types::SymbolKind::FUNCTION, - data: None, - tags: None, - detail: None, - uri: lsp_types::Url::from_file_path(&ident.path).unwrap(), - range: ident.range, - selection_range: ident.range, - }, - work_done_progress_params: lsp_types::WorkDoneProgressParams { - work_done_token: Some(lsp_types::ProgressToken::String("prepare".to_string())), - }, - }) - .unwrap(), - }) else { - tracing::warn!("RA server shut down"); - return vec![]; - }; - - let links = if let Some(e) = response.error { - tracing::warn!("unable to resolve {}: {:?}", ident, e); - vec![] - } else { - let response: Result, _> = - serde_path_to_error::deserialize(response.result.unwrap()); - - response - .unwrap() - .into_iter() - .map(|i| i.into()) - .collect::>() - }; - - tracing::debug!("links: {:?}", links); - - self.state.insert(ident.to_owned(), links.clone()); - links - } -} diff --git a/turbopack/crates/turbo-static/src/identifier.rs b/turbopack/crates/turbo-static/src/identifier.rs deleted file mode 100644 index 428629585d52..000000000000 --- a/turbopack/crates/turbo-static/src/identifier.rs +++ /dev/null @@ -1,96 +0,0 @@ -use std::{fs, path::PathBuf}; - -use lsp_types::{CallHierarchyIncomingCall, CallHierarchyItem, Range}; -use serde::{Deserialize, Serialize}; - -/// A task that references another, with the range of the reference -#[derive(Hash, PartialEq, Eq, Deserialize, Serialize, Clone, Debug)] -pub struct IdentifierReference { - pub identifier: Identifier, - pub references: Vec, // the places where this identifier is used -} - -/// identifies a task by its file, and range in the file -#[derive(Hash, PartialEq, Eq, Deserialize, Serialize, Clone)] -pub struct Identifier { - pub path: String, - // technically you can derive this from the name and range but it's easier to just store it - pub name: String, - // post_transform_name: Option, - pub range: lsp_types::Range, -} - -impl Identifier { - /// check the span matches and the text matches - /// - /// `same_location` is used to check if the location of the identifier is - /// the same as the other - pub fn equals_ident(&self, other: &syn::Ident, match_location: bool) -> bool { - *other == self.name - && (!match_location - || (self.range.start.line == other.span().start().line as u32 - && self.range.start.character == other.span().start().column as u32)) - } - - /// We cannot use `item.name` here in all cases as, during testing, the name - /// does not always align with the exact text in the range. - fn get_name(item: &CallHierarchyItem) -> String { - // open file, find range inside, extract text - let file = fs::read_to_string(item.uri.path()).unwrap(); - let start = item.selection_range.start; - let end = item.selection_range.end; - file.lines() - .nth(start.line as usize) - .unwrap() - .chars() - .skip(start.character as usize) - .take(end.character as usize - start.character as usize) - .collect() - } -} - -impl From<(PathBuf, syn::Ident)> for Identifier { - fn from((path, ident): (PathBuf, syn::Ident)) -> Self { - Self { - path: path.display().to_string(), - name: ident.to_string(), - // post_transform_name: None, - range: Range { - start: lsp_types::Position { - line: ident.span().start().line as u32 - 1, - character: ident.span().start().column as u32, - }, - end: lsp_types::Position { - line: ident.span().end().line as u32 - 1, - character: ident.span().end().column as u32, - }, - }, - } - } -} - -impl From for IdentifierReference { - fn from(item: CallHierarchyIncomingCall) -> Self { - Self { - identifier: Identifier { - name: Identifier::get_name(&item.from), - // post_transform_name: Some(item.from.name), - path: item.from.uri.path().to_owned(), - range: item.from.selection_range, - }, - references: item.from_ranges, - } - } -} - -impl std::fmt::Debug for Identifier { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - std::fmt::Display::fmt(self, f) - } -} - -impl std::fmt::Display for Identifier { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}:{}#{}", self.path, self.range.start.line, self.name,) - } -} diff --git a/turbopack/crates/turbo-static/src/lsp_client.rs b/turbopack/crates/turbo-static/src/lsp_client.rs deleted file mode 100644 index 25d29a7efd26..000000000000 --- a/turbopack/crates/turbo-static/src/lsp_client.rs +++ /dev/null @@ -1,161 +0,0 @@ -use std::{path::PathBuf, process, sync::mpsc}; - -use lsp_server::Message; - -/// An LSP client for Rust Analyzer (RA) that launches it as a subprocess. -pub struct RAClient { - /// Handle to the client - handle: process::Child, - sender: Option>, - receiver: Option>, -} - -impl RAClient { - /// Create a new LSP client for Rust Analyzer. - pub fn new() -> Self { - let stdin = process::Stdio::piped(); - let stdout = process::Stdio::piped(); - let stderr = process::Stdio::inherit(); - - let child = process::Command::new("rust-analyzer") - .stdin(stdin) - .stdout(stdout) - .stderr(stderr) - // .env("RA_LOG", "info") - .env("RUST_BACKTRACE", "1") - .spawn() - .expect("Failed to start RA LSP server"); - Self { - handle: child, - sender: None, - receiver: None, - } - } - - pub fn start(&mut self, folders: &[PathBuf]) { - let stdout = self.handle.stdout.take().unwrap(); - let mut stdin = self.handle.stdin.take().unwrap(); - - let (writer_sender, writer_receiver) = mpsc::sync_channel::(0); - _ = std::thread::spawn(move || { - writer_receiver - .into_iter() - .try_for_each(|it| it.write(&mut stdin)) - }); - - let (reader_sender, reader_receiver) = mpsc::sync_channel::(0); - _ = std::thread::spawn(move || { - let mut reader = std::io::BufReader::new(stdout); - while let Ok(Some(msg)) = Message::read(&mut reader) { - reader_sender - .send(msg) - .expect("receiver was dropped, failed to send a message"); - } - }); - - self.sender = Some(writer_sender); - self.receiver = Some(reader_receiver); - - let workspace_paths = folders - .iter() - .map(|p| std::fs::canonicalize(p).unwrap()) - .map(|p| lsp_types::WorkspaceFolder { - name: p.file_name().unwrap().to_string_lossy().to_string(), - uri: lsp_types::Url::from_file_path(p).unwrap(), - }) - .collect::>(); - - _ = self.request(lsp_server::Request { - id: 1.into(), - method: "initialize".to_string(), - params: serde_json::to_value(lsp_types::InitializeParams { - workspace_folders: Some(workspace_paths), - process_id: Some(std::process::id()), - capabilities: lsp_types::ClientCapabilities { - workspace: Some(lsp_types::WorkspaceClientCapabilities { - workspace_folders: Some(true), - ..Default::default() - }), - ..Default::default() - }, - work_done_progress_params: lsp_types::WorkDoneProgressParams { - work_done_token: Some(lsp_types::ProgressToken::String("prepare".to_string())), - }, - // we use workspace_folders so root_path and root_uri can be - // empty - ..Default::default() - }) - .unwrap(), - }); - - self.notify(lsp_server::Notification { - method: "initialized".to_string(), - params: serde_json::to_value(lsp_types::InitializedParams {}).unwrap(), - }); - } - - /// Send an LSP request to the server. This returns an option - /// in the case of an error such as the server being shut down - /// from pressing `Ctrl+C`. - pub fn request(&mut self, message: lsp_server::Request) -> Option { - tracing::debug!("sending {:?}", message); - self.sender - .as_mut() - .unwrap() - .send(Message::Request(message)) - .ok()?; - - loop { - match self.receiver.as_mut().unwrap().recv() { - Ok(lsp_server::Message::Response(response)) => { - tracing::debug!("received {:?}", response); - return Some(response); - } - Ok(m) => tracing::trace!("unexpected message: {:?}", m), - Err(_) => { - tracing::trace!("error receiving message"); - return None; - } - } - } - } - - pub fn notify(&mut self, message: lsp_server::Notification) { - self.sender - .as_mut() - .unwrap() - .send(Message::Notification(message)) - .expect("failed to send message"); - } -} - -impl Drop for RAClient { - fn drop(&mut self) { - if self.sender.is_some() { - let Some(resp) = self.request(lsp_server::Request { - id: 1.into(), - method: "shutdown".to_string(), - params: serde_json::to_value(()).unwrap(), - }) else { - return; - }; - - if resp.error.is_none() { - tracing::info!("shutting down RA LSP server"); - self.notify(lsp_server::Notification { - method: "exit".to_string(), - params: serde_json::to_value(()).unwrap(), - }); - self.handle - .wait() - .expect("failed to wait for RA LSP server"); - tracing::info!("shut down RA LSP server"); - } else { - tracing::error!("failed to shutdown RA LSP server: {:#?}", resp); - } - } - - self.sender = None; - self.receiver = None; - } -} diff --git a/turbopack/crates/turbo-static/src/main.rs b/turbopack/crates/turbo-static/src/main.rs deleted file mode 100644 index 73d8f8b43b4a..000000000000 --- a/turbopack/crates/turbo-static/src/main.rs +++ /dev/null @@ -1,302 +0,0 @@ -use std::{ - error::Error, - fs, - path::PathBuf, - sync::{ - Arc, - atomic::{AtomicBool, Ordering}, - }, -}; - -use call_resolver::CallResolver; -use clap::Parser; -use identifier::{Identifier, IdentifierReference}; -use itertools::Itertools; -use rustc_hash::{FxHashMap, FxHashSet}; -use syn::visit::Visit; -use visitor::CallingStyleVisitor; - -use crate::visitor::CallingStyle; - -mod call_resolver; -mod identifier; -mod lsp_client; -mod visitor; - -#[derive(Parser)] -struct Opt { - #[clap(required = true)] - paths: Vec, - - /// reparse all files - #[clap(long)] - reparse: bool, - - /// reindex all files - #[clap(long)] - reindex: bool, -} - -fn main() -> Result<(), Box> { - tracing_subscriber::fmt::init(); - let opt = Opt::parse(); - - let mut connection = lsp_client::RAClient::new(); - connection.start(&opt.paths); - - let call_resolver = CallResolver::new(&mut connection, Some("call_resolver.bincode".into())); - let mut call_resolver = if opt.reindex { - call_resolver.cleared() - } else { - call_resolver - }; - - let halt = Arc::new(AtomicBool::new(false)); - let halt_clone = halt.clone(); - ctrlc::set_handler({ - move || { - halt_clone.store(true, Ordering::SeqCst); - } - })?; - - tracing::info!("getting tasks"); - let mut tasks = get_all_tasks(&opt.paths); - let dep_tree = resolve_tasks(&mut tasks, &mut call_resolver, halt.clone()); - let concurrency = resolve_concurrency(&tasks, &dep_tree, halt.clone()); - - write_dep_tree(&tasks, concurrency, std::path::Path::new("graph.cypherl")); - - if halt.load(Ordering::Relaxed) { - tracing::info!("ctrl-c detected, exiting"); - } - - Ok(()) -} - -/// search the given folders recursively and attempt to find all tasks inside -#[tracing::instrument(skip_all)] -fn get_all_tasks(folders: &[PathBuf]) -> FxHashMap> { - let mut out = FxHashMap::default(); - - for folder in folders { - let walker = ignore::Walk::new(folder); - for entry in walker { - let entry = entry.unwrap(); - let rs_file = if let Some(true) = entry.file_type().map(|t| t.is_file()) { - let path = entry.path(); - let ext = path.extension().unwrap_or_default(); - if ext == "rs" { - std::fs::canonicalize(path).unwrap() - } else { - continue; - } - } else { - continue; - }; - - let file = fs::read_to_string(&rs_file).unwrap(); - let lines = file.lines(); - let mut occurrences = vec![]; - - tracing::debug!("processing {}", rs_file.display()); - - for ((_, line), (line_no, _)) in lines.enumerate().tuple_windows() { - if line.contains("turbo_tasks::function") { - tracing::debug!("found at {:?}:L{}", rs_file, line_no); - occurrences.push(line_no + 1); - } - } - - if occurrences.is_empty() { - continue; - } - - // parse the file using syn and get the span of the functions - let file = syn::parse_file(&file).unwrap(); - let occurrences_count = occurrences.len(); - let mut visitor = visitor::TaskVisitor::new(); - syn::visit::visit_file(&mut visitor, &file); - if visitor.results.len() != occurrences_count { - tracing::warn!( - "file {:?} passed the heuristic with {:?} but the visitor found {:?}", - rs_file, - occurrences_count, - visitor.results.len() - ); - } - - out.extend( - visitor - .results - .into_iter() - .map(move |(ident, tags)| ((rs_file.clone(), ident).into(), tags)), - ) - } - } - - out -} - -/// Given a list of tasks, get all the tasks that call that one -fn resolve_tasks( - tasks: &mut FxHashMap>, - client: &mut CallResolver, - halt: Arc, -) -> FxHashMap> { - tracing::info!( - "found {} tasks, of which {} cached", - tasks.len(), - client.cached_count() - ); - - let mut unresolved = tasks.keys().cloned().collect::>(); - let mut resolved = FxHashMap::default(); - - while let Some(top) = unresolved.iter().next().cloned() { - unresolved.remove(&top); - - let callers = client.resolve(&top); - - // add all non-task callers to the unresolved list if they are not in the - // resolved list - for caller in callers.iter() { - if !resolved.contains_key(&caller.identifier) - && !unresolved.contains(&caller.identifier) - { - tracing::debug!("adding {} to unresolved", caller.identifier); - unresolved.insert(caller.identifier.to_owned()); - } - } - resolved.insert(top.to_owned(), callers); - - if halt.load(Ordering::Relaxed) { - break; - } - } - - resolved -} - -/// given a map of tasks and functions that call it, produce a map of tasks and -/// those tasks that it calls -/// -/// returns a list of pairs with a task, the task that calls it, and the calling -/// style -fn resolve_concurrency( - task_list: &FxHashMap>, - dep_tree: &FxHashMap>, // pairs of tasks and call trees - halt: Arc, -) -> Vec<(Identifier, Identifier, CallingStyle)> { - // println!("{:?}", dep_tree); - // println!("{:#?}", task_list); - - let mut edges = vec![]; - - for (ident, references) in dep_tree { - for reference in references { - #[allow(clippy::map_entry)] // This doesn't insert into dep_tree, so entry isn't useful - if !dep_tree.contains_key(&reference.identifier) { - // this is a task that is not in the task list - // so we can't resolve it - tracing::error!("missing task for {}: {}", ident, reference.identifier); - for task in task_list.keys() { - if task.name == reference.identifier.name { - // we found a task that is not in the task list - // so we can't resolve it - tracing::trace!("- found {}", task); - continue; - } - } - continue; - } else { - // load the source file and get the calling style - let target = IdentifierReference { - identifier: ident.clone(), - references: reference.references.clone(), - }; - let mut visitor = CallingStyleVisitor::new(target); - tracing::info!("looking for {} from {}", ident, reference.identifier); - let file = - syn::parse_file(&fs::read_to_string(&reference.identifier.path).unwrap()) - .unwrap(); - visitor.visit_file(&file); - - edges.push(( - ident.clone(), - reference.identifier.clone(), - visitor.result().unwrap_or(CallingStyle::Once), - )); - } - - if halt.load(Ordering::Relaxed) { - break; - } - } - } - - // parse each fn between parent and child and get the max calling style - - edges -} - -/// Write the dep tree into the given file using cypher syntax -fn write_dep_tree( - task_list: &FxHashMap>, - dep_tree: Vec<(Identifier, Identifier, CallingStyle)>, - out: &std::path::Path, -) { - use std::io::Write; - - let mut node_ids = FxHashMap::default(); - let mut counter = 0; - - let mut file = std::fs::File::create(out).unwrap(); - - let empty = vec![]; - - // collect all tasks as well as all intermediate nodes - // tasks come last to ensure the tags are preserved - let node_list = dep_tree - .iter() - .flat_map(|(dest, src, _)| [(src, &empty), (dest, &empty)]) - .chain(task_list) - .collect::>(); - - for (ident, tags) in node_list { - counter += 1; - - let label = if !task_list.contains_key(ident) { - "Function" - } else if tags.contains(&"fs".to_string()) || tags.contains(&"network".to_string()) { - "ImpureTask" - } else { - "Task" - }; - - _ = writeln!( - file, - "CREATE (n_{}:{} {{name: '{}', file: '{}', line: {}, tags: [{}]}})", - counter, - label, - ident.name, - ident.path, - ident.range.start.line, - tags.iter().map(|t| format!("\"{t}\"")).join(",") - ); - node_ids.insert(ident, counter); - } - - for (dest, src, style) in &dep_tree { - let style = match style { - CallingStyle::Once => "ONCE", - CallingStyle::ZeroOrOnce => "ZERO_OR_ONCE", - CallingStyle::ZeroOrMore => "ZERO_OR_MORE", - CallingStyle::OneOrMore => "ONE_OR_MORE", - }; - - let src_id = *node_ids.get(src).unwrap(); - let dst_id = *node_ids.get(dest).unwrap(); - - _ = writeln!(file, "CREATE (n_{src_id})-[:{style}]->(n_{dst_id})",); - } -} diff --git a/turbopack/crates/turbo-static/src/visitor.rs b/turbopack/crates/turbo-static/src/visitor.rs deleted file mode 100644 index e6b260c9e65f..000000000000 --- a/turbopack/crates/turbo-static/src/visitor.rs +++ /dev/null @@ -1,274 +0,0 @@ -//! A visitor that traverses the AST and collects all functions or methods that -//! are annotated with `#[turbo_tasks::function]`. - -use std::{collections::VecDeque, ops::Add}; - -use lsp_types::Range; -use syn::{Expr, Meta, visit::Visit}; - -use crate::identifier::Identifier; - -pub struct TaskVisitor { - /// the list of results as pairs of an identifier and its tags - pub results: Vec<(syn::Ident, Vec)>, -} - -impl TaskVisitor { - pub fn new() -> Self { - Self { - results: Default::default(), - } - } -} - -impl Visit<'_> for TaskVisitor { - #[tracing::instrument(skip_all)] - fn visit_item_fn(&mut self, i: &syn::ItemFn) { - if let Some(tags) = extract_tags(i.attrs.iter()) { - tracing::trace!("L{}: {}", i.sig.ident.span().start().line, i.sig.ident,); - self.results.push((i.sig.ident.clone(), tags)); - } - } - - #[tracing::instrument(skip_all)] - fn visit_impl_item_fn(&mut self, i: &syn::ImplItemFn) { - if let Some(tags) = extract_tags(i.attrs.iter()) { - tracing::trace!("L{}: {}", i.sig.ident.span().start().line, i.sig.ident,); - self.results.push((i.sig.ident.clone(), tags)); - } - } -} - -fn extract_tags<'a>(mut meta: impl Iterator) -> Option> { - meta.find_map(|a| match &a.meta { - // path has two segments, turbo_tasks and function - Meta::Path(path) if path.segments.len() == 2 => { - let first = &path.segments[0]; - let second = &path.segments[1]; - (first.ident == "turbo_tasks" && second.ident == "function").then(std::vec::Vec::new) - } - Meta::List(list) if list.path.segments.len() == 2 => { - let first = &list.path.segments[0]; - let second = &list.path.segments[1]; - if first.ident != "turbo_tasks" || second.ident != "function" { - return None; - } - - // collect ident tokens as args - let tags: Vec<_> = list - .tokens - .clone() - .into_iter() - .filter_map(|t| { - if let proc_macro2::TokenTree::Ident(ident) = t { - Some(ident.to_string()) - } else { - None - } - }) - .collect(); - - Some(tags) - } - _ => { - tracing::trace!("skipping unknown annotation"); - None - } - }) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd)] -pub enum CallingStyle { - Once = 0b0010, - ZeroOrOnce = 0b0011, - ZeroOrMore = 0b0111, - OneOrMore = 0b0110, -} - -impl CallingStyle { - fn bitset(self) -> u8 { - self as u8 - } -} - -impl Add for CallingStyle { - type Output = Self; - - /// Add two calling styles together to determine the calling style of the - /// target function within the source function. - /// - /// Consider it as a bitset over properties. - /// - 0b000: Nothing - /// - 0b001: Zero - /// - 0b010: Once - /// - 0b011: Zero Or Once - /// - 0b100: More Than Once - /// - 0b101: Zero Or More Than Once (?) - /// - 0b110: Once Or More - /// - 0b111: Zero Or More - /// - /// Note that zero is not a valid calling style. - fn add(self, rhs: Self) -> Self { - let left = self.bitset(); - let right = rhs.bitset(); - - // we treat this as a bitset under addition - #[allow(clippy::suspicious_arithmetic_impl)] - match left | right { - 0b0010 => CallingStyle::Once, - 0b011 => CallingStyle::ZeroOrOnce, - 0b0111 => CallingStyle::ZeroOrMore, - 0b0110 => CallingStyle::OneOrMore, - // the remaining 4 (null, zero, more than once, zero or more than once) - // are unreachable because we don't detect 'zero' or 'more than once' - _ => unreachable!(), - } - } -} - -pub struct CallingStyleVisitor { - pub reference: crate::IdentifierReference, - state: VecDeque, - halt: bool, -} - -impl CallingStyleVisitor { - /// Create a new visitor that will traverse the AST and determine the - /// calling style of the target function within the source function. - pub fn new(reference: crate::IdentifierReference) -> Self { - Self { - reference, - state: Default::default(), - halt: false, - } - } - - pub fn result(self) -> Option { - self.state - .into_iter() - .map(|b| match b { - CallingStyleVisitorState::Block => CallingStyle::Once, - CallingStyleVisitorState::Loop => CallingStyle::ZeroOrMore, - CallingStyleVisitorState::If => CallingStyle::ZeroOrOnce, - CallingStyleVisitorState::Closure => CallingStyle::ZeroOrMore, - }) - .reduce(|a, b| a + b) - } -} - -#[derive(Debug, Clone, Copy)] -enum CallingStyleVisitorState { - Block, - Loop, - If, - Closure, -} - -impl Visit<'_> for CallingStyleVisitor { - fn visit_item_fn(&mut self, i: &'_ syn::ItemFn) { - self.state.push_back(CallingStyleVisitorState::Block); - syn::visit::visit_item_fn(self, i); - if !self.halt { - self.state.pop_back(); - } - } - - fn visit_impl_item_fn(&mut self, i: &'_ syn::ImplItemFn) { - self.state.push_back(CallingStyleVisitorState::Block); - syn::visit::visit_impl_item_fn(self, i); - if !self.halt { - self.state.pop_back(); - } - } - - fn visit_expr_loop(&mut self, i: &'_ syn::ExprLoop) { - self.state.push_back(CallingStyleVisitorState::Loop); - syn::visit::visit_expr_loop(self, i); - if !self.halt { - self.state.pop_back(); - } - } - - fn visit_expr_for_loop(&mut self, i: &'_ syn::ExprForLoop) { - self.state.push_back(CallingStyleVisitorState::Loop); - syn::visit::visit_expr_for_loop(self, i); - if !self.halt { - self.state.pop_back(); - } - } - - fn visit_expr_if(&mut self, i: &'_ syn::ExprIf) { - self.state.push_back(CallingStyleVisitorState::If); - syn::visit::visit_expr_if(self, i); - if !self.halt { - self.state.pop_back(); - } - } - - fn visit_expr_closure(&mut self, i: &'_ syn::ExprClosure) { - self.state.push_back(CallingStyleVisitorState::Closure); - syn::visit::visit_expr_closure(self, i); - if !self.halt { - self.state.pop_back(); - } - } - - fn visit_expr_call(&mut self, i: &'_ syn::ExprCall) { - syn::visit::visit_expr_call(self, i); - if let Expr::Path(p) = i.func.as_ref() - && let Some(last) = p.path.segments.last() - && is_match( - &self.reference.identifier, - &last.ident, - &self.reference.references, - ) - { - self.halt = true; - } - } - - // to validate this, we first check if the name is the same and then compare it - // against any of the references we are holding - fn visit_expr_method_call(&mut self, i: &'_ syn::ExprMethodCall) { - if is_match( - &self.reference.identifier, - &i.method, - &self.reference.references, - ) { - self.halt = true; - } - - syn::visit::visit_expr_method_call(self, i); - } -} - -/// Check if some ident referenced by `check` is calling the `target` by -/// looking it up in the list of known `ranges`. -fn is_match(target: &Identifier, check: &syn::Ident, ranges: &[Range]) -> bool { - if target.equals_ident(check, false) { - let span = check.span(); - // syn is 1-indexed, range is not - for reference in ranges { - if reference.start.line != span.start().line as u32 - 1 { - continue; - } - - if reference.start.character != span.start().column as u32 { - continue; - } - - if reference.end.line != span.end().line as u32 - 1 { - continue; - } - - if reference.end.character != span.end().column as u32 { - continue; - } - - // match, just exit the visitor - return true; - } - } - - false -} From 00a671b78a093e89ac972411aa3b77eb76822e4c Mon Sep 17 00:00:00 2001 From: Luke Sandberg Date: Wed, 29 Apr 2026 08:02:27 -0700 Subject: [PATCH 2/6] fix a snapshot encoding error that could happen under concurrent mutation and snapshotting (#92658) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What? Fixes a snapshot encoding panic introduced by #89370 in `turbopack/crates/turbo-tasks-backend/src/backend/storage.rs`. ## Why? #89370 refactored snapshot iteration and in doing so introduced two incorrect assertions in `SnapshotShardIter::next` for tasks in the `modified` list that were re-modified during snapshot iteration: 1. **`.expect("snapshot entry for modified_during_snapshot task must contain a value")`** — this panics in the `(true, false)` branch of `track_modification_internal`, where a task was modified in one category before the snapshot and then modified in a *different* category during iteration. The second modification stores a `None` entry in `snapshots` (because there was no pre-existing data to copy for that category), but the iterator unconditionally unwrapped `Some`. 2. **`debug_assert!(!inner.flags.any_modified())`** — after clearing the live `data_modified`/`meta_modified` flags and before promoting `_during_snapshot` flags, the old code asserted that no modified flags remained. This fires in the `(true, true)` branch because clearing the flags happens after the snapshot copy was taken, so the assert races with the flag state. ## How? - Unify the `direct_snapshots` fast-path and the `modified` list into a single path that handles both `(true, true)` and `(true, false)` cases gracefully: if `any_modified_during_snapshot()` is set, check the `snapshots` map — use the `Some(copy)` if present, otherwise fall back to live data (which is correct for the `None`/first-time-modified case). - Add a regression test covering the `(true, false)` branch (`modify_different_category_during_snapshot`). --- .../src/backend/storage.rs | 203 ++++++++++-------- 1 file changed, 113 insertions(+), 90 deletions(-) diff --git a/turbopack/crates/turbo-tasks-backend/src/backend/storage.rs b/turbopack/crates/turbo-tasks-backend/src/backend/storage.rs index 76270608f059..0fd40df481b1 100644 --- a/turbopack/crates/turbo-tasks-backend/src/backend/storage.rs +++ b/turbopack/crates/turbo-tasks-backend/src/backend/storage.rs @@ -213,7 +213,6 @@ impl Storage { if modified_count == 0 { return None; } - let mut direct_snapshots: Vec<(TaskId, Box)> = Vec::new(); let mut modified = Vec::with_capacity(modified_count as usize); { let shard_guard = shard.read(); @@ -229,44 +228,26 @@ impl Storage { // accompanied by modified flags (set_persistent_task_type calls // track_modification), so any_modified() is sufficient. if flags.any_modified() { - debug_assert!( - !key.is_transient(), - "found a modified transient task: {:?}", - shared_value.get().get_persistent_task_type() - ); - - if flags.any_modified_during_snapshot() { - // Task was modified during snapshot mode, so a snapshot - // copy must exist in the snapshots map (created by the - // (true, true) case in track_modification_internal). - // Remove the entry entirely so end_snapshot doesn't - // double-process this task. When iterating in `next` we will - // re-synchronize the task flags. - let (_, snapshot) = self.snapshots.remove(key).expect( - "task with modified_during_snapshot must have a snapshots entry", + if key.is_transient() { + debug_assert!( + false, + "found a modified transient task: {:?}", + shared_value.get().get_persistent_task_type() ); - let snapshot = snapshot.expect( - "snapshot entry for modified_during_snapshot task must contain a \ - value", - ); - direct_snapshots.push((*key, snapshot)); - } else { - modified.push(*key); + continue; } + + modified.push(*key); } } // Safety: shard_guard must outlive the iterator. drop(shard_guard); } - // Early return for shards with no entries at all - if direct_snapshots.is_empty() && modified.is_empty() { - return None; - } + debug_assert!(!modified.is_empty()); Some(SnapshotShard { shard_idx, - direct_snapshots, modified, storage: self, process, @@ -568,7 +549,6 @@ impl Drop for SnapshotGuard<'_> { pub struct SnapshotShard<'l, P> { shard_idx: usize, - direct_snapshots: Vec<(TaskId, Box)>, modified: Vec, storage: &'l Storage, process: &'l P, @@ -606,16 +586,27 @@ where type Item = SnapshotItem; fn next(&mut self) -> Option { - // direct_snapshots: these tasks had a snapshot copy created by - // track_modification. We encode from the owned snapshot copy, - // clear the stale modified flags, and promote any _during_snapshot - // flags so the task stays dirty for the next cycle. - if let Some((task_id, snapshot)) = self.shard.direct_snapshots.pop() { - let item = (self.shard.process)(task_id, &snapshot, &mut self.buffer); - // Clear pre-snapshot flags. Since we removed this task's entry from the - // snapshots map in take_snapshot, end_snapshot won't see it, so we must - // promote here. + if let Some(task_id) = self.shard.modified.pop() { let mut inner = self.shard.storage.map.get_mut(&task_id).unwrap(); + // If the task was re-modified during snapshot, the snapshots map may + // hold a pre-modification copy we must serialize instead of the live + // data. Remove the entry so end_snapshot doesn't double-promote it; + // we promote manually below. + let item = if inner.flags.any_modified_during_snapshot() { + match self.shard.storage.snapshots.remove(&task_id) { + Some((_, Some(snapshot))) => { + (self.shard.process)(task_id, &snapshot, &mut self.buffer) + } + Some((_, None)) | None => { + (self.shard.process)(task_id, &inner, &mut self.buffer) + } + } + } else { + (self.shard.process)(task_id, &inner, &mut self.buffer) + }; + // Clear the modified flags that were captured into the snapshot copy, + // then promote modified_during_snapshot → modified so the task stays + // dirty for the next snapshot cycle. inner.flags.set_data_modified(false); inner.flags.set_meta_modified(false); inner.flags.set_new_task(false); @@ -624,45 +615,6 @@ where .promote_during_snapshot_flags(&mut inner, self.shard.shard_idx); return Some(item); } - // modified tasks: acquire a write lock to encode and clear flags in one pass. - if let Some(task_id) = self.shard.modified.pop() { - let mut inner = self.shard.storage.map.get_mut(&task_id).unwrap(); - if !inner.flags.any_modified_during_snapshot() { - let item = (self.shard.process)(task_id, &inner, &mut self.buffer); - inner.flags.set_data_modified(false); - inner.flags.set_meta_modified(false); - inner.flags.set_new_task(false); - return Some(item); - } else { - // Task was modified again during snapshot mode. A snapshot copy was - // created in track_modification_internal. Remove it and encode it. - // end_snapshot must not also process it, so we take it out of the map. - // snapshots is a separate DashMap from map, so holding `inner` across - // the remove and encode is safe — no lock ordering issue. - let snapshot = self - .shard - .storage - .snapshots - .remove(&task_id) - .expect("The snapshot bit was set, so it must be in Snapshot state") - .1 - .expect( - "snapshot entry for modified_during_snapshot task must contain a value", - ); - - let item = (self.shard.process)(task_id, &snapshot, &mut self.buffer); - // Clear the modified flags that were captured into the snapshot copy, - // then promote modified_during_snapshot → modified so the task stays - // dirty for the next snapshot cycle. - inner.flags.set_data_modified(false); - inner.flags.set_meta_modified(false); - inner.flags.set_new_task(false); - self.shard - .storage - .promote_during_snapshot_flags(&mut inner, self.shard.shard_idx); - return Some(item); - } - } None } } @@ -704,20 +656,22 @@ mod tests { } /// Regression test: a task modified before a snapshot and then modified *again* during - /// snapshot iteration must not trigger `debug_assert!(!inner.flags.any_modified())` in - /// `SnapshotShardIter::next`. + /// snapshot iteration must serialize the pre-snapshot state and carry the during-snapshot + /// modification forward to the next cycle. /// /// Sequence of events: /// 1. Task is modified (data_modified = true) → added to shard_modified_counts. /// 2. `start_snapshot` puts us in snapshot mode. - /// 3. `take_snapshot` scans the shard: task has `any_modified()=true` and - /// `any_modified_during_snapshot()=false` → task goes into the `modified` list. - /// 4. **Between scan and iteration**: `track_modification` is called on the task again. This is - /// the `(true, true)` branch: already modified AND in snapshot mode. A snapshot copy of the - /// pre-snapshot state is created (carrying the modified bits) and stored in `snapshots`. - /// 5. `SnapshotShardIter::next` processes the task from the `modified` list, finds - /// `any_modified_during_snapshot()=true`, clears the live modified flags (which were - /// captured into the snapshot), then asserts `!any_modified()` before promoting. + /// 3. `take_snapshot` scans the shard: task has `any_modified()=true` → goes into the + /// `modified` list. + /// 4. **Between scan and iteration**: `track_modification` is called on the same category. This + /// is the `(true, true)` branch: already modified AND in snapshot mode. A snapshot copy of + /// the pre-second-modification state is stored in `snapshots` as `Some(copy)`, and + /// `data_modified_during_snapshot` is set. + /// 5. `SnapshotShardIter::next` processes the task from the `modified` list, detects + /// `any_modified_during_snapshot()=true`, finds the `Some(copy)` in `snapshots`, encodes the + /// pre-snapshot copy, clears the live modified flags, removes the snapshots entry, and + /// promotes `data_modified_during_snapshot → data_modified` for the next cycle. // `end_snapshot` uses `parallel::for_each` which calls `block_in_place` internally, // requiring a multi-threaded Tokio runtime. #[tokio::test(flavor = "multi_thread")] @@ -751,8 +705,8 @@ mod tests { assert!(guard.flags.data_modified_during_snapshot()) } - // Step 5: consume the iterator. The iterator clears the live modified flags - // before the assert, encodes the snapshot copy, and promotes + // Step 5: consume the iterator. The iterator encodes from the pre-snapshot copy, + // clears the live modified flags, removes the snapshots entry, and promotes // `data_modified_during_snapshot → data_modified` for the next cycle. let items: Vec<_> = shards .into_iter() @@ -765,7 +719,7 @@ mod tests { { let guard = storage.access_mut(task_id); - // Ending the snapshot should have promoted modified_during_snapshot → modified. + // The iterator should have promoted modified_during_snapshot → modified. assert!(guard.flags.data_modified()); } @@ -777,4 +731,73 @@ mod tests { "shard_modified_counts must be non-zero after promoting modified_during_snapshot" ); } + + /// Regression test for the `(true, false)` during-snapshot case: a task modified in one + /// category before a snapshot, then modified in a *different* category during snapshot + /// iteration, must not panic and must carry both modifications forward correctly. + /// + /// Sequence of events: + /// 1. Task meta is modified (meta_modified = true). + /// 2. `start_snapshot` puts us in snapshot mode. + /// 3. `take_snapshot` scans the shard: task goes into the `modified` list. + /// 4. Task data is modified during snapshot → `(true, false)` branch: data was not previously + /// modified, so `snapshots` gets a `None` entry and `data_modified_during_snapshot` is set. + /// 5. `SnapshotShardIter::next` processes the task: finds `any_modified_during_snapshot()`, + /// sees `None` in snapshots, encodes from live data (correct — live data for the + /// unmodified-before-snapshot category is still the pre-snapshot state), clears pre-snapshot + /// flags, and promotes `data_modified_during_snapshot → data_modified`. + #[tokio::test(flavor = "multi_thread")] + async fn modify_different_category_during_snapshot() { + let storage = Storage::new(2, true); + let task_id = non_transient_task(1); + + // Step 1: modify meta only, outside snapshot mode. + { + let mut guard = storage.access_mut(task_id); + guard.track_modification(SpecificTaskDataCategory::Meta, "test"); + assert!(guard.flags.meta_modified()); + assert!(!guard.flags.data_modified()); + } + + // Step 2: enter snapshot mode. + let (snapshot_guard, has_modifications) = storage.start_snapshot(); + assert!(has_modifications); + + // Step 3: take_snapshot — task goes into modified list (meta_modified = true). + let shards = storage.take_snapshot(snapshot_guard, &dummy_process); + + // Step 4: modify data during snapshot. The `(true, false)` branch fires: + // data was not previously modified, so snapshots gets a None entry. + { + let mut guard = storage.access_mut(task_id); + guard.track_modification(SpecificTaskDataCategory::Data, "test"); + assert!(guard.flags.data_modified_during_snapshot()); + assert!(!guard.flags.meta_modified_during_snapshot()); + } + + // Step 5: consume the iterator — must not panic. + let items: Vec<_> = shards + .into_iter() + .flat_map(|shard| shard.into_iter()) + .collect(); + + assert_eq!(items.len(), 1); + assert_eq!(items[0].task_id, task_id); + + { + let guard = storage.access_mut(task_id); + // meta_modified was cleared by the iterator (it was the pre-snapshot flag). + assert!(!guard.flags.meta_modified()); + // data_modified_during_snapshot was promoted to data_modified. + assert!(guard.flags.data_modified()); + assert!(!guard.flags.data_modified_during_snapshot()); + } + + // Next snapshot cycle must pick up the promoted data_modified. + let (_guard2, has_modifications) = storage.start_snapshot(); + assert!( + has_modifications, + "shard_modified_counts must be non-zero after promoting data_modified_during_snapshot" + ); + } } From a2fd4ea5c8cc41ce2c823b5a9032fbd8edbdaef0 Mon Sep 17 00:00:00 2001 From: Will Binns-Smith Date: Wed, 29 Apr 2026 08:22:00 -0700 Subject: [PATCH 3/6] Enable server HMR for metadata routes (manifest.ts, robots.ts, etc.) (#93110) **This PR removes the special-case handling for metadata routes so it benefits from server hmr. It also adds tests.** Previously, metadata routes were excluded from Turbopack's server-side HMR and fell back to a full require-cache eviction on every change. This meant unmodified dependency modules were unnecessarily re-evaluated. Metadata routes use the same app-route.js template and getUserland() per-request getter as regular route handlers, so they can participate in in-place server HMR the same way. Remove the isMetadataRoute() exclusion from the usesServerHmr gate. Test Plan: Added e2e test for testing selective hmr eviction Co-authored-by: Will Binns-Smith Co-authored-by: Claude --- .../src/server/dev/hot-reloader-turbopack.ts | 25 ++++++--------- .../app-dir/server-hmr/app/manifest-dep.ts | 2 ++ .../app-dir/server-hmr/app/manifest.ts | 11 +++++-- .../app-dir/server-hmr/server-hmr.test.ts | 31 +++++++++++++++++-- 4 files changed, 48 insertions(+), 21 deletions(-) create mode 100644 test/development/app-dir/server-hmr/app/manifest-dep.ts diff --git a/packages/next/src/server/dev/hot-reloader-turbopack.ts b/packages/next/src/server/dev/hot-reloader-turbopack.ts index 3b11ba9b0b6d..a105385fa7c4 100644 --- a/packages/next/src/server/dev/hot-reloader-turbopack.ts +++ b/packages/next/src/server/dev/hot-reloader-turbopack.ts @@ -82,10 +82,7 @@ import { isAppPageRouteDefinition } from '../route-definitions/app-page-route-de import { normalizeAppPath } from '../../shared/lib/router/utils/app-paths' import type { ModernSourceMapPayload } from '../lib/source-maps' import { isDeferredEntry } from '../../build/entries' -import { - isMetadataRoute, - isMetadataRouteFile, -} from '../../lib/metadata/is-metadata-route' +import { isMetadataRouteFile } from '../../lib/metadata/is-metadata-route' import { setBundlerFindSourceMapImplementation } from '../patch-error-inspect' import { getNextErrorFeedbackMiddleware } from '../../next-devtools/server/get-next-error-feedback-middleware' import { @@ -602,20 +599,15 @@ export async function createHotReloaderTurbopack( join(distDir, p) ) - const { type: entryType, page: entryPage } = splitEntryKey(key) + const { type: entryType } = splitEntryKey(key) // Server HMR applies to App Router entries built with the Turbopack Node.js - // runtime: app pages and regular route handlers. Edge routes, Pages Router - // pages, middleware/instrumentation, and metadata routes (manifest.ts, - // robots.ts, sitemap.ts, icon.tsx, etc.) are excluded. Metadata routes are - // excluded because they serve HTTP responses directly and must re-execute - // on every request to pick up file changes; the in-place module update - // model of Server HMR does not apply to them. + // runtime: app pages and route handlers (including metadata routes). Edge + // routes, Pages Router pages, and middleware/instrumentation are excluded. const usesServerHmr = serverFastRefresh && entryType === 'app' && - writtenEndpoint.type !== 'edge' && - !isMetadataRoute(entryPage) + writtenEndpoint.type !== 'edge' const filesToDelete: string[] = [] for (const file of serverPaths) { @@ -623,9 +615,10 @@ export async function createHotReloaderTurbopack( const relativePath = relative(distDir, file) if ( - // For Pages Router, edge routes, middleware, and manifest files: - // clear the sharedCache in evalManifest(), Node.js require.cache, - // and edge runtime module contexts. + // For Pages Router, edge routes, middleware, and any entry not + // participating in server HMR: clear the sharedCache in + // evalManifest(), Node.js require.cache, and edge runtime module + // contexts. force || !usesServerHmr || !serverHmrSubscriptions?.has(relativePath) diff --git a/test/development/app-dir/server-hmr/app/manifest-dep.ts b/test/development/app-dir/server-hmr/app/manifest-dep.ts new file mode 100644 index 000000000000..93327e4f2ee8 --- /dev/null +++ b/test/development/app-dir/server-hmr/app/manifest-dep.ts @@ -0,0 +1,2 @@ +export const depEvaluatedAt = Date.now() +export const manifestVersion = 'Version 0' diff --git a/test/development/app-dir/server-hmr/app/manifest.ts b/test/development/app-dir/server-hmr/app/manifest.ts index c24c6ade4365..9aee60abf3e8 100644 --- a/test/development/app-dir/server-hmr/app/manifest.ts +++ b/test/development/app-dir/server-hmr/app/manifest.ts @@ -1,10 +1,15 @@ -import type { MetadataRoute } from 'next' +import { depEvaluatedAt, manifestVersion } from './manifest-dep' -export default function manifest(): MetadataRoute.Manifest { +let _hmrTrigger = 0 +const manifestEvaluatedAt = Date.now() + +export default function manifest() { return { - name: 'Version 0', + name: manifestVersion, short_name: 'v0', start_url: '/', display: 'standalone', + depEvaluatedAt, + manifestEvaluatedAt, } } diff --git a/test/development/app-dir/server-hmr/server-hmr.test.ts b/test/development/app-dir/server-hmr/server-hmr.test.ts index d013f9b37621..e438714661c2 100644 --- a/test/development/app-dir/server-hmr/server-hmr.test.ts +++ b/test/development/app-dir/server-hmr/server-hmr.test.ts @@ -194,13 +194,13 @@ describe('server-hmr', () => { } ) - it('reflects manifest.ts changes on fetch/refresh', async () => { + it('reflects manifest dep changes on fetch/refresh', async () => { const initial = await next .fetch('/manifest.webmanifest') .then((res) => res.json()) expect(initial.name).toBe('Version 0') - await next.patchFile('app/manifest.ts', (content) => + await next.patchFile('app/manifest-dep.ts', (content) => content.replace('Version 0', 'Version 1') ) @@ -211,6 +211,33 @@ describe('server-hmr', () => { expect(updated.name).toBe('Version 1') }) }) + + itTurbopackDev( + 'does not re-evaluate an unmodified dep when manifest changes', + async () => { + const initial = await next + .fetch('/manifest.webmanifest') + .then((res) => res.json()) + const initialDepEvaluatedAt = initial.depEvaluatedAt + + // Patch manifest.ts itself, not the dep module + await next.patchFile('app/manifest.ts', (content) => + content.replace('_hmrTrigger = 0', '_hmrTrigger = 1') + ) + + await retry(async () => { + const updated = await next + .fetch('/manifest.webmanifest') + .then((res) => res.json()) + // manifest.ts should have been re-evaluated (new timestamp) + expect(updated.manifestEvaluatedAt).not.toBe( + initial.manifestEvaluatedAt + ) + // manifest-dep.ts should NOT have been re-evaluated + expect(updated.depEvaluatedAt).toBe(initialDepEvaluatedAt) + }) + } + ) }) describe('route handler hmr', () => { From 70d98765fc1b6daaf3988808b7b7970d764db453 Mon Sep 17 00:00:00 2001 From: "Sebastian \"Sebbie\" Silbermann" Date: Wed, 29 Apr 2026 18:49:56 +0200 Subject: [PATCH 4/6] [next/image] Only fire `onError` once (#93209) --- packages/next/src/client/image-component.tsx | 62 +++++--- .../next-image-events/app/fulfilled/page.tsx | 52 +++++++ .../app-dir/next-image-events/app/layout.tsx | 8 + .../next-image-events/app/rejected/page.tsx | 50 ++++++ .../next-image-events.test.ts | 147 ++++++++++++++++++ .../app-dir/next-image-events/next.config.js | 6 + .../app-dir/next-image-events/public/test.png | Bin 0 -> 1545 bytes 7 files changed, 303 insertions(+), 22 deletions(-) create mode 100644 test/e2e/app-dir/next-image-events/app/fulfilled/page.tsx create mode 100644 test/e2e/app-dir/next-image-events/app/layout.tsx create mode 100644 test/e2e/app-dir/next-image-events/app/rejected/page.tsx create mode 100644 test/e2e/app-dir/next-image-events/next-image-events.test.ts create mode 100644 test/e2e/app-dir/next-image-events/next.config.js create mode 100644 test/e2e/app-dir/next-image-events/public/test.png diff --git a/packages/next/src/client/image-component.tsx b/packages/next/src/client/image-component.tsx index 1e635c829354..7bace3ab334d 100644 --- a/packages/next/src/client/image-component.tsx +++ b/packages/next/src/client/image-component.tsx @@ -3,12 +3,12 @@ import React, { useRef, useEffect, - useCallback, useContext, useMemo, useState, forwardRef, use, + useLayoutEffect, } from 'react' import ReactDOM from 'react-dom' import Head from '../shared/lib/head' @@ -44,7 +44,7 @@ export type { ImageLoaderProps } export type ImageLoader = (p: ImageLoaderProps) => string type ImgElementWithDataProp = HTMLImageElement & { - 'data-loaded-src': string | undefined + 'data-loaded-src'?: string | undefined } type ImageElementProps = ImgProps & { @@ -180,6 +180,15 @@ function getDynamicProps( return { fetchpriority: fetchPriority } } +/** + * A version of useLayoutEffect that doesn't warn during SSR. + * TODO: Just useLayoutEffect once support for React 18 is dropped. + * Do not rename this to "isomorphic layout effect". There is no such thing as + * an isomorphic Layout Effect since there is no Layout on the server + */ +const useNonWarningLayoutEffect = + typeof window === 'undefined' ? useEffect : useLayoutEffect + const ImageElement = forwardRef( ( { @@ -207,18 +216,25 @@ const ImageElement = forwardRef( }, forwardedRef ) => { - const ownRef = useCallback( - (img: ImgElementWithDataProp | null) => { - if (!img) { - return - } + const didInsertRef = useRef(false) + const insertedImgRef = useRef(null) + + useNonWarningLayoutEffect(() => { + const { current: didInsert } = didInsertRef + const { current: img } = insertedImgRef + + if (!didInsert && img !== null) { + // Replay events from during hydration that React doesn't replay. if (onError) { // If the image has an error before react hydrates, then the error is lost. // The workaround is to wait until the image is mounted which is after hydration, // then we set the src again to trigger the error handler (if there was an error). + // This doesn't just trigger the error handler but retries the whole request. + // TODO: Consider dispatching a synthetic event instead. // eslint-disable-next-line no-self-assign img.src = img.src } + if (process.env.NODE_ENV !== 'production') { if (!src) { console.error(`Image is missing required "src" property:`, img) @@ -240,22 +256,24 @@ const ImageElement = forwardRef( sizesInput ) } - }, - [ - src, - placeholder, - onLoadRef, - onLoadingCompleteRef, - setBlurComplete, - onError, - unoptimized, - sizesInput, - ] - ) + didInsertRef.current = true + } + }, [ + src, + placeholder, + onLoadRef, + onLoadingCompleteRef, + onError, + unoptimized, + sizesInput, + ]) - const ref = useMergedRef(forwardedRef, ownRef) + const ref = useMergedRef(forwardedRef, insertedImgRef) return ( + // If you move this element creation, also move the Layout Effect above + // reading from the ref. Otherwise we might run the Layout Effect when + // the current value isn't set to the HTMLImageElement instance. ( src={src} ref={ref} onLoad={(event) => { - const img = event.currentTarget as ImgElementWithDataProp + const currentImage = event.currentTarget handleLoading( - img, + currentImage, placeholder, onLoadRef, onLoadingCompleteRef, diff --git a/test/e2e/app-dir/next-image-events/app/fulfilled/page.tsx b/test/e2e/app-dir/next-image-events/app/fulfilled/page.tsx new file mode 100644 index 000000000000..e195d869d2db --- /dev/null +++ b/test/e2e/app-dir/next-image-events/app/fulfilled/page.tsx @@ -0,0 +1,52 @@ +'use client' +'use no memo' + +import { useReducer, useState } from 'react' +import Image from 'next/image' + +export default function Page() { + const [, setLoadEvent] = useState(null) + const [showClientImage, setShowClientImage] = useState(false) + const [, rerender] = useReducer((i) => i + 1, 0) + + return ( + <> + foo {}} + onLoad={(event) => { + console.error('hydrated image load') + // This doesn't really make sense. We just want to check rerendering + // doesn't infinitely loop + setLoadEvent(event) + }} + /> + + + {showClientImage && ( + bar {}} + onLoad={(event) => { + console.error('client rendered image load') + // This doesn't really make sense. We just want to check rerendering + // doesn't infinitely loop + setLoadEvent(event) + }} + /> + )} + + ) +} diff --git a/test/e2e/app-dir/next-image-events/app/layout.tsx b/test/e2e/app-dir/next-image-events/app/layout.tsx new file mode 100644 index 000000000000..888614deda3b --- /dev/null +++ b/test/e2e/app-dir/next-image-events/app/layout.tsx @@ -0,0 +1,8 @@ +import { ReactNode } from 'react' +export default function Root({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} diff --git a/test/e2e/app-dir/next-image-events/app/rejected/page.tsx b/test/e2e/app-dir/next-image-events/app/rejected/page.tsx new file mode 100644 index 000000000000..cb6a7ed2aac1 --- /dev/null +++ b/test/e2e/app-dir/next-image-events/app/rejected/page.tsx @@ -0,0 +1,50 @@ +'use client' +'use no memo' + +import { useReducer, useState } from 'react' +import Image from 'next/image' + +export default function Page() { + const [, setErrorEvent] = useState(null) + const [showClientImage, setShowClientImage] = useState(false) + const [, rerender] = useReducer((i) => i + 1, 0) + + return ( + <> + foo { + console.error('hydrated image error') + // This doesn't really make sense. We just want to check rerendering + // doesn't infinitely loop + setErrorEvent(event) + }} + /> + + + {showClientImage && ( + bar { + console.error('client rendered image error') + // This doesn't really make sense. We just want to check rerendering + // doesn't infinitely loop + setErrorEvent(event) + }} + /> + )} + + ) +} diff --git a/test/e2e/app-dir/next-image-events/next-image-events.test.ts b/test/e2e/app-dir/next-image-events/next-image-events.test.ts new file mode 100644 index 000000000000..0ccf7e1b1b74 --- /dev/null +++ b/test/e2e/app-dir/next-image-events/next-image-events.test.ts @@ -0,0 +1,147 @@ +import { nextTestSetup } from 'e2e-utils' +import { retry } from 'next-test-utils' + +describe('next-image-events', () => { + const { next } = nextTestSetup({ + files: __dirname, + }) + + it('should not call onLoad multiple times', async () => { + const imageRequests = [] + const browser = await next.browser('/fulfilled', { + beforePageLoad(page) { + page.on('request', (request) => { + if (request.resourceType() === 'image') { + imageRequests.push(request.url()) + } + }) + }, + }) + + let logsIdx = 0 + await retry(async () => { + const logs = await browser.log() + expect( + logs.slice(logsIdx).filter(({ source }) => source === 'error') + ).toEqual([ + { + source: 'error', + message: 'hydrated image load', + }, + ]) + logsIdx = logs.length + }) + expect(imageRequests).toEqual([expect.stringContaining('test')]) + imageRequests.length = 0 + + await browser.locator(':text("Show Client image")').click() + + await retry(async () => { + const logs = await browser.log() + expect( + logs.slice(logsIdx).filter(({ source }) => source === 'error') + ).toEqual([ + { + source: 'error', + message: 'client rendered image load', + }, + ]) + logsIdx = logs.length + }) + expect(imageRequests).toEqual([expect.stringContaining('test')]) + imageRequests.length = 0 + + await browser.locator(':text("rerender Page")').click() + + const logs = await browser.log() + expect( + logs.slice(logsIdx).filter(({ source }) => source === 'error') + ).toEqual([]) + expect(imageRequests).toEqual([]) + imageRequests.length = 0 + }) + + it('should not infinitely retry on error', async () => { + const nextImageRequests: string[] = [] + let logsIdx = 0 + const browser = await next.browser('/rejected', { + beforePageLoad(page) { + // We're manually aborting here to simulate an error before React hydrates. + // A real request might settle too late to test this behavior reliably. + // Especially in dev, requests (even if warm) may take longer than hydration. + page.route( + /\/will-never-exist\.png|\/still-doesnt-exist\.png/, + (route) => { + nextImageRequests.push(route.request().url()) + return route.abort() + } + ) + }, + }) + + await retry(async () => { + const logs = await browser.log() + expect( + logs.slice(logsIdx).filter(({ source }) => source === 'error') + ).toEqual([ + { + source: 'error', + message: 'Failed to load resource: net::ERR_FAILED', + }, + // Next.js retries once to trigger onError on SSRed, settled images. + // If this test fails, we'll either hydrated faster than the request settled, + // or dropped the retrying behavior of next/image + { + source: 'error', + message: 'Failed to load resource: net::ERR_FAILED', + }, + { + source: 'error', + message: 'hydrated image error', + }, + ]) + logsIdx = logs.length + }) + expect(nextImageRequests).toEqual([ + expect.stringContaining('will-never-exist'), + // Next.js retries once to trigger onError on SSRed, settled images. + // If this test fails, we'll either hydrated faster than the request settled, + // or dropped the retrying behavior of next/image + expect.stringContaining('will-never-exist'), + ]) + nextImageRequests.length = 0 + + await browser.locator(':text("Show Client image")').click() + + await retry(async () => { + const logs = await browser.log() + expect( + logs.slice(logsIdx).filter(({ source }) => source === 'error') + ).toEqual([ + { + source: 'error', + message: 'Failed to load resource: net::ERR_FAILED', + }, + { + source: 'error', + message: 'client rendered image error', + }, + ]) + logsIdx = logs.length + }) + expect(nextImageRequests).toEqual([ + expect.stringContaining('still-doesnt-exist'), + ]) + nextImageRequests.length = 0 + + await browser.locator(':text("rerender Page")').click() + + const logs = await browser.log() + expect( + logs.slice(logsIdx).filter(({ source }) => source === 'error') + ).toEqual([]) + expect(nextImageRequests).toEqual([]) + nextImageRequests.length = 0 + logsIdx = logs.length + }) +}) diff --git a/test/e2e/app-dir/next-image-events/next.config.js b/test/e2e/app-dir/next-image-events/next.config.js new file mode 100644 index 000000000000..807126e4cf0b --- /dev/null +++ b/test/e2e/app-dir/next-image-events/next.config.js @@ -0,0 +1,6 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = {} + +module.exports = nextConfig diff --git a/test/e2e/app-dir/next-image-events/public/test.png b/test/e2e/app-dir/next-image-events/public/test.png new file mode 100644 index 0000000000000000000000000000000000000000..e14fafc5cf3bc63b70914ad20467f40f7fecd572 GIT binary patch literal 1545 zcmbVM{Xf$Q9A6%~&O#i9S`$MamWNGTo{nv7c{rq_?QHVUW~E6OQi@{ZJcmZ|?7l@Q zzFgO>LgSLHn=t)6BZh%t7EPMfk1SL z1Y86JvZ4In(b7~asTB_EYLbzJ#fBxtCqp2a7u(A{gEak&&i%OE5K&=dA02(f0EgVb zDQO?EwAgXhbPx#1STW3~N_6+*i-&gO&5gIVD)qtd)=yh(VkE{hpxOq=E?Uo-)5z*x z!Au!iA$YiLAm+*0qggP>?VsKD-2i&HQxQ3+OqX*8S}wK5H8(1QM_f{Jya%lp;-fFQ z-RxdA9ea)1aI;`EXvn#9J~1_}n?bl%WsA3~x1yF~ZJY?F%5TY1f>Os{GDi>X>C?IS zC87Oo3ZX}KJ*U`mZ%63leZQDa&ij+|L2Ig&kv$8+G!kJ)!A>IpI0!SpvZ=R*dmxwE z_A02!zif^Xi?D&?&%f0Tzbc>bI(#PkQsao89{0s~R(I*hM>py`YIH=n8s(l<+!VhFb)fj#H;uE`npo7 zY;0_#QmGRY6Algzb}0{05Qr9vi1UjyHCq}CIyy~&Xo)lk4660;XBm=IbzH;Vwux!6 z@U`%Q<6`U_r^#vHXzMH%_g}z&^bvih;Naksl&3F)p7Kn#$+goa*xhsUD|t?H%CawT z>JQ8!^fPzDF6c8waZPU1$^P~{X*y_EN`KC=6nc}~iEX#>ud*u)-GT=qZK~K!#eMKri|K2@v zeX7|gqiZ-a27vkY(m>jlb*A45J^WhNqUd5svx=i!WlyGoDxyIkDCJw8 zl1RKs=y0j+xtSIh@AZ-SU-~z%d7|iJXK0I}nj!QZ_;_V0t%N>WpH)B+RT91Kkuhzx zSp{CL@O&X!puOb5enarY#IKV0$GfaZ<5QCF#q6Ih66Bl1Pk?cT!sCl5^YK4KUf8=r z`aO#WUfA<6@Z|tBgFYm!h8b-eKV4c&$3bTW&<9YGGZ&`xG#9~EHI4;**~o$2bOc^F z)xqxjhTZjF)wtZ04Ns<6mIBW?61;SKUp&Ix#QrYF;SY_@rCeH2X2*tJ$*pAIHb zh#ej+0ZbcVCs7JzV7TsL6Jyyhc?vBAKW|d~E=#`(Epz?bhZI(;xeQ`sbe2CXvFp-!)9gAPmnDWWTsf>26XSP@ zv&2i`WrNZNf%ZoawxTiv7?Jj|6+NW@o>r`=449DMidcqyfhe1CUhQqXbvCSyC1#>! z&TQ9Zpp%MX zY5qJSn%bSF+=@PAVhp9?wWsW-al19&OZPE literal 0 HcmV?d00001 From ea541987d1735493c63b52d0b6e673d4435cbb20 Mon Sep 17 00:00:00 2001 From: Janka Uryga Date: Wed, 29 Apr 2026 19:25:56 +0200 Subject: [PATCH 5/6] fix: handling of falsey values in error boundaries (#93134) our error boundaries had a bunch of logic that set `state.error = error` and then checked `if (state.error)`, which only works correctly if thrown value is truthy. it breaks if something does e.g. `throw undefined`. in this case, we would incorrectly think that no error occurred and render children again (instead of a fallback), which can then lead to an infinite loop if the children throw again. the fix is to wrap the thrown value, so `state.error` is either `null` (initial/reset) or `{ thrownValue: ... }` if something errored. i initially considered using a separate `state.hasError` boolean, but that's a bit annoying to type, and really we want to model this as a discriminated union, so using a pseudo-Optional thing is nicer. --- .../src/client/components/catch-error.tsx | 22 ++++--- .../src/client/components/error-boundary.tsx | 22 ++++--- .../http-access-fallback/error-boundary.tsx | 2 +- .../client/components/nav-failure-handler.ts | 1 - .../client/components/redirect-boundary.tsx | 2 +- .../app/app-dev-overlay-error-boundary.tsx | 53 ++++++++-------- .../pages-dev-overlay-error-boundary.tsx | 14 +++-- .../client-component/catch-error-wrapper.tsx | 2 +- .../app/client-component/throw-null/page.tsx | 37 ++++++++++++ .../client-component/throw-undefined/page.tsx | 37 ++++++++++++ .../server-component/catch-error-wrapper.tsx | 2 +- .../throw-null/error-wrapper.tsx | 10 ++++ .../app/server-component/throw-null/page.tsx | 19 ++++++ .../throw-undefined/error-wrapper.tsx | 10 ++++ .../server-component/throw-undefined/page.tsx | 19 ++++++ .../app-dir/catch-error/catch-error.test.ts | 42 +++++++++++++ .../catch-error/pages/pages-router.tsx | 2 +- .../app/client-component/throw-null/error.js | 5 ++ .../app/client-component/throw-null/page.js | 21 +++++++ .../client-component/throw-undefined/error.js | 5 ++ .../client-component/throw-undefined/page.js | 21 +++++++ .../app/server-component/throw-null/error.js | 10 ++++ .../server-component/throw-undefined/error.js | 10 ++++ test/e2e/app-dir/errors/index.test.ts | 51 ++++++++++++++-- .../basic/app/client-throw-null/page.js | 21 +++++++ .../basic/app/client-throw-undefined/page.js | 21 +++++++ .../global-error/basic/app/global-error.js | 2 +- .../basic/app/rsc-throw-null/page.js | 4 ++ .../basic/app/rsc-throw-undefined/page.js | 4 ++ .../app-dir/global-error/basic/index.test.ts | 60 ++++++++++++++++--- 30 files changed, 463 insertions(+), 68 deletions(-) create mode 100644 test/e2e/app-dir/catch-error/app/client-component/throw-null/page.tsx create mode 100644 test/e2e/app-dir/catch-error/app/client-component/throw-undefined/page.tsx create mode 100644 test/e2e/app-dir/catch-error/app/server-component/throw-null/error-wrapper.tsx create mode 100644 test/e2e/app-dir/catch-error/app/server-component/throw-null/page.tsx create mode 100644 test/e2e/app-dir/catch-error/app/server-component/throw-undefined/error-wrapper.tsx create mode 100644 test/e2e/app-dir/catch-error/app/server-component/throw-undefined/page.tsx create mode 100644 test/e2e/app-dir/errors/app/client-component/throw-null/error.js create mode 100644 test/e2e/app-dir/errors/app/client-component/throw-null/page.js create mode 100644 test/e2e/app-dir/errors/app/client-component/throw-undefined/error.js create mode 100644 test/e2e/app-dir/errors/app/client-component/throw-undefined/page.js create mode 100644 test/e2e/app-dir/errors/app/server-component/throw-null/error.js create mode 100644 test/e2e/app-dir/errors/app/server-component/throw-undefined/error.js create mode 100644 test/e2e/app-dir/global-error/basic/app/client-throw-null/page.js create mode 100644 test/e2e/app-dir/global-error/basic/app/client-throw-undefined/page.js create mode 100644 test/e2e/app-dir/global-error/basic/app/rsc-throw-null/page.js create mode 100644 test/e2e/app-dir/global-error/basic/app/rsc-throw-undefined/page.js diff --git a/packages/next/src/client/components/catch-error.tsx b/packages/next/src/client/components/catch-error.tsx index e85fe29d7d4b..08ab813fcdc3 100644 --- a/packages/next/src/client/components/catch-error.tsx +++ b/packages/next/src/client/components/catch-error.tsx @@ -30,7 +30,7 @@ type CatchErrorProps

= { } type CatchErrorState = { - error: Error | null + error: null | { thrownValue: unknown } previousPathname: string | null } @@ -38,7 +38,7 @@ type CatchErrorState = { // TODO: Extend it instead of forking to easily sync the behavior? class CatchError

extends React.Component< CatchErrorProps

, - { error: Error | null; previousPathname: string | null } + CatchErrorState > { declare context: AppRouterInstance | null static contextType = AppRouterContext @@ -54,14 +54,16 @@ class CatchError

extends React.Component< } } - static getDerivedStateFromError(error: Error) { - if (isNextRouterError(error)) { + static getDerivedStateFromError( + thrownValue: unknown + ): Partial { + if (isNextRouterError(thrownValue)) { // Re-throw if an expected internal Next.js router error occurs // this means it should be handled by a different boundary (such as a NotFound boundary in a parent segment) - throw error + throw thrownValue } - return { error } + return { error: { thrownValue } } } static getDerivedStateFromProps( @@ -75,7 +77,7 @@ class CatchError

extends React.Component< // the error boundary and instead should fallback // to a hard navigation to attempt recovering if (process.env.__NEXT_APP_NAV_FAIL_HANDLING) { - if (error && handleHardNavError(error)) { + if (error && handleHardNavError(error.thrownValue)) { // clear error so we don't render anything return { error: null, @@ -124,13 +126,15 @@ class CatchError

extends React.Component< //When it's bot request, segment level error boundary will keep rendering the children, // the final error will be caught by the root error boundary and determine wether need to apply graceful degrade. if (this.state.error && !isBotUserAgent) { - handleISRError({ error: this.state.error }) + const thrownValue = this.state.error.thrownValue + handleISRError({ error: thrownValue }) return ( void unstable_retry: () => void } @@ -35,7 +35,7 @@ interface ErrorBoundaryHandlerProps extends ErrorBoundaryProps { } interface ErrorBoundaryHandlerState { - error: Error | null + error: null | { thrownValue: unknown } previousPathname: string | null } @@ -54,14 +54,16 @@ export class ErrorBoundaryHandler extends React.Component< } } - static getDerivedStateFromError(error: Error) { - if (isNextRouterError(error)) { + static getDerivedStateFromError( + thrownValue: unknown + ): Partial { + if (isNextRouterError(thrownValue)) { // Re-throw if an expected internal Next.js router error occurs // this means it should be handled by a different boundary (such as a NotFound boundary in a parent segment) - throw error + throw thrownValue } - return { error } + return { error: { thrownValue } } } static getDerivedStateFromProps( @@ -75,7 +77,7 @@ export class ErrorBoundaryHandler extends React.Component< // the error boundary and instead should fallback // to a hard navigation to attempt recovering if (process.env.__NEXT_APP_NAV_FAIL_HANDLING) { - if (error && handleHardNavError(error)) { + if (error && handleHardNavError(error.thrownValue)) { // clear error so we don't render anything return { error: null, @@ -118,14 +120,16 @@ export class ErrorBoundaryHandler extends React.Component< //When it's bot request, segment level error boundary will keep rendering the children, // the final error will be caught by the root error boundary and determine wether need to apply graceful degrade. if (this.state.error && !isBotUserAgent) { - handleISRError({ error: this.state.error }) + const thrownValue = this.state.error.thrownValue + handleISRError({ error: thrownValue }) return ( <> {this.props.errorStyles} {this.props.errorScripts} diff --git a/packages/next/src/client/components/http-access-fallback/error-boundary.tsx b/packages/next/src/client/components/http-access-fallback/error-boundary.tsx index 4f057fc9b0eb..2012422b0521 100644 --- a/packages/next/src/client/components/http-access-fallback/error-boundary.tsx +++ b/packages/next/src/client/components/http-access-fallback/error-boundary.tsx @@ -77,7 +77,7 @@ class HTTPAccessFallbackErrorBoundary extends React.Component< } } - static getDerivedStateFromError(error: any) { + static getDerivedStateFromError(error: unknown) { if (isHTTPAccessFallbackError(error)) { const httpStatus = getAccessFallbackHTTPStatus(error) return { diff --git a/packages/next/src/client/components/nav-failure-handler.ts b/packages/next/src/client/components/nav-failure-handler.ts index 795707c12f09..808c1b0fb03d 100644 --- a/packages/next/src/client/components/nav-failure-handler.ts +++ b/packages/next/src/client/components/nav-failure-handler.ts @@ -3,7 +3,6 @@ import { createHrefFromUrl } from './router-reducer/create-href-from-url' export function handleHardNavError(error: unknown): boolean { if ( - error && typeof window !== 'undefined' && window.next.__pendingUrl && createHrefFromUrl(new URL(window.location.href)) !== diff --git a/packages/next/src/client/components/redirect-boundary.tsx b/packages/next/src/client/components/redirect-boundary.tsx index 32f68f284e2c..0b17e62820f3 100644 --- a/packages/next/src/client/components/redirect-boundary.tsx +++ b/packages/next/src/client/components/redirect-boundary.tsx @@ -44,7 +44,7 @@ export class RedirectErrorBoundary extends React.Component< this.state = { redirect: null, redirectType: null } } - static getDerivedStateFromError(error: any) { + static getDerivedStateFromError(error: unknown) { if (isRedirectError(error)) { const url = getURLFromRedirectError(error) const redirectType = getRedirectTypeFromError(error) diff --git a/packages/next/src/next-devtools/userspace/app/app-dev-overlay-error-boundary.tsx b/packages/next/src/next-devtools/userspace/app/app-dev-overlay-error-boundary.tsx index e68391eb6ee5..cf1ca9394612 100644 --- a/packages/next/src/next-devtools/userspace/app/app-dev-overlay-error-boundary.tsx +++ b/packages/next/src/next-devtools/userspace/app/app-dev-overlay-error-boundary.tsx @@ -9,6 +9,7 @@ import { AppRouterContext, type AppRouterInstance, } from '../../../shared/lib/app-router-context.shared-runtime' +import isError from '../../../lib/is-error' type AppDevOverlayErrorBoundaryProps = { children: React.ReactNode @@ -16,33 +17,25 @@ type AppDevOverlayErrorBoundaryProps = { } type AppDevOverlayErrorBoundaryState = { - reactError: unknown + error: null | { thrownValue: unknown } } function ErroredHtml({ globalError: [GlobalError, globalErrorStyles], - error, + thrownValue, reset, unstable_retry, }: { globalError: GlobalErrorState - error: unknown + thrownValue: unknown reset: () => void unstable_retry: () => void }) { - if (!error) { - return ( - - - - - ) - } return ( {globalErrorStyles} @@ -58,20 +51,23 @@ export class AppDevOverlayErrorBoundary extends PureComponent< declare context: AppRouterInstance | null state: AppDevOverlayErrorBoundaryState = { - reactError: null, + error: null, } - static getDerivedStateFromError(error: Error) { + static getDerivedStateFromError( + thrownValue: Error + ): Partial { RuntimeErrorHandler.hadRuntimeError = true return { - reactError: error, + error: { thrownValue }, } } - componentDidCatch(err: Error) { + componentDidCatch(err: unknown) { if ( process.env.NODE_ENV === 'development' && + isError(err) && err.message === SEGMENT_EXPLORER_SIMULATED_ERROR_MESSAGE ) { return @@ -87,22 +83,25 @@ export class AppDevOverlayErrorBoundary extends PureComponent< } reset = () => { - this.setState({ reactError: null }) + this.setState({ error: null }) } render() { const { children, globalError } = this.props - const { reactError } = this.state + const { error } = this.state - const fallback = ( - - ) + if (error !== null) { + const thrownValue = error.thrownValue + return ( + + ) + } - return reactError !== null ? fallback : children + return children } } diff --git a/packages/next/src/next-devtools/userspace/pages/pages-dev-overlay-error-boundary.tsx b/packages/next/src/next-devtools/userspace/pages/pages-dev-overlay-error-boundary.tsx index 3d61edcc2464..4ee4753a9cf9 100644 --- a/packages/next/src/next-devtools/userspace/pages/pages-dev-overlay-error-boundary.tsx +++ b/packages/next/src/next-devtools/userspace/pages/pages-dev-overlay-error-boundary.tsx @@ -3,21 +3,25 @@ import React from 'react' type PagesDevOverlayErrorBoundaryProps = { children?: React.ReactNode } -type PagesDevOverlayErrorBoundaryState = { error: Error | null } +type PagesDevOverlayErrorBoundaryState = { + hasError: boolean +} export class PagesDevOverlayErrorBoundary extends React.PureComponent< PagesDevOverlayErrorBoundaryProps, PagesDevOverlayErrorBoundaryState > { - state = { error: null } + state = { hasError: false } - static getDerivedStateFromError(error: Error) { - return { error } + static getDerivedStateFromError( + _: unknown + ): Partial { + return { hasError: true } } // Explicit type is needed to avoid the generated `.d.ts` having a wide return type that could be specific to the `@types/react` version. render(): React.ReactNode { // The component has to be unmounted or else it would continue to error - return this.state.error ? null : this.props.children + return this.state.hasError ? null : this.props.children } } diff --git a/test/e2e/app-dir/catch-error/app/client-component/catch-error-wrapper.tsx b/test/e2e/app-dir/catch-error/app/client-component/catch-error-wrapper.tsx index e5b75997081d..f459c29bba49 100644 --- a/test/e2e/app-dir/catch-error/app/client-component/catch-error-wrapper.tsx +++ b/test/e2e/app-dir/catch-error/app/client-component/catch-error-wrapper.tsx @@ -9,7 +9,7 @@ export function ErrorFallback( ) { return ( <> -

{error.message}

+

{(error as Error).message}

{props.title}

+ ) +} + +function ErrorFallback(_props: {}, { error }: ErrorInfo) { + return

{`An error occurred: ${error}`}

+} + +const Wrapped = unstable_catchError(ErrorFallback) + +export default function Page() { + return ( + + + + ) +} diff --git a/test/e2e/app-dir/catch-error/app/client-component/throw-undefined/page.tsx b/test/e2e/app-dir/catch-error/app/client-component/throw-undefined/page.tsx new file mode 100644 index 000000000000..cf444431ad4f --- /dev/null +++ b/test/e2e/app-dir/catch-error/app/client-component/throw-undefined/page.tsx @@ -0,0 +1,37 @@ +'use client' + +import { useState } from 'react' +import type { ErrorInfo } from 'next/error' +import { unstable_catchError } from 'next/error' + +function Inner() { + const [clicked, setClicked] = useState(false) + if (clicked) { + // eslint-disable-next-line no-throw-literal -- testing bad values on purpose + throw undefined + } + return ( + + ) +} + +function ErrorFallback(_props: {}, { error }: ErrorInfo) { + return

{`An error occurred: ${error}`}

+} + +const Wrapped = unstable_catchError(ErrorFallback) + +export default function Page() { + return ( + + + + ) +} diff --git a/test/e2e/app-dir/catch-error/app/server-component/catch-error-wrapper.tsx b/test/e2e/app-dir/catch-error/app/server-component/catch-error-wrapper.tsx index e3879f6facf0..af731f397857 100644 --- a/test/e2e/app-dir/catch-error/app/server-component/catch-error-wrapper.tsx +++ b/test/e2e/app-dir/catch-error/app/server-component/catch-error-wrapper.tsx @@ -8,7 +8,7 @@ export function ErrorFallback( ) { return ( <> -

{error.message}

+

{(error as Error).message}

{props.title}

+ ) +} diff --git a/test/e2e/app-dir/errors/app/client-component/throw-undefined/error.js b/test/e2e/app-dir/errors/app/client-component/throw-undefined/error.js new file mode 100644 index 000000000000..9b4f7536053a --- /dev/null +++ b/test/e2e/app-dir/errors/app/client-component/throw-undefined/error.js @@ -0,0 +1,5 @@ +'use client' + +export default function ErrorBoundary({ error }) { + return

{`An error occurred: ${error}`}

+} diff --git a/test/e2e/app-dir/errors/app/client-component/throw-undefined/page.js b/test/e2e/app-dir/errors/app/client-component/throw-undefined/page.js new file mode 100644 index 000000000000..b6c7d3adeb4e --- /dev/null +++ b/test/e2e/app-dir/errors/app/client-component/throw-undefined/page.js @@ -0,0 +1,21 @@ +'use client' + +import { useState } from 'react' + +export default function Page() { + const [clicked, setClicked] = useState(false) + if (clicked) { + // eslint-disable-next-line no-throw-literal -- testing bad values on purpose + throw undefined + } + return ( + + ) +} diff --git a/test/e2e/app-dir/errors/app/server-component/throw-null/error.js b/test/e2e/app-dir/errors/app/server-component/throw-null/error.js new file mode 100644 index 000000000000..d6b44d2baa00 --- /dev/null +++ b/test/e2e/app-dir/errors/app/server-component/throw-null/error.js @@ -0,0 +1,10 @@ +'use client' + +export default function ErrorBoundary({ error }) { + return ( +
+

{`An error occurred: ${error}`}

+

{`${error?.digest}`}

+
+ ) +} diff --git a/test/e2e/app-dir/errors/app/server-component/throw-undefined/error.js b/test/e2e/app-dir/errors/app/server-component/throw-undefined/error.js new file mode 100644 index 000000000000..d6b44d2baa00 --- /dev/null +++ b/test/e2e/app-dir/errors/app/server-component/throw-undefined/error.js @@ -0,0 +1,10 @@ +'use client' + +export default function ErrorBoundary({ error }) { + return ( +
+

{`An error occurred: ${error}`}

+

{`${error?.digest}`}

+
+ ) +} diff --git a/test/e2e/app-dir/errors/index.test.ts b/test/e2e/app-dir/errors/index.test.ts index e45d61a10ef8..d21b06827c69 100644 --- a/test/e2e/app-dir/errors/index.test.ts +++ b/test/e2e/app-dir/errors/index.test.ts @@ -39,6 +39,44 @@ describe('app-dir - errors', () => { expect(pageErrors).toEqual([]) }) + it('should trigger error component when undefined is thrown from a client component in the browser', async () => { + const pageErrors: unknown[] = [] + const browser = await next.browser('/client-component/throw-undefined', { + beforePageLoad: (page) => { + page.on('pageerror', (error: unknown) => { + pageErrors.push(error) + }) + }, + }) + await browser.elementByCss('#error-trigger-button').click() + + expect( + await browser.waitForElementByCss('#error-boundary-message').text() + ).toBe('An error occurred: undefined') + + // Handled by custom error boundary. + expect(pageErrors).toEqual([]) + }) + + it('should trigger error component when null is thrown from a client component in the browser', async () => { + const pageErrors: unknown[] = [] + const browser = await next.browser('/client-component/throw-null', { + beforePageLoad: (page) => { + page.on('pageerror', (error: unknown) => { + pageErrors.push(error) + }) + }, + }) + await browser.elementByCss('#error-trigger-button').click() + + expect( + await browser.waitForElementByCss('#error-boundary-message').text() + ).toBe('An error occurred: null') + + // Handled by custom error boundary. + expect(pageErrors).toEqual([]) + }) + it('should trigger error component when an error happens during server components rendering', async () => { const pageErrors: unknown[] = [] const browser = await next.browser('/server-component', { @@ -97,12 +135,14 @@ describe('app-dir - errors', () => { const outputIndex = next.cliOutput.length const browser = await next.browser('/server-component/throw-undefined') + // Non-error values thrown during rendering get wrapped in an Error when transported over RSC, + // so we expect an error object with a digest. expect( await browser.waitForElementByCss('#error-boundary-message').text() ).toBe( isNextDev - ? 'undefined' - : 'Minified React error #441; visit https://react.dev/errors/441 for the full message or use the non-minified dev environment for full errors and additional helpful warnings.' + ? 'An error occurred: Error: undefined' + : 'An error occurred: Error: Minified React error #441; visit https://react.dev/errors/441 for the full message or use the non-minified dev environment for full errors and additional helpful warnings.' ) expect( await browser.waitForElementByCss('#error-boundary-digest').text() @@ -132,12 +172,15 @@ describe('app-dir - errors', () => { const outputIndex = next.cliOutput.length const browser = await next.browser('/server-component/throw-null') + // Non-error values thrown during rendering get wrapped in an Error when transported over RSC, + // so we expect an error object with a digest. + expect( await browser.waitForElementByCss('#error-boundary-message').text() ).toBe( isNextDev - ? 'null' - : 'Minified React error #441; visit https://react.dev/errors/441 for the full message or use the non-minified dev environment for full errors and additional helpful warnings.' + ? 'An error occurred: Error: null' + : 'An error occurred: Error: Minified React error #441; visit https://react.dev/errors/441 for the full message or use the non-minified dev environment for full errors and additional helpful warnings.' ) expect( await browser.waitForElementByCss('#error-boundary-digest').text() diff --git a/test/e2e/app-dir/global-error/basic/app/client-throw-null/page.js b/test/e2e/app-dir/global-error/basic/app/client-throw-null/page.js new file mode 100644 index 000000000000..ce9d13f0d391 --- /dev/null +++ b/test/e2e/app-dir/global-error/basic/app/client-throw-null/page.js @@ -0,0 +1,21 @@ +'use client' + +import { useState } from 'react' + +export default function Page() { + const [clicked, setClicked] = useState(false) + if (clicked) { + // eslint-disable-next-line no-throw-literal -- testing bad values on purpose + throw null + } + return ( + + ) +} diff --git a/test/e2e/app-dir/global-error/basic/app/client-throw-undefined/page.js b/test/e2e/app-dir/global-error/basic/app/client-throw-undefined/page.js new file mode 100644 index 000000000000..b6c7d3adeb4e --- /dev/null +++ b/test/e2e/app-dir/global-error/basic/app/client-throw-undefined/page.js @@ -0,0 +1,21 @@ +'use client' + +import { useState } from 'react' + +export default function Page() { + const [clicked, setClicked] = useState(false) + if (clicked) { + // eslint-disable-next-line no-throw-literal -- testing bad values on purpose + throw undefined + } + return ( + + ) +} diff --git a/test/e2e/app-dir/global-error/basic/app/global-error.js b/test/e2e/app-dir/global-error/basic/app/global-error.js index 40d18f27cf4d..1b1def2f79ff 100644 --- a/test/e2e/app-dir/global-error/basic/app/global-error.js +++ b/test/e2e/app-dir/global-error/basic/app/global-error.js @@ -6,7 +6,7 @@ export default function GlobalError({ error }) {

Global Error

-

{`Global error: ${error?.message}`}

+

{`Global error: ${error}`}

{error?.digest &&

{error?.digest}

} diff --git a/test/e2e/app-dir/global-error/basic/app/rsc-throw-null/page.js b/test/e2e/app-dir/global-error/basic/app/rsc-throw-null/page.js new file mode 100644 index 000000000000..e77bd6455174 --- /dev/null +++ b/test/e2e/app-dir/global-error/basic/app/rsc-throw-null/page.js @@ -0,0 +1,4 @@ +export default function page() { + // eslint-disable-next-line no-throw-literal -- testing bad values on purpose + throw null +} diff --git a/test/e2e/app-dir/global-error/basic/app/rsc-throw-undefined/page.js b/test/e2e/app-dir/global-error/basic/app/rsc-throw-undefined/page.js new file mode 100644 index 000000000000..aee20ec6156f --- /dev/null +++ b/test/e2e/app-dir/global-error/basic/app/rsc-throw-undefined/page.js @@ -0,0 +1,4 @@ +export default function page() { + // eslint-disable-next-line no-throw-literal -- testing bad values on purpose + throw undefined +} diff --git a/test/e2e/app-dir/global-error/basic/index.test.ts b/test/e2e/app-dir/global-error/basic/index.test.ts index d49cd5aca2b7..2aa70f412713 100644 --- a/test/e2e/app-dir/global-error/basic/index.test.ts +++ b/test/e2e/app-dir/global-error/basic/index.test.ts @@ -28,7 +28,7 @@ describe('app dir - global-error', () => { `) } expect(await browser.elementByCss('#error').text()).toBe( - 'Global error: Client error' + 'Global error: Error: Client error' ) }) @@ -54,8 +54,8 @@ describe('app dir - global-error', () => { // Show original error message in dev mode, but hide with the react fallback RSC error message in production mode expect(await browser.elementByCss('#error').text()).toBe( isNextDev - ? 'Global error: server page error' - : 'Global error: Minified React error #441; visit https://react.dev/errors/441 for the full message or use the non-minified dev environment for full errors and additional helpful warnings.' + ? 'Global error: Error: server page error' + : 'Global error: Error: Minified React error #441; visit https://react.dev/errors/441 for the full message or use the non-minified dev environment for full errors and additional helpful warnings.' ) expect(await browser.elementByCss('#digest').text()).toMatch(/\w+/) }) @@ -80,12 +80,58 @@ describe('app dir - global-error', () => { } expect(await browser.elementByCss('h1').text()).toBe('Global Error') expect(await browser.elementByCss('#error').text()).toBe( - 'Global error: client page error' + 'Global error: Error: client page error' ) expect(await browser.hasElementByCssSelector('#digest')).toBeFalsy() }) + it('should render global error when undefined is thrown in a server component', async () => { + const browser = await next.browser('/rsc-throw-undefined') + // Non-error values thrown during RSC render get wrapped in an Error when transported. + expect(await browser.waitForElementByCss('#error').text()).toBe( + isNextDev + ? 'Global error: Error: undefined' + : 'Global error: Error: Minified React error #441; visit https://react.dev/errors/441 for the full message or use the non-minified dev environment for full errors and additional helpful warnings.' + ) + expect(await browser.elementByCss('h1').text()).toBe('Global Error') + }) + + it('should render global error when null is thrown in a server component', async () => { + const browser = await next.browser('/rsc-throw-null') + // Non-error values thrown during RSC render get wrapped in an Error when transported. + expect(await browser.waitForElementByCss('#error').text()).toBe( + isNextDev + ? 'Global error: Error: null' + : 'Global error: Error: Minified React error #441; visit https://react.dev/errors/441 for the full message or use the non-minified dev environment for full errors and additional helpful warnings.' + ) + expect(await browser.elementByCss('h1').text()).toBe('Global Error') + }) + + it('should render global error when undefined is thrown in a client component', async () => { + const browser = await next.browser('/client-throw-undefined') + await browser + .waitForElementByCss('#error-trigger-button') + .elementByCss('#error-trigger-button') + .click() + expect(await browser.waitForElementByCss('#error').text()).toBe( + 'Global error: undefined' + ) + expect(await browser.elementByCss('h1').text()).toBe('Global Error') + }) + + it('should render global error when null is thrown in a client component', async () => { + const browser = await next.browser('/client-throw-null') + await browser + .waitForElementByCss('#error-trigger-button') + .elementByCss('#error-trigger-button') + .click() + expect(await browser.waitForElementByCss('#error').text()).toBe( + 'Global error: null' + ) + expect(await browser.elementByCss('h1').text()).toBe('Global Error') + }) + it('should catch metadata error in error boundary if presented', async () => { const browser = await next.browser('/metadata-error-with-boundary') @@ -116,8 +162,8 @@ describe('app dir - global-error', () => { expect(await browser.elementByCss('h1').text()).toBe('Global Error') expect(await browser.elementByCss('#error').text()).toBe( isNextDev - ? 'Global error: Metadata error' - : 'Global error: Minified React error #441; visit https://react.dev/errors/441 for the full message or use the non-minified dev environment for full errors and additional helpful warnings.' + ? 'Global error: Error: Metadata error' + : 'Global error: Error: Minified React error #441; visit https://react.dev/errors/441 for the full message or use the non-minified dev environment for full errors and additional helpful warnings.' ) }) @@ -140,7 +186,7 @@ describe('app dir - global-error', () => { } expect(await browser.elementByCss('h1').text()).toBe('Global Error') expect(await browser.elementByCss('#error').text()).toBe( - 'Global error: nested error' + 'Global error: Error: nested error' ) }) }) From f3f7c7c09b0ad2f79f9c4ac0b464af23721dedbc Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Wed, 29 Apr 2026 19:55:29 +0200 Subject: [PATCH 6/6] Update upload trace url (#93350) ## What? Updates the trace upload url as it has been moved. Since this subcommand is only available on canary currently the previous API url will not be preserved. --- packages/next/src/cli/internal/upload-trace.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/next/src/cli/internal/upload-trace.ts b/packages/next/src/cli/internal/upload-trace.ts index 3ed3903b61b2..831d2ab43dc3 100644 --- a/packages/next/src/cli/internal/upload-trace.ts +++ b/packages/next/src/cli/internal/upload-trace.ts @@ -1,7 +1,7 @@ import path from 'path' import fs from 'fs/promises' -const UPLOAD_TRACE_URL = 'https://api.nextjs.org/api/upload-trace' +const UPLOAD_TRACE_URL = 'https://nextjs.org/api/upload-trace' // V8 CPU profiles are JSON objects starting with {"nodes": const CPUPROFILE_HEADER = Buffer.from('{"nodes":')