diff --git a/.github/actions/next-repo-actions/src/popular-prs.ts b/.github/actions/next-repo-actions/src/popular-prs.ts index 210d44bd426bb..088ac0f2125b9 100644 --- a/.github/actions/next-repo-actions/src/popular-prs.ts +++ b/.github/actions/next-repo-actions/src/popular-prs.ts @@ -39,7 +39,7 @@ async function run() { const blocks = BlockCollection([ Section({ - text: `*A list of the top ${count} PRs sorted by the most reactions (> 1) over the last 90 days.*\n_Note: This :github2: will run every Monday at 10AM UTC (6AM EST)._`, + text: `*A list of the top ${count} PRs sorted by the most reactions (> 1) over the last 90 days.*\n_Note: This :github2: will run every Monday at 10AM UTC (6AM EST)._`, }), Divider(), Section({ diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index 46c28490a56db..fa4019ab1d848 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -202,10 +202,12 @@ jobs: fail-fast: false matrix: group: [1/5, 2/5, 3/5, 4/5, 5/5] + # Empty value uses default + react: ['', '18.3.1'] uses: ./.github/workflows/build_reusable.yml with: - afterBuild: RUST_BACKTRACE=0 NEXT_EXTERNAL_TESTS_FILTERS="$(pwd)/test/turbopack-dev-tests-manifest.json" TURBOPACK=1 TURBOPACK_DEV=1 NEXT_E2E_TEST_TIMEOUT=240000 NEXT_TEST_MODE=dev node run-tests.js --test-pattern '^(test\/(development|e2e))/.*\.test\.(js|jsx|ts|tsx)$' --timings -g ${{ matrix.group }} -c ${TEST_CONCURRENCY} - stepName: 'test-turbopack-dev-${{ matrix.group }}' + afterBuild: RUST_BACKTRACE=0 NEXT_EXTERNAL_TESTS_FILTERS="$(pwd)/test/turbopack-dev-tests-manifest.json" TURBOPACK=1 TURBOPACK_DEV=1 NEXT_E2E_TEST_TIMEOUT=240000 NEXT_TEST_MODE=dev NEXT_TEST_REACT_VERSION="${{ matrix.react }}" node run-tests.js --test-pattern '^(test\/(development|e2e))/.*\.test\.(js|jsx|ts|tsx)$' --timings -g ${{ matrix.group }} -c ${TEST_CONCURRENCY} + stepName: 'test-turbopack-dev-react-${{ matrix.react }}-${{ matrix.group }}' secrets: inherit test-turbopack-integration: @@ -217,11 +219,13 @@ jobs: fail-fast: false matrix: group: [1/5, 2/5, 3/5, 4/5, 5/5] + # Empty value uses default + react: [''] uses: ./.github/workflows/build_reusable.yml with: nodeVersion: 18.18.2 - afterBuild: RUST_BACKTRACE=0 NEXT_EXTERNAL_TESTS_FILTERS="$(pwd)/test/turbopack-dev-tests-manifest.json" TURBOPACK=1 TURBOPACK_DEV=1 node run-tests.js --timings -g ${{ matrix.group }} -c ${TEST_CONCURRENCY} --type integration - stepName: 'test-turbopack-integration-${{ matrix.group }}' + afterBuild: RUST_BACKTRACE=0 NEXT_EXTERNAL_TESTS_FILTERS="$(pwd)/test/turbopack-dev-tests-manifest.json" TURBOPACK=1 TURBOPACK_DEV=1 NEXT_TEST_REACT_VERSION="${{ matrix.react }}" node run-tests.js --timings -g ${{ matrix.group }} -c ${TEST_CONCURRENCY} --type integration + stepName: 'test-turbopack-integration-react-${{ matrix.react }}-${{ matrix.group }}' secrets: inherit test-turbopack-production: @@ -233,11 +237,17 @@ jobs: fail-fast: false matrix: group: [1/5, 2/5, 3/5, 4/5, 5/5] + # Empty value uses default + # TODO: Run with React 18. + # Integration tests use the installed React version in next/package.json.include: + # We can't easily switch like we do for e2e tests. + # Skipping this dimensions until we can figure out a way to test multiple React versions. + react: ['', '18.3.1'] uses: ./.github/workflows/build_reusable.yml with: nodeVersion: 18.18.2 - afterBuild: RUST_BACKTRACE=0 NEXT_EXTERNAL_TESTS_FILTERS="$(pwd)/test/turbopack-build-tests-manifest.json" TURBOPACK=1 TURBOPACK_BUILD=1 NEXT_TEST_MODE=start node run-tests.js --timings -g ${{ matrix.group }} -c ${TEST_CONCURRENCY} --type production - stepName: 'test-turbopack-production-${{ matrix.group }}' + afterBuild: RUST_BACKTRACE=0 NEXT_EXTERNAL_TESTS_FILTERS="$(pwd)/test/turbopack-build-tests-manifest.json" TURBOPACK=1 TURBOPACK_BUILD=1 NEXT_TEST_MODE=start NEXT_TEST_REACT_VERSION="${{ matrix.react }}" node run-tests.js --timings -g ${{ matrix.group }} -c ${TEST_CONCURRENCY} --type production + stepName: 'test-turbopack-production-react-${{ matrix.react }}-${{ matrix.group }}' secrets: inherit test-turbopack-production-integration: @@ -362,10 +372,12 @@ jobs: fail-fast: false matrix: group: [1/4, 2/4, 3/4, 4/4] + # Empty value uses default + react: ['', '18.3.1'] uses: ./.github/workflows/build_reusable.yml with: - afterBuild: NEXT_TEST_MODE=dev node run-tests.js --timings -g ${{ matrix.group }} -c ${TEST_CONCURRENCY} --type development - stepName: 'test-dev-${{ matrix.group }}' + afterBuild: NEXT_TEST_MODE=dev NEXT_TEST_REACT_VERSION="${{ matrix.react }}" node run-tests.js --timings -g ${{ matrix.group }} -c ${TEST_CONCURRENCY} --type development + stepName: 'test-dev-react-${{ matrix.react }}-${{ matrix.group }}' secrets: inherit test-prod: @@ -377,10 +389,12 @@ jobs: fail-fast: false matrix: group: [1/5, 2/5, 3/5, 4/5, 5/5] + # Empty value uses default + react: ['', '18.3.1'] uses: ./.github/workflows/build_reusable.yml with: - afterBuild: NEXT_TEST_MODE=start node run-tests.js --timings -g ${{ matrix.group }} -c ${TEST_CONCURRENCY} --type production - stepName: 'test-prod-${{ matrix.group }}' + afterBuild: NEXT_TEST_MODE=start NEXT_TEST_REACT_VERSION="${{ matrix.react }}" node run-tests.js --timings -g ${{ matrix.group }} -c ${TEST_CONCURRENCY} --type production + stepName: 'test-prod-react-${{ matrix.react }}-${{ matrix.group }}' secrets: inherit test-integration: @@ -404,11 +418,17 @@ jobs: - 10/12 - 11/12 - 12/12 + # Empty value uses default + # TODO: Run with React 18. + # Integration tests use the installed React version in next/package.json.include: + # We can't easily switch like we do for e2e tests. + # Skipping this dimensions until we can figure out a way to test multiple React versions. + react: [''] uses: ./.github/workflows/build_reusable.yml with: nodeVersion: 18.18.2 - afterBuild: node run-tests.js --timings -g ${{ matrix.group }} -c ${TEST_CONCURRENCY} --type integration - stepName: 'test-integration-${{ matrix.group }}' + afterBuild: NEXT_TEST_REACT_VERSION="${{ matrix.react }}" node run-tests.js --timings -g ${{ matrix.group }} -c ${TEST_CONCURRENCY} --type integration + stepName: 'test-integration-${{ matrix.group }}-react-${{ matrix.react }}' secrets: inherit test-firefox-safari: diff --git a/.vscode/settings.json b/.vscode/settings.json index 7b4b1e6a9bfb5..0f288b28648a1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -60,14 +60,29 @@ "codemod", "codemods", "Destructuring", + "buildtime", + "callsites", + "codemod", + "datastream", + "deduped", + "draftmode", "Entrypoints", "jscodeshift", "napi", + "navigations", "nextjs", "opentelemetry", + "Preinit", "prerendered", + "prerendering", + "proxied", + "renderable", + "revalidates", + "subresource", + "thenables", "Threadsafe", "Turbopack", + "unproxied", "zipkin" ], "grammarly.selectors": [ diff --git a/crates/next-api/src/app.rs b/crates/next-api/src/app.rs index fb2e275b834f8..d86f1d59bf841 100644 --- a/crates/next-api/src/app.rs +++ b/crates/next-api/src/app.rs @@ -11,7 +11,8 @@ use next_core::{ next_app::{ app_client_references_chunks::get_app_server_reference_modules, get_app_client_references_chunks, get_app_client_shared_chunk_group, get_app_page_entry, - get_app_route_entry, metadata::route::get_app_metadata_route_entry, AppEntry, AppPage, + get_app_route_entry, include_modules_module::IncludeModulesModule, + metadata::route::get_app_metadata_route_entry, AppEntry, AppPage, }, next_client::{ get_client_module_options_context, get_client_resolve_options_context, @@ -36,7 +37,9 @@ use next_core::{ }; use serde::{Deserialize, Serialize}; use tracing::Instrument; -use turbo_tasks::{trace::TraceRawVcs, Completion, RcStr, TryJoinIterExt, Value, Vc}; +use turbo_tasks::{ + trace::TraceRawVcs, Completion, RcStr, TryJoinIterExt, Value, ValueToString, Vc, +}; use turbo_tasks_env::{CustomProcessEnv, ProcessEnv}; use turbo_tasks_fs::{File, FileContent, FileSystemPath}; use turbopack::{ @@ -686,6 +689,11 @@ fn client_shared_chunks() -> Vc { Vc::cell("client_shared_chunks".into()) } +#[turbo_tasks::function] +fn server_utils_module() -> Vc { + Vc::cell("server-utils".into()) +} + #[derive(Copy, Clone, Serialize, Deserialize, PartialEq, Eq, Debug, TraceRawVcs)] enum AppPageEndpointType { Html, @@ -1256,31 +1264,62 @@ impl AppEndpoint { let mut current_chunks = OutputAssets::empty(); let mut current_availability_info = AvailabilityInfo::Root; if let Some(client_references) = client_references { - for server_component in client_references - .await? - .server_component_entries - .iter() - .copied() - { - let server_path = server_component.server_path(); - let is_layout = - server_path.file_stem().await?.as_deref() == Some("layout"); + let client_references = client_references.await?; + let span = tracing::trace_span!("server utils",); + async { + let utils_module = IncludeModulesModule::new( + AssetIdent::from_path(this.app_project.project().project_path()) + .with_modifier(server_utils_module()), + client_references.server_utils.clone(), + ); let chunk_group = chunking_context .chunk_group( - server_component.ident(), - Vc::upcast(server_component), + utils_module.ident(), + Vc::upcast(utils_module), Value::new(current_availability_info), ) .await?; - if is_layout { + current_chunks = current_chunks + .concatenate(chunk_group.assets) + .resolve() + .await?; + current_availability_info = chunk_group.availability_info; + + anyhow::Ok(()) + } + .instrument(span) + .await?; + for server_component in client_references + .server_component_entries + .iter() + .copied() + .take(client_references.server_component_entries.len() - 1) + { + let span = tracing::trace_span!( + "layout segment", + name = server_component.ident().to_string().await?.as_str() + ); + async { + let chunk_group = chunking_context + .chunk_group( + server_component.ident(), + Vc::upcast(server_component), + Value::new(current_availability_info), + ) + .await?; + current_chunks = current_chunks .concatenate(chunk_group.assets) .resolve() .await?; current_availability_info = chunk_group.availability_info; + + anyhow::Ok(()) } + .instrument(span) + .await?; } } chunking_context diff --git a/crates/next-api/src/middleware.rs b/crates/next-api/src/middleware.rs index 720881f9aae34..57de8fe4a14d2 100644 --- a/crates/next-api/src/middleware.rs +++ b/crates/next-api/src/middleware.rs @@ -255,13 +255,11 @@ impl MiddlewareEndpoint { } #[turbo_tasks::function] - async fn userland_module(self: Vc) -> Result>> { - let this = self.await?; - - Ok(this + async fn userland_module(&self) -> Result>> { + Ok(self .asset_context .process( - this.source, + self.source, Value::new(ReferenceType::Entry(EntryReferenceSubType::Middleware)), ) .module()) diff --git a/crates/next-api/src/pages.rs b/crates/next-api/src/pages.rs index bfe4aecf1ffa4..7a4925ef06a42 100644 --- a/crates/next-api/src/pages.rs +++ b/crates/next-api/src/pages.rs @@ -640,9 +640,8 @@ impl PageEndpoint { } #[turbo_tasks::function] - async fn source(self: Vc) -> Result>> { - let this = self.await?; - Ok(Vc::upcast(FileSource::new(this.page.project_path()))) + async fn source(&self) -> Result>> { + Ok(Vc::upcast(FileSource::new(self.page.project_path()))) } #[turbo_tasks::function] @@ -946,11 +945,10 @@ impl PageEndpoint { #[turbo_tasks::function] async fn pages_manifest( - self: Vc, + &self, entry_chunk: Vc>, ) -> Result>> { - let this = self.await?; - let node_root = this.pages_project.project().node_root(); + let node_root = self.pages_project.project().node_root(); let chunk_path = entry_chunk.ident().path().await?; let asset_path = node_root @@ -960,11 +958,11 @@ impl PageEndpoint { .context("ssr chunk entry path must be inside the node root")?; let pages_manifest = PagesManifest { - pages: [(this.pathname.await?.clone_value(), asset_path.into())] + pages: [(self.pathname.await?.clone_value(), asset_path.into())] .into_iter() .collect(), }; - let manifest_path_prefix = get_asset_prefix_from_pathname(&this.pathname.await?); + let manifest_path_prefix = get_asset_prefix_from_pathname(&self.pathname.await?); Ok(Vc::upcast(VirtualOutputAsset::new( node_root .join(format!("server/pages{manifest_path_prefix}/pages-manifest.json",).into()), @@ -991,16 +989,15 @@ impl PageEndpoint { #[turbo_tasks::function] async fn build_manifest( - self: Vc, + &self, client_chunks: Vc, ) -> Result>> { - let this = self.await?; - let node_root = this.pages_project.project().node_root(); - let client_relative_path = this.pages_project.project().client_relative_path(); + let node_root = self.pages_project.project().node_root(); + let client_relative_path = self.pages_project.project().client_relative_path(); let client_relative_path_ref = client_relative_path.await?; let build_manifest = BuildManifest { pages: [( - this.pathname.await?.clone_value(), + self.pathname.await?.clone_value(), client_chunks .await? .iter() @@ -1022,7 +1019,7 @@ impl PageEndpoint { .collect(), ..Default::default() }; - let manifest_path_prefix = get_asset_prefix_from_pathname(&this.pathname.await?); + let manifest_path_prefix = get_asset_prefix_from_pathname(&self.pathname.await?); Ok(Vc::upcast(VirtualOutputAsset::new( node_root .join(format!("server/pages{manifest_path_prefix}/build-manifest.json",).into()), diff --git a/crates/next-api/src/project.rs b/crates/next-api/src/project.rs index 65e07fe2026cf..38f8cad5fe39d 100644 --- a/crates/next-api/src/project.rs +++ b/crates/next-api/src/project.rs @@ -301,9 +301,7 @@ impl ProjectContainer { #[turbo_tasks::value_impl] impl ProjectContainer { #[turbo_tasks::function] - pub async fn project(self: Vc) -> Result> { - let this = self.await?; - + pub async fn project(&self) -> Result> { let env_map: Vc; let next_config; let define_env; @@ -317,7 +315,7 @@ impl ProjectContainer { let preview_props; let browserslist_query; { - let options = this.options_state.get(); + let options = self.options_state.get(); let options = options .as_ref() .context("ProjectContainer need to be initialized with initialize()")?; @@ -361,7 +359,7 @@ impl ProjectContainer { } else { NextMode::Build.cell() }, - versioned_content_map: this.versioned_content_map, + versioned_content_map: self.versioned_content_map, build_id, encryption_key, preview_props, @@ -385,11 +383,11 @@ impl ProjectContainer { /// disabled, this will always return [`OptionSourceMap::none`]. #[turbo_tasks::function] pub async fn get_source_map( - self: Vc, + &self, file_path: Vc, section: Option, ) -> Result> { - Ok(if let Some(map) = self.await?.versioned_content_map { + Ok(if let Some(map) = self.versioned_content_map { map.get_source_map(file_path, section) } else { OptionSourceMap::none() @@ -517,14 +515,13 @@ impl Project { } #[turbo_tasks::function] - async fn project_fs(self: Vc) -> Result> { - let this = self.await?; + async fn project_fs(&self) -> Result> { let disk_fs = DiskFileSystem::new( PROJECT_FILESYSTEM_NAME.into(), - this.root_path.clone(), + self.root_path.clone(), vec![], ); - if this.watch { + if self.watch { disk_fs.await?.start_watching_with_invalidation_reason()?; } Ok(disk_fs) @@ -537,15 +534,14 @@ impl Project { } #[turbo_tasks::function] - pub async fn output_fs(self: Vc) -> Result> { - let this = self.await?; - let disk_fs = DiskFileSystem::new("output".into(), this.project_path.clone(), vec![]); + pub async fn output_fs(&self) -> Result> { + let disk_fs = DiskFileSystem::new("output".into(), self.project_path.clone(), vec![]); Ok(disk_fs) } #[turbo_tasks::function] - pub async fn dist_dir(self: Vc) -> Result> { - Ok(Vc::cell(self.await?.dist_dir.clone())) + pub async fn dist_dir(&self) -> Result> { + Ok(Vc::cell(self.dist_dir.clone())) } #[turbo_tasks::function] @@ -589,23 +585,23 @@ impl Project { } #[turbo_tasks::function] - pub(super) async fn env(self: Vc) -> Result>> { - Ok(self.await?.env) + pub(super) async fn env(&self) -> Result>> { + Ok(self.env) } #[turbo_tasks::function] - pub(super) async fn next_config(self: Vc) -> Result> { - Ok(self.await?.next_config) + pub(super) async fn next_config(&self) -> Result> { + Ok(self.next_config) } #[turbo_tasks::function] - pub(super) async fn next_mode(self: Vc) -> Result> { - Ok(self.await?.mode) + pub(super) async fn next_mode(&self) -> Result> { + Ok(self.mode) } #[turbo_tasks::function] - pub(super) async fn js_config(self: Vc) -> Result> { - Ok(self.await?.js_config) + pub(super) async fn js_config(&self) -> Result> { + Ok(self.js_config) } #[turbo_tasks::function] diff --git a/crates/next-api/src/versioned_content_map.rs b/crates/next-api/src/versioned_content_map.rs index a16ed6fd13bb0..189a968534d89 100644 --- a/crates/next-api/src/versioned_content_map.rs +++ b/crates/next-api/src/versioned_content_map.rs @@ -95,7 +95,7 @@ impl VersionedContentMap { /// operation. When assets change, map_path_to_op is updated. #[turbo_tasks::function] async fn compute_entry( - self: Vc, + &self, assets_operation: Vc, node_root: Vc, client_relative_path: Vc, @@ -118,7 +118,7 @@ impl VersionedContentMap { } let entries = get_entries(assets).await.unwrap_or_default(); - self.await?.map_path_to_op.update_conditionally(|map| { + self.map_path_to_op.update_conditionally(|map| { let mut changed = false; // get current map's keys, subtract keys that don't exist in operation diff --git a/crates/next-core/src/app_page_loader_tree.rs b/crates/next-core/src/app_page_loader_tree.rs index 0eef5e8fba706..b78bbe5113f82 100644 --- a/crates/next-core/src/app_page_loader_tree.rs +++ b/crates/next-core/src/app_page_loader_tree.rs @@ -5,15 +5,10 @@ use std::{ use anyhow::Result; use indexmap::IndexMap; -use turbo_tasks::{RcStr, Value, Vc}; +use turbo_tasks::{RcStr, Vc}; use turbo_tasks_fs::FileSystemPath; use turbopack::{transition::Transition, ModuleAssetContext}; -use turbopack_core::{ - context::AssetContext, - file_source::FileSource, - module::Module, - reference_type::{EcmaScriptModulesReferenceSubType, InnerAssets, ReferenceType}, -}; +use turbopack_core::{file_source::FileSource, module::Module}; use turbopack_ecmascript::{magic_identifier, text::TextContentFileSource, utils::StringifyJs}; use crate::{ @@ -193,16 +188,7 @@ impl AppPageLoaderTreeBuilder { app_page.clone(), ); - let module = self - .base - .module_asset_context - .process( - source, - Value::new(ReferenceType::EcmaScriptModules( - EcmaScriptModulesReferenceSubType::Undefined, - )), - ) - .module(); + let module = self.base.process_source(source); self.base .inner_assets .insert(inner_module_id.into(), module); @@ -237,14 +223,15 @@ impl AppPageLoaderTreeBuilder { self.base .imports .push(format!("import {identifier} from \"{inner_module_id}\";").into()); - self.base.inner_assets.insert( - inner_module_id.into(), - Vc::upcast(StructuredImageModuleType::create_module( - Vc::upcast(FileSource::new(path)), - BlurPlaceholderMode::None, - self.base.module_asset_context, - )), - ); + let module = Vc::upcast(StructuredImageModuleType::create_module( + Vc::upcast(FileSource::new(path)), + BlurPlaceholderMode::None, + self.base.module_asset_context, + )); + let module = self.base.process_module(module); + self.base + .inner_assets + .insert(inner_module_id.into(), module); let s = " "; writeln!(self.loader_tree_code, "{s}(async (props) => [{{")?; @@ -286,14 +273,9 @@ impl AppPageLoaderTreeBuilder { let module = self .base - .module_asset_context - .process( - Vc::upcast(TextContentFileSource::new(Vc::upcast(FileSource::new( - alt_path, - )))), - Value::new(ReferenceType::Internal(InnerAssets::empty())), - ) - .module(); + .process_source(Vc::upcast(TextContentFileSource::new(Vc::upcast( + FileSource::new(alt_path), + )))); self.base .inner_assets @@ -339,12 +321,18 @@ impl AppPageLoaderTreeBuilder { route: _, } = &modules; + // Ensure global metadata being written only once at the root level + // Otherwise child pages will have redundant metadata + let global_metadata = &*global_metadata.await?; + self.write_metadata( + app_page, + metadata, + if root { Some(global_metadata) } else { None }, + ) + .await?; + self.write_modules_entry(AppDirModuleType::Layout, *layout) .await?; - self.write_modules_entry(AppDirModuleType::Page, *page) - .await?; - self.write_modules_entry(AppDirModuleType::DefaultPage, *default) - .await?; self.write_modules_entry(AppDirModuleType::Error, *error) .await?; self.write_modules_entry(AppDirModuleType::Loading, *loading) @@ -353,6 +341,10 @@ impl AppPageLoaderTreeBuilder { .await?; self.write_modules_entry(AppDirModuleType::NotFound, *not_found) .await?; + self.write_modules_entry(AppDirModuleType::Page, *page) + .await?; + self.write_modules_entry(AppDirModuleType::DefaultPage, *default) + .await?; let modules_code = replace(&mut self.loader_tree_code, temp_loader_tree_code); @@ -366,16 +358,6 @@ impl AppPageLoaderTreeBuilder { self.loader_tree_code += &modules_code; - // Ensure global metadata being written only once at the root level - // Otherwise child pages will have redundant metadata - let global_metadata = &*global_metadata.await?; - self.write_metadata( - app_page, - metadata, - if root { Some(global_metadata) } else { None }, - ) - .await?; - write!(self.loader_tree_code, "}}]")?; Ok(()) } @@ -388,7 +370,9 @@ impl AppPageLoaderTreeBuilder { let modules = &loader_tree.modules; if let Some(global_error) = modules.global_error { - let module = self.base.process_module(global_error); + let module = self + .base + .process_source(Vc::upcast(FileSource::new(global_error))); self.base.inner_assets.insert(GLOBAL_ERROR.into(), module); }; diff --git a/crates/next-core/src/app_structure.rs b/crates/next-core/src/app_structure.rs index 5fbf6104587f3..b1548aa49b4a9 100644 --- a/crates/next-core/src/app_structure.rs +++ b/crates/next-core/src/app_structure.rs @@ -732,9 +732,8 @@ struct DuplicateParallelRouteIssue { #[turbo_tasks::value_impl] impl Issue for DuplicateParallelRouteIssue { #[turbo_tasks::function] - async fn file_path(self: Vc) -> Result> { - let this = self.await?; - Ok(this.app_dir.join(this.page.to_string().into())) + async fn file_path(&self) -> Result> { + Ok(self.app_dir.join(self.page.to_string().into())) } #[turbo_tasks::function] diff --git a/crates/next-core/src/base_loader_tree.rs b/crates/next-core/src/base_loader_tree.rs index 2292c8ef7b3b3..152ddd07bde1d 100644 --- a/crates/next-core/src/base_loader_tree.rs +++ b/crates/next-core/src/base_loader_tree.rs @@ -8,6 +8,7 @@ use turbopack_core::{ file_source::FileSource, module::Module, reference_type::{EcmaScriptModulesReferenceSubType, ReferenceType}, + source::Source, }; use turbopack_ecmascript::{magic_identifier, utils::StringifyJs}; @@ -64,9 +65,7 @@ impl BaseLoaderTreeBuilder { i } - pub fn process_module(&self, path: Vc) -> Vc> { - let source = Vc::upcast(FileSource::new(path)); - + pub fn process_source(&self, source: Vc>) -> Vc> { let reference_type = Value::new(ReferenceType::EcmaScriptModules( EcmaScriptModulesReferenceSubType::Undefined, )); @@ -76,6 +75,11 @@ impl BaseLoaderTreeBuilder { .module() } + pub fn process_module(&self, module: Vc>) -> Vc> { + self.server_component_transition + .process_module(module, self.module_asset_context) + } + pub async fn create_module_tuple_code( &mut self, module_type: AppDirModuleType, @@ -96,7 +100,7 @@ impl BaseLoaderTreeBuilder { .into(), ); - let module = self.process_module(path); + let module = self.process_source(Vc::upcast(FileSource::new(path))); self.inner_assets .insert(format!("MODULE_{i}").into(), module); diff --git a/crates/next-core/src/next_app/app_page_entry.rs b/crates/next-core/src/next_app/app_page_entry.rs index 6ecac241094fe..c1f1bda123a25 100644 --- a/crates/next-core/src/next_app/app_page_entry.rs +++ b/crates/next-core/src/next_app/app_page_entry.rs @@ -107,7 +107,7 @@ pub async fn get_app_page_entry( let source = VirtualSource::new_with_ident( source .ident() - .with_query(Vc::cell(query.to_string().into())), + .with_query(Vc::cell(format!("?{}", query).into())), AssetContent::file(file.into()), ); diff --git a/crates/next-core/src/next_client/runtime_entry.rs b/crates/next-core/src/next_client/runtime_entry.rs index 2bba0e124677c..719d8895ed506 100644 --- a/crates/next-core/src/next_client/runtime_entry.rs +++ b/crates/next-core/src/next_client/runtime_entry.rs @@ -69,12 +69,12 @@ pub struct RuntimeEntries(Vec>); impl RuntimeEntries { #[turbo_tasks::function] pub async fn resolve_entries( - self: Vc, + &self, asset_context: Vc>, ) -> Result> { let mut runtime_entries = Vec::new(); - for reference in &self.await? { + for reference in &self.0 { let resolved_entries = reference.resolve_entry(asset_context).await?; runtime_entries.extend(&resolved_entries); } diff --git a/crates/next-core/src/next_client_reference/visit_client_reference.rs b/crates/next-core/src/next_client_reference/visit_client_reference.rs index 10207b384b1c7..d74d07db67441 100644 --- a/crates/next-core/src/next_client_reference/visit_client_reference.rs +++ b/crates/next-core/src/next_client_reference/visit_client_reference.rs @@ -10,6 +10,7 @@ use turbo_tasks::{ trace::TraceRawVcs, RcStr, ReadRef, TryJoinIterExt, ValueToString, Vc, }; +use turbo_tasks_fs::FileSystemPath; use turbopack::css::CssModuleAsset; use turbopack_core::{ module::{Module, Modules}, @@ -50,6 +51,7 @@ pub enum ClientReferenceType { pub struct ClientReferenceGraphResult { pub client_references: Vec, pub server_component_entries: Vec>, + pub server_utils: Vec>>, } #[turbo_tasks::value(transparent)] @@ -77,6 +79,7 @@ pub async fn client_reference_graph( let mut client_references = vec![]; let mut server_component_entries = vec![]; + let mut server_utils = vec![]; let graph = AdjacencyMap::new() .skip_duplicates() @@ -86,7 +89,9 @@ pub async fn client_reference_graph( .copied() .map(|module| async move { Ok(VisitClientReferenceNode { - server_component: None, + state: VisitClientReferenceNodeState::Entry { + entry_path: module.ident().path().resolve().await?, + }, ty: VisitClientReferenceNodeType::Internal( module, module.ident().to_string().await?, @@ -111,6 +116,9 @@ pub async fn client_reference_graph( VisitClientReferenceNodeType::ClientReference(client_reference, _) => { client_references.push(*client_reference); } + VisitClientReferenceNodeType::ServerUtilEntry(server_util, _) => { + server_utils.push(*server_util); + } VisitClientReferenceNodeType::ServerComponentEntry(server_component, _) => { server_component_entries.push(*server_component); } @@ -120,6 +128,7 @@ pub async fn client_reference_graph( Ok(ClientReferenceGraphResult { client_references, server_component_entries, + server_utils, } .cell()) } @@ -133,16 +142,41 @@ struct VisitClientReference; Clone, Eq, PartialEq, Hash, Serialize, Deserialize, Debug, ValueDebugFormat, TraceRawVcs, )] struct VisitClientReferenceNode { - server_component: Option>, + state: VisitClientReferenceNodeState, ty: VisitClientReferenceNodeType, } +#[derive( + Clone, Copy, Eq, PartialEq, Hash, Serialize, Deserialize, Debug, ValueDebugFormat, TraceRawVcs, +)] +enum VisitClientReferenceNodeState { + Entry { + entry_path: Vc, + }, + InServerComponent { + server_component: Vc, + }, + InServerUtil, +} +impl VisitClientReferenceNodeState { + fn server_component(&self) -> Option> { + match self { + VisitClientReferenceNodeState::Entry { .. } => None, + VisitClientReferenceNodeState::InServerComponent { server_component } => { + Some(*server_component) + } + VisitClientReferenceNodeState::InServerUtil => None, + } + } +} + #[derive( Clone, Eq, PartialEq, Hash, Serialize, Deserialize, Debug, ValueDebugFormat, TraceRawVcs, )] enum VisitClientReferenceNodeType { ClientReference(ClientReference, ReadRef), ServerComponentEntry(Vc, ReadRef), + ServerUtilEntry(Vc>, ReadRef), Internal(Vc>, ReadRef), } @@ -155,6 +189,7 @@ impl Visit for VisitClientReference { match edge.ty { VisitClientReferenceNodeType::ClientReference(..) => VisitControlFlow::Skip(edge), VisitClientReferenceNodeType::Internal(..) => VisitControlFlow::Continue(edge), + VisitClientReferenceNodeType::ServerUtilEntry(..) => VisitControlFlow::Continue(edge), VisitClientReferenceNodeType::ServerComponentEntry(..) => { VisitControlFlow::Continue(edge) } @@ -169,6 +204,7 @@ impl Visit for VisitClientReference { // nodes' edges. VisitClientReferenceNodeType::ClientReference(..) => return Ok(vec![]), VisitClientReferenceNodeType::Internal(module, _) => module, + VisitClientReferenceNodeType::ServerUtilEntry(module, _) => module, VisitClientReferenceNodeType::ServerComponentEntry(module, _) => Vc::upcast(module), }; @@ -180,10 +216,10 @@ impl Visit for VisitClientReference { Vc::try_resolve_downcast_type::(module).await? { return Ok(VisitClientReferenceNode { - server_component: node.server_component, + state: node.state, ty: VisitClientReferenceNodeType::ClientReference( ClientReference { - server_component: node.server_component, + server_component: node.state.server_component(), ty: ClientReferenceType::EcmascriptClientReference( client_reference_module, ), @@ -197,10 +233,10 @@ impl Visit for VisitClientReference { Vc::try_resolve_downcast_type::(module).await? { return Ok(VisitClientReferenceNode { - server_component: node.server_component, + state: node.state, ty: VisitClientReferenceNodeType::ClientReference( ClientReference { - server_component: node.server_component, + server_component: node.state.server_component(), ty: ClientReferenceType::CssClientReference( css_client_reference_asset, ), @@ -214,7 +250,9 @@ impl Visit for VisitClientReference { Vc::try_resolve_downcast_type::(module).await? { return Ok(VisitClientReferenceNode { - server_component: Some(server_component_asset), + state: VisitClientReferenceNodeState::InServerComponent { + server_component: server_component_asset, + }, ty: VisitClientReferenceNodeType::ServerComponentEntry( server_component_asset, server_component_asset.ident().to_string().await?, @@ -222,8 +260,20 @@ impl Visit for VisitClientReference { }); } + if let VisitClientReferenceNodeState::Entry { entry_path } = &node.state { + if module.ident().path().resolve().await? != *entry_path { + return Ok(VisitClientReferenceNode { + state: VisitClientReferenceNodeState::InServerUtil, + ty: VisitClientReferenceNodeType::ServerUtilEntry( + module, + module.ident().to_string().await?, + ), + }); + } + } + Ok(VisitClientReferenceNode { - server_component: node.server_component, + state: node.state, ty: VisitClientReferenceNodeType::Internal( module, module.ident().to_string().await?, @@ -245,6 +295,9 @@ impl Visit for VisitClientReference { VisitClientReferenceNodeType::Internal(_, name) => { tracing::info_span!("module", name = name.to_string()) } + VisitClientReferenceNodeType::ServerUtilEntry(_, name) => { + tracing::info_span!("server util", name = name.to_string()) + } VisitClientReferenceNodeType::ServerComponentEntry(_, name) => { tracing::info_span!("layout segment", name = name.to_string()) } diff --git a/crates/next-core/src/next_config.rs b/crates/next-core/src/next_config.rs index 1b63a4cf1b95c..a137783d4ae45 100644 --- a/crates/next-core/src/next_config.rs +++ b/crates/next-core/src/next_config.rs @@ -792,10 +792,9 @@ impl NextConfig { } #[turbo_tasks::function] - pub async fn server_external_packages(self: Vc) -> Result>> { + pub async fn server_external_packages(&self) -> Result>> { Ok(Vc::cell( - self.await? - .server_external_packages + self.server_external_packages .as_ref() .cloned() .unwrap_or_default(), @@ -803,12 +802,11 @@ impl NextConfig { } #[turbo_tasks::function] - pub async fn env(self: Vc) -> Result> { + pub async fn env(&self) -> Result> { // The value expected for env is Record, but config itself // allows arbitrary object (https://github.com/vercel/next.js/blob/25ba8a74b7544dfb6b30d1b67c47b9cb5360cb4e/packages/next/src/server/config-schema.ts#L203) // then stringifies it. We do the interop here as well. let env = self - .await? .env .iter() .map(|(k, v)| { @@ -828,29 +826,28 @@ impl NextConfig { } #[turbo_tasks::function] - pub async fn image_config(self: Vc) -> Result> { - Ok(self.await?.images.clone().cell()) + pub async fn image_config(&self) -> Result> { + Ok(self.images.clone().cell()) } #[turbo_tasks::function] - pub async fn page_extensions(self: Vc) -> Result>> { - Ok(Vc::cell(self.await?.page_extensions.clone())) + pub async fn page_extensions(&self) -> Result>> { + Ok(Vc::cell(self.page_extensions.clone())) } #[turbo_tasks::function] - pub async fn transpile_packages(self: Vc) -> Result>> { + pub async fn transpile_packages(&self) -> Result>> { Ok(Vc::cell( - self.await?.transpile_packages.clone().unwrap_or_default(), + self.transpile_packages.clone().unwrap_or_default(), )) } #[turbo_tasks::function] pub async fn webpack_rules( - self: Vc, + &self, active_conditions: Vec, ) -> Result> { - let this = self.await?; - let Some(turbo_rules) = this + let Some(turbo_rules) = self .experimental .turbo .as_ref() @@ -937,9 +934,8 @@ impl NextConfig { } #[turbo_tasks::function] - pub async fn resolve_alias_options(self: Vc) -> Result> { - let this = self.await?; - let Some(resolve_alias) = this + pub async fn resolve_alias_options(&self) -> Result> { + let Some(resolve_alias) = self .experimental .turbo .as_ref() @@ -952,9 +948,8 @@ impl NextConfig { } #[turbo_tasks::function] - pub async fn resolve_extension(self: Vc) -> Result> { - let this = self.await?; - let Some(resolve_extensions) = this + pub async fn resolve_extension(&self) -> Result> { + let Some(resolve_extensions) = self .experimental .turbo .as_ref() @@ -966,8 +961,8 @@ impl NextConfig { } #[turbo_tasks::function] - pub async fn import_externals(self: Vc) -> Result> { - Ok(Vc::cell(match self.await?.experimental.esm_externals { + pub async fn import_externals(&self) -> Result> { + Ok(Vc::cell(match self.experimental.esm_externals { Some(EsmExternals::Bool(b)) => b, Some(EsmExternals::Loose(_)) => bail!("esmExternals = \"loose\" is not supported"), None => true, @@ -975,8 +970,8 @@ impl NextConfig { } #[turbo_tasks::function] - pub async fn mdx_rs(self: Vc) -> Result> { - let options = &self.await?.experimental.mdx_rs; + pub async fn mdx_rs(&self) -> Result> { + let options = &self.experimental.mdx_rs; let options = match options { Some(MdxRsOptions::Boolean(true)) => OptionalMdxTransformOptions(Some( @@ -1005,8 +1000,8 @@ impl NextConfig { } #[turbo_tasks::function] - pub async fn react_compiler(self: Vc) -> Result> { - let options = &self.await?.experimental.react_compiler; + pub async fn react_compiler(&self) -> Result> { + let options = &self.experimental.react_compiler; let options = match options { Some(ReactCompilerOptionsOrBoolean::Boolean(true)) => { @@ -1028,24 +1023,20 @@ impl NextConfig { } #[turbo_tasks::function] - pub async fn sass_config(self: Vc) -> Result> { - Ok(Vc::cell( - self.await?.sass_options.clone().unwrap_or_default(), - )) + pub async fn sass_config(&self) -> Result> { + Ok(Vc::cell(self.sass_options.clone().unwrap_or_default())) } #[turbo_tasks::function] - pub async fn skip_middleware_url_normalize(self: Vc) -> Result> { + pub async fn skip_middleware_url_normalize(&self) -> Result> { Ok(Vc::cell( - self.await?.skip_middleware_url_normalize.unwrap_or(false), + self.skip_middleware_url_normalize.unwrap_or(false), )) } #[turbo_tasks::function] - pub async fn skip_trailing_slash_redirect(self: Vc) -> Result> { - Ok(Vc::cell( - self.await?.skip_trailing_slash_redirect.unwrap_or(false), - )) + pub async fn skip_trailing_slash_redirect(&self) -> Result> { + Ok(Vc::cell(self.skip_trailing_slash_redirect.unwrap_or(false))) } /// Returns the final asset prefix. If an assetPrefix is set, it's used. @@ -1069,10 +1060,9 @@ impl NextConfig { } #[turbo_tasks::function] - pub async fn enable_ppr(self: Vc) -> Result> { + pub async fn enable_ppr(&self) -> Result> { Ok(Vc::cell( - self.await? - .experimental + self.experimental .ppr .as_ref() .map(|ppr| match ppr { @@ -1086,22 +1076,19 @@ impl NextConfig { } #[turbo_tasks::function] - pub async fn enable_taint(self: Vc) -> Result> { - Ok(Vc::cell(self.await?.experimental.taint.unwrap_or(false))) + pub async fn enable_taint(&self) -> Result> { + Ok(Vc::cell(self.experimental.taint.unwrap_or(false))) } #[turbo_tasks::function] - pub async fn enable_dynamic_io(self: Vc) -> Result> { - Ok(Vc::cell( - self.await?.experimental.dynamic_io.unwrap_or(false), - )) + pub async fn enable_dynamic_io(&self) -> Result> { + Ok(Vc::cell(self.experimental.dynamic_io.unwrap_or(false))) } #[turbo_tasks::function] - pub async fn use_swc_css(self: Vc) -> Result> { + pub async fn use_swc_css(&self) -> Result> { Ok(Vc::cell( - self.await? - .experimental + self.experimental .turbo .as_ref() .and_then(|turbo| turbo.use_swc_css) @@ -1110,10 +1097,9 @@ impl NextConfig { } #[turbo_tasks::function] - pub async fn optimize_package_imports(self: Vc) -> Result>> { + pub async fn optimize_package_imports(&self) -> Result>> { Ok(Vc::cell( - self.await? - .experimental + self.experimental .optimize_package_imports .clone() .unwrap_or_default(), @@ -1122,11 +1108,10 @@ impl NextConfig { #[turbo_tasks::function] pub async fn tree_shaking_mode_for_foreign_code( - self: Vc, + &self, is_development: bool, ) -> Result> { let tree_shaking = self - .await? .experimental .turbo .as_ref() @@ -1159,9 +1144,8 @@ impl NextConfig { } #[turbo_tasks::function] - pub async fn module_id_strategy_config(self: Vc) -> Result> { - let this = self.await?; - let Some(module_id_strategy) = this + pub async fn module_id_strategy_config(&self) -> Result> { + let Some(module_id_strategy) = self .experimental .turbo .as_ref() @@ -1194,10 +1178,8 @@ impl JsConfig { } #[turbo_tasks::function] - pub async fn compiler_options(self: Vc) -> Result> { - Ok(Vc::cell( - self.await?.compiler_options.clone().unwrap_or_default(), - )) + pub async fn compiler_options(&self) -> Result> { + Ok(Vc::cell(self.compiler_options.clone().unwrap_or_default())) } } diff --git a/crates/next-core/src/next_dynamic/dynamic_module.rs b/crates/next-core/src/next_dynamic/dynamic_module.rs index 5837bfd7b713a..70c9747185bb8 100644 --- a/crates/next-core/src/next_dynamic/dynamic_module.rs +++ b/crates/next-core/src/next_dynamic/dynamic_module.rs @@ -30,13 +30,11 @@ impl NextDynamicEntryModule { #[turbo_tasks::function] pub async fn client_chunks( - self: Vc, + &self, client_chunking_context: Vc>, ) -> Result> { - let this = self.await?; - let Some(client_entry_module) = - Vc::try_resolve_sidecast::>(this.client_entry_module).await? + Vc::try_resolve_sidecast::>(self.client_entry_module).await? else { bail!("dynamic client asset must be chunkable"); }; diff --git a/crates/next-core/src/next_font/google/options.rs b/crates/next-core/src/next_font/google/options.rs index f2f62b0726b96..1ecc78bfdaac2 100644 --- a/crates/next-core/src/next_font/google/options.rs +++ b/crates/next-core/src/next_font/google/options.rs @@ -35,8 +35,8 @@ impl NextFontGoogleOptions { } #[turbo_tasks::function] - pub async fn font_family(self: Vc) -> Result> { - Ok(Vc::cell((*self.await?.font_family).into())) + pub async fn font_family(&self) -> Result> { + Ok(Vc::cell((*self.font_family).into())) } } diff --git a/crates/next-core/src/next_font/local/mod.rs b/crates/next-core/src/next_font/local/mod.rs index 3662051a419f5..d3fd243d09028 100644 --- a/crates/next-core/src/next_font/local/mod.rs +++ b/crates/next-core/src/next_font/local/mod.rs @@ -321,8 +321,8 @@ impl Issue for FontResolvingIssue { } #[turbo_tasks::function] - async fn file_path(self: Vc) -> Result> { - Ok(self.await?.origin_path) + async fn file_path(&self) -> Result> { + Ok(self.origin_path) } #[turbo_tasks::function] diff --git a/crates/next-core/src/next_font/local/options.rs b/crates/next-core/src/next_font/local/options.rs index cd1198f975a89..2e06626db987b 100644 --- a/crates/next-core/src/next_font/local/options.rs +++ b/crates/next-core/src/next_font/local/options.rs @@ -41,8 +41,8 @@ impl NextFontLocalOptions { } #[turbo_tasks::function] - pub async fn font_family(self: Vc) -> Result> { - Ok(Vc::cell(self.await?.variable_name.clone())) + pub async fn font_family(&self) -> Result> { + Ok(Vc::cell(self.variable_name.clone())) } } diff --git a/crates/next-core/src/next_import_map.rs b/crates/next-core/src/next_import_map.rs index 5cef244e1df1a..d7c4739868396 100644 --- a/crates/next-core/src/next_import_map.rs +++ b/crates/next-core/src/next_import_map.rs @@ -567,6 +567,15 @@ async fn insert_next_server_special_aliases( external_esm_if_node(project_path, "next/dist/compiled/@vercel/og/index.node.js"), ); + import_map.insert_exact_alias( + "next/dist/server/ReactDOMServerPages", + ImportMapping::Alternatives(vec![ + request_to_import_mapping(project_path, "react-dom/server.edge"), + request_to_import_mapping(project_path, "react-dom/server.browser"), + ]) + .cell(), + ); + import_map.insert_exact_alias( "@opentelemetry/api", // It needs to prefer the local version of @opentelemetry/api diff --git a/crates/next-core/src/next_server_component/server_component_module.rs b/crates/next-core/src/next_server_component/server_component_module.rs index 4f1b49637f2c9..aa049cee125f0 100644 --- a/crates/next-core/src/next_server_component/server_component_module.rs +++ b/crates/next-core/src/next_server_component/server_component_module.rs @@ -46,9 +46,8 @@ impl NextServerComponentModule { } #[turbo_tasks::function] - pub async fn server_path(self: Vc) -> Result> { - let this = self.await?; - Ok(this.module.ident().path()) + pub async fn server_path(&self) -> Result> { + Ok(self.module.ident().path()) } } @@ -143,13 +142,12 @@ impl EcmascriptChunkItem for NextServerComponentChunkItem { } #[turbo_tasks::function] - async fn content(self: Vc) -> Result> { - let this = self.await?; - let inner = this.inner.await?; + async fn content(&self) -> Result> { + let inner = self.inner.await?; let module_id = inner .module - .as_chunk_item(Vc::upcast(this.chunking_context)) + .as_chunk_item(Vc::upcast(self.chunking_context)) .id() .await?; Ok(EcmascriptChunkItemContent { diff --git a/crates/next-core/src/page_loader.rs b/crates/next-core/src/page_loader.rs index 006fcf2d4c243..f3e04516d6ff4 100644 --- a/crates/next-core/src/page_loader.rs +++ b/crates/next-core/src/page_loader.rs @@ -96,11 +96,10 @@ impl PageLoaderAsset { #[turbo_tasks::function] async fn chunks_data( - self: Vc, + &self, rebase_prefix_path: Vc, ) -> Result> { - let this = self.await?; - let mut chunks = this.page_chunks; + let mut chunks = self.page_chunks; // If we are provided a prefix path, we need to rewrite our chunk paths to // remove that prefix. @@ -119,7 +118,7 @@ impl PageLoaderAsset { chunks = Vc::cell(rebased); }; - Ok(ChunkData::from_assets(this.server_root, chunks)) + Ok(ChunkData::from_assets(self.server_root, chunks)) } } diff --git a/crates/next-core/src/pages_structure.rs b/crates/next-core/src/pages_structure.rs index ae5586b36e050..1a27898110d6a 100644 --- a/crates/next-core/src/pages_structure.rs +++ b/crates/next-core/src/pages_structure.rs @@ -102,8 +102,8 @@ impl PagesDirectoryStructure { /// Returns the path to the directory of this structure in the project file /// system. #[turbo_tasks::function] - pub async fn project_path(self: Vc) -> Result> { - Ok(self.await?.project_path) + pub async fn project_path(&self) -> Result> { + Ok(self.project_path) } } diff --git a/crates/next-custom-transforms/src/transforms/react_server_components.rs b/crates/next-custom-transforms/src/transforms/react_server_components.rs index 4d5288503c89d..da68d741af571 100644 --- a/crates/next-custom-transforms/src/transforms/react_server_components.rs +++ b/crates/next-custom-transforms/src/transforms/react_server_components.rs @@ -21,7 +21,7 @@ use swc_core::{ }, }; -use super::cjs_finder::contains_cjs; +use super::{cjs_finder::contains_cjs, import_analyzer::ImportMap}; #[derive(Clone, Debug, Deserialize)] #[serde(untagged)] @@ -76,6 +76,7 @@ enum RSCErrorKind { NextRscErrConflictMetadataExport(Span), NextRscErrInvalidApi((String, Span)), NextRscErrDeprecatedApi((String, String, Span)), + NextSsrDynamicFalseNotAllowed(Span), } impl VisitMut for ReactServerComponents { @@ -301,6 +302,11 @@ fn report_error(app_dir: &Option, filepath: &str, error_kind: RSCErrorK ), _ => (format!("\"{source}\" is deprecated."), span), }, + RSCErrorKind::NextSsrDynamicFalseNotAllowed(span) => ( + "`ssr: false` is not allowed with `next/dynamic` in Server Components. Please move it into a client component." + .to_string(), + span, + ), }; HANDLER.with(|handler| handler.struct_span_err(span, msg.as_str()).emit()) @@ -502,6 +508,7 @@ struct ReactServerComponentValidator { invalid_client_imports: Vec, invalid_client_lib_apis_mapping: HashMap<&'static str, Vec<&'static str>>, pub directive_import_collection: Option<(bool, bool, RcVec, RcVec)>, + imports: ImportMap, } // A type to workaround a clippy warning. @@ -576,6 +583,7 @@ impl ReactServerComponentValidator { invalid_client_imports: vec![JsWord::from("server-only"), JsWord::from("next/headers")], invalid_client_lib_apis_mapping: [("next/server", vec!["unstable_after"])].into(), + imports: ImportMap::default(), } } @@ -584,6 +592,13 @@ impl ReactServerComponentValidator { RE.is_match(filepath) } + fn is_callee_next_dynamic(&self, callee: &Callee) -> bool { + match callee { + Callee::Expr(expr) => self.imports.is_import(expr, "next/dynamic", "default"), + _ => false, + } + } + // Asserts the server lib apis // e.g. // assert_invalid_server_lib_apis("react", import) @@ -817,6 +832,44 @@ impl ReactServerComponentValidator { } } } + + /// ``` + /// import dynamic from 'next/dynamic' + /// + /// dynamic(() => import(...)) // ✅ + /// dynamic(() => import(...), { ssr: true }) // ✅ + /// dynamic(() => import(...), { ssr: false }) // ❌ + /// ``` + + fn check_for_next_ssr_false(&self, node: &CallExpr) -> Option<()> { + if !self.is_callee_next_dynamic(&node.callee) { + return None; + } + + let ssr_arg = node.args.get(1)?; + let obj = ssr_arg.expr.as_object()?; + + for prop in obj.props.iter().filter_map(|v| v.as_prop()?.as_key_value()) { + let is_ssr = match &prop.key { + PropName::Ident(IdentName { sym, .. }) => sym == "ssr", + PropName::Str(s) => s.value == "ssr", + _ => false, + }; + + if is_ssr { + let value = prop.value.as_lit()?; + if let Lit::Bool(Bool { value: false, .. }) = value { + report_error( + &self.app_dir, + &self.filepath, + RSCErrorKind::NextSsrDynamicFalseNotAllowed(node.span), + ); + } + } + } + + None + } } impl Visit for ReactServerComponentValidator { @@ -830,7 +883,17 @@ impl Visit for ReactServerComponentValidator { } } + fn visit_call_expr(&mut self, node: &CallExpr) { + node.visit_children_with(self); + + if self.is_react_server_layer { + self.check_for_next_ssr_false(node); + } + } + fn visit_module(&mut self, module: &Module) { + self.imports = ImportMap::analyze(module); + let (is_client_entry, is_action_file, imports, export_names) = collect_top_level_directives_and_imports(&self.app_dir, &self.filepath, module); let imports = Rc::new(imports); diff --git a/crates/next-custom-transforms/src/transforms/warn_for_edge_runtime.rs b/crates/next-custom-transforms/src/transforms/warn_for_edge_runtime.rs index 8b38ffe44f652..f7cf3535020bf 100644 --- a/crates/next-custom-transforms/src/transforms/warn_for_edge_runtime.rs +++ b/crates/next-custom-transforms/src/transforms/warn_for_edge_runtime.rs @@ -298,10 +298,16 @@ impl Visit for WarnForEdgeRuntime { fn visit_bin_expr(&mut self, node: &BinExpr) { match node.op { op!("&&") | op!("||") | op!("??") => { - self.with_new_scope(move |this| { - this.add_guards(&node.left); - node.right.visit_with(this); - }); + if self.should_add_guards { + // This is a condition and not a shorthand for if-then + self.add_guards(&node.left); + node.right.visit_with(self); + } else { + self.with_new_scope(move |this| { + this.add_guards(&node.left); + node.right.visit_with(this); + }); + } } op!("==") | op!("===") => { self.add_guard_for_test(&node.left); diff --git a/crates/next-custom-transforms/tests/errors/react-server-components/server-graph/dynamic-ssr-false/input.js b/crates/next-custom-transforms/tests/errors/react-server-components/server-graph/dynamic-ssr-false/input.js new file mode 100644 index 0000000000000..fcf24da9a9b05 --- /dev/null +++ b/crates/next-custom-transforms/tests/errors/react-server-components/server-graph/dynamic-ssr-false/input.js @@ -0,0 +1,5 @@ +import dynamic from 'next/dynamic' + +export default function () { + return dynamic(() => import('client-only'), { ssr: false }) +} diff --git a/crates/next-custom-transforms/tests/errors/react-server-components/server-graph/dynamic-ssr-false/output.js b/crates/next-custom-transforms/tests/errors/react-server-components/server-graph/dynamic-ssr-false/output.js new file mode 100644 index 0000000000000..9b8e0b5585152 --- /dev/null +++ b/crates/next-custom-transforms/tests/errors/react-server-components/server-graph/dynamic-ssr-false/output.js @@ -0,0 +1,6 @@ +import dynamic from 'next/dynamic'; +export default function() { + return dynamic(()=>import('client-only'), { + ssr: false + }); +} diff --git a/crates/next-custom-transforms/tests/errors/react-server-components/server-graph/dynamic-ssr-false/output.stderr b/crates/next-custom-transforms/tests/errors/react-server-components/server-graph/dynamic-ssr-false/output.stderr new file mode 100644 index 0000000000000..7df6cc022eb2a --- /dev/null +++ b/crates/next-custom-transforms/tests/errors/react-server-components/server-graph/dynamic-ssr-false/output.stderr @@ -0,0 +1,7 @@ + x `ssr: false` is not allowed with `next/dynamic` in Server Components. Please move it into a client component. + ,-[input.js:4:1] + 3 | export default function () { + 4 | return dynamic(() => import('client-only'), { ssr: false }) + : ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 5 | } + `---- diff --git a/crates/next-custom-transforms/tests/fixture/edge-assert/guarded-process/input.js b/crates/next-custom-transforms/tests/fixture/edge-assert/guarded-process/input.js index 6ca9667a2f2db..46b9e5d2607e5 100644 --- a/crates/next-custom-transforms/tests/fixture/edge-assert/guarded-process/input.js +++ b/crates/next-custom-transforms/tests/fixture/edge-assert/guarded-process/input.js @@ -1,3 +1,8 @@ if (typeof process.loadEnvFile === 'function') { console.log(process.loadEnvFile()) } + +typeof process < 'u' && + typeof process.off == 'function' && + (process.off('uncaughtException', this.onUncaughtException), + process.off('unhandledRejection', this.onUncaughtRejection)) diff --git a/crates/next-custom-transforms/tests/fixture/edge-assert/guarded-process/output.js b/crates/next-custom-transforms/tests/fixture/edge-assert/guarded-process/output.js index 536ed876cf32e..9c5b3c8b7db3a 100644 --- a/crates/next-custom-transforms/tests/fixture/edge-assert/guarded-process/output.js +++ b/crates/next-custom-transforms/tests/fixture/edge-assert/guarded-process/output.js @@ -1,3 +1,4 @@ if (typeof process.loadEnvFile === 'function') { console.log(process.loadEnvFile()); } +typeof process < 'u' && typeof process.off == 'function' && (process.off('uncaughtException', this.onUncaughtException), process.off('unhandledRejection', this.onUncaughtRejection)); diff --git a/docs/02-app/01-building-your-application/06-optimizing/07-lazy-loading.mdx b/docs/02-app/01-building-your-application/06-optimizing/07-lazy-loading.mdx index ccde08193c244..5cf94abb99f03 100644 --- a/docs/02-app/01-building-your-application/06-optimizing/07-lazy-loading.mdx +++ b/docs/02-app/01-building-your-application/06-optimizing/07-lazy-loading.mdx @@ -72,8 +72,6 @@ const ComponentC = dynamic(() => import('../components/C'), { ssr: false }) If you dynamically import a Server Component, only the Client Components that are children of the Server Component will be lazy-loaded - not the Server Component itself. It will also help preload the static assets such as CSS when you're using it in Server Components. -> **Note:** `ssr: false` option is not supported in Server Components. - ```jsx filename="app/page.js" import dynamic from 'next/dynamic' @@ -89,6 +87,9 @@ export default function ServerComponentExample() { } ``` +> **Note:** `ssr: false` option is not supported in Server Components. You will see an error if you try to use it in Server Components. +> `ssr: false` is not allowed with `next/dynamic` in Server Components. Please move it into a client component. + ### Loading External Libraries External libraries can be loaded on demand using the [`import()`](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Operators/import) function. This example uses the external library `fuse.js` for fuzzy search. The module is only loaded on the client after the user types in the search input. diff --git a/docs/02-app/01-building-your-application/09-authentication/index.mdx b/docs/02-app/01-building-your-application/09-authentication/index.mdx index 933053d2e6f60..d9013731f13ca 100644 --- a/docs/02-app/01-building-your-application/09-authentication/index.mdx +++ b/docs/02-app/01-building-your-application/09-authentication/index.mdx @@ -1059,12 +1059,12 @@ export default async function middleware(req: NextRequest) { const cookie = cookies().get('session')?.value const session = await decrypt(cookie) - // 5. Redirect to /login if the user is not authenticated + // 4. Redirect to /login if the user is not authenticated if (isProtectedRoute && !session?.userId) { return NextResponse.redirect(new URL('/login', req.nextUrl)) } - // 6. Redirect to /dashboard if the user is authenticated + // 5. Redirect to /dashboard if the user is authenticated if ( isPublicRoute && session?.userId && diff --git a/docs/02-app/01-building-your-application/11-upgrading/04-app-router-migration.mdx b/docs/02-app/01-building-your-application/11-upgrading/04-app-router-migration.mdx index 2bfd0fa087ad4..98c6da98e32c9 100644 --- a/docs/02-app/01-building-your-application/11-upgrading/04-app-router-migration.mdx +++ b/docs/02-app/01-building-your-application/11-upgrading/04-app-router-migration.mdx @@ -474,6 +474,7 @@ In addition, the new `useRouter` hook has the following changes: - `basePath` has been removed. The alternative will not be part of `useRouter`. It has not yet been implemented. - `asPath` has been removed because the concept of `as` has been removed from the new router. - `isReady` has been removed because it is no longer necessary. During [static rendering](/docs/app/building-your-application/rendering/server-components#static-rendering-default), any component that uses the [`useSearchParams()`](/docs/app/api-reference/functions/use-search-params) hook will skip the prerendering step and instead be rendered on the client at runtime. +- `route` has been removed. `usePathname` or `useSelectedLayoutSegments()` provide an alternative. [View the `useRouter()` API reference](/docs/app/api-reference/functions/use-router). diff --git a/examples/with-docker-multi-env/docker/development/Dockerfile b/examples/with-docker-multi-env/docker/development/Dockerfile index 764fec08f57b3..f18bc1f09e53c 100644 --- a/examples/with-docker-multi-env/docker/development/Dockerfile +++ b/examples/with-docker-multi-env/docker/development/Dockerfile @@ -48,4 +48,4 @@ EXPOSE 3000 ENV PORT=3000 -CMD HOSTNAME=localhost node server.js +CMD HOSTNAME="0.0.0.0" node server.js diff --git a/examples/with-docker-multi-env/docker/production/Dockerfile b/examples/with-docker-multi-env/docker/production/Dockerfile index 12b3557b62b19..a7727706ae369 100644 --- a/examples/with-docker-multi-env/docker/production/Dockerfile +++ b/examples/with-docker-multi-env/docker/production/Dockerfile @@ -49,4 +49,4 @@ EXPOSE 3000 ENV PORT=3000 -CMD HOSTNAME=localhost node server.js +CMD HOSTNAME="0.0.0.0" node server.js diff --git a/examples/with-docker-multi-env/docker/staging/Dockerfile b/examples/with-docker-multi-env/docker/staging/Dockerfile index 4cd91b0dc2b75..282d3f3c1e189 100644 --- a/examples/with-docker-multi-env/docker/staging/Dockerfile +++ b/examples/with-docker-multi-env/docker/staging/Dockerfile @@ -49,4 +49,4 @@ EXPOSE 3000 ENV PORT=3000 -CMD HOSTNAME=localhost node server.js +CMD HOSTNAME="0.0.0.0" node server.js diff --git a/examples/with-draft-js/.gitignore b/examples/with-draft-js/.gitignore deleted file mode 100644 index fd3dbb571a12a..0000000000000 --- a/examples/with-draft-js/.gitignore +++ /dev/null @@ -1,36 +0,0 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. - -# dependencies -/node_modules -/.pnp -.pnp.js -.yarn/install-state.gz - -# testing -/coverage - -# next.js -/.next/ -/out/ - -# production -/build - -# misc -.DS_Store -*.pem - -# debug -npm-debug.log* -yarn-debug.log* -yarn-error.log* - -# local env files -.env*.local - -# vercel -.vercel - -# typescript -*.tsbuildinfo -next-env.d.ts diff --git a/examples/with-draft-js/README.md b/examples/with-draft-js/README.md deleted file mode 100644 index 79a1f2bb6a6ae..0000000000000 --- a/examples/with-draft-js/README.md +++ /dev/null @@ -1,29 +0,0 @@ -# DraftJS Medium editor inspiration - -Have you ever wanted to have an editor like medium.com in your Next.js app? DraftJS is available for SSR, but some plugins like the toolbar are using `window`, which does not work when doing SSR. - -This example aims to provide a fully customizable example of the famous medium editor with DraftJS. The goal was to get it as customizable as possible, and fully working with Next.js without using the react-no-ssr package. - -## Deploy your own - -Deploy the example using [Vercel](https://vercel.com?utm_source=github&utm_medium=readme&utm_campaign=next-example) or preview live with [StackBlitz](https://stackblitz.com/github/vercel/next.js/tree/canary/examples/with-draft-js) - -[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/vercel/next.js/tree/canary/examples/with-draft-js&project-name=with-draft-js&repository-name=with-draft-js) - -## How to use - -Execute [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app) with [npm](https://docs.npmjs.com/cli/init), [Yarn](https://yarnpkg.com/lang/en/docs/cli/create/), or [pnpm](https://pnpm.io) to bootstrap the example: - -```bash -npx create-next-app --example with-draft-js with-draft-js-app -``` - -```bash -yarn create next-app --example with-draft-js with-draft-js-app -``` - -```bash -pnpm create next-app --example with-draft-js with-draft-js-app -``` - -Deploy it to the cloud with [Vercel](https://vercel.com/new?utm_source=github&utm_medium=readme&utm_campaign=next-example) ([Documentation](https://nextjs.org/docs/deployment)). diff --git a/examples/with-draft-js/package.json b/examples/with-draft-js/package.json deleted file mode 100644 index 53c82f07b2871..0000000000000 --- a/examples/with-draft-js/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "private": true, - "scripts": { - "dev": "next", - "build": "next build", - "start": "next start" - }, - "dependencies": { - "draft-js": "^0.11.5", - "next": "latest", - "react": "^18.2.0", - "react-dom": "^18.2.0" - } -} diff --git a/examples/with-draft-js/pages/index.js b/examples/with-draft-js/pages/index.js deleted file mode 100644 index 987d2ac469376..0000000000000 --- a/examples/with-draft-js/pages/index.js +++ /dev/null @@ -1,345 +0,0 @@ -import { Component } from "react"; -import { - Editor, - EditorState, - RichUtils, - convertToRaw, - convertFromRaw, -} from "draft-js"; - -const initialData = { - blocks: [ - { - key: "16d0k", - text: "You can edit this text.", - type: "unstyled", - depth: 0, - inlineStyleRanges: [{ offset: 0, length: 23, style: "BOLD" }], - entityRanges: [], - data: {}, - }, - { - key: "98peq", - text: "", - type: "unstyled", - depth: 0, - inlineStyleRanges: [], - entityRanges: [], - data: {}, - }, - { - key: "ecmnc", - text: "Luke Skywalker has vanished. In his absence, the sinister FIRST ORDER has risen from the ashes of the Empire and will not rest until Skywalker, the last Jedi, has been destroyed.", - type: "unstyled", - depth: 0, - inlineStyleRanges: [ - { offset: 0, length: 14, style: "BOLD" }, - { offset: 133, length: 9, style: "BOLD" }, - ], - entityRanges: [], - data: {}, - }, - { - key: "fe2gn", - text: "", - type: "unstyled", - depth: 0, - inlineStyleRanges: [], - entityRanges: [], - data: {}, - }, - { - key: "4481k", - text: "With the support of the REPUBLIC, General Leia Organa leads a brave RESISTANCE. She is desperate to find her brother Luke and gain his help in restoring peace and justice to the galaxy.", - type: "unstyled", - depth: 0, - inlineStyleRanges: [ - { offset: 34, length: 19, style: "BOLD" }, - { offset: 117, length: 4, style: "BOLD" }, - { offset: 68, length: 10, style: "ANYCUSTOMSTYLE" }, - ], - entityRanges: [], - data: {}, - }, - ], - entityMap: {}, -}; - -// Custom overrides for each style -const styleMap = { - CODE: { - backgroundColor: "rgba(0, 0, 0, 0.05)", - fontFamily: '"Inconsolata", "Menlo", "Consolas", monospace', - fontSize: 16, - padding: 4, - }, - BOLD: { - color: "#395296", - fontWeight: "bold", - }, - ANYCUSTOMSTYLE: { - color: "#00e400", - }, -}; - -export default class App extends Component { - constructor(props) { - super(props); - this.state = { - editorState: EditorState.createWithContent(convertFromRaw(initialData)), - showToolbar: false, - windowWidth: 0, - toolbarMeasures: { - w: 0, - h: 0, - }, - selectionMeasures: { - w: 0, - h: 0, - }, - selectionCoordinates: { - x: 0, - y: 0, - }, - toolbarCoordinates: { - x: 0, - y: 0, - }, - showRawData: false, - }; - - this.focus = () => this.editor.focus(); - this.onChange = (editorState) => this.setState({ editorState }); - } - - onClickEditor = () => { - this.focus(); - this.checkSelectedText(); - }; - - // 1- Check if some text is selected - checkSelectedText = () => { - if (typeof window !== "undefined") { - const text = window.getSelection().toString(); - if (text !== "") { - // 1-a Define the selection coordinates - this.setSelectionXY(); - } else { - // Hide the toolbar if nothing is selected - this.setState({ - showToolbar: false, - }); - } - } - }; - - // 2- Identify the selection coordinates - setSelectionXY = () => { - var r = window.getSelection().getRangeAt(0).getBoundingClientRect(); - var relative = document.body.parentNode.getBoundingClientRect(); - // 2-a Set the selection coordinates in the state - this.setState( - { - selectionCoordinates: r, - windowWidth: relative.width, - selectionMeasures: { - w: r.width, - h: r.height, - }, - }, - () => this.showToolbar(), - ); - }; - - // 3- Show the toolbar - showToolbar = () => { - this.setState( - { - showToolbar: true, - }, - () => this.measureToolbar(), - ); - }; - - // 4- The toolbar was hidden until now - measureToolbar = () => { - // 4-a Define the toolbar width and height, as it is now visible - this.setState( - { - toolbarMeasures: { - w: this.elemWidth, - h: this.elemHeight, - }, - }, - () => this.setToolbarXY(), - ); - }; - - // 5- Now that we have all measures, define toolbar coordinates - setToolbarXY = () => { - let coordinates = {}; - - const { - selectionMeasures, - selectionCoordinates, - toolbarMeasures, - windowWidth, - } = this.state; - - const hiddenTop = selectionCoordinates.y < toolbarMeasures.h; - const hiddenRight = - windowWidth - selectionCoordinates.x < toolbarMeasures.w / 2; - const hiddenLeft = selectionCoordinates.x < toolbarMeasures.w / 2; - - const normalX = - selectionCoordinates.x - toolbarMeasures.w / 2 + selectionMeasures.w / 2; - const normalY = selectionCoordinates.y - toolbarMeasures.h; - - const invertedY = selectionCoordinates.y + selectionMeasures.h; - const moveXToLeft = windowWidth - toolbarMeasures.w; - const moveXToRight = 0; - - coordinates = { - x: normalX, - y: normalY, - }; - - if (hiddenTop) { - coordinates.y = invertedY; - } - - if (hiddenRight) { - coordinates.x = moveXToLeft; - } - - if (hiddenLeft) { - coordinates.x = moveXToRight; - } - - this.setState({ - toolbarCoordinates: coordinates, - }); - }; - - handleKeyCommand = (command) => { - const { editorState } = this.state; - const newState = RichUtils.handleKeyCommand(editorState, command); - if (newState) { - this.onChange(newState); - return true; - } - return false; - }; - - toggleToolbar = (inlineStyle) => { - this.onChange( - RichUtils.toggleInlineStyle(this.state.editorState, inlineStyle), - ); - }; - - render() { - const { editorState } = this.state; - // Make sure we're not on the ssr - if (typeof window !== "undefined") { - // Let's stick the toolbar to the selection - // when the window is resized - window.addEventListener("resize", this.checkSelectedText); - } - - const toolbarStyle = { - display: this.state.showToolbar ? "block" : "none", - backgroundColor: "black", - color: "white", - position: "absolute", - left: this.state.toolbarCoordinates.x, - top: this.state.toolbarCoordinates.y, - zIndex: 999, - padding: 10, - }; - return ( -
-
{ - this.elemWidth = elem ? elem.clientWidth : 0; - this.elemHeight = elem ? elem.clientHeight : 0; - }} - style={toolbarStyle} - > - -
-
- { - this.editor = element; - }} - /> -
-
- -
- {this.state.showRawData && - JSON.stringify(convertToRaw(editorState.getCurrentContent()))} -
-
- ); - } -} - -class ToolbarButton extends Component { - constructor() { - super(); - this.onToggle = (e) => { - e.preventDefault(); - this.props.onToggle(this.props.style); - }; - } - - render() { - const buttonStyle = { - padding: 10, - }; - return ( - - {this.props.label} - - ); - } -} - -var toolbarItems = [ - { label: "Bold", style: "BOLD" }, - { label: "Italic", style: "ITALIC" }, - { label: "Underline", style: "UNDERLINE" }, - { label: "Code", style: "CODE" }, - { label: "Surprise", style: "ANYCUSTOMSTYLE" }, -]; - -const ToolBar = (props) => { - var currentStyle = props.editorState.getCurrentInlineStyle(); - return ( -
- {toolbarItems.map((toolbarItem) => ( - - ))} -
- ); -}; diff --git a/lerna.json b/lerna.json index b664e74b612ba..1aa1b227198b3 100644 --- a/lerna.json +++ b/lerna.json @@ -16,5 +16,5 @@ "registry": "https://registry.npmjs.org/" } }, - "version": "15.0.0-canary.166" + "version": "15.0.0-canary.171" } diff --git a/package.json b/package.json index bc650b6083af5..d680f5ad29c08 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,7 @@ "@swc/core": "1.6.13", "@swc/helpers": "0.5.13", "@swc/types": "0.1.7", + "@taskr/esnext": "1.1.0", "@testing-library/jest-dom": "6.1.2", "@testing-library/react": "^15.0.5", "@types/busboy": "1.5.3", diff --git a/packages/create-next-app/package.json b/packages/create-next-app/package.json index d00c469649cc8..54c9b508b0000 100644 --- a/packages/create-next-app/package.json +++ b/packages/create-next-app/package.json @@ -1,6 +1,6 @@ { "name": "create-next-app", - "version": "15.0.0-canary.166", + "version": "15.0.0-canary.171", "keywords": [ "react", "next", diff --git a/packages/eslint-config-next/package.json b/packages/eslint-config-next/package.json index 842f888abd411..91ab34d4439e9 100644 --- a/packages/eslint-config-next/package.json +++ b/packages/eslint-config-next/package.json @@ -1,6 +1,6 @@ { "name": "eslint-config-next", - "version": "15.0.0-canary.166", + "version": "15.0.0-canary.171", "description": "ESLint configuration used by Next.js.", "main": "index.js", "license": "MIT", @@ -10,7 +10,7 @@ }, "homepage": "https://nextjs.org/docs/app/building-your-application/configuring/eslint#eslint-config", "dependencies": { - "@next/eslint-plugin-next": "15.0.0-canary.166", + "@next/eslint-plugin-next": "15.0.0-canary.171", "@rushstack/eslint-patch": "^1.3.3", "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", diff --git a/packages/eslint-plugin-next/package.json b/packages/eslint-plugin-next/package.json index 1b01f8b3f2bfa..c63db88e6ed2a 100644 --- a/packages/eslint-plugin-next/package.json +++ b/packages/eslint-plugin-next/package.json @@ -1,6 +1,6 @@ { "name": "@next/eslint-plugin-next", - "version": "15.0.0-canary.166", + "version": "15.0.0-canary.171", "description": "ESLint plugin for Next.js.", "main": "dist/index.js", "license": "MIT", diff --git a/packages/font/package.json b/packages/font/package.json index 7202127be15e7..e0a218bfa3475 100644 --- a/packages/font/package.json +++ b/packages/font/package.json @@ -1,7 +1,7 @@ { "name": "@next/font", "private": true, - "version": "15.0.0-canary.166", + "version": "15.0.0-canary.171", "repository": { "url": "vercel/next.js", "directory": "packages/font" diff --git a/packages/next-bundle-analyzer/package.json b/packages/next-bundle-analyzer/package.json index 22ea1ad625e60..3003ac4695230 100644 --- a/packages/next-bundle-analyzer/package.json +++ b/packages/next-bundle-analyzer/package.json @@ -1,6 +1,6 @@ { "name": "@next/bundle-analyzer", - "version": "15.0.0-canary.166", + "version": "15.0.0-canary.171", "main": "index.js", "types": "index.d.ts", "license": "MIT", diff --git a/packages/next-codemod/package.json b/packages/next-codemod/package.json index 431e1abe8cab5..b49083f7e0ac8 100644 --- a/packages/next-codemod/package.json +++ b/packages/next-codemod/package.json @@ -1,6 +1,6 @@ { "name": "@next/codemod", - "version": "15.0.0-canary.166", + "version": "15.0.0-canary.171", "license": "MIT", "repository": { "type": "git", diff --git a/packages/next-env/package.json b/packages/next-env/package.json index da3a6724a5715..054a84cc46ad8 100644 --- a/packages/next-env/package.json +++ b/packages/next-env/package.json @@ -1,6 +1,6 @@ { "name": "@next/env", - "version": "15.0.0-canary.166", + "version": "15.0.0-canary.171", "keywords": [ "react", "next", diff --git a/packages/next-mdx/package.json b/packages/next-mdx/package.json index 90a64428b7aba..52e83eb61c627 100644 --- a/packages/next-mdx/package.json +++ b/packages/next-mdx/package.json @@ -1,6 +1,6 @@ { "name": "@next/mdx", - "version": "15.0.0-canary.166", + "version": "15.0.0-canary.171", "main": "index.js", "license": "MIT", "repository": { diff --git a/packages/next-plugin-storybook/package.json b/packages/next-plugin-storybook/package.json index d8f9dc240c541..65361e3630411 100644 --- a/packages/next-plugin-storybook/package.json +++ b/packages/next-plugin-storybook/package.json @@ -1,6 +1,6 @@ { "name": "@next/plugin-storybook", - "version": "15.0.0-canary.166", + "version": "15.0.0-canary.171", "repository": { "url": "vercel/next.js", "directory": "packages/next-plugin-storybook" diff --git a/packages/next-polyfill-module/package.json b/packages/next-polyfill-module/package.json index fdaaca5b72780..39fa701f0b02b 100644 --- a/packages/next-polyfill-module/package.json +++ b/packages/next-polyfill-module/package.json @@ -1,6 +1,6 @@ { "name": "@next/polyfill-module", - "version": "15.0.0-canary.166", + "version": "15.0.0-canary.171", "description": "A standard library polyfill for ES Modules supporting browsers (Edge 16+, Firefox 60+, Chrome 61+, Safari 10.1+)", "main": "dist/polyfill-module.js", "license": "MIT", diff --git a/packages/next-polyfill-nomodule/package.json b/packages/next-polyfill-nomodule/package.json index fc65a5c8c8433..51ae32860b9e0 100644 --- a/packages/next-polyfill-nomodule/package.json +++ b/packages/next-polyfill-nomodule/package.json @@ -1,6 +1,6 @@ { "name": "@next/polyfill-nomodule", - "version": "15.0.0-canary.166", + "version": "15.0.0-canary.171", "description": "A polyfill for non-dead, nomodule browsers.", "main": "dist/polyfill-nomodule.js", "license": "MIT", diff --git a/packages/next-swc/package.json b/packages/next-swc/package.json index 66404e6c2148b..d39d8806036ef 100644 --- a/packages/next-swc/package.json +++ b/packages/next-swc/package.json @@ -1,6 +1,6 @@ { "name": "@next/swc", - "version": "15.0.0-canary.166", + "version": "15.0.0-canary.171", "private": true, "scripts": { "clean": "node ../../scripts/rm.mjs native", diff --git a/packages/next/headers.d.ts b/packages/next/headers.d.ts index f48ca4e26c287..e9317d037a84b 100644 --- a/packages/next/headers.d.ts +++ b/packages/next/headers.d.ts @@ -1 +1,3 @@ +export * from './dist/server/request/cookies' export * from './dist/server/request/headers' +export * from './dist/server/request/draft-mode' diff --git a/packages/next/headers.js b/packages/next/headers.js index 3fc596b76c475..8e77c79174870 100644 --- a/packages/next/headers.js +++ b/packages/next/headers.js @@ -1 +1,3 @@ -module.exports = require('./dist/server/request/headers') +module.exports.cookies = require('./dist/server/request/cookies').cookies +module.exports.headers = require('./dist/server/request/headers').headers +module.exports.draftMode = require('./dist/server/request/draft-mode').draftMode diff --git a/packages/next/package.json b/packages/next/package.json index f1856ad21e516..29c7ed6ff1b70 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -1,6 +1,6 @@ { "name": "next", - "version": "15.0.0-canary.166", + "version": "15.0.0-canary.171", "description": "The React Framework", "main": "./dist/server/next.js", "license": "MIT", @@ -95,7 +95,7 @@ ] }, "dependencies": { - "@next/env": "15.0.0-canary.166", + "@next/env": "15.0.0-canary.171", "@swc/counter": "0.1.3", "@swc/helpers": "0.5.13", "busboy": "1.6.0", @@ -107,8 +107,8 @@ "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.41.2", "babel-plugin-react-compiler": "*", - "react": "19.0.0-rc-5d19e1c8-20240923", - "react-dom": "19.0.0-rc-5d19e1c8-20240923", + "react": "^18.2.0 || 19.0.0-rc-5d19e1c8-20240923", + "react-dom": "^18.2.0 || 19.0.0-rc-5d19e1c8-20240923", "sass": "^1.3.0" }, "peerDependenciesMeta": { @@ -159,11 +159,11 @@ "@jest/types": "29.5.0", "@mswjs/interceptors": "0.23.0", "@napi-rs/triples": "1.2.0", - "@next/font": "15.0.0-canary.166", - "@next/polyfill-module": "15.0.0-canary.166", - "@next/polyfill-nomodule": "15.0.0-canary.166", - "@next/react-refresh-utils": "15.0.0-canary.166", - "@next/swc": "15.0.0-canary.166", + "@next/font": "15.0.0-canary.171", + "@next/polyfill-module": "15.0.0-canary.171", + "@next/polyfill-nomodule": "15.0.0-canary.171", + "@next/react-refresh-utils": "15.0.0-canary.171", + "@next/swc": "15.0.0-canary.171", "@opentelemetry/api": "1.6.0", "@playwright/test": "1.41.2", "@swc/core": "1.7.0-nightly-20240714.1", diff --git a/packages/next/server.d.ts b/packages/next/server.d.ts index 872bbc3899ff1..e3ba1fa950e07 100644 --- a/packages/next/server.d.ts +++ b/packages/next/server.d.ts @@ -14,3 +14,5 @@ export { URLPattern } from 'next/dist/compiled/@edge-runtime/primitives/url' export { ImageResponse } from 'next/dist/server/web/spec-extension/image-response' export type { ImageResponseOptions } from 'next/dist/compiled/@vercel/og/types' export { unstable_after } from 'next/dist/server/after' +export type { UnsafeUnwrappedSearchParams } from 'next/dist/server/request/search-params' +export type { UnsafeUnwrappedParams } from 'next/dist/server/request/params' diff --git a/packages/next/src/api/headers.ts b/packages/next/src/api/headers.ts index 80f57fa8f5f8d..f264546cb19ff 100644 --- a/packages/next/src/api/headers.ts +++ b/packages/next/src/api/headers.ts @@ -1 +1,3 @@ +export * from '../server/request/cookies' export * from '../server/request/headers' +export * from '../server/request/draft-mode' diff --git a/packages/next/src/build/create-compiler-aliases.ts b/packages/next/src/build/create-compiler-aliases.ts index 49788fde474d2..27f6d454616bf 100644 --- a/packages/next/src/build/create-compiler-aliases.ts +++ b/packages/next/src/build/create-compiler-aliases.ts @@ -1,4 +1,5 @@ import path from 'path' +import * as React from 'react' import { DOT_NEXT_ALIAS, PAGES_DIR_ALIAS, @@ -21,6 +22,8 @@ interface CompilerAliases { [alias: string]: string | string[] } +const isReact19 = typeof React.use === 'function' + export function createWebpackAliases({ distDir, isClient, @@ -90,6 +93,12 @@ export function createWebpackAliases({ return { '@vercel/og$': 'next/dist/server/og/image-response', + // Avoid bundling both entrypoints in React 19 when we just need one. + // Also avoids bundler warnings in React 18 where react-dom/server.edge doesn't exist. + 'next/dist/server/ReactDOMServerPages': isReact19 + ? 'react-dom/server.edge' + : 'react-dom/server.browser', + // Alias next/dist imports to next/dist/esm assets, // let this alias hit before `next` alias. ...(isEdgeServer diff --git a/packages/next/src/build/webpack-config.ts b/packages/next/src/build/webpack-config.ts index 56c8349cfc07d..5dee552b02fa9 100644 --- a/packages/next/src/build/webpack-config.ts +++ b/packages/next/src/build/webpack-config.ts @@ -107,8 +107,8 @@ const NEXT_PROJECT_ROOT_DIST_CLIENT = path.join( 'client' ) -if (parseInt(React.version) < 19) { - throw new Error('Next.js requires react >= 19.0.0 to be installed.') +if (parseInt(React.version) < 18) { + throw new Error('Next.js requires react >= 18.2.0 to be installed.') } export const babelIncludeRegexes: RegExp[] = [ diff --git a/packages/next/src/build/webpack/loaders/next-flight-loader/cache-wrapper.ts b/packages/next/src/build/webpack/loaders/next-flight-loader/cache-wrapper.ts index 3d18403408619..c225f960175df 100644 --- a/packages/next/src/build/webpack/loaders/next-flight-loader/cache-wrapper.ts +++ b/packages/next/src/build/webpack/loaders/next-flight-loader/cache-wrapper.ts @@ -1,3 +1 @@ -export function cache(_kind: string, _id: string, fn: any) { - return fn -} +export { cache } from '../../../../server/use-cache/use-cache-wrapper' diff --git a/packages/next/src/build/webpack/plugins/next-types-plugin/index.ts b/packages/next/src/build/webpack/plugins/next-types-plugin/index.ts index 2199634bc4938..17cdbcd0f767f 100644 --- a/packages/next/src/build/webpack/plugins/next-types-plugin/index.ts +++ b/packages/next/src/build/webpack/plugins/next-types-plugin/index.ts @@ -85,6 +85,7 @@ checkFields>() +${options.type === 'route' ? `type RouteContext = { params: Promise }` : ''} ${ options.type === 'route' ? HTTP_METHODS.map( @@ -103,7 +104,7 @@ if ('${method}' in entry) { >() checkFields< Diff< - ParamCheck, + ParamCheck, { __tag__: '${method}' __param_position__: 'second' @@ -158,14 +159,14 @@ if ('generateViewport' in entry) { } // Check the arguments and return type of the generateStaticParams function if ('generateStaticParams' in entry) { - checkFields>, 'generateStaticParams'>>() + checkFields>, 'generateStaticParams'>>() checkFields }, { __tag__: 'generateStaticParams', __return_type__: ReturnType> }>>() } -type PageParams = any +type SegmentParams = {[param: string]: string | string[] | undefined} export interface PageProps { - params?: any - searchParams?: any + params?: Promise + searchParams?: Promise } export interface LayoutProps { children?: React.ReactNode @@ -174,7 +175,7 @@ ${ ? options.slots.map((slot) => ` ${slot}: React.ReactNode`).join('\n') : '' } - params?: any + params?: Promise } // ============= diff --git a/packages/next/src/client/components/async-local-storage.ts b/packages/next/src/client/components/async-local-storage.ts index bab68f2a0e5a9..166f34d3f14c1 100644 --- a/packages/next/src/client/components/async-local-storage.ts +++ b/packages/next/src/client/components/async-local-storage.ts @@ -40,3 +40,15 @@ export function createAsyncLocalStorage< } return new FakeAsyncLocalStorage() } + +export function createSnapshot(): ( + fn: (...args: TArgs) => R, + ...args: TArgs +) => R { + if (maybeGlobalAsyncLocalStorage) { + return maybeGlobalAsyncLocalStorage.snapshot() + } + return function (fn: any, ...args: any[]) { + return fn(...args) + } +} diff --git a/packages/next/src/client/components/client-page.tsx b/packages/next/src/client/components/client-page.tsx index bafacc9eb9e90..29cb3be77308b 100644 --- a/packages/next/src/client/components/client-page.tsx +++ b/packages/next/src/client/components/client-page.tsx @@ -1,30 +1,76 @@ 'use client' +import type { ParsedUrlQuery } from 'querystring' +import { InvariantError } from '../../shared/lib/invariant-error' + +import type { Params } from '../../server/request/params' + +/** + * When the Page is a client component we send the params and searchParams to this client wrapper + * where they are turned into dynamically tracked values before being passed to the actual Page component. + * + * additionally we may send promises representing the params and searchParams. We don't ever use these passed + * values but it can be necessary for the sender to send a Promise that doesn't resolve in certain situations. + * It is up to the caller to decide if the promises are needed. + */ export function ClientPageRoot({ Component, - props, + searchParams, + params, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + promises, }: { Component: React.ComponentType - props: { [props: string]: any } + searchParams: ParsedUrlQuery + params: Params + promises?: Array> }) { if (typeof window === 'undefined') { - const { createDynamicallyTrackedParams } = - require('../../server/request/fallback-params') as typeof import('../../server/request/fallback-params') - const { createDynamicallyTrackedSearchParams } = - require('../../server/request/search-params') as typeof import('../../server/request/search-params') - - // We expect to be passed searchParams but even if we aren't we can construct one from - // an empty object. We only do this if we are in a static generation as a performance - // optimization. Ideally we'd unconditionally construct the tracked params but since - // this creates a proxy which is slow and this would happen even for client navigations - // that are done entirely dynamically and we know there the dynamic tracking is a noop - // in this dynamic case we can safely elide it. - props.searchParams = createDynamicallyTrackedSearchParams( - props.searchParams || {} - ) - props.params = props.params - ? createDynamicallyTrackedParams(props.params) - : {} + const { staticGenerationAsyncStorage } = + require('./static-generation-async-storage.external') as typeof import('./static-generation-async-storage.external') + + let clientSearchParams: Promise + let clientParams: Promise + // We are going to instrument the searchParams prop with tracking for the + // appropriate context. We wrap differently in prerendering vs rendering + const store = staticGenerationAsyncStorage.getStore() + if (!store) { + throw new InvariantError( + 'Expected staticGenerationStore to exist when handling searchParams in a client Page.' + ) + } + + if (store.isStaticGeneration) { + // We are in a prerender context + const { createPrerenderSearchParamsFromClient } = + require('../../server/request/search-params') as typeof import('../../server/request/search-params') + clientSearchParams = createPrerenderSearchParamsFromClient(store) + + const { createPrerenderParamsFromClient } = + require('../../server/request/params') as typeof import('../../server/request/params') + + clientParams = createPrerenderParamsFromClient(params, store) + } else { + const { createRenderSearchParamsFromClient } = + require('../../server/request/search-params') as typeof import('../../server/request/search-params') + clientSearchParams = createRenderSearchParamsFromClient( + searchParams, + store + ) + const { createRenderParamsFromClient } = + require('../../server/request/params') as typeof import('../../server/request/params') + clientParams = createRenderParamsFromClient(params, store) + } + + return + } else { + const { createRenderSearchParamsFromClient } = + require('../../server/request/search-params.browser') as typeof import('../../server/request/search-params.browser') + const clientSearchParams = createRenderSearchParamsFromClient(searchParams) + const { createRenderParamsFromClient } = + require('../../server/request/params.browser') as typeof import('../../server/request/params.browser') + const clientParams = createRenderParamsFromClient(params) + + return } - return } diff --git a/packages/next/src/client/components/client-segment.tsx b/packages/next/src/client/components/client-segment.tsx index a99bb021a50fc..f7bcf8a907937 100644 --- a/packages/next/src/client/components/client-segment.tsx +++ b/packages/next/src/client/components/client-segment.tsx @@ -1,21 +1,58 @@ 'use client' -type ClientSegmentRootProps = { - Component: React.ComponentType - props: { [props: string]: any } -} +import { InvariantError } from '../../shared/lib/invariant-error' + +import type { Params } from '../../server/request/params' +/** + * When the Page is a client component we send the params to this client wrapper + * where they are turned into dynamically tracked values before being passed to the actual Segment component. + * + * additionally we may send a promise representing params. We don't ever use this passed + * value but it can be necessary for the sender to send a Promise that doesn't resolve in certain situations + * such as when dynamicIO is enabled. It is up to the caller to decide if the promises are needed. + */ export function ClientSegmentRoot({ Component, - props, -}: ClientSegmentRootProps) { + slots, + params, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + promise, +}: { + Component: React.ComponentType + slots: { [key: string]: React.ReactNode } + params: Params + promise?: Promise +}) { if (typeof window === 'undefined') { - const { createDynamicallyTrackedParams } = - require('../../server/request/fallback-params') as typeof import('../../server/request/fallback-params') + const { staticGenerationAsyncStorage } = + require('./static-generation-async-storage.external') as typeof import('./static-generation-async-storage.external') + + let clientParams: Promise + // We are going to instrument the searchParams prop with tracking for the + // appropriate context. We wrap differently in prerendering vs rendering + const store = staticGenerationAsyncStorage.getStore() + if (!store) { + throw new InvariantError( + 'Expected staticGenerationStore to exist when handling params in a client segment such as a Layout or Template.' + ) + } + + const { createPrerenderParamsFromClient } = + require('../../server/request/params') as typeof import('../../server/request/params') - props.params = props.params - ? createDynamicallyTrackedParams(props.params) - : {} + if (store.isStaticGeneration) { + clientParams = createPrerenderParamsFromClient(params, store) + } else { + const { createRenderParamsFromClient } = + require('../../server/request/params') as typeof import('../../server/request/params') + clientParams = createRenderParamsFromClient(params, store) + } + return + } else { + const { createRenderParamsFromClient } = + require('../../server/request/params.browser') as typeof import('../../server/request/params.browser') + const clientParams = createRenderParamsFromClient(params) + return } - return } diff --git a/packages/next/src/client/components/navigation.ts b/packages/next/src/client/components/navigation.ts index b7ed000325179..ff1a6b88caa80 100644 --- a/packages/next/src/client/components/navigation.ts +++ b/packages/next/src/client/components/navigation.ts @@ -15,7 +15,7 @@ import { import { getSegmentValue } from './router-reducer/reducers/get-segment-value' import { PAGE_SEGMENT_KEY, DEFAULT_SEGMENT_KEY } from '../../shared/lib/segment' import { ReadonlyURLSearchParams } from './navigation.react-server' -import { trackFallbackParamAccessed } from '../../server/app-render/dynamic-rendering' +import { useDynamicRouteParams } from '../../server/app-render/dynamic-rendering' /** * A [Client Component](https://nextjs.org/docs/app/building-your-application/rendering/client-components) hook @@ -65,27 +65,6 @@ export function useSearchParams(): ReadonlyURLSearchParams { return readonlySearchParams } -function trackParamsAccessed(expression: string) { - if (typeof window === 'undefined') { - // AsyncLocalStorage should not be included in the client bundle. - const { staticGenerationAsyncStorage } = - require('./static-generation-async-storage.external') as typeof import('./static-generation-async-storage.external') - - const staticGenerationStore = staticGenerationAsyncStorage.getStore() - - if ( - staticGenerationStore && - staticGenerationStore.isStaticGeneration && - staticGenerationStore.fallbackRouteParams && - staticGenerationStore.fallbackRouteParams.size > 0 - ) { - // There are fallback route params, we should track these as dynamic - // accesses. - trackFallbackParamAccessed(staticGenerationStore, expression) - } - } -} - /** * A [Client Component](https://nextjs.org/docs/app/building-your-application/rendering/client-components) hook * that lets you read the current URL's pathname. @@ -105,7 +84,7 @@ function trackParamsAccessed(expression: string) { */ // Client components API export function usePathname(): string { - trackParamsAccessed('usePathname()') + useDynamicRouteParams('usePathname()') // In the case where this is `null`, the compat types added in `next-env.d.ts` // will add a new overload that changes the return type to include `null`. @@ -165,21 +144,19 @@ export function useRouter(): AppRouterInstance { */ // Client components API export function useParams(): T { - trackParamsAccessed('useParams()') + useDynamicRouteParams('useParams()') return useContext(PathParamsContext) as T } /** Get the canonical parameters from the current level to the leaf node. */ // Client components API -export function getSelectedLayoutSegmentPath( +function getSelectedLayoutSegmentPath( tree: FlightRouterState, parallelRouteKey: string, first = true, segmentPath: string[] = [] ): string[] { - trackParamsAccessed('getSelectedLayoutSegmentPath()') - let node: FlightRouterState if (first) { // Use the provided parallel route key on the first parallel route @@ -238,7 +215,7 @@ export function getSelectedLayoutSegmentPath( export function useSelectedLayoutSegments( parallelRouteKey: string = 'children' ): string[] { - trackParamsAccessed('useSelectedLayoutSegments()') + useDynamicRouteParams('useSelectedLayoutSegments()') const context = useContext(LayoutRouterContext) // @ts-expect-error This only happens in `pages`. Type is overwritten in navigation.d.ts @@ -269,7 +246,7 @@ export function useSelectedLayoutSegments( export function useSelectedLayoutSegment( parallelRouteKey: string = 'children' ): string | null { - trackParamsAccessed('useSelectedLayoutSegment()') + useDynamicRouteParams('useSelectedLayoutSegment()') const selectedLayoutSegments = useSelectedLayoutSegments(parallelRouteKey) diff --git a/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/component-stack-pseudo-html.tsx b/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/component-stack-pseudo-html.tsx index 6b2c858d6f346..58b2e2d199665 100644 --- a/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/component-stack-pseudo-html.tsx +++ b/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/component-stack-pseudo-html.tsx @@ -66,7 +66,7 @@ export function PseudoHtmlDiff({ firstContent: string secondContent: string reactOutputComponentDiff: string | undefined - hydrationMismatchType: 'tag' | 'text' + hydrationMismatchType: 'tag' | 'text' | 'text-in-tag' } & React.HTMLAttributes) { const isHtmlTagsWarning = hydrationMismatchType === 'tag' const isReactHydrationDiff = !!reactOutputComponentDiff diff --git a/packages/next/src/client/components/react-dev-overlay/internal/helpers/hydration-error-info.ts b/packages/next/src/client/components/react-dev-overlay/internal/helpers/hydration-error-info.ts index f99a6e41c55d0..3ca474eeedb1a 100644 --- a/packages/next/src/client/components/react-dev-overlay/internal/helpers/hydration-error-info.ts +++ b/packages/next/src/client/components/react-dev-overlay/internal/helpers/hydration-error-info.ts @@ -17,21 +17,62 @@ export const hydrationErrorState: HydrationErrorState = {} // https://github.com/facebook/react/blob/main/packages/react-dom/src/__tests__/ReactDOMHydrationDiff-test.js used as a reference const htmlTagsWarnings = new Set([ - 'In HTML, %s cannot be a child of <%s>.%s\nThis will cause a hydration error.%s', - 'In HTML, %s cannot be a descendant of <%s>.\nThis will cause a hydration error.%s', - 'In HTML, text nodes cannot be a child of <%s>.\nThis will cause a hydration error.', - "In HTML, whitespace text nodes cannot be a child of <%s>. Make sure you don't have any extra whitespace between tags on each line of your source code.\nThis will cause a hydration error.", + 'Warning: In HTML, %s cannot be a child of <%s>.%s\nThis will cause a hydration error.%s', + 'Warning: In HTML, %s cannot be a descendant of <%s>.\nThis will cause a hydration error.%s', + 'Warning: In HTML, text nodes cannot be a child of <%s>.\nThis will cause a hydration error.', + "Warning: In HTML, whitespace text nodes cannot be a child of <%s>. Make sure you don't have any extra whitespace between tags on each line of your source code.\nThis will cause a hydration error.", + 'Warning: Expected server HTML to contain a matching <%s> in <%s>.%s', + 'Warning: Did not expect server HTML to contain a <%s> in <%s>.%s', ]) +const textAndTagsMismatchWarnings = new Set([ + 'Warning: Expected server HTML to contain a matching text node for "%s" in <%s>.%s', + 'Warning: Did not expect server HTML to contain the text node "%s" in <%s>.%s', +]) +const textMismatchWarning = + 'Warning: Text content did not match. Server: "%s" Client: "%s"%s' + +export const getHydrationWarningType = ( + message: NullableText +): 'tag' | 'text' | 'text-in-tag' => { + if (typeof message !== 'string') { + // TODO: Doesn't make sense to treat no message as a hydration error message. + // We should bail out somewhere earlier. + return 'text' + } + + const normalizedMessage = message.startsWith('Warning: ') + ? message + : `Warning: ${message}` + + if (isHtmlTagsWarning(normalizedMessage)) return 'tag' + if (isTextInTagsMismatchWarning(normalizedMessage)) return 'text-in-tag' -export const getHydrationWarningType = (msg: NullableText): 'tag' | 'text' => { - if (isHtmlTagsWarning(msg)) return 'tag' return 'text' } -const isHtmlTagsWarning = (msg: NullableText) => - Boolean(msg && htmlTagsWarnings.has(msg)) +const isHtmlTagsWarning = (message: string) => htmlTagsWarnings.has(message) -const isKnownHydrationWarning = (msg: NullableText) => isHtmlTagsWarning(msg) +const isTextMismatchWarning = (message: string) => + textMismatchWarning === message +const isTextInTagsMismatchWarning = (msg: string) => + textAndTagsMismatchWarnings.has(msg) + +const isKnownHydrationWarning = (message: NullableText) => { + if (typeof message !== 'string') { + return false + } + // React 18 has the `Warning: ` prefix. + // React 19 does not. + const normalizedMessage = message.startsWith('Warning: ') + ? message + : `Warning: ${message}` + + return ( + isHtmlTagsWarning(normalizedMessage) || + isTextInTagsMismatchWarning(normalizedMessage) || + isTextMismatchWarning(normalizedMessage) + ) +} export const getReactHydrationDiffSegments = (msg: NullableText) => { if (msg) { diff --git a/packages/next/src/client/components/static-generation-async-storage.external.ts b/packages/next/src/client/components/static-generation-async-storage.external.ts index f14af9874144b..6df289fcd8c84 100644 --- a/packages/next/src/client/components/static-generation-async-storage.external.ts +++ b/packages/next/src/client/components/static-generation-async-storage.external.ts @@ -47,6 +47,7 @@ export interface StaticGenerationStore { forceStatic?: boolean dynamicShouldError?: boolean pendingRevalidates?: Record> + pendingRevalidateWrites?: Array> // This is like pendingRevalidates but isn't used for deduping. dynamicUsageDescription?: string dynamicUsageStack?: string diff --git a/packages/next/src/client/legacy/image.tsx b/packages/next/src/client/legacy/image.tsx index aa79f37ac05f5..40116cea747e8 100644 --- a/packages/next/src/client/legacy/image.tsx +++ b/packages/next/src/client/legacy/image.tsx @@ -9,6 +9,8 @@ import React, { useState, type JSX, } from 'react' +import * as ReactDOM from 'react-dom' +import Head from '../../shared/lib/head' import { imageConfigDefault, VALID_LOADERS, @@ -26,6 +28,8 @@ function normalizeSrc(src: string): string { return src[0] === '/' ? src.slice(1) : src } +const supportsFloat = typeof ReactDOM.preload === 'function' + const configEnv = process.env.__NEXT_IMAGE_OPTS as any as ImageConfigComplete const loadedImageURLs = new Set() const allImgs = new Map< @@ -978,6 +982,20 @@ export default function Image({ } } + const linkProps: + | React.DetailedHTMLProps< + React.LinkHTMLAttributes, + HTMLLinkElement + > + | undefined = supportsFloat + ? undefined + : { + imageSrcSet: imgAttributes.srcSet, + imageSizes: imgAttributes.sizes, + crossOrigin: rest.crossOrigin, + referrerPolicy: rest.referrerPolicy, + } + const useLayoutEffect = typeof window === 'undefined' ? React.useEffect : React.useLayoutEffect const onLoadingCompleteRef = useRef(onLoadingComplete) @@ -1044,6 +1062,27 @@ export default function Image({ ) : null} + {!supportsFloat && priority ? ( + // Note how we omit the `href` attribute, as it would only be relevant + // for browsers that do not support `imagesrcset`, and in those cases + // it would likely cause the incorrect image to be preloaded. + // + // https://html.spec.whatwg.org/multipage/semantics.html#attr-link-imagesrcset + + + + ) : null} ) } diff --git a/packages/next/src/client/use-merged-ref.ts b/packages/next/src/client/use-merged-ref.ts index 7fce7fb1f0ee1..65bb8dd4dcb42 100644 --- a/packages/next/src/client/use-merged-ref.ts +++ b/packages/next/src/client/use-merged-ref.ts @@ -1,29 +1,34 @@ -import { useMemo, type Ref } from 'react' +import { useMemo, useRef, type Ref } from 'react' +// This is a compatibility hook to support React 18 and 19 refs. +// In 19, a cleanup function from refs may be returned. +// In 18, returning a cleanup function creates a warning. +// Since we take userspace refs, we don't know ahead of time if a cleanup function will be returned. +// This implements cleanup functions with the old behavior in 18. +// We know refs are always called alternating with `null` and then `T`. +// So a call with `null` means we need to call the previous cleanup functions. export function useMergedRef( refA: Ref, refB: Ref ): Ref { - return useMemo(() => mergeRefs(refA, refB), [refA, refB]) -} + const cleanupA = useRef<() => void>(() => {}) + const cleanupB = useRef<() => void>(() => {}) -export function mergeRefs( - refA: Ref, - refB: Ref -): Ref { - if (!refA || !refB) { - return refA || refB - } - - return (current: TElement) => { - const cleanupA = applyRef(refA, current) - const cleanupB = applyRef(refB, current) + return useMemo(() => { + if (!refA || !refB) { + return refA || refB + } - return () => { - cleanupA() - cleanupB() + return (current: TElement | null): void => { + if (current === null) { + cleanupA.current() + cleanupB.current() + } else { + cleanupA.current = applyRef(refA, current) + cleanupB.current = applyRef(refB, current) + } } - } + }, [refA, refB]) } function applyRef( diff --git a/packages/next/src/export/routes/pages.ts b/packages/next/src/export/routes/pages.ts index eb42799bb9689..749b0654584ef 100644 --- a/packages/next/src/export/routes/pages.ts +++ b/packages/next/src/export/routes/pages.ts @@ -56,6 +56,12 @@ export async function exportPagesPage( hybrid: components.pageConfig?.amp === 'hybrid', } + if (!ampValidatorPath) { + ampValidatorPath = require.resolve( + 'next/dist/compiled/amphtml-validator/validator_wasm.js' + ) + } + const inAmpMode = isInAmpMode(ampState) const hybridAmp = ampState.hybrid diff --git a/packages/next/src/lib/metadata/metadata.tsx b/packages/next/src/lib/metadata/metadata.tsx index ba79f472dad0f..bc982d5e66ab4 100644 --- a/packages/next/src/lib/metadata/metadata.tsx +++ b/packages/next/src/lib/metadata/metadata.tsx @@ -4,6 +4,7 @@ import type { GetDynamicParamFromSegment, } from '../../server/app-render/app-render' import type { LoaderTree } from '../../server/lib/app-dir-module' +import type { CreateServerParamsForMetadata } from '../../server/request/params' import React from 'react' import { @@ -30,7 +31,6 @@ import type { } from './types/metadata-interface' import { isNotFoundError } from '../../client/components/not-found' import type { MetadataContext } from './types/resolvers' -import type { CreateDynamicallyTrackedParams } from '../../server/request/fallback-params' import type { StaticGenerationStore } from '../../client/components/static-generation-async-storage.external' import { trackFallbackParamAccessed } from '../../server/app-render/dynamic-rendering' @@ -84,24 +84,22 @@ export function createTrackedMetadataContext( // and the error will be caught by the error boundary and trigger fallbacks. export function createMetadataComponents({ tree, - query, + searchParams, metadataContext, getDynamicParamFromSegment, appUsingSizeAdjustment, errorType, - createDynamicallyTrackedSearchParams, - createDynamicallyTrackedParams, + createServerParamsForMetadata, + staticGenerationStore, }: { tree: LoaderTree - query: ParsedUrlQuery + searchParams: Promise metadataContext: MetadataContext getDynamicParamFromSegment: GetDynamicParamFromSegment appUsingSizeAdjustment: boolean errorType?: 'not-found' | 'redirect' - createDynamicallyTrackedParams: CreateDynamicallyTrackedParams - createDynamicallyTrackedSearchParams: ( - searchParams: ParsedUrlQuery - ) => ParsedUrlQuery + createServerParamsForMetadata: CreateServerParamsForMetadata + staticGenerationStore: StaticGenerationStore }): [React.ComponentType, () => Promise] { let currentMetadataReady: | null @@ -113,11 +111,11 @@ export function createMetadataComponents({ async function MetadataTree() { const pendingMetadata = getResolvedMetadata( tree, - query, + searchParams, getDynamicParamFromSegment, metadataContext, - createDynamicallyTrackedSearchParams, - createDynamicallyTrackedParams, + createServerParamsForMetadata, + staticGenerationStore, errorType ) @@ -177,18 +175,15 @@ export function createMetadataComponents({ async function getResolvedMetadata( tree: LoaderTree, - query: ParsedUrlQuery, + searchParams: Promise, getDynamicParamFromSegment: GetDynamicParamFromSegment, metadataContext: MetadataContext, - createDynamicallyTrackedSearchParams: ( - searchParams: ParsedUrlQuery - ) => ParsedUrlQuery, - createDynamicallyTrackedParams: CreateDynamicallyTrackedParams, + createServerParamsForMetadata: CreateServerParamsForMetadata, + staticGenerationStore: StaticGenerationStore, errorType?: 'not-found' | 'redirect' ): Promise<[any, Array]> { const errorMetadataItem: [null, null, null] = [null, null, null] const errorConvention = errorType === 'redirect' ? undefined : errorType - const searchParams = createDynamicallyTrackedSearchParams(query) const [error, metadata, viewport] = await resolveMetadata({ tree, @@ -199,7 +194,8 @@ async function getResolvedMetadata( getDynamicParamFromSegment, errorConvention, metadataContext, - createDynamicallyTrackedParams, + createServerParamsForMetadata, + staticGenerationStore, }) if (!error) { return [null, createMetadataElements(metadata, viewport)] @@ -219,7 +215,8 @@ async function getResolvedMetadata( getDynamicParamFromSegment, errorConvention: 'not-found', metadataContext, - createDynamicallyTrackedParams, + createServerParamsForMetadata, + staticGenerationStore, }) return [ notFoundMetadataError || error, diff --git a/packages/next/src/lib/metadata/resolve-metadata.ts b/packages/next/src/lib/metadata/resolve-metadata.ts index e0eede8485590..508643c49c98c 100644 --- a/packages/next/src/lib/metadata/resolve-metadata.ts +++ b/packages/next/src/lib/metadata/resolve-metadata.ts @@ -49,7 +49,11 @@ import { getTracer } from '../../server/lib/trace/tracer' import { ResolveMetadataSpan } from '../../server/lib/trace/constants' import { PAGE_SEGMENT_KEY } from '../../shared/lib/segment' import * as Log from '../../build/output/log' -import type { CreateDynamicallyTrackedParams } from '../../server/request/fallback-params' +import type { StaticGenerationStore } from '../../client/components/static-generation-async-storage.external' +import type { + Params, + CreateServerParamsForMetadata, +} from '../../server/request/params' type StaticIcons = Pick @@ -479,10 +483,11 @@ export async function resolveMetadataItems({ getDynamicParamFromSegment, searchParams, errorConvention, - createDynamicallyTrackedParams, + createServerParamsForMetadata, + staticGenerationStore, }: { tree: LoaderTree - parentParams: { [key: string]: any } + parentParams: Params metadataItems: MetadataItems errorMetadataItem: MetadataItems[number] /** Provided tree can be nested subtree, this argument says what is the path of such subtree */ @@ -490,7 +495,8 @@ export async function resolveMetadataItems({ getDynamicParamFromSegment: GetDynamicParamFromSegment searchParams: ParsedUrlQuery errorConvention: 'not-found' | undefined - createDynamicallyTrackedParams: CreateDynamicallyTrackedParams + createServerParamsForMetadata: CreateServerParamsForMetadata + staticGenerationStore: StaticGenerationStore }): Promise { const [segment, parallelRoutes, { page }] = tree const currentTreePrefix = [...treePrefix, segment] @@ -501,17 +507,18 @@ export async function resolveMetadataItems({ /** * Create object holding the parent params and current params */ - const currentParams = - // Handle null case where dynamic param is optional - segmentParam && segmentParam.value !== null - ? { - ...parentParams, - [segmentParam.param]: segmentParam.value, - } - : // Pass through parent params to children - parentParams + let currentParams = parentParams + if (segmentParam && segmentParam.value !== null) { + currentParams = { + ...parentParams, + [segmentParam.param]: segmentParam.value, + } + } - const params = createDynamicallyTrackedParams(currentParams) + const params = createServerParamsForMetadata( + currentParams, + staticGenerationStore + ) let layerProps: LayoutProps | PageProps if (isPage) { @@ -548,7 +555,8 @@ export async function resolveMetadataItems({ searchParams, getDynamicParamFromSegment, errorConvention, - createDynamicallyTrackedParams, + createServerParamsForMetadata, + staticGenerationStore, }) } @@ -891,10 +899,11 @@ export async function resolveMetadata({ searchParams, errorConvention, metadataContext, - createDynamicallyTrackedParams, + createServerParamsForMetadata, + staticGenerationStore, }: { tree: LoaderTree - parentParams: { [key: string]: any } + parentParams: Params metadataItems: MetadataItems errorMetadataItem: MetadataItems[number] /** Provided tree can be nested subtree, this argument says what is the path of such subtree */ @@ -903,7 +912,8 @@ export async function resolveMetadata({ searchParams: { [key: string]: any } errorConvention: 'not-found' | undefined metadataContext: MetadataContext - createDynamicallyTrackedParams: CreateDynamicallyTrackedParams + createServerParamsForMetadata: CreateServerParamsForMetadata + staticGenerationStore: StaticGenerationStore }): Promise<[any, ResolvedMetadata, ResolvedViewport]> { const resolvedMetadataItems = await resolveMetadataItems({ tree, @@ -913,7 +923,8 @@ export async function resolveMetadata({ getDynamicParamFromSegment, searchParams, errorConvention, - createDynamicallyTrackedParams, + createServerParamsForMetadata, + staticGenerationStore, }) let error let metadata: ResolvedMetadata = createDefaultMetadata() diff --git a/packages/next/src/server/ReactDOMServerPages.d.ts b/packages/next/src/server/ReactDOMServerPages.d.ts new file mode 100644 index 0000000000000..4d4f000bdf487 --- /dev/null +++ b/packages/next/src/server/ReactDOMServerPages.d.ts @@ -0,0 +1 @@ +export * from 'react-dom/server.edge' diff --git a/packages/next/src/server/ReactDOMServerPages.js b/packages/next/src/server/ReactDOMServerPages.js new file mode 100644 index 0000000000000..1fa8fef05963e --- /dev/null +++ b/packages/next/src/server/ReactDOMServerPages.js @@ -0,0 +1,17 @@ +let ReactDOMServer + +try { + ReactDOMServer = require('react-dom/server.edge') +} catch (error) { + if ( + error.code !== 'MODULE_NOT_FOUND' && + error.code !== 'ERR_PACKAGE_PATH_NOT_EXPORTED' + ) { + throw error + } + // In React versions without react-dom/server.edge, the browser build works in Node.js. + // The Node.js build does not support renderToReadableStream. + ReactDOMServer = require('react-dom/server.browser') +} + +module.exports = ReactDOMServer diff --git a/packages/next/src/server/app-render/action-handler.ts b/packages/next/src/server/app-render/action-handler.ts index 95cdaccb48ae4..b2e4f86df9c6e 100644 --- a/packages/next/src/server/app-render/action-handler.ts +++ b/packages/next/src/server/app-render/action-handler.ts @@ -122,6 +122,7 @@ async function addRevalidationHeader( staticGenerationStore.revalidatedTags || [] ), ...Object.values(staticGenerationStore.pendingRevalidates || {}), + ...(staticGenerationStore.pendingRevalidateWrites || []), ]) // If a tag was revalidated, the client router needs to invalidate all the @@ -533,6 +534,7 @@ export async function handleAction({ staticGenerationStore.revalidatedTags || [] ), ...Object.values(staticGenerationStore.pendingRevalidates || {}), + ...(staticGenerationStore.pendingRevalidateWrites || []), ]) const promise = Promise.reject(error) @@ -565,7 +567,8 @@ export async function handleAction({ 'Cache-Control', 'no-cache, no-store, max-age=0, must-revalidate' ) - let bound = [] + + let boundActionArguments: unknown[] = [] const { actionAsyncStorage } = ComponentMod @@ -618,14 +621,18 @@ export async function handleAction({ // TODO-APP: Add streaming support const formData = await req.request.formData() if (isFetchAction) { - bound = await decodeReply(formData, serverModuleMap) + boundActionArguments = await decodeReply(formData, serverModuleMap) } else { const action = await decodeAction(formData, serverModuleMap) if (typeof action === 'function') { // Only warn if it's a server action, otherwise skip for other post requests warnBadServerActionRequest() const actionReturnedState = await action() - formState = decodeFormState(actionReturnedState, formData) + formState = decodeFormState( + actionReturnedState, + formData, + serverModuleMap + ) } // Skip the fetch path @@ -657,9 +664,12 @@ export async function handleAction({ if (isURLEncodedAction) { const formData = formDataFromSearchQueryString(actionData) - bound = await decodeReply(formData, serverModuleMap) + boundActionArguments = await decodeReply(formData, serverModuleMap) } else { - bound = await decodeReply(actionData, serverModuleMap) + boundActionArguments = await decodeReply( + actionData, + serverModuleMap + ) } } } else if ( @@ -721,7 +731,10 @@ export async function handleAction({ body.pipe(busboy) - bound = await decodeReplyFromBusboy(busboy, serverModuleMap) + boundActionArguments = await decodeReplyFromBusboy( + busboy, + serverModuleMap + ) } else { // React doesn't yet publish a busboy version of decodeAction // so we polyfill the parsing of FormData. @@ -777,9 +790,12 @@ export async function handleAction({ if (isURLEncodedAction) { const formData = formDataFromSearchQueryString(actionData) - bound = await decodeReply(formData, serverModuleMap) + boundActionArguments = await decodeReply(formData, serverModuleMap) } else { - bound = await decodeReply(actionData, serverModuleMap) + boundActionArguments = await decodeReply( + actionData, + serverModuleMap + ) } } } else { @@ -817,7 +833,7 @@ export async function handleAction({ actionId! ] - const returnVal = await actionHandler.apply(null, bound) + const returnVal = await actionHandler.apply(null, boundActionArguments) // For form actions, we need to continue rendering the page. if (isFetchAction) { @@ -924,6 +940,7 @@ export async function handleAction({ staticGenerationStore.revalidatedTags || [] ), ...Object.values(staticGenerationStore.pendingRevalidates || {}), + ...(staticGenerationStore.pendingRevalidateWrites || []), ]) const promise = Promise.reject(err) try { diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index 71252f1cd9f76..d4c3d77286f0d 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -22,7 +22,7 @@ import type { DeepReadonly } from '../../shared/lib/deep-readonly' import type { BaseNextRequest, BaseNextResponse } from '../base-http' import type { IncomingHttpHeaders } from 'http' -import React, { type ErrorInfo, type JSX } from 'react' +import React, { type JSX } from 'react' import RenderResult, { type AppPageRenderResultMetadata, @@ -385,8 +385,8 @@ async function generateDynamicRSCPayload( const { componentMod: { tree: loaderTree, - createDynamicallyTrackedSearchParams, - createDynamicallyTrackedParams, + createServerSearchParamsForMetadata, + createServerParamsForMetadata, }, getDynamicParamFromSegment, appUsingSizeAdjustment, @@ -400,9 +400,13 @@ async function generateDynamicRSCPayload( if (!options?.skipFlight) { const preloadCallbacks: PreloadCallbacks = [] + const searchParams = createServerSearchParamsForMetadata( + query, + staticGenerationStore + ) const [MetadataTree, getMetadataReady] = createMetadataComponents({ tree: loaderTree, - query, + searchParams, metadataContext: createTrackedMetadataContext( url.pathname, ctx.renderOpts, @@ -410,8 +414,8 @@ async function generateDynamicRSCPayload( ), getDynamicParamFromSegment, appUsingSizeAdjustment, - createDynamicallyTrackedSearchParams, - createDynamicallyTrackedParams, + createServerParamsForMetadata, + staticGenerationStore, }) flightData = ( await walkTreeWithFlightRouterState({ @@ -506,7 +510,6 @@ async function generateDynamicFlightRenderResult( ctx.clientReferenceManifest.clientModules, { onError, - nonce: ctx.nonce, } ) @@ -547,8 +550,8 @@ async function getRSCPayload( appUsingSizeAdjustment, componentMod: { GlobalError, - createDynamicallyTrackedSearchParams, - createDynamicallyTrackedParams, + createServerSearchParamsForMetadata, + createServerParamsForMetadata, }, requestStore: { url }, staticGenerationStore, @@ -559,10 +562,14 @@ async function getRSCPayload( query ) + const searchParams = createServerSearchParamsForMetadata( + query, + staticGenerationStore + ) const [MetadataTree, getMetadataReady] = createMetadataComponents({ tree, errorType: is404 ? 'not-found' : undefined, - query, + searchParams, metadataContext: createTrackedMetadataContext( url.pathname, ctx.renderOpts, @@ -570,8 +577,8 @@ async function getRSCPayload( ), getDynamicParamFromSegment, appUsingSizeAdjustment, - createDynamicallyTrackedSearchParams, - createDynamicallyTrackedParams, + createServerParamsForMetadata, + staticGenerationStore, }) const preloadCallbacks: PreloadCallbacks = [] @@ -643,24 +650,29 @@ async function getErrorRSCPayload( appUsingSizeAdjustment, componentMod: { GlobalError, - createDynamicallyTrackedSearchParams, - createDynamicallyTrackedParams, + createServerSearchParamsForMetadata, + createServerParamsForMetadata, }, requestStore: { url }, requestId, + staticGenerationStore, } = ctx + const searchParams = createServerSearchParamsForMetadata( + query, + staticGenerationStore + ) const [MetadataTree] = createMetadataComponents({ tree, + searchParams, // We create an untracked metadata context here because we can't postpone // again during the error render. metadataContext: createMetadataContext(url.pathname, ctx.renderOpts), errorType, - query, getDynamicParamFromSegment, appUsingSizeAdjustment, - createDynamicallyTrackedSearchParams, - createDynamicallyTrackedParams, + createServerParamsForMetadata, + staticGenerationStore, }) const initialHead = ( @@ -1070,12 +1082,16 @@ async function renderToHTMLOrFlightImpl( metadata, } // If we have pending revalidates, wait until they are all resolved. - if (staticGenerationStore.pendingRevalidates) { + if ( + staticGenerationStore.pendingRevalidates || + staticGenerationStore.pendingRevalidateWrites + ) { options.waitUntil = Promise.all([ staticGenerationStore.incrementalCache?.revalidateTag( staticGenerationStore.revalidatedTags || [] ), ...Object.values(staticGenerationStore.pendingRevalidates || {}), + ...(staticGenerationStore.pendingRevalidateWrites || []), ]) } @@ -1175,12 +1191,16 @@ async function renderToHTMLOrFlightImpl( ) // If we have pending revalidates, wait until they are all resolved. - if (staticGenerationStore.pendingRevalidates) { + if ( + staticGenerationStore.pendingRevalidates || + staticGenerationStore.pendingRevalidateWrites + ) { options.waitUntil = Promise.all([ staticGenerationStore.incrementalCache?.revalidateTag( staticGenerationStore.revalidatedTags || [] ), ...Object.values(staticGenerationStore.pendingRevalidates || {}), + ...(staticGenerationStore.pendingRevalidateWrites || []), ]) } @@ -1380,7 +1400,6 @@ async function renderToStream( clientReferenceManifest.clientModules, { onError: serverComponentsErrorHandler, - nonce: ctx.nonce, } ) ) @@ -1588,7 +1607,6 @@ async function renderToStream( clientReferenceManifest.clientModules, { onError: serverComponentsErrorHandler, - nonce: ctx.nonce, } ) @@ -1824,12 +1842,14 @@ async function prerenderToStream( let flightController = new AbortController() // We're not going to use the result of this render because the only time it could be used // is if it completes in a microtask and that's likely very rare for any non-trivial app - const firstAttemptRSCPayload = await getRSCPayload( + const firstAttemptRSCPayload = await prerenderAsyncStorage.run( + prospectiveRenderPrerenderStore, + getRSCPayload, tree, ctx, res.statusCode === 404 ) - function voidOnError() {} + ;( prerenderAsyncStorage.run( // The store to scope @@ -1840,9 +1860,8 @@ async function prerenderToStream( firstAttemptRSCPayload, clientReferenceManifest.clientModules, { - nonce: ctx.nonce, // This render will be thrown away so we don't need to track errors or postpones - onError: voidOnError, + onError: undefined, onPostpone: undefined, // we don't care to track postpones during the prospective render because we need // to always do a final render anyway @@ -1874,13 +1893,13 @@ async function prerenderToStream( } let reactServerIsDynamic = false - function onError(err: unknown, errorInfo: ErrorInfo) { + function onError(err: unknown) { if (err === abortReason || isPrerenderInterruptedError(err)) { reactServerIsDynamic = true return } - return serverComponentsErrorHandler(err, errorInfo) + return serverComponentsErrorHandler(err) } function onPostpone(reason: string) { @@ -1891,7 +1910,9 @@ async function prerenderToStream( reactServerIsDynamic = true } } - const finalAttemptRSCPayload = await getRSCPayload( + const finalAttemptRSCPayload = await prerenderAsyncStorage.run( + finalRenderPrerenderStore, + getRSCPayload, tree, ctx, res.statusCode === 404 @@ -1909,7 +1930,6 @@ async function prerenderToStream( finalAttemptRSCPayload, clientReferenceManifest.clientModules, { - nonce: ctx.nonce, onError, onPostpone, signal: flightController.signal, @@ -1938,13 +1958,13 @@ async function prerenderToStream( dynamicTracking, } let SSRIsDynamic = false - function SSROnError(err: unknown, errorInfo: unknown) { + function SSROnError(err: unknown) { if (err === abortReason || isPrerenderInterruptedError(err)) { SSRIsDynamic = true return } - return htmlRendererErrorHandler(err, errorInfo) + return htmlRendererErrorHandler(err) } function SSROnPostpone(reason: string) { @@ -2109,13 +2129,13 @@ async function prerenderToStream( let flightController = new AbortController() let reactServerIsDynamic = false - function onError(err: unknown, errorInfo: ErrorInfo) { + function onError(err: unknown) { if (err === abortReason || isPrerenderInterruptedError(err)) { reactServerIsDynamic = true return } - return serverComponentsErrorHandler(err, errorInfo) + return serverComponentsErrorHandler(err) } dynamicTracking = createDynamicTrackingState( @@ -2132,7 +2152,9 @@ async function prerenderToStream( dynamicTracking, } - const firstAttemptRSCPayload = await getRSCPayload( + const firstAttemptRSCPayload = await prerenderAsyncStorage.run( + prospectiveRenderPrerenderStore, + getRSCPayload, tree, ctx, res.statusCode === 404 @@ -2149,7 +2171,6 @@ async function prerenderToStream( firstAttemptRSCPayload, clientReferenceManifest.clientModules, { - nonce: ctx.nonce, onError, signal: flightController.signal, } @@ -2197,7 +2218,9 @@ async function prerenderToStream( dynamicTracking, } - const finalAttemptRSCPayload = await getRSCPayload( + const finalAttemptRSCPayload = await prerenderAsyncStorage.run( + finalRenderPrerenderStore, + getRSCPayload, tree, ctx, res.statusCode === 404 @@ -2216,7 +2239,6 @@ async function prerenderToStream( finalAttemptRSCPayload, clientReferenceManifest.clientModules, { - nonce: ctx.nonce, onError, signal: flightController.signal, } @@ -2254,13 +2276,13 @@ async function prerenderToStream( dynamicTracking, } let SSRIsDynamic = false - function SSROnError(err: unknown, errorInfo: unknown) { + function SSROnError(err: unknown) { if (err === abortReason || isPrerenderInterruptedError(err)) { SSRIsDynamic = true return } - return htmlRendererErrorHandler(err, errorInfo) + return htmlRendererErrorHandler(err) } function SSROnPostpone(_: string) { // We don't really support postponing when PPR is off but since experimental react @@ -2353,7 +2375,13 @@ async function prerenderToStream( controller: null, dynamicTracking, } - const RSCPayload = await getRSCPayload(tree, ctx, res.statusCode === 404) + const RSCPayload = await prerenderAsyncStorage.run( + reactServerPrerenderStore, + getRSCPayload, + tree, + ctx, + res.statusCode === 404 + ) const reactServerResult = (reactServerPrerenderResult = await createReactServerPrerenderResultFromRender( prerenderAsyncStorage.run( @@ -2364,7 +2392,6 @@ async function prerenderToStream( clientReferenceManifest.clientModules, { onError: serverComponentsErrorHandler, - nonce: ctx.nonce, } ) )) @@ -2535,7 +2562,6 @@ async function prerenderToStream( clientReferenceManifest.clientModules, { onError: serverComponentsErrorHandler, - nonce: ctx.nonce, } ) )) @@ -2678,7 +2704,6 @@ async function prerenderToStream( clientReferenceManifest.clientModules, { onError: serverComponentsErrorHandler, - nonce: ctx.nonce, } ) diff --git a/packages/next/src/server/app-render/create-component-tree.tsx b/packages/next/src/server/app-render/create-component-tree.tsx index e624de6177ee1..dfb0c2d39cae5 100644 --- a/packages/next/src/server/app-render/create-component-tree.tsx +++ b/packages/next/src/server/app-render/create-component-tree.tsx @@ -47,7 +47,10 @@ export function createComponentTree(props: { ) } -function errorMissingDefaultExport(pagePath: string, convention: string) { +function errorMissingDefaultExport( + pagePath: string, + convention: string +): never { throw new Error( `The default export is not a React Component in "${pagePath}/${convention}"` ) @@ -91,9 +94,10 @@ async function createComponentTreeInternal({ RenderFromTemplateContext, ClientPageRoot, ClientSegmentRoot, - createUntrackedSearchParams, - createDynamicallyTrackedSearchParams, - createDynamicallyTrackedParams, + createServerSearchParamsForServerPage, + createPrerenderSearchParamsForClientPage, + createServerParamsForServerSegment, + createPrerenderParamsForClientSegment, serverHooks: { DynamicServerError }, Postpone, }, @@ -308,15 +312,13 @@ async function createComponentTreeInternal({ const segmentParam = getDynamicParamFromSegment(segment) // Create object holding the parent params and current params - const currentParams = - // Handle null case where dynamic param is optional - segmentParam && segmentParam.value !== null - ? { - ...parentParams, - [segmentParam.param]: segmentParam.value, - } - : // Pass through parent params to children - parentParams + let currentParams: Params = parentParams + if (segmentParam && segmentParam.value !== null) { + currentParams = { + ...parentParams, + [segmentParam.param]: segmentParam.value, + } + } // Resolve the segment param const actualSegment = segmentParam ? segmentParam.treeSegment : segment @@ -514,10 +516,6 @@ async function createComponentTreeInternal({ const isClientComponent = isClientReference(layoutOrPageMod) - // We avoid cloning this object because it gets consumed here exclusively. - const props: { [prop: string]: any } = parallelRouteProps - - // Assign params to props if ( process.env.NODE_ENV === 'development' && 'params' in parallelRouteProps @@ -529,23 +527,49 @@ async function createComponentTreeInternal({ } if (isPage) { + const PageComponent = Component // Assign searchParams to props if this is a page let pageElement: React.ReactNode if (isClientComponent) { - // When we are passing searchParams to a client component Page we don't want to track the dynamic access - // here in the RSC layer because the serialization will trigger a dynamic API usage. - // Instead we pass the searchParams untracked but we wrap the Page in a root client component - // which can among other things adds the dynamic tracking before rendering the page. - // @TODO make the root wrapper part of next-app-loader so we don't need the extra client component - props.params = currentParams - props.searchParams = createUntrackedSearchParams(query) - pageElement = + if (isStaticGeneration) { + const promiseOfParams = createPrerenderParamsForClientSegment( + currentParams, + staticGenerationStore + ) + const promiseOfSearchParams = createPrerenderSearchParamsForClientPage( + staticGenerationStore + ) + pageElement = ( + + ) + } else { + pageElement = ( + + ) + } } else { // If we are passing searchParams to a server component Page we need to track their usage in case // the current render mode tracks dynamic API usage. - props.params = createDynamicallyTrackedParams(currentParams) - props.searchParams = createDynamicallyTrackedSearchParams(query) - pageElement = + const params = createServerParamsForServerSegment( + currentParams, + staticGenerationStore + ) + const searchParams = createServerSearchParamsForServerPage( + query, + staticGenerationStore + ) + pageElement = ( + + ) } return [ actualSegment, @@ -564,65 +588,165 @@ async function createComponentTreeInternal({ loadingData, ] } else { + const SegmentComponent = Component + const isRootLayoutWithChildrenSlotAndAtLeastOneMoreSlot = rootLayoutAtThisLevel && 'children' in parallelRoutes && Object.keys(parallelRoutes).length > 1 - let serverSegment: React.ReactNode + let segmentNode: React.ReactNode + if (isClientComponent) { - props.params = currentParams - serverSegment = - } else { - props.params = createDynamicallyTrackedParams(currentParams) - serverSegment = - } + let clientSegment: React.ReactNode - let segmentNode: React.ReactNode - if (isRootLayoutWithChildrenSlotAndAtLeastOneMoreSlot) { - // TODO-APP: This is a hack to support unmatched parallel routes, which will throw `notFound()`. - // This ensures that a `NotFoundBoundary` is available for when that happens, - // but it's not ideal, as it needlessly invokes the `NotFound` component and renders the `RootLayout` twice. - // We should instead look into handling the fallback behavior differently in development mode so that it doesn't - // rely on the `NotFound` behavior. - segmentNode = ( - - - {layerAssets} - - {notFoundStyles} - - - - ) : undefined - } + if (isStaticGeneration) { + const promiseOfParams = createPrerenderParamsForClientSegment( + currentParams, + staticGenerationStore + ) + + clientSegment = ( + + ) + } else { + clientSegment = ( + + ) + } + + if (isRootLayoutWithChildrenSlotAndAtLeastOneMoreSlot) { + // TODO-APP: This is a hack to support unmatched parallel routes, which will throw `notFound()`. + // This ensures that a `NotFoundBoundary` is available for when that happens, + // but it's not ideal, as it needlessly invokes the `NotFound` component and renders the `RootLayout` twice. + // We should instead look into handling the fallback behavior differently in development mode so that it doesn't + // rely on the `NotFound` behavior. + if (NotFound) { + const notFoundParallelRouteProps = { + children: ( + <> + {notFoundStyles} + + + ), + } + const notfoundClientSegment = ( + + ) + + segmentNode = ( + + + {layerAssets} + {notfoundClientSegment} + + } + > + {layerAssets} + {clientSegment} + + + ) + } else { + segmentNode = ( + + + {layerAssets} + {clientSegment} + + + ) + } + } else { + segmentNode = ( + {layerAssets} - {serverSegment} - - - ) + {clientSegment} + + ) + } } else { - segmentNode = ( - - {layerAssets} - {serverSegment} - + const params = createServerParamsForServerSegment( + currentParams, + staticGenerationStore ) - } + let serverSegment = ( + + ) + + if (isRootLayoutWithChildrenSlotAndAtLeastOneMoreSlot) { + // TODO-APP: This is a hack to support unmatched parallel routes, which will throw `notFound()`. + // This ensures that a `NotFoundBoundary` is available for when that happens, + // but it's not ideal, as it needlessly invokes the `NotFound` component and renders the `RootLayout` twice. + // We should instead look into handling the fallback behavior differently in development mode so that it doesn't + // rely on the `NotFound` behavior. + segmentNode = ( + + + {layerAssets} + + {notFoundStyles} + + + + ) : undefined + } + > + {layerAssets} + {serverSegment} + + + ) + } else { + segmentNode = ( + + {layerAssets} + {serverSegment} + + ) + } + } // For layouts we just render the component return [ actualSegment, diff --git a/packages/next/src/server/app-render/create-error-handler.tsx b/packages/next/src/server/app-render/create-error-handler.tsx index 9db7cbbc49f18..10597355f73cb 100644 --- a/packages/next/src/server/app-render/create-error-handler.tsx +++ b/packages/next/src/server/app-render/create-error-handler.tsx @@ -10,7 +10,7 @@ declare global { var __next_log_error__: undefined | ((err: unknown) => void) } -type ErrorHandler = (err: unknown, errorInfo: unknown) => string | undefined +type ErrorHandler = (err: unknown, errorInfo?: unknown) => string | undefined export type DigestedError = Error & { digest: string } @@ -18,13 +18,18 @@ export function createFlightReactServerErrorHandler( dev: boolean, onReactServerRenderError: (err: any) => void ): ErrorHandler { - return (err: any, errorInfo: any) => { + return (err: any, errorInfo?: unknown) => { // If the error already has a digest, respect the original digest, // so it won't get re-generated into another new error. if (!err.digest) { // TODO-APP: look at using webcrypto instead. Requires a promise to be awaited. err.digest = stringHash( - err.message + (errorInfo?.stack || err.stack || '') + err.message + + (typeof errorInfo === 'object' && + errorInfo !== null && + 'stack' in errorInfo + ? errorInfo.stack + : err.stack || '') ).toString() } diff --git a/packages/next/src/server/app-render/dynamic-rendering.ts b/packages/next/src/server/app-render/dynamic-rendering.ts index 76cb82c0dab96..c0ac2cebd6ee3 100644 --- a/packages/next/src/server/app-render/dynamic-rendering.ts +++ b/packages/next/src/server/app-render/dynamic-rendering.ts @@ -27,7 +27,13 @@ import React from 'react' import { DynamicServerError } from '../../client/components/hooks-server-context' import { StaticGenBailoutError } from '../../client/components/static-generation-bailout' -import { prerenderAsyncStorage } from './prerender-async-storage.external' +import { + isDynamicIOPrerender, + prerenderAsyncStorage, + type PrerenderStore, +} from './prerender-async-storage.external' +import { staticGenerationAsyncStorage } from '../../client/components/static-generation-async-storage.external' +import { makeHangingPromise } from '../dynamic-rendering-utils' const hasPostpone = typeof React.unstable_postpone === 'function' @@ -107,7 +113,7 @@ export function markCurrentScopeAsDynamic( // current render because something dynamic is being used. // This won't throw so we still need to fall through to determine if/how we handle // this specific dynamic request. - abortRSCRender(prerenderStore.controller, store.route, expression) + abortRender(prerenderStore.controller, store.route, expression) errorWithTracking(prerenderStore.dynamicTracking, store.route, expression) } else if (prerenderStore.cacheSignal) { // we're prerendering with dynamicIO but we don't want to eagerly abort this @@ -116,9 +122,9 @@ export function markCurrentScopeAsDynamic( errorWithTracking(prerenderStore.dynamicTracking, store.route, expression) } else { postponeWithTracking( - prerenderStore.dynamicTracking, store.route, - expression + expression, + prerenderStore.dynamicTracking ) } } else { @@ -152,7 +158,7 @@ export function trackFallbackParamAccessed( const prerenderStore = prerenderAsyncStorage.getStore() if (!prerenderStore) return - postponeWithTracking(prerenderStore.dynamicTracking, store.route, expression) + postponeWithTracking(store.route, expression, prerenderStore.dynamicTracking) } /** @@ -185,7 +191,7 @@ export function trackDynamicDataAccessed( // current render because something dynamic is being used. // This won't throw so we still need to fall through to determine if/how we handle // this specific dynamic request. - abortRSCRender(prerenderStore.controller, store.route, expression) + abortRender(prerenderStore.controller, store.route, expression) errorWithTracking(prerenderStore.dynamicTracking, store.route, expression) } else if (prerenderStore.cacheSignal) { // we're prerendering with dynamicIO but we don't want to eagerly abort this @@ -194,9 +200,9 @@ export function trackDynamicDataAccessed( errorWithTracking(prerenderStore.dynamicTracking, store.route, expression) } else { postponeWithTracking( - prerenderStore.dynamicTracking, store.route, - expression + expression, + prerenderStore.dynamicTracking ) } } else { @@ -215,6 +221,90 @@ export function trackDynamicDataAccessed( } } +/** + * This function is meant to be used when prerendering without dynamicIO or PPR. + * When called during a build it will cause Next.js to consider the route as dynamic. + * + * @internal + */ +export function throwToInterruptStaticGeneration( + expression: string, + store: StaticGenerationStore +): never { + store.revalidate = 0 + + // We aren't prerendering but we are generating a static page. We need to bail out of static generation + const err = new DynamicServerError( + `Route ${store.route} couldn't be rendered statically because it used \`${expression}\`. See more info here: https://nextjs.org/docs/messages/dynamic-server-error` + ) + store.dynamicUsageDescription = expression + store.dynamicUsageStack = err.stack + + throw err +} + +/** + * This function should be used to track whether something dynamic happened even when + * we are in a dynamic render. This is useful for Dev where all renders are dynamic but + * we still track whether dynamic APIs were accessed for helpful messaging + * + * @internal + */ +export function trackDynamicDataInDynamicRender(store: StaticGenerationStore) { + store.revalidate = 0 +} + +// Despite it's name we don't actually abort unless we have a controller to call abort on +// There are times when we let a prerender run long to discover caches where we want the semantics +// of tracking dynamic access without terminating the prerender early +function abortOnSynchronousDynamicDataAccess( + route: string, + expression: string, + prerenderStore: PrerenderStore +): void { + const reason = `Route ${route} needs to bail out of prerendering at this point because it used ${expression}.` + + const error = createPrerenderInterruptedError(reason) + + if (prerenderStore.controller) { + prerenderStore.controller.abort(error) + } + + const dynamicTracking = prerenderStore.dynamicTracking + if (dynamicTracking) { + dynamicTracking.dynamicAccesses.push({ + // When we aren't debugging, we don't need to create another error for the + // stack trace. + stack: dynamicTracking.isDebugDynamicAccesses + ? new Error().stack + : undefined, + expression, + }) + } +} + +/** + * use this function when prerendering with dynamicIO. If we are doing a + * prospective prerender we don't actually abort because we want to discover + * all caches for the shell. If this is the actual prerender we do abort. + * + * This function accepts a prerenderStore but the caller should ensure we're + * actually running in dynamicIO mode. + * + * + * @internal + */ +export function abortAndThrowOnSynchronousDynamicDataAccess( + route: string, + expression: string, + prerenderStore: PrerenderStore +): never { + abortOnSynchronousDynamicDataAccess(route, expression, prerenderStore) + throw new Error( + `Route ${route} needs to bail out of prerendering at this point because it used ${expression}.` + ) +} + /** * This component will call `React.postpone` that throws the postponed error. */ @@ -225,7 +315,7 @@ type PostponeProps = { export function Postpone({ reason, route }: PostponeProps): never { const prerenderStore = prerenderAsyncStorage.getStore() const dynamicTracking = prerenderStore?.dynamicTracking || null - postponeWithTracking(dynamicTracking, route, reason) + postponeWithTracking(route, reason, dynamicTracking) } function errorWithTracking( @@ -251,10 +341,10 @@ function errorWithTracking( throw createPrerenderInterruptedError(reason) } -function postponeWithTracking( - dynamicTracking: null | DynamicTrackingState, +export function postponeWithTracking( route: string, - expression: string + expression: string, + dynamicTracking: null | DynamicTrackingState ): never { assertPostpone() if (dynamicTracking) { @@ -323,7 +413,7 @@ export function isPrerenderInterruptedError(error: unknown) { ) } -function abortRSCRender( +function abortRender( controller: AbortController, route: string, expression: string @@ -409,3 +499,54 @@ export function createPostponedAbortSignal(reason: string): AbortSignal { } return controller.signal } + +export function annotateDynamicAccess( + expression: string, + prerenderStore: PrerenderStore +) { + const dynamicTracking = prerenderStore.dynamicTracking + if (dynamicTracking) { + dynamicTracking.dynamicAccesses.push({ + stack: dynamicTracking.isDebugDynamicAccesses + ? new Error().stack + : undefined, + expression, + }) + } +} + +export function useDynamicRouteParams(expression: string) { + if (typeof window === 'undefined') { + const staticGenerationStore = staticGenerationAsyncStorage.getStore() + + if ( + staticGenerationStore && + staticGenerationStore.isStaticGeneration && + staticGenerationStore.fallbackRouteParams && + staticGenerationStore.fallbackRouteParams.size > 0 + ) { + // There are fallback route params, we should track these as dynamic + // accesses. + const prerenderStore = prerenderAsyncStorage.getStore() + if (prerenderStore) { + // We're prerendering with dynamicIO or PPR or both + if (isDynamicIOPrerender(prerenderStore)) { + // We are in a prerender with dynamicIO semantics + // We are going to hang here and never resolve. This will cause the currently + // rendering component to effectively be a dynamic hole + React.use(makeHangingPromise()) + } else { + // We're prerendering with PPR + postponeWithTracking( + staticGenerationStore.route, + expression, + prerenderStore.dynamicTracking + ) + } + } else { + // We're prerendering in legacy mode + throwToInterruptStaticGeneration(expression, staticGenerationStore) + } + } + } +} diff --git a/packages/next/src/server/app-render/entry-base.ts b/packages/next/src/server/app-render/entry-base.ts index f4e0ce9fe0146..d4d2c37e4c3b8 100644 --- a/packages/next/src/server/app-render/entry-base.ts +++ b/packages/next/src/server/app-render/entry-base.ts @@ -18,10 +18,15 @@ import { actionAsyncStorage } from '../../client/components/action-async-storage import { ClientPageRoot } from '../../client/components/client-page' import { ClientSegmentRoot } from '../../client/components/client-segment' import { - createUntrackedSearchParams, - createDynamicallyTrackedSearchParams, + createServerSearchParamsForServerPage, + createPrerenderSearchParamsForClientPage, + createServerSearchParamsForMetadata, } from '../request/search-params' -import { createDynamicallyTrackedParams } from '../request/fallback-params' +import { + createServerParamsForServerSegment, + createServerParamsForMetadata, + createPrerenderParamsForClientSegment, +} from '../request/params' import * as serverHooks from '../../client/components/hooks-server-context' import { NotFoundBoundary } from '../../client/components/not-found-boundary' import { patchFetch as _patchFetch } from '../lib/patch-fetch' @@ -48,9 +53,12 @@ export { staticGenerationAsyncStorage, requestAsyncStorage, actionAsyncStorage, - createUntrackedSearchParams, - createDynamicallyTrackedSearchParams, - createDynamicallyTrackedParams, + createServerSearchParamsForServerPage, + createServerSearchParamsForMetadata, + createPrerenderSearchParamsForClientPage, + createServerParamsForServerSegment, + createServerParamsForMetadata, + createPrerenderParamsForClientSegment, serverHooks, preloadStyle, preloadFont, diff --git a/packages/next/src/server/app-render/prerender-async-storage.external.ts b/packages/next/src/server/app-render/prerender-async-storage.external.ts index 3e6e2cf946431..14f5d9bb2cab8 100644 --- a/packages/next/src/server/app-render/prerender-async-storage.external.ts +++ b/packages/next/src/server/app-render/prerender-async-storage.external.ts @@ -35,5 +35,9 @@ export type PrerenderStore = { readonly dynamicTracking: null | DynamicTrackingState } +export function isDynamicIOPrerender(prerenderStore: PrerenderStore): boolean { + return !!(prerenderStore.controller || prerenderStore.cacheSignal) +} + export type PrerenderAsyncStorage = AsyncLocalStorage export { prerenderAsyncStorage } diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts index c67f9737a800a..534fe45b83545 100644 --- a/packages/next/src/server/base-server.ts +++ b/packages/next/src/server/base-server.ts @@ -1,7 +1,7 @@ import type { __ApiPreviewProps } from './api-utils' import type { LoadComponentsReturnType } from './load-components' import type { MiddlewareRouteMatch } from '../shared/lib/router/utils/middleware-route-matcher' -import type { Params } from '../server/request/params' +import type { Params } from './request/params' import { type FallbackRouteParams, getFallbackRouteParams, diff --git a/packages/next/src/server/dev/next-dev-server.ts b/packages/next/src/server/dev/next-dev-server.ts index 091e8080b1985..40b2ea7346402 100644 --- a/packages/next/src/server/dev/next-dev-server.ts +++ b/packages/next/src/server/dev/next-dev-server.ts @@ -176,9 +176,13 @@ export default class DevServer extends Server { this.nextConfig.experimental?.amp?.skipValidation ?? false this.renderOpts.ampValidator = (html: string, pathname: string) => { const validatorPath = - this.nextConfig.experimental && - this.nextConfig.experimental.amp && - this.nextConfig.experimental.amp.validator + (this.nextConfig.experimental && + this.nextConfig.experimental.amp && + this.nextConfig.experimental.amp.validator) || + require.resolve( + 'next/dist/compiled/amphtml-validator/validator_wasm.js' + ) + const AmpHtmlValidator = require('next/dist/compiled/amphtml-validator') as typeof import('next/dist/compiled/amphtml-validator') return AmpHtmlValidator.getInstance(validatorPath).then((validator) => { diff --git a/packages/next/src/server/dynamic-rendering-utils.ts b/packages/next/src/server/dynamic-rendering-utils.ts new file mode 100644 index 0000000000000..fd333d3a2bdd7 --- /dev/null +++ b/packages/next/src/server/dynamic-rendering-utils.ts @@ -0,0 +1,12 @@ +function hangForever() {} + +/** + * This function constructs a promise that will never resolve. This is primarily + * useful for dynamicIO where we use promise resolution timing to determine which + * parts of a render can be included in a prerender. + * + * @internal + */ +export function makeHangingPromise(): Promise { + return new Promise(hangForever) +} diff --git a/packages/next/src/server/render.tsx b/packages/next/src/server/render.tsx index 2d3433e2e9922..ae913fb6c3a64 100644 --- a/packages/next/src/server/render.tsx +++ b/packages/next/src/server/render.tsx @@ -40,7 +40,7 @@ import type { Revalidate, SwrDelta } from './lib/revalidate' import type { COMPILER_NAMES } from '../shared/lib/constants' import React, { type JSX } from 'react' -import ReactDOMServerEdge from 'react-dom/server.edge' +import ReactDOMServerPages from 'next/dist/server/ReactDOMServerPages' import { StyleRegistry, createStyleRegistry } from 'styled-jsx' import { GSP_NO_RETURNED_VALUE, @@ -127,7 +127,7 @@ function noRouter() { } async function renderToString(element: React.ReactElement) { - const renderStream = await ReactDOMServerEdge.renderToReadableStream(element) + const renderStream = await ReactDOMServerPages.renderToReadableStream(element) await renderStream.allReady return streamToString(renderStream) } @@ -1326,7 +1326,7 @@ export async function renderToHTMLImpl( ) => { const content = renderContent(EnhancedApp, EnhancedComponent) return await renderToInitialFizzStream({ - ReactDOMServer: ReactDOMServerEdge, + ReactDOMServer: ReactDOMServerPages, element: content, }) } diff --git a/packages/next/src/server/request/cookies.ts b/packages/next/src/server/request/cookies.ts new file mode 100644 index 0000000000000..fe1f77ecd33ca --- /dev/null +++ b/packages/next/src/server/request/cookies.ts @@ -0,0 +1,544 @@ +import { + type ReadonlyRequestCookies, + type ResponseCookies, + RequestCookiesAdapter, +} from '../../server/web/spec-extension/adapters/request-cookies' +import { RequestCookies } from '../../server/web/spec-extension/cookies' +import { staticGenerationAsyncStorage } from '../../client/components/static-generation-async-storage.external' +import { + isDynamicIOPrerender, + prerenderAsyncStorage, + type PrerenderStore, +} from '../app-render/prerender-async-storage.external' +import { + postponeWithTracking, + abortAndThrowOnSynchronousDynamicDataAccess, + throwToInterruptStaticGeneration, + trackDynamicDataInDynamicRender, +} from '../../server/app-render/dynamic-rendering' +import { getExpectedRequestStore } from '../../client/components/request-async-storage.external' +import { actionAsyncStorage } from '../../client/components/action-async-storage.external' +import { StaticGenBailoutError } from '../../client/components/static-generation-bailout' +import { makeResolvedReactPromise } from './utils' +import { makeHangingPromise } from '../dynamic-rendering-utils' + +/** + * In this version of Next.js `cookies()` returns a Promise however you can still reference the properties of the underlying cookies object + * synchronously to facilitate migration. The `UnsafeUnwrappedCookies` type is added to your code by a codemod that attempts to automatically + * updates callsites to reflect the new Promise return type. There are some cases where `cookies()` cannot be automatically converted, namely + * when it is used inside a synchronous function and we can't be sure the function can be made async automatically. In these cases we add an + * explicit type case to `UnsafeUnwrappedCookies` to enable typescript to allow for the synchronous usage only where it is actually necessary. + * + * You should should update these callsites to either be async functions where the `cookies()` value can be awaited or you should call `cookies()` + * from outside and await the return value before passing it into this function. + * + * You can find instances that require manual migration by searching for `UnsafeUnwrappedCookies` in your codebase or by search for a comment that + * starts with: + * + * ``` + * // TODO [sync-cookies-usage] + * ``` + * In a future version of Next.js `cookies()` will only return a Promise and you will not be able to access the underlying cookies object directly + * without awaiting the return value first. When this change happens the type `UnsafeUnwrappedCookies` will be updated to reflect that is it no longer + * usable. + * + * This type is marked deprecated to help identify it as target for refactoring away. + * + * @deprecated + */ +export type UnsafeUnwrappedCookies = ReadonlyRequestCookies + +export function cookies(): Promise { + const callingExpression = 'cookies' + const requestStore = getExpectedRequestStore(callingExpression) + const staticGenerationStore = staticGenerationAsyncStorage.getStore() + const prerenderStore = prerenderAsyncStorage.getStore() + + if (staticGenerationStore) { + if (staticGenerationStore.forceStatic) { + // When using forceStatic we override all other logic and always just return an empty + // cookies object without tracking + const underlyingCookies = createEmptyCookies() + return makeUntrackedExoticCookies(underlyingCookies) + } + + if (staticGenerationStore.isUnstableCacheCallback) { + throw new Error( + `Route ${staticGenerationStore.route} used "cookies" inside a function cached with "unstable_cache(...)". Accessing Dynamic data sources inside a cache scope is not supported. If you need this data inside a cached function use "cookies" outside of the cached function and pass the required dynamic data in as an argument. See more info here: https://nextjs.org/docs/app/api-reference/functions/unstable_cache` + ) + } else if (staticGenerationStore.dynamicShouldError) { + throw new StaticGenBailoutError( + `Route ${staticGenerationStore.route} with \`dynamic = "error"\` couldn't be rendered statically because it used \`cookies\`. See more info here: https://nextjs.org/docs/app/building-your-application/rendering/static-and-dynamic#dynamic-rendering` + ) + } + + if (prerenderStore) { + // We are in PPR and/or dynamicIO mode and prerendering + + if (isDynamicIOPrerender(prerenderStore)) { + // We use the controller and cacheSignal as an indication we are in dynamicIO mode. + // When resolving cookies for a prerender with dynamic IO we return a forever promise + // along with property access tracked synchronous cookies. + + // We don't track dynamic access here because access will be tracked when you access + // one of the properties of the cookies object. + return makeDynamicallyTrackedExoticCookies( + staticGenerationStore.route, + prerenderStore + ) + } else { + // We are prerendering with PPR. We need track dynamic access here eagerly + // to keep continuity with how cookies has worked in PPR without dynamicIO. + // TODO consider switching the semantic to throw on property access instead + postponeWithTracking( + staticGenerationStore.route, + callingExpression, + prerenderStore.dynamicTracking + ) + } + } else if (staticGenerationStore.isStaticGeneration) { + // We are in a legacy static generation mode while prerendering + // We track dynamic access here so we don't need to wrap the cookies in + // individual property access tracking. + throwToInterruptStaticGeneration(callingExpression, staticGenerationStore) + } + // We fall through to the dynamic context below but we still track dynamic access + // because in dev we can still error for things like using cookies inside a cache context + trackDynamicDataInDynamicRender(staticGenerationStore) + } + + // cookies is being called in a dynamic context + const actionStore = actionAsyncStorage.getStore() + + let underlyingCookies: ReadonlyRequestCookies + + // The current implementation of cookies will return Response cookies + // for a server action during the render phase of a server action. + // This is not correct b/c the type of cookies during render is ReadOnlyRequestCookies + // where as the type of cookies during action is ResponseCookies + // This was found because RequestCookies is iterable and ResponseCookies is not + if (actionStore?.isAction || actionStore?.isAppRoute) { + // We can't conditionally return different types here based on the context. + // To avoid confusion, we always return the readonly type here. + underlyingCookies = + requestStore.mutableCookies as unknown as ReadonlyRequestCookies + } else { + underlyingCookies = requestStore.cookies + } + + if (process.env.NODE_ENV === 'development') { + return makeUntrackedExoticCookiesWithDevWarnings( + underlyingCookies, + staticGenerationStore?.route + ) + } else { + return makeUntrackedExoticCookies(underlyingCookies) + } +} + +function createEmptyCookies(): ReadonlyRequestCookies { + return RequestCookiesAdapter.seal(new RequestCookies(new Headers({}))) +} + +interface CacheLifetime {} +const CachedCookies = new WeakMap< + CacheLifetime, + Promise +>() + +function makeDynamicallyTrackedExoticCookies( + route: string, + prerenderStore: PrerenderStore +): Promise { + const cachedPromise = CachedCookies.get(prerenderStore) + if (cachedPromise) { + return cachedPromise + } + + const promise = makeHangingPromise() + CachedCookies.set(prerenderStore, promise) + + Object.defineProperties(promise, { + [Symbol.iterator]: { + value: function () { + const expression = 'cookies()[Symbol.iterator]()' + abortAndThrowOnSynchronousDynamicDataAccess( + route, + expression, + prerenderStore + ) + }, + }, + size: { + get() { + const expression = `cookies().size` + abortAndThrowOnSynchronousDynamicDataAccess( + route, + expression, + prerenderStore + ) + }, + }, + get: { + value: function get() { + let expression: string + if (arguments.length === 0) { + expression = 'cookies().get()' + } else { + expression = `cookies().get(${describeNameArg(arguments[0])})` + } + abortAndThrowOnSynchronousDynamicDataAccess( + route, + expression, + prerenderStore + ) + }, + }, + getAll: { + value: function getAll() { + let expression: string + if (arguments.length === 0) { + expression = `cookies().getAll()` + } else { + expression = `cookies().getAll(${describeNameArg(arguments[0])})` + } + abortAndThrowOnSynchronousDynamicDataAccess( + route, + expression, + prerenderStore + ) + }, + }, + has: { + value: function has() { + let expression: string + if (arguments.length === 0) { + expression = `cookies().has()` + } else { + expression = `cookies().has(${describeNameArg(arguments[0])})` + } + abortAndThrowOnSynchronousDynamicDataAccess( + route, + expression, + prerenderStore + ) + }, + }, + set: { + value: function set() { + let expression: string + if (arguments.length === 0) { + expression = 'cookies().set()' + } else { + const arg = arguments[0] + if (arg) { + expression = `cookies().set(${describeNameArg(arg)}, ...)` + } else { + expression = `cookies().set(...)` + } + } + abortAndThrowOnSynchronousDynamicDataAccess( + route, + expression, + prerenderStore + ) + }, + }, + delete: { + value: function () { + let expression: string + if (arguments.length === 0) { + expression = `cookies().delete()` + } else if (arguments.length === 1) { + expression = `cookies().delete(${describeNameArg(arguments[0])})` + } else { + expression = `cookies().delete(${describeNameArg(arguments[0])}, ...)` + } + abortAndThrowOnSynchronousDynamicDataAccess( + route, + expression, + prerenderStore + ) + }, + }, + clear: { + value: function clear() { + const expression = 'cookies().clear()' + abortAndThrowOnSynchronousDynamicDataAccess( + route, + expression, + prerenderStore + ) + }, + }, + toString: { + value: function toString() { + const expression = 'cookies().toString()' + abortAndThrowOnSynchronousDynamicDataAccess( + route, + expression, + prerenderStore + ) + }, + }, + } satisfies CookieExtensions) + + return promise +} + +function makeUntrackedExoticCookies( + underlyingCookies: ReadonlyRequestCookies +): Promise { + const cachedCookies = CachedCookies.get(underlyingCookies) + if (cachedCookies) { + return cachedCookies + } + + const promise = makeResolvedReactPromise(underlyingCookies) + CachedCookies.set(underlyingCookies, promise) + + Object.defineProperties(promise, { + [Symbol.iterator]: { + value: underlyingCookies[Symbol.iterator] + ? underlyingCookies[Symbol.iterator].bind(underlyingCookies) + : // TODO this is a polyfill for when the underlying type is ResponseCookies + // We should remove this and unify our cookies types. We could just let this continue to throw lazily + // but that's already a hard thing to debug so we may as well implement it consistently. The biggest problem with + // implementing this in this way is the underlying cookie type is a ResponseCookie and not a RequestCookie and so it + // has extra properties not available on RequestCookie instances. + polyfilledResponseCookiesIterator.bind(underlyingCookies), + }, + size: { + get(): number { + return underlyingCookies.size + }, + }, + get: { + value: underlyingCookies.get.bind(underlyingCookies), + }, + getAll: { + value: underlyingCookies.getAll.bind(underlyingCookies), + }, + has: { + value: underlyingCookies.has.bind(underlyingCookies), + }, + set: { + value: underlyingCookies.set.bind(underlyingCookies), + }, + delete: { + value: underlyingCookies.delete.bind(underlyingCookies), + }, + clear: { + value: + // @ts-expect-error clear is defined in RequestCookies implementation but not in the type + typeof underlyingCookies.clear === 'function' + ? // @ts-expect-error clear is defined in RequestCookies implementation but not in the type + underlyingCookies.clear.bind(underlyingCookies) + : // TODO this is a polyfill for when the underlying type is ResponseCookies + // We should remove this and unify our cookies types. We could just let this continue to throw lazily + // but that's already a hard thing to debug so we may as well implement it consistently. The biggest problem with + // implementing this in this way is the underlying cookie type is a ResponseCookie and not a RequestCookie and so it + // has extra properties not available on RequestCookie instances. + polyfilledResponseCookiesClear.bind(underlyingCookies, promise), + }, + toString: { + value: underlyingCookies.toString.bind(underlyingCookies), + }, + } satisfies CookieExtensions) + + return promise +} + +function makeUntrackedExoticCookiesWithDevWarnings( + underlyingCookies: ReadonlyRequestCookies, + route?: string +): Promise { + const cachedCookies = CachedCookies.get(underlyingCookies) + if (cachedCookies) { + return cachedCookies + } + + const promise = makeResolvedReactPromise(underlyingCookies) + CachedCookies.set(underlyingCookies, promise) + + Object.defineProperties(promise, { + [Symbol.iterator]: { + value: function () { + warnForSyncIteration(route) + return underlyingCookies[Symbol.iterator] + ? underlyingCookies[Symbol.iterator].apply( + underlyingCookies, + arguments as any + ) + : // TODO this is a polyfill for when the underlying type is ResponseCookies + // We should remove this and unify our cookies types. We could just let this continue to throw lazily + // but that's already a hard thing to debug so we may as well implement it consistently. The biggest problem with + // implementing this in this way is the underlying cookie type is a ResponseCookie and not a RequestCookie and so it + // has extra properties not available on RequestCookie instances. + polyfilledResponseCookiesIterator.call(underlyingCookies) + }, + writable: false, + }, + size: { + get(): number { + const expression = 'cookies().size' + warnForSyncAccess(route, expression) + return underlyingCookies.size + }, + }, + get: { + value: function get() { + let expression: string + if (arguments.length === 0) { + expression = 'cookies().get()' + } else { + expression = `cookies().get(${describeNameArg(arguments[0])})` + } + warnForSyncAccess(route, expression) + return underlyingCookies.get.apply(underlyingCookies, arguments as any) + }, + writable: false, + }, + getAll: { + value: function getAll() { + let expression: string + if (arguments.length === 0) { + expression = `cookies().getAll()` + } else { + expression = `cookies().getAll(${describeNameArg(arguments[0])})` + } + warnForSyncAccess(route, expression) + return underlyingCookies.getAll.apply( + underlyingCookies, + arguments as any + ) + }, + writable: false, + }, + has: { + value: function get() { + let expression: string + if (arguments.length === 0) { + expression = `cookies().has()` + } else { + expression = `cookies().has(${describeNameArg(arguments[0])})` + } + warnForSyncAccess(route, expression) + return underlyingCookies.has.apply(underlyingCookies, arguments as any) + }, + writable: false, + }, + set: { + value: function set() { + let expression: string + if (arguments.length === 0) { + expression = 'cookies().set()' + } else { + const arg = arguments[0] + if (arg) { + expression = `cookies().set(${describeNameArg(arg)}, ...)` + } else { + expression = `cookies().set(...)` + } + } + warnForSyncAccess(route, expression) + return underlyingCookies.set.apply(underlyingCookies, arguments as any) + }, + writable: false, + }, + delete: { + value: function () { + let expression: string + if (arguments.length === 0) { + expression = `cookies().delete()` + } else if (arguments.length === 1) { + expression = `cookies().delete(${describeNameArg(arguments[0])})` + } else { + expression = `cookies().delete(${describeNameArg(arguments[0])}, ...)` + } + warnForSyncAccess(route, expression) + return underlyingCookies.delete.apply( + underlyingCookies, + arguments as any + ) + }, + writable: false, + }, + clear: { + value: function clear() { + const expression = 'cookies().clear()' + warnForSyncAccess(route, expression) + // @ts-ignore clear is defined in RequestCookies implementation but not in the type + return typeof underlyingCookies.clear === 'function' + ? // @ts-ignore clear is defined in RequestCookies implementation but not in the type + underlyingCookies.clear.apply(underlyingCookies, arguments) + : // TODO this is a polyfill for when the underlying type is ResponseCookies + // We should remove this and unify our cookies types. We could just let this continue to throw lazily + // but that's already a hard thing to debug so we may as well implement it consistently. The biggest problem with + // implementing this in this way is the underlying cookie type is a ResponseCookie and not a RequestCookie and so it + // has extra properties not available on RequestCookie instances. + polyfilledResponseCookiesClear.call(underlyingCookies, promise) + }, + writable: false, + }, + toString: { + value: function toString() { + const expression = 'cookies().toString()' + warnForSyncAccess(route, expression) + return underlyingCookies.toString.apply( + underlyingCookies, + arguments as any + ) + }, + writable: false, + }, + } satisfies CookieExtensions) + + return promise +} + +function describeNameArg(arg: unknown) { + return typeof arg === 'object' && + arg !== null && + typeof (arg as any).name === 'string' + ? `'${(arg as any).name}'` + : typeof arg === 'string' + ? `'${arg}'` + : '...' +} + +function warnForSyncIteration(route?: string) { + const prefix = route ? ` In route ${route} ` : '' + console.error( + `${prefix}cookies were iterated implicitly with something like \`for...of cookies())\` or \`[...cookies()]\`, or explicitly with \`cookies()[Symbol.iterator]()\`. \`cookies()\` now returns a Promise and the return value should be awaited before attempting to iterate over cookies. In this version of Next.js iterating cookies without awaiting first is still supported to facilitate migration but in a future version you will be required to await the result. If this \`cookies()\` use is inside an async function await the return value before accessing attempting iteration. If this use is inside a synchronous function then convert the function to async or await the call from outside this function and pass the result in.` + ) +} + +function warnForSyncAccess(route: undefined | string, expression: string) { + const prefix = route ? ` In route ${route} a ` : 'A ' + console.error( + `${prefix}cookie property was accessed directly with \`${expression}\`. \`cookies()\` now returns a Promise and the return value should be awaited before accessing properties of the underlying cookies instance. In this version of Next.js direct access to \`${expression}\` is still supported to facilitate migration but in a future version you will be required to await the result. If this \`cookies()\` use is inside an async function await the return value before accessing attempting iteration. If this use is inside a synchronous function then convert the function to async or await the call from outside this function and pass the result in.` + ) +} + +function polyfilledResponseCookiesIterator( + this: ResponseCookies +): ReturnType { + return this.getAll() + .map((c) => [c.name, c] as [string, any]) + .values() +} + +function polyfilledResponseCookiesClear( + this: ResponseCookies, + returnable: Promise +): typeof returnable { + for (const cookie of this.getAll()) { + this.delete(cookie.name) + } + return returnable +} + +type CookieExtensions = { + [K in keyof ReadonlyRequestCookies | 'clear']: unknown +} diff --git a/packages/next/src/server/request/draft-mode.ts b/packages/next/src/server/request/draft-mode.ts index a2c225ba77123..2ecdb55f06188 100644 --- a/packages/next/src/server/request/draft-mode.ts +++ b/packages/next/src/server/request/draft-mode.ts @@ -1,9 +1,134 @@ -import type { DraftModeProvider } from '../async-storage/draft-mode-provider' +import { getExpectedRequestStore } from '../../client/components/request-async-storage.external' + +import type { DraftModeProvider } from '../../server/async-storage/draft-mode-provider' import { staticGenerationAsyncStorage } from '../../client/components/static-generation-async-storage.external' import { trackDynamicDataAccessed } from '../app-render/dynamic-rendering' -export class DraftMode { +/** + * In this version of Next.js `draftMode()` returns a Promise however you can still reference the properties of the underlying draftMode object + * synchronously to facilitate migration. The `UnsafeUnwrappedDraftMode` type is added to your code by a codemod that attempts to automatically + * updates callsites to reflect the new Promise return type. There are some cases where `draftMode()` cannot be automatically converted, namely + * when it is used inside a synchronous function and we can't be sure the function can be made async automatically. In these cases we add an + * explicit type case to `UnsafeUnwrappedDraftMode` to enable typescript to allow for the synchronous usage only where it is actually necessary. + * + * You should should update these callsites to either be async functions where the `draftMode()` value can be awaited or you should call `draftMode()` + * from outside and await the return value before passing it into this function. + * + * You can find instances that require manual migration by searching for `UnsafeUnwrappedDraftMode` in your codebase or by search for a comment that + * starts with: + * + * ``` + * // TODO [sync-draftMode-usage] + * ``` + * In a future version of Next.js `draftMode()` will only return a Promise and you will not be able to access the underlying draftMode object directly + * without awaiting the return value first. When this change happens the type `UnsafeUnwrappedDraftMode` will be updated to reflect that is it no longer + * usable. + * + * This type is marked deprecated to help identify it as target for refactoring away. + * + * @deprecated + */ +export type UnsafeUnwrappedDraftMode = DraftMode + +export function draftMode(): Promise { + const callingExpression = 'draftMode' + const requestStore = getExpectedRequestStore(callingExpression) + + if (process.env.NODE_ENV === 'development') { + const staticGenerationStore = staticGenerationAsyncStorage.getStore() + const route = staticGenerationStore?.route + return createExoticDraftModeWithDevWarnings(requestStore.draftMode, route) + } else { + return createExoticDraftMode(requestStore.draftMode) + } +} + +interface CacheLifetime {} +const CachedDraftModes = new WeakMap>() + +function createExoticDraftMode( + underlyingProvider: DraftModeProvider +): Promise { + const cachedDraftMode = CachedDraftModes.get(underlyingProvider) + if (cachedDraftMode) { + return cachedDraftMode + } + + const instance = new DraftMode(underlyingProvider) + const promise = Promise.resolve(instance) + CachedDraftModes.set(underlyingProvider, promise) + + Object.defineProperty(promise, 'isEnabled', { + get() { + return instance.isEnabled + }, + set(newValue) { + Object.defineProperty(promise, 'isEnabled', { + value: newValue, + writable: true, + enumerable: true, + }) + }, + enumerable: true, + configurable: true, + }) + ;(promise as any).enable = instance.enable.bind(instance) + ;(promise as any).disable = instance.disable.bind(instance) + + return promise +} + +function createExoticDraftModeWithDevWarnings( + underlyingProvider: DraftModeProvider, + route: undefined | string +): Promise { + const cachedDraftMode = CachedDraftModes.get(underlyingProvider) + if (cachedDraftMode) { + return cachedDraftMode + } + + const instance = new DraftMode(underlyingProvider) + const promise = Promise.resolve(instance) + CachedDraftModes.set(underlyingProvider, promise) + + Object.defineProperty(promise, 'isEnabled', { + get() { + const expression = 'draftMode().isEnabled' + warnForSyncAccess(route, expression) + return instance.isEnabled + }, + set(newValue) { + Object.defineProperty(promise, 'isEnabled', { + value: newValue, + writable: true, + enumerable: true, + }) + }, + enumerable: true, + configurable: true, + }) + + Object.defineProperty(promise, 'enable', { + value: function get() { + const expression = 'draftMode().enable()' + warnForSyncAccess(route, expression) + return instance.enable.apply(instance, arguments as any) + }, + }) + + Object.defineProperty(promise, 'disable', { + value: function get() { + const expression = 'draftMode().disable()' + warnForSyncAccess(route, expression) + return instance.disable.apply(instance, arguments as any) + }, + }) + + return promise +} + +class DraftMode { /** * @internal - this declaration is stripped via `tsc --stripInternal` */ @@ -34,3 +159,10 @@ export class DraftMode { return this._provider.disable() } } + +function warnForSyncAccess(route: undefined | string, expression: string) { + const prefix = route ? ` In route ${route} a ` : 'A ' + console.error( + `${prefix}\`draftMode()\` property was accessed directly with \`${expression}\`. \`draftMode()\` now returns a Promise and the return value should be awaited before accessing properties of the underlying draftMode object. In this version of Next.js direct access to \`${expression}\` is still supported to facilitate migration but in a future version you will be required to await the result. If this \`draftMode()\` use is inside an async function await the return value before accessing attempting iteration. If this use is inside a synchronous function then convert the function to async or await the call from outside this function and pass the result in.` + ) +} diff --git a/packages/next/src/server/request/fallback-params.ts b/packages/next/src/server/request/fallback-params.ts index 67b0e5f70f10d..e3af245362e80 100644 --- a/packages/next/src/server/request/fallback-params.ts +++ b/packages/next/src/server/request/fallback-params.ts @@ -1,9 +1,5 @@ -import { trackFallbackParamAccessed } from '../app-render/dynamic-rendering' -import { ReflectAdapter } from '../web/spec-extension/adapters/reflect' import { getRouteMatcher } from '../../shared/lib/router/utils/route-matcher' import { getRouteRegex } from '../../shared/lib/router/utils/route-regex' -import type { Params } from './params' -import { staticGenerationAsyncStorage } from '../../client/components/static-generation-async-storage.external' export type FallbackRouteParams = ReadonlyMap @@ -41,57 +37,3 @@ export function getFallbackRouteParams( return params } - -export type CreateDynamicallyTrackedParams = - typeof createDynamicallyTrackedParams - -export function createDynamicallyTrackedParams(params: Params): Params { - const staticGenerationStore = staticGenerationAsyncStorage.getStore() - if ( - !staticGenerationStore || - !staticGenerationStore.isStaticGeneration || - !staticGenerationStore.fallbackRouteParams || - staticGenerationStore.fallbackRouteParams.size === 0 - ) { - return params - } - - // If there are no unknown route params, we can just return the params. - const { fallbackRouteParams } = staticGenerationStore - - return new Proxy(params as Params, { - get(target, prop, receiver) { - // If the property is in the params object, we should track the access if - // it's an unknown dynamic param. - if ( - typeof prop === 'string' && - prop in params && - fallbackRouteParams.has(prop) - ) { - trackFallbackParamAccessed(staticGenerationStore, `params.${prop}`) - } - - return ReflectAdapter.get(target, prop, receiver) - }, - has(target, prop) { - if ( - typeof prop === 'string' && - prop in params && - fallbackRouteParams.has(prop) - ) { - trackFallbackParamAccessed(staticGenerationStore, `params.${prop}`) - } - - return ReflectAdapter.has(target, prop) - }, - ownKeys(target) { - for (const key in params) { - if (fallbackRouteParams.has(key)) { - trackFallbackParamAccessed(staticGenerationStore, 'params') - } - } - - return Reflect.ownKeys(target) - }, - }) -} diff --git a/packages/next/src/server/request/headers.ts b/packages/next/src/server/request/headers.ts index 540b199ce6961..9d8dfbf9908e0 100644 --- a/packages/next/src/server/request/headers.ts +++ b/packages/next/src/server/request/headers.ts @@ -1,14 +1,49 @@ import { - type ReadonlyRequestCookies, - RequestCookiesAdapter, -} from '../web/spec-extension/adapters/request-cookies' -import { HeadersAdapter } from '../web/spec-extension/adapters/headers' -import { RequestCookies } from '../web/spec-extension/cookies' -import { actionAsyncStorage } from '../../client/components/action-async-storage.external' -import { DraftMode } from './draft-mode' -import { trackDynamicDataAccessed } from '../app-render/dynamic-rendering' + HeadersAdapter, + type ReadonlyHeaders, +} from '../../server/web/spec-extension/adapters/headers' import { staticGenerationAsyncStorage } from '../../client/components/static-generation-async-storage.external' import { getExpectedRequestStore } from '../../client/components/request-async-storage.external' +import { + isDynamicIOPrerender, + prerenderAsyncStorage, + type PrerenderStore, +} from '../app-render/prerender-async-storage.external' +import { + postponeWithTracking, + abortAndThrowOnSynchronousDynamicDataAccess, + throwToInterruptStaticGeneration, + trackDynamicDataInDynamicRender, +} from '../app-render/dynamic-rendering' +import { StaticGenBailoutError } from '../../client/components/static-generation-bailout' +import { makeResolvedReactPromise } from './utils' +import { makeHangingPromise } from '../dynamic-rendering-utils' + +/** + * In this version of Next.js `headers()` returns a Promise however you can still reference the properties of the underlying Headers instance + * synchronously to facilitate migration. The `UnsafeUnwrappedHeaders` type is added to your code by a codemod that attempts to automatically + * updates callsites to reflect the new Promise return type. There are some cases where `headers()` cannot be automatically converted, namely + * when it is used inside a synchronous function and we can't be sure the function can be made async automatically. In these cases we add an + * explicit type case to `UnsafeUnwrappedHeaders` to enable typescript to allow for the synchronous usage only where it is actually necessary. + * + * You should should update these callsites to either be async functions where the `headers()` value can be awaited or you should call `headers()` + * from outside and await the return value before passing it into this function. + * + * You can find instances that require manual migration by searching for `UnsafeUnwrappedHeaders` in your codebase or by search for a comment that + * starts with: + * + * ``` + * // TODO [sync-headers-usage] + * ``` + * In a future version of Next.js `headers()` will only return a Promise and you will not be able to access the underlying Headers instance + * without awaiting the return value first. When this change happens the type `UnsafeUnwrappedHeaders` will be updated to reflect that is it no longer + * usable. + * + * This type is marked deprecated to help identify it as target for refactoring away. + * + * @deprecated + */ +export type UnsafeUnwrappedHeaders = ReadonlyHeaders /** * This function allows you to read the HTTP incoming request headers in @@ -19,52 +54,388 @@ import { getExpectedRequestStore } from '../../client/components/request-async-s * * Read more: [Next.js Docs: `headers`](https://nextjs.org/docs/app/api-reference/functions/headers) */ -export function headers() { - const callingExpression = 'headers' +export function headers(): Promise { + const requestStore = getExpectedRequestStore('headers') const staticGenerationStore = staticGenerationAsyncStorage.getStore() + const prerenderStore = prerenderAsyncStorage.getStore() if (staticGenerationStore) { if (staticGenerationStore.forceStatic) { - // When we are forcing static we don't mark this as a Dynamic read and we return an empty headers object - return HeadersAdapter.seal(new Headers({})) - } else { - // We will return a real headers object below so we mark this call as reading from a dynamic data source - trackDynamicDataAccessed(staticGenerationStore, callingExpression) + // When using forceStatic we override all other logic and always just return an empty + // headers object without tracking + const underlyingHeaders = HeadersAdapter.seal(new Headers({})) + return makeUntrackedExoticHeaders(underlyingHeaders) } + + if (staticGenerationStore.isUnstableCacheCallback) { + throw new Error( + `Route ${staticGenerationStore.route} used "headers" inside a function cached with "unstable_cache(...)". Accessing Dynamic data sources inside a cache scope is not supported. If you need this data inside a cached function use "headers" outside of the cached function and pass the required dynamic data in as an argument. See more info here: https://nextjs.org/docs/app/api-reference/functions/unstable_cache` + ) + } else if (staticGenerationStore.dynamicShouldError) { + throw new StaticGenBailoutError( + `Route ${staticGenerationStore.route} with \`dynamic = "error"\` couldn't be rendered statically because it used \`headers\`. See more info here: https://nextjs.org/docs/app/building-your-application/rendering/static-and-dynamic#dynamic-rendering` + ) + } + + if (prerenderStore) { + // We are in PPR and/or dynamicIO mode and prerendering + + if (isDynamicIOPrerender(prerenderStore)) { + // We use the controller and cacheSignal as an indication we are in dynamicIO mode. + // When resolving headers for a prerender with dynamic IO we return a forever promise + // along with property access tracked synchronous headers. + + // We don't track dynamic access here because access will be tracked when you access + // one of the properties of the headers object. + return makeDynamicallyTrackedExoticHeaders( + staticGenerationStore.route, + prerenderStore + ) + } else { + // We are prerendering with PPR. We need track dynamic access here eagerly + // to keep continuity with how headers has worked in PPR without dynamicIO. + // TODO consider switching the semantic to throw on property access instead + postponeWithTracking( + staticGenerationStore.route, + 'headers', + prerenderStore.dynamicTracking + ) + } + } else if (staticGenerationStore.isStaticGeneration) { + // We are in a legacy static generation mode while prerendering + // We track dynamic access here so we don't need to wrap the headers in + // individual property access tracking. + throwToInterruptStaticGeneration('headers', staticGenerationStore) + } + // We fall through to the dynamic context below but we still track dynamic access + // because in dev we can still error for things like using headers inside a cache context + trackDynamicDataInDynamicRender(staticGenerationStore) } - return getExpectedRequestStore(callingExpression).headers + if (process.env.NODE_ENV === 'development') { + return makeUntrackedExoticHeadersWithDevWarnings( + requestStore.headers, + staticGenerationStore?.route + ) + } else { + return makeUntrackedExoticHeaders(requestStore.headers) + } } -export function cookies() { - const callingExpression = 'cookies' - const staticGenerationStore = staticGenerationAsyncStorage.getStore() +interface CacheLifetime {} +const CachedHeaders = new WeakMap>() - if (staticGenerationStore) { - if (staticGenerationStore.forceStatic) { - // When we are forcing static we don't mark this as a Dynamic read and we return an empty cookies object - return RequestCookiesAdapter.seal(new RequestCookies(new Headers({}))) - } else { - // We will return a real headers object below so we mark this call as reading from a dynamic data source - trackDynamicDataAccessed(staticGenerationStore, callingExpression) - } +function makeDynamicallyTrackedExoticHeaders( + route: string, + prerenderStore: PrerenderStore +): Promise { + const cachedHeaders = CachedHeaders.get(prerenderStore) + if (cachedHeaders) { + return cachedHeaders } - const requestStore = getExpectedRequestStore(callingExpression) + const promise = makeHangingPromise() + CachedHeaders.set(prerenderStore, promise) + + Object.defineProperties(promise, { + append: { + value: function append() { + const expression = `headers().append(${describeNameArg(arguments[0])}, ...)` + abortAndThrowOnSynchronousDynamicDataAccess( + route, + expression, + prerenderStore + ) + }, + }, + delete: { + value: function _delete() { + const expression = `headers().delete(${describeNameArg(arguments[0])})` + abortAndThrowOnSynchronousDynamicDataAccess( + route, + expression, + prerenderStore + ) + }, + }, + get: { + value: function get() { + const expression = `headers().get(${describeNameArg(arguments[0])})` + abortAndThrowOnSynchronousDynamicDataAccess( + route, + expression, + prerenderStore + ) + }, + }, + has: { + value: function has() { + const expression = `headers().has(${describeNameArg(arguments[0])})` + abortAndThrowOnSynchronousDynamicDataAccess( + route, + expression, + prerenderStore + ) + }, + }, + set: { + value: function set() { + const expression = `headers().set(${describeNameArg(arguments[0])}, ...)` + abortAndThrowOnSynchronousDynamicDataAccess( + route, + expression, + prerenderStore + ) + }, + }, + getSetCookie: { + value: function getSetCookie() { + const expression = `headers().getSetCookie()` + abortAndThrowOnSynchronousDynamicDataAccess( + route, + expression, + prerenderStore + ) + }, + }, + forEach: { + value: function forEach() { + const expression = `headers().forEach(...)` + abortAndThrowOnSynchronousDynamicDataAccess( + route, + expression, + prerenderStore + ) + }, + }, + keys: { + value: function keys() { + const expression = `headers().keys()` + abortAndThrowOnSynchronousDynamicDataAccess( + route, + expression, + prerenderStore + ) + }, + }, + values: { + value: function values() { + const expression = `headers().values()` + abortAndThrowOnSynchronousDynamicDataAccess( + route, + expression, + prerenderStore + ) + }, + }, + entries: { + value: function entries() { + const expression = `headers().entries()` + abortAndThrowOnSynchronousDynamicDataAccess( + route, + expression, + prerenderStore + ) + }, + }, + [Symbol.iterator]: { + value: function () { + const expression = 'headers()[Symbol.iterator]()' + abortAndThrowOnSynchronousDynamicDataAccess( + route, + expression, + prerenderStore + ) + }, + }, + } satisfies HeadersExtensions) + + return promise +} + +function makeUntrackedExoticHeaders( + underlyingHeaders: ReadonlyHeaders +): Promise { + const cachedHeaders = CachedHeaders.get(underlyingHeaders) + if (cachedHeaders) { + return cachedHeaders + } + + const promise = makeResolvedReactPromise(underlyingHeaders) + CachedHeaders.set(underlyingHeaders, promise) + + Object.defineProperties(promise, { + append: { + value: underlyingHeaders.append.bind(underlyingHeaders), + }, + delete: { + value: underlyingHeaders.delete.bind(underlyingHeaders), + }, + get: { + value: underlyingHeaders.get.bind(underlyingHeaders), + }, + has: { + value: underlyingHeaders.has.bind(underlyingHeaders), + }, + set: { + value: underlyingHeaders.set.bind(underlyingHeaders), + }, + getSetCookie: { + value: underlyingHeaders.getSetCookie.bind(underlyingHeaders), + }, + forEach: { + value: underlyingHeaders.forEach.bind(underlyingHeaders), + }, + keys: { + value: underlyingHeaders.keys.bind(underlyingHeaders), + }, + values: { + value: underlyingHeaders.values.bind(underlyingHeaders), + }, + entries: { + value: underlyingHeaders.entries.bind(underlyingHeaders), + }, + [Symbol.iterator]: { + value: underlyingHeaders[Symbol.iterator].bind(underlyingHeaders), + }, + } satisfies HeadersExtensions) - const asyncActionStore = actionAsyncStorage.getStore() - if (asyncActionStore?.isAction || asyncActionStore?.isAppRoute) { - // We can't conditionally return different types here based on the context. - // To avoid confusion, we always return the readonly type here. - return requestStore.mutableCookies as unknown as ReadonlyRequestCookies + return promise +} + +function makeUntrackedExoticHeadersWithDevWarnings( + underlyingHeaders: ReadonlyHeaders, + route?: string +): Promise { + const cachedHeaders = CachedHeaders.get(underlyingHeaders) + if (cachedHeaders) { + return cachedHeaders } - return requestStore.cookies + const promise = makeResolvedReactPromise(underlyingHeaders) + CachedHeaders.set(underlyingHeaders, promise) + + Object.defineProperties(promise, { + append: { + value: function append() { + const expression = `headers().append(${describeNameArg(arguments[0])}, ...)` + warnForSyncAccess(route, expression) + return underlyingHeaders.append.apply( + underlyingHeaders, + arguments as any + ) + }, + }, + delete: { + value: function _delete() { + const expression = `headers().delete(${describeNameArg(arguments[0])})` + warnForSyncAccess(route, expression) + return underlyingHeaders.delete.apply( + underlyingHeaders, + arguments as any + ) + }, + }, + get: { + value: function get() { + const expression = `headers().get(${describeNameArg(arguments[0])})` + warnForSyncAccess(route, expression) + return underlyingHeaders.get.apply(underlyingHeaders, arguments as any) + }, + }, + has: { + value: function has() { + const expression = `headers().has(${describeNameArg(arguments[0])})` + warnForSyncAccess(route, expression) + return underlyingHeaders.has.apply(underlyingHeaders, arguments as any) + }, + }, + set: { + value: function set() { + const expression = `headers().set(${describeNameArg(arguments[0])}, ...)` + warnForSyncAccess(route, expression) + return underlyingHeaders.set.apply(underlyingHeaders, arguments as any) + }, + }, + getSetCookie: { + value: function getSetCookie() { + const expression = `headers().getSetCookie()` + warnForSyncAccess(route, expression) + return underlyingHeaders.getSetCookie.apply( + underlyingHeaders, + arguments as any + ) + }, + }, + forEach: { + value: function forEach() { + const expression = `headers().forEach(...)` + warnForSyncAccess(route, expression) + return underlyingHeaders.forEach.apply( + underlyingHeaders, + arguments as any + ) + }, + }, + keys: { + value: function keys() { + const expression = `headers().keys()` + warnForSyncAccess(route, expression) + return underlyingHeaders.keys.apply(underlyingHeaders, arguments as any) + }, + }, + values: { + value: function values() { + const expression = `headers().values()` + warnForSyncAccess(route, expression) + return underlyingHeaders.values.apply( + underlyingHeaders, + arguments as any + ) + }, + }, + entries: { + value: function entries() { + const expression = `headers().entries()` + warnForSyncAccess(route, expression) + return underlyingHeaders.entries.apply( + underlyingHeaders, + arguments as any + ) + }, + }, + [Symbol.iterator]: { + value: function () { + warnForSyncIteration(route) + return underlyingHeaders[Symbol.iterator].apply( + underlyingHeaders, + arguments as any + ) + }, + }, + } satisfies HeadersExtensions) + + return promise } -export function draftMode() { - const callingExpression = 'draftMode' - const requestStore = getExpectedRequestStore(callingExpression) +function describeNameArg(arg: unknown) { + return typeof arg === 'string' ? `'${arg}'` : '...' +} + +function warnForSyncIteration(route?: string) { + const prefix = route ? ` In route ${route} ` : '' + console.error( + `${prefix}headers were iterated implicitly with something like \`for...of headers())\` or \`[...headers()]\`, or explicitly with \`headers()[Symbol.iterator]()\`. \`headers()\` now returns a Promise and the return value should be awaited before attempting to iterate over headers. In this version of Next.js iterating headers without awaiting first is still supported to facilitate migration but in a future version you will be required to await the result. If this \`headers()\` use is inside an async function await the return value before accessing attempting iteration. If this use is inside a synchronous function then convert the function to async or await the call from outside this function and pass the result in.` + ) +} + +function warnForSyncAccess(route: undefined | string, expression: string) { + const prefix = route ? ` In route ${route} a ` : 'A ' + console.error( + `${prefix}header property was accessed directly with \`${expression}\`. \`headers()\` now returns a Promise and the return value should be awaited before accessing properties of the underlying headers instance. In this version of Next.js direct access to \`${expression}\` is still supported to facilitate migration but in a future version you will be required to await the result. If this \`headers()\` use is inside an async function await the return value before accessing attempting iteration. If this use is inside a synchronous function then convert the function to async or await the call from outside this function and pass the result in.` + ) +} - return new DraftMode(requestStore.draftMode) +type HeadersExtensions = { + [K in keyof ReadonlyHeaders]: unknown } diff --git a/packages/next/src/server/request/params.browser.ts b/packages/next/src/server/request/params.browser.ts new file mode 100644 index 0000000000000..6a678fa6834a0 --- /dev/null +++ b/packages/next/src/server/request/params.browser.ts @@ -0,0 +1,161 @@ +import type { Params } from './params' + +import { ReflectAdapter } from '../web/spec-extension/adapters/reflect' +import { InvariantError } from '../../shared/lib/invariant-error' +import { describeStringPropertyAccess } from './utils' + +export function createRenderParamsFromClient(underlyingParams: Params) { + if (process.env.NODE_ENV === 'development') { + return makeDynamicallyTrackedExoticParamsWithDevWarnings(underlyingParams) + } else { + return makeUntrackedExoticParams(underlyingParams) + } +} + +interface CacheLifetime {} +const CachedParams = new WeakMap>() + +function makeUntrackedExoticParams(underlyingParams: Params): Promise { + const cachedParams = CachedParams.get(underlyingParams) + if (cachedParams) { + return cachedParams + } + + const promise = Promise.resolve(underlyingParams) + CachedParams.set(underlyingParams, promise) + + Object.defineProperties(promise, { + status: { + value: 'fulfilled', + writable: true, + }, + value: { + value: underlyingParams, + writable: true, + }, + }) + + Object.keys(underlyingParams).forEach((prop) => { + switch (prop) { + case 'then': + case 'value': + case 'status': { + // These properties cannot be shadowed with a search param because they + // are necessary for ReactPromise's to work correctly with `use` + break + } + default: { + ;(promise as any)[prop] = underlyingParams[prop] + } + } + }) + + return promise +} + +function makeDynamicallyTrackedExoticParamsWithDevWarnings( + underlyingParams: Params +): Promise { + const cachedParams = CachedParams.get(underlyingParams) + if (cachedParams) { + return cachedParams + } + + const promise = Promise.resolve(underlyingParams) + + Object.defineProperties(promise, { + status: { + value: 'fulfilled', + writable: true, + }, + value: { + value: underlyingParams, + writable: true, + }, + }) + + const proxiedProperties = new Set() + const unproxiedProperties: Array = [] + + Object.keys(underlyingParams).forEach((prop) => { + switch (prop) { + case 'then': + case 'value': + case 'status': { + // These properties cannot be shadowed with a search param because they + // are necessary for ReactPromise's to work correctly with `use` + unproxiedProperties.push(prop) + break + } + default: { + proxiedProperties.add(prop) + ;(promise as any)[prop] = underlyingParams[prop] + } + } + }) + + const proxiedPromise = new Proxy(promise, { + get(target, prop, receiver) { + if (typeof prop === 'string') { + if ( + // We are accessing a property that was proxied to the promise instance + proxiedProperties.has(prop) || + // We are accessing a property that doesn't exist on the promise nor the underlying + Reflect.has(target, prop) === false + ) { + const expression = describeStringPropertyAccess('params', prop) + warnForSyncAccess(expression) + } + } + return ReflectAdapter.get(target, prop, receiver) + }, + ownKeys(target) { + warnForEnumeration(unproxiedProperties) + return Reflect.ownKeys(target) + }, + }) + + CachedParams.set(underlyingParams, proxiedPromise) + return proxiedPromise +} + +function warnForSyncAccess(expression: string) { + console.error( + `A param property was accessed directly with ${expression}. \`params\` is now a Promise and should be awaited before accessing properties of the underlying params object. In this version of Next.js direct access to param properties is still supported to facilitate migration but in a future version you will be required to await \`params\`. If this use is inside an async function await it. If this use is inside a synchronous function then convert the function to async or await it from outside this function and pass the result in.` + ) +} + +function warnForEnumeration(missingProperties: Array) { + if (missingProperties.length) { + const describedMissingProperties = + describeListOfPropertyNames(missingProperties) + console.error( + `params are being enumerated incompletely with \`{...params}\`, \`Object.keys(params)\`, or similar. The following properties were not copied: ${describedMissingProperties}. \`params\` is now a Promise, however in the current version of Next.js direct access to the underlying params object is still supported to facilitate migration to the new type. param names that conflict with Promise properties cannot be accessed directly and must be accessed by first awaiting the \`params\` promise.` + ) + } else { + console.error( + `params are being enumerated with \`{...params}\`, \`Object.keys(params)\`, or similar. \`params\` is now a Promise, however in the current version of Next.js direct access to the underlying params object is still supported to facilitate migration to the new type. You should update your code to await \`params\` before accessing its properties.` + ) + } +} + +function describeListOfPropertyNames(properties: Array) { + switch (properties.length) { + case 0: + throw new InvariantError( + 'Expected describeListOfPropertyNames to be called with a non-empty list of strings.' + ) + case 1: + return `\`${properties[0]}\`` + case 2: + return `\`${properties[0]}\` and \`${properties[1]}\`` + default: { + let description = '' + for (let i = 0; i < properties.length - 1; i++) { + description += `\`${properties[i]}\`, ` + } + description += `, and \`${properties[properties.length - 1]}\`` + return description + } + } +} diff --git a/packages/next/src/server/request/params.ts b/packages/next/src/server/request/params.ts index a3e8c24c9cd3d..1882806865498 100644 --- a/packages/next/src/server/request/params.ts +++ b/packages/next/src/server/request/params.ts @@ -1 +1,454 @@ -export type Params = Record +import type { StaticGenerationStore } from '../../client/components/static-generation-async-storage.external' +import type { FallbackRouteParams } from './fallback-params' + +import { ReflectAdapter } from '../web/spec-extension/adapters/reflect' +import { + abortAndThrowOnSynchronousDynamicDataAccess, + throwToInterruptStaticGeneration, + postponeWithTracking, +} from '../app-render/dynamic-rendering' + +import { + isDynamicIOPrerender, + prerenderAsyncStorage, + type PrerenderStore, +} from '../app-render/prerender-async-storage.external' +import { InvariantError } from '../../shared/lib/invariant-error' +import { + makeResolvedReactPromise, + describeStringPropertyAccess, + throwWithStaticGenerationBailoutErrorWithDynamicError, +} from './utils' +import { makeHangingPromise } from '../dynamic-rendering-utils' + +export type Params = Record | undefined> + +/** + * In this version of Next.js the `params` prop passed to Layouts, Pages, and other Segments is a Promise. + * However to facilitate migration to this new Promise type you can currently still access params directly on the Promise instance passed to these Segments. + * The `UnsafeUnwrappedParams` type is available if you need to temporarily access the underlying params without first awaiting or `use`ing the Promise. + * + * In a future version of Next.js the `params` prop will be a plain Promise and this type will be removed. + * + * Typically instances of `params` can be updated automatically to be treated as a Promise by a codemod published alongside this Next.js version however if you + * have not yet run the codemod of the codemod cannot detect certain instances of `params` usage you should first try to refactor your code to await `params`. + * + * If refactoring is not possible but you still want to be able to access params directly without typescript errors you can cast the params Promise to this type + * + * ```tsx + * type Props = { params: Promise<{ id: string }>} + * + * export default async function Layout(props: Props) { + * const directParams = (props.params as unknown as UnsafeUnwrappedParams) + * return ... + * } + * ``` + * + * This type is marked deprecated to help identify it as target for refactoring away. + * + * @deprecated + */ +export type UnsafeUnwrappedParams

= + P extends Promise ? Omit : never + +export function createPrerenderParamsFromClient( + underlyingParams: Params, + staticGenerationStore: StaticGenerationStore +) { + return createPrerenderParams(underlyingParams, staticGenerationStore) +} + +export function createRenderParamsFromClient( + underlyingParams: Params, + staticGenerationStore: StaticGenerationStore +) { + return createRenderParams(underlyingParams, staticGenerationStore) +} + +// generateMetadata always runs in RSC context so it is equivalent to a Server Page Component +export type CreateServerParamsForMetadata = typeof createServerParamsForMetadata +export const createServerParamsForMetadata = createServerParamsForServerSegment + +// routes always runs in RSC context so it is equivalent to a Server Page Component +export function createServerParamsForRoute( + underlyingParams: Params, + staticGenerationStore: StaticGenerationStore +) { + if (staticGenerationStore.isStaticGeneration) { + return createPrerenderParams(underlyingParams, staticGenerationStore) + } else { + return createRenderParams(underlyingParams, staticGenerationStore) + } +} + +export function createServerParamsForServerSegment( + underlyingParams: Params, + staticGenerationStore: StaticGenerationStore +): Promise { + if (staticGenerationStore.isStaticGeneration) { + return createPrerenderParams(underlyingParams, staticGenerationStore) + } else { + return createRenderParams(underlyingParams, staticGenerationStore) + } +} + +export function createPrerenderParamsForClientSegment( + underlyingParams: Params, + staticGenerationStore: StaticGenerationStore +): Promise { + const prerenderStore = prerenderAsyncStorage.getStore() + if (prerenderStore) { + if (isDynamicIOPrerender(prerenderStore)) { + const fallbackParams = staticGenerationStore.fallbackRouteParams + if (fallbackParams) { + for (let key in underlyingParams) { + if (fallbackParams.has(key)) { + // This params object has one of more fallback params so we need to consider + // the awaiting of this params object "dynamic". Since we are in dynamicIO mode + // we encode this as a promise that never resolves + return makeHangingPromise() + } + } + } + } + } + // We're prerendering in a mode that does not abort. We resolve the promise without + // any tracking because we're just transporting a value from server to client where the tracking + // will be applied. + return makeResolvedReactPromise(underlyingParams) +} + +function createPrerenderParams( + underlyingParams: Params, + staticGenerationStore: StaticGenerationStore +): Promise { + const fallbackParams = staticGenerationStore.fallbackRouteParams + if (fallbackParams) { + let hasSomeFallbackParams = false + for (const key in underlyingParams) { + if (fallbackParams.has(key)) { + hasSomeFallbackParams = true + break + } + } + + if (hasSomeFallbackParams) { + // params need to be treated as dynamic because we have at least one fallback param + const prerenderStore = prerenderAsyncStorage.getStore() + if (prerenderStore) { + if (isDynamicIOPrerender(prerenderStore)) { + // We are in a dynamicIO (PPR or otherwise) prerender + return makeAbortingExoticParams( + underlyingParams, + staticGenerationStore.route, + prerenderStore + ) + } + } + // We aren't in a dynamicIO prerender but we do have fallback params at this + // level so we need to make an erroring exotic params object which will postpone + // if you access the fallback params + return makeErroringExoticParams( + underlyingParams, + fallbackParams, + staticGenerationStore, + prerenderStore + ) + } + } + + // We don't have any fallback params so we have an entirely static safe params object + return makeUntrackedExoticParams(underlyingParams) +} + +function createRenderParams( + underlyingParams: Params, + staticGenerationStore: StaticGenerationStore +): Promise { + if (process.env.NODE_ENV === 'development') { + return makeDynamicallyTrackedExoticParamsWithDevWarnings( + underlyingParams, + staticGenerationStore + ) + } else { + return makeUntrackedExoticParams(underlyingParams) + } +} + +interface CacheLifetime {} +const CachedParams = new WeakMap>() + +function makeAbortingExoticParams( + underlyingParams: Params, + route: string, + prerenderStore: PrerenderStore +): Promise { + const cachedParams = CachedParams.get(underlyingParams) + if (cachedParams) { + return cachedParams + } + + const promise = makeHangingPromise() + CachedParams.set(underlyingParams, promise) + + Object.keys(underlyingParams).forEach((prop) => { + switch (prop) { + case 'then': + case 'status': { + // We can't assign params over these properties because the VM and React use + // them to reason about the Promise. + break + } + default: { + Object.defineProperty(promise, prop, { + get() { + const expression = describeStringPropertyAccess('params', prop) + abortAndThrowOnSynchronousDynamicDataAccess( + route, + expression, + prerenderStore + ) + }, + set(newValue) { + Object.defineProperty(promise, prop, { + value: newValue, + writable: true, + enumerable: true, + }) + }, + enumerable: true, + configurable: true, + }) + } + } + }) + + return promise +} + +function makeErroringExoticParams( + underlyingParams: Params, + fallbackParams: FallbackRouteParams, + staticGenerationStore: StaticGenerationStore, + prerenderStore: undefined | PrerenderStore +): Promise { + const cachedParams = CachedParams.get(underlyingParams) + if (cachedParams) { + return cachedParams + } + + const augmentedUnderlying = { ...underlyingParams } + + // We don't use makeResolvedReactPromise here because params + // supports copying with spread and we don't want to unnecessarily + // instrument the promise with spreadable properties of ReactPromise. + const promise = Promise.resolve(augmentedUnderlying) + CachedParams.set(underlyingParams, promise) + + Object.keys(underlyingParams).forEach((prop) => { + switch (prop) { + case 'then': + case 'status': + case 'value': { + // We can't assign params over these properties because the VM and React use + // them to reason about the Promise. + break + } + default: { + if (fallbackParams.has(prop)) { + Object.defineProperty(augmentedUnderlying, prop, { + get() { + const expression = describeStringPropertyAccess('params', prop) + if (staticGenerationStore.dynamicShouldError) { + throwWithStaticGenerationBailoutErrorWithDynamicError( + staticGenerationStore.route, + expression + ) + } else if (prerenderStore) { + postponeWithTracking( + staticGenerationStore.route, + expression, + prerenderStore.dynamicTracking + ) + } else { + throwToInterruptStaticGeneration( + expression, + staticGenerationStore + ) + } + }, + enumerable: true, + }) + Object.defineProperty(promise, prop, { + get() { + const expression = describeStringPropertyAccess('params', prop) + if (staticGenerationStore.dynamicShouldError) { + throwWithStaticGenerationBailoutErrorWithDynamicError( + staticGenerationStore.route, + expression + ) + } else if (prerenderStore) { + postponeWithTracking( + staticGenerationStore.route, + expression, + prerenderStore.dynamicTracking + ) + } else { + throwToInterruptStaticGeneration( + expression, + staticGenerationStore + ) + } + }, + set(newValue) { + Object.defineProperty(promise, prop, { + value: newValue, + writable: true, + enumerable: true, + }) + }, + enumerable: true, + configurable: true, + }) + } else { + ;(promise as any)[prop] = underlyingParams[prop] + } + } + } + }) + + return promise +} + +function makeUntrackedExoticParams(underlyingParams: Params): Promise { + const cachedParams = CachedParams.get(underlyingParams) + if (cachedParams) { + return cachedParams + } + + // We don't use makeResolvedReactPromise here because params + // supports copying with spread and we don't want to unnecessarily + // instrument the promise with spreadable properties of ReactPromise. + const promise = Promise.resolve(underlyingParams) + CachedParams.set(underlyingParams, promise) + + Object.keys(underlyingParams).forEach((prop) => { + switch (prop) { + case 'then': + case 'value': + case 'status': { + // These properties cannot be shadowed with a search param because they + // are necessary for ReactPromise's to work correctly with `use` + break + } + default: { + ;(promise as any)[prop] = underlyingParams[prop] + } + } + }) + + return promise +} + +function makeDynamicallyTrackedExoticParamsWithDevWarnings( + underlyingParams: Params, + store: StaticGenerationStore +): Promise { + const cachedParams = CachedParams.get(underlyingParams) + if (cachedParams) { + return cachedParams + } + + // We don't use makeResolvedReactPromise here because params + // supports copying with spread and we don't want to unnecessarily + // instrument the promise with spreadable properties of ReactPromise. + const promise = Promise.resolve(underlyingParams) + + const proxiedProperties = new Set() + const unproxiedProperties: Array = [] + + Object.keys(underlyingParams).forEach((prop) => { + switch (prop) { + case 'then': + case 'value': + case 'status': { + // These properties cannot be shadowed with a search param because they + // are necessary for ReactPromise's to work correctly with `use` + unproxiedProperties.push(prop) + break + } + default: { + proxiedProperties.add(prop) + ;(promise as any)[prop] = underlyingParams[prop] + } + } + }) + + const proxiedPromise = new Proxy(promise, { + get(target, prop, receiver) { + if (typeof prop === 'string') { + if ( + // We are accessing a property that was proxied to the promise instance + proxiedProperties.has(prop) + ) { + const expression = describeStringPropertyAccess('params', prop) + warnForSyncAccess(store.route, expression) + } + } + return ReflectAdapter.get(target, prop, receiver) + }, + ownKeys(target) { + warnForEnumeration(store.route, unproxiedProperties) + return Reflect.ownKeys(target) + }, + }) + + CachedParams.set(underlyingParams, proxiedPromise) + return proxiedPromise +} + +function warnForSyncAccess(route: undefined | string, expression: string) { + const prefix = route ? ` In route ${route} a ` : 'A ' + console.error( + `${prefix}param property was accessed directly with ${expression}. \`params\` is now a Promise and should be awaited before accessing properties of the underlying params object. In this version of Next.js direct access to param properties is still supported to facilitate migration but in a future version you will be required to await \`params\`. If this use is inside an async function await it. If this use is inside a synchronous function then convert the function to async or await it from outside this function and pass the result in.` + ) +} + +function warnForEnumeration( + route: undefined | string, + missingProperties: Array +) { + const prefix = route ? ` In route ${route} ` : '' + if (missingProperties.length) { + const describedMissingProperties = + describeListOfPropertyNames(missingProperties) + console.error( + `${prefix}params are being enumerated incompletely with \`{...params}\`, \`Object.keys(params)\`, or similar. The following properties were not copied: ${describedMissingProperties}. \`params\` is now a Promise, however in the current version of Next.js direct access to the underlying params object is still supported to facilitate migration to the new type. param names that conflict with Promise properties cannot be accessed directly and must be accessed by first awaiting the \`params\` promise.` + ) + } else { + console.error( + `${prefix}params are being enumerated with \`{...params}\`, \`Object.keys(params)\`, or similar. \`params\` is now a Promise, however in the current version of Next.js direct access to the underlying params object is still supported to facilitate migration to the new type. You should update your code to await \`params\` before accessing its properties.` + ) + } +} + +function describeListOfPropertyNames(properties: Array) { + switch (properties.length) { + case 0: + throw new InvariantError( + 'Expected describeListOfPropertyNames to be called with a non-empty list of strings.' + ) + case 1: + return `\`${properties[0]}\`` + case 2: + return `\`${properties[0]}\` and \`${properties[1]}\`` + default: { + let description = '' + for (let i = 0; i < properties.length - 1; i++) { + description += `\`${properties[i]}\`, ` + } + description += `, and \`${properties[properties.length - 1]}\`` + return description + } + } +} diff --git a/packages/next/src/server/request/search-params.browser.ts b/packages/next/src/server/request/search-params.browser.ts new file mode 100644 index 0000000000000..94548cd55af79 --- /dev/null +++ b/packages/next/src/server/request/search-params.browser.ts @@ -0,0 +1,132 @@ +import type { SearchParams } from './search-params' + +import { ReflectAdapter } from '../web/spec-extension/adapters/reflect' +import { + describeStringPropertyAccess, + describeHasCheckingStringProperty, +} from './utils' + +export function createRenderSearchParamsFromClient( + underlyingSearchParams: SearchParams +): Promise { + if (process.env.NODE_ENV === 'development') { + return makeUntrackedExoticSearchParamsWithDevWarnings( + underlyingSearchParams + ) + } else { + return makeUntrackedExoticSearchParams(underlyingSearchParams) + } +} + +interface CacheLifetime {} +const CachedSearchParams = new WeakMap>() + +function makeUntrackedExoticSearchParamsWithDevWarnings( + underlyingSearchParams: SearchParams +): Promise { + const cachedSearchParams = CachedSearchParams.get(underlyingSearchParams) + if (cachedSearchParams) { + return cachedSearchParams + } + + const promise = Promise.resolve(underlyingSearchParams) + Object.defineProperties(promise, { + status: { + value: 'fulfilled', + }, + value: { + value: underlyingSearchParams, + }, + }) + + Object.keys(underlyingSearchParams).forEach((prop) => { + if (Reflect.has(promise, prop)) { + // We can't assign a value over a property on the promise. The only way to + // access this is if you await the promise and recover the underlying searchParams object. + } else { + Object.defineProperty(promise, prop, { + value: underlyingSearchParams[prop], + writable: false, + enumerable: true, + }) + } + }) + + const proxiedPromise = new Proxy(promise, { + get(target, prop, receiver) { + if (Reflect.has(target, prop)) { + return ReflectAdapter.get(target, prop, receiver) + } else if (typeof prop === 'symbol') { + return undefined + } else { + const expression = describeStringPropertyAccess('searchParams', prop) + warnForSyncAccess(expression) + return underlyingSearchParams[prop] + } + }, + has(target, prop) { + if (Reflect.has(target, prop)) { + return true + } else if (typeof prop === 'symbol') { + // searchParams never has symbol properties containing searchParam data + // and we didn't match above so we just return false here. + return false + } else { + const expression = describeHasCheckingStringProperty( + 'searchParams', + prop + ) + warnForSyncAccess(expression) + return Reflect.has(underlyingSearchParams, prop) + } + }, + ownKeys(target) { + warnForSyncSpread() + return Reflect.ownKeys(target) + }, + }) + + CachedSearchParams.set(underlyingSearchParams, proxiedPromise) + return proxiedPromise +} + +function makeUntrackedExoticSearchParams( + underlyingSearchParams: SearchParams +): Promise { + const promise = Promise.resolve(underlyingSearchParams) + Object.defineProperties(promise, { + status: { + value: 'fulfilled', + }, + value: { + value: underlyingSearchParams, + }, + }) + + Object.keys(underlyingSearchParams).forEach((prop) => { + if (Reflect.has(promise, prop)) { + // We can't assign a value over a property on the promise. The only way to + // access this is if you await the promise and recover the underlying searchParams object. + } else { + Object.defineProperty(promise, prop, { + value: underlyingSearchParams[prop], + writable: false, + enumerable: true, + }) + } + }) + + return promise +} + +function warnForSyncAccess(expression: string) { + console.error( + `A searchParam property was accessed directly with ${expression}. \`searchParams\` is now a Promise and should be awaited before accessing properties of the underlying searchParams object. In this version of Next.js direct access to searchParam properties is still supported to facilitate migration but in a future version you will be required to await \`searchParams\`. If this use is inside an async function await it. If this use is inside a synchronous function then convert the function to async or await it from outside this function and pass the result in.` + ) +} + +function warnForSyncSpread() { + console.error( + `the keys of \`searchParams\` were accessed through something like \`Object.keys(searchParams)\` or \`{...searchParams}\`. \`searchParams\` is now a Promise and should be awaited before accessing properties of the underlying searchParams object. In this version of Next.js direct access to searchParam properties is still supported to facilitate migration but in a future version you will be required to await \`searchParams\`. If this use is inside an async function await it. If this use is inside a synchronous function then convert the function to async or await it from outside this function and pass the result in.` + ) +} diff --git a/packages/next/src/server/request/search-params.ts b/packages/next/src/server/request/search-params.ts index 1a8a7fe5e98a6..b0171480e26a8 100644 --- a/packages/next/src/server/request/search-params.ts +++ b/packages/next/src/server/request/search-params.ts @@ -1,73 +1,610 @@ -import type { ParsedUrlQuery } from 'querystring' +import type { StaticGenerationStore } from '../../client/components/static-generation-async-storage.external' -import { staticGenerationAsyncStorage } from '../../client/components/static-generation-async-storage.external' -import { trackDynamicDataAccessed } from '../app-render/dynamic-rendering' import { ReflectAdapter } from '../web/spec-extension/adapters/reflect' +import { + abortAndThrowOnSynchronousDynamicDataAccess, + throwToInterruptStaticGeneration, + postponeWithTracking, + trackDynamicDataInDynamicRender, + annotateDynamicAccess, +} from '../app-render/dynamic-rendering' + +import { + isDynamicIOPrerender, + prerenderAsyncStorage, + type PrerenderStore, +} from '../app-render/prerender-async-storage.external' +import { InvariantError } from '../../shared/lib/invariant-error' +import { makeHangingPromise } from '../dynamic-rendering-utils' +import { + describeStringPropertyAccess, + describeHasCheckingStringProperty, + throwWithStaticGenerationBailoutErrorWithDynamicError, +} from './utils' + +export type SearchParams = { [key: string]: string | string[] | undefined } /** - * Takes a ParsedUrlQuery object and either returns it unmodified or returns an empty object + * In this version of Next.js the `params` prop passed to Layouts, Pages, and other Segments is a Promise. + * However to facilitate migration to this new Promise type you can currently still access params directly on the Promise instance passed to these Segments. + * The `UnsafeUnwrappedSearchParams` type is available if you need to temporarily access the underlying params without first awaiting or `use`ing the Promise. + * + * In a future version of Next.js the `params` prop will be a plain Promise and this type will be removed. + * + * Typically instances of `params` can be updated automatically to be treated as a Promise by a codemod published alongside this Next.js version however if you + * have not yet run the codemod of the codemod cannot detect certain instances of `params` usage you should first try to refactor your code to await `params`. + * + * If refactoring is not possible but you still want to be able to access params directly without typescript errors you can cast the params Promise to this type * - * Even though we do not track read access on the returned searchParams we need to - * return an empty object if we are doing a 'force-static' render. This is to ensure - * we don't encode the searchParams into the flight data. + * ```tsx + * type Props = { searchParams: Promise<{ foo: string }> } + * + * export default async function Page(props: Props) { + * const { searchParams } = (props.searchParams as unknown as UnsafeUnwrappedSearchParams) + * return ... + * } + * ``` + * + * This type is marked deprecated to help identify it as target for refactoring away. + * + * @deprecated */ -export function createUntrackedSearchParams( - searchParams: ParsedUrlQuery -): ParsedUrlQuery { - const store = staticGenerationAsyncStorage.getStore() - if (store && store.forceStatic) { - return {} +export type UnsafeUnwrappedSearchParams

= + P extends Promise ? Omit : never + +export function createPrerenderSearchParamsFromClient( + staticGenerationStore: StaticGenerationStore +) { + return createPrerenderSearchParams(staticGenerationStore) +} + +export function createRenderSearchParamsFromClient( + underlyingSearchParams: SearchParams, + staticGenerationStore: StaticGenerationStore +) { + return createRenderSearchParams(underlyingSearchParams, staticGenerationStore) +} + +// generateMetadata always runs in RSC context so it is equivalent to a Server Page Component +export const createServerSearchParamsForMetadata = + createServerSearchParamsForServerPage + +export function createServerSearchParamsForServerPage( + underlyingSearchParams: SearchParams, + staticGenerationStore: StaticGenerationStore +): Promise { + if (staticGenerationStore.isStaticGeneration) { + return createPrerenderSearchParams(staticGenerationStore) } else { - return searchParams + return createRenderSearchParams( + underlyingSearchParams, + staticGenerationStore + ) } } -/** - * Takes a ParsedUrlQuery object and returns a Proxy that tracks read access to the object - * - * If running in the browser will always return the provided searchParams object. - * When running during SSR will return empty during a 'force-static' render and - * otherwise it returns a searchParams object which tracks reads to trigger dynamic rendering - * behavior if appropriate - */ -export function createDynamicallyTrackedSearchParams( - searchParams: ParsedUrlQuery -): ParsedUrlQuery { - const store = staticGenerationAsyncStorage.getStore() - if (!store) { - // we assume we are in a route handler or page render. just return the searchParams - return searchParams - } else if (store.forceStatic) { - // If we forced static we omit searchParams entirely. This is true both during SSR - // and browser render because we need there to be parity between these environments - return {} +export function createPrerenderSearchParamsForClientPage( + staticGenerationStore: StaticGenerationStore +): Promise { + if (staticGenerationStore.forceStatic) { + // When using forceStatic we override all other logic and always just return an empty + // dictionary object. + return Promise.resolve({}) + } + + const prerenderStore = prerenderAsyncStorage.getStore() + if (prerenderStore) { + if (isDynamicIOPrerender(prerenderStore)) { + // We're prerendering in a mode that aborts (dynamicIO) and should stall + // the promise to ensure the RSC side is considered dynamic + return makeHangingPromise() + } + } + // We're prerendering in a mode that does not aborts. We resolve the promise without + // any tracking because we're just transporting a value from server to client where the tracking + // will be applied. + return Promise.resolve({}) +} + +function createPrerenderSearchParams( + staticGenerationStore: StaticGenerationStore +): Promise { + if (staticGenerationStore.forceStatic) { + // When using forceStatic we override all other logic and always just return an empty + // dictionary object. + return Promise.resolve({}) + } + + const prerenderStore = prerenderAsyncStorage.getStore() + if (prerenderStore) { + if (prerenderStore.controller || prerenderStore.cacheSignal) { + // We are in a dynamicIO (PPR or otherwise) prerender + return makeAbortingExoticSearchParams( + staticGenerationStore.route, + prerenderStore + ) + } + } + + // We are in a legacy static generation and need to interrupt the prerender + // when search params are accessed. + return makeErroringExoticSearchParams(staticGenerationStore, prerenderStore) +} + +function createRenderSearchParams( + underlyingSearchParams: SearchParams, + staticGenerationStore: StaticGenerationStore +): Promise { + if (staticGenerationStore.forceStatic) { + // When using forceStatic we override all other logic and always just return an empty + // dictionary object. + return Promise.resolve({}) } else { - // We need to track dynamic access with a Proxy. If `dynamic = "error"`, we use this information - // to fail the build. This also signals to the patched fetch that it's inside - // of a dynamic render and should bail from data cache. We implement get, has, and ownKeys because - // these can all be used to exfiltrate information about searchParams. - - const trackedSearchParams: ParsedUrlQuery = store.isStaticGeneration - ? {} - : searchParams - - return new Proxy(trackedSearchParams, { - get(target, prop, receiver) { - if (typeof prop === 'string') { - trackDynamicDataAccessed(store, `searchParams.${prop}`) + if (process.env.NODE_ENV === 'development') { + return makeDynamicallyTrackedExoticSearchParamsWithDevWarnings( + underlyingSearchParams, + staticGenerationStore + ) + } else { + return makeUntrackedExoticSearchParams( + underlyingSearchParams, + staticGenerationStore + ) + } + } +} + +interface CacheLifetime {} +const CachedSearchParams = new WeakMap>() + +function makeAbortingExoticSearchParams( + route: string, + prerenderStore: PrerenderStore +): Promise { + const cachedSearchParams = CachedSearchParams.get(prerenderStore) + if (cachedSearchParams) { + return cachedSearchParams + } + + const promise = makeHangingPromise() + + const proxiedPromise = new Proxy(promise, { + get(target, prop, receiver) { + if (Object.hasOwn(promise, prop)) { + // The promise has this property directly. we must return it. + // We know it isn't a dynamic access because it can only be something + // that was previously written to the promise and thus not an underlying searchParam value + return ReflectAdapter.get(target, prop, receiver) + } + + switch (prop) { + case 'then': { + const expression = + '`await searchParams`, `searchParams.then`, or similar' + annotateDynamicAccess(expression, prerenderStore) + return ReflectAdapter.get(target, prop, receiver) + } + case 'status': { + const expression = + '`use(searchParams)`, `searchParams.status`, or similar' + annotateDynamicAccess(expression, prerenderStore) + return ReflectAdapter.get(target, prop, receiver) } + default: { + if (typeof prop === 'string') { + const expression = describeStringPropertyAccess( + 'searchParams', + prop + ) + abortAndThrowOnSynchronousDynamicDataAccess( + route, + expression, + prerenderStore + ) + } + return ReflectAdapter.get(target, prop, receiver) + } + } + }, + has(target, prop) { + // We don't expect key checking to be used except for testing the existence of + // searchParams so we make all has tests trigger dynamic. this means that `promise.then` + // can resolve to the then function on the Promise prototype but 'then' in promise will assume + // you are testing whether the searchParams has a 'then' property. + if (typeof prop === 'string') { + const expression = describeHasCheckingStringProperty( + 'searchParams', + prop + ) + abortAndThrowOnSynchronousDynamicDataAccess( + route, + expression, + prerenderStore + ) + } + return ReflectAdapter.has(target, prop) + }, + ownKeys() { + const expression = + '`{...searchParams}`, `Object.keys(searchParams)`, or similar' + abortAndThrowOnSynchronousDynamicDataAccess( + route, + expression, + prerenderStore + ) + }, + }) + + CachedSearchParams.set(prerenderStore, proxiedPromise) + return proxiedPromise +} + +function makeErroringExoticSearchParams( + staticGenerationStore: StaticGenerationStore, + prerenderStore: undefined | PrerenderStore +): Promise { + const cachedSearchParams = CachedSearchParams.get(staticGenerationStore) + if (cachedSearchParams) { + return cachedSearchParams + } + + const underlyingSearchParams = {} + // For search params we don't construct a ReactPromise because we want to interrupt + // rendering on any property access that was not set from outside and so we only want + // to have properties like value and status if React sets them. + const promise = Promise.resolve(underlyingSearchParams) + + const proxiedPromise = new Proxy(promise, { + get(target, prop, receiver) { + if (Object.hasOwn(promise, prop)) { + // The promise has this property directly. we must return it. + // We know it isn't a dynamic access because it can only be something + // that was previously written to the promise and thus not an underlying searchParam value return ReflectAdapter.get(target, prop, receiver) - }, - has(target, prop) { - if (typeof prop === 'string') { - trackDynamicDataAccessed(store, `searchParams.${prop}`) + } + + switch (prop) { + case 'then': { + const expression = + '`await searchParams`, `searchParams.then`, or similar' + if (staticGenerationStore.dynamicShouldError) { + throwWithStaticGenerationBailoutErrorWithDynamicError( + staticGenerationStore.route, + expression + ) + } else if (prerenderStore) { + postponeWithTracking( + staticGenerationStore.route, + expression, + prerenderStore.dynamicTracking + ) + } else { + throwToInterruptStaticGeneration(expression, staticGenerationStore) + } + return + } + case 'status': { + const expression = + '`use(searchParams)`, `searchParams.status`, or similar' + if (staticGenerationStore.dynamicShouldError) { + throwWithStaticGenerationBailoutErrorWithDynamicError( + staticGenerationStore.route, + expression + ) + } else if (prerenderStore) { + postponeWithTracking( + staticGenerationStore.route, + expression, + prerenderStore.dynamicTracking + ) + } else { + throwToInterruptStaticGeneration(expression, staticGenerationStore) + } + return + } + default: { + if (typeof prop === 'string') { + const expression = describeStringPropertyAccess( + 'searchParams', + prop + ) + if (staticGenerationStore.dynamicShouldError) { + throwWithStaticGenerationBailoutErrorWithDynamicError( + staticGenerationStore.route, + expression + ) + } else if (prerenderStore) { + postponeWithTracking( + staticGenerationStore.route, + expression, + prerenderStore.dynamicTracking + ) + } else { + throwToInterruptStaticGeneration( + expression, + staticGenerationStore + ) + } + } + return ReflectAdapter.get(target, prop, receiver) + } + } + }, + has(target, prop) { + // We don't expect key checking to be used except for testing the existence of + // searchParams so we make all has tests trigger dynamic. this means that `promise.then` + // can resolve to the then function on the Promise prototype but 'then' in promise will assume + // you are testing whether the searchParams has a 'then' property. + if (typeof prop === 'string') { + const expression = describeHasCheckingStringProperty( + 'searchParams', + prop + ) + if (staticGenerationStore.dynamicShouldError) { + throwWithStaticGenerationBailoutErrorWithDynamicError( + staticGenerationStore.route, + expression + ) + } else if (prerenderStore) { + postponeWithTracking( + staticGenerationStore.route, + expression, + prerenderStore.dynamicTracking + ) + } else { + throwToInterruptStaticGeneration(expression, staticGenerationStore) + } + return false + } + return ReflectAdapter.has(target, prop) + }, + ownKeys() { + const expression = + '`{...searchParams}`, `Object.keys(searchParams)`, or similar' + if (staticGenerationStore.dynamicShouldError) { + throwWithStaticGenerationBailoutErrorWithDynamicError( + staticGenerationStore.route, + expression + ) + } else if (prerenderStore) { + postponeWithTracking( + staticGenerationStore.route, + expression, + prerenderStore.dynamicTracking + ) + } else { + throwToInterruptStaticGeneration(expression, staticGenerationStore) + } + }, + }) + + CachedSearchParams.set(staticGenerationStore, proxiedPromise) + return proxiedPromise +} + +function makeUntrackedExoticSearchParams( + underlyingSearchParams: SearchParams, + store: StaticGenerationStore +): Promise { + const cachedSearchParams = CachedSearchParams.get(underlyingSearchParams) + if (cachedSearchParams) { + return cachedSearchParams + } + + // We don't use makeResolvedReactPromise here because searchParams + // supports copying with spread and we don't want to unnecessarily + // instrument the promise with spreadable properties of ReactPromise. + const promise = Promise.resolve(underlyingSearchParams) + CachedSearchParams.set(underlyingSearchParams, promise) + + Object.keys(underlyingSearchParams).forEach((prop) => { + switch (prop) { + case 'then': + case 'value': + case 'status': { + // These properties cannot be shadowed with a search param because they + // are necessary for ReactPromise's to work correctly with `use` + break + } + default: { + Object.defineProperty(promise, prop, { + get() { + trackDynamicDataInDynamicRender(store) + return underlyingSearchParams[prop] + }, + set(value) { + Object.defineProperty(promise, prop, { + value, + writable: true, + enumerable: true, + }) + }, + enumerable: true, + configurable: true, + }) + } + } + }) + + return promise +} + +function makeDynamicallyTrackedExoticSearchParamsWithDevWarnings( + underlyingSearchParams: SearchParams, + store: StaticGenerationStore +): Promise { + const cachedSearchParams = CachedSearchParams.get(underlyingSearchParams) + if (cachedSearchParams) { + return cachedSearchParams + } + + const proxiedProperties = new Set() + const unproxiedProperties: Array = [] + + // We have an unfortunate sequence of events that requires this initialization logic. We want to instrument the underlying + // searchParams object to detect if you are accessing values in dev. This is used for warnings and for things like the static prerender + // indicator. However when we pass this proxy to our Promise.resolve() below the VM checks if the resolved value is a promise by looking + // at the `.then` property. To our dynamic tracking logic this is indistinguishable from a `then` searchParam and so we would normally trigger + // dynamic tracking. However we know that this .then is not real dynamic access, it's just how thenables resolve in sequence. So we introduce + // this initialization concept so we omit the dynamic check until after we've constructed our resolved promise. + let promiseInitialized = false + const proxiedUnderlying = new Proxy(underlyingSearchParams, { + get(target, prop, receiver) { + if (typeof prop === 'string' && promiseInitialized) { + if (store.dynamicShouldError) { + const expression = describeStringPropertyAccess('searchParams', prop) + throwWithStaticGenerationBailoutErrorWithDynamicError( + store.route, + expression + ) + } + trackDynamicDataInDynamicRender(store) + } + return ReflectAdapter.get(target, prop, receiver) + }, + has(target, prop) { + if (typeof prop === 'string') { + if (store.dynamicShouldError) { + const expression = describeHasCheckingStringProperty( + 'searchParams', + prop + ) + throwWithStaticGenerationBailoutErrorWithDynamicError( + store.route, + expression + ) + } + } + return Reflect.has(target, prop) + }, + ownKeys(target) { + if (store.dynamicShouldError) { + const expression = + '`{...searchParams}`, `Object.keys(searchParams)`, or similar' + throwWithStaticGenerationBailoutErrorWithDynamicError( + store.route, + expression + ) + } + return Reflect.ownKeys(target) + }, + }) + + // We don't use makeResolvedReactPromise here because searchParams + // supports copying with spread and we don't want to unnecessarily + // instrument the promise with spreadable properties of ReactPromise. + const promise = Promise.resolve(proxiedUnderlying) + promise.then(() => { + promiseInitialized = true + }) + + Object.keys(underlyingSearchParams).forEach((prop) => { + switch (prop) { + case 'then': + case 'value': + case 'status': { + // These properties cannot be shadowed with a search param because they + // are necessary for ReactPromise's to work correctly with `use` + unproxiedProperties.push(prop) + break + } + default: { + proxiedProperties.add(prop) + Object.defineProperty(promise, prop, { + get() { + return proxiedUnderlying[prop] + }, + set(newValue) { + Object.defineProperty(promise, prop, { + value: newValue, + writable: true, + enumerable: true, + }) + }, + enumerable: true, + configurable: true, + }) + } + } + }) + + const proxiedPromise = new Proxy(promise, { + get(target, prop, receiver) { + if (typeof prop === 'string') { + if ( + // We are accessing a property that was proxied to the promise instance + proxiedProperties.has(prop) || + // We are accessing a property that doesn't exist on the promise nor the underlying + Reflect.has(target, prop) === false + ) { + const expression = describeStringPropertyAccess('searchParams', prop) + warnForSyncAccess(store.route, expression) } - return Reflect.has(target, prop) - }, - ownKeys(target) { - trackDynamicDataAccessed(store, 'searchParams') - return Reflect.ownKeys(target) - }, - }) + } + return ReflectAdapter.get(target, prop, receiver) + }, + has(target, prop) { + if (typeof prop === 'string') { + const expression = describeHasCheckingStringProperty( + 'searchParams', + prop + ) + warnForSyncAccess(store.route, expression) + } + return Reflect.has(target, prop) + }, + ownKeys(target) { + warnForEnumeration(store.route, unproxiedProperties) + return Reflect.ownKeys(target) + }, + }) + + CachedSearchParams.set(underlyingSearchParams, proxiedPromise) + return proxiedPromise +} + +function warnForSyncAccess(route: undefined | string, expression: string) { + const prefix = route ? ` In route ${route} a ` : 'A ' + console.error( + `${prefix}searchParam property was accessed directly with ${expression}. \`searchParams\` is now a Promise and should be awaited before accessing properties of the underlying searchParams object. In this version of Next.js direct access to searchParam properties is still supported to facilitate migration but in a future version you will be required to await \`searchParams\`. If this use is inside an async function await it. If this use is inside a synchronous function then convert the function to async or await it from outside this function and pass the result in.` + ) +} + +function warnForEnumeration( + route: undefined | string, + missingProperties: Array +) { + const prefix = route ? ` In route ${route} ` : '' + if (missingProperties.length) { + const describedMissingProperties = + describeListOfPropertyNames(missingProperties) + console.error( + `${prefix}searchParams are being enumerated incompletely with \`{...searchParams}\`, \`Object.keys(searchParams)\`, or similar. The following properties were not copied: ${describedMissingProperties}. \`searchParams\` is now a Promise, however in the current version of Next.js direct access to the underlying searchParams object is still supported to facilitate migration to the new type. search parameter names that conflict with Promise properties cannot be accessed directly and must be accessed by first awaiting the \`searchParams\` promise.` + ) + } else { + console.error( + `${prefix}searchParams are being enumerated with \`{...searchParams}\`, \`Object.keys(searchParams)\`, or similar. \`searchParams\` is now a Promise, however in the current version of Next.js direct access to the underlying searchParams object is still supported to facilitate migration to the new type. You should update your code to await \`searchParams\` before accessing its properties.` + ) + } +} + +function describeListOfPropertyNames(properties: Array) { + switch (properties.length) { + case 0: + throw new InvariantError( + 'Expected describeListOfPropertyNames to be called with a non-empty list of strings.' + ) + case 1: + return `\`${properties[0]}\`` + case 2: + return `\`${properties[0]}\` and \`${properties[1]}\`` + default: { + let description = '' + for (let i = 0; i < properties.length - 1; i++) { + description += `\`${properties[i]}\`, ` + } + description += `, and \`${properties[properties.length - 1]}\`` + return description + } } } diff --git a/packages/next/src/server/request/utils.ts b/packages/next/src/server/request/utils.ts new file mode 100644 index 0000000000000..ebfac7d5be2ca --- /dev/null +++ b/packages/next/src/server/request/utils.ts @@ -0,0 +1,55 @@ +import { StaticGenBailoutError } from '../../client/components/static-generation-bailout' + +/** + * React annotates Promises with extra properties to make unwrapping them synchronous + * after they have resolved. We sometimes create promises that are compatible with this + * internal implementation detail when we want to construct a promise that is already resolved. + * + * @internal + */ +export function makeResolvedReactPromise(value: T): Promise { + const promise = Promise.resolve(value) + ;(promise as any).status = 'fulfilled' + ;(promise as any).value = value + return promise +} + +// This regex will have fast negatives meaning valid identifiers may not pass +// this test. However this is only used during static generation to provide hints +// about why a page bailed out of some or all prerendering and we can use bracket notation +// for example while `ಠ_ಠ` is a valid identifier it's ok to print `searchParams['ಠ_ಠ']` +// even if this would have been fine too `searchParams.ಠ_ಠ` +const isDefinitelyAValidIdentifier = /^[A-Za-z_$][A-Za-z0-9_$]*$/ + +export function describeStringPropertyAccess(target: string, prop: string) { + if (isDefinitelyAValidIdentifier.test(prop)) { + return `\`${target}.${prop}\`` + } + return `\`${target}[${JSON.stringify(prop)}]\`` +} + +export function describeHasCheckingStringProperty( + target: string, + prop: string +) { + const stringifiedProp = JSON.stringify(prop) + return `\`Reflect.has(${target}, ${stringifiedProp}\`, \`${stringifiedProp} in ${target}\`, or similar` +} + +export function throwWithStaticGenerationBailoutError( + route: string, + expression: string +): never { + throw new StaticGenBailoutError( + `Route ${route} couldn't be rendered statically because it used ${expression}. See more info here: https://nextjs.org/docs/app/building-your-application/rendering/static-and-dynamic#dynamic-rendering` + ) +} + +export function throwWithStaticGenerationBailoutErrorWithDynamicError( + route: string, + expression: string +): never { + throw new StaticGenBailoutError( + `Route ${route} with \`dynamic = "error"\` couldn't be rendered statically because it used ${expression}. See more info here: https://nextjs.org/docs/app/building-your-application/rendering/static-and-dynamic#dynamic-rendering` + ) +} diff --git a/packages/next/src/server/route-modules/app-route/module.ts b/packages/next/src/server/route-modules/app-route/module.ts index 9112f8b5c1142..74a70bc354895 100644 --- a/packages/next/src/server/route-modules/app-route/module.ts +++ b/packages/next/src/server/route-modules/app-route/module.ts @@ -68,6 +68,7 @@ import { ReflectAdapter } from '../../web/spec-extension/adapters/reflect' import type { RenderOptsPartial } from '../../app-render/types' import { CacheSignal } from '../../app-render/cache-signal' import { scheduleImmediate } from '../../../lib/scheduler' +import { createServerParamsForRoute } from '../../request/params' /** * The AppRouteModule is the type of the module exported by the bundled App @@ -90,7 +91,7 @@ export interface AppRouteRouteHandlerContext extends RouteModuleHandleContext { * second argument. */ type AppRouteHandlerFnContext = { - params?: Record + params?: Promise> } /** @@ -402,9 +403,12 @@ export class AppRouteRouteModule extends RouteModule< prerenderAsyncStorage: this.prerenderAsyncStorage, }) - const handlerContext = { + const handlerContext: AppRouteHandlerFnContext = { params: context.params - ? parsedUrlQueryToParams(context.params) + ? createServerParamsForRoute( + parsedUrlQueryToParams(context.params), + staticGenerationStore + ) : undefined, } diff --git a/packages/next/src/server/stream-utils/node-web-streams-helper.ts b/packages/next/src/server/stream-utils/node-web-streams-helper.ts index dc9beea33b4e8..4e07dd231c6b0 100644 --- a/packages/next/src/server/stream-utils/node-web-streams-helper.ts +++ b/packages/next/src/server/stream-utils/node-web-streams-helper.ts @@ -195,7 +195,7 @@ export function renderToInitialFizzStream({ }: { ReactDOMServer: typeof import('react-dom/server.edge') element: React.ReactElement - streamOptions?: any + streamOptions?: Parameters[1] }): Promise { return getTracer().trace(AppRenderSpan.renderToReadableStream, async () => ReactDOMServer.renderToReadableStream(element, streamOptions) diff --git a/packages/next/src/server/use-cache/use-cache-wrapper.ts b/packages/next/src/server/use-cache/use-cache-wrapper.ts new file mode 100644 index 0000000000000..ecfe5c7675cbf --- /dev/null +++ b/packages/next/src/server/use-cache/use-cache-wrapper.ts @@ -0,0 +1,247 @@ +import { createSnapshot } from '../../client/components/async-local-storage' +/* eslint-disable import/no-extraneous-dependencies */ +import { + renderToReadableStream, + decodeReply, + createTemporaryReferenceSet as createServerTemporaryReferenceSet, +} from 'react-server-dom-webpack/server.edge' +/* eslint-disable import/no-extraneous-dependencies */ +import { + createFromReadableStream, + encodeReply, + createTemporaryReferenceSet as createClientTemporaryReferenceSet, +} from 'react-server-dom-webpack/client.edge' + +import type { StaticGenerationStore } from '../../client/components/static-generation-async-storage.external' +import { staticGenerationAsyncStorage } from '../../client/components/static-generation-async-storage.external' + +type CacheEntry = { + value: ReadableStream + stale: boolean +} + +interface CacheHandler { + get(cacheKey: string | ArrayBuffer): Promise + set(cacheKey: string | ArrayBuffer, value: ReadableStream): Promise + shouldRevalidateStale: boolean +} + +const cacheHandlerMap: Map = new Map() + +// TODO: Move default implementation to be injectable. +const defaultCacheStorage: Map = new Map() +cacheHandlerMap.set('default', { + async get(cacheKey: string | ArrayBuffer) { + // TODO: Implement proper caching. + if (typeof cacheKey === 'string') { + const value = defaultCacheStorage.get(cacheKey) + if (value !== undefined) { + const [returnStream, newSaved] = value.tee() + defaultCacheStorage.set(cacheKey, newSaved) + return { + value: returnStream, + stale: false, + } + } + } else { + // TODO: Handle binary keys. + } + return undefined + }, + async set(cacheKey: string | ArrayBuffer, value: ReadableStream) { + // TODO: Implement proper caching. + if (typeof cacheKey === 'string') { + defaultCacheStorage.set(cacheKey, value) + } else { + // TODO: Handle binary keys. + await value.cancel() + } + }, + // In-memory caches are fragile and should not use stale-while-revalidate + // semantics on the caches because it's not worth warming up an entry that's + // likely going to get evicted before we get to use it anyway. + shouldRevalidateStale: false, +}) + +const serverManifest: any = null // TODO +const clientManifest: any = null // TODO +const ssrManifest: any = { + moduleMap: {}, + moduleLoading: null, +} // TODO + +// TODO: Consider moving this another module that is guaranteed to be required in a safe scope. +const runInCleanSnapshot = createSnapshot() + +async function generateCacheEntry( + staticGenerationStore: StaticGenerationStore, + cacheHandler: CacheHandler, + serializedCacheKey: string | ArrayBuffer, + encodedArguments: FormData | string, + fn: any +): Promise { + const temporaryReferences = createServerTemporaryReferenceSet() + const [, args] = await decodeReply(encodedArguments, serverManifest, { + temporaryReferences, + }) + + // Invoke the inner function to load a new result. + const result = fn.apply(null, args) + + let didError = false + let firstError: any = null + + const stream = renderToReadableStream(result, clientManifest, { + environmentName: 'Cache', + temporaryReferences, + onError(error: any) { + // Report the error. + console.error(error) + if (!didError) { + didError = true + firstError = error + } + }, + }) + + const [returnStream, savedStream] = stream.tee() + + // We create a stream that passed through the RSC render of the response. + // It always runs to completion but at the very end, if something errored + // or rejected anywhere in the render. We close the stream as errored. + // This lets a CacheHandler choose to save the errored result for future + // hits for a while to avoid unnecessary retries or not to retry. + // We use the end of the stream for this to avoid another complicated + // side-channel. A receiver has to consider that the stream might also + // error for other reasons anyway such as losing connection. + const reader = savedStream.getReader() + const erroringSavedStream = new ReadableStream({ + pull(controller) { + return reader.read().then(({ done, value }) => { + if (done) { + if (didError) { + controller.error(firstError) + } else { + controller.close() + } + return + } + controller.enqueue(value) + }) + }, + cancel(reason: any) { + reader.cancel(reason) + }, + }) + + if (!staticGenerationStore.pendingRevalidateWrites) { + staticGenerationStore.pendingRevalidateWrites = [] + } + + const promise = cacheHandler.set(serializedCacheKey, erroringSavedStream) + + staticGenerationStore.pendingRevalidateWrites.push(promise) + + // Return the stream as we're creating it. This means that if it ends up + // erroring we cannot return a stale-while-error version but it allows + // streaming back the result earlier. + return returnStream +} + +export function cache(kind: string, id: string, fn: any) { + const cacheHandler = cacheHandlerMap.get(kind) + if (cacheHandler === undefined) { + throw new Error('Unknown cache handler: ' + kind) + } + const name = fn.name + const cachedFn = { + [name]: async function (...args: any[]) { + const temporaryReferences = createClientTemporaryReferenceSet() + const encodedArguments: FormData | string = await encodeReply( + [id, args], + { + temporaryReferences, + } + ) + + const serializedCacheKey = + typeof encodedArguments === 'string' + ? // Fast path for the simple case for simple inputs. We let the CacheHandler + // Convert it to an ArrayBuffer if it wants to. + encodedArguments + : // The FormData might contain binary data that is not valid UTF-8 so this + // cannot be a string in this case. I.e. .text() is not valid here and it + // is not valid to use TextDecoder on this result. + await new Response(encodedArguments).arrayBuffer() + + let entry: undefined | CacheEntry = + await cacheHandler.get(serializedCacheKey) + + const staticGenerationStore = staticGenerationAsyncStorage.getStore() + if (staticGenerationStore === undefined) { + throw new Error( + '"use cache" cannot be used outside of App Router. Expected a StaticGenerationStore.' + ) + } + + let stream + if ( + entry === undefined || + (staticGenerationStore.isStaticGeneration && entry.stale) + ) { + // Miss. Generate a new result. + + // If the cache entry is stale and we're prerendering, we don't want to use the + // stale entry since it would unnecessarily need to shorten the lifetime of the + // prerender. We're not time constrained here so we can re-generated it now. + + // We need to run this inside a clean AsyncLocalStorage snapshot so that the cache + // generation cannot read anything from the context we're currently executing which + // might include request specific things like cookies() inside a React.cache(). + // Note: It is important that we await at least once before this because it lets us + // pop out of any stack specific contexts as well - aka "Sync" Local Storage. + stream = await runInCleanSnapshot( + generateCacheEntry, + staticGenerationStore, + cacheHandler, + serializedCacheKey, + encodedArguments, + fn + ) + } else { + stream = entry.value + if (entry.stale && cacheHandler.shouldRevalidateStale) { + // If this is stale, and we're not in a prerender (i.e. this is dynamic render), + // then we should warm up the cache with a fresh revalidated entry. We only do this + // for long lived cache handlers because it's not worth warming up the cache with an + // an entry that's just going to get evicted before we can use it anyway. + const ignoredStream = await runInCleanSnapshot( + generateCacheEntry, + staticGenerationStore, + cacheHandler, + serializedCacheKey, + encodedArguments, + fn + ) + await ignoredStream.cancel() + } + } + + // Logs are replayed even if it's a hit - to ensure we see them on the client eventually. + // If we didn't then the client wouldn't see the logs if it was seeded from a prewarm that + // never made it to the client. However, this also means that you see logs even when the + // cached function isn't actually re-executed. We should instead ensure prewarms always + // make it to the client. Another issue is that this will cause double logging in the + // server terminal. Once while generating the cache entry and once when replaying it on + // the server, which is required to pick it up for replaying again on the client. + const replayConsoleLogs = true + return createFromReadableStream(stream, { + ssrManifest, + temporaryReferences, + replayConsoleLogs, + environmentName: 'Cache', + }) + }, + }[name] + return cachedFn +} diff --git a/packages/next/src/server/web/spec-extension/adapters/request-cookies.ts b/packages/next/src/server/web/spec-extension/adapters/request-cookies.ts index 659b7073d9622..e6dde748ebbdc 100644 --- a/packages/next/src/server/web/spec-extension/adapters/request-cookies.ts +++ b/packages/next/src/server/web/spec-extension/adapters/request-cookies.ts @@ -19,6 +19,9 @@ export class ReadonlyRequestCookiesError extends Error { } } +// We use this to type some APIs but we don't construct instances directly +export type { ResponseCookies } + // The `cookies()` API is a mix of request and response cookies. For `.get()` methods, // we want to return the request cookie if it exists. For mutative methods like `.set()`, // we want to return the response cookie. diff --git a/packages/next/taskfile.js b/packages/next/taskfile.js index c053e81adf6a5..57d462d96f7e0 100644 --- a/packages/next/taskfile.js +++ b/packages/next/taskfile.js @@ -2260,6 +2260,21 @@ export async function precompile(task, opts) { ['browser_polyfills', 'copy_ncced', 'copy_styled_jsx_assets'], opts ) + + const validatorRes = await fetch( + 'https://cdn.ampproject.org/v0/validator_wasm.js' + ) + + if (!validatorRes.ok) { + throw new Error( + `Failed to get the AMP validator, status: ${validatorRes.status}` + ) + } + + await fs.writeFile( + join(__dirname, 'dist/compiled/amphtml-validator/validator_wasm.js'), + require('buffer').Buffer.from(await validatorRes.arrayBuffer()) + ) } // eslint-disable-next-line camelcase diff --git a/packages/next/types/$$compiled.internal.d.ts b/packages/next/types/$$compiled.internal.d.ts index 76832e459fb4e..c5b048b6cecf1 100644 --- a/packages/next/types/$$compiled.internal.d.ts +++ b/packages/next/types/$$compiled.internal.d.ts @@ -27,9 +27,80 @@ declare module 'next/dist/compiled/react-dom/server.edge' declare module 'next/dist/compiled/browserslist' declare module 'react-server-dom-webpack/client' -declare module 'react-server-dom-webpack/server.edge' +declare module 'react-server-dom-webpack/server.edge' { + export function renderToReadableStream( + model: any, + webpackMap: { + readonly [id: string]: { + readonly id: string | number + readonly chunks: readonly string[] + readonly name: string + readonly async?: boolean + } + }, + options?: { + temporaryReferences?: string + environmentName?: string + filterStackFrame?: (url: string, functionName: string) => boolean + onError?: (error: unknown) => void + onPostpone?: (reason: string) => void + signal?: AbortSignal + } + ): ReadableStream + + export function createTemporaryReferenceSet(...args: any[]): any + + type ServerManifest = {} + + export function decodeReply( + body: string | FormData, + webpackMap: ServerManifest, + options?: { + temporaryReferences?: unknown + } + ): Promise + export function decodeAction( + body: FormData, + serverManifest: ServerManifest + ): Promise<() => T> | null + export function decodeFormState( + actionResult: S, + body: FormData, + serverManifest: ServerManifest + ): Promise + + export function registerServerReference( + reference: T, + id: string, + exportName: string | null + ): unknown + + export function createClientModuleProxy(moduleId: string): unknown +} declare module 'react-server-dom-webpack/server.node' -declare module 'react-server-dom-webpack/static.edge' +declare module 'react-server-dom-webpack/static.edge' { + export function prerender( + children: any, + webpackMap: { + readonly [id: string]: { + readonly id: string | number + readonly chunks: readonly string[] + readonly name: string + readonly async?: boolean + } + }, + options?: { + environmentName?: string | (() => string) + filterStackFrame?: (url: string, functionName: string) => boolean + identifierPrefix?: string + signal?: AbortSignal + onError?: (error: unknown) => void + onPostpone?: (reason: string) => void + } + ): Promise<{ + prelude: ReadableStream + }> +} declare module 'react-server-dom-webpack/client.edge' declare module 'VAR_MODULE_GLOBAL_ERROR' @@ -37,6 +108,10 @@ declare module 'VAR_USERLAND' declare module 'VAR_MODULE_DOCUMENT' declare module 'VAR_MODULE_APP' +declare module 'next/dist/server/ReactDOMServerPages' { + export * from 'react-dom/server.edge' +} + declare module 'next/dist/compiled/@napi-rs/triples' { export * from '@napi-rs/triples' } diff --git a/packages/next/types/react-dom.d.ts b/packages/next/types/react-dom.d.ts index 9b811922e4d55..d7151f6d52458 100644 --- a/packages/next/types/react-dom.d.ts +++ b/packages/next/types/react-dom.d.ts @@ -17,7 +17,7 @@ declare module 'react-dom/server.edge' { export type ResumeOptions = { nonce?: string signal?: AbortSignal - onError?: (error: unknown, errorInfo: unknown) => string | undefined + onError?: (error: unknown) => string | undefined onPostpone?: (reason: string) => void unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor } @@ -42,7 +42,7 @@ declare module 'react-dom/server.edge' { bootstrapModules?: Array progressiveChunkSize?: number signal?: AbortSignal - onError?: (error: unknown, errorInfo: unknown) => string | undefined + onError?: (error: unknown) => string | undefined onPostpone?: (reason: string) => void unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor importMap?: { @@ -94,7 +94,7 @@ declare module 'react-dom/static.edge' { bootstrapModules?: Array progressiveChunkSize?: number signal?: AbortSignal - onError?: (error: unknown, errorInfo: unknown) => string | undefined + onError?: (error: unknown) => string | undefined onPostpone?: (reason: string) => void unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor importMap?: { diff --git a/packages/next/webpack.config.js b/packages/next/webpack.config.js index 51e2090a11643..7f4af265bf73c 100644 --- a/packages/next/webpack.config.js +++ b/packages/next/webpack.config.js @@ -13,6 +13,7 @@ const pagesExternals = [ 'react-dom/package.json', 'react-dom/client', 'react-dom/server', + 'react-dom/server.browser', 'react-dom/server.edge', 'react-server-dom-webpack/client', 'react-server-dom-webpack/client.edge', diff --git a/packages/react-refresh-utils/package.json b/packages/react-refresh-utils/package.json index 6ef2cfc46478e..11a15639f4353 100644 --- a/packages/react-refresh-utils/package.json +++ b/packages/react-refresh-utils/package.json @@ -1,6 +1,6 @@ { "name": "@next/react-refresh-utils", - "version": "15.0.0-canary.166", + "version": "15.0.0-canary.171", "description": "An experimental package providing utilities for React Refresh.", "repository": { "url": "vercel/next.js", diff --git a/packages/third-parties/package.json b/packages/third-parties/package.json index 71ceb560578d8..6fc1070d4e37a 100644 --- a/packages/third-parties/package.json +++ b/packages/third-parties/package.json @@ -1,6 +1,6 @@ { "name": "@next/third-parties", - "version": "15.0.0-canary.166", + "version": "15.0.0-canary.171", "repository": { "url": "vercel/next.js", "directory": "packages/third-parties" @@ -26,7 +26,7 @@ "third-party-capital": "1.0.20" }, "devDependencies": { - "next": "15.0.0-canary.166", + "next": "15.0.0-canary.171", "outdent": "0.8.0", "prettier": "2.5.1" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cf4144e34b405..c040dca6b1790 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -134,6 +134,9 @@ importers: '@swc/types': specifier: 0.1.7 version: 0.1.7 + '@taskr/esnext': + specifier: 1.1.0 + version: 1.1.0 '@testing-library/jest-dom': specifier: 6.1.2 version: 6.1.2(@jest/globals@29.7.0)(@types/jest@29.5.5)(jest@29.7.0(@types/node@20.12.3)(babel-plugin-macros@3.1.0)) @@ -786,7 +789,7 @@ importers: packages/eslint-config-next: dependencies: '@next/eslint-plugin-next': - specifier: 15.0.0-canary.166 + specifier: 15.0.0-canary.171 version: link:../eslint-plugin-next '@rushstack/eslint-patch': specifier: ^1.3.3 @@ -847,7 +850,7 @@ importers: packages/next: dependencies: '@next/env': - specifier: 15.0.0-canary.166 + specifier: 15.0.0-canary.171 version: link:../next-env '@swc/counter': specifier: 0.1.3 @@ -857,7 +860,7 @@ importers: version: 0.5.13 babel-plugin-react-compiler: specifier: '*' - version: 0.0.0-experimental-6067d4e-20240924 + version: 0.0.0-experimental-6067d4e-20240925 busboy: specifier: 1.6.0 version: 1.6.0 @@ -975,19 +978,19 @@ importers: specifier: 1.2.0 version: 1.2.0 '@next/font': - specifier: 15.0.0-canary.166 + specifier: 15.0.0-canary.171 version: link:../font '@next/polyfill-module': - specifier: 15.0.0-canary.166 + specifier: 15.0.0-canary.171 version: link:../next-polyfill-module '@next/polyfill-nomodule': - specifier: 15.0.0-canary.166 + specifier: 15.0.0-canary.171 version: link:../next-polyfill-nomodule '@next/react-refresh-utils': - specifier: 15.0.0-canary.166 + specifier: 15.0.0-canary.171 version: link:../react-refresh-utils '@next/swc': - specifier: 15.0.0-canary.166 + specifier: 15.0.0-canary.171 version: link:../next-swc '@opentelemetry/api': specifier: 1.6.0 @@ -1606,7 +1609,7 @@ importers: version: 1.0.20 devDependencies: next: - specifier: 15.0.0-canary.166 + specifier: 15.0.0-canary.171 version: link:../next outdent: specifier: 0.8.0 @@ -5947,8 +5950,8 @@ packages: peerDependencies: '@babel/core': 7.22.5 - babel-plugin-react-compiler@0.0.0-experimental-6067d4e-20240924: - resolution: {integrity: sha512-Xprt5PqHZKqF2H8Di7y+o9j1RTFsNGJ6ntBcRFu8kcChy5sVSVVIKXq+FBezcBhVChzaRrUb+OV/nWZlJH1aJA==} + babel-plugin-react-compiler@0.0.0-experimental-6067d4e-20240925: + resolution: {integrity: sha512-PFMEEvTj2jWIPXh3C0PnDNhfAlQLJjwn/24PrvOzN8KIkQHfPawn95xIj0LnYyz/EnY6hYWNe45iGJoJYSOZ5g==} babel-plugin-react-compiler@0.0.0-experimental-c23de8d-20240515: resolution: {integrity: sha512-0XN2gmpT55QtAz5n7d5g91y1AuO9tRhWBaLgCRyc4ExHrlr7+LfxW+YTb3mOwxngkkiggwM8HyYsaEK9MqhnlQ==} @@ -10796,10 +10799,6 @@ packages: resolution: {integrity: sha512-NbJtWIE2QEVbr9xQHXBY92fxX0Tu8EsS9NBwz7Qn3zoeuvcbP3LzBJw3EUJDpfb9IY8qnZvFSWIepeEFQga28w==} engines: {node: '>=4'} - mri@1.1.4: - resolution: {integrity: sha512-6y7IjGPm8AzlvoUrwAaw1tLnUBudaS3752vcd8JtrpGGQn+rXIe63LFVHm/YMwtqAuh+LJPCFdlLYPWM1nYn6w==} - engines: {node: '>=4'} - mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -14064,8 +14063,8 @@ packages: resolution: {integrity: sha512-ApGvZ6vVvTNdsmt676grvCkUCGwzG9IqXma5Z07xJgiC5L7akUMof5U8G2JTI9Rz/ovtVhJBlY6mNhEvtjzOIg==} engines: {node: '>=6'} - tinydate@1.2.0: - resolution: {integrity: sha512-3GwPk8VhDFnUZ2TrgkhXJs6hcMAIIw4x/xkz+ayK6dGoQmp2nUwKzBXK0WnMsqkh6vfUhpqQicQF3rbshfyJkg==} + tinydate@1.3.0: + resolution: {integrity: sha512-7cR8rLy2QhYHpsBDBVYnnWXm8uRTr38RoZakFSW7Bs7PzfMPNZthuMLkwqZv7MTu8lhQ91cOFYS5a7iFj2oR3w==} engines: {node: '>=4'} title-case@3.0.3: @@ -20684,7 +20683,7 @@ snapshots: - supports-color optional: true - babel-plugin-react-compiler@0.0.0-experimental-6067d4e-20240924: + babel-plugin-react-compiler@0.0.0-experimental-6067d4e-20240925: dependencies: '@babel/generator': 7.2.0 '@babel/types': 7.22.5 @@ -24666,7 +24665,7 @@ snapshots: cli-width: 3.0.0 external-editor: 3.1.0 figures: 3.1.0 - lodash: 4.17.21 + lodash: 4.17.20 mute-stream: 0.0.8 run-async: 2.4.1 rxjs: 6.6.2 @@ -27088,8 +27087,6 @@ snapshots: mri@1.1.0: {} - mri@1.1.4: {} - mri@1.2.0: {} mrmime@2.0.0: {} @@ -30666,8 +30663,8 @@ snapshots: clor: 5.2.0 glob: 7.1.7 mk-dirs: 1.0.0 - mri: 1.1.4 - tinydate: 1.2.0 + mri: 1.2.0 + tinydate: 1.3.0 temp-dir@1.0.0: {} @@ -30798,7 +30795,7 @@ snapshots: tiny-lru@8.0.2: {} - tinydate@1.2.0: {} + tinydate@1.3.0: {} title-case@3.0.3: dependencies: diff --git a/scripts/sync-react.js b/scripts/sync-react.js index 2e4473a08b5bc..339207d028ce3 100644 --- a/scripts/sync-react.js +++ b/scripts/sync-react.js @@ -330,8 +330,9 @@ Or, run this command with no arguments to use the most recently published versio const nextjsPackageJson = JSON.parse( await fsp.readFile(nextjsPackageJsonPath, 'utf-8') ) - nextjsPackageJson.peerDependencies.react = `${newVersionStr}` - nextjsPackageJson.peerDependencies['react-dom'] = `${newVersionStr}` + nextjsPackageJson.peerDependencies.react = `^18.2.0 || ${newVersionStr}` + nextjsPackageJson.peerDependencies['react-dom'] = + `^18.2.0 || ${newVersionStr}` await fsp.writeFile( nextjsPackageJsonPath, JSON.stringify(nextjsPackageJson, null, 2) + diff --git a/test/development/acceptance-app/dynamic-error.test.ts b/test/development/acceptance-app/dynamic-error.test.ts index beccee29e6ca9..65be42a03566d 100644 --- a/test/development/acceptance-app/dynamic-error.test.ts +++ b/test/development/acceptance-app/dynamic-error.test.ts @@ -21,8 +21,8 @@ describe('dynamic = "error" in devmode', () => { import Component from '../../index' - export default function Page() { - cookies() + export default async function Page() { + await cookies() return } diff --git a/test/development/acceptance-app/hydration-error.test.ts b/test/development/acceptance-app/hydration-error.test.ts index 7926221ebb064..62cba0745e591 100644 --- a/test/development/acceptance-app/hydration-error.test.ts +++ b/test/development/acceptance-app/hydration-error.test.ts @@ -875,8 +875,8 @@ describe('Error overlay for hydration errors in App router', () => { - - + +

@@ -895,8 +895,8 @@ describe('Error overlay for hydration errors in App router', () => { - - + +
diff --git a/test/development/acceptance/hydration-error.test.ts b/test/development/acceptance/hydration-error.test.ts index 99baf79bdd171..6046e2ea8e872 100644 --- a/test/development/acceptance/hydration-error.test.ts +++ b/test/development/acceptance/hydration-error.test.ts @@ -5,6 +5,7 @@ import path from 'path' import { outdent } from 'outdent' import { getRedboxTotalErrorCount } from 'next-test-utils' +const isReact18 = parseInt(process.env.NEXT_TEST_REACT_VERSION) === 18 // https://github.com/facebook/react/blob/main/packages/react-dom/src/__tests__/ReactDOMHydrationDiff-test.js used as a reference describe('Error overlay for hydration errors in Pages router', () => { @@ -39,10 +40,13 @@ describe('Error overlay for hydration errors in Pages router', () => { expect(logs).toEqual( expect.arrayContaining([ { - // TODO: Should probably link to https://nextjs.org/docs/messages/react-hydration-error instead. - message: expect.stringContaining( - 'https://react.dev/link/hydration-mismatch' - ), + message: isReact18 + ? // React 18 has no link in the hydration message + expect.stringContaining('Warning: Text content did not match.') + : // TODO: Should probably link to https://nextjs.org/docs/messages/react-hydration-error instead. + expect.stringContaining( + 'https://react.dev/link/hydration-mismatch' + ), source: 'error', }, ]) @@ -70,48 +74,85 @@ describe('Error overlay for hydration errors in Pages router', () => { ) await session.assertHasRedbox() - expect(await getRedboxTotalErrorCount(browser)).toBe(1) + expect(await getRedboxTotalErrorCount(browser)).toBe(isReact18 ? 2 : 1) - expect(await session.getRedboxDescription()).toMatchInlineSnapshot(` - "Hydration failed because the server rendered HTML didn't match the client. As a result this tree will be regenerated on the client. This can happen if a SSR-ed Client Component used - See more info here: https://nextjs.org/docs/messages/react-hydration-error" - `) - - expect(await session.getRedboxDescriptionWarning()).toMatchInlineSnapshot(` - "- A server/client branch \`if (typeof window !== 'undefined')\`. - - Variable input such as \`Date.now()\` or \`Math.random()\` which changes each time it's called. - - Date formatting in a user's locale which doesn't match the server. - - External changing data without sending a snapshot of it along with the HTML. - - Invalid HTML tag nesting. + if (isReact18) { + expect(await session.getRedboxDescription()).toMatchInlineSnapshot( + `"Text content did not match. Server: "server" Client: "client""` + ) + } else { + expect(await session.getRedboxDescription()).toMatchInlineSnapshot(` + "Hydration failed because the server rendered HTML didn't match the client. As a result this tree will be regenerated on the client. This can happen if a SSR-ed Client Component used + See more info here: https://nextjs.org/docs/messages/react-hydration-error" + `) + } - It can also happen if the client has a browser extension installed which messes with the HTML before React loaded." - `) + if (isReact18) { + expect(await session.getRedboxDescriptionWarning()).toMatchInlineSnapshot( + `undefined` + ) + } else { + expect(await session.getRedboxDescriptionWarning()) + .toMatchInlineSnapshot(` + "- A server/client branch \`if (typeof window !== 'undefined')\`. + - Variable input such as \`Date.now()\` or \`Math.random()\` which changes each time it's called. + - Date formatting in a user's locale which doesn't match the server. + - External changing data without sending a snapshot of it along with the HTML. + - Invalid HTML tag nesting. + + It can also happen if the client has a browser extension installed which messes with the HTML before React loaded." + `) + } const pseudoHtml = await session.getRedboxComponentStack() if (isTurbopack) { - expect(pseudoHtml).toMatchInlineSnapshot(` - "... - - - - - - - -
-
- + client - - server" - `) + if (isReact18) { + expect(pseudoHtml).toMatchInlineSnapshot(` + "... + + + + +
+
+ "server" + "client"" + `) + } else { + expect(pseudoHtml).toMatchInlineSnapshot(` + "... + + + + + + + +
+
+ + client + - server" + `) + } } else { - expect(pseudoHtml).toMatchInlineSnapshot(` - "... -
-
- + client - - server" - `) + if (isReact18) { + expect(pseudoHtml).toMatchInlineSnapshot(` + " +
+
+ "server" + "client"" + `) + } else { + expect(pseudoHtml).toMatchInlineSnapshot(` + "... +
+
+ + client + - server" + `) + } } await session.patch( @@ -156,36 +197,63 @@ describe('Error overlay for hydration errors in Pages router', () => { ) await session.assertHasRedbox() - expect(await getRedboxTotalErrorCount(browser)).toBe(1) + expect(await getRedboxTotalErrorCount(browser)).toBe(isReact18 ? 3 : 1) const pseudoHtml = await session.getRedboxComponentStack() if (isTurbopack) { - expect(pseudoHtml).toMatchInlineSnapshot(` - "... - - - - - - - -
- ... - +
" - `) + if (isReact18) { + expect(pseudoHtml).toMatchInlineSnapshot(` + "... + +
+ ^^^^^ +
+ ^^^^^^" + `) + } else { + expect(pseudoHtml).toMatchInlineSnapshot(` + "... + + + + + + + +
+ ... + +
" + `) + } } else { - expect(pseudoHtml).toMatchInlineSnapshot(` - "... -
- ... - +
" - `) + if (isReact18) { + expect(pseudoHtml).toMatchInlineSnapshot(` + " +
+ ^^^^^ +
+ ^^^^^^" + `) + } else { + expect(pseudoHtml).toMatchInlineSnapshot(` + "... +
+ ... + +
" + `) + } } - expect(await session.getRedboxDescription()).toMatchInlineSnapshot(` - "Hydration failed because the server rendered HTML didn't match the client. As a result this tree will be regenerated on the client. This can happen if a SSR-ed Client Component used - See more info here: https://nextjs.org/docs/messages/react-hydration-error" - `) + if (isReact18) { + expect(await session.getRedboxDescription()).toMatchInlineSnapshot( + `"Expected server HTML to contain a matching
in
."` + ) + } else { + expect(await session.getRedboxDescription()).toMatchInlineSnapshot(` + "Hydration failed because the server rendered HTML didn't match the client. As a result this tree will be regenerated on the client. This can happen if a SSR-ed Client Component used + See more info here: https://nextjs.org/docs/messages/react-hydration-error" + `) + } await cleanup() }) @@ -213,36 +281,65 @@ describe('Error overlay for hydration errors in Pages router', () => { ) await session.assertHasRedbox() - expect(await getRedboxTotalErrorCount(browser)).toBe(1) + expect(await getRedboxTotalErrorCount(browser)).toBe(isReact18 ? 3 : 1) const pseudoHtml = await session.getRedboxComponentStack() if (isTurbopack) { - expect(pseudoHtml).toMatchInlineSnapshot(` - "... - - - - - - - -
- + second - -