Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 41 additions & 2 deletions crates/next-api/src/next_server_nft.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,39 @@ pub(crate) async fn require_hook_modules(project_path: FileSystemPath) -> Result
))
}

/// The Pages renderer selected dynamically by `pages/module.compiled` in Turbopack production
/// builds. A Pages API endpoint can load the compiled module through a vendored context when an
/// external dependency imports `next/head`, but neither dynamic edge is visible in its module
/// graph. Include the renderer as an explicit Pages trace entry so that its runtime closure is
/// available when the endpoint initializes.
#[turbo_tasks::function]
pub(crate) async fn pages_renderer_modules(project_path: FileSystemPath) -> Result<Vc<Modules>> {
let asset_context = Vc::upcast(externals_tracing_module_context(
get_tracing_compile_time_info(),
false,
));
let next_resolve_origin = Vc::upcast(PlainResolveOrigin::new(
asset_context,
get_next_package(project_path).await?.join("_")?,
));

Ok(Vc::cell(
cjs_resolve(
next_resolve_origin,
Request::parse_string(
"next/dist/compiled/next-server/pages-turbo.runtime.prod.js".into(),
),
CommonJsReferenceSubType::Undefined,
None,
ResolveErrorMode::Error,
)
.await?
.primary_modules()
.await?
.to_vec(),
))
}

