From 45aa287ddb0ac3eff17acc0e4e11d9e243652f73 Mon Sep 17 00:00:00 2001 From: Niklas Mischkulnig <4586894+mischnic@users.noreply.github.com> Date: Wed, 25 Feb 2026 15:54:20 +0100 Subject: [PATCH 01/13] segment_config: max_duration --- crates/next-core/src/segment_config.rs | 43 ++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/crates/next-core/src/segment_config.rs b/crates/next-core/src/segment_config.rs index 28bbfc7bb83..3dbf3d881c0 100644 --- a/crates/next-core/src/segment_config.rs +++ b/crates/next-core/src/segment_config.rs @@ -83,6 +83,7 @@ pub struct NextSegmentConfig { pub dynamic: Option, pub dynamic_params: Option, pub revalidate: Option, + pub max_duration: Option, pub fetch_cache: Option, pub runtime: Option, pub preferred_region: Option>, @@ -121,14 +122,20 @@ impl NextSegmentConfig { dynamic, dynamic_params, revalidate, + max_duration, fetch_cache, runtime, preferred_region, - .. + // Don't need merging + middleware_matcher: _, + generate_image_metadata: _, + generate_sitemaps: _, + generate_static_params: _, } = self; *dynamic = dynamic.or(parent.dynamic); *dynamic_params = dynamic_params.or(parent.dynamic_params); *revalidate = revalidate.or(parent.revalidate); + *max_duration = max_duration.or(parent.max_duration); *fetch_cache = fetch_cache.or(parent.fetch_cache); *runtime = runtime.or(parent.runtime); *preferred_region = preferred_region.take().or(parent.preferred_region.clone()); @@ -160,10 +167,15 @@ impl NextSegmentConfig { dynamic, dynamic_params, revalidate, + max_duration, fetch_cache, runtime, preferred_region, - .. + // Don't need merging + middleware_matcher: _, + generate_image_metadata: _, + generate_sitemaps: _, + generate_static_params: _, } = self; merge_parallel(dynamic, ¶llel_config.dynamic, "dynamic")?; merge_parallel( @@ -172,6 +184,7 @@ impl NextSegmentConfig { "dynamicParams", )?; merge_parallel(revalidate, ¶llel_config.revalidate, "revalidate")?; + merge_parallel(max_duration, ¶llel_config.max_duration, "maxDuration")?; merge_parallel(fetch_cache, ¶llel_config.fetch_cache, "fetchCache")?; merge_parallel(runtime, ¶llel_config.runtime, "runtime")?; merge_parallel( @@ -851,6 +864,32 @@ async fn parse_config_value( } } } + "maxDuration" => { + let Some(value) = get_value() else { + return invalid_config( + source, + "maxDuration", + span, + rcstr!("It mustn't be reexported."), + None, + IssueSeverity::Error, + ) + .await; + }; + + let JsValue::Constant(ConstantValue::Num(ConstantNumber(val))) = value else { + return invalid_config( + source, + "maxDuration", + span, + rcstr!("It needs to be a static number."), + Some(&value), + IssueSeverity::Error, + ) + .await; + }; + config.max_duration = Some(val as u32); + } "fetchCache" => { let Some(value) = get_value() else { return invalid_config( From 21ccfc5662a7b227ed6da835a77b9fedceef0d9e Mon Sep 17 00:00:00 2001 From: Niklas Mischkulnig <4586894+mischnic@users.noreply.github.com> Date: Fri, 21 Aug 2026 08:42:27 +0200 Subject: [PATCH 02/13] segment_config --- crates/next-core/src/segment_config.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/next-core/src/segment_config.rs b/crates/next-core/src/segment_config.rs index 3dbf3d881c0..36f54006799 100644 --- a/crates/next-core/src/segment_config.rs +++ b/crates/next-core/src/segment_config.rs @@ -126,6 +126,9 @@ impl NextSegmentConfig { fetch_cache, runtime, preferred_region, + // TODO what about these? + instant: _, + prefetch: _, // Don't need merging middleware_matcher: _, generate_image_metadata: _, @@ -171,6 +174,9 @@ impl NextSegmentConfig { fetch_cache, runtime, preferred_region, + // TODO what about these? + instant: _, + prefetch: _, // Don't need merging middleware_matcher: _, generate_image_metadata: _, From c547c7670a6906b3cb24b9922acf9db7e88e3136 Mon Sep 17 00:00:00 2001 From: Niklas Mischkulnig <4586894+mischnic@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:57:40 +0200 Subject: [PATCH 03/13] unstable_allowDynamic --- crates/next-core/src/segment_config.rs | 12 ++++++++++++ .../production/edge-config-validations/index.test.ts | 6 ++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/crates/next-core/src/segment_config.rs b/crates/next-core/src/segment_config.rs index 36f54006799..91319dbc9f1 100644 --- a/crates/next-core/src/segment_config.rs +++ b/crates/next-core/src/segment_config.rs @@ -131,6 +131,7 @@ impl NextSegmentConfig { prefetch: _, // Don't need merging middleware_matcher: _, + unstable_allow_dynamic: _, generate_image_metadata: _, generate_sitemaps: _, generate_static_params: _, @@ -179,6 +180,7 @@ impl NextSegmentConfig { prefetch: _, // Don't need merging middleware_matcher: _, + unstable_allow_dynamic: _, generate_image_metadata: _, generate_sitemaps: _, generate_static_params: _, @@ -756,6 +758,16 @@ async fn parse_config_value( config.middleware_matcher = parse_route_matcher_from_js_value(source, span, value).await?; } + "unstable_allowDynamic" => { + config.unstable_allow_dynamic = parse_static_string_or_array_from_js_value( + source, + span, + "config", + "unstable_allowDynamic", + value, + ) + .await?; + } "regions" => { config.preferred_region = parse_static_string_or_array_from_js_value( source, span, "config", "regions", value, diff --git a/test/production/edge-config-validations/index.test.ts b/test/production/edge-config-validations/index.test.ts index c1552f5aa7c..5e21f2d76b8 100644 --- a/test/production/edge-config-validations/index.test.ts +++ b/test/production/edge-config-validations/index.test.ts @@ -1,7 +1,7 @@ import { nextTestSetup } from 'e2e-utils' describe('Edge config validations', () => { - const { next } = nextTestSetup({ + const { next, isTurbopack } = nextTestSetup({ skipStart: true, files: { 'pages/index.js': ` @@ -24,7 +24,9 @@ describe('Edge config validations', () => { const res = await next.build() expect(res.exitCode).toBe(1) expect(res.cliOutput).toContain( - 'middleware contains invalid middleware config: Expected string, received boolean at "unstable_allowDynamic", or Expected array, received boolean at "unstable_allowDynamic"' + isTurbopack + ? "Next.js can't recognize the exported `config` field in route. `unstable_allowDynamic` needs to be a static string or array of static strings." + : 'middleware contains invalid middleware config: Expected string, received boolean at "unstable_allowDynamic", or Expected array, received boolean at "unstable_allowDynamic"' ) }) }) From 6cc3266a2d2b723409559c8ec03c89c954d25433 Mon Sep 17 00:00:00 2001 From: Niklas Mischkulnig <4586894+mischnic@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:57:57 +0200 Subject: [PATCH 04/13] dynamic --- crates/next-core/src/segment_config.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/next-core/src/segment_config.rs b/crates/next-core/src/segment_config.rs index 91319dbc9f1..07b2133ef08 100644 --- a/crates/next-core/src/segment_config.rs +++ b/crates/next-core/src/segment_config.rs @@ -88,6 +88,7 @@ pub struct NextSegmentConfig { pub runtime: Option, pub preferred_region: Option>, pub middleware_matcher: Option>, + pub unstable_allow_dynamic: Option>, /// Whether these exports are defined in the source file. pub generate_image_metadata: bool, From 09ae2867037fda4afdc2f6c8d845a276b3b7717b Mon Sep 17 00:00:00 2001 From: Niklas Mischkulnig <4586894+mischnic@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:58:09 +0200 Subject: [PATCH 05/13] maxDuration --- crates/next-core/src/segment_config.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/crates/next-core/src/segment_config.rs b/crates/next-core/src/segment_config.rs index 07b2133ef08..5e6c907b2ce 100644 --- a/crates/next-core/src/segment_config.rs +++ b/crates/next-core/src/segment_config.rs @@ -775,6 +775,21 @@ async fn parse_config_value( ) .await?; } + "maxDuration" => { + let JsValue::Constant(ConstantValue::Num(ConstantNumber(val))) = value + else { + return invalid_config( + source, + "config", + span, + rcstr!("`maxDuration` needs to be a static number."), + Some(value), + IssueSeverity::Error, + ) + .await; + }; + config.max_duration = Some(*val as u32); + } _ => { // Ignore, } From bdcc3d1a0db190dd03b14a82f8b810a3063bf98a Mon Sep 17 00:00:00 2001 From: Niklas Mischkulnig <4586894+mischnic@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:58:57 +0200 Subject: [PATCH 06/13] regions --- crates/next-api/src/app.rs | 4 +-- crates/next-api/src/middleware.rs | 16 ++++----- crates/next-api/src/pages.rs | 12 +++++-- crates/next-core/src/segment_config.rs | 47 ++++++++++++++++++++++---- 4 files changed, 57 insertions(+), 22 deletions(-) diff --git a/crates/next-api/src/app.rs b/crates/next-api/src/app.rs index 60fd6d9d257..5073636dfab 100644 --- a/crates/next-api/src/app.rs +++ b/crates/next-api/src/app.rs @@ -1776,8 +1776,8 @@ impl AppEndpoint { .config .await? .preferred_region - .clone() - .map(Regions::Multiple), + .as_ref() + .map(|region| Regions::Multiple(region.to_vec())), matchers: vec![matchers], env: project.edge_env().owned().await?, }; diff --git a/crates/next-api/src/middleware.rs b/crates/next-api/src/middleware.rs index e98892922dd..e9a844b4a33 100644 --- a/crates/next-api/src/middleware.rs +++ b/crates/next-api/src/middleware.rs @@ -282,17 +282,13 @@ impl MiddlewareEndpoint { let all_assets = get_asset_paths_from_root(&node_root_value, edge_all_assets.await?).await?; - let regions = if let Some(regions) = config.preferred_region.as_ref() { - if regions.len() == 1 { - regions - .first() - .map(|region| Regions::Single(region.clone())) - } else { - Some(Regions::Multiple(regions.clone())) + let regions = config.preferred_region.as_ref().map(|region| match region { + NextSegmentRegion::Single(region) => Regions::Single(region.clone()), + NextSegmentRegion::Multiple(regions) if regions.len() == 1 => { + Regions::Single(regions[0].clone()) } - } else { - None - }; + NextSegmentRegion::Multiple(regions) => Regions::Multiple(regions.clone()), + }); let edge_function_definition = EdgeFunctionDefinition { files: file_paths_from_root, diff --git a/crates/next-api/src/pages.rs b/crates/next-api/src/pages.rs index 924b9976e06..00af648e0d6 100644 --- a/crates/next-api/src/pages.rs +++ b/crates/next-api/src/pages.rs @@ -26,7 +26,7 @@ use next_core::{ PagesDirectoryStructure, PagesStructure, PagesStructureItem, find_pages_structure, }, parse_segment_config_from_source, - segment_config::ParseSegmentMode, + segment_config::{NextSegmentRegion, ParseSegmentMode}, util::{NextRuntime, get_asset_prefix_from_pathname, pages_function_name}, }; use smallvec::smallvec; @@ -966,7 +966,10 @@ impl PageEndpoint { // /_app and /_document are always rendered for Node.js for this case. For edge // they're included in the page bundle. runtime: NextRuntime::NodeJs, - regions: config.preferred_region.clone(), + regions: config + .preferred_region + .as_ref() + .map(NextSegmentRegion::to_vec), } } else { let modules = create_page_ssr_entry_module( @@ -991,7 +994,10 @@ impl PageEndpoint { app_module: modules.app_module, document_module: modules.document_module, runtime, - regions: config.preferred_region.clone(), + regions: config + .preferred_region + .as_ref() + .map(NextSegmentRegion::to_vec), } } .cell(), diff --git a/crates/next-core/src/segment_config.rs b/crates/next-core/src/segment_config.rs index 5e6c907b2ce..3cfa57775ff 100644 --- a/crates/next-core/src/segment_config.rs +++ b/crates/next-core/src/segment_config.rs @@ -77,6 +77,21 @@ pub enum NextRevalidate { }, } +#[derive(PartialEq, Eq, Clone, Debug, TraceRawVcs, NonLocalValue, Encode, Decode)] +pub enum NextSegmentRegion { + Single(RcStr), + Multiple(Vec), +} + +impl NextSegmentRegion { + pub fn to_vec(&self) -> Vec { + match self { + Self::Single(region) => vec![region.clone()], + Self::Multiple(regions) => regions.clone(), + } + } +} + #[turbo_tasks::value(shared)] #[derive(Debug, Default, Clone)] pub struct NextSegmentConfig { @@ -86,7 +101,7 @@ pub struct NextSegmentConfig { pub max_duration: Option, pub fetch_cache: Option, pub runtime: Option, - pub preferred_region: Option>, + pub preferred_region: Option, pub middleware_matcher: Option>, pub unstable_allow_dynamic: Option>, @@ -770,7 +785,7 @@ async fn parse_config_value( .await?; } "regions" => { - config.preferred_region = parse_static_string_or_array_from_js_value( + config.preferred_region = parse_preferred_region_from_js_value( source, span, "config", "regions", value, ) .await?; @@ -1024,17 +1039,14 @@ async fn parse_config_value( return Ok(()); } - if let Some(preferred_region) = parse_static_string_or_array_from_js_value( + config.preferred_region = parse_preferred_region_from_js_value( source, span, "preferredRegion", "preferredRegion", &value, ) - .await? - { - config.preferred_region = Some(preferred_region); - } + .await?; } "generateImageMetadata" => { config.generate_image_metadata = true; @@ -1060,6 +1072,27 @@ async fn parse_config_value( Ok(()) } +async fn parse_preferred_region_from_js_value( + source: ResolvedVc>, + span: Span, + key: &str, + sub_key: &str, + value: &JsValue<'_>, +) -> Result> { + let is_array = matches!(value, JsValue::Array { .. }); + Ok( + parse_static_string_or_array_from_js_value(source, span, key, sub_key, value) + .await? + .and_then(|regions| { + if is_array { + Some(NextSegmentRegion::Multiple(regions)) + } else { + regions.into_iter().next().map(NextSegmentRegion::Single) + } + }), + ) +} + async fn parse_static_string_or_array_from_js_value( source: ResolvedVc>, span: Span, From 8a9c60a051c33af7c9e1933124fd143dc6f36dfd Mon Sep 17 00:00:00 2001 From: Niklas Mischkulnig <4586894+mischnic@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:08:54 +0200 Subject: [PATCH 07/13] get_proxy_matchers --- crates/next-api/src/middleware.rs | 75 ++++---------------------- crates/next-core/src/segment_config.rs | 64 ++++++++++++++++++++++ 2 files changed, 75 insertions(+), 64 deletions(-) diff --git a/crates/next-api/src/middleware.rs b/crates/next-api/src/middleware.rs index e9a844b4a33..8a04aa895ac 100644 --- a/crates/next-api/src/middleware.rs +++ b/crates/next-api/src/middleware.rs @@ -6,8 +6,8 @@ use next_core::{ middleware::get_middleware_module, next_edge::entry::wrap_edge_entry, next_manifests::{EdgeFunctionDefinition, MiddlewaresManifestV2, ProxyMatcher, Regions}, - segment_config::NextSegmentConfig, - util::{MiddlewareMatcherKind, NextRuntime}, + segment_config::{NextSegmentConfig, NextSegmentRegion}, + util::NextRuntime, }; use tracing::Instrument; use turbo_rcstr::{RcStr, rcstr}; @@ -161,68 +161,15 @@ impl MiddlewareEndpoint { let has_i18n_locales = i18n.is_some(); let base_path = next_config.base_path().await?; - let matchers = if let Some(matchers) = config.middleware_matcher.as_ref() { - matchers - .iter() - .map(|matcher| { - let mut matcher = match matcher { - MiddlewareMatcherKind::Str(matcher) => ProxyMatcher { - original_source: matcher.as_str().into(), - ..Default::default() - }, - MiddlewareMatcherKind::Matcher(matcher) => matcher.clone(), - }; - - // Mirrors implementation in get-page-static-info.ts getMiddlewareMatchers - let mut source = matcher.original_source.to_string(); - let is_root = source == "/"; - let has_locale = matcher.locale; - - if has_i18n_locales && has_locale { - if is_root { - source.clear(); - } - source.insert_str(0, "/:nextInternalLocale((?!_next/)[^/.]{1,})"); - } - - // Match transport-specific route forms that resolve to the - // same page: - // - Pages Router data routes: /_next/data//... - // - App Router transport routes: .rsc, ...segments/...segment.rsc - if is_root { - source.push('('); - if has_i18n { - source.push_str("|\\.json|"); - } - source.push_str("/?index|/?index\\.json|"); - source.push_str("/?index(?:\\.rsc|\\.segments/.+\\.segment\\.rsc)"); - source.push_str(")?"); - } else { - source.push_str("{(\\.json|\\.rsc|\\.segments/.+\\.segment\\.rsc)}?"); - }; - - source.insert_str(0, "/:nextData(_next/data/[^/]{1,})?"); - - if let Some(base_path) = base_path.as_ref() { - source.insert_str(0, base_path); - } - - // TODO: The implementation of getMiddlewareMatchers outputs a regex here - // using path-to-regexp. Currently there is no - // equivalent of that so it post-processes - // this value to the relevant regex in manifest-loader.ts - matcher.regexp = Some(RcStr::from(source)); - - matcher - }) - .collect() - } else { - vec![ProxyMatcher { - regexp: Some(rcstr!("^/.*$")), - original_source: rcstr!("/:path*"), - ..Default::default() - }] - }; + let matchers = config + .get_proxy_matchers(has_i18n, has_i18n_locales, base_path.as_deref()) + .unwrap_or_else(|| { + vec![ProxyMatcher { + regexp: Some(rcstr!("^/.*$")), + original_source: rcstr!("/:path*"), + ..Default::default() + }] + }); if matches!(this.runtime, NextRuntime::NodeJs) { let chunk = self.node_chunk().to_resolved().await?; diff --git a/crates/next-core/src/segment_config.rs b/crates/next-core/src/segment_config.rs index 3cfa57775ff..5ec7e291551 100644 --- a/crates/next-core/src/segment_config.rs +++ b/crates/next-core/src/segment_config.rs @@ -218,6 +218,70 @@ impl NextSegmentConfig { )?; Ok(()) } + + pub fn get_proxy_matchers( + &self, + has_i18n: bool, + has_i18n_locales: bool, + base_path: Option<&str>, + ) -> Option> { + self.middleware_matcher.as_ref().map(|matchers| { + matchers + .iter() + .map(|matcher| { + let mut matcher = match matcher { + MiddlewareMatcherKind::Str(matcher) => ProxyMatcher { + original_source: matcher.as_str().into(), + ..Default::default() + }, + MiddlewareMatcherKind::Matcher(matcher) => matcher.clone(), + }; + + // Mirrors implementation in get-page-static-info.ts getMiddlewareMatchers + let mut source = matcher.original_source.to_string(); + let is_root = source == "/"; + let has_locale = matcher.locale; + + if has_i18n_locales && has_locale { + if is_root { + source.clear(); + } + source.insert_str(0, "/:nextInternalLocale((?!_next/)[^/.]{1,})"); + } + + // Match transport-specific route forms that resolve to the + // same page: + // - Pages Router data routes: /_next/data//... + // - App Router transport routes: .rsc, ...segments/...segment.rsc + if is_root { + source.push('('); + if has_i18n { + source.push_str("|\\.json|"); + } + source.push_str("/?index|/?index\\.json|"); + source.push_str("/?index(?:\\.rsc|\\.segments/.+\\.segment\\.rsc)"); + source.push_str(")?"); + } else { + source.push_str("{(\\.json|\\.rsc|\\.segments/.+\\.segment\\.rsc)}?"); + }; + + source.insert_str(0, "/:nextData(_next/data/[^/]{1,})?"); + + if let Some(base_path) = base_path.as_ref() { + source.insert_str(0, base_path); + } + + // TODO: The implementation of getMiddlewareMatchers outputs a regex here + // using path-to-regexp. Currently there is no + // equivalent of that so it post-processes + // this value to the relevant regex in manifest-loader.ts + matcher.regexp = Some(RcStr::from(source)); + + matcher + }) + .collect() + }) + } } /// An issue that occurred while parsing the app segment config. From 0b50bc7a778f41984f620f65c64c773ae3d17897 Mon Sep 17 00:00:00 2001 From: Niklas Mischkulnig <4586894+mischnic@users.noreply.github.com> Date: Wed, 16 Sep 2026 09:39:10 +0200 Subject: [PATCH 08/13] parse rsc in segment_config.rs --- crates/next-core/src/segment_config.rs | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/crates/next-core/src/segment_config.rs b/crates/next-core/src/segment_config.rs index 5ec7e291551..fda277e1538 100644 --- a/crates/next-core/src/segment_config.rs +++ b/crates/next-core/src/segment_config.rs @@ -3,7 +3,7 @@ use std::borrow::Cow; use anyhow::{Result, bail}; use async_trait::async_trait; use bincode::{Decode, Encode}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_json::Value; use swc_core::{ common::{DUMMY_SP, GLOBALS, Span, Spanned, source_map::SmallPos}, @@ -83,6 +83,15 @@ pub enum NextSegmentRegion { Multiple(Vec), } +#[derive( + PartialEq, Eq, Clone, Copy, Debug, TraceRawVcs, NonLocalValue, Encode, Decode, Serialize, +)] +#[serde(rename_all = "lowercase")] +pub enum NextSegmentRsc { + Server, + Client, +} + impl NextSegmentRegion { pub fn to_vec(&self) -> Vec { match self { @@ -95,6 +104,7 @@ impl NextSegmentRegion { #[turbo_tasks::value(shared)] #[derive(Debug, Default, Clone)] pub struct NextSegmentConfig { + pub rsc: Option, pub dynamic: Option, pub dynamic_params: Option, pub revalidate: Option, @@ -135,6 +145,8 @@ impl NextSegmentConfig { /// the parent's values. pub fn apply_parent_config(&mut self, parent: &Self) { let NextSegmentConfig { + // A layout's RSC classification does not apply to its child page. + rsc: _, dynamic, dynamic_params, revalidate, @@ -184,6 +196,7 @@ impl NextSegmentConfig { Ok(()) } let Self { + rsc, dynamic, dynamic_params, revalidate, @@ -201,6 +214,9 @@ impl NextSegmentConfig { generate_sitemaps: _, generate_static_params: _, } = self; + // Carry the leaf page's RSC classification up through its loader-tree branch. Unlike route + // segment config, classifications in different parallel branches are not conflicts. + *rsc = (*rsc).or(parallel_config.rsc); merge_parallel(dynamic, ¶llel_config.dynamic, "dynamic")?; merge_parallel( dynamic_params, @@ -497,7 +513,7 @@ pub async fn parse_segment_config_from_source( // Arena for the `JsValue`s produced while evaluating config expressions; // freed when this function returns. let arena = ThreadLocal::new(); - let config = WrapFuture::new( + let mut config = WrapFuture::new( async { let mut config = NextSegmentConfig::default(); @@ -612,6 +628,12 @@ pub async fn parse_segment_config_from_source( _ => false, }); + config.rsc = Some(if is_client_entry { + NextSegmentRsc::Client + } else { + NextSegmentRsc::Server + }); + if mode == ParseSegmentMode::App && is_client_entry { if let Some(span) = config.generate_static_params { invalid_config( From 3858f9955314043f5dc48434e745ee2c477536aa Mon Sep 17 00:00:00 2001 From: "vercel-fleet-prod[bot]" <318278635+vercel-fleet-prod[bot]@users.noreply.github.com> Date: Fri, 25 Sep 2026 07:51:56 +0000 Subject: [PATCH 09/13] Fix segment config build after rebase Co-authored-by: Niklas Mischkulnig <4586894+mischnic@users.noreply.github.com> --- crates/next-core/src/segment_config.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/next-core/src/segment_config.rs b/crates/next-core/src/segment_config.rs index fda277e1538..1c7548598ce 100644 --- a/crates/next-core/src/segment_config.rs +++ b/crates/next-core/src/segment_config.rs @@ -77,15 +77,13 @@ pub enum NextRevalidate { }, } -#[derive(PartialEq, Eq, Clone, Debug, TraceRawVcs, NonLocalValue, Encode, Decode)] +#[derive(PartialEq, Eq, Clone, Debug, NonLocalValue, Encode, Decode)] pub enum NextSegmentRegion { Single(RcStr), Multiple(Vec), } -#[derive( - PartialEq, Eq, Clone, Copy, Debug, TraceRawVcs, NonLocalValue, Encode, Decode, Serialize, -)] +#[derive(PartialEq, Eq, Clone, Copy, Debug, NonLocalValue, Encode, Decode, Serialize)] #[serde(rename_all = "lowercase")] pub enum NextSegmentRsc { Server, @@ -157,6 +155,7 @@ impl NextSegmentConfig { // TODO what about these? instant: _, prefetch: _, + ensure_static: _, // Don't need merging middleware_matcher: _, unstable_allow_dynamic: _, @@ -207,6 +206,7 @@ impl NextSegmentConfig { // TODO what about these? instant: _, prefetch: _, + ensure_static: _, // Don't need merging middleware_matcher: _, unstable_allow_dynamic: _, From 2cf4df86c8cada82856eb577ae31d4fcf4c43d80 Mon Sep 17 00:00:00 2001 From: Niklas Mischkulnig <4586894+mischnic@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:39:48 +0200 Subject: [PATCH 10/13] fix preferred_region merging --- crates/next-core/src/segment_config.rs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/crates/next-core/src/segment_config.rs b/crates/next-core/src/segment_config.rs index 1c7548598ce..9ead7f82fef 100644 --- a/crates/next-core/src/segment_config.rs +++ b/crates/next-core/src/segment_config.rs @@ -227,11 +227,15 @@ impl NextSegmentConfig { merge_parallel(max_duration, ¶llel_config.max_duration, "maxDuration")?; merge_parallel(fetch_cache, ¶llel_config.fetch_cache, "fetchCache")?; merge_parallel(runtime, ¶llel_config.runtime, "runtime")?; - merge_parallel( - preferred_region, - ¶llel_config.preferred_region, - "preferredRegion", - )?; + match (preferred_region.as_ref(), ¶llel_config.preferred_region) { + (Some(a), Some(b)) if a.to_vec() != b.to_vec() => { + bail!("Sibling segment configs have conflicting values for preferredRegion") + } + (None, Some(b)) => { + *preferred_region = Some(b.clone()); + } + _ => {} + } Ok(()) } From a59b383ad8e8efab9b1ee015b93080682747cc8d Mon Sep 17 00:00:00 2001 From: Niklas Mischkulnig <4586894+mischnic@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:01:13 +0200 Subject: [PATCH 11/13] refactor --- crates/next-core/src/segment_config.rs | 37 ++++++++++++++++++-------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/crates/next-core/src/segment_config.rs b/crates/next-core/src/segment_config.rs index 9ead7f82fef..7dafe3966e2 100644 --- a/crates/next-core/src/segment_config.rs +++ b/crates/next-core/src/segment_config.rs @@ -1016,18 +1016,20 @@ async fn parse_config_value( .await; }; - let JsValue::Constant(ConstantValue::Num(ConstantNumber(val))) = value else { - return invalid_config( - source, - "maxDuration", - span, - rcstr!("It needs to be a static number."), - Some(&value), - IssueSeverity::Error, - ) - .await; + config.max_duration = match parse_max_duration(&value) { + Ok(duration) => duration, + Err(error) => { + return invalid_config( + source, + "maxDuration", + span, + format!("It {error}").into(), + Some(&value), + IssueSeverity::Error, + ) + .await; + } }; - config.max_duration = Some(val as u32); } "fetchCache" => { let Some(value) = get_value() else { @@ -1162,6 +1164,19 @@ async fn parse_config_value( Ok(()) } +fn parse_max_duration(value: &JsValue<'_>) -> Result, &'static str> { + if matches!(value, JsValue::Constant(ConstantValue::Undefined)) { + return Ok(None); + } + let JsValue::Constant(ConstantValue::Num(ConstantNumber(val))) = value else { + return Err("needs to be a static number."); + }; + if !val.is_finite() || *val < 0.0 || val.fract() != 0.0 || *val > u32::MAX as f64 { + return Err("needs to be a non-negative integer no greater than 4294967295."); + } + Ok(Some(*val as u32)) +} + async fn parse_preferred_region_from_js_value( source: ResolvedVc>, span: Span, From 9ae07ed1f0bb0da9adf6db975cc101df6f06d206 Mon Sep 17 00:00:00 2001 From: Niklas Mischkulnig <4586894+mischnic@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:05:01 +0200 Subject: [PATCH 12/13] fix precedence --- crates/next-core/src/segment_config.rs | 41 ++++++++++++++++++-------- 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/crates/next-core/src/segment_config.rs b/crates/next-core/src/segment_config.rs index 7dafe3966e2..03560ca7b89 100644 --- a/crates/next-core/src/segment_config.rs +++ b/crates/next-core/src/segment_config.rs @@ -130,6 +130,13 @@ pub struct NextSegmentConfig { pub ensure_static: Option, } +/// Legacy config fields that are fallbacks for individual exports outside the App Router. +#[derive(Default)] +struct LegacySegmentConfig { + runtime: Option, + max_duration: Option, +} + #[turbo_tasks::value_impl] impl ValueDefault for NextSegmentConfig { #[turbo_tasks::function] @@ -520,12 +527,14 @@ pub async fn parse_segment_config_from_source( let mut config = WrapFuture::new( async { let mut config = NextSegmentConfig::default(); + let mut legacy_config = LegacySegmentConfig::default(); let mut parse = async |ident, init, span| { parse_config_value( source, mode, &mut config, + &mut legacy_config, eval_context, &arena, ident, @@ -610,6 +619,10 @@ pub async fn parse_segment_config_from_source( } } } + // Individual exports take precedence over legacy config fields, regardless of + // export order. App Router ignores legacy config, so its fallback stays empty. + config.runtime = config.runtime.or(legacy_config.runtime); + config.max_duration = config.max_duration.or(legacy_config.max_duration); anyhow::Ok(config) }, |f, ctx| GLOBALS.set(globals, || f.poll(ctx)), @@ -716,6 +729,7 @@ async fn parse_config_value( source: ResolvedVc>, mode: ParseSegmentMode, config: &mut NextSegmentConfig, + legacy_config: &mut LegacySegmentConfig, eval_context: &EvalContext, arena: &ThreadLocal, key: Cow<'_, str>, @@ -858,7 +872,7 @@ async fn parse_config_value( continue; } - config.runtime = runtime + legacy_config.runtime = runtime } "matcher" => { config.middleware_matcher = @@ -881,19 +895,20 @@ async fn parse_config_value( .await?; } "maxDuration" => { - let JsValue::Constant(ConstantValue::Num(ConstantNumber(val))) = value - else { - return invalid_config( - source, - "config", - span, - rcstr!("`maxDuration` needs to be a static number."), - Some(value), - IssueSeverity::Error, - ) - .await; + legacy_config.max_duration = match parse_max_duration(value) { + Ok(duration) => duration, + Err(error) => { + return invalid_config( + source, + "config", + span, + format!("`maxDuration` {error}").into(), + Some(value), + IssueSeverity::Error, + ) + .await; + } }; - config.max_duration = Some(*val as u32); } _ => { // Ignore, From e7e1304e34ab2baaa9b6c54132a00ddb699ca0ec Mon Sep 17 00:00:00 2001 From: Niklas Mischkulnig <4586894+mischnic@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:16:42 +0200 Subject: [PATCH 13/13] add test --- .../segment-config-precedence/app/layout.tsx | 9 +++ .../segment-config-precedence/app/page.tsx | 5 ++ .../app/region-array-first/@slot/page.tsx | 5 ++ .../app/region-array-first/layout.tsx | 16 ++++ .../app/region-array-first/page.tsx | 5 ++ .../app/region-string-first/@slot/page.tsx | 5 ++ .../app/region-string-first/layout.tsx | 16 ++++ .../app/region-string-first/page.tsx | 5 ++ .../segment-config-precedence/next.config.ts | 5 ++ .../pages/api/config-first.js | 7 ++ .../pages/api/export-first.js | 7 ++ .../pages/api/undefined-config-first.js | 7 ++ .../pages/api/undefined-export-first.js | 7 ++ .../segment-config-precedence.test.ts | 77 +++++++++++++++++++ 14 files changed, 176 insertions(+) create mode 100644 test/production/segment-config-precedence/app/layout.tsx create mode 100644 test/production/segment-config-precedence/app/page.tsx create mode 100644 test/production/segment-config-precedence/app/region-array-first/@slot/page.tsx create mode 100644 test/production/segment-config-precedence/app/region-array-first/layout.tsx create mode 100644 test/production/segment-config-precedence/app/region-array-first/page.tsx create mode 100644 test/production/segment-config-precedence/app/region-string-first/@slot/page.tsx create mode 100644 test/production/segment-config-precedence/app/region-string-first/layout.tsx create mode 100644 test/production/segment-config-precedence/app/region-string-first/page.tsx create mode 100644 test/production/segment-config-precedence/next.config.ts create mode 100644 test/production/segment-config-precedence/pages/api/config-first.js create mode 100644 test/production/segment-config-precedence/pages/api/export-first.js create mode 100644 test/production/segment-config-precedence/pages/api/undefined-config-first.js create mode 100644 test/production/segment-config-precedence/pages/api/undefined-export-first.js create mode 100644 test/production/segment-config-precedence/segment-config-precedence.test.ts diff --git a/test/production/segment-config-precedence/app/layout.tsx b/test/production/segment-config-precedence/app/layout.tsx new file mode 100644 index 00000000000..7c3f422f003 --- /dev/null +++ b/test/production/segment-config-precedence/app/layout.tsx @@ -0,0 +1,9 @@ +import type { ReactNode } from 'react' + +export default function Root({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} diff --git a/test/production/segment-config-precedence/app/page.tsx b/test/production/segment-config-precedence/app/page.tsx new file mode 100644 index 00000000000..e36ba1f4931 --- /dev/null +++ b/test/production/segment-config-precedence/app/page.tsx @@ -0,0 +1,5 @@ +export const maxDuration = undefined + +export default function Page() { + return

hello world

+} diff --git a/test/production/segment-config-precedence/app/region-array-first/@slot/page.tsx b/test/production/segment-config-precedence/app/region-array-first/@slot/page.tsx new file mode 100644 index 00000000000..b9e8acc4b1c --- /dev/null +++ b/test/production/segment-config-precedence/app/region-array-first/@slot/page.tsx @@ -0,0 +1,5 @@ +export const preferredRegion = 'iad1' + +export default function Page() { + return

slot

+} diff --git a/test/production/segment-config-precedence/app/region-array-first/layout.tsx b/test/production/segment-config-precedence/app/region-array-first/layout.tsx new file mode 100644 index 00000000000..426a0369008 --- /dev/null +++ b/test/production/segment-config-precedence/app/region-array-first/layout.tsx @@ -0,0 +1,16 @@ +import type { ReactNode } from 'react' + +export default function Layout({ + children, + slot, +}: { + children: ReactNode + slot: ReactNode +}) { + return ( + <> + {children} + {slot} + + ) +} diff --git a/test/production/segment-config-precedence/app/region-array-first/page.tsx b/test/production/segment-config-precedence/app/region-array-first/page.tsx new file mode 100644 index 00000000000..02edd5fd6be --- /dev/null +++ b/test/production/segment-config-precedence/app/region-array-first/page.tsx @@ -0,0 +1,5 @@ +export const preferredRegion = ['iad1'] + +export default function Page() { + return

children

+} diff --git a/test/production/segment-config-precedence/app/region-string-first/@slot/page.tsx b/test/production/segment-config-precedence/app/region-string-first/@slot/page.tsx new file mode 100644 index 00000000000..db7aef74868 --- /dev/null +++ b/test/production/segment-config-precedence/app/region-string-first/@slot/page.tsx @@ -0,0 +1,5 @@ +export const preferredRegion = ['iad1'] + +export default function Page() { + return

slot

+} diff --git a/test/production/segment-config-precedence/app/region-string-first/layout.tsx b/test/production/segment-config-precedence/app/region-string-first/layout.tsx new file mode 100644 index 00000000000..426a0369008 --- /dev/null +++ b/test/production/segment-config-precedence/app/region-string-first/layout.tsx @@ -0,0 +1,16 @@ +import type { ReactNode } from 'react' + +export default function Layout({ + children, + slot, +}: { + children: ReactNode + slot: ReactNode +}) { + return ( + <> + {children} + {slot} + + ) +} diff --git a/test/production/segment-config-precedence/app/region-string-first/page.tsx b/test/production/segment-config-precedence/app/region-string-first/page.tsx new file mode 100644 index 00000000000..14b120988ec --- /dev/null +++ b/test/production/segment-config-precedence/app/region-string-first/page.tsx @@ -0,0 +1,5 @@ +export const preferredRegion = 'iad1' + +export default function Page() { + return

children

+} diff --git a/test/production/segment-config-precedence/next.config.ts b/test/production/segment-config-precedence/next.config.ts new file mode 100644 index 00000000000..e4f5738a310 --- /dev/null +++ b/test/production/segment-config-precedence/next.config.ts @@ -0,0 +1,5 @@ +import type { NextConfig } from 'next' + +const nextConfig: NextConfig = {} + +export default nextConfig diff --git a/test/production/segment-config-precedence/pages/api/config-first.js b/test/production/segment-config-precedence/pages/api/config-first.js new file mode 100644 index 00000000000..2990d4f8f8a --- /dev/null +++ b/test/production/segment-config-precedence/pages/api/config-first.js @@ -0,0 +1,7 @@ +export const config = { runtime: 'edge', maxDuration: 20 } +export const runtime = 'nodejs' +export const maxDuration = 10 + +export default function handler(_req, res) { + res.status(200).json({ runtime: process.env.NEXT_RUNTIME }) +} diff --git a/test/production/segment-config-precedence/pages/api/export-first.js b/test/production/segment-config-precedence/pages/api/export-first.js new file mode 100644 index 00000000000..2c17cb45c21 --- /dev/null +++ b/test/production/segment-config-precedence/pages/api/export-first.js @@ -0,0 +1,7 @@ +export const runtime = 'nodejs' +export const maxDuration = 10 +export const config = { runtime: 'edge', maxDuration: 20 } + +export default function handler(_req, res) { + res.status(200).json({ runtime: process.env.NEXT_RUNTIME }) +} diff --git a/test/production/segment-config-precedence/pages/api/undefined-config-first.js b/test/production/segment-config-precedence/pages/api/undefined-config-first.js new file mode 100644 index 00000000000..50bad7958bf --- /dev/null +++ b/test/production/segment-config-precedence/pages/api/undefined-config-first.js @@ -0,0 +1,7 @@ +export const config = { runtime: 'edge', maxDuration: 20 } +export const runtime = undefined +export const maxDuration = undefined + +export default function handler() { + return Response.json({ runtime: process.env.NEXT_RUNTIME }) +} diff --git a/test/production/segment-config-precedence/pages/api/undefined-export-first.js b/test/production/segment-config-precedence/pages/api/undefined-export-first.js new file mode 100644 index 00000000000..86ffa65e075 --- /dev/null +++ b/test/production/segment-config-precedence/pages/api/undefined-export-first.js @@ -0,0 +1,7 @@ +export const runtime = undefined +export const maxDuration = undefined +export const config = { runtime: 'edge', maxDuration: 20 } + +export default function handler() { + return Response.json({ runtime: process.env.NEXT_RUNTIME }) +} diff --git a/test/production/segment-config-precedence/segment-config-precedence.test.ts b/test/production/segment-config-precedence/segment-config-precedence.test.ts new file mode 100644 index 00000000000..e49ee7bfa73 --- /dev/null +++ b/test/production/segment-config-precedence/segment-config-precedence.test.ts @@ -0,0 +1,77 @@ +import { nextTestSetup } from 'e2e-utils' + +describe('segment-config-precedence', () => { + const { next } = nextTestSetup({ + files: __dirname, + }) + + it('accepts an undefined maxDuration export', async () => { + const $ = await next.render$('/') + expect($('p').text()).toBe('hello world') + }) + + it.each(['region-string-first', 'region-array-first'])( + 'accepts equivalent preferredRegion values in parallel routes: %s', + async (route) => { + const $ = await next.render$(`/${route}`) + expect($('#children').text()).toBe('children') + expect($('#slot').text()).toBe('slot') + } + ) + + it.each([ + ['export-first', 'nodejs'], + ['config-first', 'nodejs'], + ['undefined-export-first', 'edge'], + ['undefined-config-first', 'edge'], + ])( + 'resolves runtime independently of export order: %s', + async (route, runtime) => { + const res = await next.fetch(`/api/${route}`) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ runtime }) + } + ) + + it('gives top-level maxDuration precedence and falls back for undefined exports', async () => { + const manifest = await next.readJSON( + '.next/server/functions-config-manifest.json' + ) + expect(manifest.functions['/api/export-first'].maxDuration).toBe(10) + expect(manifest.functions['/api/config-first'].maxDuration).toBe(10) + expect(manifest.functions['/api/undefined-export-first'].maxDuration).toBe( + 20 + ) + expect(manifest.functions['/api/undefined-config-first'].maxDuration).toBe( + 20 + ) + expect(manifest.functions['/']).toBeUndefined() + }) +}) + +describe('segment-config validation', () => { + describe.each(['-1', '1.5', 'NaN', 'Infinity'])( + 'maxDuration = %s', + (value) => { + const { next } = nextTestSetup({ + files: __dirname, + skipStart: true, + overrideFiles: { + 'app/page.tsx': ` + export const maxDuration = ${value} + + export default function Page() { + return

hello world

+ } + `, + }, + }) + + it('validates maxDuration at build time', async () => { + const result = await next.build() + expect(result.exitCode).toBe(1) + expect(result.cliOutput).toContain('maxDuration') + }) + } + ) +})