diff --git a/Cargo.lock b/Cargo.lock index 6e069ac13edb..1f5903fe2cec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9994,6 +9994,7 @@ dependencies = [ "bincode 2.0.1", "indexmap 2.13.0", "serde", + "serde_json", ] [[package]] @@ -10527,6 +10528,7 @@ dependencies = [ "const_format", "data-encoding", "either", + "erased-serde", "indexmap 2.13.0", "num-bigint", "patricia_tree", @@ -10702,6 +10704,7 @@ dependencies = [ "serde", "serde_json", "turbo-rcstr", + "turbo-tasks", "turbopack-cli-utils", "turbopack-core", ] diff --git a/crates/next-api/src/aggregate_hmr.rs b/crates/next-api/src/aggregate_hmr.rs index 895e67f6710d..c773cca54302 100644 --- a/crates/next-api/src/aggregate_hmr.rs +++ b/crates/next-api/src/aggregate_hmr.rs @@ -1,7 +1,4 @@ -use std::sync::Arc; - use anyhow::Result; -use rustc_hash::FxHashMap; use turbo_rcstr::RcStr; use turbo_tasks::{ FxIndexMap, FxIndexSet, NonLocalValue, ReadRef, ResolvedVc, TraitRef, TryJoinIterExt, Vc, @@ -10,7 +7,14 @@ use turbo_tasks::{ use turbo_tasks_fs::FileSystemPath; use turbo_tasks_hash::{Xxh3Hash64Hasher, encode_base64}; use turbopack_browser::ecmascript::list::content::EcmascriptDevChunkListContent; -use turbopack_core::version::{PartialUpdate, Update, Version, VersionState, VersionedContent}; +use turbopack_core::{ + update_instruction::UpdateInstruction, + version::{PartialUpdate, Update, Version, VersionState, VersionedContent}, +}; +use turbopack_ecmascript::chunk_list::{ + merged_update::EcmascriptMergedUpdate, + update::{ChunkListUpdate, ChunkUpdate, EcmascriptUpdateInstruction}, +}; use turbopack_nodejs::ecmascript::node::entry::chunk_list_content::EcmascriptBuildNodeChunkListContent; use crate::versioned_content_map::VersionedContentMap; @@ -103,38 +107,30 @@ impl AggregateHmrVersion { /// Aggregates per-entry HMR instructions into a single combined `ChunkListUpdate`. #[derive(Default)] pub struct ChunkListUpdateBuilder { - chunks: FxHashMap, - merged: FxIndexSet, + chunks: FxIndexMap, + merged: FxIndexSet, } impl ChunkListUpdateBuilder { - pub fn add_instruction(&mut self, instruction: &serde_json::Value) { - let Some(obj) = instruction.as_object() else { - return; - }; - match obj.get("type").and_then(|v| v.as_str()) { - Some("ChunkListUpdate") => { - if let Some(chunks) = obj.get("chunks").and_then(|v| v.as_object()) { - for (k, v) in chunks { - self.chunks.insert(k.clone(), v.clone()); - } + pub fn add_instruction(&mut self, instruction: &UpdateInstruction) { + let instruction = instruction + .downcast_ref::() + .expect("aggregate HMR only accepts ECMAScript update instructions"); + + match instruction { + EcmascriptUpdateInstruction::ChunkList(update) => { + for (chunk_path, update) in &update.chunks { + self.chunks.insert(chunk_path.clone(), update.clone()); } - if let Some(merged) = obj.get("merged").and_then(|v| v.as_array()) { - for update in merged { - self.push_merged(update); - } + for update in &update.merged { + self.push_merged(update); } } - Some("EcmascriptMergedUpdate") => { - self.push_merged(instruction); - } - // Unknown instruction shapes are ignored; the caller already - // escalates `Total`/`Missing` updates to a full restart. - _ => {} + EcmascriptUpdateInstruction::Merged(update) => self.push_merged(update), } } - fn push_merged(&mut self, update: &serde_json::Value) { + fn push_merged(&mut self, update: &EcmascriptMergedUpdate) { self.merged.insert(update.clone()); } @@ -143,26 +139,13 @@ impl ChunkListUpdateBuilder { } pub fn build(self, to: TraitRef>) -> Update { - let mut instruction = serde_json::Map::new(); - instruction.insert( - "type".to_string(), - serde_json::Value::String("ChunkListUpdate".to_string()), - ); - if !self.chunks.is_empty() { - instruction.insert( - "chunks".to_string(), - serde_json::Value::Object(self.chunks.into_iter().collect()), - ); - } - if !self.merged.is_empty() { - instruction.insert( - "merged".to_string(), - serde_json::Value::Array(self.merged.into_iter().collect()), - ); - } Update::Partial(PartialUpdate { to, - instruction: Arc::new(serde_json::Value::Object(instruction)), + instruction: ChunkListUpdate { + chunks: self.chunks, + merged: self.merged.into_iter().collect(), + } + .into_instruction(), }) } } @@ -222,3 +205,82 @@ pub async fn diff_chunks_against( has_new_chunks, }) } + +#[cfg(test)] +mod tests { + use turbo_tasks::{FxIndexMap, FxIndexSet}; + use turbopack_core::update_instruction::UpdateInstruction; + use turbopack_ecmascript::chunk_list::{ + merged_update::{ + EcmascriptMergedChunkDeleted, EcmascriptMergedChunkUpdate, EcmascriptMergedUpdate, + }, + update::{ChunkListUpdate, ChunkUpdate, EcmascriptUpdateInstruction}, + }; + + use super::ChunkListUpdateBuilder; + + fn merged(chunk_path: &str) -> EcmascriptMergedUpdate { + EcmascriptMergedUpdate { + entries: Default::default(), + chunks: [( + chunk_path.into(), + EcmascriptMergedChunkUpdate::Deleted(EcmascriptMergedChunkDeleted { + modules: Default::default(), + }), + )] + .into_iter() + .collect(), + } + } + + #[test] + fn deduplicates_merged_updates_in_first_seen_order() { + let first = merged("first.js"); + let second = merged("second.js"); + let mut builder = ChunkListUpdateBuilder::default(); + + builder.add_instruction(&UpdateInstruction::new( + EcmascriptUpdateInstruction::Merged(first.clone()), + )); + builder.add_instruction(&UpdateInstruction::new( + EcmascriptUpdateInstruction::Merged(second.clone()), + )); + builder.add_instruction(&UpdateInstruction::new( + EcmascriptUpdateInstruction::Merged(first.clone()), + )); + + assert_eq!(builder.merged, FxIndexSet::from_iter([first, second])); + } + + #[test] + fn chunk_updates_use_last_writer_and_stable_order() { + let mut builder = ChunkListUpdateBuilder::default(); + let first = ChunkListUpdate { + chunks: FxIndexMap::from_iter([ + ("a.js".into(), ChunkUpdate::Total), + ("b.js".into(), ChunkUpdate::Added), + ]), + merged: vec![], + }; + let second = ChunkListUpdate { + chunks: FxIndexMap::from_iter([ + ("a.js".into(), ChunkUpdate::Deleted), + ("c.js".into(), ChunkUpdate::Total), + ]), + merged: vec![], + }; + + builder.add_instruction(&first.into_instruction()); + builder.add_instruction(&second.into_instruction()); + + assert_eq!( + builder + .chunks + .keys() + .map(|path| path.as_str()) + .collect::>(), + ["a.js", "b.js", "c.js"] + ); + assert_eq!(builder.chunks["a.js"], ChunkUpdate::Deleted); + } +} diff --git a/crates/next-api/src/project.rs b/crates/next-api/src/project.rs index 5cfc1be77210..2b8cd228caa4 100644 --- a/crates/next-api/src/project.rs +++ b/crates/next-api/src/project.rs @@ -179,39 +179,6 @@ pub struct DebugBuildPaths { pub pages: Vec, } -/// Target for HMR operations - client-side (browser) or server-side (Node.js). -#[turbo_tasks::task_input] -#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Hash, TraceRawVcs, Encode, Decode)] -pub enum HmrTarget { - #[default] - Client, - Server, -} - -impl std::fmt::Display for HmrTarget { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - HmrTarget::Client => write!(f, "client"), - HmrTarget::Server => write!(f, "server"), - } - } -} - -impl std::str::FromStr for HmrTarget { - type Err = String; - - fn from_str(s: &str) -> Result { - match s { - "client" => Ok(HmrTarget::Client), - "server" => Ok(HmrTarget::Server), - _ => Err(format!( - "Invalid HMR target: '{}'. Expected 'client' or 'server'", - s - )), - } - } -} - /// Pre-converted route keys from debug build paths for O(1) lookups. struct DebugBuildPathsRouteKeys { app: FxHashSet, @@ -894,8 +861,8 @@ impl ProjectContainer { /// See [`Project::hmr_chunk_names`]. #[turbo_tasks::function] - pub fn hmr_chunk_names(self: Vc, target: HmrTarget) -> Vc> { - self.project().hmr_chunk_names(target) + pub fn hmr_chunk_names(self: Vc) -> Vc> { + self.project().hmr_chunk_names() } /// Gets a source map for a particular `file_path`. If `dev` mode is disabled, this will always @@ -2491,36 +2458,16 @@ impl Project { .await } - /// Returns the root path for HMR content based on the target. - /// Client uses client_relative_path, Server uses node_root. - #[turbo_tasks::function] - async fn hmr_root_path(self: Vc, target: HmrTarget) -> Result> { - Ok(match target { - HmrTarget::Client => self.client_relative_path(), - HmrTarget::Server => self.node_root(), - }) - } - #[turbo_tasks::function] - async fn aggregate_hmr_root_path( - self: Vc, - target: HmrTarget, - ) -> Result> { - match target { - HmrTarget::Client => bail!("aggregate HMR is not implemented for the client"), - HmrTarget::Server => Ok(self.node_root().await?.join("server/app")?.cell()), - } + async fn server_hmr_root_path(self: Vc) -> Result> { + Ok(self.node_root().await?.join("server/app")?.cell()) } - /// Get HMR content by chunk_name for the specified target. + /// Get client HMR content by chunk_name. #[turbo_tasks::function] - async fn hmr_content( - self: Vc, - chunk_name: RcStr, - target: HmrTarget, - ) -> Result> { + async fn hmr_content(self: Vc, chunk_name: RcStr) -> Result> { if let Some(map) = self.await?.versioned_content_map { - let content = map.get(self.hmr_root_path(target).await?.join(&chunk_name)?); + let content = map.get(self.client_relative_path().await?.join(&chunk_name)?); Ok(content) } else { bail!("must be in dev mode to hmr") @@ -2533,7 +2480,6 @@ impl Project { pub async fn hmr_version_state( self: ResolvedVc, chunk_name: RcStr, - target: HmrTarget, session: TransientInstance<()>, ) -> Result> { // The session argument is important to avoid caching this function between @@ -2544,23 +2490,22 @@ impl Project { level = "info", name = "get HMR version", skip_all, - fields(chunk_name = %chunk_name, target = %target), + fields(chunk_name = %chunk_name), )] #[turbo_tasks::function(operation, root)] async fn hmr_version_operation( this: ResolvedVc, chunk_name: RcStr, - target: HmrTarget, ) -> Result>> { - tracing::info!(chunk_name = %chunk_name, target = %target, "hmr subscription"); - let content = this.hmr_content(chunk_name, target).await?; + tracing::info!(chunk_name = %chunk_name, "hmr subscription"); + let content = this.hmr_content(chunk_name).await?; if let Some(content) = &*content { Ok(content.version()) } else { Ok(Vc::upcast(NotFoundVersion::new())) } } - let version_op = hmr_version_operation(self, chunk_name, target); + let version_op = hmr_version_operation(self, chunk_name); // INVALIDATION: This is intentionally untracked to avoid invalidating this // function completely. We want to initialize the VersionState with the @@ -2576,16 +2521,15 @@ impl Project { } /// Emits opaque HMR events whenever a change is detected in the chunk group - /// internally known as `chunk_name` for the specified target. + /// internally known as `chunk_name`. #[turbo_tasks::function] pub async fn hmr_update( self: Vc, chunk_name: RcStr, - target: HmrTarget, from: Vc, ) -> Result> { let from = from.get(); - let content = self.hmr_content(chunk_name, target).await?; + let content = self.hmr_content(chunk_name).await?; if let Some(content) = *content { Ok(content.update(from)) } else { @@ -2594,35 +2538,21 @@ impl Project { } /// Aggregate counterpart to [`Self::hmr_version_state`]: one [`VersionState`] - /// covering every HMR-eligible chunk under `target`'s root. See - /// [`Self::all_hmr_update`]. + /// covering every server HMR-eligible chunk. See [`Self::server_hmr_update`]. #[turbo_tasks::function(session_dependent)] - pub async fn all_hmr_version_state( - self: ResolvedVc, - target: HmrTarget, - ) -> Result> { - if target == HmrTarget::Client { - bail!("all_hmr_version_state is not yet implemented for the client target"); - } - - #[tracing::instrument( - level = "info", - name = "get aggregate HMR version", - skip_all, - fields(target = %target), - )] + pub async fn server_hmr_version_state(self: ResolvedVc) -> Result> { + #[tracing::instrument(level = "info", name = "get server HMR version", skip_all)] #[turbo_tasks::function(operation, root)] - async fn aggregate_hmr_version_operation( + async fn server_hmr_version_operation( this: ResolvedVc, - target: HmrTarget, ) -> Result>> { let Some(map) = this.await?.versioned_content_map else { bail!("must be in dev mode to hmr") }; - let root = this.aggregate_hmr_root_path(target).owned().await?; + let root = this.server_hmr_root_path().owned().await?; AggregateHmrVersion::from_map(*map, root).await } - let version_op = aggregate_hmr_version_operation(self, target); + let version_op = server_hmr_version_operation(self); // INVALIDATION: untracked initial read; the subscription drives invalidation. let state = VersionState::new( @@ -2636,8 +2566,7 @@ impl Project { } /// Aggregate counterpart to [`Self::hmr_update`]: a single `Update` whose - /// combined `ChunkListUpdate` is the union of the per-entry-chunk diffs - /// under `target`'s root. + /// combined `ChunkListUpdate` is the union of the server entry chunk diffs. /// /// Each tracked entry chunk's own update is a `ChunkListUpdate` (carrying /// the module deltas for its shared chunks via the merger) or a bare @@ -2649,19 +2578,11 @@ impl Project { /// chunks absent from `from` are skipped; the runtime require()s them on /// demand. #[turbo_tasks::function] - pub async fn all_hmr_update( - self: Vc, - target: HmrTarget, - from: Vc, - ) -> Result> { - if target == HmrTarget::Client { - bail!("all_hmr_update is not yet implemented for the client target"); - } - + pub async fn server_hmr_update(self: Vc, from: Vc) -> Result> { let Some(map) = self.await?.versioned_content_map else { bail!("must be in dev mode to hmr") }; - let root = self.aggregate_hmr_root_path(target).owned().await?; + let root = self.server_hmr_root_path().owned().await?; let chunks_versioned_content = map.hmr_chunks_in_path(root).await?; // No chunks to diff yet (e.g. before any endpoints have been written). @@ -2710,14 +2631,11 @@ impl Project { Ok(builder.build(to_ref).cell()) } - /// Gets a list of all HMR chunk names that can be subscribed to for the - /// specified target. Used by the dev server to set up server-side HMR - /// subscriptions for all Node.js App Router entries (pages and route - /// handlers). + /// Gets a list of all client HMR chunk names that can be subscribed to. #[turbo_tasks::function] - pub async fn hmr_chunk_names(self: Vc, target: HmrTarget) -> Result>> { + pub async fn hmr_chunk_names(self: Vc) -> Result>> { if let Some(map) = self.await?.versioned_content_map { - Ok(map.keys_in_path(self.hmr_root_path(target).owned().await?)) + Ok(map.keys_in_path(self.client_relative_path().owned().await?)) } else { bail!("must be in dev mode to hmr") } diff --git a/crates/next-build-test/src/lib.rs b/crates/next-build-test/src/lib.rs index f93be9adb732..e9ea3a4776cb 100644 --- a/crates/next-build-test/src/lib.rs +++ b/crates/next-build-test/src/lib.rs @@ -8,7 +8,7 @@ use anyhow::{Context, Result, bail}; use futures_util::{StreamExt, TryStreamExt}; use next_api::{ entrypoints::Entrypoints, - project::{HmrTarget, ProjectContainer, ProjectOptions}, + project::{ProjectContainer, ProjectOptions}, route::{Endpoint, EndpointOutputPaths, Route, endpoint_write_to_disk}, }; use turbo_rcstr::{RcStr, rcstr}; @@ -292,7 +292,7 @@ async fn hmr( #[turbo_tasks::function(operation, root)] fn project_hmr_chunk_names_operation(project: ResolvedVc) -> Vc> { - project.hmr_chunk_names(HmrTarget::Client) + project.hmr_chunk_names() } let idents = tt @@ -316,10 +316,8 @@ async fn hmr( let ident = ident_for_task.clone(); async move { let project = project.project(); - let state = project.hmr_version_state(ident.clone(), HmrTarget::Client, session); - project - .hmr_update(ident.clone(), HmrTarget::Client, state) - .await?; + let state = project.hmr_version_state(ident.clone(), session); + project.hmr_update(ident.clone(), state).await?; Ok(Vc::<()>::cell(())) } }); diff --git a/crates/next-napi-bindings/src/next_api/project.rs b/crates/next-napi-bindings/src/next_api/project.rs index 053380dd0cc6..d23497f285a5 100644 --- a/crates/next-napi-bindings/src/next_api/project.rs +++ b/crates/next-napi-bindings/src/next_api/project.rs @@ -26,7 +26,7 @@ use next_api::{ RouteOperation, }, project::{ - DebugBuildPaths, DefineEnv, DraftModeOptions, HmrTarget, PartialProjectOptions, Project, + DebugBuildPaths, DefineEnv, DraftModeOptions, PartialProjectOptions, Project, ProjectContainer, ProjectOptions, WatchOptions, }, project_asset_hashes_manifest::immutable_hashes_manifest_asset_if_enabled, @@ -1814,27 +1814,25 @@ struct HmrUpdateWithIssues { fn project_hmr_update_operation( project: ResolvedVc, chunk_name: RcStr, - target: HmrTarget, state: ResolvedVc, ) -> Vc { - project.hmr_update(chunk_name, target, *state) + project.hmr_update(chunk_name, *state) } #[tracing::instrument( level = "info", name = "hmr subscription", skip_all, - fields(chunk_name = %chunk_name, target = %target), + fields(chunk_name = %chunk_name), )] #[turbo_tasks::function(operation, root)] async fn hmr_update_with_issues_operation( project: ResolvedVc, chunk_name: RcStr, state: ResolvedVc, - target: HmrTarget, ) -> Result> { - tracing::info!(chunk_name = %chunk_name, target = %target, "hmr subscription"); - let update_op = project_hmr_update_operation(project, chunk_name, target, state); + tracing::info!(chunk_name = %chunk_name, "hmr subscription"); + let update_op = project_hmr_update_operation(project, chunk_name, state); // NOTE: we do not use `strongly_consistent_catch_collectables` here. The JS HMR // consumers in `hot-reloader-turbopack.ts` (`subscribeToServerHmr` and // `subscribeToClientHmrEvents`) rely on this read *throwing* on build-graph @@ -1853,29 +1851,22 @@ async fn hmr_update_with_issues_operation( /// Aggregate counterpart to [`project_hmr_update_operation`]. #[turbo_tasks::function(operation, root)] -fn project_all_hmr_update_operation( +fn project_server_hmr_update_operation( project: ResolvedVc, - target: HmrTarget, state: ResolvedVc, ) -> Vc { - project.all_hmr_update(target, *state) + project.server_hmr_update(*state) } /// Aggregate counterpart to [`hmr_update_with_issues_operation`]. -#[tracing::instrument( - level = "info", - name = "aggregate hmr subscription", - skip_all, - fields(target = %target), -)] +#[tracing::instrument(level = "info", name = "server hmr subscription", skip_all)] #[turbo_tasks::function(operation, root)] -async fn all_hmr_update_with_issues_operation( +async fn server_hmr_update_with_issues_operation( project: ResolvedVc, state: ResolvedVc, - target: HmrTarget, ) -> Result> { - tracing::info!(target = %target, "aggregate hmr subscription"); - let update_op = project_all_hmr_update_operation(project, target, state); + tracing::info!("server hmr subscription"); + let update_op = project_server_hmr_update_operation(project, state); // See `hmr_update_with_issues_operation`: the JS consumer relies on this // read *throwing* on build-graph failures; don't swallow errors. let update = update_op @@ -1893,19 +1884,18 @@ async fn all_hmr_update_with_issues_operation( .cell()) } -#[tracing::instrument(level = "info", name = "get all HMR events", skip(env, project, func), fields(target = %target))] +#[tracing::instrument( + level = "info", + name = "get server HMR events", + skip(env, project, func) +)] #[napi(ts_return_type = "{ __napiType: \"RootTask\" }")] -pub fn project_all_hmr_events( +pub fn project_server_hmr_events( env: Env, #[napi(ts_arg_type = "{ __napiType: \"Project\" }")] project: &External, - target: String, #[napi(ts_arg_type = "(err: Error, value: TurbopackResult) => void")] func: FunctionRef>, ()>, ) -> napi::Result> { - let hmr_target = target - .parse::() - .map_err(napi::Error::from_reason)?; - let container = project.container; // Sentinel resource id for the aggregated stream (no real chunk path). let identifier_path: RcStr = rcstr!("__next_all_hmr__"); @@ -1918,12 +1908,9 @@ pub fn project_all_hmr_events( unmark_top_level_task_may_leak_eventually_consistent_state(); let project = container.project().to_resolved().await?; - let state = project - .all_hmr_version_state(hmr_target) - .to_resolved() - .await?; + let state = project.server_hmr_version_state().to_resolved().await?; - let update_op = all_hmr_update_with_issues_operation(project, state, hmr_target); + let update_op = server_hmr_update_with_issues_operation(project, state); // HACK(bgw): Remove this mark call mark_top_level_task(); @@ -1982,20 +1969,17 @@ pub fn project_all_hmr_events( ) } -#[tracing::instrument(level = "info", name = "get HMR events", skip(env, project, func), fields(target = %target, chunk_name = %chunk_name))] +#[tracing::instrument(level = "info", name = "get client HMR events", skip(env, project, func), fields(chunk_name = %chunk_name))] #[napi(ts_return_type = "{ __napiType: \"RootTask\" }")] -pub fn project_hmr_events( +pub fn project_client_hmr_events( env: Env, #[napi(ts_arg_type = "{ __napiType: \"Project\" }")] project: &External, chunk_name: RcStr, - target: String, - #[napi(ts_arg_type = "(err: Error, value: TurbopackResult) => void")] - func: FunctionRef>, ()>, + #[napi(ts_arg_type = "(err: Error, value: TurbopackResult) => void")] func: FunctionRef< + TurbopackResult>, + (), + >, ) -> napi::Result> { - let hmr_target = target - .parse::() - .map_err(napi::Error::from_reason)?; - let container = project.container; let session = TransientInstance::new(()); subscribe( @@ -2013,16 +1997,12 @@ pub fn project_hmr_events( unmark_top_level_task_may_leak_eventually_consistent_state(); let project = container.project().to_resolved().await?; let state = project - .hmr_version_state(chunk_name.clone(), hmr_target, session) + .hmr_version_state(chunk_name.clone(), session) .to_resolved() .await?; - let update_op = hmr_update_with_issues_operation( - project, - chunk_name.clone(), - state, - hmr_target, - ); + let update_op = + hmr_update_with_issues_operation(project, chunk_name.clone(), state); // HACK(bgw): Remove this mark call mark_top_level_task(); let read = @@ -2093,19 +2073,17 @@ struct HmrChunkNamesWithIssues { } #[turbo_tasks::function(operation, root)] -fn project_hmr_chunk_names_operation( +fn project_client_hmr_chunk_names_operation( container: ResolvedVc, - target: HmrTarget, ) -> Vc> { - container.hmr_chunk_names(target) + container.hmr_chunk_names() } #[turbo_tasks::function(operation, root)] -async fn get_hmr_chunk_names_with_issues_operation( +async fn get_client_hmr_chunk_names_with_issues_operation( container: ResolvedVc, - target: HmrTarget, ) -> Result> { - let hmr_chunk_names_op = project_hmr_chunk_names_operation(container, target); + let hmr_chunk_names_op = project_client_hmr_chunk_names_operation(container); // Do NOT switch this to `strongly_consistent_catch_collectables`. The JS HMR // chunk-names consumer in `hot-reloader-turbopack.ts` relies on this read // *throwing* on build-graph failures so its outer `try` block exits the @@ -2124,19 +2102,18 @@ async fn get_hmr_chunk_names_with_issues_operation( .cell()) } -#[tracing::instrument(level = "info", name = "get HMR chunk names", skip(env, project, func), fields(target = %target))] +#[tracing::instrument( + level = "info", + name = "get client HMR chunk names", + skip(env, project, func) +)] #[napi(ts_return_type = "{ __napiType: \"RootTask\" }")] -pub fn project_hmr_chunk_names_subscribe( +pub fn project_client_hmr_chunk_names_subscribe( env: Env, #[napi(ts_arg_type = "{ __napiType: \"Project\" }")] project: &External, - target: String, #[napi(ts_arg_type = "(err: Error, value: TurbopackResult) => void")] func: FunctionRef, ()>, ) -> napi::Result> { - let hmr_target = target - .parse::() - .map_err(napi::Error::from_reason)?; - let container = project.container; subscribe( project.turbopack_ctx.clone(), @@ -2144,7 +2121,7 @@ pub fn project_hmr_chunk_names_subscribe( &func, move || async move { let hmr_chunk_names_with_issues_op = - get_hmr_chunk_names_with_issues_operation(container, hmr_target); + get_client_hmr_chunk_names_with_issues_operation(container); let read = read_strongly_consistent_and_apply_effects(hmr_chunk_names_with_issues_op, |v| { &v.effects diff --git a/docs/01-app/02-guides/instant-navigation.mdx b/docs/01-app/02-guides/instant-navigation.mdx index b81a187c98c7..add69d286f10 100644 --- a/docs/01-app/02-guides/instant-navigation.mdx +++ b/docs/01-app/02-guides/instant-navigation.mdx @@ -577,7 +577,7 @@ For opted-out segments, the navigation blocks on the server. If the content depe ## Next steps -- [Adopting Partial Prefetching](/docs/app/guides/adopting-partial-prefetching) for the recommended `` defaults and the migration path off `unstable_eager` +- [Adopting Partial Prefetching](/docs/app/guides/adopting-partial-prefetching) for the recommended `` defaults and a step-by-step adoption path - [`instant` API reference](/docs/app/api-reference/file-conventions/route-segment-config/instant) for the full configuration - [Optimizing prefetching](/docs/app/guides/optimizing-prefetching) when parts of your route depend on URL data (`searchParams` or `params`) and should resolve before navigation - [Caching](/docs/app/getting-started/caching) for background on `use cache`, Suspense, and Partial Prerendering diff --git a/examples/with-ant-design/app/page.tsx b/examples/with-ant-design/app/page.tsx index c3ae654c0c32..3366650085d5 100644 --- a/examples/with-ant-design/app/page.tsx +++ b/examples/with-ant-design/app/page.tsx @@ -19,7 +19,7 @@ const HomePage = () => (
- +

Welcome to the world !

diff --git a/lerna.json b/lerna.json index ac12ebd57423..0eb4a94fa3fb 100644 --- a/lerna.json +++ b/lerna.json @@ -15,5 +15,5 @@ "registry": "https://registry.npmjs.org/" } }, - "version": "16.3.1-canary.24" + "version": "16.3.1-canary.25" } \ No newline at end of file diff --git a/packages/create-next-app/package.json b/packages/create-next-app/package.json index f8950dd7f932..211159a14744 100644 --- a/packages/create-next-app/package.json +++ b/packages/create-next-app/package.json @@ -1,6 +1,6 @@ { "name": "create-next-app", - "version": "16.3.1-canary.24", + "version": "16.3.1-canary.25", "keywords": [ "react", "next", diff --git a/packages/devlow-bench/package.json b/packages/devlow-bench/package.json index 2f66ad728027..b5f69ed0ec65 100644 --- a/packages/devlow-bench/package.json +++ b/packages/devlow-bench/package.json @@ -1,7 +1,7 @@ { "name": "@vercel/devlow-bench", "private": true, - "version": "16.3.1-canary.24", + "version": "16.3.1-canary.25", "description": "Benchmarking tool for the developer workflow", "repository": { "type": "git", diff --git a/packages/eslint-config-next/package.json b/packages/eslint-config-next/package.json index c1a8f834aa70..6b2a3f048be1 100644 --- a/packages/eslint-config-next/package.json +++ b/packages/eslint-config-next/package.json @@ -1,6 +1,6 @@ { "name": "eslint-config-next", - "version": "16.3.1-canary.24", + "version": "16.3.1-canary.25", "description": "ESLint configuration used by Next.js.", "license": "MIT", "repository": { @@ -12,7 +12,7 @@ "dist" ], "dependencies": { - "@next/eslint-plugin-next": "16.3.1-canary.24", + "@next/eslint-plugin-next": "16.3.1-canary.25", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", diff --git a/packages/eslint-plugin-internal/package.json b/packages/eslint-plugin-internal/package.json index 88d9d9c200e3..c12f6d6943b3 100644 --- a/packages/eslint-plugin-internal/package.json +++ b/packages/eslint-plugin-internal/package.json @@ -1,7 +1,7 @@ { "name": "@next/eslint-plugin-internal", "private": true, - "version": "16.3.1-canary.24", + "version": "16.3.1-canary.25", "description": "ESLint plugin for working on Next.js.", "exports": { ".": "./src/eslint-plugin-internal.js" diff --git a/packages/eslint-plugin-next/package.json b/packages/eslint-plugin-next/package.json index fb2f835e9167..89514a1db2be 100644 --- a/packages/eslint-plugin-next/package.json +++ b/packages/eslint-plugin-next/package.json @@ -1,6 +1,6 @@ { "name": "@next/eslint-plugin-next", - "version": "16.3.1-canary.24", + "version": "16.3.1-canary.25", "description": "ESLint plugin for Next.js.", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/font/package.json b/packages/font/package.json index 54d2387933f8..a44e1238775b 100644 --- a/packages/font/package.json +++ b/packages/font/package.json @@ -1,7 +1,7 @@ { "name": "@next/font", "private": true, - "version": "16.3.1-canary.24", + "version": "16.3.1-canary.25", "repository": { "url": "vercel/next.js", "directory": "packages/font" diff --git a/packages/next-bundle-analyzer/package.json b/packages/next-bundle-analyzer/package.json index e5d460d26ee8..cee12c930732 100644 --- a/packages/next-bundle-analyzer/package.json +++ b/packages/next-bundle-analyzer/package.json @@ -1,6 +1,6 @@ { "name": "@next/bundle-analyzer", - "version": "16.3.1-canary.24", + "version": "16.3.1-canary.25", "main": "index.js", "types": "index.d.ts", "license": "MIT", diff --git a/packages/next-codemod/package.json b/packages/next-codemod/package.json index ab88dc94269f..288baf0741e2 100644 --- a/packages/next-codemod/package.json +++ b/packages/next-codemod/package.json @@ -1,6 +1,6 @@ { "name": "@next/codemod", - "version": "16.3.1-canary.24", + "version": "16.3.1-canary.25", "license": "MIT", "repository": { "type": "git", diff --git a/packages/next-env/package.json b/packages/next-env/package.json index 2d4f6977552d..e2b0cc768186 100644 --- a/packages/next-env/package.json +++ b/packages/next-env/package.json @@ -1,6 +1,6 @@ { "name": "@next/env", - "version": "16.3.1-canary.24", + "version": "16.3.1-canary.25", "keywords": [ "react", "next", diff --git a/packages/next-mdx/package.json b/packages/next-mdx/package.json index f7afa0de78e3..7f65a3606c34 100644 --- a/packages/next-mdx/package.json +++ b/packages/next-mdx/package.json @@ -1,6 +1,6 @@ { "name": "@next/mdx", - "version": "16.3.1-canary.24", + "version": "16.3.1-canary.25", "main": "index.js", "license": "MIT", "repository": { diff --git a/packages/next-playwright/package.json b/packages/next-playwright/package.json index 4e9fdb072a48..3fc78617b328 100644 --- a/packages/next-playwright/package.json +++ b/packages/next-playwright/package.json @@ -1,6 +1,6 @@ { "name": "@next/playwright", - "version": "16.3.1-canary.24", + "version": "16.3.1-canary.25", "repository": { "url": "vercel/next.js", "directory": "packages/next-playwright" diff --git a/packages/next-plugin-storybook/package.json b/packages/next-plugin-storybook/package.json index 496cb796844c..ff1d693bca1f 100644 --- a/packages/next-plugin-storybook/package.json +++ b/packages/next-plugin-storybook/package.json @@ -1,6 +1,6 @@ { "name": "@next/plugin-storybook", - "version": "16.3.1-canary.24", + "version": "16.3.1-canary.25", "repository": { "url": "vercel/next.js", "directory": "packages/next-plugin-storybook" diff --git a/packages/next-polyfill-module/package.json b/packages/next-polyfill-module/package.json index 86aa388a3957..fa81f72ba73e 100644 --- a/packages/next-polyfill-module/package.json +++ b/packages/next-polyfill-module/package.json @@ -1,6 +1,6 @@ { "name": "@next/polyfill-module", - "version": "16.3.1-canary.24", + "version": "16.3.1-canary.25", "description": "A standard library polyfill for ES Modules supporting browsers (Edge 16+, Firefox 60+, Chrome 61+, Safari 10.1+)", "main": "dist/polyfill-module.js", "license": "MIT", diff --git a/packages/next-polyfill-nomodule/package.json b/packages/next-polyfill-nomodule/package.json index a96e118e0124..ffb829c328a5 100644 --- a/packages/next-polyfill-nomodule/package.json +++ b/packages/next-polyfill-nomodule/package.json @@ -1,6 +1,6 @@ { "name": "@next/polyfill-nomodule", - "version": "16.3.1-canary.24", + "version": "16.3.1-canary.25", "description": "A polyfill for non-dead, nomodule browsers.", "main": "dist/polyfill-nomodule.js", "license": "MIT", diff --git a/packages/next-routing/package.json b/packages/next-routing/package.json index d8f710b67c24..475d144aef75 100644 --- a/packages/next-routing/package.json +++ b/packages/next-routing/package.json @@ -1,6 +1,6 @@ { "name": "@next/routing", - "version": "16.3.1-canary.24", + "version": "16.3.1-canary.25", "keywords": [ "react", "next", diff --git a/packages/next-rspack/package.json b/packages/next-rspack/package.json index cc42df58b3d7..d2416ee24ae9 100644 --- a/packages/next-rspack/package.json +++ b/packages/next-rspack/package.json @@ -1,6 +1,6 @@ { "name": "next-rspack", - "version": "16.3.1-canary.24", + "version": "16.3.1-canary.25", "repository": { "url": "vercel/next.js", "directory": "packages/next-rspack" diff --git a/packages/next-swc/package.json b/packages/next-swc/package.json index 794b151969fe..89af2c0fa961 100644 --- a/packages/next-swc/package.json +++ b/packages/next-swc/package.json @@ -1,6 +1,6 @@ { "name": "@next/swc", - "version": "16.3.1-canary.24", + "version": "16.3.1-canary.25", "private": true, "files": [ "native/" diff --git a/packages/next/package.json b/packages/next/package.json index 1e40f227e73e..bcefc20cfde7 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -1,6 +1,6 @@ { "name": "next", - "version": "16.3.1-canary.24", + "version": "16.3.1-canary.25", "description": "The React Framework", "main": "./dist/server/next.js", "license": "MIT", @@ -100,7 +100,7 @@ ] }, "dependencies": { - "@next/env": "16.3.1-canary.24", + "@next/env": "16.3.1-canary.25", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", @@ -164,11 +164,11 @@ "@modelcontextprotocol/sdk": "1.18.1", "@mswjs/interceptors": "0.42.0", "@napi-rs/triples": "1.2.0", - "@next/font": "16.3.1-canary.24", - "@next/polyfill-module": "16.3.1-canary.24", - "@next/polyfill-nomodule": "16.3.1-canary.24", - "@next/react-refresh-utils": "16.3.1-canary.24", - "@next/swc": "16.3.1-canary.24", + "@next/font": "16.3.1-canary.25", + "@next/polyfill-module": "16.3.1-canary.25", + "@next/polyfill-nomodule": "16.3.1-canary.25", + "@next/react-refresh-utils": "16.3.1-canary.25", + "@next/swc": "16.3.1-canary.25", "@opentelemetry/api": "1.6.0", "@playwright/test": "1.61.0", "@rspack/core": "1.6.7", diff --git a/packages/next/src/build/segment-config/app/app-segment-config.ts b/packages/next/src/build/segment-config/app/app-segment-config.ts index a6157228b04d..489970c15af2 100644 --- a/packages/next/src/build/segment-config/app/app-segment-config.ts +++ b/packages/next/src/build/segment-config/app/app-segment-config.ts @@ -36,16 +36,11 @@ const InstantConfigSchema = z.union([ z.literal(false), ]) -const PrefetchSchema = z.enum([ - 'auto', - 'partial', - 'unstable_eager', - 'force-disabled', -]) +const PrefetchSchema = z.enum(['auto', 'partial', 'force-disabled']) export type Instant = InstantConfig | true | false -export type Prefetch = 'auto' | 'partial' | 'unstable_eager' | 'force-disabled' +export type Prefetch = 'auto' | 'partial' | 'force-disabled' export type InstantConfigForTypeCheckInternal = __GenericInstantConfig | Instant // the __GenericInstantConfig type is used to avoid type widening issues with @@ -138,9 +133,6 @@ const AppSegmentConfigSchema = z.object({ * - 'partial' enables Partial Prefetching. Only Cache Components are * prefetched, not dynamic ones. When a static prefetch is insufficient, * the segment may be prefetched with a runtime request instead. - * - 'unstable_eager' behaves like 'partial' but, when App Shells are enabled, - * keeps eagerly prefetching the route's segments instead of relying on the - * shared app shell. Internal migration aid; not part of the public API. * - 'force-disabled' disables prefetching for the segment. */ prefetch: PrefetchSchema.optional(), @@ -197,7 +189,7 @@ export function parseAppSegmentConfig( } case 'prefetch': { return { - message: `Invalid prefetch value ${JSON.stringify(ctx.data)} on "${route}", must be "auto", "partial", "unstable_eager", or "force-disabled".`, + message: `Invalid prefetch value ${JSON.stringify(ctx.data)} on "${route}", must be "auto", "partial", or "force-disabled".`, } } case 'unstable_dynamicStaleTime': { @@ -265,9 +257,6 @@ export type AppSegmentConfig = { * - 'partial' enables Partial Prefetching. Only Cache Components are * prefetched, not dynamic ones. When a static prefetch is insufficient, * the segment may be prefetched with a runtime request instead. - * - 'unstable_eager' behaves like 'partial' but, when App Shells are enabled, - * keeps eagerly prefetching the route's segments instead of relying on the - * shared app shell. Internal migration aid; not part of the public API. * - 'force-disabled' disables prefetching for the segment. */ prefetch?: Prefetch diff --git a/packages/next/src/build/swc/generated-native.d.ts b/packages/next/src/build/swc/generated-native.d.ts index 43e9044ae3ee..69ad05307c38 100644 --- a/packages/next/src/build/swc/generated-native.d.ts +++ b/packages/next/src/build/swc/generated-native.d.ts @@ -581,10 +581,15 @@ export declare function parse( signal?: AbortSignal | undefined | null ): Promise -export declare function projectAllHmrEvents( +export declare function projectClientHmrChunkNamesSubscribe( project: { __napiType: 'Project' }, - target: string, - func: (err: Error, value: TurbopackResult) => void + func: (err: Error, value: TurbopackResult) => void +): { __napiType: 'RootTask' } + +export declare function projectClientHmrEvents( + project: { __napiType: 'Project' }, + chunkName: RcStr, + func: (err: Error, value: TurbopackResult) => void ): { __napiType: 'RootTask' } /** Subscribes to all compilation events that are not cached like timing and progress information. */ @@ -634,19 +639,6 @@ export declare function projectGetSourceMapSync( sourceMapUrl: RcStr ): string | null -export declare function projectHmrChunkNamesSubscribe( - project: { __napiType: 'Project' }, - target: string, - func: (err: Error, value: TurbopackResult) => void -): { __napiType: 'RootTask' } - -export declare function projectHmrEvents( - project: { __napiType: 'Project' }, - chunkName: RcStr, - target: string, - func: (err: Error, value: TurbopackResult) => void -): { __napiType: 'RootTask' } - /** * Invalidates the filesystem cache so that it will be deleted next time that a turbopack project * is created with filesystem cache enabled. @@ -671,6 +663,11 @@ export declare function projectOnExit(project: { __napiType: 'Project' }): Promise +export declare function projectServerHmrEvents( + project: { __napiType: 'Project' }, + func: (err: Error, value: TurbopackResult) => void +): { __napiType: 'RootTask' } + /** * Runs `project_on_exit`, and then waits for turbo_tasks to gracefully shut down. * diff --git a/packages/next/src/build/swc/index.ts b/packages/next/src/build/swc/index.ts index 25fc665b22d0..8484b3fd0d0d 100644 --- a/packages/next/src/build/swc/index.ts +++ b/packages/next/src/build/swc/index.ts @@ -43,11 +43,6 @@ import type { } from './types' import { runLoaderWorkerPool } from './loaderWorkerPool' -export enum HmrTarget { - Client = 'client', - Server = 'server', -} - type RawBindings = typeof import('./generated-native') type RawWasmBindings = typeof import('./generated-wasm') & { default?(): Promise @@ -762,47 +757,27 @@ function bindingToApi( })() } - // Note: only the Server target is implemented in the native binding; - // add a Client overload once `all_hmr_update` supports it. - allHmrEvents( - target: HmrTarget.Server - ): AsyncIterableIterator> { + serverHmrEvents(): AsyncIterableIterator> { return subscribe(true, async (callback) => - binding.projectAllHmrEvents(this._nativeProject, target, callback) + binding.projectServerHmrEvents(this._nativeProject, callback) ) } - hmrEvents( - chunkName: string, - target: HmrTarget.Client - ): AsyncIterableIterator> - hmrEvents( - chunkName: string, - target: HmrTarget.Server - ): AsyncIterableIterator> - hmrEvents(chunkName: string, target: HmrTarget.Client | HmrTarget.Server) { + clientHmrEvents( + chunkName: string + ): AsyncIterableIterator> { return subscribe(true, async (callback) => - binding.projectHmrEvents( - this._nativeProject, - chunkName, - target, - callback - ) + binding.projectClientHmrEvents(this._nativeProject, chunkName, callback) ) } - /** - * Subscribe to the list of output chunk paths that can receive HMR updates. - * Chunk paths are output file paths like "server/chunks/ssr/..._.js" for server - * or "_next/static/chunks/app/page.js" for client. - */ - hmrChunkNamesSubscribe(target: HmrTarget) { + /** Subscribe to client output chunk paths that can receive HMR updates. */ + clientHmrChunkNamesSubscribe() { return subscribe>( false, async (callback) => - binding.projectHmrChunkNamesSubscribe( + binding.projectClientHmrChunkNamesSubscribe( this._nativeProject, - target, callback ) ) diff --git a/packages/next/src/build/swc/types.ts b/packages/next/src/build/swc/types.ts index d06dcaf55019..6ffbaaeab4ab 100644 --- a/packages/next/src/build/swc/types.ts +++ b/packages/next/src/build/swc/types.ts @@ -334,24 +334,15 @@ export interface Project { TurbopackResult > - // Note: only the Server target is implemented in the native binding; - // add a Client overload once `all_hmr_update` supports it. - allHmrEvents( - target: import('./index').HmrTarget.Server - ): AsyncIterableIterator> - - hmrEvents( - identifier: string, - target: import('./index').HmrTarget.Client + serverHmrEvents(): AsyncIterableIterator> + + clientHmrEvents( + identifier: string ): AsyncIterableIterator> - hmrEvents( - identifier: string, - target: import('./index').HmrTarget.Server - ): AsyncIterableIterator> - - hmrChunkNamesSubscribe( - target: import('./index').HmrTarget - ): AsyncIterableIterator> + + clientHmrChunkNamesSubscribe(): AsyncIterableIterator< + TurbopackResult + > getSourceForAsset(filePath: string): Promise diff --git a/packages/next/src/client/components/segment-cache/cache.ts b/packages/next/src/client/components/segment-cache/cache.ts index 2b84f03e6322..367a23a6c0e0 100644 --- a/packages/next/src/client/components/segment-cache/cache.ts +++ b/packages/next/src/client/components/segment-cache/cache.ts @@ -2689,11 +2689,7 @@ export async function fetchSegmentPrefetchesUsingRuntimeRequest( // Runtime prefetch responses (PPRRuntime and RuntimeShell requests) are // partial when the server marks the response as '~' (Partial). - // Full/LoadingBoundary prefetch responses are always complete. This only - // describes the FULL payload: shell-tier writes don't consume it — a - // shell is partial by construction (RuntimeShell responses omit every - // dynamic suspense boundary below the shell stage, regardless of what - // the server marker says); see writeResponsePayloadsIntoCache. + // Full/LoadingBoundary prefetch responses are always complete. const isFullResponsePartial = (fetchStrategy === FetchStrategy.PPRRuntime || fetchStrategy === FetchStrategy.RuntimeShell) && @@ -2866,12 +2862,11 @@ function writeResponsePayloadsIntoCache( } return null } - // No shell exists, and the request wasn't a static shell walk. The full - // payload fulfills the spawned entries at the request's own keying — - // including for a RuntimeShell request (shell staging not enabled, or - // the render wasn't staged), whose response is then conservatively treated - // as the shell it asked for: keyed at the shell tier and partial - // by construction. + // This request either: + // - didn't allow recovering a shell (no staged rendering), + // - or was a (runtime) shell request, so we already have a shell without recovering anything. + // In either case, we don't have anything to consider other than the request itself, + // so the payload simply fulfills the spawned entries at the request's own keying. fulfilledEntries = writeServerResponseIntoCache( now, fetchStrategy, @@ -2882,9 +2877,7 @@ function writeResponsePayloadsIntoCache( renderedSearch, buildId, staleAt, - fetchStrategy === FetchStrategy.RuntimeShell - ? true - : isFullResponsePartial, + isFullResponsePartial, metadataVaryPath, spawnedEntries, null, @@ -2909,7 +2902,7 @@ function writeResponsePayloadsIntoCache( renderedSearch, buildId, staleAt, - shellWasRequested ? true : isFullResponsePartial, + isFullResponsePartial, metadataVaryPath, spawnedEntries, // The full payload's tier: PPR for a static response, PPRRuntime for diff --git a/packages/next/src/client/components/segment-cache/navigation-testing-lock.ts b/packages/next/src/client/components/segment-cache/navigation-testing-lock.ts index 2fb41191b4b3..2271fcf7461d 100644 --- a/packages/next/src/client/components/segment-cache/navigation-testing-lock.ts +++ b/packages/next/src/client/components/segment-cache/navigation-testing-lock.ts @@ -25,10 +25,10 @@ import { } from '../../../shared/lib/app-router-types' import { NEXT_INSTANT_TEST_COOKIE } from '../app-router-headers' import { refreshOnInstantNavigationUnlock } from '../use-action-queue' -import { subtreeHasSpeculativePrefetch } from './scheduler' +import { needsSpeculativePrefetch } from './scheduler' import type { SegmentCacheEntry } from './cache' import { createCacheMap, type CacheMap } from './cache-map' -import type { FetchStrategy } from './types' +import type { PrefetchTaskFetchStrategy } from './types' type InstantNavCookieState = 'empty' | 'pending' | 'mpa' | 'spa' @@ -535,19 +535,19 @@ export function getCurrentNavigationGate(): Promise | null { * enabled for the target route, and no whole-route ("speculative") prefetch * would have been made, only the shell is prefetched — so that's all a * navigation should be allowed to match. A speculative prefetch happens for a - * `` or an eagerly-prefetched subtree, in which case the - * concrete-param entry is genuinely warm and may be matched. + * ``, in which case the concrete-param entry is genuinely + * warm and may be matched. * * Always returns false outside the testing API, via the aliased * `navigation-testing-lock.disabled` module. */ export function shouldRestrictNavigationToShell( rootPrefetchHints: number, - linkFetchStrategy: FetchStrategy + linkFetchStrategy: PrefetchTaskFetchStrategy ): boolean { return ( isNavigationLocked() && (rootPrefetchHints & PrefetchHint.SubtreeHasPartialPrefetching) !== 0 && - !subtreeHasSpeculativePrefetch(linkFetchStrategy, rootPrefetchHints) + !needsSpeculativePrefetch(linkFetchStrategy, rootPrefetchHints) ) } diff --git a/packages/next/src/client/components/segment-cache/scheduler.ts b/packages/next/src/client/components/segment-cache/scheduler.ts index 4757e4f83b6b..e9ff78d14ada 100644 --- a/packages/next/src/client/components/segment-cache/scheduler.ts +++ b/packages/next/src/client/components/segment-cache/scheduler.ts @@ -624,8 +624,7 @@ function processQueueInMicrotask() { // Finished prefetching the route tree. The two-phase (Shell then // Speculative) flow only applies to routes that have opted into // Partial Prefetching — either globally via the `partialPrefetching` - // config or per segment (`prefetch: 'partial'` or - // `'unstable_eager'`), all surfaced as the + // config or per segment (`prefetch: 'partial'`), both surfaced as the // `SubtreeHasPartialPrefetching` hint on the route tree. Every other // route skips the Shell phase and goes straight to Speculative. // @@ -907,16 +906,14 @@ function pingRootRouteTree( ? FetchStrategy.StaticShell : FetchStrategy.PPR + // In PPF, links may skip speculative prefetching if they only need a shell. if ( staticWalkStrategy === FetchStrategy.PPR && - !subtreeHasSpeculativePrefetch( + !needsSpeculativePrefetch( task.fetchStrategy, - tree.prefetchHints + route.tree.prefetchHints ) ) { - // Nothing in the target route needs to be speculatively prefetched. - // Bail out. (A PPR walk is the Speculative pass; same check as - // the per-subtree bail in pingNewPartOfCacheComponentsTree.) return PrefetchTaskExitStatus.Done } @@ -1172,17 +1169,11 @@ function pingStaticHead( * walks prefetch static data and partial entries are acceptable — the * dynamic holes are filled by the navigation-time request. * - * Note that on a Partial Prefetching route, non-eager subtrees are still - * skipped by the Speculative pass of a default (auto) link — eagerness is - * unaffected by this predicate. But every segment the pass DOES walk (eager - * segments, and everything on a `prefetch={true}` walk) is held to the - * runtime-completeness contract. The contract is affordable because most + * Note: The runtime contract is affordable because most * routes carry the ShouldAttemptStaticPrefetch hint: their segments are * prefetched statically and the responses' own sufficiency signal makes a * runtime request rare. On a hint-unset route, a walked segment deopts - * directly to the batched runtime request — which then serves the segment's - * whole subtree, so navigations into it are complete without a - * navigation-time request. + * directly to the batched runtime request. * * This is also the gate for the batched runtime request at the end of * pingRootRouteTree; requiring runtime completeness does not itself mean a @@ -1479,15 +1470,11 @@ function pingNewPartOfCacheComponentsTree( // runtime data carries it, and the dynamic holes are filled by the // navigation-time request. + // In PPF, links may skip speculative prefetching if they only need a shell. if ( - // Only the Speculative pass skips subtrees with nothing to speculatively - // prefetch. (It's also the only pass that walks at FetchStrategy.PPR; - // the Shell phase walks at StaticShell and covers the whole new tree.) fetchStrategy === FetchStrategy.PPR && - !subtreeHasSpeculativePrefetch(task.fetchStrategy, tree.prefetchHints) + !needsSpeculativePrefetch(task.fetchStrategy, route.tree.prefetchHints) ) { - // Nothing in the new part of the tree needs to be speculatively prefetched. - // Bail out. return PrefetchTaskExitStatus.Done } @@ -2678,24 +2665,21 @@ function doesCurrentSegmentMatchCachedSegment( } /** - * Decides whether to skip the speculative prefetch of a subtree. Usually we - * only perform a speculative prefetch if the Link's prefetch prop is set to - * true. However, we also will do a speculative prefetch if the prefetching - * mode of the segment is set to "unstable_eager". + * Decides whether to speculatively prefetch a subtree. Under Partial + * Prefetching we only do this if the Link's prefetch prop is set to true — + * otherwise the subtree relies on the shell that the Shell phase prefetches. */ -export function subtreeHasSpeculativePrefetch( - fetchStrategy: FetchStrategy, - prefetchHints: number +export function needsSpeculativePrefetch( + taskfetchStrategy: PrefetchTaskFetchStrategy, + rootPrefetchHints: number ): boolean { - return ( - // Check if this is a "full" prefetch (). - fetchStrategy === FetchStrategy.Full || - // Check if something in this subtree is configured to be eagerly - // prefetched at the route level. Segments that don't opt into Partial - // Prefetching are marked eager, so a route without any Partial Prefetching - // still speculatively prefetches everything. - (prefetchHints & PrefetchHint.SubtreeHasEagerPrefetch) !== 0 - ) + if ((rootPrefetchHints & PrefetchHint.SubtreeHasPartialPrefetching) !== 0) { + // PPF - only needs a speculative prefetch if this is a ``. + return taskfetchStrategy === FetchStrategy.Full + } else { + // non-PPF -- all prefetches are speculative. + return true + } } // ----------------------------------------------------------------------------- diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index 7a88209d4c13..567dd6b4e0e3 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -1099,7 +1099,7 @@ async function generateStagedDynamicFlightRenderResultNode( // Check if this route should runtime-cache its navigation. This happens when // Partial Prefetching is enabled for the route, either per segment (a - // `prefetch` of 'partial' or 'unstable_eager') or globally (the + // `prefetch` of 'partial') or globally (the // `partialPrefetching` config). If so, we piggyback on the dynamic render to // fill caches and then spawn a final runtime prerender whose result stream // is embedded in the RSC payload. This is gated because it adds extra server @@ -1464,7 +1464,6 @@ async function generateDynamicFlightRenderResultWithStagesInDev( prefetchStage = RenderStage.Static } else { if (prefetchMode === PrefetchingMode.Partial) { - // TODO(app-shells): model `partialPrefetching: "unstable_eager"` // TODO(app-shells): if this navigation came from , // we should show the shell for a speculative prefetch // (which can have more data than the app shell) @@ -2363,7 +2362,7 @@ async function getErrorRSCPayload( errorHints, errorPrefetchInliningEnabled, ctx.missingPrefetchHintPolicy, - ctx.renderOpts.partialPrefetching, + Boolean(ctx.renderOpts.partialPrefetching), getDynamicParamFromSegment, query ) @@ -3848,7 +3847,7 @@ async function renderToStream( // embedded in the initial RSC payload so the client can cache // runtime-prefetchable content during hydration. This is enabled when // Partial Prefetching is on for the route, either per segment (a - // `prefetch` of 'partial' or 'unstable_eager') or globally (the + // `prefetch` of 'partial') or globally (the // `partialPrefetching` config). if ( Boolean(renderOpts.partialPrefetching) || @@ -5288,7 +5287,6 @@ async function getPrefetchingModeForPage( const debug = process.env.NEXT_PRIVATE_DEBUG_VALIDATION === '1' ? console.log : undefined - // TODO(app-shells): support "unstable_eager" if (renderOpts.partialPrefetching) { debug?.('using prefetching mode Partial because of next.config.js') return PrefetchingMode.Partial @@ -5399,7 +5397,7 @@ type StreamRevealStage = | RenderStage.Runtime function navigationHasAppShell(navigationKind: DevNavigationKind): boolean { - // TODO(app-shells): when we implement `/`prefetch = "unstable_eager"` in dev, + // TODO(app-shells): when we implement `` in dev, // this might need to be adjusted, because we'll use `Runtime` for the stage return ( navigationKind.type === 'prefetched-client' && diff --git a/packages/next/src/server/app-render/create-component-tree.tsx b/packages/next/src/server/app-render/create-component-tree.tsx index 4c43c302a89e..e6f0f9956f2c 100644 --- a/packages/next/src/server/app-render/create-component-tree.tsx +++ b/packages/next/src/server/app-render/create-component-tree.tsx @@ -191,7 +191,7 @@ async function createComponentTreeInternal( parseLoaderTree(tree) const prefetchInliningEnabled = Boolean(experimental.prefetchInlining) - const partialPrefetching = ctx.renderOpts.partialPrefetching + const partialPrefetching = Boolean(ctx.renderOpts.partialPrefetching) const { layout, diff --git a/packages/next/src/server/app-render/create-transport-tree-from-loader-tree.ts b/packages/next/src/server/app-render/create-transport-tree-from-loader-tree.ts index acee295ee36a..a78918dbbdbe 100644 --- a/packages/next/src/server/app-render/create-transport-tree-from-loader-tree.ts +++ b/packages/next/src/server/app-render/create-transport-tree-from-loader-tree.ts @@ -61,7 +61,7 @@ export async function computeSegmentPrefetchHints( hintTree: PrefetchHints | null, prefetchInliningEnabled: boolean, missingPrefetchHintPolicy: MissingPrefetchHintPolicy, - partialPrefetching: boolean | 'unstable_eager' | undefined, + partialPrefetching: boolean, // Whether this segment is at or above the root layout (no layout was found // above it). isRootLayoutOrAbove: boolean @@ -76,11 +76,7 @@ export async function computeSegmentPrefetchHints( const instantConfig = mod ? (mod as AppSegmentConfig).instant : undefined const prefetchConfig = (mod ? (mod as AppSegmentConfig).prefetch : undefined) ?? - (partialPrefetching === 'unstable_eager' - ? 'unstable_eager' - : partialPrefetching - ? 'partial' - : undefined) + (partialPrefetching ? 'partial' : undefined) let prefetchHints = 0 // Union in the precomputed build-time hints (e.g. segment inlining @@ -132,25 +128,10 @@ export async function computeSegmentPrefetchHints( if (prefetchConfig === 'partial') { prefetchHints |= PrefetchHint.SubtreeHasPartialPrefetching - } else if (prefetchConfig === 'unstable_eager') { - // Like 'partial' (uses the PPR fetch strategy) but also marks the segment - // as eager, so App Shells keeps prefetching it instead of relying on the - // shared app shell. - prefetchHints |= - PrefetchHint.SubtreeHasPartialPrefetching | - PrefetchHint.SubtreeHasEagerPrefetch } else if (prefetchConfig === 'force-disabled') { prefetchHints |= PrefetchHint.PrefetchDisabled } - // Mark the segment as "eager" unless its effective prefetch strategy is - // 'partial'. 'unstable_eager' already set the bit above. Under App Shells, - // a subtree with no eager segment skips its Speculative prefetch and relies - // on the shared app shell instead. - if (prefetchConfig !== 'partial') { - prefetchHints |= PrefetchHint.SubtreeHasEagerPrefetch - } - // Check if this segment has a loading boundary if (loading) { prefetchHints |= PrefetchHint.SegmentHasLoadingBoundary @@ -176,7 +157,7 @@ async function createTransportTreeFromLoaderTreeImpl( hintTree: PrefetchHints | null, prefetchInliningEnabled: boolean, missingPrefetchHintPolicy: MissingPrefetchHintPolicy, - partialPrefetching: boolean | 'unstable_eager' | undefined, + partialPrefetching: boolean, getDynamicParamFromSegment: GetDynamicParamFromSegment, searchParams: any, didFindRootLayout: boolean @@ -254,7 +235,7 @@ export async function createTransportTreeFromLoaderTree( hintTree: PrefetchHints | null, prefetchInliningEnabled: boolean, missingPrefetchHintPolicy: MissingPrefetchHintPolicy, - partialPrefetching: boolean | 'unstable_eager' | undefined, + partialPrefetching: boolean, getDynamicParamFromSegment: GetDynamicParamFromSegment, searchParams: any, // Whether a root layout was already found above this loader tree slice, so a @@ -285,7 +266,7 @@ export async function createFullTransportTreeFromLoaderTree( hintTree: PrefetchHints | null, prefetchInliningEnabled: boolean, missingPrefetchHintPolicy: MissingPrefetchHintPolicy, - partialPrefetching: boolean | 'unstable_eager' | undefined, + partialPrefetching: boolean, getDynamicParamFromSegment: GetDynamicParamFromSegment, searchParams: any ): Promise { @@ -314,7 +295,7 @@ export async function createRouteTreePrefetch( hintTree: PrefetchHints | null, prefetchInliningEnabled: boolean, missingPrefetchHintPolicy: MissingPrefetchHintPolicy, - partialPrefetching: boolean | 'unstable_eager' | undefined, + partialPrefetching: boolean, getDynamicParamFromSegment: GetDynamicParamFromSegment, // See note on createTransportTreeFromLoaderTree's didFindRootLayout. didFindRootLayout: boolean = false diff --git a/packages/next/src/server/app-render/instant-validation/instant-config.tsx b/packages/next/src/server/app-render/instant-validation/instant-config.tsx index 59ae0f816861..aec3dd4b06c5 100644 --- a/packages/next/src/server/app-render/instant-validation/instant-config.tsx +++ b/packages/next/src/server/app-render/instant-validation/instant-config.tsx @@ -51,10 +51,9 @@ export function isFrameworkErrorRoute(route: string | undefined): boolean { } /** - * Matches any `prefetch` config that enables Partial Prefetching for the - * segment: 'partial' or 'unstable_eager'. A route with Partial Prefetching - * enabled also runtime-caches its navigations, so this gates the runtime - * prefetch spawn. + * Matches the `prefetch` config that enables Partial Prefetching for the + * segment: 'partial'. A route with Partial Prefetching enabled also + * runtime-caches its navigations, so this gates the runtime prefetch spawn. */ export async function anySegmentHasPartialPrefetchingEnabled( tree: LoaderTree @@ -65,7 +64,7 @@ export async function anySegmentHasPartialPrefetchingEnabled( const prefetchConfig = layoutOrPageMod ? (layoutOrPageMod as AppSegmentConfig).prefetch : undefined - if (prefetchConfig === 'partial' || prefetchConfig === 'unstable_eager') { + if (prefetchConfig === 'partial') { return true } diff --git a/packages/next/src/server/app-render/walk-tree-with-flight-router-state.tsx b/packages/next/src/server/app-render/walk-tree-with-flight-router-state.tsx index f1856dbb2e0f..e40f0737b709 100644 --- a/packages/next/src/server/app-render/walk-tree-with-flight-router-state.tsx +++ b/packages/next/src/server/app-render/walk-tree-with-flight-router-state.tsx @@ -81,7 +81,7 @@ export async function walkTreeWithFlightRouterState({ parsedRequestHeaders, } = ctx const prefetchInliningEnabled = Boolean(experimental.prefetchInlining) - const partialPrefetching = ctx.renderOpts.partialPrefetching + const partialPrefetching = Boolean(ctx.renderOpts.partialPrefetching) const [segment, parallelRoutes, modules] = loaderTreeToFilter diff --git a/packages/next/src/server/config-schema.ts b/packages/next/src/server/config-schema.ts index 3fd0c238c99d..5c211ee47c9d 100644 --- a/packages/next/src/server/config-schema.ts +++ b/packages/next/src/server/config-schema.ts @@ -805,9 +805,7 @@ export const configSchema: zod.ZodType = z.lazy(() => .optional(), pageExtensions: z.array(z.string()).min(1).optional(), instrumentationClientInject: z.array(z.string()).optional(), - partialPrefetching: z - .union([z.boolean(), z.literal('unstable_eager')]) - .optional(), + partialPrefetching: z.boolean().optional(), poweredByHeader: z.boolean().optional(), productionBrowserSourceMaps: z.boolean().optional(), reactCompiler: z.union([ diff --git a/packages/next/src/server/config-shared.ts b/packages/next/src/server/config-shared.ts index 644df33842e1..0d3f6ddcb5d2 100644 --- a/packages/next/src/server/config-shared.ts +++ b/packages/next/src/server/config-shared.ts @@ -2015,12 +2015,8 @@ export interface NextConfig { * * When `false` or omitted, this does nothing (the legacy behavior, where * dynamic data is included in the prefetch). - * - * `'unstable_eager'` is like `true`, except the default becomes - * `'unstable_eager'` instead of `'partial'`: every Link has an implied - * prefetch={true}. Internal migration aid; not part of the public API. */ - partialPrefetching?: boolean | 'unstable_eager' + partialPrefetching?: boolean cacheLife?: { [profile: string]: { diff --git a/packages/next/src/server/dev/hot-reloader-turbopack.ts b/packages/next/src/server/dev/hot-reloader-turbopack.ts index d0fc70d1cc6a..62be66696bf7 100644 --- a/packages/next/src/server/dev/hot-reloader-turbopack.ts +++ b/packages/next/src/server/dev/hot-reloader-turbopack.ts @@ -28,7 +28,7 @@ import type { NodeJsHmrUpdate, NodeJsPartialHmrUpdate, } from '../../build/swc/types' -import { createDefineEnv, getBindingsSync, HmrTarget } from '../../build/swc' +import { createDefineEnv, getBindingsSync } from '../../build/swc' import * as Log from '../../build/output/log' import { BLOCKED_PAGES } from '../../shared/lib/constants' import { @@ -217,7 +217,7 @@ function setupServerHmr( } ) { async function runSubscription() { - const subscription = project.allHmrEvents(HmrTarget.Server) + const subscription = project.serverHmrEvents() // Subscribing immediately emits one event describing the current state. // There's no previous state to diff it against, so it never carries anything @@ -988,7 +988,7 @@ export async function createHotReloaderTurbopack( return } - const subscription = project!.hmrEvents(id, HmrTarget.Client) + const subscription = project!.clientHmrEvents(id) state.subscriptions.set(id, subscription) // The subscription will always emit once, which is the initial diff --git a/packages/next/src/server/lib/app-info-log.ts b/packages/next/src/server/lib/app-info-log.ts index 35bbce54eca1..5b70f3f88179 100644 --- a/packages/next/src/server/lib/app-info-log.ts +++ b/packages/next/src/server/lib/app-info-log.ts @@ -68,16 +68,14 @@ export function logExperimentalInfo({ }: { experimentalFeatures?: ConfiguredExperimentalFeature[] cacheComponents?: boolean - partialPrefetching?: boolean | 'unstable_eager' + partialPrefetching?: boolean }) { if (cacheComponents) { Log.bootstrap(`- Cache Components enabled`) } if (partialPrefetching) { - const mode = - partialPrefetching === 'unstable_eager' ? ' (unstable_eager)' : '' - Log.bootstrap(`- Partial Prefetching enabled${mode}`) + Log.bootstrap(`- Partial Prefetching enabled`) } if (experimentalFeatures?.length) { diff --git a/packages/next/src/server/lib/render-server.ts b/packages/next/src/server/lib/render-server.ts index 9e4f8fd6801d..83db7afe512c 100644 --- a/packages/next/src/server/lib/render-server.ts +++ b/packages/next/src/server/lib/render-server.ts @@ -22,8 +22,8 @@ export type ServerInitResult = { experimentalFeatures: ConfiguredExperimentalFeature[] // Whether cache components is enabled cacheComponents: boolean - // Whether partial prefetching is enabled (and its mode) - partialPrefetching?: boolean | 'unstable_eager' + // Whether partial prefetching is enabled + partialPrefetching?: boolean // Whether AGENTS.md / CLAUDE.md auto-generation is enabled (default true) agentRules?: boolean // Whether the development server memory threshold restart is enabled @@ -107,7 +107,7 @@ async function initializeImpl(opts: { distDir: string experimentalFeatures: ConfiguredExperimentalFeature[] cacheComponents: boolean - partialPrefetching?: boolean | 'unstable_eager' + partialPrefetching?: boolean devMemoryThresholdRestart: boolean }): Promise { const type = process.env.__NEXT_PRIVATE_RENDER_WORKER diff --git a/packages/next/src/server/typescript/rules/config.ts b/packages/next/src/server/typescript/rules/config.ts index 29dd6eb35519..9db590475ddd 100644 --- a/packages/next/src/server/typescript/rules/config.ts +++ b/packages/next/src/server/typescript/rules/config.ts @@ -159,16 +159,11 @@ const API_DOCS: Record< prefetch: { description: `Controls prefetching behavior for this segment. Some options are experimental and may change.`, link: '(docs coming soon)', - type: `"auto" | "partial" | "unstable_eager" | "force-disabled"`, + type: `"auto" | "partial" | "force-disabled"`, options: { auto: 'Default. Framework decides based on instant validation and segment configuration. You do not need to set this explicitly.', partial: 'Enables Partial Prefetching for this segment. When a static prefetch is insufficient, Next.js may prefetch the segment with a runtime server request so it can access session data, such as cookies.', - unstable_eager: - 'Like "partial", but adds an implied prop of prefetch={true} to ' + - 'every Link. This option only exists to aid migration of apps that ' + - 'adopted Partial Prefetching in canary before the behavior changed to ' + - 'only fetch the shell by default.', 'force-disabled': 'Never prefetch this segment.', }, insertText: `prefetch = 'partial';`, diff --git a/packages/next/src/shared/lib/app-router-types.ts b/packages/next/src/shared/lib/app-router-types.ts index 0b612246c586..3818d1ef2d6a 100644 --- a/packages/next/src/shared/lib/app-router-types.ts +++ b/packages/next/src/shared/lib/app-router-types.ts @@ -189,9 +189,9 @@ export const enum PrefetchHint { // This segment or one of its descendants opts into Partial Prefetching, i.e. // uses the two-phase (Shell then Speculative) prefetch flow. Set when - // `prefetch` is 'partial' or 'unstable_eager' (including the defaults - // implied by the global `partialPrefetching` config). Propagates upward so - // the root segment reflects the entire subtree. + // `prefetch` is 'partial' (including the default implied by the global + // `partialPrefetching` config). Propagates upward so the root segment + // reflects the entire subtree. // // Partial Prefetching segments require RUNTIME COMPLETENESS: a prefetch // isn't considered done for such a segment until an entry at least as @@ -246,12 +246,10 @@ export const enum PrefetchHint { // (see SubtreeHasPartialPrefetching), so the bit was removed. Do not reuse // it without considering caches populated by older builds. - // This segment or one of its descendants prefetches "eagerly" — i.e. its - // effective prefetch strategy is anything other than 'partial'. Used by - // the scheduler's phasing: a non-eager subtree relies on the shell the - // Shell phase prefetches and skips its Speculative prefetch. Propagates - // upward so the root reflects the entire subtree. - SubtreeHasEagerPrefetch = 0b1000000000000, + // NOTE: The 0b1000000000000 bit was previously SubtreeHasEagerPrefetch + // (prefetch: 'unstable_eager', plus every segment that did not opt into + // Partial Prefetching). After `unstable_eager` was removed, it's no longer needed. + // This segment or one of its descendants exports `instant = false`, // explicitly opting out of Partial Prefetching. Propagates upward so the root // reflects the entire subtree. Used only to suppress the dev-time @@ -311,8 +309,7 @@ export const StaticPrefetchDisabled = PrefetchHint.PrefetchDisabled export const SubtreePrefetchHints = PrefetchHint.SubtreeHasPartialPrefetching | PrefetchHint.SubtreeHasLoadingBoundary | - PrefetchHint.SubtreeHasInstantFalse | - PrefetchHint.SubtreeHasEagerPrefetch + PrefetchHint.SubtreeHasInstantFalse /** * Folds a child segment's prefetch hints into its parent's, propagating the @@ -342,13 +339,8 @@ export function propagateSubtreeBits( ) { parentHints |= PrefetchHint.SubtreeHasLoadingBoundary } - // And for eager prefetch. The bit is set directly on each eager segment, so - // there's no separate segment-local flag — propagate it as-is. - if (childHints & PrefetchHint.SubtreeHasEagerPrefetch) { - parentHints |= PrefetchHint.SubtreeHasEagerPrefetch - } - // And for `instant = false`. Like eager prefetch, the bit is set directly on - // each opted-out segment, so propagate it as-is. + // And for `instant = false`. The bit is set directly on each opted-out + // segment, so there's no separate segment-local flag — propagate it as-is. if (childHints & PrefetchHint.SubtreeHasInstantFalse) { parentHints |= PrefetchHint.SubtreeHasInstantFalse } diff --git a/packages/react-refresh-utils/package.json b/packages/react-refresh-utils/package.json index bbfa30468eff..ffebf603ac57 100644 --- a/packages/react-refresh-utils/package.json +++ b/packages/react-refresh-utils/package.json @@ -1,6 +1,6 @@ { "name": "@next/react-refresh-utils", - "version": "16.3.1-canary.24", + "version": "16.3.1-canary.25", "description": "An experimental package providing utilities for React Refresh.", "repository": { "url": "vercel/next.js", diff --git a/packages/third-parties/package.json b/packages/third-parties/package.json index 274a8bf5f91a..004af18f1a01 100644 --- a/packages/third-parties/package.json +++ b/packages/third-parties/package.json @@ -1,6 +1,6 @@ { "name": "@next/third-parties", - "version": "16.3.1-canary.24", + "version": "16.3.1-canary.25", "repository": { "url": "vercel/next.js", "directory": "packages/third-parties" @@ -26,7 +26,7 @@ "third-party-capital": "1.0.20" }, "devDependencies": { - "next": "16.3.1-canary.24", + "next": "16.3.1-canary.25", "outdent": "0.8.0", "prettier": "2.5.1", "typescript": "6.0.2" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c0eb8bda2eda..d9c2c695680e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1024,7 +1024,7 @@ importers: packages/eslint-config-next: dependencies: '@next/eslint-plugin-next': - specifier: 16.3.1-canary.24 + specifier: 16.3.1-canary.25 version: link:../eslint-plugin-next eslint: specifier: '>=9.0.0' @@ -1107,7 +1107,7 @@ importers: packages/next: dependencies: '@next/env': - specifier: 16.3.1-canary.24 + specifier: 16.3.1-canary.25 version: link:../next-env '@swc/helpers': specifier: 0.5.23 @@ -1228,19 +1228,19 @@ importers: specifier: 1.2.0 version: 1.2.0 '@next/font': - specifier: 16.3.1-canary.24 + specifier: 16.3.1-canary.25 version: link:../font '@next/polyfill-module': - specifier: 16.3.1-canary.24 + specifier: 16.3.1-canary.25 version: link:../next-polyfill-module '@next/polyfill-nomodule': - specifier: 16.3.1-canary.24 + specifier: 16.3.1-canary.25 version: link:../next-polyfill-nomodule '@next/react-refresh-utils': - specifier: 16.3.1-canary.24 + specifier: 16.3.1-canary.25 version: link:../react-refresh-utils '@next/swc': - specifier: 16.3.1-canary.24 + specifier: 16.3.1-canary.25 version: link:../next-swc '@opentelemetry/api': specifier: 1.6.0 @@ -1983,7 +1983,7 @@ importers: version: 1.0.20 devDependencies: next: - specifier: 16.3.1-canary.24 + specifier: 16.3.1-canary.25 version: link:../next outdent: specifier: 0.8.0 diff --git a/test/development/basic/next-rs-api.test.ts b/test/development/basic/next-rs-api.test.ts index da47698709e6..b07ff6226832 100644 --- a/test/development/basic/next-rs-api.test.ts +++ b/test/development/basic/next-rs-api.test.ts @@ -1,6 +1,6 @@ import { nextTestSetup } from 'e2e-utils' import { PHASE_DEVELOPMENT_SERVER } from 'next/constants' -import { createDefineEnv, loadBindings, HmrTarget } from 'next/dist/build/swc' +import { createDefineEnv, loadBindings } from 'next/dist/build/swc' import type { Issue, MemoryEvictionMode, @@ -618,15 +618,13 @@ describe('next.rs api', () => { } } - const result = await project - .hmrChunkNamesSubscribe(HmrTarget.Client) - .next() + const result = await project.clientHmrChunkNamesSubscribe().next() expect(result.done).toBe(false) const chunkNames = result.value.chunkNames expect(chunkNames).toHaveProperty('length', expect.toBePositive()) const subscriptions = chunkNames.map((chunkName) => - project.hmrEvents(chunkName, HmrTarget.Client) + project.clientHmrEvents(chunkName) ) await Promise.all( subscriptions.map(async (subscription) => { @@ -740,12 +738,12 @@ describe('next.rs api', () => { if (route.type !== 'page') throw new Error('unknown route type') await route.htmlEndpoint.writeToDisk() - const result = await project.hmrChunkNamesSubscribe(HmrTarget.Client).next() + const result = await project.clientHmrChunkNamesSubscribe().next() expect(result.done).toBe(false) const chunkNames = result.value.chunkNames const subscriptions = chunkNames.map((chunkName) => - project.hmrEvents(chunkName, HmrTarget.Client) + project.clientHmrEvents(chunkName) ) await Promise.all( subscriptions.map(async (subscription) => { diff --git a/test/e2e/app-dir/segment-cache/cached-navigations/cached-navigations.test.ts b/test/e2e/app-dir/segment-cache/cached-navigations/cached-navigations.test.ts index 72d862be4b86..001a038141f0 100644 --- a/test/e2e/app-dir/segment-cache/cached-navigations/cached-navigations.test.ts +++ b/test/e2e/app-dir/segment-cache/cached-navigations/cached-navigations.test.ts @@ -991,11 +991,11 @@ describe('cached navigations', () => { }) }) - // A `prefetch` config that enables Partial Prefetching ('partial' or - // 'unstable_eager') also opts the route into runtime Cached Navigations, - // even though this fixture does not set the global `partialPrefetching` - // flag. Contrast with `partially-static`, which has no `prefetch` config - // and only gets static caching. + // A `prefetch` config that enables Partial Prefetching ('partial') also opts + // the route into runtime Cached Navigations, even though this fixture does + // not set the global `partialPrefetching` flag. Contrast with + // `partially-static`, which has no `prefetch` config and only gets static + // caching. async function expectRuntimeCachedOnSecondNavigation(route: string) { let page: Playwright.Page const browser = await next.browser('/', { @@ -1057,8 +1057,4 @@ describe('cached navigations', () => { it('runtime-caches a route with prefetch = "partial"', async () => { await expectRuntimeCachedOnSecondNavigation('/prefetch-partial') }) - - it('runtime-caches a route with prefetch = "unstable_eager"', async () => { - await expectRuntimeCachedOnSecondNavigation('/prefetch-eager') - }) }) diff --git a/test/e2e/app-dir/segment-cache/cached-navigations/default/app/page.tsx b/test/e2e/app-dir/segment-cache/cached-navigations/default/app/page.tsx index b41370438010..b6f0d5ed4c62 100644 --- a/test/e2e/app-dir/segment-cache/cached-navigations/default/app/page.tsx +++ b/test/e2e/app-dir/segment-cache/cached-navigations/default/app/page.tsx @@ -38,11 +38,6 @@ export default function Home() { Go to prefetch=partial page -
  • - - Go to prefetch=unstable_eager page - -
  • ) diff --git a/test/e2e/app-dir/segment-cache/cached-navigations/default/app/prefetch-eager/page.tsx b/test/e2e/app-dir/segment-cache/cached-navigations/default/app/prefetch-eager/page.tsx deleted file mode 100644 index 7d3a3eeda30e..000000000000 --- a/test/e2e/app-dir/segment-cache/cached-navigations/default/app/prefetch-eager/page.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { RuntimeContent } from '../../components/runtime-content' - -// `prefetch = 'unstable_eager'` enables Partial Prefetching, which also opts -// the route into runtime Cached Navigations. -export const prefetch = 'unstable_eager' - -export default async function Page({ - searchParams, -}: { - searchParams: Promise<{ q?: string }> -}) { - return -} diff --git a/test/e2e/app-dir/segment-cache/prefetch-app-shell-global-eager/app/layout.tsx b/test/e2e/app-dir/segment-cache/prefetch-app-shell-global-eager/app/layout.tsx deleted file mode 100644 index 7758e801dc83..000000000000 --- a/test/e2e/app-dir/segment-cache/prefetch-app-shell-global-eager/app/layout.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { ReactNode } from 'react' - -export default function RootLayout({ children }: { children: ReactNode }) { - return ( - - {children} - - ) -} diff --git a/test/e2e/app-dir/segment-cache/prefetch-app-shell-global-eager/app/page.tsx b/test/e2e/app-dir/segment-cache/prefetch-app-shell-global-eager/app/page.tsx deleted file mode 100644 index e2cb7bb6b28b..000000000000 --- a/test/e2e/app-dir/segment-cache/prefetch-app-shell-global-eager/app/page.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { LinkAccordion } from '../components/link-accordion' - -export default function Page() { - return ( -
    -

    Home

    -
      -
    • - Post 1 (default) -
    • -
    • - Post 2 (default) -
    • -
    -
    - ) -} diff --git a/test/e2e/app-dir/segment-cache/prefetch-app-shell-global-eager/app/posts/[id]/page.tsx b/test/e2e/app-dir/segment-cache/prefetch-app-shell-global-eager/app/posts/[id]/page.tsx deleted file mode 100644 index 3b28ee5276b0..000000000000 --- a/test/e2e/app-dir/segment-cache/prefetch-app-shell-global-eager/app/posts/[id]/page.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { Suspense } from 'react' - -type Params = { id: string } - -// No per-segment `prefetch`. The route's prefetch config comes from -// the global `partialPrefetching: 'unstable_eager'` in next.config, which makes -// it eager — so the App Shells skip does NOT apply. -export function generateStaticParams() { - return [{ id: '1' }, { id: '2' }, { id: '3' }] -} - -export default function Page({ params }: { params: Promise }) { - return ( -
    - App shell

    }> - -
    -
    - ) -} - -async function ParamContent({ params }: { params: Promise }) { - const { id } = await params - return

    {`Eager post ${id}`}

    -} diff --git a/test/e2e/app-dir/segment-cache/prefetch-app-shell-global-eager/components/link-accordion.tsx b/test/e2e/app-dir/segment-cache/prefetch-app-shell-global-eager/components/link-accordion.tsx deleted file mode 100644 index c6848d479aef..000000000000 --- a/test/e2e/app-dir/segment-cache/prefetch-app-shell-global-eager/components/link-accordion.tsx +++ /dev/null @@ -1,33 +0,0 @@ -'use client' - -import Link, { type LinkProps } from 'next/link' -import { useState } from 'react' - -export function LinkAccordion({ - href, - children, - prefetch, -}: { - href: string - children: React.ReactNode - prefetch?: LinkProps['prefetch'] -}) { - const [isVisible, setIsVisible] = useState(false) - return ( - <> - setIsVisible(!isVisible)} - data-link-accordion={href} - /> - {isVisible ? ( - - {children} - - ) : ( - <>{children} (link is hidden) - )} - - ) -} diff --git a/test/e2e/app-dir/segment-cache/prefetch-app-shell-global-eager/next.config.ts b/test/e2e/app-dir/segment-cache/prefetch-app-shell-global-eager/next.config.ts deleted file mode 100644 index a5804410a1d7..000000000000 --- a/test/e2e/app-dir/segment-cache/prefetch-app-shell-global-eager/next.config.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { NextConfig } from 'next' - -const nextConfig: NextConfig = { - cacheComponents: true, - // Opt the whole app into Partial Prefetching in "eager" mode. Every route's - // default prefetch config becomes 'unstable_eager', so under App Shells the - // per-link Speculative prefetch is NOT skipped — even for routes with no - // per-segment `prefetch` export. - partialPrefetching: 'unstable_eager', - experimental: { - prefetchInlining: true, - optimisticRouting: true, - cachedNavigations: true, - varyParams: true, - }, -} - -export default nextConfig diff --git a/test/e2e/app-dir/segment-cache/prefetch-app-shell-global-eager/prefetch-app-shell-global-eager.test.ts b/test/e2e/app-dir/segment-cache/prefetch-app-shell-global-eager/prefetch-app-shell-global-eager.test.ts deleted file mode 100644 index ad326d3dc470..000000000000 --- a/test/e2e/app-dir/segment-cache/prefetch-app-shell-global-eager/prefetch-app-shell-global-eager.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { nextTestSetup } from 'e2e-utils' -import type * as Playwright from 'playwright' -import { createRouterAct } from 'router-act' - -describe('App Shell prefetching - global unstable_eager', () => { - const { next, isNextDev } = nextTestSetup({ - files: __dirname, - }) - if (isNextDev) { - it('is skipped', () => {}) - return - } - - it('does NOT skip the Speculative prefetch when partialPrefetching is "unstable_eager" globally', async () => { - let page: Playwright.Page - const browser = await next.browser('/', { - beforePageLoad(p: Playwright.Page) { - page = p - }, - }) - const act = createRouterAct(page) - - // /posts/[id] has no per-segment prefetch config, but the global - // `partialPrefetching: 'unstable_eager'` makes it eager. Reveal /posts/1 to - // prime the shared app shell. - await act(async () => { - await browser - .elementByCss('input[data-link-accordion="/posts/1"]') - .click() - }) - - // Reveal /posts/2 — a different param, shell already cached. Because the - // global config makes the route eager, the per-link Speculative prefetch - // still fires for param 2 (a single request carrying "Eager post 2"), - // rather than firing no requests as a non-eager route's second link would. - await act( - async () => { - await browser - .elementByCss('input[data-link-accordion="/posts/2"]') - .click() - }, - { includes: 'Eager post 2' } - ) - }) -}) diff --git a/test/e2e/app-dir/segment-cache/prefetch-app-shell/app/(default)/eager-instant/[id]/page.tsx b/test/e2e/app-dir/segment-cache/prefetch-app-shell/app/(default)/eager-instant/[id]/page.tsx deleted file mode 100644 index 8979e879df30..000000000000 --- a/test/e2e/app-dir/segment-cache/prefetch-app-shell/app/(default)/eager-instant/[id]/page.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { Suspense } from 'react' - -type Params = { id: string } - -// Combines both segment-level opt-ins: `instant` (which on its own -// behaves like 'partial' — not eager) AND `prefetch = 'unstable_eager'`. -// 'unstable_eager' wins: the segment is marked eager, so under App Shells the -// per-link Speculative prefetch still fires and the param-specific content -// below IS prefetched. -export const instant = true -export const prefetch = 'unstable_eager' - -export function generateStaticParams() { - return [{ id: '1' }, { id: '2' }, { id: '3' }] -} - -export default function Page({ params }: { params: Promise }) { - return ( -
    - {/* The fallback is the param-independent app shell. */} - Eager-instant app shell

    }> - -
    -
    - ) -} - -async function ParamContent({ params }: { params: Promise }) { - const { id } = await params - return

    {`Eager-instant post ${id}`}

    -} diff --git a/test/e2e/app-dir/segment-cache/prefetch-app-shell/app/(default)/eager/[id]/page.tsx b/test/e2e/app-dir/segment-cache/prefetch-app-shell/app/(default)/eager/[id]/page.tsx deleted file mode 100644 index 1c782cc89deb..000000000000 --- a/test/e2e/app-dir/segment-cache/prefetch-app-shell/app/(default)/eager/[id]/page.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { Suspense } from 'react' - -type Params = { id: string } - -// Opts into Partial Prefetching in "eager" mode. Behaves like 'partial', but -// under App Shells it keeps prefetching the route's segments instead of relying -// on the shared app shell — so the param-specific content below IS prefetched. -export const prefetch = 'unstable_eager' - -export function generateStaticParams() { - return [{ id: '1' }, { id: '2' }, { id: '3' }] -} - -export default function Page({ params }: { params: Promise }) { - return ( -
    - {/* The fallback is the param-independent app shell. */} - Eager app shell

    }> - -
    -
    - ) -} - -async function ParamContent({ params }: { params: Promise }) { - const { id } = await params - return

    {`Eager post ${id}`}

    -} diff --git a/test/e2e/app-dir/segment-cache/prefetch-app-shell/app/(default)/page.tsx b/test/e2e/app-dir/segment-cache/prefetch-app-shell/app/(default)/page.tsx index 75fe55cd082a..53ebb13dab14 100644 --- a/test/e2e/app-dir/segment-cache/prefetch-app-shell/app/(default)/page.tsx +++ b/test/e2e/app-dir/segment-cache/prefetch-app-shell/app/(default)/page.tsx @@ -108,22 +108,11 @@ export default function Page() { -

    Eager posts

    +

    Complete shell

    • - Eager 1 (default) -
    • -
    • - Eager 2 (default) -
    • -
    • - - Eager-instant 1 (instant + unstable_eager) - -
    • -
    • - - Eager-instant 2 (instant + unstable_eager) + + Complete runtime shell
    diff --git a/test/e2e/app-dir/segment-cache/prefetch-app-shell/app/(default)/runtime-shell-complete/page.tsx b/test/e2e/app-dir/segment-cache/prefetch-app-shell/app/(default)/runtime-shell-complete/page.tsx new file mode 100644 index 000000000000..93c157e43319 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/prefetch-app-shell/app/(default)/runtime-shell-complete/page.tsx @@ -0,0 +1,20 @@ +import { Suspense } from 'react' +import { cookies } from 'next/headers' + +export const prefetch = 'partial' + +export default function Page() { + return ( +
    + Loading cookie...

    }> + +
    +
    + ) +} + +async function CookieDependent() { + const cookieStore = await cookies() + const value = cookieStore.get('testCookie')?.value ?? 'none' + return +} diff --git a/test/e2e/app-dir/segment-cache/prefetch-app-shell/prefetch-app-shell.test.ts b/test/e2e/app-dir/segment-cache/prefetch-app-shell/prefetch-app-shell.test.ts index 594d7601ae33..45cd22698922 100644 --- a/test/e2e/app-dir/segment-cache/prefetch-app-shell/prefetch-app-shell.test.ts +++ b/test/e2e/app-dir/segment-cache/prefetch-app-shell/prefetch-app-shell.test.ts @@ -28,19 +28,14 @@ describe('App Shell prefetching', () => { // Reveal the LinkAccordion for /posts/1. This caches the App Shell // for the route — the param-independent content of the page that's // reusable for any /posts/[id]. + // The route reads request data, so it uses a runtime shell. await act(async () => { await browser .elementByCss('input[data-link-accordion="/posts/1"]') .click() }, [ - // Two runtime responses carry the shell text, in order: the Shell - // phase's runtime App Shell request, then the Speculative phase's - // batched per-link runtime prefetch. The route reads request data, - // so its static-attempt hint is unset and the prefetch deopts to - // runtime requests — the runtime-completeness contract of Partial - // Prefetching routes. - { includes: 'App shell for posts', kind: 'runtime' }, { includes: 'App shell for posts', kind: 'runtime' }, + { includes: 'param-value', block: 'reject' }, // Only a shell, no URL data. ]) await act(async () => { @@ -55,8 +50,7 @@ describe('App Shell prefetching', () => { expect(await browser.elementById('shell').text()).toEqual( 'App shell for posts' ) - // Sesssion data (cookies) is not dependent on URL-data, so they are - // allowed to be accessed in the shell. + // Session data (cookies) can be accessed in the shell. expect(await browser.elementById('cookie-value').text()).toEqual( 'Cookie: none' ) @@ -70,7 +64,34 @@ describe('App Shell prefetching', () => { ) }) - it('runtime-prefetches per-link content of a dynamic route whose static-attempt hint is unset', async () => { + it('can navigate without extra requests if a runtime app shell is complete', async () => { + let page: Playwright.Page + const browser = await next.browser('/', { + beforePageLoad(p: Playwright.Page) { + page = p + }, + }) + const act = createRouterAct(page, { includeAppShellRequests: true }) + + // Reveal the link and fetch the shell. The route uses cookies, so this will be + // a runtime shell. + await act(async () => { + await browser + .elementByCss('input[data-link-accordion="/runtime-shell-complete"]') + .click() + }, [{ includes: 'Cookie: none', kind: 'runtime' }]) + + // Navigate. The shell is complete, so this shouldn't require fetching anything else. + await act(async () => { + await browser.elementByCss('a[href="/runtime-shell-complete"]').click() + }, 'no-requests') + + expect(await browser.elementById('cookie-value').text()).toEqual( + 'Cookie: none' + ) + }) + + it('reuses a runtime App Shell across params without firing a per-link prefetch', async () => { let page: Playwright.Page const browser = await next.browser('/', { beforePageLoad(p: Playwright.Page) { @@ -79,41 +100,47 @@ describe('App Shell prefetching', () => { }) const act = createRouterAct(page, { includeAppShellRequests: true }) - // Reveal /posts/1 (default/auto prefetch). The page itself is partial - // (non-eager), but the path segments above it are eager, so the - // Speculative pass still walks them. Unlike /partial (covered by the - // next test), this route is dynamic — it reads cookies — so its - // static-attempt hint is unset and the walked segments deopt to a - // per-link runtime prefetch, which serves the whole subtree, page - // included. + // Reveal /posts/1 (default/auto prefetch). Unlike /partial (covered by the next test), this route + // reads cookies, so it deopts to a runtime shell prefetch. await act(async () => { await browser .elementByCss('input[data-link-accordion="/posts/1"]') .click() }, [ - // Two runtime responses carry the shell text, in order: the Shell - // phase's runtime App Shell request, then the Speculative phase's - // batched per-link runtime prefetch. The route reads request data, - // so its static-attempt hint is unset and the prefetch deopts to - // runtime requests — the runtime-completeness contract of Partial - // Prefetching routes. - { includes: 'App shell for posts', kind: 'runtime' }, { includes: 'App shell for posts', kind: 'runtime' }, + { includes: 'param-value', block: 'reject' }, // Only a shell, no URL data. ]) // Reveal /posts/2 — a different param that shares the same App Shell. - // The shell is already cached and is not re-fetched, but the hint-unset - // deopt issues a runtime prefetch for the new param's subtree, so the - // per-link content for param 2 arrives ahead of any navigation. - // Contrast with the /partial route in the next test, whose hint is set: - // there the cached shell satisfies the second link with no requests. - await act( - async () => { - await browser - .elementByCss('input[data-link-accordion="/posts/2"]') - .click() - }, - { includes: 'Post 2', kind: 'runtime' } + // The shell is already cached and is not re-fetched. + await act(async () => { + await browser + .elementByCss('input[data-link-accordion="/posts/2"]') + .click() + }, 'no-requests') + + await act(async () => { + // Click the link to /posts/2. The cached App + // Shell should render immediately, before any navigation response + // arrives. + await browser.elementByCss('a[href="/posts/2"]').click() + + // While the navigation response is blocked (we're still in the + // `act` block), the cached App Shell should already be visible. + expect(await browser.elementById('shell').text()).toEqual( + 'App shell for posts' + ) + // Session data (cookies) can be accessed in the shell. + expect(await browser.elementById('cookie-value').text()).toEqual( + 'Cookie: none' + ) + }) + + // After the outer act unblocks the navigation, params resolve and the + // dynamic content streams in. + expect(await browser.elementById('param-value').text()).toEqual('Post 2') + expect(await browser.elementById('dynamic-content').text()).toEqual( + 'Post body for 2' ) }) @@ -138,7 +165,7 @@ describe('App Shell prefetching', () => { .elementByCss('input[data-link-accordion="/partial/1"]') .click() }, - { includes: 'Partial app shell' } + { includes: 'Partial app shell', kind: 'static' } ) // Reveal /partial/2 — a different param that shares the same app shell. The @@ -153,74 +180,6 @@ describe('App Shell prefetching', () => { }, 'no-requests') }) - it('does NOT skip the Speculative prefetch for a route with prefetch = "unstable_eager"', async () => { - let page: Playwright.Page - const browser = await next.browser('/', { - beforePageLoad(p: Playwright.Page) { - page = p - }, - }) - const act = createRouterAct(page, { includeAppShellRequests: true }) - - // Reveal /eager/1. /eager/[id] opts into Partial Prefetching in "eager" - // mode, so this primes the shared app shell. (Because the route is eager it - // also speculatively prefetches param 1 here, but the assertion that - // demonstrates the eager behavior is on the second link below, where the - // shell is already cached and only the Speculative prefetch can fire.) - await act(async () => { - await browser - .elementByCss('input[data-link-accordion="/eager/1"]') - .click() - }) - - // Reveal /eager/2 — a different param that shares the same app shell. The - // shell is already cached, so it is NOT re-fetched. Because the route is - // eager, the per-link Speculative prefetch fires for param 2 — a single - // request carrying that param's content ("Eager post 2"). This is the - // counterpart to the partial route's second link, which fired no requests: - // an eager route keeps speculatively prefetching each new param. - await act( - async () => { - await browser - .elementByCss('input[data-link-accordion="/eager/2"]') - .click() - }, - { includes: 'Eager post 2' } - ) - }) - - it('treats a segment with both instant and prefetch = "unstable_eager" as eager', async () => { - let page: Playwright.Page - const browser = await next.browser('/', { - beforePageLoad(p: Playwright.Page) { - page = p - }, - }) - const act = createRouterAct(page, { includeAppShellRequests: true }) - - // /eager-instant/[id] sets BOTH instant (which alone behaves like - // 'partial' — not eager) and prefetch = 'unstable_eager'. The eager - // opt-in wins, so the segment is treated as eager. Same two-link pattern as - // the plain eager test: the first link primes the shared shell... - await act(async () => { - await browser - .elementByCss('input[data-link-accordion="/eager-instant/1"]') - .click() - }) - - // ...and the second link (different param, shell already cached) fires the - // per-link Speculative prefetch for param 2, proving the route is treated as - // eager rather than skipping the Speculative phase. - await act( - async () => { - await browser - .elementByCss('input[data-link-accordion="/eager-instant/2"]') - .click() - }, - { includes: 'Eager-instant post 2' } - ) - }) - it('does NOT skip the Speculative prefetch for a prefetch={true} link, even on a partial route', async () => { let page: Playwright.Page const browser = await next.browser('/', { @@ -230,9 +189,7 @@ describe('App Shell prefetching', () => { }) const act = createRouterAct(page, { includeAppShellRequests: true }) - // Reveal /partial/1 (default). /partial/[id] opts into Partial Prefetching, - // so the default link primes the shared shell and skips the Speculative - // prefetch (asserted by the other partial test). Here we just prime. + // Reveal /partial/1 (default). This primes the shared shell. await act(async () => { await browser .elementByCss('input[data-link-accordion="/partial/1"]') @@ -243,9 +200,7 @@ describe('App Shell prefetching', () => { // (a Full prefetch). prefetch={true} always prefetches the route's segments, // bypassing the App Shells skip. The shell is already cached, so the only // request is the Speculative prefetch for param 3, carrying its content - // ("Partial post 3"). Contrast with the default partial link, whose second - // link fires no requests: prefetch={true} opts back into per-link - // prefetching even on a partial route. + // ("Partial post 3"). await act( async () => { await browser @@ -272,16 +227,14 @@ describe('App Shell prefetching', () => { }) const act = createRouterAct(page, { includeAppShellRequests: true }) - // Reveal the LinkAccordion for /static-posts/1. The route is fully static - // and doesn't opt into Partial Prefetching, so there's no separate runtime - // shell prefetch — a single per-segment static prefetch fires, carrying the - // resolved page content plus the shell prefix above the params boundary - // (with a byte offset the client uses to extract and cache the shell). + // Reveal the LinkAccordion for /static-posts/1. The route is fully static, + // so there's no need for a runtime shell -- the static per-segment prefetch returns the + // full page content from which we can extract a static app shell. await act(async () => { await browser .elementByCss('input[data-link-accordion="/static-posts/1"]') .click() - }, [{ includes: 'App shell for static posts' }]) + }, [{ includes: 'App shell for static posts', kind: 'static' }]) // Click the link to /static-posts/124 — a different param than what // was prefetched, rendered with prefetch={false}. The cached App @@ -324,13 +277,8 @@ describe('App Shell prefetching', () => { .elementByCss('input[data-link-accordion="/short-stale/1"]') .click() }, [ - // Two runtime responses carry the shell text, in order: the Shell - // phase's runtime App Shell request, then the Speculative phase's - // batched per-link runtime prefetch. The route reads request data, - // so its static-attempt hint is unset and the prefetch deopts to - // runtime requests — the runtime-completeness contract of Partial - // Prefetching routes. - { includes: 'App shell for short-stale', kind: 'runtime' }, + // The route reads request data, so its static-attempt hint is unset + // and the prefetch deopts to a runtime request. { includes: 'App shell for short-stale', kind: 'runtime' }, ]) @@ -390,7 +338,7 @@ describe('App Shell prefetching', () => { await browser .elementByCss('input[data-link-accordion="/static-short-stale/1"]') .click() - }, [{ includes: 'App shell for static short-stale posts' }]) + }, [{ includes: 'App shell for static short-stale posts', kind: 'static' }]) await act(async () => { // Click the link to /static-short-stale/124 — a different param than @@ -451,20 +399,13 @@ describe('App Shell prefetching', () => { ) .click() }, [ - // Two runtime responses carry the shell text, in order: the Shell - // phase's runtime App Shell request, then the Speculative phase's - // batched per-link runtime prefetch. The route reads request data, - // so its static-attempt hint is unset and the prefetch deopts to - // runtime requests — the runtime-completeness contract of Partial - // Prefetching routes. - { - includes: 'App shell for posts with root param: en', - kind: 'runtime', - }, + // The route reads request data, so its static-attempt hint is unset and the prefetch + // deopts to a runtime request. { includes: 'App shell for posts with root param: en', kind: 'runtime', }, + { includes: 'param-value', block: 'reject' }, // Only a shell, no URL data. ]) await act(async () => { @@ -481,8 +422,7 @@ describe('App Shell prefetching', () => { expect(await browser.elementById('shell').text()).toEqual( 'App shell for posts with root param: en' ) - // Sesssion data (cookies) is not dependent on URL-data, so they are - // allowed to be accessed in the shell. + // Session data (cookies) can be accessed in the shell. expect(await browser.elementById('cookie-value').text()).toEqual( 'Cookie: none' ) @@ -525,7 +465,12 @@ describe('App Shell prefetching', () => { 'input[data-link-accordion="/with-root-param/en/static-posts/1"]' ) .click() - }, [{ includes: 'App shell for static posts with root param: en' }]) + }, [ + { + includes: 'App shell for static posts with root param: en', + kind: 'static', + }, + ]) // Click the link to /with-root-param/en/static-posts/124 — a different param than what // was prefetched, rendered with prefetch={false}. The cached App @@ -572,15 +517,9 @@ describe('App Shell prefetching', () => { ) .click() }, [ - // Two runtime responses carry the shell text, in order: the Shell - // phase's runtime App Shell request, then the Speculative phase's - // per-link runtime prefetch (the page reads cookies, so the hint - // is unset and the prefetch deopts to a runtime request). Unlike - // the /posts routes, no static bundle fetch fires in between: the - // page has no non-root params, so its runtime App Shell entry is - // already as complete as any static response could be. - { includes: 'App shell for page with root param: en' }, - { includes: 'App shell for page with root param: en' }, + // The page reads cookies, so the hint is unset and the prefetch deopts to a runtime request. + { includes: 'App shell for page with root param: en', kind: 'runtime' }, + { includes: 'param-value', block: 'reject' }, // Only a shell, no URL data. ]) await act(async () => { @@ -601,8 +540,7 @@ describe('App Shell prefetching', () => { expect(await browser.elementById('shell').text()).toEqual( 'App shell for page with root param: en' ) - // Sesssion data (cookies) is not dependent on URL-data, so they are - // allowed to be accessed in the shell. + // Session data (cookies) can be accessed in the shell. expect(await browser.elementById('cookie-value').text()).toEqual( 'Cookie: none' ) @@ -637,7 +575,7 @@ describe('App Shell prefetching', () => { ) .click() }, - { includes: 'App shell for page with root param: en' } + { includes: 'App shell for page with root param: en', kind: 'static' } ) await act(async () => { @@ -688,20 +626,13 @@ describe('App Shell prefetching', () => { ) .click() }, [ - // Two runtime responses carry the shell text, in order: the Shell - // phase's runtime App Shell request, then the Speculative phase's - // batched per-link runtime prefetch. The route reads request data, - // so its static-attempt hint is unset and the prefetch deopts to - // runtime requests — the runtime-completeness contract of Partial - // Prefetching routes. - { - includes: 'App shell for posts with root param: en', - kind: 'runtime', - }, + // The route reads request data, so its static-attempt hint is unset + // and the prefetch deopts to a runtime request. { includes: 'App shell for posts with root param: en', kind: 'runtime', }, + { includes: 'param-value', block: 'reject' }, // Only a shell, no URL data. ]) await act(async () => { @@ -750,20 +681,13 @@ describe('App Shell prefetching', () => { ) .click() }, [ - // Two runtime responses carry the shell text, in order: the Shell - // phase's runtime App Shell request, then the Speculative phase's - // batched per-link runtime prefetch. The route reads request data, - // so its static-attempt hint is unset and the prefetch deopts to - // runtime requests — the runtime-completeness contract of Partial - // Prefetching routes. - { - includes: 'App shell for posts with root param: fr', - kind: 'runtime', - }, + // The route reads request data, so its static-attempt hint is unset + // and the prefetch deopts to a runtime request. { includes: 'App shell for posts with root param: fr', kind: 'runtime', }, + { includes: 'param-value', block: 'reject' }, // Only a shell, no URL data. ]) await act(async () => { @@ -810,7 +734,12 @@ describe('App Shell prefetching', () => { 'input[data-link-accordion="/with-root-param/en/static-posts/1"]' ) .click() - }, [{ includes: 'App shell for static posts with root param: en' }]) + }, [ + { + includes: 'App shell for static posts with root param: en', + kind: 'static', + }, + ]) await act(async () => { const startingUrl = await browser.url() @@ -859,7 +788,6 @@ describe('App Shell prefetching', () => { [ // TODO(app-shells): why aren't there requests here? // { includes: 'App shell for static posts with root param: fr' }, - // { includes: 'App shell for static posts with root param: fr' }, ] ) diff --git a/test/e2e/app-dir/segment-cache/prefetch-inlining/app/page.tsx b/test/e2e/app-dir/segment-cache/prefetch-inlining/app/page.tsx index 009c47879f41..9b16f4878de9 100644 --- a/test/e2e/app-dir/segment-cache/prefetch-inlining/app/page.tsx +++ b/test/e2e/app-dir/segment-cache/prefetch-inlining/app/page.tsx @@ -71,12 +71,12 @@ export default function Page() {
  • - + Independent head A
  • - + Independent head B
  • diff --git a/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-independent-head/[item]/page.tsx b/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-independent-head/[item]/page.tsx index a9b00e81fab5..d242925d3ad5 100644 --- a/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-independent-head/[item]/page.tsx +++ b/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-independent-head/[item]/page.tsx @@ -30,7 +30,7 @@ export default async function Page({ return (

    Independent head page

    - + Go to {sibling}
    diff --git a/test/e2e/app-dir/segment-cache/prefetch-inlining/prefetch-inlining.test.ts b/test/e2e/app-dir/segment-cache/prefetch-inlining/prefetch-inlining.test.ts index bd0549b6a750..d9ff1569b21c 100644 --- a/test/e2e/app-dir/segment-cache/prefetch-inlining/prefetch-inlining.test.ts +++ b/test/e2e/app-dir/segment-cache/prefetch-inlining/prefetch-inlining.test.ts @@ -636,15 +636,15 @@ describe('prefetch inlining', () => { page = p }, }) - const act = createRouterAct(page!) + const act = createRouterAct(page!, { includeAppShellRequests: true }) // Reveal a default (auto) link to the route. The route is a Partial // Prefetching route (the page is partial), so every segment the // prefetch walks is held to the runtime-completeness contract — and the // route's static-attempt hint is unset because the page reads cookies, - // so the walked layout deopts directly to the batched runtime prefetch, + // so the walked layout deopts directly to the batched runtime shell, // which serves its whole subtree. The inlined layout content arrives in - // that runtime response. (No static bundle request fires: the Shell + // that runtime shell response. (No static bundle request fires: the Shell // phase already runtime-cached every entry in the bundle chain, and a // runtime-complete entry is never re-fetched by a static prefetch.) await act( @@ -659,9 +659,8 @@ describe('prefetch inlining', () => { { includes: 'Static layout content', kind: 'runtime' } ) - // Reveal a prefetch={true} link to the same route. Everything is - // already runtime-cached at the per-link tier by the prefetch above, so - // opting in has nothing left to fetch. + // Reveal a prefetch={true} link to the same route. The shell is complete, + // so a runtime prefetch will not give us any more data and should be skipped. await act(async () => { await browser .elementByCss( @@ -709,7 +708,7 @@ describe('prefetch inlining', () => { page = p }, }) - const act = createRouterAct(page!) + const act = createRouterAct(page!, { includeAppShellRequests: true }) await act( async () => { @@ -721,7 +720,7 @@ describe('prefetch inlining', () => { }, // The layout reads cookies, so the route's static-attempt hint is // unset and the Speculative pass deopts the layout directly to the - // batched runtime prefetch, which serves the whole subtree — the + // batched runtime shell, which serves the whole subtree — the // static inner layout and page ride along in that single runtime // response. No static bundle request fires: every entry was already // runtime-cached at the shell tier by the Shell phase, and a @@ -810,7 +809,7 @@ describe('prefetch inlining', () => { page = p }, }) - const act = createRouterAct(page!) + const act = createRouterAct(page!, { includeAppShellRequests: true }) await act( async () => { @@ -821,7 +820,7 @@ describe('prefetch inlining', () => { .click() }, // Same as the runtime passthrough test: the hint-unset layout deopts - // to the batched runtime prefetch, which serves the whole subtree + // to the batched runtime shell, which serves the whole subtree // (both slots) in a single runtime response. { includes: 'Runtime parallel main content', kind: 'runtime' } ) @@ -843,8 +842,8 @@ describe('prefetch inlining', () => { // [item] param and searchParams, making it depend on runtime data. // // Because the layout reads cookies, the route's static-attempt hint is - // unset, so on this Partial Prefetching route every per-link prefetch - // deopts its new subtree to the batched runtime prefetch. The head is + // unset, so on this Partial Prefetching route every shell and prefetch + // deopt its new subtree to a runtime request. The head is // param-dependent, so it is NOT part of the reusable App Shell — but // whenever a runtime prefetch fires for a segment, the head rides // along in the same request. So each prefetched sibling gets its own @@ -866,22 +865,27 @@ describe('prefetch inlining', () => { page = p }, }) - const act = createRouterAct(page!) + const act = createRouterAct(page!, { includeAppShellRequests: true }) - // Prefetch and navigate to route A. This caches the layout, the static - // page, and A's head (riding along with the runtime prefetch), and - // makes A the current page. + // Runtime-prefetch (with prefetch={true}) route A. This caches the layout, the + // static page, and A's head. await act(async () => { await browser .elementByCss('input[data-link-accordion="/test-independent-head/a"]') .click() - }) + }, [ + // Shell + { includes: 'item-layout', kind: 'runtime' }, + // Speculative (search params) + { includes: 'Independent Head Title: a', kind: 'runtime' }, + ]) + // Navigate to A. It should be fully prefetched. await act(async () => { await browser.elementByCss('a[href="/test-independent-head/a"]').click() }, 'no-requests') - // Now we're on route A. Reveal the sibling link to route B. The - // layout is shared between A and B, so it's already cached and won't + // Now we're on route A. Reveal the sibling link to route B (with prefetch={true}). + // The layout is shared between A and B, so it's already cached and won't // be re-fetched. The only new segment is the [item] page. On this // hint-unset route it deopts to the batched runtime prefetch, and B's // param-specific head rides along in the same request — no standalone @@ -891,9 +895,7 @@ describe('prefetch inlining', () => { .elementByCss('input[data-link-accordion="/test-independent-head/b"]') .click() }, [ - // The page below the layout arrives via the runtime prefetch. - { includes: 'page-independent-head', kind: 'runtime' }, - // ...and B's head rides along in the same runtime response. + // The page and the head arrive in the same runtime response. { includes: 'Independent Head Title: b', kind: 'runtime' }, ]) diff --git a/test/e2e/app-dir/segment-cache/prefetch-runtime/prefetch-runtime.test.ts b/test/e2e/app-dir/segment-cache/prefetch-runtime/prefetch-runtime.test.ts index 962a769e58e0..41172c9a0b1a 100644 --- a/test/e2e/app-dir/segment-cache/prefetch-runtime/prefetch-runtime.test.ts +++ b/test/e2e/app-dir/segment-cache/prefetch-runtime/prefetch-runtime.test.ts @@ -510,18 +510,20 @@ describe('runtime prefetching', () => { // Clear cookies after the test. This currently doesn't happen automatically. await using _ = defer(() => browser.deleteCookies()) - const act = createRouterAct(page) + const act = createRouterAct(page, { includeAppShellRequests: true }) await browser.addCookie({ name: 'testCookie', value: 'initialValue' }) - // Reveal the link to trigger a runtime prefetch for the initial cookie value + // Reveal the link. + // We won't actually perform a runtime prefetch, because the request is + // satisfied by the app shell. await act(async () => { const linkToggle = await browser.elementByCss( `input[data-link-accordion="/${prefix}/cookies-only"]` ) await linkToggle.click() }, [ - // Should allow reading cookies + // Should allow reading cookies in the app shell { includes: 'Cookie: initialValue', }, @@ -561,11 +563,14 @@ describe('runtime prefetching', () => { // Clear cookies after the test. This currently doesn't happen automatically. await using _ = defer(() => browser.deleteCookies()) - const act = createRouterAct(page) + const act = createRouterAct(page, { includeAppShellRequests: true }) await browser.addCookie({ name: 'testCookie', value: 'initialValue' }) - // Reveal the link to trigger a runtime prefetch for the initial cookie value + // Reveal the link. + // We won't actually perform a runtime prefetch, because the request is + // satisfied by the app shell. + await act(async () => { const linkToggle = await browser.elementByCss( `input[data-link-accordion="/${prefix}/cookies-only"]` @@ -1056,11 +1061,13 @@ describe('runtime prefetching', () => { page = p }, }) - const act = createRouterAct(page) + const act = createRouterAct(page, { includeAppShellRequests: true }) const STATIC_CONTENT = 'This page errors after a cookies call' - // Reveal the link to trigger a runtime prefetch + // Reveal the link. + // We won't actually perform a runtime prefetch, because the request is + // satisfied by the app shell. await act(async () => { const linkToggle = await browser.elementByCss( `input[data-link-accordion="/errors/error-after-cookies"]` @@ -1077,7 +1084,7 @@ describe('runtime prefetching', () => { expect(getCliOutput()).toContain('Error: Kaboom') } - // Navigate to the page. We already have the paged cached. + // Navigate to the page. We already have the page cached. // Even though the render errored, we shouldn't fetch it again. await act(async () => { await browser diff --git a/test/e2e/app-dir/segment-cache/prefetch-static-shell/app/speculative-cookies/page.tsx b/test/e2e/app-dir/segment-cache/prefetch-static-shell/app/speculative-cookies/page.tsx index f4291dc795f1..4fc87b81bdc7 100644 --- a/test/e2e/app-dir/segment-cache/prefetch-static-shell/app/speculative-cookies/page.tsx +++ b/test/e2e/app-dir/segment-cache/prefetch-static-shell/app/speculative-cookies/page.tsx @@ -7,20 +7,16 @@ import { cookies } from 'next/headers' // Prefetching segment, the page requires runtime-completeness during the // Speculative phase (which the consuming test enters via a `prefetch={true}` // link), and with the hint unset the scheduler skips the static attempt -// entirely and issues the runtime prefetch directly. Unlike the uses-cookies -// fixture, the Speculative runtime prefetch RESOLVES the cookies() read, so -// the cookie-derived content itself arrives in the runtime response. +// entirely and issues the runtime prefetch directly. +// It also awaits searchParams so that a speculative prefetch has non-shell +// contents to resolve. export const prefetch = 'partial' -async function CookieContent() { - const cookieStore = await cookies() - const value = cookieStore.get('testCookie')?.value ?? 'none' - return ( - - ) +type PageProps = { + searchParams: Promise } -export default function Page() { +export default function Page(props: PageProps) { return (

    Speculative-cookies page shell text

    @@ -29,8 +25,34 @@ export default function Page() { } > - +
    ) } + +async function CookieContent(props: PageProps) { + const cookieStore = await cookies() + const value = cookieStore.get('testCookie')?.value ?? 'none' + return ( + <> + + Loading search params...

    + } + > + +
    + + ) +} + +type SearchParams = Record + +async function SearchParamsContent(props: PageProps) { + const searchCount = Object.keys(await props.searchParams).length + return ( +
    {`Search params count: ${searchCount}`}
    + ) +} diff --git a/test/e2e/app-dir/segment-cache/prefetch-static-shell/prefetch-static-shell.test.ts b/test/e2e/app-dir/segment-cache/prefetch-static-shell/prefetch-static-shell.test.ts index 2f9890c31464..b137964f87a8 100644 --- a/test/e2e/app-dir/segment-cache/prefetch-static-shell/prefetch-static-shell.test.ts +++ b/test/e2e/app-dir/segment-cache/prefetch-static-shell/prefetch-static-shell.test.ts @@ -123,11 +123,8 @@ describe('static App Shell prefetch attempt', () => { .elementByCss('input[data-link-accordion="/uses-cookies"]') .click() }, [ - // The page (the new part) arrives in the runtime shell response. - // (The bare cookies() read itself isn't resolved by the runtime - // prerender — it stays a hole for the navigation-time dynamic - // request — so we only assert on the shell text.) - { includes: 'Cookies page shell text', kind: 'runtime' }, + // Cookies are included in the runtime shell. + { includes: 'cookie-content', kind: 'runtime' }, // No static attempt for the new part: the page content must not // arrive in a static per-segment response. (The route tree prefetch // is also `kind: 'static'`, but its response doesn't contain rendered @@ -382,29 +379,15 @@ describe('static App Shell prefetch attempt', () => { .elementByCss('input[data-link-accordion="/speculative-cookies"]') .click() }, [ - // Two runtime responses arrive, in order, and each resolves the - // cookies() read (a runtime prefetch renders with the request's - // cookies): - // - // 1. The Shell phase's runtime shell request — the same direct - // runtime shell behavior the hint-unset tests above exercise, - // except that here the session content resolves instead of - // remaining a hole. - { includes: 'Speculative-cookies page shell text', kind: 'runtime' }, - { includes: 'Speculative-cookies cookie: none', kind: 'runtime' }, - // 2. The Speculative phase's runtime prefetch of the page segment, - // which re-delivers the page content. (It fires on top of the - // runtime shell entry because a per-URL runtime prefetch can - // provide content a URL-independent shell response cannot.) - { includes: 'Speculative-cookies page shell text', kind: 'runtime' }, + // 1. Runtime shell (includes cookies) { includes: 'Speculative-cookies cookie: none', kind: 'runtime' }, + // 2. Runtime prefetch (includes cookies and search params) + { includes: 'Search params count: 0', kind: 'runtime' }, + // No static attempt in either phase: the page content must not // arrive in ANY static per-segment response. (The server does emit // static data for the segment — the shell text is in it — but with - // the hint unset nothing fetches it: this blanket rejection - // was verified empirically against the full request log. The route - // tree prefetch is also kind: 'static', but its response doesn't - // contain rendered page content, so it can't match this.) + // the hint unset nothing fetches it. { includes: 'Speculative-cookies page shell text', kind: 'static', diff --git a/turbopack/crates/turbo-frozenmap/Cargo.toml b/turbopack/crates/turbo-frozenmap/Cargo.toml index 9691b90a4d2d..e2842a11a527 100644 --- a/turbopack/crates/turbo-frozenmap/Cargo.toml +++ b/turbopack/crates/turbo-frozenmap/Cargo.toml @@ -10,6 +10,7 @@ indexmap = { workspace = true } serde = { workspace = true } [dev-dependencies] +serde_json = { workspace = true } [lints] workspace = true diff --git a/turbopack/crates/turbo-frozenmap/src/map.rs b/turbopack/crates/turbo-frozenmap/src/map.rs index cf99eba3dfb9..f8f7c962f749 100644 --- a/turbopack/crates/turbo-frozenmap/src/map.rs +++ b/turbopack/crates/turbo-frozenmap/src/map.rs @@ -4,12 +4,16 @@ use std::{ fmt::{self, Debug}, hash::BuildHasher, iter::FusedIterator, + marker::PhantomData, ops::{Bound, Index, RangeBounds}, }; use bincode::{BorrowDecode, Decode, Encode}; use indexmap::IndexMap; -use serde::{Deserialize, Serialize}; +use serde::{ + Deserialize, Serialize, + de::{MapAccess, Visitor}, +}; /// A compact frozen (immutable) ordered map backed by a sorted boxed slice. /// @@ -38,7 +42,7 @@ use serde::{Deserialize, Serialize}; /// /// Overlapping keys encountered during construction preserve the last overlapping entry, matching /// similar behavior for other maps in the standard library. -#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Encode, Decode, Serialize, Deserialize)] +#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Encode, Decode)] #[rustfmt::skip] // rustfmt breaks bincode's proc macro string processing #[bincode( decode_bounds = "K: Decode<__Context> + 'static, V: Decode<__Context> + 'static", @@ -49,6 +53,44 @@ pub struct FrozenMap { pub(crate) entries: Box<[(K, V)]>, } +impl Serialize for FrozenMap { + fn serialize(&self, serializer: S) -> Result { + serializer.collect_map(self.iter()) + } +} + +impl<'de, K, V> Deserialize<'de> for FrozenMap +where + K: Deserialize<'de> + Ord, + V: Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + struct MapVisitor(PhantomData<(K, V)>); + + impl<'de, K, V> Visitor<'de> for MapVisitor + where + K: Deserialize<'de> + Ord, + V: Deserialize<'de>, + { + type Value = FrozenMap; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a map") + } + + fn visit_map>(self, mut map: A) -> Result { + let mut entries = Vec::with_capacity(map.size_hint().unwrap_or(0)); + while let Some(entry) = map.next_entry()? { + entries.push(entry); + } + Ok(FrozenMap::from(entries)) + } + } + + deserializer.deserialize_map(MapVisitor(PhantomData)) + } +} + impl FrozenMap { /// Creates an empty [`FrozenMap`]. Does not perform any heap allocations. pub fn new() -> Self { @@ -812,6 +854,18 @@ impl Clone for Range<'_, K, V> { mod tests { use super::*; + #[test] + fn serde_uses_map_shape() { + let map = FrozenMap::from([("b", 2), ("a", 1)]); + let json = serde_json::to_string(&map).unwrap(); + + assert_eq!(json, r#"{"a":1,"b":2}"#); + assert_eq!( + serde_json::from_str::>(r#"{"b":2, "a":1}"#).unwrap(), + map + ); + } + #[test] fn test_empty() { let map = FrozenMap::::new(); diff --git a/turbopack/crates/turbo-frozenmap/src/set.rs b/turbopack/crates/turbo-frozenmap/src/set.rs index 30e12dea888f..4361bcea789e 100644 --- a/turbopack/crates/turbo-frozenmap/src/set.rs +++ b/turbopack/crates/turbo-frozenmap/src/set.rs @@ -4,12 +4,16 @@ use std::{ fmt::{self, Debug}, hash::BuildHasher, iter::FusedIterator, + marker::PhantomData, ops::RangeBounds, }; use bincode::{BorrowDecode, Decode, Encode}; use indexmap::IndexSet; -use serde::{Deserialize, Serialize}; +use serde::{ + Deserialize, Serialize, + de::{SeqAccess, Visitor}, +}; use crate::map::{self, FrozenMap}; @@ -37,7 +41,7 @@ use crate::map::{self, FrozenMap}; /// [`Vec`] or boxed slice. Because of limitations of the internal representation and Rust's memory /// layout rules, the most efficient way to convert from these data structures is via an /// [`Iterator`]. -#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Encode, Decode, Serialize, Deserialize)] +#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Encode, Decode)] #[bincode( decode_bounds = "T: Decode<__Context> + 'static", borrow_decode_bounds = "T: BorrowDecode<'__de, __Context> + '__de" @@ -46,6 +50,44 @@ pub struct FrozenSet { map: FrozenMap, } +impl Serialize for FrozenSet { + fn serialize(&self, serializer: S) -> Result { + serializer.collect_seq(self.iter()) + } +} + +impl<'de, T> Deserialize<'de> for FrozenSet +where + T: Deserialize<'de> + Ord, +{ + fn deserialize>(deserializer: D) -> Result { + struct SeqVisitor(PhantomData); + + impl<'de, T> Visitor<'de> for SeqVisitor + where + T: Deserialize<'de> + Ord, + { + type Value = FrozenSet; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a sequence") + } + + fn visit_seq>(self, mut seq: A) -> Result { + let mut items = Vec::with_capacity(seq.size_hint().unwrap_or(0)); + while let Some(item) = seq.next_element()? { + items.push((item, ())); + } + Ok(FrozenSet { + map: FrozenMap::from(items), + }) + } + } + + deserializer.deserialize_seq(SeqVisitor(PhantomData)) + } +} + impl FrozenSet { /// Creates an empty [`FrozenSet`]. Does not perform any heap allocations. pub fn new() -> Self { @@ -355,6 +397,18 @@ impl Clone for Range<'_, T> { mod tests { use super::*; + #[test] + fn serde_uses_sequence_shape() { + let set = FrozenSet::from([2, 1]); + let json = serde_json::to_string(&set).unwrap(); + + assert_eq!(json, "[1,2]"); + assert_eq!( + serde_json::from_str::>("[2, 1]").unwrap(), + set + ); + } + #[test] fn test_empty() { let set = FrozenSet::::new(); diff --git a/turbopack/crates/turbo-tasks-fs/src/rope.rs b/turbopack/crates/turbo-tasks-fs/src/rope.rs index 7745fe703eab..acbf0e0f9db8 100644 --- a/turbopack/crates/turbo-tasks-fs/src/rope.rs +++ b/turbopack/crates/turbo-tasks-fs/src/rope.rs @@ -2,6 +2,7 @@ use std::{ borrow::Cow, cmp::{Ordering, min}, fmt, + hash::{Hash, Hasher}, io::{BufRead, Read, Result as IoResult, Write}, mem, ops::{AddAssign, Deref}, @@ -22,7 +23,7 @@ use bytes::Bytes; use futures::Stream; use tokio::io::{AsyncRead, ReadBuf}; use triomphe::Arc; -use turbo_tasks_hash::{DeterministicHash, DeterministicHasher}; +use turbo_tasks_hash::{DeterministicHash, DeterministicHasher, hash_xxh3_hash64}; static EMPTY_BUF: &[u8] = &[]; @@ -491,6 +492,12 @@ impl PartialEq for Rope { impl Eq for Rope {} +impl Hash for Rope { + fn hash(&self, state: &mut H) { + hash_xxh3_hash64(self.content_hash()).hash(state); + } +} + impl Ord for Rope { fn cmp(&self, other: &Self) -> Ordering { if Arc::ptr_eq(&self.data, &other.data) { @@ -1124,6 +1131,24 @@ mod test { assert_eq!(hash_xxh3_hash64(rope.content_hash()), hasher.finish()); } + #[test] + fn standard_hash_uses_content() { + use std::{ + collections::hash_map::DefaultHasher, + hash::{Hash, Hasher}, + }; + + let original = Rope::from("same content"); + let copied = Rope::from(original.to_bytes().into_owned()); + let mut original_hasher = DefaultHasher::new(); + let mut copied_hasher = DefaultHasher::new(); + original.hash(&mut original_hasher); + copied.hash(&mut copied_hasher); + + assert_eq!(original, copied); + assert_eq!(original_hasher.finish(), copied_hasher.finish()); + } + #[test] fn iteration() { let shared = Rope::from("def"); diff --git a/turbopack/crates/turbopack-browser/src/ecmascript/list/content.rs b/turbopack/crates/turbopack-browser/src/ecmascript/list/content.rs index ff2dab584309..fe977163ee32 100644 --- a/turbopack/crates/turbopack-browser/src/ecmascript/list/content.rs +++ b/turbopack/crates/turbopack-browser/src/ecmascript/list/content.rs @@ -36,7 +36,6 @@ enum CurrentChunkMethodWithData { DocumentCurrentScript, } -/// Contents of an [`EcmascriptDevChunkList`]. #[turbo_tasks::value] pub struct EcmascriptDevChunkListContent { current_chunk_method: CurrentChunkMethodWithData, diff --git a/turbopack/crates/turbopack-core/Cargo.toml b/turbopack/crates/turbopack-core/Cargo.toml index 8ab938429845..1217222e0dc5 100644 --- a/turbopack/crates/turbopack-core/Cargo.toml +++ b/turbopack/crates/turbopack-core/Cargo.toml @@ -23,6 +23,7 @@ bytes-str = { workspace = true } const_format = { workspace = true } data-encoding = { workspace = true } either = { workspace = true } +erased-serde = { workspace = true } indexmap = { workspace = true } num-bigint = "0.4" patricia_tree = { version = "0.10.1", features = ["serde"] } diff --git a/turbopack/crates/turbopack-core/src/lib.rs b/turbopack/crates/turbopack-core/src/lib.rs index fa924a889955..43ed7036dfef 100644 --- a/turbopack/crates/turbopack-core/src/lib.rs +++ b/turbopack/crates/turbopack-core/src/lib.rs @@ -41,6 +41,7 @@ pub mod source_map; pub mod source_pos; pub mod source_transform; pub mod target; +pub mod update_instruction; mod utils; pub mod version; pub mod virtual_output; diff --git a/turbopack/crates/turbopack-core/src/update_instruction.rs b/turbopack/crates/turbopack-core/src/update_instruction.rs new file mode 100644 index 000000000000..a4ddb8407991 --- /dev/null +++ b/turbopack/crates/turbopack-core/src/update_instruction.rs @@ -0,0 +1,105 @@ +use std::{any::Any, fmt::Debug, sync::Arc}; + +use serde::Serialize; +use turbo_tasks::{ + NonLocalValue, + debug::ValueDebugFormat, + trace::{TraceRawVcs, TraceRawVcsContext}, +}; + +trait ErasedUpdateInstruction: + erased_serde::Serialize + Debug + Send + Sync + NonLocalValue + 'static +{ + fn as_any(&self) -> &dyn Any; + fn dyn_eq(&self, other: &dyn Any) -> bool; + fn trace_raw_vcs(&self, trace_context: &mut TraceRawVcsContext); +} + +impl ErasedUpdateInstruction for T +where + T: Serialize + Eq + Debug + Send + Sync + NonLocalValue + TraceRawVcs + 'static, +{ + fn as_any(&self) -> &dyn Any { + self + } + + fn dyn_eq(&self, other: &dyn Any) -> bool { + other.downcast_ref::() == Some(self) + } + + fn trace_raw_vcs(&self, trace_context: &mut TraceRawVcsContext) { + TraceRawVcs::trace_raw_vcs(self, trace_context); + } +} + +erased_serde::serialize_trait_object!(ErasedUpdateInstruction); + +#[derive(Clone, Debug, Serialize, ValueDebugFormat, NonLocalValue)] +#[serde(transparent)] +pub struct UpdateInstruction(Arc); + +impl PartialEq for UpdateInstruction { + fn eq(&self, other: &Self) -> bool { + self.0.dyn_eq(other.0.as_any()) + } +} + +impl Eq for UpdateInstruction {} + +impl UpdateInstruction { + pub fn new(instruction: T) -> Self + where + T: Serialize + Eq + Debug + Send + Sync + NonLocalValue + TraceRawVcs + 'static, + { + Self(Arc::new(instruction)) + } + + pub fn downcast_ref(&self) -> Option<&T> { + self.0.as_any().downcast_ref() + } +} + +impl TraceRawVcs for UpdateInstruction { + fn trace_raw_vcs(&self, trace_context: &mut TraceRawVcsContext) { + ErasedUpdateInstruction::trace_raw_vcs(self.0.as_ref(), trace_context); + } +} + +#[cfg(test)] +mod tests { + use serde::Serialize; + use turbo_tasks::{NonLocalValue, trace::TraceRawVcs}; + + use super::UpdateInstruction; + + #[derive(Debug, PartialEq, Eq, Serialize, TraceRawVcs, NonLocalValue)] + struct TestInstruction { + value: u32, + } + + #[test] + fn serializes_without_an_extra_wrapper() { + let instruction = UpdateInstruction::new(TestInstruction { value: 42 }); + + assert_eq!( + serde_json::to_value(&instruction).unwrap(), + serde_json::json!({ "value": 42 }) + ); + } + + #[test] + fn downcasts_by_concrete_type() { + let instruction = UpdateInstruction::new(TestInstruction { value: 42 }); + + assert_eq!( + instruction + .downcast_ref::() + .map(|instruction| instruction.value), + Some(42) + ); + assert_eq!( + instruction, + UpdateInstruction::new(TestInstruction { value: 42 }) + ); + } +} diff --git a/turbopack/crates/turbopack-core/src/version.rs b/turbopack/crates/turbopack-core/src/version.rs index bb1d422adc9c..89744f6f80ea 100644 --- a/turbopack/crates/turbopack-core/src/version.rs +++ b/turbopack/crates/turbopack-core/src/version.rs @@ -1,5 +1,3 @@ -use std::sync::Arc; - use anyhow::{Context, Result, bail}; use turbo_rcstr::RcStr; use turbo_tasks::{ @@ -213,8 +211,7 @@ pub struct PartialUpdate { pub to: TraitRef>, /// The instructions to be passed to a remote system in order to update the /// versioned object. - #[turbo_tasks(trace_ignore)] - pub instruction: Arc, + pub instruction: crate::update_instruction::UpdateInstruction, } /// [`Version`] implementation that hashes a file at a given path and returns diff --git a/turbopack/crates/turbopack-ecmascript-hmr-protocol/Cargo.toml b/turbopack/crates/turbopack-ecmascript-hmr-protocol/Cargo.toml index 08a5893c1cee..6e85c6ae11cf 100644 --- a/turbopack/crates/turbopack-ecmascript-hmr-protocol/Cargo.toml +++ b/turbopack/crates/turbopack-ecmascript-hmr-protocol/Cargo.toml @@ -14,8 +14,11 @@ workspace = true [dependencies] serde = { workspace = true } -serde_json = { workspace = true } turbo-rcstr = { workspace = true } turbopack-cli-utils = { workspace = true } turbopack-core = { workspace = true } + +[dev-dependencies] +serde_json = { workspace = true } +turbo-tasks = { workspace = true } diff --git a/turbopack/crates/turbopack-ecmascript-hmr-protocol/src/lib.rs b/turbopack/crates/turbopack-ecmascript-hmr-protocol/src/lib.rs index 95fc8213b4e1..4da42b8a0a81 100644 --- a/turbopack/crates/turbopack-ecmascript-hmr-protocol/src/lib.rs +++ b/turbopack/crates/turbopack-ecmascript-hmr-protocol/src/lib.rs @@ -1,12 +1,12 @@ use std::{collections::BTreeMap, fmt::Display, path::PathBuf}; use serde::{Deserialize, Serialize}; -use serde_json::Value; use turbo_rcstr::RcStr; use turbopack_cli_utils::issue::{LogOptions, format_issue}; use turbopack_core::{ issue::{IssueSeverity, IssueStage, PlainIssue, StyledString}, source_pos::SourcePos, + update_instruction::UpdateInstruction, }; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] @@ -78,7 +78,7 @@ impl<'a> ClientUpdateInstruction<'a> { pub fn partial( resource: &'a ResourceIdentifier, - instruction: &'a Value, + instruction: &'a UpdateInstruction, issues: &'a [Issue<'a>], ) -> Self { Self::new( @@ -106,7 +106,7 @@ impl<'a> ClientUpdateInstruction<'a> { pub enum ClientUpdateInstructionType<'a> { Restart, NotFound, - Partial { instruction: &'a Value }, + Partial { instruction: &'a UpdateInstruction }, Issues, } @@ -187,3 +187,50 @@ impl<'a> From<&'a PlainIssue> for Issue<'a> { } } } + +#[cfg(test)] +mod tests { + use serde::Serialize; + use serde_json::json; + use turbo_rcstr::rcstr; + use turbo_tasks::{NonLocalValue, trace::TraceRawVcs}; + use turbopack_core::update_instruction::UpdateInstruction; + + use super::{ClientUpdateInstruction, ResourceIdentifier}; + + #[derive(Debug, PartialEq, Eq, Serialize, TraceRawVcs, NonLocalValue)] + struct TestInstruction(serde_json::Value); + + #[test] + fn partial_instruction_wire_format_is_unchanged() { + let resource = ResourceIdentifier { + path: rcstr!("server/app.js"), + headers: None, + }; + let instruction = UpdateInstruction::new(TestInstruction(json!({ + "type": "ecmascriptMerged", + "chunks": {}, + }))); + + assert_eq!( + serde_json::to_value(ClientUpdateInstruction::partial( + &resource, + &instruction, + &[], + )) + .unwrap(), + json!({ + "resource": { + "path": "server/app.js", + "headers": null, + }, + "type": "partial", + "instruction": { + "type": "ecmascriptMerged", + "chunks": {}, + }, + "issues": [], + }) + ); + } +} diff --git a/turbopack/crates/turbopack-ecmascript/src/chunk_list/merged_update.rs b/turbopack/crates/turbopack-ecmascript/src/chunk_list/merged_update.rs index 1901649fc091..3c4c88ea9762 100644 --- a/turbopack/crates/turbopack-ecmascript/src/chunk_list/merged_update.rs +++ b/turbopack/crates/turbopack-ecmascript/src/chunk_list/merged_update.rs @@ -12,34 +12,36 @@ use anyhow::Result; use serde::Serialize; -use turbo_tasks::{FxIndexMap, FxIndexSet, Vc}; +use turbo_frozenmap::{FrozenMap, FrozenSet}; +use turbo_rcstr::RcStr; +use turbo_tasks::{NonLocalValue, Vc, trace::TraceRawVcs}; use turbo_tasks_fs::rope::Rope; use turbopack_core::{chunk::ModuleId, code_builder::Code, source_map::GenerateSourceMap}; /// A merged update covering one or more ecmascript chunks that share a merger. -#[derive(Serialize, Default)] +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, TraceRawVcs, NonLocalValue)] #[serde( tag = "type", rename = "EcmascriptMergedUpdate", rename_all = "camelCase" )] -pub struct EcmascriptMergedUpdate<'a> { +pub struct EcmascriptMergedUpdate { /// A map from module id to its latest module entry (code + source map url). - #[serde(skip_serializing_if = "FxIndexMap::is_empty")] - pub entries: FxIndexMap, + #[serde(skip_serializing_if = "FrozenMap::is_empty")] + pub entries: FrozenMap, /// A map from chunk path to the update for that chunk. - #[serde(skip_serializing_if = "FxIndexMap::is_empty")] - pub chunks: FxIndexMap<&'a str, EcmascriptMergedChunkUpdate>, + #[serde(skip_serializing_if = "FrozenMap::is_empty")] + pub chunks: FrozenMap, } -impl EcmascriptMergedUpdate<'_> { +impl EcmascriptMergedUpdate { pub fn is_empty(&self) -> bool { self.entries.is_empty() && self.chunks.is_empty() } } /// Per-chunk portion of an [`EcmascriptMergedUpdate`]. -#[derive(Serialize)] +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, TraceRawVcs, NonLocalValue)] #[serde(tag = "type", rename_all = "camelCase")] pub enum EcmascriptMergedChunkUpdate { Added(EcmascriptMergedChunkAdded), @@ -48,41 +50,41 @@ pub enum EcmascriptMergedChunkUpdate { } /// A chunk that was newly added in this version. -#[derive(Serialize, Default)] +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, TraceRawVcs, NonLocalValue)] #[serde(rename_all = "camelCase")] pub struct EcmascriptMergedChunkAdded { - #[serde(skip_serializing_if = "FxIndexSet::is_empty")] - pub modules: FxIndexSet, + #[serde(skip_serializing_if = "FrozenSet::is_empty")] + pub modules: FrozenSet, } /// A chunk that was removed in this version. -#[derive(Serialize, Default)] +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, TraceRawVcs, NonLocalValue)] #[serde(rename_all = "camelCase")] pub struct EcmascriptMergedChunkDeleted { // Technically, this is redundant, since the client will already know all // modules in the chunk from the previous version. However, it's useful for // merging updates without access to an initial state. - #[serde(skip_serializing_if = "FxIndexSet::is_empty")] - pub modules: FxIndexSet, + #[serde(skip_serializing_if = "FrozenSet::is_empty")] + pub modules: FrozenSet, } /// A chunk that was present in both versions and whose module membership /// changed. -#[derive(Serialize, Default)] +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, TraceRawVcs, NonLocalValue)] #[serde(rename_all = "camelCase")] pub struct EcmascriptMergedChunkPartial { - #[serde(skip_serializing_if = "FxIndexSet::is_empty")] - pub added: FxIndexSet, - #[serde(skip_serializing_if = "FxIndexSet::is_empty")] - pub deleted: FxIndexSet, + #[serde(skip_serializing_if = "FrozenSet::is_empty")] + pub added: FrozenSet, + #[serde(skip_serializing_if = "FrozenSet::is_empty")] + pub deleted: FrozenSet, } /// The code (and source map) for a single module in a merged update. -#[derive(Serialize)] +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, TraceRawVcs, NonLocalValue)] pub struct EcmascriptModuleEntry { #[serde(with = "turbo_tasks_fs::rope::ser_as_string")] pub code: Rope, - pub url: String, + pub url: RcStr, #[serde(with = "turbo_tasks_fs::rope::ser_option_as_string")] pub map: Option, } @@ -102,7 +104,7 @@ impl EcmascriptModuleEntry { Ok(EcmascriptModuleEntry { // Cloning a rope is cheap. code: code.await?.source_code().clone(), - url: format!("{}?{}", chunk_path, id), + url: format!("{}?{}", chunk_path, id).into(), map, }) } diff --git a/turbopack/crates/turbopack-ecmascript/src/chunk_list/update.rs b/turbopack/crates/turbopack-ecmascript/src/chunk_list/update.rs index 7233d294976b..663b795110b9 100644 --- a/turbopack/crates/turbopack-ecmascript/src/chunk_list/update.rs +++ b/turbopack/crates/turbopack-ecmascript/src/chunk_list/update.rs @@ -1,51 +1,57 @@ -use std::sync::Arc; - use anyhow::Result; use serde::Serialize; -use turbo_tasks::{FxIndexMap, ResolvedVc, TraitRef, Vc}; -use turbopack_core::version::{ - MergeableVersionedContent, PartialUpdate, TotalUpdate, Update, Version, VersionedContent, - VersionedContentMerger, +use turbo_rcstr::RcStr; +use turbo_tasks::{FxIndexMap, NonLocalValue, ResolvedVc, TraitRef, Vc, trace::TraceRawVcs}; +use turbopack_core::{ + update_instruction::UpdateInstruction, + version::{ + MergeableVersionedContent, PartialUpdate, TotalUpdate, Update, Version, VersionedContent, + VersionedContentMerger, + }, }; -use super::version::ChunkListVersion; +use super::{merged_update::EcmascriptMergedUpdate, version::ChunkListVersion}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TraceRawVcs, NonLocalValue)] +#[serde(untagged)] +pub enum EcmascriptUpdateInstruction { + ChunkList(ChunkListUpdate), + Merged(EcmascriptMergedUpdate), +} /// Update of a chunk list from one version to another. -#[derive(Serialize)] -#[serde(tag = "type")] -#[serde(rename_all = "camelCase")] -struct ChunkListUpdate<'a> { +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TraceRawVcs, NonLocalValue)] +#[serde(tag = "type", rename = "ChunkListUpdate", rename_all = "camelCase")] +pub struct ChunkListUpdate { /// A map from chunk path to a corresponding update of that chunk. #[serde(skip_serializing_if = "FxIndexMap::is_empty")] - chunks: FxIndexMap<&'a str, ChunkUpdate>, + pub chunks: FxIndexMap, /// List of merged updates since the last version. #[serde(skip_serializing_if = "Vec::is_empty")] - merged: Vec>, + pub merged: Vec, +} + +impl ChunkListUpdate { + pub fn into_instruction(self) -> UpdateInstruction { + UpdateInstruction::new(EcmascriptUpdateInstruction::ChunkList(self)) + } } /// Update of a chunk from one version to another. -#[derive(Serialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TraceRawVcs, NonLocalValue)] #[serde(tag = "type")] #[serde(rename_all = "camelCase")] -enum ChunkUpdate { +pub enum ChunkUpdate { /// The chunk was updated and must be reloaded. Total, /// The chunk was updated and can be merged with the previous version. - Partial { instruction: Arc }, + Partial { instruction: EcmascriptMergedUpdate }, /// The chunk was added. Added, /// The chunk was deleted. Deleted, } -impl ChunkListUpdate<'_> { - /// Returns `true` if this update is empty. - fn is_empty(&self) -> bool { - let ChunkListUpdate { chunks, merged } = self; - chunks.is_empty() && merged.is_empty() - } -} - /// Computes the update of a chunk list from one version to another. /// /// Runtime-agnostic (takes plain paths + [`VersionedContent`]) so the browser @@ -105,25 +111,26 @@ pub async fn update_chunk_list( match &*chunk_update { Update::Total(_) => { - chunks.insert(chunk_path.as_ref(), ChunkUpdate::Total); + chunks.insert(chunk_path.clone().into(), ChunkUpdate::Total); } Update::Partial(partial) => { + let instruction = expect_merged_instruction_from_partial(partial); chunks.insert( - chunk_path.as_ref(), + chunk_path.clone().into(), ChunkUpdate::Partial { - instruction: partial.instruction.clone(), + instruction: instruction.clone(), }, ); } Update::Missing | Update::None => {} } } else { - chunks.insert(chunk_path.as_ref(), ChunkUpdate::Deleted); + chunks.insert(chunk_path.clone().into(), ChunkUpdate::Deleted); } } for chunk_path in by_path.keys() { - chunks.insert(chunk_path.as_ref(), ChunkUpdate::Added); + chunks.insert((*chunk_path).clone().into(), ChunkUpdate::Added); } let mut merged = vec![]; @@ -147,24 +154,73 @@ pub async fn update_chunk_list( .cell()); } Update::Partial(partial) => { - merged.push(partial.instruction.clone()); + let instruction = expect_merged_instruction_from_partial(partial); + merged.push(instruction.clone()); } Update::Missing | Update::None => {} } } } - let update = ChunkListUpdate { chunks, merged }; - - let update = if update.is_empty() { + let update = if chunks.is_empty() && merged.is_empty() { Update::None } else { Update::Partial(PartialUpdate { to: Vc::upcast::>(to_version) .into_trait_ref() .await?, - instruction: Arc::new(serde_json::to_value(&update)?), + instruction: ChunkListUpdate { chunks, merged }.into_instruction(), }) }; Ok(update.cell()) } + +fn expect_merged_instruction_from_partial(partial: &PartialUpdate) -> &EcmascriptMergedUpdate { + let Some(EcmascriptUpdateInstruction::Merged(instruction)) = partial + .instruction + .downcast_ref::( + ) else { + panic!("ECMAScript partial updates must contain a merged instruction"); + }; + instruction +} + +#[cfg(test)] +mod tests { + use serde_json::json; + use turbo_frozenmap::{FrozenMap, FrozenSet}; + use turbo_tasks::FxIndexMap; + + use super::{ChunkListUpdate, ChunkUpdate, EcmascriptUpdateInstruction}; + use crate::chunk_list::merged_update::{ + EcmascriptMergedChunkAdded, EcmascriptMergedChunkUpdate, EcmascriptMergedUpdate, + }; + + #[test] + fn instruction_wire_format() { + let instruction = EcmascriptUpdateInstruction::ChunkList(ChunkListUpdate { + chunks: FxIndexMap::from_iter([("app.js".into(), ChunkUpdate::Total)]), + merged: vec![EcmascriptMergedUpdate { + entries: FrozenMap::default(), + chunks: FrozenMap::from_iter([( + "app.js".into(), + EcmascriptMergedChunkUpdate::Added(EcmascriptMergedChunkAdded { + modules: FrozenSet::default(), + }), + )]), + }], + }); + + assert_eq!( + serde_json::to_value(instruction).unwrap(), + json!({ + "type": "ChunkListUpdate", + "chunks": { "app.js": { "type": "total" } }, + "merged": [{ + "type": "EcmascriptMergedUpdate", + "chunks": { "app.js": { "type": "added" } }, + }], + }) + ); + } +} diff --git a/turbopack/crates/turbopack-ecmascript/src/hmr/update.rs b/turbopack/crates/turbopack-ecmascript/src/hmr/update.rs index dc0eda68e24e..5af6997c42e6 100644 --- a/turbopack/crates/turbopack-ecmascript/src/hmr/update.rs +++ b/turbopack/crates/turbopack-ecmascript/src/hmr/update.rs @@ -1,18 +1,21 @@ -use std::sync::Arc; - use anyhow::Result; -use turbo_tasks::{FxIndexMap, ReadRef, ResolvedVc, TryJoinIterExt, Vc}; +use turbo_frozenmap::{FrozenMap, FrozenSet}; +use turbo_tasks::{FxIndexMap, FxIndexSet, ReadRef, ResolvedVc, TryJoinIterExt, Vc}; use turbopack_core::{ chunk::ModuleId, code_builder::Code, + update_instruction::UpdateInstruction, version::{PartialUpdate, TotalUpdate, Update, Version}, }; use crate::{ chunk::EcmascriptChunkContentEntries, - chunk_list::merged_update::{ - EcmascriptMergedChunkAdded, EcmascriptMergedChunkDeleted, EcmascriptMergedChunkPartial, - EcmascriptMergedChunkUpdate, EcmascriptMergedUpdate, EcmascriptModuleEntry, + chunk_list::{ + merged_update::{ + EcmascriptMergedChunkAdded, EcmascriptMergedChunkDeleted, EcmascriptMergedChunkPartial, + EcmascriptMergedChunkUpdate, EcmascriptMergedUpdate, EcmascriptModuleEntry, + }, + update::EcmascriptUpdateInstruction, }, hmr::{ EcmascriptHmrChunkContent, @@ -145,21 +148,24 @@ async fn partial_chunk_update( unreachable!("caller filters out EcmascriptChunkUpdate::None"); }; - let mut partial = EcmascriptMergedChunkPartial::default(); + let mut added_modules = FxIndexSet::default(); for (id, AddedModule { hash, code }) in added { - partial.added.insert(id.clone()); + added_modules.insert(id.clone()); insert_entry_unless_shipped(entries, from_versions, id, hash, *code, chunk_path).await?; } - partial.deleted.extend(deleted.into_keys()); - for (id, code) in modified { let entry = EcmascriptModuleEntry::from_code(&id, *code, chunk_path).await?; entries.insert(id, entry); } - Ok(EcmascriptMergedChunkUpdate::Partial(partial)) + Ok(EcmascriptMergedChunkUpdate::Partial( + EcmascriptMergedChunkPartial { + added: FrozenSet::from(added_modules), + deleted: deleted.into_keys().collect(), + }, + )) } /// Builds the payload for a chunk that wasn't present in the previous version. @@ -169,10 +175,10 @@ async fn added_chunk_update( from_versions: &[ReadRef], entries: &mut FxIndexMap, ) -> Result { - let mut added = EcmascriptMergedChunkAdded::default(); + let mut modules = FxIndexSet::default(); for (id, entry) in chunk_entries.iter() { - added.modules.insert(id.clone()); + modules.insert(id.clone()); insert_entry_unless_shipped( entries, from_versions, @@ -184,7 +190,11 @@ async fn added_chunk_update( .await?; } - Ok(EcmascriptMergedChunkUpdate::Added(added)) + Ok(EcmascriptMergedChunkUpdate::Added( + EcmascriptMergedChunkAdded { + modules: FrozenSet::from(modules), + }, + )) } /// Computes a single [`Update`] covering every chunk in a merged chunk content. @@ -235,7 +245,8 @@ pub async fn update_ecmascript_merged_chunk( .try_join() .await?; - let mut merged_update = EcmascriptMergedUpdate::default(); + let mut merged_entries = FxIndexMap::default(); + let mut chunks = FxIndexMap::default(); for (content, entries, to_version) in &to_contents { let chunk_path = to_version.chunk_path.as_str(); @@ -247,40 +258,34 @@ pub async fn update_ecmascript_merged_chunk( { EcmascriptChunkUpdate::None => continue, update => { - partial_chunk_update( - update, - chunk_path, - from_versions, - &mut merged_update.entries, - ) - .await? + partial_chunk_update(update, chunk_path, from_versions, &mut merged_entries) + .await? } } } None => { - added_chunk_update( - entries, - chunk_path, - from_versions, - &mut merged_update.entries, - ) - .await? + added_chunk_update(entries, chunk_path, from_versions, &mut merged_entries).await? } }; - merged_update.chunks.insert(chunk_path, chunk_update); + chunks.insert(to_version.chunk_path.clone(), chunk_update); } - for (chunk_path, chunk_version) in from_versions_by_chunk_path { + for chunk_version in from_versions_by_chunk_path.into_values() { let hashes = &chunk_version.entries_hashes; - merged_update.chunks.insert( - chunk_path, + chunks.insert( + chunk_version.chunk_path.clone(), EcmascriptMergedChunkUpdate::Deleted(EcmascriptMergedChunkDeleted { modules: hashes.keys().cloned().collect(), }), ); } + let merged_update = EcmascriptMergedUpdate { + entries: FrozenMap::from(merged_entries), + chunks: FrozenMap::from(chunks), + }; + Ok(if merged_update.is_empty() { Update::None } else { @@ -288,7 +293,7 @@ pub async fn update_ecmascript_merged_chunk( to: Vc::upcast::>(to_merged_version) .into_trait_ref() .await?, - instruction: Arc::new(serde_json::to_value(&merged_update)?), + instruction: UpdateInstruction::new(EcmascriptUpdateInstruction::Merged(merged_update)), }) }) } diff --git a/turbopack/crates/turbopack-nodejs/src/ecmascript/node/entry/chunk_list_content.rs b/turbopack/crates/turbopack-nodejs/src/ecmascript/node/entry/chunk_list_content.rs index 60148fe1c59c..ccdabf4177be 100644 --- a/turbopack/crates/turbopack-nodejs/src/ecmascript/node/entry/chunk_list_content.rs +++ b/turbopack/crates/turbopack-nodejs/src/ecmascript/node/entry/chunk_list_content.rs @@ -100,7 +100,7 @@ impl EcmascriptBuildNodeChunkListContent { /// Builds a chunk list content directly from a fixed set of `chunks`, /// without expanding async-loader references. Used by - /// [`super::chunk_list::EcmascriptBuildNodeChunkList`] to track chunks + /// `super::chunk_list::EcmascriptBuildNodeChunkList` to track chunks /// (e.g. client-component SSR chunks) that are already fully enumerated by /// the caller. #[turbo_tasks::function]