From d54122a71227db0c7088e13da7c94613899cbb3e Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Sun, 17 Nov 2024 17:03:16 +0100 Subject: [PATCH] Emit build error for unknown cache kinds (#72832) When a `"use cache"` directive with a custom cache kind is used, e.g. `"use cache: custom"`, a cache handler with the same name must be specified in the Next.js config: ```js /** * @type {import('next').NextConfig} */ const nextConfig = { experimental: { dynamicIO: true, cacheHandlers: { custom: require.resolve('path/to/custom/cache/handler'), }, }, } module.exports = nextConfig ``` If this is not the case, we emit a build error with an error message that explains this requirement. Screenshot 2024-11-14 at 23 30 01 When we'll get a docs page for this experimental config, we will add the usual "Read more: ..." hint as well. --------- Co-authored-by: Benjamin Woodruff Co-authored-by: Janka Uryga --- Cargo.lock | 1 + .../next-core/src/next_client/transforms.rs | 9 +- crates/next-core/src/next_config.rs | 29 ++- .../next-core/src/next_server/transforms.rs | 11 +- .../next_react_server_components.rs | 6 +- .../next_shared/transforms/server_actions.rs | 5 + crates/next-custom-transforms/Cargo.toml | 1 + .../src/transforms/server_actions.rs | 169 ++++++++++++------ crates/next-custom-transforms/tests/errors.rs | 6 +- .../server-actions/server-graph/16/input.js | 5 + .../server-actions/server-graph/16/output.js | 11 ++ .../server-graph/16/output.stderr | 6 + .../server-actions/server-graph/17/input.js | 5 + .../server-actions/server-graph/17/output.js | 11 ++ .../server-graph/17/output.stderr | 7 + .../next-custom-transforms/tests/fixture.rs | 5 + packages/next/src/build/swc/options.ts | 6 + .../build/webpack/loaders/next-swc-loader.ts | 1 + .../app/layout.tsx | 8 + .../use-cache-unknown-cache-kind/app/page.tsx | 5 + .../next.config.js | 10 ++ .../use-cache-unknown-cache-kind.test.ts | 155 ++++++++++++++++ 22 files changed, 390 insertions(+), 82 deletions(-) create mode 100644 crates/next-custom-transforms/tests/errors/server-actions/server-graph/16/input.js create mode 100644 crates/next-custom-transforms/tests/errors/server-actions/server-graph/16/output.js create mode 100644 crates/next-custom-transforms/tests/errors/server-actions/server-graph/16/output.stderr create mode 100644 crates/next-custom-transforms/tests/errors/server-actions/server-graph/17/input.js create mode 100644 crates/next-custom-transforms/tests/errors/server-actions/server-graph/17/output.js create mode 100644 crates/next-custom-transforms/tests/errors/server-actions/server-graph/17/output.stderr create mode 100644 test/e2e/app-dir/use-cache-unknown-cache-kind/app/layout.tsx create mode 100644 test/e2e/app-dir/use-cache-unknown-cache-kind/app/page.tsx create mode 100644 test/e2e/app-dir/use-cache-unknown-cache-kind/next.config.js create mode 100644 test/e2e/app-dir/use-cache-unknown-cache-kind/use-cache-unknown-cache-kind.test.ts diff --git a/Cargo.lock b/Cargo.lock index 5484193aed37e..073d6a1fbf966 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4086,6 +4086,7 @@ dependencies = [ "swc_relay", "testing", "tracing", + "turbo-rcstr", "turbopack-ecmascript-plugins", "walkdir", ] diff --git a/crates/next-core/src/next_client/transforms.rs b/crates/next-core/src/next_client/transforms.rs index 67dec64380538..f21dccffe193e 100644 --- a/crates/next-core/src/next_client/transforms.rs +++ b/crates/next-core/src/next_client/transforms.rs @@ -47,12 +47,8 @@ pub async fn get_next_client_transforms_rules( rules.push(get_debug_fn_name_rule(enable_mdx_rs)); } - let dynamic_io_enabled = next_config - .experimental() - .await? - .dynamic_io - .unwrap_or(false); - + let dynamic_io_enabled = *next_config.enable_dynamic_io().await?; + let cache_kinds = next_config.cache_kinds().to_resolved().await?; let mut is_app_dir = false; match context_ty { @@ -79,6 +75,7 @@ pub async fn get_next_client_transforms_rules( ActionsTransform::Client, enable_mdx_rs, dynamic_io_enabled, + cache_kinds, )); } ClientContextType::Fallback | ClientContextType::Other => {} diff --git a/crates/next-core/src/next_config.rs b/crates/next-core/src/next_config.rs index e5b20e16c45b7..8a133d939ecd2 100644 --- a/crates/next-core/src/next_config.rs +++ b/crates/next-core/src/next_config.rs @@ -1,6 +1,7 @@ use std::collections::HashSet; use anyhow::{bail, Context, Result}; +use rustc_hash::FxHashSet; use serde::{Deserialize, Deserializer, Serialize}; use serde_json::Value as JsonValue; use turbo_rcstr::RcStr; @@ -40,6 +41,9 @@ struct CustomRoutes { #[turbo_tasks::value(transparent)] pub struct ModularizeImports(FxIndexMap); +#[turbo_tasks::value(transparent)] +pub struct CacheKinds(FxHashSet); + #[turbo_tasks::value(serialization = "custom", eq = "manual")] #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -520,7 +524,7 @@ pub struct ExperimentalConfig { pub sri: Option, react_compiler: Option, #[serde(rename = "dynamicIO")] - pub dynamic_io: Option, + dynamic_io: Option, // --- // UNSUPPORTED // --- @@ -529,6 +533,7 @@ pub struct ExperimentalConfig { after: Option, amp: Option, app_document_preloading: Option, + cache_handlers: Option>, cache_life: Option>, case_sensitive_routes: Option, cpus: Option, @@ -1150,10 +1155,24 @@ impl NextConfig { } #[turbo_tasks::function] - pub async fn enable_react_owner_stack(self: Vc) -> Result> { - Ok(Vc::cell( - self.await?.experimental.react_owner_stack.unwrap_or(false), - )) + pub fn enable_react_owner_stack(&self) -> Vc { + Vc::cell(self.experimental.react_owner_stack.unwrap_or(false)) + } + + #[turbo_tasks::function] + pub fn enable_dynamic_io(&self) -> Vc { + Vc::cell(self.experimental.dynamic_io.unwrap_or(false)) + } + + #[turbo_tasks::function] + pub fn cache_kinds(&self) -> Vc { + Vc::cell( + self.experimental + .cache_handlers + .as_ref() + .map(|handlers| handlers.keys().cloned().collect()) + .unwrap_or_default(), + ) } #[turbo_tasks::function] diff --git a/crates/next-core/src/next_server/transforms.rs b/crates/next-core/src/next_server/transforms.rs index b37be21a37f83..d3321d87213ae 100644 --- a/crates/next-core/src/next_server/transforms.rs +++ b/crates/next-core/src/next_server/transforms.rs @@ -54,12 +54,8 @@ pub async fn get_next_server_transforms_rules( )); } - let dynamic_io_enabled = next_config - .experimental() - .await? - .dynamic_io - .unwrap_or(false); - + let dynamic_io_enabled = *next_config.enable_dynamic_io().await?; + let cache_kinds = next_config.cache_kinds().to_resolved().await?; let mut is_app_dir = false; let is_server_components = match context_ty { @@ -96,6 +92,7 @@ pub async fn get_next_server_transforms_rules( ActionsTransform::Client, mdx_rs, dynamic_io_enabled, + cache_kinds, )); is_app_dir = true; @@ -107,6 +104,7 @@ pub async fn get_next_server_transforms_rules( ActionsTransform::Server, mdx_rs, dynamic_io_enabled, + cache_kinds, )); is_app_dir = true; @@ -118,6 +116,7 @@ pub async fn get_next_server_transforms_rules( ActionsTransform::Server, mdx_rs, dynamic_io_enabled, + cache_kinds, )); is_app_dir = true; diff --git a/crates/next-core/src/next_shared/transforms/next_react_server_components.rs b/crates/next-core/src/next_shared/transforms/next_react_server_components.rs index aa93f1845cc48..da5cff9e26286 100644 --- a/crates/next-core/src/next_shared/transforms/next_react_server_components.rs +++ b/crates/next-core/src/next_shared/transforms/next_react_server_components.rs @@ -34,11 +34,7 @@ pub async fn get_next_react_server_components_transform_rule( app_dir: Option>, ) -> Result { let enable_mdx_rs = next_config.mdx_rs().await?.is_some(); - let dynamic_io_enabled = next_config - .experimental() - .await? - .dynamic_io - .unwrap_or(false); + let dynamic_io_enabled = *next_config.enable_dynamic_io().await?; Ok(get_ecma_transform_rule( Box::new(NextJsReactServerComponents::new( is_react_server_layer, diff --git a/crates/next-core/src/next_shared/transforms/server_actions.rs b/crates/next-core/src/next_shared/transforms/server_actions.rs index ff120b7a5b624..8c79704ef5d08 100644 --- a/crates/next-core/src/next_shared/transforms/server_actions.rs +++ b/crates/next-core/src/next_shared/transforms/server_actions.rs @@ -7,6 +7,7 @@ use turbopack::module_options::{ModuleRule, ModuleRuleEffect}; use turbopack_ecmascript::{CustomTransformer, EcmascriptInputTransform, TransformContext}; use super::module_rule_match_js_no_url; +use crate::next_config::CacheKinds; #[derive(Debug)] pub enum ActionsTransform { @@ -19,11 +20,13 @@ pub fn get_server_actions_transform_rule( transform: ActionsTransform, enable_mdx_rs: bool, dynamic_io_enabled: bool, + cache_kinds: ResolvedVc, ) -> ModuleRule { let transformer = EcmascriptInputTransform::Plugin(ResolvedVc::cell(Box::new(NextServerActions { transform, dynamic_io_enabled, + cache_kinds, }) as _)); ModuleRule::new( module_rule_match_js_no_url(enable_mdx_rs), @@ -38,6 +41,7 @@ pub fn get_server_actions_transform_rule( struct NextServerActions { transform: ActionsTransform, dynamic_io_enabled: bool, + cache_kinds: ResolvedVc, } #[async_trait] @@ -50,6 +54,7 @@ impl CustomTransformer for NextServerActions { is_react_server_layer: matches!(self.transform, ActionsTransform::Server), dynamic_io_enabled: self.dynamic_io_enabled, hash_salt: "".into(), + cache_kinds: self.cache_kinds.await?.clone_value(), }, ctx.comments.clone(), ); diff --git a/crates/next-custom-transforms/Cargo.toml b/crates/next-custom-transforms/Cargo.toml index 9b0c03304ad5b..7d0f5e82b4fea 100644 --- a/crates/next-custom-transforms/Cargo.toml +++ b/crates/next-custom-transforms/Cargo.toml @@ -56,6 +56,7 @@ styled_jsx = { workspace = true } swc_emotion = { workspace = true } swc_relay = { workspace = true } turbopack-ecmascript-plugins = { workspace = true, optional = true } +turbo-rcstr = { workspace = true } react_remove_properties = "0.24.25" remove_console = "0.25.25" diff --git a/crates/next-custom-transforms/src/transforms/server_actions.rs b/crates/next-custom-transforms/src/transforms/server_actions.rs index 8ac7be9becdee..f8949d3db7c20 100644 --- a/crates/next-custom-transforms/src/transforms/server_actions.rs +++ b/crates/next-custom-transforms/src/transforms/server_actions.rs @@ -6,6 +6,7 @@ use std::{ use hex::encode as hex_encode; use indoc::formatdoc; +use rustc_hash::FxHashSet; use serde::Deserialize; use sha1::{Digest, Sha1}; use swc_core::{ @@ -22,6 +23,7 @@ use swc_core::{ visit::{noop_visit_mut_type, visit_mut_pass, VisitMut, VisitMutWith}, }, }; +use turbo_rcstr::RcStr; #[derive(Clone, Debug, Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] @@ -29,6 +31,7 @@ pub struct Config { pub is_react_server_layer: bool, pub dynamic_io_enabled: bool, pub hash_salt: String, + pub cache_kinds: FxHashSet, } enum DirectiveLocation { @@ -65,6 +68,10 @@ enum ServerActionsErrorKind { directive: String, expected_directive: String, }, + UnknownCacheKind { + span: Span, + cache_kind: RcStr, + }, UseCacheWithoutDynamicIO { span: Span, directive: String, @@ -79,6 +86,12 @@ enum ServerActionsErrorKind { // Using BTreeMap to ensure the order of the actions is deterministic. pub type ActionsMap = BTreeMap; +// Directive-level information about a function body +struct BodyInfo { + is_action_fn: bool, + cache_kind: Option, +} + #[tracing::instrument(level = tracing::Level::TRACE, skip_all)] pub fn server_actions(file_name: &FileName, config: Config, comments: C) -> impl Pass { visit_mut_pass(ServerActions { @@ -87,7 +100,7 @@ pub fn server_actions(file_name: &FileName, config: Config, comment file_name: file_name.to_string(), start_pos: BytePos(0), in_action_file: false, - in_cache_file: None, + file_cache_kind: None, in_exported_expr: false, in_default_export_decl: false, in_callee: false, @@ -137,7 +150,7 @@ struct ServerActions { start_pos: BytePos, in_action_file: bool, - in_cache_file: Option, + file_cache_kind: Option, in_exported_expr: bool, in_default_export_decl: bool, in_callee: bool, @@ -296,9 +309,9 @@ impl ServerActions { } // Check if the function or arrow function is an action or cache function - fn get_body_info(&mut self, maybe_body: Option<&mut BlockStmt>) -> (bool, Option) { + fn get_body_info(&mut self, maybe_body: Option<&mut BlockStmt>) -> BodyInfo { let mut is_action_fn = false; - let mut cache_type = None; + let mut cache_kind = None; // Even if it's a file-level action or cache module, the function body // might still have directives that override the module-level annotations. @@ -309,9 +322,9 @@ impl ServerActions { remove_server_directive_index_in_fn( &mut body.stmts, &mut is_action_fn, - &mut cache_type, + &mut cache_kind, &mut span, - self.config.dynamic_io_enabled, + &self.config, ); if !self.config.is_react_server_layer { @@ -321,7 +334,7 @@ impl ServerActions { }) } - if cache_type.is_some() && self.in_cache_file.is_none() && !self.in_action_file { + if cache_kind.is_some() && self.file_cache_kind.is_none() && !self.in_action_file { emit_error(ServerActionsErrorKind::InlineUseCacheInClientComponent { span: span.unwrap_or(body.span), }); @@ -330,17 +343,20 @@ impl ServerActions { } // Self-annotations take precedence over module-level annotations. - if self.in_exported_expr && !is_action_fn && cache_type.is_none() { + if self.in_exported_expr && !is_action_fn && cache_kind.is_none() { if self.in_action_file { // All export functions in a server file are actions is_action_fn = true; - } else if let Some(cache_file_type) = &self.in_cache_file { + } else if let Some(cache_file_type) = &self.file_cache_kind { // All export functions in a cache file are cache functions - cache_type = Some(cache_file_type.clone()); + cache_kind = Some(cache_file_type.clone()); } } - (is_action_fn, cache_type) + BodyInfo { + is_action_fn, + cache_kind, + } } fn maybe_hoist_and_create_proxy_for_server_action_arrow_expr( @@ -601,7 +617,7 @@ impl ServerActions { fn maybe_hoist_and_create_proxy_for_cache_arrow_expr( &mut self, ids_from_closure: Vec, - cache_type: &str, + cache_kind: &str, arrow: &mut ArrowExpr, ) -> Box { let mut new_params: Vec = vec![]; @@ -673,7 +689,7 @@ impl ServerActions { ..Default::default() }), })), - cache_type, + cache_kind, &reference_id, ids_from_closure.len(), )), @@ -736,7 +752,7 @@ impl ServerActions { &mut self, ids_from_closure: Vec, fn_name: Option, - cache_type: &str, + cache_kind: &str, function: &mut Box, ) -> Box { let mut new_params: Vec = vec![]; @@ -795,7 +811,7 @@ impl ServerActions { ..*function.take() }), })), - cache_type, + cache_kind, &reference_id, ids_from_closure.len(), )), @@ -881,7 +897,10 @@ impl VisitMut for ServerActions { } fn visit_mut_fn_expr(&mut self, f: &mut FnExpr) { - let (is_action_fn, cache_type) = self.get_body_info(f.function.body.as_mut()); + let BodyInfo { + is_action_fn, + cache_kind, + } = self.get_body_info(f.function.body.as_mut()); let declared_idents_until = self.declared_idents.len(); let current_names = take(&mut self.names); @@ -894,7 +913,7 @@ impl VisitMut for ServerActions { let old_in_default_export_decl = self.in_default_export_decl; self.in_module_level = false; self.should_track_names = - is_action_fn || cache_type.is_some() || self.should_track_names; + is_action_fn || cache_kind.is_some() || self.should_track_names; self.in_exported_expr = false; self.in_default_export_decl = false; f.visit_mut_children_with(self); @@ -913,7 +932,7 @@ impl VisitMut for ServerActions { take(&mut self.names) }; - if (is_action_fn || cache_type.is_some()) && !f.function.is_async { + if (is_action_fn || cache_kind.is_some()) && !f.function.is_async { emit_error(ServerActionsErrorKind::InlineSyncFunction { span: f.function.span, is_action_fn, @@ -922,11 +941,11 @@ impl VisitMut for ServerActions { return; } - if !is_action_fn && cache_type.is_none() || !self.config.is_react_server_layer { + if !is_action_fn && cache_kind.is_none() || !self.config.is_react_server_layer { return; } - if let Some(cache_type_str) = cache_type { + if let Some(cache_kind_str) = cache_kind { // Collect all the identifiers defined inside the closure and used // in the cache function. With deduplication. retain_names_from_declared_idents( @@ -937,7 +956,7 @@ impl VisitMut for ServerActions { let new_expr = self.maybe_hoist_and_create_proxy_for_cache_function( child_names.clone(), f.ident.clone().or(self.arrow_or_fn_expr_ident.clone()), - cache_type_str.as_str(), + cache_kind_str.as_str(), &mut f.function, ); @@ -996,7 +1015,10 @@ impl VisitMut for ServerActions { self.in_exported_expr = true } - let (is_action_fn, cache_type) = self.get_body_info(f.function.body.as_mut()); + let BodyInfo { + is_action_fn, + cache_kind, + } = self.get_body_info(f.function.body.as_mut()); let declared_idents_until = self.declared_idents.len(); let current_names = take(&mut self.names); @@ -1009,7 +1031,7 @@ impl VisitMut for ServerActions { let old_in_default_export_decl = self.in_default_export_decl; self.in_module_level = false; self.should_track_names = - is_action_fn || cache_type.is_some() || self.should_track_names; + is_action_fn || cache_kind.is_some() || self.should_track_names; self.in_exported_expr = false; self.in_default_export_decl = false; f.visit_mut_children_with(self); @@ -1019,7 +1041,7 @@ impl VisitMut for ServerActions { self.in_default_export_decl = old_in_default_export_decl; } - if !is_action_fn && cache_type.is_none() || !self.config.is_react_server_layer { + if !is_action_fn && cache_kind.is_none() || !self.config.is_react_server_layer { self.in_exported_expr = old_in_exported_expr; return; @@ -1034,7 +1056,7 @@ impl VisitMut for ServerActions { take(&mut self.names) }; - if let Some(cache_type_str) = cache_type { + if let Some(cache_kind_str) = cache_kind { if !f.function.is_async { emit_error(ServerActionsErrorKind::InlineSyncFunction { span: f.ident.span, @@ -1056,7 +1078,7 @@ impl VisitMut for ServerActions { let new_expr = self.maybe_hoist_and_create_proxy_for_cache_function( child_names, Some(f.ident.clone()), - cache_type_str.as_str(), + cache_kind_str.as_str(), &mut f.function, ); @@ -1136,12 +1158,14 @@ impl VisitMut for ServerActions { fn visit_mut_arrow_expr(&mut self, a: &mut ArrowExpr) { // Arrow expressions need to be visited in prepass to determine if it's // an action function or not. - let (is_action_fn, cache_type) = - self.get_body_info(if let BlockStmtOrExpr::BlockStmt(block) = &mut *a.body { - Some(block) - } else { - None - }); + let BodyInfo { + is_action_fn, + cache_kind, + } = self.get_body_info(if let BlockStmtOrExpr::BlockStmt(block) = &mut *a.body { + Some(block) + } else { + None + }); let declared_idents_until = self.declared_idents.len(); let current_names = take(&mut self.names); @@ -1154,7 +1178,7 @@ impl VisitMut for ServerActions { let old_in_default_export_decl = self.in_default_export_decl; self.in_module_level = false; self.should_track_names = - is_action_fn || cache_type.is_some() || self.should_track_names; + is_action_fn || cache_kind.is_some() || self.should_track_names; self.in_exported_expr = false; self.in_default_export_decl = false; { @@ -1178,7 +1202,7 @@ impl VisitMut for ServerActions { take(&mut self.names) }; - if !a.is_async && (is_action_fn || cache_type.is_some()) { + if !a.is_async && (is_action_fn || cache_kind.is_some()) { emit_error(ServerActionsErrorKind::InlineSyncFunction { span: a.span, is_action_fn, @@ -1187,7 +1211,7 @@ impl VisitMut for ServerActions { return; } - if !is_action_fn && cache_type.is_none() || !self.config.is_react_server_layer { + if !is_action_fn && cache_kind.is_none() || !self.config.is_react_server_layer { return; } @@ -1201,10 +1225,10 @@ impl VisitMut for ServerActions { let maybe_new_expr = if is_action_fn && !self.in_action_file { Some(self.maybe_hoist_and_create_proxy_for_server_action_arrow_expr(child_names, a)) } else { - cache_type.map(|cache_type_str| { + cache_kind.map(|cache_kind_str| { self.maybe_hoist_and_create_proxy_for_cache_arrow_expr( child_names, - cache_type_str.as_str(), + cache_kind_str.as_str(), a, ) }) @@ -1304,10 +1328,10 @@ impl VisitMut for ServerActions { remove_server_directive_index_in_module( stmts, &mut self.in_action_file, - &mut self.in_cache_file, + &mut self.file_cache_kind, &mut self.has_action, &mut self.has_cache, - self.config.dynamic_io_enabled, + &self.config, ); // If we're in a "use cache" file, collect all original IDs from export @@ -1320,7 +1344,7 @@ impl VisitMut for ServerActions { // export { foo } // export default Bar // ``` - if self.in_cache_file.is_some() { + if self.file_cache_kind.is_some() { for stmt in stmts.iter() { match stmt { ModuleItem::ModuleDecl(ModuleDecl::ExportDefaultExpr(export_default_expr)) => { @@ -1347,7 +1371,7 @@ impl VisitMut for ServerActions { } // Only track exported identifiers in action files or cache files. - let is_cache_file = self.in_cache_file.is_some(); + let is_cache_file = self.file_cache_kind.is_some(); let should_track_exports = self.in_action_file || is_cache_file; let old_annotations = self.annotations.take(); @@ -1682,7 +1706,7 @@ impl VisitMut for ServerActions { } if self.config.is_react_server_layer - || (!self.in_action_file && self.in_cache_file.is_none()) + || (!self.in_action_file && self.file_cache_kind.is_none()) { new.append(&mut self.hoisted_extra_items); new.push(new_stmt); @@ -1802,7 +1826,7 @@ impl VisitMut for ServerActions { })); new.push(export_expr); } - } else if self.in_cache_file.is_none() { + } else if self.file_cache_kind.is_none() { self.annotations.push(Stmt::Expr(ExprStmt { span: DUMMY_SP, expr: Box::new(annotate_ident_as_server_reference( @@ -1825,7 +1849,7 @@ impl VisitMut for ServerActions { new.append(&mut self.extra_items); // For "use cache" files, there's no need to do extra annotations. - if self.in_cache_file.is_none() && !self.exported_idents.is_empty() { + if self.file_cache_kind.is_none() && !self.exported_idents.is_empty() { let ensure_ident = private_ident!("ensureServerEntryExports"); new.push(ModuleItem::ModuleDecl(ModuleDecl::Import(ImportDecl { span: DUMMY_SP, @@ -1880,7 +1904,7 @@ impl VisitMut for ServerActions { // All exported values are considered as actions if the file is an action file. if self.in_action_file - || self.in_cache_file.is_some() && !self.config.is_react_server_layer + || self.file_cache_kind.is_some() && !self.config.is_react_server_layer { actions.extend( self.exported_idents @@ -2346,10 +2370,10 @@ fn detect_similar_strings(a: &str, b: &str) -> bool { fn remove_server_directive_index_in_module( stmts: &mut Vec, in_action_file: &mut bool, - in_cache_file: &mut Option, + file_cache_kind: &mut Option, has_action: &mut bool, has_cache: &mut bool, - dynamic_io_enabled: bool, + config: &Config, ) { let mut is_directive = true; @@ -2375,19 +2399,29 @@ fn remove_server_directive_index_in_module( // `use cache` or `use cache: foo` if value == "use cache" || value.starts_with("use cache: ") { if is_directive { - if !dynamic_io_enabled { + if !config.dynamic_io_enabled { emit_error(ServerActionsErrorKind::UseCacheWithoutDynamicIO { span: *span, directive: value.to_string(), }); } - *in_cache_file = Some(if value == "use cache" { - "default".into() + if value == "use cache" { + *file_cache_kind = Some("default".into()); } else { // Slice the value after "use cache: " - value.split_at("use cache: ".len()).1.into() - }); + let cache_kind_str = RcStr::from(value.split_at("use cache: ".len()).1); + + if !config.cache_kinds.contains(&cache_kind_str) { + emit_error(ServerActionsErrorKind::UnknownCacheKind { + span: *span, + cache_kind: cache_kind_str.clone(), + }); + } + + *file_cache_kind = Some(cache_kind_str) + } + *has_cache = true; return false; } else { @@ -2486,9 +2520,9 @@ fn has_body_directive(maybe_body: &Option) -> (bool, bool) { fn remove_server_directive_index_in_fn( stmts: &mut Vec, is_action_fn: &mut bool, - cache_type: &mut Option, + cache_kind: &mut Option, action_span: &mut Option, - dynamic_io_enabled: bool, + config: &Config, ) { let mut is_directive = true; @@ -2520,19 +2554,28 @@ fn remove_server_directive_index_in_fn( }); } else if value == "use cache" || value.starts_with("use cache: ") { if is_directive { - if !dynamic_io_enabled { + if !config.dynamic_io_enabled { emit_error(ServerActionsErrorKind::UseCacheWithoutDynamicIO { span: *span, directive: value.to_string(), }); } - *cache_type = Some(if value == "use cache" { - "default".into() + if value == "use cache" { + *cache_kind = Some("default".into()); } else { // Slice the value after "use cache: " - value.split_at("use cache: ".len()).1.into() - }); + let cache_kind_str = RcStr::from(value.split_at("use cache: ".len()).1); + + if !config.cache_kinds.contains(&cache_kind_str) { + emit_error(ServerActionsErrorKind::UnknownCacheKind { + span: *span, + cache_kind: cache_kind_str.clone(), + }); + } + + *cache_kind = Some(cache_kind_str); + }; return false; } else { emit_error(ServerActionsErrorKind::MisplacedDirective { @@ -2891,6 +2934,14 @@ fn emit_error(error_kind: ServerActionsErrorKind) { "# }, ), + ServerActionsErrorKind::UnknownCacheKind { span, cache_kind } => ( + span, + formatdoc! { + r#" + Unknown cache kind "{cache_kind}". Please configure a cache handler for this kind in the "experimental.cacheHandlers" object in your Next.js config. + "# + }, + ), ServerActionsErrorKind::UseCacheWithoutDynamicIO { span, directive } => ( span, formatdoc! { diff --git a/crates/next-custom-transforms/tests/errors.rs b/crates/next-custom-transforms/tests/errors.rs index eaf15623992ba..7bee850a1c62e 100644 --- a/crates/next-custom-transforms/tests/errors.rs +++ b/crates/next-custom-transforms/tests/errors.rs @@ -1,4 +1,4 @@ -use std::path::PathBuf; +use std::{iter::FromIterator, path::PathBuf}; use next_custom_transforms::transforms::{ disallow_re_export_all_in_page::disallow_re_export_all_in_page, @@ -11,6 +11,7 @@ use next_custom_transforms::transforms::{ }, strip_page_exports::{next_transform_strip_page_exports, ExportFilter}, }; +use rustc_hash::FxHashSet; use swc_core::{ common::{FileName, Mark}, ecma::{ @@ -187,6 +188,7 @@ fn react_server_actions_server_errors(input: PathBuf) { is_react_server_layer: true, dynamic_io_enabled: true, hash_salt: "".into(), + cache_kinds: FxHashSet::default(), }, tr.comments.as_ref().clone(), ), @@ -226,6 +228,7 @@ fn react_server_actions_client_errors(input: PathBuf) { is_react_server_layer: false, dynamic_io_enabled: true, hash_salt: "".into(), + cache_kinds: FxHashSet::default(), }, tr.comments.as_ref().clone(), ), @@ -283,6 +286,7 @@ fn use_cache_not_allowed(input: PathBuf) { is_react_server_layer: true, dynamic_io_enabled: false, hash_salt: "".into(), + cache_kinds: FxHashSet::from_iter(["x".into()]), }, tr.comments.as_ref().clone(), ), diff --git a/crates/next-custom-transforms/tests/errors/server-actions/server-graph/16/input.js b/crates/next-custom-transforms/tests/errors/server-actions/server-graph/16/input.js new file mode 100644 index 0000000000000..be374144ada15 --- /dev/null +++ b/crates/next-custom-transforms/tests/errors/server-actions/server-graph/16/input.js @@ -0,0 +1,5 @@ +'use cache: x' + +export async function foo() { + return 'data' +} diff --git a/crates/next-custom-transforms/tests/errors/server-actions/server-graph/16/output.js b/crates/next-custom-transforms/tests/errors/server-actions/server-graph/16/output.js new file mode 100644 index 0000000000000..fd8981f7b00a2 --- /dev/null +++ b/crates/next-custom-transforms/tests/errors/server-actions/server-graph/16/output.js @@ -0,0 +1,11 @@ +/* __next_internal_action_entry_do_not_use__ {"803128060c414d59f8552e4788b846c0d2b7f74743":"$$RSC_SERVER_CACHE_0"} */ import { registerServerReference } from "private-next-rsc-server-reference"; +import { encryptActionBoundArgs, decryptActionBoundArgs } from "private-next-rsc-action-encryption"; +import { cache as $$cache__ } from "private-next-rsc-cache-wrapper"; +export var $$RSC_SERVER_CACHE_0 = $$cache__("x", "803128060c414d59f8552e4788b846c0d2b7f74743", 0, async function foo() { + return 'data'; +}); +Object.defineProperty($$RSC_SERVER_CACHE_0, "name", { + "value": "foo", + "writable": false +}); +export var foo = registerServerReference($$RSC_SERVER_CACHE_0, "803128060c414d59f8552e4788b846c0d2b7f74743", null); diff --git a/crates/next-custom-transforms/tests/errors/server-actions/server-graph/16/output.stderr b/crates/next-custom-transforms/tests/errors/server-actions/server-graph/16/output.stderr new file mode 100644 index 0000000000000..5354e8d5920c6 --- /dev/null +++ b/crates/next-custom-transforms/tests/errors/server-actions/server-graph/16/output.stderr @@ -0,0 +1,6 @@ + x Unknown cache kind "x". Please configure a cache handler for this kind in the "experimental.cacheHandlers" object in your Next.js config. + | + ,-[input.js:1:1] + 1 | 'use cache: x' + : ^^^^^^^^^^^^^^ + `---- diff --git a/crates/next-custom-transforms/tests/errors/server-actions/server-graph/17/input.js b/crates/next-custom-transforms/tests/errors/server-actions/server-graph/17/input.js new file mode 100644 index 0000000000000..583e0663a9a77 --- /dev/null +++ b/crates/next-custom-transforms/tests/errors/server-actions/server-graph/17/input.js @@ -0,0 +1,5 @@ +export async function foo() { + 'use cache: x' + + return 'data' +} diff --git a/crates/next-custom-transforms/tests/errors/server-actions/server-graph/17/output.js b/crates/next-custom-transforms/tests/errors/server-actions/server-graph/17/output.js new file mode 100644 index 0000000000000..fd8981f7b00a2 --- /dev/null +++ b/crates/next-custom-transforms/tests/errors/server-actions/server-graph/17/output.js @@ -0,0 +1,11 @@ +/* __next_internal_action_entry_do_not_use__ {"803128060c414d59f8552e4788b846c0d2b7f74743":"$$RSC_SERVER_CACHE_0"} */ import { registerServerReference } from "private-next-rsc-server-reference"; +import { encryptActionBoundArgs, decryptActionBoundArgs } from "private-next-rsc-action-encryption"; +import { cache as $$cache__ } from "private-next-rsc-cache-wrapper"; +export var $$RSC_SERVER_CACHE_0 = $$cache__("x", "803128060c414d59f8552e4788b846c0d2b7f74743", 0, async function foo() { + return 'data'; +}); +Object.defineProperty($$RSC_SERVER_CACHE_0, "name", { + "value": "foo", + "writable": false +}); +export var foo = registerServerReference($$RSC_SERVER_CACHE_0, "803128060c414d59f8552e4788b846c0d2b7f74743", null); diff --git a/crates/next-custom-transforms/tests/errors/server-actions/server-graph/17/output.stderr b/crates/next-custom-transforms/tests/errors/server-actions/server-graph/17/output.stderr new file mode 100644 index 0000000000000..99bae2533eecc --- /dev/null +++ b/crates/next-custom-transforms/tests/errors/server-actions/server-graph/17/output.stderr @@ -0,0 +1,7 @@ + x Unknown cache kind "x". Please configure a cache handler for this kind in the "experimental.cacheHandlers" object in your Next.js config. + | + ,-[input.js:2:1] + 1 | export async function foo() { + 2 | 'use cache: x' + : ^^^^^^^^^^^^^^ + `---- diff --git a/crates/next-custom-transforms/tests/fixture.rs b/crates/next-custom-transforms/tests/fixture.rs index 4275d58e3825f..27e0203fdb7a2 100644 --- a/crates/next-custom-transforms/tests/fixture.rs +++ b/crates/next-custom-transforms/tests/fixture.rs @@ -1,5 +1,6 @@ use std::{ env::current_dir, + iter::FromIterator, path::{Path, PathBuf}, }; @@ -21,6 +22,7 @@ use next_custom_transforms::transforms::{ strip_page_exports::{next_transform_strip_page_exports, ExportFilter}, warn_for_edge_runtime::warn_for_edge_runtime, }; +use rustc_hash::FxHashSet; use serde::de::DeserializeOwned; use swc_core::{ common::{comments::SingleThreadedComments, FileName, Mark, SyntaxContext}, @@ -416,6 +418,7 @@ fn server_actions_server_fixture(input: PathBuf) { is_react_server_layer: true, dynamic_io_enabled: true, hash_salt: "".into(), + cache_kinds: FxHashSet::from_iter(["x".into()]), }, _tr.comments.as_ref().clone(), ), @@ -448,6 +451,7 @@ fn next_font_with_directive_fixture(input: PathBuf) { is_react_server_layer: true, dynamic_io_enabled: true, hash_salt: "".into(), + cache_kinds: FxHashSet::default(), }, _tr.comments.as_ref().clone(), ), @@ -473,6 +477,7 @@ fn server_actions_client_fixture(input: PathBuf) { is_react_server_layer: false, dynamic_io_enabled: true, hash_salt: "".into(), + cache_kinds: FxHashSet::default(), }, _tr.comments.as_ref().clone(), ), diff --git a/packages/next/src/build/swc/options.ts b/packages/next/src/build/swc/options.ts index 4b5d3717657fa..1e90db96857dd 100644 --- a/packages/next/src/build/swc/options.ts +++ b/packages/next/src/build/swc/options.ts @@ -65,6 +65,7 @@ function getBaseSWCOptions({ serverReferenceHashSalt, bundleLayer, isDynamicIo, + cacheHandlers, }: { filename: string jest?: boolean @@ -82,6 +83,7 @@ function getBaseSWCOptions({ serverReferenceHashSalt: string bundleLayer?: WebpackLayerName isDynamicIo?: boolean + cacheHandlers?: ExperimentalConfig['cacheHandlers'] }) { const isReactServerLayer = isWebpackServerOnlyLayer(bundleLayer) const isAppRouterPagesLayer = isWebpackAppPagesLayer(bundleLayer) @@ -211,6 +213,7 @@ function getBaseSWCOptions({ isReactServerLayer, dynamicIoEnabled: isDynamicIo, hashSalt: serverReferenceHashSalt, + cacheKinds: cacheHandlers ? Object.keys(cacheHandlers) : [], } : undefined, // For app router we prefer to bundle ESM, @@ -355,6 +358,7 @@ export function getLoaderSWCOptions({ serverReferenceHashSalt, bundleLayer, esm, + cacheHandlers, }: { filename: string development: boolean @@ -379,6 +383,7 @@ export function getLoaderSWCOptions({ serverComponents?: boolean serverReferenceHashSalt: string bundleLayer?: WebpackLayerName + cacheHandlers: ExperimentalConfig['cacheHandlers'] }) { let baseOptions: any = getBaseSWCOptions({ filename, @@ -396,6 +401,7 @@ export function getLoaderSWCOptions({ serverReferenceHashSalt, esm: !!esm, isDynamicIo, + cacheHandlers, }) baseOptions.fontLoaders = { fontLoaders: ['next/font/local', 'next/font/google'], diff --git a/packages/next/src/build/webpack/loaders/next-swc-loader.ts b/packages/next/src/build/webpack/loaders/next-swc-loader.ts index 8b89aba642047..bce034cfeb410 100644 --- a/packages/next/src/build/webpack/loaders/next-swc-loader.ts +++ b/packages/next/src/build/webpack/loaders/next-swc-loader.ts @@ -142,6 +142,7 @@ async function loaderTransform( serverReferenceHashSalt, bundleLayer, esm, + cacheHandlers: nextConfig.experimental?.cacheHandlers, }) const programmaticOptions = { diff --git a/test/e2e/app-dir/use-cache-unknown-cache-kind/app/layout.tsx b/test/e2e/app-dir/use-cache-unknown-cache-kind/app/layout.tsx new file mode 100644 index 0000000000000..888614deda3ba --- /dev/null +++ b/test/e2e/app-dir/use-cache-unknown-cache-kind/app/layout.tsx @@ -0,0 +1,8 @@ +import { ReactNode } from 'react' +export default function Root({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} diff --git a/test/e2e/app-dir/use-cache-unknown-cache-kind/app/page.tsx b/test/e2e/app-dir/use-cache-unknown-cache-kind/app/page.tsx new file mode 100644 index 0000000000000..7fa0f0ec81336 --- /dev/null +++ b/test/e2e/app-dir/use-cache-unknown-cache-kind/app/page.tsx @@ -0,0 +1,5 @@ +'use cache: custom' + +export default async function Page() { + return

hello world

+} diff --git a/test/e2e/app-dir/use-cache-unknown-cache-kind/next.config.js b/test/e2e/app-dir/use-cache-unknown-cache-kind/next.config.js new file mode 100644 index 0000000000000..ac4afcf432196 --- /dev/null +++ b/test/e2e/app-dir/use-cache-unknown-cache-kind/next.config.js @@ -0,0 +1,10 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = { + experimental: { + dynamicIO: true, + }, +} + +module.exports = nextConfig diff --git a/test/e2e/app-dir/use-cache-unknown-cache-kind/use-cache-unknown-cache-kind.test.ts b/test/e2e/app-dir/use-cache-unknown-cache-kind/use-cache-unknown-cache-kind.test.ts new file mode 100644 index 0000000000000..75e50a62cc4f8 --- /dev/null +++ b/test/e2e/app-dir/use-cache-unknown-cache-kind/use-cache-unknown-cache-kind.test.ts @@ -0,0 +1,155 @@ +import { nextTestSetup } from 'e2e-utils' +import { NextConfig } from 'next' +import { + assertHasRedbox, + assertNoRedbox, + getRedboxDescription, + getRedboxSource, + retry, +} from 'next-test-utils' +import stripAnsi from 'strip-ansi' + +const nextConfigWithCacheHandler: NextConfig = { + experimental: { + dynamicIO: true, + cacheHandlers: { + custom: require.resolve('next/dist/server/lib/cache-handlers/default'), + }, + }, +} + +describe('use-cache-unknown-cache-kind', () => { + const { next, isNextStart, isTurbopack, skipped } = nextTestSetup({ + files: __dirname, + skipStart: process.env.NEXT_TEST_MODE !== 'dev', + skipDeployment: true, + }) + + if (skipped) { + return + } + + if (isNextStart) { + it('should fail the build with an error', async () => { + const { cliOutput } = await next.build() + const buildOutput = getBuildOutput(cliOutput) + + if (isTurbopack) { + expect(buildOutput).toMatchInlineSnapshot(` + "Error: Turbopack build failed with 1 errors: + Page: {"type":"app","side":"server","page":"/page"} + ./app/page.tsx:1:1 + Ecmascript file had an error + > 1 | 'use cache: custom' + | ^^^^^^^^^^^^^^^^^^^ + 2 | + 3 | export default async function Page() { + 4 | return

hello world

+ + Unknown cache kind "custom". Please configure a cache handler for this kind in the "experimental.cacheHandlers" object in your Next.js config. + + + + at (./app/page.tsx:1:1)" + `) + } else { + expect(buildOutput).toMatchInlineSnapshot(` + " + ./app/page.tsx + Error: x Unknown cache kind "custom". Please configure a cache handler for this kind in the "experimental.cacheHandlers" object in your Next.js config. + | + ,-[1:1] + 1 | 'use cache: custom' + : ^^^^^^^^^^^^^^^^^^^ + 2 | + 3 | export default async function Page() { + 4 | return

hello world

+ \`---- + + Import trace for requested module: + ./app/page.tsx + + + > Build failed because of webpack errors + " + `) + } + }) + } else { + it('should show a build error', async () => { + const browser = await next.browser('/') + + await assertHasRedbox(browser) + + const errorDescription = await getRedboxDescription(browser) + const errorSource = await getRedboxSource(browser) + + expect(errorDescription).toBe('Failed to compile') + + if (isTurbopack) { + expect(errorSource).toMatchInlineSnapshot(` + "./app/page.tsx:1:1 + Ecmascript file had an error + > 1 | 'use cache: custom' + | ^^^^^^^^^^^^^^^^^^^ + 2 | + 3 | export default async function Page() { + 4 | return

hello world

+ + Unknown cache kind "custom". Please configure a cache handler for this kind in the "experimental.cacheHandlers" object in your Next.js config." + `) + } else { + expect(errorSource).toMatchInlineSnapshot(` + "./app/page.tsx + Error: x Unknown cache kind "custom". Please configure a cache handler for this kind in the "experimental.cacheHandlers" object in your Next.js config. + | + ,-[1:1] + 1 | 'use cache: custom' + : ^^^^^^^^^^^^^^^^^^^ + 2 | + 3 | export default async function Page() { + 4 | return

hello world

+ \`----" + `) + } + }) + + it('should recover from the build error if the cache handler is defined', async () => { + const browser = await next.browser('/') + + await assertHasRedbox(browser) + + await next.patchFile( + 'next.config.js', + `module.exports = ${JSON.stringify(nextConfigWithCacheHandler)}`, + () => + retry(async () => { + expect(await browser.elementByCss('p').text()).toBe('hello world') + await assertNoRedbox(browser) + }) + ) + }) + } +}) + +function getBuildOutput(cliOutput: string): string { + const lines: string[] = [] + let skipLines = true + + for (const line of cliOutput.split('\n')) { + if (!skipLines) { + if (line.includes('at turbopackBuild')) { + break + } + + lines.push(stripAnsi(line)) + } else if ( + line.includes('Build error occurred') || + line.includes('Failed to compile') + ) { + skipLines = false + } + } + + return lines.join('\n') +}