#[turbo_tasks::task_input]
#[derive(PartialEq, Eq, TraceRawVcs, Debug, Clone, Hash, Encode, Decode)]
enum ServerNftType {
Expand All @@ -74,18 +107,24 @@ enum ServerNftType {

#[turbo_tasks::function]
pub async fn next_server_nft_assets(project: Vc<Project>) -> Result<Vc<OutputAssets>> {
if *project.next_config().is_using_adapter().await? {
let is_standalone = *project.next_config().is_standalone().await?;

if *project.next_config().is_using_adapter().await? && !is_standalone {
// When using an adapter, `next-server.js.nft.json` / `next-minimal-server.js.nft.json` are
// not needed: they exist for `output: 'standalone'` (see `copyTracedFiles`), while an
// adapter assembles the deployment from the per-endpoint NFTs in build-complete.ts. What
// those two files trace on top of the endpoints - the `styled-jsx` modules the require hook
// needs at runtime - is part of every endpoint's trace via
// `Project::additional_traced_modules`, so nothing is lost here.
//
// The exception is `output: 'standalone'` configured alongside an adapter:
// `copyTracedFiles` reads `next-server.js.nft.json` unconditionally whenever standalone
// output is requested (adapter or not), so suppressing the pair crashes the build
// (see #96646).
return Ok(Vc::cell(vec![]));
}

let has_next_support = *project.ci_has_next_support().await?;
let is_standalone = *project.next_config().is_standalone().await?;

let minimal = ResolvedVc::upcast(
ServerNftJsonAsset::new(project, ServerNftType::Minimal)
Expand Down
13 changes: 8 additions & 5 deletions crates/next-api/src/project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ use crate::{
entrypoints::Entrypoints,
instrumentation::InstrumentationEndpoint,
middleware::MiddlewareEndpoint,
next_server_nft::require_hook_modules,
next_server_nft::{pages_renderer_modules, require_hook_modules},
pages::PagesProject,
route::{
Endpoint, EndpointGroup, EndpointGroupEntry, EndpointGroupKey, EndpointGroups, Endpoints,
Expand Down Expand Up @@ -2854,22 +2854,25 @@ impl Project {
))
}

/// [`Project::additional_traced_modules`] plus the modules
/// `next/dist/server/require-hook` resolves at runtime. Only the Pages Router needs the
/// latter, so this is the traced module list for pages endpoints, while other endpoints use
/// [`Project::additional_traced_modules`].
/// [`Project::additional_traced_modules`] plus the modules the Pages Router resolves only at
/// runtime: the targets of `next/dist/server/require-hook` and the production Pages renderer.
/// Other endpoints use [`Project::additional_traced_modules`].
#[turbo_tasks::function]
pub async fn pages_traced_modules(self: Vc<Self>) -> Result<Vc<Modules>> {
let hook_modules = require_hook_modules(self.project_path().owned().await?)
.owned()
.await?;
let renderer_modules = pages_renderer_modules(self.project_path().owned().await?)
.owned()
.await?;

Ok(Vc::cell(
self.additional_traced_modules()
.owned()
.await?
.into_iter()
.chain(hook_modules)
.chain(renderer_modules)
.collect(),
))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,24 @@ fn join_atoms(atoms: &[Atom]) -> String {
.join(",")
}

/// Returns whether `filepath` is a file inside `app_dir`.
///
/// The App Router file conventions are matched by filename. The same filenames
/// are also valid Pages Router routes, where the conventions do not apply.
/// `pages/sitemap.js` is an ordinary page, not a sitemap. Gate App Router
/// checks on this function to keep them out of the Pages Router.
fn is_in_app_dir(app_dir: &Option<PathBuf>, filepath: &str) -> bool {
let Some(app_dir) = app_dir.as_ref().and_then(|app_dir| app_dir.to_str()) else {
return false;
};

// The rest of the path must start with a separator. A plain prefix match
// would also accept a sibling directory such as `apparel` for `app`.
filepath
.strip_prefix(app_dir.trim_end_matches(['/', '\\']))
.is_some_and(|rest| rest.starts_with(['/', '\\']))
}

/// Consolidated place to parse, generate error messages for the RSC parsing
/// errors.
fn report_error(app_dir: &Option<PathBuf>, filepath: &str, error_kind: RSCErrorKind) {
Expand Down Expand Up @@ -315,18 +333,7 @@ fn report_error(app_dir: &Option<PathBuf>, filepath: &str, error_kind: RSCErrorK
(msg, vec![span])
}
RSCErrorKind::NextRscErrClientImport((source, span)) => {
let is_app_dir = app_dir
.as_ref()
.map(|app_dir| {
if let Some(app_dir) = app_dir.as_os_str().to_str() {
filepath.starts_with(app_dir)
} else {
false
}
})
.unwrap_or_default();

let msg = if !is_app_dir {
let msg = if !is_in_app_dir(app_dir, filepath) {
format!("You're importing a module that depends on \"{source}\". This API is only available in Server Components in the App Router, but you are using it in the Pages Router.\nLearn more: https://nextjs.org/docs/app/building-your-application/rendering/server-components\n\n")
} else {
format!("You're importing a module that depends on \"{source}\" into a React Client Component module. This API is only available in Server Components but one of its parents is marked with \"use client\", so this module is also a Client Component.\nLearn more: https://nextjs.org/docs/app/building-your-application/rendering\n\n")
Expand Down Expand Up @@ -842,11 +849,7 @@ impl ReactServerComponentValidator {

let is_error_file = re.is_match(&self.filepath);

if is_error_file
&& let Some(app_dir) = &self.app_dir
&& let Some(app_dir) = app_dir.to_str()
&& self.filepath.starts_with(app_dir)
{
if is_error_file && is_in_app_dir(&self.app_dir, &self.filepath) {
let span = if let Some(first_item) = module.body.first() {
first_item.span()
} else {
Expand Down Expand Up @@ -910,7 +913,8 @@ impl ReactServerComponentValidator {
r"[\\/](page|layout|route|icon\d?|apple-icon\d?|opengraph-image\d?|twitter-image\d?|sitemap|robots|manifest)\.{ext_pattern}$",
))
.unwrap();
let is_app_entry = re.is_match(&self.filepath);
let is_app_entry =
re.is_match(&self.filepath) && is_in_app_dir(&self.app_dir, &self.filepath);

if is_app_entry {
let mut possibly_invalid_exports: FxIndexMap<Atom, (InvalidExportKind, Span)> =
Expand Down
5 changes: 5 additions & 0 deletions crates/next-custom-transforms/tests/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,13 +92,18 @@ fn next_ssg_errors(input: PathBuf) {
#[fixture("tests/errors/react-server-components/**/input.js")]
#[fixture("tests/errors/react-server-components/**/page.js")]
#[fixture("tests/errors/react-server-components/**/route.js")]
#[fixture("tests/errors/react-server-components/**/sitemap.js")]
fn react_server_components_errors(input: PathBuf) {
use next_custom_transforms::transforms::react_server_components::{Config, Options};
let is_react_server_layer = input.iter().any(|s| s.to_str() == Some("server-graph"));
let cache_components_enabled = input.iter().any(|s| s.to_str() == Some("cache-components"));
let use_cache_enabled = input.iter().any(|s| s.to_str() == Some("use-cache"));
let taint_enabled = input.iter().any(|s| s.to_str() == Some("taint-enabled"));

// A path segment named `app-dir` marks the fixture as an App Router file.
// Everything up to and including that segment becomes `appDir`. A fixture
// without the segment compiles as a Pages Router file, so the checks that
// only apply inside `appDir` do not run for it.
let app_dir = input
.iter()
.position(|s| s.to_str() == Some("app-dir"))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export function getStaticProps() {}
export default function() {
return null;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export function getStaticProps() {}

export default function () {
return null
}
42 changes: 18 additions & 24 deletions docs/01-app/02-guides/single-page-applications.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,7 @@ export function DeletePost({ id }) {

For list-like state where each change should appear instantly, you can combine `useActionState` with `useOptimistic`. The example below is a to-do list: a pure reducer defines how each action changes the list, so the client and server share one copy of that logic:

```ts filename="app/reducer.ts" switcher
```ts filename="app/todos-reducer.ts" switcher
export type Todo = { id: string; text: string; done: boolean }

export type TodoAction =
Expand All @@ -335,7 +335,7 @@ export type TodoAction =
| { type: 'edit'; id: string; text: string }
| { type: 'delete'; id: string }

export function applyAction(todos: Todo[], action: TodoAction): Todo[] {
export function todosReducer(todos: Todo[], action: TodoAction): Todo[] {
switch (action.type) {
case 'add':
return [...todos, { id: action.id, text: action.text, done: false }]
Expand All @@ -355,8 +355,8 @@ export function applyAction(todos: Todo[], action: TodoAction): Todo[] {
}
```

```js filename="app/reducer.js" switcher
export function applyAction(todos, action) {
```js filename="app/todos-reducer.js" switcher
export function todosReducer(todos, action) {
switch (action.type) {
case 'add':
return [...todos, { id: action.id, text: action.text, done: false }]
Expand All @@ -382,13 +382,13 @@ The Server Action applies the reducer, persists the result, and returns the next
'use server'

import { db } from './db'
import { applyAction, type Todo, type TodoAction } from './reducer'
import { todosReducer, type Todo, type TodoAction } from './todos-reducer'

export async function todosReducer(
export async function saveTodos(
todos: Todo[],
action: TodoAction
): Promise<Todo[]> {
const next = applyAction(todos, action)
const next = todosReducer(todos, action)
await db.saveTodos(next)
return next
}
Expand All @@ -398,10 +398,10 @@ export async function todosReducer(
'use server'

import { db } from './db'
import { applyAction } from './reducer'
import { todosReducer } from './todos-reducer'

export async function todosReducer(todos, action) {
const next = applyAction(todos, action)
export async function saveTodos(todos, action) {
const next = todosReducer(todos, action)
await db.saveTodos(next)
return next
}
Expand All @@ -413,15 +413,12 @@ The client passes the same reducer to `useOptimistic`, so the optimistic update
'use client'

import { useActionState, useOptimistic, startTransition } from 'react'
import { todosReducer } from './actions'
import { applyAction, type Todo, type TodoAction } from './reducer'
import { saveTodos } from './actions'
import { todosReducer, type Todo, type TodoAction } from './todos-reducer'

export function TodoList({ initialTodos }: { initialTodos: Todo[] }) {
const [todos, dispatch, isPending] = useActionState(
todosReducer,
initialTodos
)
const [optimisticTodos, addOptimistic] = useOptimistic(todos, applyAction)
const [todos, dispatch, isPending] = useActionState(saveTodos, initialTodos)
const [optimisticTodos, addOptimistic] = useOptimistic(todos, todosReducer)

function runAction(action: TodoAction) {
startTransition(() => {
Expand Down Expand Up @@ -473,15 +470,12 @@ export function TodoList({ initialTodos }: { initialTodos: Todo[] }) {
'use client'

import { useActionState, useOptimistic, startTransition } from 'react'
import { todosReducer } from './actions'
import { applyAction } from './reducer'
import { saveTodos } from './actions'
import { todosReducer } from './todos-reducer'

export function TodoList({ initialTodos }) {
const [todos, dispatch, isPending] = useActionState(
todosReducer,
initialTodos
)
const [optimisticTodos, addOptimistic] = useOptimistic(todos, applyAction)
const [todos, dispatch, isPending] = useActionState(saveTodos, initialTodos)
const [optimisticTodos, addOptimistic] = useOptimistic(todos, todosReducer)

function runAction(action) {
startTransition(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,6 @@ export default nextConfig

Then add `'use cache: private'` to your function along with a `cacheLife` configuration.

> **Good to know**: This directive is not available in Route Handlers.

### Basic example

In this example, we demonstrate that you can access cookies within a `'use cache: private'` scope:
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@
"@vercel/blob": "2.3.2",
"@vercel/devlow-bench": "workspace:*",
"@vercel/kv": "3.0.0",
"@vercel/og": "0.11.1",
"@vercel/og": "1.0.1",
"abort-controller": "3.0.0",
"alex": "9.1.0",
"async-sema": "3.0.1",
Expand Down Expand Up @@ -277,7 +277,7 @@
"request-promise-core": "1.1.2",
"resolve-from": "5.0.0",
"sass": "1.54.0",
"satori": "0.25.0",
"satori": "0.29.0",
"scheduler-builtin": "npm:scheduler@0.28.0-canary-22e4f993-20260811",
"scheduler-experimental-builtin": "npm:scheduler@0.0.0-experimental-22e4f993-20260811",
"seedrandom": "3.0.5",
Expand Down
31 changes: 31 additions & 0 deletions packages/next/src/build/adapter/build-complete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2437,6 +2437,37 @@ async function getSharedNodeAssets({
sharedTraceIgnores
)

// The require hook redirects shared-runtime imports from external packages
// to the Pages vendored contexts. Those contexts load module.compiled, whose
// runtime dependency is selected dynamically. Turbopack includes this via
// `Project::pages_traced_modules`; trace the Webpack runtime here.
const pagesRuntimePath = require.resolve(
'next/dist/compiled/next-server/pages.runtime.prod.js'
)
const pagesRuntimeTrace = await nodeFileTrace([pagesRuntimePath], {
base: outputFileTracingRoot,
ignore: sharedIgnoreFn,
moduleSyncCatchall: true,
})
pagesRuntimeTrace.esmFileList.forEach((item) =>
pagesRuntimeTrace.fileList.add(item)
)

for (const tracingRootRelativeFilePath of pagesRuntimeTrace.fileList) {
const absoluteFilePath = path.join(
outputFileTracingRoot,
tracingRootRelativeFilePath
)
await pushAsset(
pagesSharedNodeAssets,
pagesSharedNodeAssetsHashes,
path.relative(repoRoot, absoluteFilePath),
absoluteFilePath,
bundler,
salt
)
}

// These are modules that are necessary for bootstrapping node env
const necessaryNodeDependencies = [
require.resolve('next/dist/server/node-environment'),
Expand Down
Loading
Loading