diff --git a/crates/next-api/src/app.rs b/crates/next-api/src/app.rs index 60fd6d9d2570..5073636dfab2 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 e98892922dd0..8a04aa895ace 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?; @@ -282,17 +229,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 924b9976e06b..00af648e0d6f 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 28bbfc7bb837..03560ca7b890 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}, @@ -77,16 +77,41 @@ pub enum NextRevalidate { }, } +#[derive(PartialEq, Eq, Clone, Debug, NonLocalValue, Encode, Decode)] +pub enum NextSegmentRegion { + Single(RcStr), + Multiple(Vec), +} + +#[derive(PartialEq, Eq, Clone, Copy, Debug, NonLocalValue, Encode, Decode, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum NextSegmentRsc { + Server, + Client, +} + +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 { + pub rsc: Option, 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>, + 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, @@ -105,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] @@ -118,17 +150,30 @@ 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, + max_duration, fetch_cache, runtime, preferred_region, - .. + // TODO what about these? + instant: _, + prefetch: _, + ensure_static: _, + // Don't need merging + middleware_matcher: _, + unstable_allow_dynamic: _, + 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()); @@ -157,14 +202,28 @@ impl NextSegmentConfig { Ok(()) } let Self { + rsc, dynamic, dynamic_params, revalidate, + max_duration, fetch_cache, runtime, preferred_region, - .. + // TODO what about these? + instant: _, + prefetch: _, + ensure_static: _, + // Don't need merging + middleware_matcher: _, + unstable_allow_dynamic: _, + generate_image_metadata: _, + 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, @@ -172,15 +231,84 @@ 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( - 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(()) } + + 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. @@ -396,15 +524,17 @@ 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(); + 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, @@ -489,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)), @@ -511,6 +645,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( @@ -589,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>, @@ -731,18 +872,44 @@ async fn parse_config_value( continue; } - config.runtime = runtime + legacy_config.runtime = runtime } "matcher" => { 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( + config.preferred_region = parse_preferred_region_from_js_value( source, span, "config", "regions", value, ) .await?; } + "maxDuration" => { + 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; + } + }; + } _ => { // Ignore, } @@ -851,6 +1018,34 @@ 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; + }; + + 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; + } + }; + } "fetchCache" => { let Some(value) = get_value() else { return invalid_config( @@ -951,17 +1146,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; @@ -987,6 +1179,40 @@ 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, + 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, diff --git a/test/production/edge-config-validations/index.test.ts b/test/production/edge-config-validations/index.test.ts index c1552f5aa7ce..5e21f2d76b82 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"' ) }) }) 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 000000000000..7c3f422f0039 --- /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 000000000000..e36ba1f49314 --- /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 000000000000..b9e8acc4b1c2 --- /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 000000000000..426a0369008a --- /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 000000000000..02edd5fd6be9 --- /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 000000000000..db7aef748684 --- /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 000000000000..426a0369008a --- /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 000000000000..14b120988ecf --- /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 000000000000..e4f5738a310b --- /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 000000000000..2990d4f8f8a4 --- /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 000000000000..2c17cb45c21e --- /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 000000000000..50bad7958bf5 --- /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 000000000000..86ffa65e075e --- /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 000000000000..e49ee7bfa738 --- /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') + }) + } + ) +})